diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 829053201d..748bf12248 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -56,6 +56,7 @@ export interface LobbyConfig { serverConfig: ServerConfig; cosmetics: PlayerCosmeticRefs; playerName: string; + playerClanTag: string | null; gameID: GameID; turnstileToken: string | null; // GameStartInfo only exists when playing a singleplayer game. @@ -228,6 +229,7 @@ async function createClientGame( gameMap, clientID, lobbyConfig.playerName, + lobbyConfig.playerClanTag, lobbyConfig.gameStartInfo.gameID, lobbyConfig.gameStartInfo.players, ); @@ -301,6 +303,7 @@ export class ClientGameRunner { { persistentID: getPersistentID(), username: this.lobby.playerName, + clanTag: this.lobby.playerClanTag ?? undefined, clientID: this.clientID, stats: update.allPlayersStats[this.clientID], }, diff --git a/src/client/GameInfoModal.ts b/src/client/GameInfoModal.ts index 787f5820d4..b405ea711b 100644 --- a/src/client/GameInfoModal.ts +++ b/src/client/GameInfoModal.ts @@ -4,7 +4,6 @@ import { GameEndInfo } from "../core/Schemas"; import { GameMapType, hasUnusualThumbnailSize } from "../core/game/Game"; import { fetchGameById } from "./Api"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; -import { UsernameInput } from "./UsernameInput"; import { renderDuration, translateText } from "./Utils"; import { PlayerInfo, @@ -28,7 +27,7 @@ export class GameInfoModal extends LitElement { @property({ type: String }) gameId: string | null = null; @property({ type: String }) rankType = RankType.Lifetime; - @state() private username: string | null = null; + @state() private currentClientID: string | null = null; @state() private isLoadingGame: boolean = true; private ranking: Ranking | null = null; @@ -155,7 +154,7 @@ export class GameInfoModal extends LitElement { .score=${this.ranking?.score(player, this.rankType) ?? 0} .rankType=${this.rankType} .bestScore=${bestScore} - .currentPlayer=${this.username === player.rawUsername} + .currentPlayer=${this.currentClientID === player.id} > `, )} @@ -186,26 +185,16 @@ export class GameInfoModal extends LitElement { } } - public loadUserName() { - const usernameInput = document.querySelector( - "username-input", - ) as UsernameInput; - if (usernameInput) { - this.username = usernameInput.getCurrentUsername(); - } - } - - public async loadGame(gameId: string) { + public async loadGame(gameId: string, currentClientID: string | null = null) { try { this.isLoadingGame = true; - this.loadUserName(); + this.currentClientID = currentClientID; const session = await fetchGameById(gameId); if (!session) return; this.gameInfo = session.info; this.ranking = new Ranking(session); this.updateRanking(); - this.isLoadingGame = false; await this.loadMapImage(session.info.config.gameMap); } catch (err) { console.error("Failed to load game:", err); diff --git a/src/client/GameModeSelector.ts b/src/client/GameModeSelector.ts index 682db741ab..42c336b7e5 100644 --- a/src/client/GameModeSelector.ts +++ b/src/client/GameModeSelector.ts @@ -16,6 +16,7 @@ import { PublicLobbySocket } from "./LobbySocket"; import { JoinLobbyEvent } from "./Main"; import { SinglePlayerModal } from "./SinglePlayerModal"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; +import { UsernameInput } from "./UsernameInput"; import { getMapName, renderDuration, translateText } from "./Utils"; const CARD_BG = "bg-[color-mix(in_oklab,var(--frenchBlue)_70%,black)]"; @@ -38,20 +39,10 @@ export class GameModeSelector extends LitElement { * Returns true if valid, false otherwise. */ private validateUsername(): boolean { - const usernameInput = document.querySelector("username-input") as any; - if (usernameInput?.isValid?.() === false) { - window.dispatchEvent( - new CustomEvent("show-message", { - detail: { - message: usernameInput.validationError, - color: "red", - duration: 3000, - }, - }), - ); - return false; - } - return true; + const usernameInput = document.querySelector( + "username-input", + ) as UsernameInput | null; + return usernameInput ? usernameInput.validateOrShowError() : true; } connectedCallback() { diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 978fa12ac5..5af8f30309 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -19,7 +19,7 @@ import { TeamCountConfig, isValidGameID, } from "../core/Schemas"; -import { generateID } from "../core/Util"; +import { clientInfoListsEqual, generateID } from "../core/Util"; import { getPlayToken } from "./Auth"; import "./components/baseComponents/Modal"; import { BaseModal } from "./components/BaseModal"; @@ -90,7 +90,7 @@ export class HostLobbyModal extends BaseModal { return; } this.lobbyCreatorClientID = lobby.lobbyCreatorClientID ?? ""; - if (lobby.clients) { + if (lobby.clients && !clientInfoListsEqual(this.clients, lobby.clients)) { this.clients = lobby.clients; } }; diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts index 22e674fdab..d44cef5ec5 100644 --- a/src/client/JoinLobbyModal.ts +++ b/src/client/JoinLobbyModal.ts @@ -18,6 +18,7 @@ import { LobbyInfoEvent, PublicGameInfo, } from "../core/Schemas"; +import { clientInfoListsEqual, gameConfigsEqual } from "../core/Util"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { GameMapSize, @@ -61,7 +62,9 @@ export class JoinLobbyModal extends BaseModal { private readonly handleLobbyInfo = (event: LobbyInfoEvent) => { const lobby = event.lobby; - this.currentClientID = event.myClientID; + if (this.currentClientID !== event.myClientID) { + this.currentClientID = event.myClientID; + } // Only stop showing spinner when we have player info if (this.isConnecting && lobby.clients) { this.isConnecting = false; @@ -514,21 +517,34 @@ export class JoinLobbyModal extends BaseModal { // --- Lobby event handling --- private updateFromLobby(lobby: GameInfo | PublicGameInfo) { - this.players = "clients" in lobby ? (lobby.clients ?? []) : []; - this.lobbyStartAt = lobby.startsAt ?? null; + const nextPlayers = "clients" in lobby ? (lobby.clients ?? []) : []; + if (!clientInfoListsEqual(this.players, nextPlayers)) { + this.players = nextPlayers; + } + + const nextLobbyStartAt = lobby.startsAt ?? null; + if (this.lobbyStartAt !== nextLobbyStartAt) { + this.lobbyStartAt = nextLobbyStartAt; + } this.syncCountdownTimer(); if (lobby.gameConfig) { const mapChanged = this.gameConfig?.gameMap !== lobby.gameConfig.gameMap; - this.gameConfig = lobby.gameConfig; + // Avoid unnecessary rerenders from per-second lobby_info broadcasts. + if (!gameConfigsEqual(this.gameConfig, lobby.gameConfig)) { + this.gameConfig = lobby.gameConfig; + } if (mapChanged) { this.loadNationCount(); } } - this.lobbyCreatorClientID = + const nextLobbyCreatorClientID = "lobbyCreatorClientID" in lobby ? (lobby.lobbyCreatorClientID ?? null) : null; + if (this.lobbyCreatorClientID !== nextLobbyCreatorClientID) { + this.lobbyCreatorClientID = nextLobbyCreatorClientID; + } } private startLobbyUpdates() { diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 90712b868b..2e244345bf 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -15,7 +15,6 @@ import { import { createPartialGameRecord, decompressGameRecord, - getClanTag, replacer, } from "../core/Util"; import { getPersistentID } from "./Auth"; @@ -240,10 +239,10 @@ export class LocalServer { { persistentID: getPersistentID(), username: this.lobbyConfig.playerName, + clanTag: this.lobbyConfig.playerClanTag ?? undefined, clientID: this.clientID!, stats: this.allPlayersStats[this.clientID!], cosmetics: this.lobbyConfig.gameStartInfo?.players[0].cosmetics, - clanTag: getClanTag(this.lobbyConfig.playerName) ?? undefined, }, ]; if (this.lobbyConfig.gameStartInfo === undefined) { diff --git a/src/client/Main.ts b/src/client/Main.ts index 35dfa6b5c2..dfd2fdce78 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -737,6 +737,10 @@ class Client { private async handleJoinLobby(event: CustomEvent) { const lobby = event.detail; + if (this.usernameInput && !this.usernameInput.validateOrShowError()) { + return; + } + console.log(`joining lobby ${lobby.gameID}`); if (this.gameStop !== null) { console.log("joining lobby, stopping existing game"); @@ -758,8 +762,8 @@ class Client { serverConfig: config, cosmetics: await getPlayerCosmeticsRefs(), turnstileToken: await this.getTurnstileToken(lobby), - playerName: - this.usernameInput?.getCurrentUsername() ?? genAnonUsername(), + playerName: this.usernameInput?.getUsername() ?? genAnonUsername(), + playerClanTag: this.usernameInput?.getClanTag() ?? null, gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info, gameRecord: lobby.gameRecord, }, diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 0b36f13a0a..872446c50c 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -633,7 +633,8 @@ export class SinglePlayerModal extends BaseModal { players: [ { clientID, - username: usernameInput.getCurrentUsername(), + username: usernameInput.getUsername(), + clanTag: usernameInput.getClanTag() ?? undefined, cosmetics: await getPlayerCosmetics(), }, ], diff --git a/src/client/Transport.ts b/src/client/Transport.ts index d86f0fe828..cb743cc2ae 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -399,6 +399,7 @@ export class Transport { gameID: this.lobbyConfig.gameID, // Note: clientID is not sent - server assigns it based on persistentID username: this.lobbyConfig.playerName, + clanTag: this.lobbyConfig.playerClanTag ?? undefined, cosmetics: this.lobbyConfig.cosmetics, turnstileToken: this.lobbyConfig.turnstileToken, token: await getPlayToken(), diff --git a/src/client/UsernameInput.ts b/src/client/UsernameInput.ts index eb2d7385d7..a5ca57584d 100644 --- a/src/client/UsernameInput.ts +++ b/src/client/UsernameInput.ts @@ -2,15 +2,19 @@ import { LitElement, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { v4 as uuidv4 } from "uuid"; import { translateText } from "../client/Utils"; -import { getClanTagOriginalCase, sanitizeClanTag } from "../core/Util"; +import { sanitizeClanTag } from "../core/Util"; import { + MAX_CLAN_TAG_LENGTH, MAX_USERNAME_LENGTH, + MIN_CLAN_TAG_LENGTH, MIN_USERNAME_LENGTH, + validateClanTag, validateUsername, } from "../core/validations/username"; import { crazyGamesSDK } from "./CrazyGamesSDK"; const usernameKey: string = "username"; +const clanTagKey: string = "clanTag"; @customElement("username-input") export class UsernameInput extends LitElement { @@ -27,46 +31,44 @@ export class UsernameInput extends LitElement { return this; } - public getCurrentUsername(): string { - return this.constructFullUsername(); + public getUsername(): string { + return this.baseUsername.trim(); } - private constructFullUsername(): string { - if (this.clanTag.length >= 2) { - return `[${this.clanTag}] ${this.baseUsername}`; - } - return this.baseUsername; + public getClanTag(): string | null { + return this.clanTag.length >= MIN_CLAN_TAG_LENGTH && + this.clanTag.length <= MAX_CLAN_TAG_LENGTH + ? this.clanTag + : null; } connectedCallback() { super.connectedCallback(); - const stored = this.getUsername(); - this.parseAndSetUsername(stored); + this.loadStoredUsername(); crazyGamesSDK.getUsername().then((username) => { if (username) { - this.parseAndSetUsername(username ?? genAnonUsername()); - this.requestUpdate(); + this.baseUsername = username; + this.validateAndStore(); } }); crazyGamesSDK.addAuthListener((user) => { if (user) { - this.parseAndSetUsername(user?.username); + this.baseUsername = user.username; + this.validateAndStore(); } - this.requestUpdate(); }); } - private parseAndSetUsername(fullUsername: string) { - const tag = getClanTagOriginalCase(fullUsername); - if (tag) { - this.clanTag = tag.toUpperCase(); - this.baseUsername = fullUsername.replace(`[${tag}]`, "").trim(); + private loadStoredUsername() { + const storedUsername = localStorage.getItem(usernameKey); + if (storedUsername) { + this.clanTag = localStorage.getItem(clanTagKey) ?? ""; + this.baseUsername = storedUsername; + this.validateAndStore(); } else { - this.clanTag = ""; - this.baseUsername = fullUsername; + this.baseUsername = genAnonUsername(); + this.validateAndStore(); } - - this.validateAndStore(); } render() { @@ -77,7 +79,8 @@ export class UsernameInput extends LitElement { .value=${this.clanTag} @input=${this.handleClanTagChange} placeholder="${translateText("username.tag")}" - maxlength="5" + minlength="${MIN_CLAN_TAG_LENGTH}" + maxlength="${MAX_CLAN_TAG_LENGTH}" class="w-[6rem] text-xl font-bold text-center uppercase shrink-0 bg-transparent text-white placeholder-white/70 focus:placeholder-transparent border-0 border-b border-white/40 focus:outline-none focus:border-white/60" /> @@ -147,58 +151,53 @@ export class UsernameInput extends LitElement { } private validateAndStore() { - // Prevent empty username even if clan tag is present - const trimmedBase = this.baseUsername.trim(); - if (!trimmedBase || trimmedBase.length < MIN_USERNAME_LENGTH) { - this._isValid = false; - this.validationError = translateText("username.too_short", { - min: MIN_USERNAME_LENGTH, - }); - return; - } + const trimmedBase = this.getUsername(); - // Validate clan tag if present - if (this.clanTag.length > 0 && this.clanTag.length < 2) { + const clanTagResult = validateClanTag(this.clanTag); + if (!clanTagResult.isValid) { this._isValid = false; - this.validationError = translateText("username.tag_too_short"); + this.validationError = clanTagResult.error ?? ""; return; } - const full = this.constructFullUsername(); - const trimmedFull = full.trim(); - - const result = validateUsername(trimmedFull); + const result = validateUsername(trimmedBase); this._isValid = result.isValid; if (result.isValid) { - this.storeUsername(trimmedFull); + localStorage.setItem(usernameKey, trimmedBase); + localStorage.setItem(clanTagKey, this.getClanTag() ?? ""); this.validationError = ""; } else { this.validationError = result.error ?? ""; } } - private getUsername(): string { - const storedUsername = localStorage.getItem(usernameKey); - if (storedUsername) { - return storedUsername; - } - return this.generateNewUsername(); - } - - private storeUsername(username: string) { - if (username) { - localStorage.setItem(usernameKey, username); - } + public isValid(): boolean { + return this._isValid; } - private generateNewUsername(): string { - const newUsername = genAnonUsername(); - this.storeUsername(newUsername); - return newUsername; + public showValidationFeedback(): void { + const message = + this.validationError || translateText("username.invalid_chars"); + window.dispatchEvent( + new CustomEvent("show-message", { + detail: { + message, + color: "red", + duration: 2500, + }, + }), + ); + document + .getElementById("username-validation-error") + ?.classList.remove("hidden"); } - public isValid(): boolean { - return this._isValid; + public validateOrShowError(): boolean { + if (this.isValid()) { + return true; + } + this.showValidationFeedback(); + return false; } } diff --git a/src/client/components/LobbyPlayerView.ts b/src/client/components/LobbyPlayerView.ts index 6429ffef77..6c9ac0e136 100644 --- a/src/client/components/LobbyPlayerView.ts +++ b/src/client/components/LobbyPlayerView.ts @@ -17,7 +17,11 @@ import { getCompactMapNationCount } from "../../core/game/NationCreation"; import { assignTeamsLobbyPreview } from "../../core/game/TeamAssignment"; import { UserSettings } from "../../core/game/UserSettings"; import { ClientInfo, TeamCountConfig } from "../../core/Schemas"; -import { createRandomName } from "../../core/Util"; +import { + clientInfoListsEqual, + createRandomName, + formatPlayerDisplayName, +} from "../../core/Util"; import { translateText } from "../Utils"; export interface TeamPreviewData { @@ -28,7 +32,12 @@ export interface TeamPreviewData { @customElement("lobby-player-view") export class LobbyTeamView extends LitElement { @property({ type: String }) gameMode: GameMode = GameMode.FFA; - @property({ type: Array }) clients: ClientInfo[] = []; + @property({ + type: Array, + hasChanged: (value: ClientInfo[], oldValue: ClientInfo[] | undefined) => + !clientInfoListsEqual(value, oldValue), + }) + clients: ClientInfo[] = []; @state() private teamPreview: TeamPreviewData[] = []; @state() private teamMaxSize: number = 0; @property({ type: String }) lobbyCreatorClientID: string = ""; @@ -113,7 +122,7 @@ export class LobbyTeamView extends LitElement { this.clients, (c) => c.clientID ?? c.username, (client) => { - const displayName = this.displayUsername(client); + const displayName = this.getClientDisplayName(client); return html`
@@ -158,7 +167,7 @@ export class LobbyTeamView extends LitElement { this.clients, (c) => c.clientID ?? c.username, (client) => { - const displayName = this.displayUsername(client); + const displayName = this.getClientDisplayName(client); return html` ${displayName} ${client.clientID === this.lobbyCreatorClientID @@ -216,7 +225,7 @@ export class LobbyTeamView extends LitElement { preview.players, (p) => p.clientID ?? p.username, (p) => { - const displayName = this.displayUsername(p); + const displayName = this.getClientDisplayName(p); return html`
@@ -308,7 +317,14 @@ export class LobbyTeamView extends LitElement { const players = this.clients.map( (c) => - new PlayerInfo(c.username, PlayerType.Human, c.clientID, c.clientID), + new PlayerInfo( + c.username, + PlayerType.Human, + c.clientID, + c.clientID, + false, + c.clanTag, + ), ); const assignment = assignTeamsLobbyPreview( players, @@ -364,17 +380,17 @@ export class LobbyTeamView extends LitElement { return getCompactMapNationCount(this.nationCount, this.isCompactMap); } - private displayUsername(client: ClientInfo): string { + private getClientDisplayName(client: ClientInfo): string { + const full = formatPlayerDisplayName(client.username, client.clanTag); if (!this.userSettings.anonymousNames()) { - return client.username; + return full; } - if (this.currentClientID && client.clientID === this.currentClientID) { - return client.username; + return full; } - - return ( - createRandomName(client.username, PlayerType.Human) ?? client.username - ); + // Keep clan tag visible while anonymizing only the username. + const anonymizedUsername = + createRandomName(client.username, PlayerType.Human) ?? client.username; + return formatPlayerDisplayName(anonymizedUsername, client.clanTag); } } diff --git a/src/client/components/baseComponents/ranking/GameInfoRanking.ts b/src/client/components/baseComponents/ranking/GameInfoRanking.ts index dff65db0fb..4ac999821f 100644 --- a/src/client/components/baseComponents/ranking/GameInfoRanking.ts +++ b/src/client/components/baseComponents/ranking/GameInfoRanking.ts @@ -26,9 +26,8 @@ export enum RankType { export interface PlayerInfo { id: string; - rawUsername: string; username: string; - tag?: string; + clanTag?: string; killedAt?: number; gold: bigint[]; conquests: bigint[]; @@ -77,18 +76,12 @@ export class Ranking { for (const player of session.info.players) { if (player === undefined || !hasPlayed(player)) continue; const stats = player.stats!; - const match = player.username.match(/^\[(.*?)\]\s*(.*)$/); - let username = player.username; - if (player.clanTag && match) { - username = match[2]; - } const gold = (stats.gold ?? []).map((v) => BigInt(v ?? 0)); const conquests = (stats.conquests ?? []).map((v) => BigInt(v ?? 0)); players[player.clientID] = { id: player.clientID, - rawUsername: player.username, - username, - tag: player.clanTag, + username: player.username, + clanTag: player.clanTag, conquests, flag: player.cosmetics?.flag ?? undefined, killedAt: stats.killedAt !== null ? Number(stats.killedAt) : undefined, diff --git a/src/client/components/baseComponents/ranking/PlayerRow.ts b/src/client/components/baseComponents/ranking/PlayerRow.ts index ed3cfc6ba1..612569192a 100644 --- a/src/client/components/baseComponents/ranking/PlayerRow.ts +++ b/src/client/components/baseComponents/ranking/PlayerRow.ts @@ -220,7 +220,7 @@ export class PlayerRow extends LitElement { private renderPlayerName() { return html`
- ${this.player.tag ? this.renderTag(this.player.tag) : ""} + ${this.player.clanTag ? this.renderTag(this.player.clanTag) : ""}
diff --git a/src/client/components/baseComponents/stats/GameList.ts b/src/client/components/baseComponents/stats/GameList.ts index 111ef013bd..06ee522703 100644 --- a/src/client/components/baseComponents/stats/GameList.ts +++ b/src/client/components/baseComponents/stats/GameList.ts @@ -20,7 +20,7 @@ export class GameList extends LitElement { this.expandedGameId = this.expandedGameId === gameId ? null : gameId; } - private showRanking(gameId: string) { + private showRanking(game: PlayerGame) { const gameInfoModal = document.querySelector( "game-info-modal", ) as GameInfoModal; @@ -28,7 +28,7 @@ export class GameList extends LitElement { if (!gameInfoModal) { console.warn("Game info modal element not found"); } else { - gameInfoModal.loadGame(gameId); + gameInfoModal.loadGame(game.gameId, game.clientId ?? null); gameInfoModal.open(); } } @@ -93,7 +93,7 @@ export class GameList extends LitElement { diff --git a/src/client/components/leaderboard/LeaderboardPlayerList.ts b/src/client/components/leaderboard/LeaderboardPlayerList.ts index fcee8f73c7..a182928f32 100644 --- a/src/client/components/leaderboard/LeaderboardPlayerList.ts +++ b/src/client/components/leaderboard/LeaderboardPlayerList.ts @@ -249,9 +249,7 @@ export class LeaderboardPlayerList extends LitElement {
` : ""} ${player.clanTag - ? player.username.replace(/^\[.*?\]\s*/, "") - : player.username}${player.username}
@@ -434,14 +432,18 @@ export class LeaderboardPlayerList extends LitElement { "leaderboard_modal.your_ranking", )} - ${this.currentUserEntry.clanTag - ? this.currentUserEntry.username.replace( - /^\[.*?\]\s*/, - "", - ) - : this.currentUserEntry.username} +
+ ${this.currentUserEntry.clanTag + ? html`
+ ${this.currentUserEntry.clanTag} +
` + : ""} + ${this.currentUserEntry.username} +
diff --git a/src/client/graphics/NameBoxCalculator.ts b/src/client/graphics/NameBoxCalculator.ts index fa21655124..ea26ce1222 100644 --- a/src/client/graphics/NameBoxCalculator.ts +++ b/src/client/graphics/NameBoxCalculator.ts @@ -52,7 +52,7 @@ export function placeName(game: Game, player: Player): NameViewData { ), ); - const fontSize = calculateFontSize(largestRectangle, player.name()); + const fontSize = calculateFontSize(largestRectangle, player.displayName()); center = new Cell(center.x, center.y - fontSize / 3); return { diff --git a/src/client/graphics/layers/AttacksDisplay.ts b/src/client/graphics/layers/AttacksDisplay.ts index f77411e553..b8457c08e2 100644 --- a/src/client/graphics/layers/AttacksDisplay.ts +++ b/src/client/graphics/layers/AttacksDisplay.ts @@ -235,7 +235,7 @@ export class AttacksDisplay extends LitElement implements Layer { ${( this.game.playerBySmallID(attack.attackerID) as PlayerView - )?.name()} ${attack.retreating ? `(${translateText("events_display.retreating")}...)` @@ -283,7 +283,7 @@ export class AttacksDisplay extends LitElement implements Layer { ${( this.game.playerBySmallID(attack.targetID) as PlayerView - )?.name()} `, onClick: async () => this.attackWarningOnClick(attack), className: @@ -348,7 +348,7 @@ export class AttacksDisplay extends LitElement implements Layer { const ownerID = this.game.ownerID(target); if (ownerID === 0) return ""; const player = this.game.playerBySmallID(ownerID) as PlayerView; - return player?.name() ?? ""; + return player?.displayName() ?? ""; } private renderBoatIcon(boat: UnitView) { @@ -411,7 +411,7 @@ export class AttacksDisplay extends LitElement implements Layer { >${renderTroops(boat.troops())} ${boat.owner()?.name()}${boat.owner()?.displayName()}`, onClick: () => this.eventBus.emit(new GoToUnitEvent(boat)), className: diff --git a/src/client/graphics/layers/ChatModal.ts b/src/client/graphics/layers/ChatModal.ts index 5f673e2f9c..9448c5ec84 100644 --- a/src/client/graphics/layers/ChatModal.ts +++ b/src/client/graphics/layers/ChatModal.ts @@ -147,7 +147,7 @@ export class ChatModal extends LitElement { .toHex()};" @click=${() => this.selectPlayer(player)} > - ${player.name()} + ${player.displayName()} `, )} @@ -216,7 +216,8 @@ export class ChatModal extends LitElement { private selectPlayer(player: PlayerView) { if (this.previewText) { this.previewText = - this.selectedPhraseTemplate?.replace("[P1]", player.name()) ?? null; + this.selectedPhraseTemplate?.replace("[P1]", player.displayName()) ?? + null; this.selectedPlayer = player; this.requiresPlayerSelection = false; this.requestUpdate(); @@ -255,13 +256,13 @@ export class ChatModal extends LitElement { private getSortedFilteredPlayers(): PlayerView[] { const sorted = [...this.players].sort((a, b) => - a.name().localeCompare(b.name()), + a.displayName().localeCompare(b.displayName()), ); const filtered = sorted.filter((p) => - p.name().toLowerCase().includes(this.playerSearchQuery), + p.displayName().toLowerCase().includes(this.playerSearchQuery), ); const others = sorted.filter( - (p) => !p.name().toLowerCase().includes(this.playerSearchQuery), + (p) => !p.displayName().toLowerCase().includes(this.playerSearchQuery), ); return [...filtered, ...others]; } diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index 1d39d7af96..ca6d1bc9b3 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -283,7 +283,7 @@ export class EventsDisplay extends LitElement implements Layer { this.addEvent({ description: translateText("events_display.about_to_expire", { - name: other.name(), + name: other.displayName(), }), type: MessageType.RENEW_ALLIANCE, duration: this.game.config().allianceExtensionPromptOffset() - 3 * 10, // 3 second buffer @@ -296,7 +296,7 @@ export class EventsDisplay extends LitElement implements Layer { }, { text: translateText("events_display.renew_alliance", { - name: other.name(), + name: other.displayName(), }), className: "btn", action: () => @@ -460,7 +460,7 @@ export class EventsDisplay extends LitElement implements Layer { this.addEvent({ description: translateText("events_display.request_alliance", { - name: requestor.name(), + name: requestor.displayName(), }), buttons: [ { @@ -525,7 +525,7 @@ export class EventsDisplay extends LitElement implements Layer { ) as PlayerView; this.addEvent({ description: translateText("events_display.alliance_request_status", { - name: recipient.name(), + name: recipient.displayName(), status: update.accepted ? translateText("events_display.alliance_accepted") : translateText("events_display.alliance_rejected"), @@ -569,7 +569,7 @@ export class EventsDisplay extends LitElement implements Layer { this.addEvent({ description: translateText("events_display.betrayal_description", { - name: betrayed.name(), + name: betrayed.displayName(), malusPercent: malusPercent, durationText: durationText, }), @@ -589,7 +589,7 @@ export class EventsDisplay extends LitElement implements Layer { ]; this.addEvent({ description: translateText("events_display.betrayed_you", { - name: traitor.name(), + name: traitor.displayName(), }), type: MessageType.ALLIANCE_BROKEN, highlight: true, @@ -616,7 +616,7 @@ export class EventsDisplay extends LitElement implements Layer { this.addEvent({ description: translateText("events_display.alliance_expired", { - name: other.name(), + name: other.displayName(), }), type: MessageType.ALLIANCE_EXPIRED, highlight: true, @@ -639,8 +639,8 @@ export class EventsDisplay extends LitElement implements Layer { this.addEvent({ description: translateText("events_display.attack_request", { - name: other.name(), - target: target.name(), + name: other.displayName(), + target: target.displayName(), }), type: MessageType.ATTACK_REQUEST, highlight: true, diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index e23d4d609e..5dd2d7263b 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -116,6 +116,7 @@ export class NameLayer implements Layer { const baseSize = Math.max(1, Math.floor(render.player.nameLocation().size)); const size = this.transformHandler.scale * baseSize; + const isMyPlayer = render.player.clientID() === this.game.myClientID(); const isOnScreen = render.location ? this.transformHandler.isOnScreen(render.location) : false; @@ -123,7 +124,7 @@ export class NameLayer implements Layer { if ( !this.isVisible || - size < 7 || + (!isMyPlayer && size < 7) || (this.transformHandler.scale > maxZoomScale && size > 100) || !isOnScreen ) { @@ -239,7 +240,7 @@ export class NameLayer implements Layer { const nameSpan = document.createElement("span"); nameSpan.className = "player-name-span"; - nameSpan.innerHTML = player.name(); + nameSpan.textContent = player.displayName(); nameDiv.appendChild(nameSpan); element.appendChild(nameDiv); @@ -335,7 +336,7 @@ export class NameLayer implements Layer { nameDiv.style.color = render.fontColor; const span = nameDiv.querySelector(".player-name-span"); if (span) { - span.innerHTML = render.player.name(); + span.textContent = render.player.displayName(); } if (flagDiv) { flagDiv.style.height = `${render.fontSize}px`; diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index 22d3b341ad..a22b824050 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -356,7 +356,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer { src=${"/flags/" + player.cosmetics.flag! + ".svg"} />` : html``} - ${player.name()} + ${player.displayName()} ${player.team() !== null && player.type() !== PlayerType.Bot ? html`
- ${unit.owner().name()} + ${unit.owner().displayName()}
${unit.type()}
diff --git a/src/client/graphics/layers/PlayerModerationModal.ts b/src/client/graphics/layers/PlayerModerationModal.ts index c51f9efc11..e08230ce21 100644 --- a/src/client/graphics/layers/PlayerModerationModal.ts +++ b/src/client/graphics/layers/PlayerModerationModal.ts @@ -65,7 +65,7 @@ export class PlayerModerationModal extends LitElement { if (!targetClientID || targetClientID.length === 0) return; const confirmed = confirm( - translateText("player_panel.kick_confirm", { name: other.name() }), + translateText("player_panel.kick_confirm", { name: other.displayName() }), ); if (!confirmed) return; @@ -142,9 +142,9 @@ export class PlayerModerationModal extends LitElement { >
- ${other.name()} + ${other.displayName()}
diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index d3b1ac5843..f57397a55c 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -508,9 +508,9 @@ export class PlayerPanel extends LitElement implements Layer {

- ${other.name()} + ${other.displayName()}

${chip @@ -629,7 +629,7 @@ export class PlayerPanel extends LitElement implements Layer { const nameCollator = new Intl.Collator(undefined, { sensitivity: "base" }); const alliesSorted = [...allies].sort((a, b) => - nameCollator.compare(a.name(), b.name()), + nameCollator.compare(a.displayName(), b.displayName()), ); return html` @@ -672,9 +672,9 @@ export class PlayerPanel extends LitElement implements Layer { rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-[14px] text-zinc-100 hover:bg-white/8 active:scale-[0.99] transition" - title=${p.name()} + title=${p.displayName()} > - ${p.name()} + ${p.displayName()} `, )} diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts index ed3aa07d8f..84ad24d3dd 100644 --- a/src/client/graphics/layers/WinModal.ts +++ b/src/client/graphics/layers/WinModal.ts @@ -335,7 +335,7 @@ export class WinModal extends LitElement implements Layer { crazyGamesSDK.happytime(); } else { this._title = translateText("win_modal.other_won", { - player: winner.name(), + player: winner.displayName(), }); this.isWin = false; } diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index c3ab186e7f..0585346c4f 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { base64urlToUuid } from "./Base64"; +import { ClanTagSchema } from "./Schemas"; import { BigIntStringSchema, PlayerStatsSchema } from "./StatsSchemas"; import { Difficulty, @@ -9,6 +10,18 @@ import { RankedType, } from "./game/Game"; +function stripClanTagFromUsername(username: string): string { + return username.replace(/^\s*\[[a-zA-Z0-9]{2,5}\]\s*/u, "").trim(); +} + +// Historical leaderboard rows can include legacy usernames +// that predate current strict join-time validation rules. +const LeaderboardUsernameSchema = z + .string() + .transform(stripClanTagFromUsername) + .pipe(z.string().min(1).max(64)); +const LeaderboardClanTagSchema = ClanTagSchema.unwrap(); + export const RefreshResponseSchema = z.object({ token: z.string(), }); @@ -122,7 +135,7 @@ export const PlayerProfileSchema = z.object({ export type PlayerProfile = z.infer; export const ClanLeaderboardEntrySchema = z.object({ - clanTag: z.string(), + clanTag: LeaderboardClanTagSchema, games: z.number(), wins: z.number(), losses: z.number(), @@ -145,8 +158,8 @@ export type ClanLeaderboardResponse = z.infer< export const PlayerLeaderboardEntrySchema = z.object({ rank: z.number(), playerId: z.string(), - username: z.string(), - clanTag: z.string().optional(), + username: LeaderboardUsernameSchema, + clanTag: LeaderboardClanTagSchema.nullable().optional(), flag: z.string().optional(), elo: z.number(), games: z.number(), @@ -174,8 +187,8 @@ export const RankedLeaderboardEntrySchema = z.object({ total: z.number(), public_id: z.string(), user: DiscordUserSchema.nullable().optional(), - username: z.string(), - clanTag: z.string().nullable().optional(), + username: LeaderboardUsernameSchema, + clanTag: LeaderboardClanTagSchema.nullable().optional(), }); export type RankedLeaderboardEntry = z.infer< typeof RankedLeaderboardEntrySchema diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index e8c46803de..d8071f1a97 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -50,6 +50,7 @@ export async function createGameRunner( p.clientID, random.nextID(), p.isLobbyCreator ?? false, + p.clanTag, ); }); diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 86c74292d2..70ab895579 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -143,7 +143,8 @@ export const PublicGameTypeSchema = z.enum(["ffa", "team", "special"]); const ClientInfoSchema = z.object({ clientID: z.string(), - username: z.string(), + username: z.lazy(() => UsernameSchema), + clanTag: z.lazy(() => ClanTagSchema), }); export const GameInfoSchema = z.object({ @@ -179,6 +180,7 @@ export class LobbyInfoEvent implements GameEvent { export interface ClientInfo { clientID: ClientID; username: string; + clanTag?: string; } export enum LogSeverity { Debug = "DEBUG", @@ -272,9 +274,14 @@ export const AllPlayersStatsSchema = z.record(ID, PlayerStatsSchema); export const UsernameSchema = z .string() - .regex(/^[a-zA-Z0-9_ [\]üÜ.]+$/u) + .regex(/^[a-zA-Z0-9_ üÜ.]+$/u) .min(3) .max(27); + +export const ClanTagSchema = z + .string() + .regex(/^[a-zA-Z0-9]{2,5}$/) + .optional(); const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code); export const QuickChatKeySchema = z.enum( @@ -501,6 +508,7 @@ export const PlayerCosmeticsSchema = z.object({ export const PlayerSchema = z.object({ clientID: ID, username: UsernameSchema, + clanTag: ClanTagSchema, cosmetics: PlayerCosmeticsSchema.optional(), isLobbyCreator: z.boolean().optional(), }); @@ -620,6 +628,7 @@ export const ClientJoinMessageSchema = z.object({ token: TokenSchema, // WARNING: PII - server extracts persistentID from this gameID: ID, username: UsernameSchema, + clanTag: ClanTagSchema, // Server replaces the refs with the actual cosmetic data. cosmetics: PlayerCosmeticRefsSchema.optional(), turnstileToken: z.string().nullable(), @@ -649,7 +658,6 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [ export const PlayerRecordSchema = PlayerSchema.extend({ persistentID: PersistentIdSchema.nullable(), // WARNING: PII - clanTag: z.string().optional(), stats: PlayerStatsSchema, }); export type PlayerRecord = z.infer; diff --git a/src/core/Util.ts b/src/core/Util.ts index 1c8a070889..5d0fc6762b 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -3,6 +3,7 @@ import { customAlphabet } from "nanoid"; import { Cell, PlayerType, Unit } from "./game/Game"; import { GameMap, TileRef } from "./game/GameMap"; import { + ClientInfo, GameConfig, GameID, GameRecord, @@ -339,29 +340,114 @@ export function sigmoid( return 1 / (1 + Math.exp(-decayRate * (value - midpoint))); } -// Compute clan from name -export function getClanTag(name: string): string | null { - const clanTag = clanMatch(name); - return clanTag ? clanTag[1].toUpperCase() : null; +export function formatPlayerDisplayName( + username: string, + clanTag?: string | null, +): string { + return clanTag ? `[${clanTag}] ${username}` : username; } -export function getClanTagOriginalCase(name: string): string | null { - const clanTag = clanMatch(name); - return clanTag ? clanTag[1] : null; +export function clientInfoListsEqual( + a: readonly ClientInfo[] = [], + b: readonly ClientInfo[] = [], +): boolean { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + const left = a[i]; + const right = b[i]; + if ( + left.clientID !== right.clientID || + left.username !== right.username || + left.clanTag !== right.clanTag + ) { + return false; + } + } + return true; +} + +function sortedArraysEqual( + left: readonly string[] = [], + right: readonly string[] = [], +): boolean { + if (left.length !== right.length) { + return false; + } + + const sortedLeft = [...left].sort(); + const sortedRight = [...right].sort(); + for (let i = 0; i < sortedLeft.length; i++) { + if (sortedLeft[i] !== sortedRight[i]) { + return false; + } + } + return true; +} + +function publicGameModifiersEqual( + left: GameConfig["publicGameModifiers"], + right: GameConfig["publicGameModifiers"], +): boolean { + if (left === right) { + return true; + } + if (!left || !right) { + return false; + } + return ( + left.isCompact === right.isCompact && + left.isRandomSpawn === right.isRandomSpawn && + left.isCrowded === right.isCrowded && + left.startingGold === right.startingGold + ); +} + +export function gameConfigsEqual( + left: GameConfig | null | undefined, + right: GameConfig | null | undefined, +): boolean { + if (left === right) { + return true; + } + if (!left || !right) { + return false; + } + return ( + left.gameMap === right.gameMap && + left.difficulty === right.difficulty && + left.donateGold === right.donateGold && + left.donateTroops === right.donateTroops && + left.gameType === right.gameType && + left.gameMode === right.gameMode && + left.rankedType === right.rankedType && + left.gameMapSize === right.gameMapSize && + publicGameModifiersEqual( + left.publicGameModifiers, + right.publicGameModifiers, + ) && + left.disableNations === right.disableNations && + left.bots === right.bots && + left.infiniteGold === right.infiniteGold && + left.infiniteTroops === right.infiniteTroops && + left.instantBuild === right.instantBuild && + left.disableNavMesh === right.disableNavMesh && + left.randomSpawn === right.randomSpawn && + left.maxPlayers === right.maxPlayers && + left.maxTimerValue === right.maxTimerValue && + left.spawnImmunityDuration === right.spawnImmunityDuration && + sortedArraysEqual(left.disabledUnits, right.disabledUnits) && + left.playerTeams === right.playerTeams && + left.goldMultiplier === right.goldMultiplier && + left.startingGold === right.startingGold + ); } const CLAN_TAG_CHARS = "a-zA-Z0-9"; const CLAN_TAG_INVALID_CHARS = new RegExp(`[^${CLAN_TAG_CHARS}]`, "g"); -const CLAN_TAG_REGEX = new RegExp(`\\[([${CLAN_TAG_CHARS}]{2,5})\\]`); export function sanitizeClanTag(tag: string): string { return tag.replace(CLAN_TAG_INVALID_CHARS, "").substring(0, 5).toUpperCase(); } - -function clanMatch(name: string): RegExpMatchArray | null { - if (!name.includes("[") || !name.includes("]")) { - return null; - } - return name.match(CLAN_TAG_REGEX); -} diff --git a/src/core/execution/MIRVExecution.ts b/src/core/execution/MIRVExecution.ts index b840976b94..3fef4c8941 100644 --- a/src/core/execution/MIRVExecution.ts +++ b/src/core/execution/MIRVExecution.ts @@ -90,7 +90,7 @@ export class MirvExecution implements Execution { this.mg.displayIncomingUnit( this.nuke.id(), // TODO TranslateText - `⚠️⚠️⚠️ ${this.player.name()} - MIRV INBOUND ⚠️⚠️⚠️`, + `⚠️⚠️⚠️ ${this.player.displayName()} - MIRV INBOUND ⚠️⚠️⚠️`, MessageType.MIRV_INBOUND, this.targetPlayer.id(), ); diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index fc1743f26a..913ba089d3 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -150,7 +150,7 @@ export class NukeExecution implements Execution { this.mg.displayIncomingUnit( this.nuke.id(), // TODO TranslateText - `${this.player.name()} - atom bomb inbound`, + `${this.player.displayName()} - atom bomb inbound`, MessageType.NUKE_INBOUND, target.id(), ); @@ -158,7 +158,7 @@ export class NukeExecution implements Execution { this.mg.displayIncomingUnit( this.nuke.id(), // TODO TranslateText - `${this.player.name()} - hydrogen bomb inbound`, + `${this.player.displayName()} - hydrogen bomb inbound`, MessageType.HYDROGEN_BOMB_INBOUND, target.id(), ); diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 11722e142d..576e38aa01 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -2,7 +2,7 @@ import { Config } from "../configuration/Config"; import { AbstractGraph } from "../pathfinding/algorithms/AbstractGraph"; import { PathFinder } from "../pathfinding/types"; import { AllPlayersStats, ClientID } from "../Schemas"; -import { getClanTag } from "../Util"; +import { formatPlayerDisplayName } from "../Util"; import { GameMap, TileRef } from "./GameMap"; import { GameUpdate, @@ -459,7 +459,7 @@ export interface MutableAlliance extends Alliance { } export class PlayerInfo { - public readonly clan: string | null; + public readonly displayName: string; constructor( public readonly name: string, @@ -469,8 +469,9 @@ export class PlayerInfo { // TODO: make player id the small id public readonly id: PlayerID, public readonly isLobbyCreator: boolean = false, + public readonly clanTag: string | null = null, ) { - this.clan = getClanTag(name); + this.displayName = formatPlayerDisplayName(this.name, this.clanTag); } } @@ -655,7 +656,6 @@ export interface Player { // Either allied or on same team. isFriendly(other: Player, treatAFKFriendly?: boolean): boolean; team(): Team | null; - clan(): string | null; incomingAllianceRequests(): AllianceRequest[]; outgoingAllianceRequests(): AllianceRequest[]; alliances(): MutableAlliance[]; diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 717e090569..18c0f924ff 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -4,7 +4,7 @@ import { Config } from "../configuration/Config"; import { ColorPalette } from "../CosmeticSchemas"; import { PatternDecoder } from "../PatternDecoder"; import { ClientID, GameID, Player, PlayerCosmetics } from "../Schemas"; -import { createRandomName } from "../Util"; +import { createRandomName, formatPlayerDisplayName } from "../Util"; import { WorkerClient } from "../worker/WorkerClient"; import { Cell, @@ -453,7 +453,7 @@ export class PlayerView { displayName(): string { return this.anonymousName !== null && userSettings.anonymousNames() ? this.anonymousName - : this.data.name; + : this.data.displayName; } clientID(): ClientID | null { @@ -605,21 +605,15 @@ export class GameView implements GameMap { private _mapData: TerrainMapData, private _myClientID: ClientID, private _myUsername: string, + private _myClanTag: string | null, private _gameID: GameID, - private humans: Player[], + humans: Player[], ) { this._map = this._mapData.gameMap; this.lastUpdate = null; this.unitGrid = new UnitGrid(this._map); - // Replace the local player's username with their own stored username. - // This way the user does not know they are being censored. - for (const h of this.humans) { - if (h.clientID === this._myClientID) { - h.username = this._myUsername; - } - } this._cosmetics = new Map( - this.humans.map((h) => [h.clientID, h.cosmetics ?? {}]), + humans.map((h) => [h.clientID, h.cosmetics ?? {}]), ); for (const nation of this._mapData.nations) { // Nations don't have client ids, so we use their name as the key instead. @@ -659,29 +653,63 @@ export class GameView implements GameMap { if (gu.updates === null) { throw new Error("lastUpdate.updates not initialized"); } + const myDisplayName = formatPlayerDisplayName( + this._myUsername, + this._myClanTag, + ); + const myPlayerUpdateCandidates: PlayerUpdate[] = []; + gu.updates[GameUpdateType.Player].forEach((pu) => { + const isMyPlayerUpdate = + pu.clientID === this._myClientID || + this._myPlayer?.id() === pu.id || + (this._myPlayer === null && + pu.playerType === PlayerType.Human && + pu.displayName === myDisplayName); + + if (isMyPlayerUpdate) { + myPlayerUpdateCandidates.push(pu); + } + this.smallIDToID.set(pu.smallID, pu.id); - const player = this._players.get(pu.id); + let player = this._players.get(pu.id); if (player !== undefined) { player.data = pu; - player.nameData = gu.playerNameViewData[pu.id]; + const nextNameData = gu.playerNameViewData[pu.id]; + if (nextNameData !== undefined) { + player.nameData = nextNameData; + } } else { - this._players.set( - pu.id, - new PlayerView( - this, - pu, - gu.playerNameViewData[pu.id], - // First check human by clientID, then check nation by name. - this._cosmetics.get(pu.clientID ?? "") ?? - this._cosmetics.get(pu.name) ?? - {}, - ), + player = new PlayerView( + this, + pu, + gu.playerNameViewData[pu.id], + // First check human by clientID, then check nation by name. + this._cosmetics.get(pu.clientID ?? "") ?? + this._cosmetics.get(pu.name) ?? + {}, ); + this._players.set(pu.id, player); } }); + if (myPlayerUpdateCandidates.length === 1) { + const myUpdate = myPlayerUpdateCandidates[0]; + myUpdate.name = this._myUsername; + myUpdate.displayName = myDisplayName; + this._myPlayer = this._players.get(myUpdate.id) ?? null; + } + this._myPlayer ??= this.playerByClientID(this._myClientID); + if (this._myPlayer === null) { + const matches = this.playerViews().filter( + (p) => + p.type() === PlayerType.Human && p.data.displayName === myDisplayName, + ); + if (matches.length === 1) { + this._myPlayer = matches[0]; + } + } for (const unit of this._units.values()) { unit._wasUpdated = false; diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 2f4d78dc03..013c9ba7e7 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -83,9 +83,6 @@ export class PlayerImpl implements Player { public _units: Unit[] = []; public _tiles: Set = new Set(); - private _name: string; - private _displayName: string; - public pastOutgoingAllianceRequests: AllianceRequest[] = []; private _expiredAlliances: Alliance[] = []; @@ -114,10 +111,8 @@ export class PlayerImpl implements Player { startTroops: number, private readonly _team: Team | null, ) { - this._name = playerInfo.name; this._troops = toInt(startTroops); this._gold = mg.config().startingGold(playerInfo); - this._displayName = this._name; this._pseudo_random = new PseudoRandom(simpleHash(this.playerInfo.id)); } @@ -192,10 +187,10 @@ export class PlayerImpl implements Player { } name(): string { - return this._name; + return this.playerInfo.name; } displayName(): string { - return this._displayName; + return this.playerInfo.displayName; } clientID(): ClientID | null { @@ -210,10 +205,6 @@ export class PlayerImpl implements Player { return this.playerInfo.playerType; } - clan(): string | null { - return this.playerInfo.clan; - } - units(...types: UnitType[]): Unit[] { const len = types.length; if (len === 0) { @@ -756,14 +747,14 @@ export class PlayerImpl implements Player { MessageType.SENT_TROOPS_TO_PLAYER, this.id(), undefined, - { troops: renderTroops(troops), name: recipient.name() }, + { troops: renderTroops(troops), name: recipient.displayName() }, ); this.mg.displayMessage( "events_display.received_troops_from_player", MessageType.RECEIVED_TROOPS_FROM_PLAYER, recipient.id(), undefined, - { troops: renderTroops(troops), name: this.name() }, + { troops: renderTroops(troops), name: this.displayName() }, ); return true; } @@ -780,14 +771,14 @@ export class PlayerImpl implements Player { MessageType.SENT_GOLD_TO_PLAYER, this.id(), undefined, - { gold: renderNumber(gold), name: recipient.name() }, + { gold: renderNumber(gold), name: recipient.displayName() }, ); this.mg.displayMessage( "events_display.received_gold_from_player", MessageType.RECEIVED_GOLD_FROM_PLAYER, recipient.id(), gold, - { gold: renderNumber(gold), name: this.name() }, + { gold: renderNumber(gold), name: this.displayName() }, ); return true; } diff --git a/src/core/game/TeamAssignment.ts b/src/core/game/TeamAssignment.ts index 0251c4466f..c8b8607e90 100644 --- a/src/core/game/TeamAssignment.ts +++ b/src/core/game/TeamAssignment.ts @@ -16,24 +16,24 @@ export function assignTeams( // Sort players into clan groups or no-clan list for (const player of players) { - if (player.clan) { - if (!clanGroups.has(player.clan)) { - clanGroups.set(player.clan, []); + const clanTag = player.clanTag; + if (clanTag) { + if (!clanGroups.has(clanTag)) { + clanGroups.set(clanTag, []); } - clanGroups.get(player.clan)!.push(player); + clanGroups.get(clanTag)!.push(player); } else { noClanPlayers.push(player); } } // Sort clans by size (largest first) - const sortedClans = Array.from(clanGroups.entries()).sort( - (a, b) => b[1].length - a[1].length, + const sortedClanPlayers = Array.from(clanGroups.values()).sort( + (a, b) => b.length - a.length, ); // First, assign clan players - // eslint-disable-next-line @typescript-eslint/no-unused-vars - for (const [_, clanPlayers] of sortedClans) { + for (const clanPlayers of sortedClanPlayers) { // Try to keep the clan together on the team with fewer players let team: Team | null = null; let teamSize = 0; diff --git a/src/core/validations/username.ts b/src/core/validations/username.ts index cb55390f2a..15ac2660a8 100644 --- a/src/core/validations/username.ts +++ b/src/core/validations/username.ts @@ -1,8 +1,10 @@ import { translateText } from "../../client/Utils"; -import { UsernameSchema } from "../Schemas"; +import { ClanTagSchema, UsernameSchema } from "../Schemas"; export const MIN_USERNAME_LENGTH = 3; export const MAX_USERNAME_LENGTH = 27; +export const MIN_CLAN_TAG_LENGTH = 2; +export const MAX_CLAN_TAG_LENGTH = 5; export function validateUsername(username: string): { isValid: boolean; @@ -44,3 +46,28 @@ export function validateUsername(username: string): { // All checks passed return { isValid: true }; } + +export function validateClanTag(clanTag: string): { + isValid: boolean; + error?: string; +} { + if (clanTag.length === 0) { + return { isValid: true }; + } + if (clanTag.length < MIN_CLAN_TAG_LENGTH) { + return { isValid: false, error: translateText("username.tag_too_short") }; + } + if (clanTag.length > MAX_CLAN_TAG_LENGTH) { + return { isValid: false, error: translateText("username.tag_too_short") }; + } + + const parsed = ClanTagSchema.safeParse(clanTag); + if (!parsed.success) { + return { + isValid: false, + error: translateText("username.tag_invalid_chars"), + }; + } + + return { isValid: true }; +} diff --git a/src/server/Client.ts b/src/server/Client.ts index 9fda7317d0..ca57acc3ee 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -17,8 +17,8 @@ export class Client { public readonly roles: string[] | undefined, public readonly flares: string[] | undefined, public readonly ip: string, - public readonly username: string, - public readonly uncensoredUsername: string, + public username: string, + public clanTag: string | null, public ws: WebSocket, public readonly cosmetics: PlayerCosmetics | undefined, ) {} diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index 1be464dfa5..1530e90395 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -46,10 +46,11 @@ export class GameManager { persistentID: string, gameID: GameID, lastTurn: number = 0, + identityUpdate?: { username: string; clanTag: string | null }, ): boolean { const game = this.games.get(gameID); if (!game) return false; - return game.rejoinClient(ws, persistentID, lastTurn); + return game.rejoinClient(ws, persistentID, lastTurn, identityUpdate); } createGame( diff --git a/src/server/GamePreviewBuilder.ts b/src/server/GamePreviewBuilder.ts index ea99160258..cdb8ab0aa0 100644 --- a/src/server/GamePreviewBuilder.ts +++ b/src/server/GamePreviewBuilder.ts @@ -1,10 +1,10 @@ import { z } from "zod"; -import { GameInfo } from "../core/Schemas"; +import { GameInfo, UsernameSchema } from "../core/Schemas"; import { GameMode } from "../core/game/Game"; export const PlayerInfoSchema = z.object({ clientID: z.string().optional(), - username: z.string().optional(), + username: UsernameSchema.optional(), stats: z.unknown().optional(), }); diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 42b46a68df..74c49c68e2 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -23,7 +23,7 @@ import { StampedIntent, Turn, } from "../core/Schemas"; -import { createPartialGameRecord, getClanTag } from "../core/Util"; +import { createPartialGameRecord } from "../core/Util"; import { archive, finalizeGameRecord } from "./Archive"; import { Client } from "./Client"; export enum GamePhase { @@ -255,12 +255,13 @@ export class GameServer { } // Attempt to reconnect a client by persistentID. Returns true if successful. - // Only the WebSocket is updated — username, cosmetics, etc. are preserved - // from the original join to maintain consistency throughout the game session. + // WebSocket is always updated. Optional identity updates are applied only + // before the game has started. public rejoinClient( ws: WebSocket, persistentID: string, lastTurn: number = 0, + identityUpdate?: { username: string; clanTag: string | null }, ): boolean { const clientID = this.getClientIdForPersistentId(persistentID); if (!clientID) return false; @@ -280,6 +281,10 @@ export class GameServer { (c) => c.clientID !== client.clientID, ); this.activeClients.push(client); + if (identityUpdate && !this.hasStarted()) { + client.username = identityUpdate.username; + client.clanTag = identityUpdate.clanTag; + } client.lastPing = Date.now(); this.markClientDisconnected(client.clientID, false); @@ -613,6 +618,7 @@ export class GameServer { config: this.gameConfig, players: this.activeClients.map((c) => ({ username: c.username, + clanTag: c.clanTag ?? undefined, clientID: c.clientID, cosmetics: c.cosmetics, isLobbyCreator: this.lobbyCreatorID === c.clientID, @@ -824,6 +830,7 @@ export class GameServer { gameID: this.id, clients: this.activeClients.map((c) => ({ username: c.username, + clanTag: c.clanTag ?? undefined, clientID: c.clientID, })), lobbyCreatorClientID: this.lobbyCreatorID, @@ -934,11 +941,11 @@ export class GameServer { return { clientID: player.clientID, username: player.username, + clanTag: player.clanTag, persistentID: this.allClients.get(player.clientID)?.persistentID ?? "", stats, cosmetics: player.cosmetics, - clanTag: getClanTag(player.username) ?? undefined, } satisfies PlayerRecord; }, ); diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts index c3d5af3e02..347d7c5799 100644 --- a/src/server/Privilege.ts +++ b/src/server/Privilege.ts @@ -18,7 +18,7 @@ import { PlayerCosmetics, PlayerPattern, } from "../core/Schemas"; -import { getClanTagOriginalCase, simpleHash } from "../core/Util"; +import { simpleHash } from "../core/Util"; export const shadowNames = [ "UnhuggedToday", @@ -68,7 +68,7 @@ export function createMatcher(bannedWords: string[]): RegExpMatcher { } /** - * Sanitizes and censors profane usernames and clan tags. + * Sanitizes and censors profane usernames and clan tags separately. * Profane username is overwritten, profane clan tag is removed. * * Removing bad clan tags won't hurt existing clans nor cause desyncs: @@ -76,36 +76,28 @@ export function createMatcher(bannedWords: string[]): RegExpMatcher { * - only each separate local player name with a profane clan tag will remain, no clan team assignment * * Examples: - * - "GoodName" -> "GoodName" - * - "BadName" -> "Censored" - * - "[CLAN]GoodName" -> "[CLAN]GoodName" - * - "[CLaN]BadName" -> "[CLAN] Censored" - * - "[BAD]GoodName" -> "GoodName" - * - "[BAD]BadName" -> "Censored" + * - username="GoodName", clanTag=null -> { username: "GoodName", clanTag: null } + * - username="BadName", clanTag=null -> { username: "Censored", clanTag: null } + * - username="GoodName", clanTag="CLaN" -> { username: "GoodName", clanTag: "CLAN" } + * - username="GoodName", clanTag="BAD" -> { username: "GoodName", clanTag: null } + * - username="BadName", clanTag="BAD" -> { username: "Censored", clanTag: null } */ -function censorUsernameWithMatcher( + +function censorWithMatcher( username: string, + clanTag: string | null, matcher: RegExpMatcher, -): string { - const clanTag = getClanTagOriginalCase(username); - - const nameWithoutClan = clanTag - ? username.replace(`[${clanTag}]`, "").trim() +): { username: string; clanTag: string | null } { + const usernameIsProfane = matcher.hasMatch(username); + const censoredName = usernameIsProfane + ? shadowNames[simpleHash(username) % shadowNames.length] : username; const clanTagIsProfane = clanTag ? matcher.hasMatch(clanTag) : false; - const usernameIsProfane = matcher.hasMatch(nameWithoutClan); - - const censoredName = usernameIsProfane - ? shadowNames[simpleHash(nameWithoutClan) % shadowNames.length] - : nameWithoutClan; - - // Restore clan tag only if it's clean, otherwise remove it entirely - if (clanTag && !clanTagIsProfane) { - return `[${clanTag.toUpperCase()}] ${censoredName}`; - } + const censoredClanTag = + clanTag && !clanTagIsProfane ? clanTag.toUpperCase() : null; - return censoredName; + return { username: censoredName, clanTag: censoredClanTag }; } type CosmeticResult = @@ -114,7 +106,10 @@ type CosmeticResult = export interface PrivilegeChecker { isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult; - censorUsername(username: string): string; + censor( + username: string, + clanTag: string | null, + ): { username: string; clanTag: string | null }; } export class PrivilegeCheckerImpl implements PrivilegeChecker { @@ -213,8 +208,11 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker { return { color }; } - censorUsername(username: string): string { - return censorUsernameWithMatcher(username, this.matcher); + censor( + username: string, + clanTag: string | null, + ): { username: string; clanTag: string | null } { + return censorWithMatcher(username, clanTag, this.matcher); } } @@ -226,8 +224,10 @@ export class FailOpenPrivilegeChecker implements PrivilegeChecker { return { type: "allowed", cosmetics: {} }; } - censorUsername(username: string): string { - // Fail open: use matcher with just the built-in English profanity dataset - return censorUsernameWithMatcher(username, defaultMatcher); + censor( + username: string, + clanTag: string | null, + ): { username: string; clanTag: string | null } { + return censorWithMatcher(username, clanTag, defaultMatcher); } } diff --git a/src/server/Worker.ts b/src/server/Worker.ts index a5b2ecdf1c..469b65e269 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -355,9 +355,21 @@ export async function startWorker() { return; } + // Normalize username and clan tag before any rejoin/join handling. + // If this connection maps to an existing lobby client, we still want + // the latest pre-join identity to be reflected. + const censored = privilegeRefresher + .get() + .censor(clientMsg.username, clientMsg.clanTag ?? null); + // Try to reconnect an existing client (e.g., page refresh) // If successful, skip all authorization - if (gm.rejoinClient(ws, persistentId, clientMsg.gameID)) { + if ( + gm.rejoinClient(ws, persistentId, clientMsg.gameID, 0, { + username: censored.username, + clanTag: censored.clanTag, + }) + ) { return; } @@ -439,11 +451,6 @@ export async function startWorker() { } } - // Censor profane usernames server-side (don't reject, just rename) - const censoredUsername = privilegeRefresher - .get() - .censorUsername(clientMsg.username); - // Create client and add to game const client = new Client( generateID(), @@ -452,8 +459,8 @@ export async function startWorker() { roles, flares, ip, - censoredUsername, - clientMsg.username, + censored.username, + censored.clanTag, ws, cosmeticResult.cosmetics, ); diff --git a/tests/Censor.test.ts b/tests/Censor.test.ts index 4c5253d728..7faf9bd98f 100644 --- a/tests/Censor.test.ts +++ b/tests/Censor.test.ts @@ -5,7 +5,9 @@ vi.mock("../src/client/Utils", () => ({ })); import { + MAX_CLAN_TAG_LENGTH, MAX_USERNAME_LENGTH, + validateClanTag, validateUsername, } from "../src/core/validations/username"; @@ -39,4 +41,34 @@ describe("username.ts functions", () => { expect(res.isValid).toBe(true); }); }); + + describe("validateClanTag", () => { + test("accepts empty clan tag", () => { + const res = validateClanTag(""); + expect(res.isValid).toBe(true); + }); + + test("rejects too short clan tag", () => { + const res = validateClanTag("A"); + expect(res.isValid).toBe(false); + expect(res.error).toBe("username.tag_too_short"); + }); + + test("rejects invalid clan tag characters", () => { + const res = validateClanTag("A!"); + expect(res.isValid).toBe(false); + expect(res.error).toBe("username.tag_invalid_chars"); + }); + + test("rejects too long clan tag", () => { + const res = validateClanTag("A".repeat(MAX_CLAN_TAG_LENGTH + 1)); + expect(res.isValid).toBe(false); + expect(res.error).toBe("username.tag_too_short"); + }); + + test("accepts valid clan tag", () => { + const res = validateClanTag("AB12"); + expect(res.isValid).toBe(true); + }); + }); }); diff --git a/tests/Disconnected.test.ts b/tests/Disconnected.test.ts index c52f009114..1538b993d1 100644 --- a/tests/Disconnected.test.ts +++ b/tests/Disconnected.test.ts @@ -179,16 +179,20 @@ describe("Disconnected", () => { beforeEach(async () => { const player1Info = new PlayerInfo( - "[CLAN]Player1", + "Player1", PlayerType.Human, null, "player_1_id", + false, + "CLAN", ); const player2Info = new PlayerInfo( - "[CLAN]Player2", + "Player2", PlayerType.Human, null, "player_2_id", + false, + "CLAN", ); game = await setup( diff --git a/tests/GameInfoRanking.test.ts b/tests/GameInfoRanking.test.ts index 1eeefebb9a..8d251f580d 100644 --- a/tests/GameInfoRanking.test.ts +++ b/tests/GameInfoRanking.test.ts @@ -51,7 +51,7 @@ describe("Ranking class", () => { players: [ { clientID: "p1", - username: "[X] Alice", + username: "Alice", clanTag: "X", cosmetics: { flag: "USA" }, stats: { diff --git a/tests/NationCounterWarshipInfestation.test.ts b/tests/NationCounterWarshipInfestation.test.ts index ecb9858bbf..ceef241c42 100644 --- a/tests/NationCounterWarshipInfestation.test.ts +++ b/tests/NationCounterWarshipInfestation.test.ts @@ -141,28 +141,36 @@ describe("Counter Warship Infestation", () => { test("rich nation sends counter-warship in Team game when enemy team has too many warships", async () => { // Create players with team setup - use clan tags to group players const nationInfo = new PlayerInfo( - "[ALPHA]defender_nation", + "defender_nation", PlayerType.Nation, null, "nation_id", + false, + "ALPHA", ); const allyInfo = new PlayerInfo( - "[ALPHA]ally_player", + "ally_player", PlayerType.Human, null, "ally_id", + false, + "ALPHA", ); const enemy1Info = new PlayerInfo( - "[BETA]enemy_player_1", + "enemy_player_1", PlayerType.Human, null, "enemy1_id", + false, + "BETA", ); const enemy2Info = new PlayerInfo( - "[BETA]enemy_player_2", + "enemy_player_2", PlayerType.Human, null, "enemy2_id", + false, + "BETA", ); const game = await setup( diff --git a/tests/NationMIRV.test.ts b/tests/NationMIRV.test.ts index abaeb60aa7..0264620ca9 100644 --- a/tests/NationMIRV.test.ts +++ b/tests/NationMIRV.test.ts @@ -602,16 +602,20 @@ describe("Nation MIRV Retaliation", () => { test("nation launches MIRV to prevent team victory when team approaches victory denial threshold (targets biggest team member)", async () => { // Setup game const teamPlayer1Info = new PlayerInfo( - "[ALPHA]team_player_1", + "team_player_1", PlayerType.Human, null, "team1_id", + false, + "ALPHA", ); const teamPlayer2Info = new PlayerInfo( - "[ALPHA]team_player_2", + "team_player_2", PlayerType.Human, null, "team2_id", + false, + "ALPHA", ); const nationInfo = new PlayerInfo( "defender_nation", diff --git a/tests/PlayerInfo.test.ts b/tests/PlayerInfo.test.ts index c1fa8b5590..20ee5d4c06 100644 --- a/tests/PlayerInfo.test.ts +++ b/tests/PlayerInfo.test.ts @@ -1,215 +1,99 @@ import { PlayerInfo, PlayerType } from "../src/core/game/Game"; describe("PlayerInfo", () => { - describe("clan", () => { - test("should extract clan from name when format contains [XX]", () => { - const playerInfo = new PlayerInfo( - "[CL]PlayerName", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("CL"); - }); - - test("should extract clan from name when format contains [XXX]", () => { - const playerInfo = new PlayerInfo( - "[ABC]PlayerName", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("ABC"); - }); - - test("should extract clan from name when format contains [XXXX]", () => { - const playerInfo = new PlayerInfo( - "[ABCD]PlayerName", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("ABCD"); - }); - - test("should extract clan from name when format contains [XXXXX]", () => { - const playerInfo = new PlayerInfo( - "[ABCDE]PlayerName", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("ABCDE"); - }); - - test("should extract uppercase clan from name when format contains [xxxxx]", () => { - const playerInfo = new PlayerInfo( - "[abcde]PlayerName", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("ABCDE"); - }); - - test("should extract uppercase clan from name when format contains [XxXxX]", () => { - const playerInfo = new PlayerInfo( - "[AbCdE]PlayerName", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("ABCDE"); - }); - - test("should extract uppercase clan from name when format contains [Xx#xX]", () => { - const playerInfo = new PlayerInfo( - "[Ab1cD]PlayerName", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("AB1CD"); - }); - - test("should return null when name doesn't contain [", () => { + describe("clanTag from explicit clanTag parameter", () => { + test("should set clanTag from clanTag parameter", () => { const playerInfo = new PlayerInfo( "PlayerName", PlayerType.Human, null, "player_id", + false, + "abc", ); - expect(playerInfo.clan).toBeNull(); + expect(playerInfo.clanTag).toBe("abc"); }); - test("should return null when name doesn't contain ]", () => { + test("should preserve already-uppercase clan tag", () => { const playerInfo = new PlayerInfo( - "[ABCPlayerName", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBeNull(); - }); - - test("should return null when clan tag is not 2-5 alphanumeric letters", () => { - const playerInfo = new PlayerInfo( - "[A]PlayerName", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBeNull(); - }); - - test("should return null when clan tag contains non alphanumeric characters", () => { - const playerInfo = new PlayerInfo( - "[A?c]PlayerName", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBeNull(); - }); - - test("should return null when clan tag is too long", () => { - const playerInfo = new PlayerInfo( - "[ABCDEF]PlayerName", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBeNull(); - }); - - test("should extract uppercase clan name from any location in the player name", () => { - const playerInfo = new PlayerInfo( - "Player[aa]Name", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("AA"); - }); - - test("should extract only the first occurrence of a clan name match", () => { - const playerInfo = new PlayerInfo( - "[Ab1cD]Player[aa]Name", + "PlayerName", PlayerType.Human, null, "player_id", + false, + "CLAN", ); - expect(playerInfo.clan).toBe("AB1CD"); + expect(playerInfo.clanTag).toBe("CLAN"); }); - test("should extract only the first occurrence of a valid clan name match and extract as uppercase", () => { + test("should set clan to null when clanTag is not provided", () => { const playerInfo = new PlayerInfo( - "[Ab1cDEF]Player[aa]Name", + "PlayerName", PlayerType.Human, null, "player_id", ); - expect(playerInfo.clan).toBe("AA"); + expect(playerInfo.clanTag).toBeNull(); }); - test("should extract numeric-only clan names", () => { + test("should set clan to null when clanTag is null", () => { const playerInfo = new PlayerInfo( - "[012]PlayerName", + "PlayerName", PlayerType.Human, null, "player_id", - ); - expect(playerInfo.clan).toBe("012"); - }); - - test("should extract numeric-only clan names and only the first valid clan name", () => { - const playerInfo = new PlayerInfo( - "[012]Player[aa]Name", - PlayerType.Human, + false, null, - "player_id", ); - expect(playerInfo.clan).toBe("012"); + expect(playerInfo.clanTag).toBeNull(); }); - test("should extract numeric-only clan names from anywhere within the name", () => { + test("should set clan to null when clanTag is undefined", () => { const playerInfo = new PlayerInfo( - "Player[012]Name", + "PlayerName", PlayerType.Human, null, "player_id", + false, + undefined, ); - expect(playerInfo.clan).toBe("012"); + expect(playerInfo.clanTag).toBeNull(); }); + }); - test("should extract numeric-only clan names from the end of the name", () => { + describe("displayName", () => { + test("should construct display name with clan tag", () => { const playerInfo = new PlayerInfo( - "PlayerName[012]", + "PlayerName", PlayerType.Human, null, "player_id", + false, + "CLAN", ); - expect(playerInfo.clan).toBe("012"); + expect(playerInfo.displayName).toBe("[CLAN] PlayerName"); }); - test("should extract uppercase alphanumeric clan names from anywhere within the name", () => { + test("should return just name when no clan tag", () => { const playerInfo = new PlayerInfo( - "Player[0a1B2]Name", + "PlayerName", PlayerType.Human, null, "player_id", ); - expect(playerInfo.clan).toBe("0A1B2"); + expect(playerInfo.displayName).toBe("PlayerName"); }); - test("should extract uppercase alphanumeric clan names from the end of the name", () => { + test("should preserve clan tag casing in display name", () => { const playerInfo = new PlayerInfo( - "PlayerName[0a1B2]", + "PlayerName", PlayerType.Human, null, "player_id", + false, + "abc", ); - expect(playerInfo.clan).toBe("0A1B2"); + expect(playerInfo.displayName).toBe("[abc] PlayerName"); }); }); }); diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts index e3acc62b36..97d5922c25 100644 --- a/tests/Privilege.test.ts +++ b/tests/Privilege.test.ts @@ -17,7 +17,7 @@ const bannedWords = [ const matcher = createMatcher(bannedWords); -// Create a minimal PrivilegeCheckerImpl for testing censorUsername +// Create a minimal PrivilegeCheckerImpl for testing censor const mockCosmetics = { patterns: {}, colorPalettes: {} }; const mockDecoder = () => new Uint8Array(); const checker = new PrivilegeCheckerImpl( @@ -75,73 +75,82 @@ describe("UsernameCensor", () => { }); }); - describe("censorUsername", () => { + describe("censor", () => { test("returns clean usernames unchanged", () => { - expect(checker.censorUsername("CoolPlayer")).toBe("CoolPlayer"); - expect(checker.censorUsername("GameMaster")).toBe("GameMaster"); + expect(checker.censor("CoolPlayer", null).username).toBe("CoolPlayer"); + expect(checker.censor("GameMaster", null).username).toBe("GameMaster"); }); test("replaces profane usernames with a shadow name", () => { - const result = checker.censorUsername("hitler"); - expect(shadowNames).toContain(result); + const result = checker.censor("hitler", null); + expect(shadowNames).toContain(result.username); }); test("replaces leet speak profane usernames with a shadow name", () => { - const result = checker.censorUsername("h1tl3r"); - expect(shadowNames).toContain(result); + const result = checker.censor("h1tl3r", null); + expect(shadowNames).toContain(result.username); }); test("preserves clean clan tag when username is profane", () => { - const result = checker.censorUsername("[COOL]hitler"); - expect(result).toMatch(/^\[COOL\] /); - const nameAfterTag = result.replace("[COOL] ", ""); - expect(shadowNames).toContain(nameAfterTag); + const result = checker.censor("hitler", "COOL"); + expect(result.clanTag).toBe("COOL"); + expect(shadowNames).toContain(result.username); }); test("removes profane clan tag but keeps clean username", () => { - expect(checker.censorUsername("[NAZI]CoolPlayer")).toBe("CoolPlayer"); + const result = checker.censor("CoolPlayer", "NAZI"); + expect(result.username).toBe("CoolPlayer"); + expect(result.clanTag).toBeNull(); }); test("removes clan tag with leet speak profanity", () => { - expect(checker.censorUsername("[N4Z1]CoolPlayer")).toBe("CoolPlayer"); + const result = checker.censor("CoolPlayer", "N4Z1"); + expect(result.username).toBe("CoolPlayer"); + expect(result.clanTag).toBeNull(); }); test("removes clan tag with uppercased banned word", () => { - expect(checker.censorUsername("[ADOLF]CoolPlayer")).toBe("CoolPlayer"); + const result = checker.censor("CoolPlayer", "ADOLF"); + expect(result.username).toBe("CoolPlayer"); + expect(result.clanTag).toBeNull(); }); test("removes clan tag containing banned word substring", () => { - expect(checker.censorUsername("[JEWS]CoolPlayer")).toBe("CoolPlayer"); + const result = checker.censor("CoolPlayer", "JEWS"); + expect(result.username).toBe("CoolPlayer"); + expect(result.clanTag).toBeNull(); }); test("removes profane clan tag and censors profane username", () => { - const result = checker.censorUsername("[NAZI]hitler"); - // No clan tag prefix, just a shadow name - expect(shadowNames).toContain(result); + const result = checker.censor("hitler", "NAZI"); + expect(result.clanTag).toBeNull(); + expect(shadowNames).toContain(result.username); }); test("removes leet speak profane clan tag and censors leet speak username", () => { - const result = checker.censorUsername("[N4Z1]h1tl3r"); - // No clan tag prefix, just a shadow name - expect(shadowNames).toContain(result); + const result = checker.censor("h1tl3r", "N4Z1"); + expect(result.clanTag).toBeNull(); + expect(shadowNames).toContain(result.username); }); test("returns deterministic shadow name for same input", () => { - const a = checker.censorUsername("hitler"); - const b = checker.censorUsername("hitler"); - expect(a).toBe(b); + const a = checker.censor("hitler", null); + const b = checker.censor("hitler", null); + expect(a.username).toBe(b.username); }); test("handles username with no clan tag", () => { - expect(checker.censorUsername("NormalPlayer")).toBe("NormalPlayer"); + expect(checker.censor("NormalPlayer", null).username).toBe( + "NormalPlayer", + ); }); test("empty banned words list still catches englishDataset profanity", () => { - // The emptyChecker still uses englishDataset, so common profanity is caught - expect(emptyChecker.censorUsername("CoolPlayer")).toBe("CoolPlayer"); - // Verify a known english profanity gets censored even without custom banned words - const result = emptyChecker.censorUsername("fuck"); - expect(shadowNames).toContain(result); + expect(emptyChecker.censor("CoolPlayer", null).username).toBe( + "CoolPlayer", + ); + const result = emptyChecker.censor("fuck", null); + expect(shadowNames).toContain(result.username); }); }); }); diff --git a/tests/TeamAssignment.test.ts b/tests/TeamAssignment.test.ts index c3e11671b3..999f025926 100644 --- a/tests/TeamAssignment.test.ts +++ b/tests/TeamAssignment.test.ts @@ -5,12 +5,13 @@ const teams = [ColoredTeams.Red, ColoredTeams.Blue]; describe("assignTeams", () => { const createPlayer = (id: string, clan?: string): PlayerInfo => { - const name = clan ? `[${clan}]Player ${id}` : `Player ${id}`; return new PlayerInfo( - name, + `Player ${id}`, PlayerType.Human, null, // clientID (null for testing) id, + false, + clan, ); }; diff --git a/tests/client/graphics/layers/PlayerPanelKick.test.ts b/tests/client/graphics/layers/PlayerPanelKick.test.ts index ea87feed30..e5a37bfeff 100644 --- a/tests/client/graphics/layers/PlayerPanelKick.test.ts +++ b/tests/client/graphics/layers/PlayerPanelKick.test.ts @@ -53,6 +53,7 @@ describe("PlayerPanel - kick player moderation", () => { const other = { id: () => 2, name: () => "Other", + displayName: () => "[TAG] Other", type: () => PlayerType.Human, clientID: () => "client-2", } as unknown as PlayerView; @@ -84,6 +85,7 @@ describe("PlayerPanel - kick player moderation", () => { const other = { id: () => 2, name: () => "Other", + displayName: () => "[TAG] Other", type: () => PlayerType.Human, clientID: () => "client-2", } as unknown as PlayerView; @@ -119,6 +121,7 @@ describe("PlayerModerationModal - kick confirmation", () => { const other = { id: () => 2, name: () => "Other", + displayName: () => "[TAG] Other", type: () => PlayerType.Human, clientID: () => "client-2", } as unknown as PlayerView; @@ -151,6 +154,7 @@ describe("PlayerModerationModal - kick confirmation", () => { const other = { id: () => 2, name: () => "Other", + displayName: () => "[TAG] Other", type: () => PlayerType.Human, clientID: () => "client-2", } as unknown as PlayerView;