diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 09271afda..78e589d91 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 ?? null, clientID: this.clientID, stats: update.allPlayersStats[this.clientID], }, diff --git a/src/client/GameInfoModal.ts b/src/client/GameInfoModal.ts index 9dc8bf395..198a292d3 100644 --- a/src/client/GameInfoModal.ts +++ b/src/client/GameInfoModal.ts @@ -4,7 +4,6 @@ import { GameEndInfo } from "../core/Schemas"; import { GameMapType } 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; @@ -152,7 +151,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} > `, )} @@ -183,26 +182,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 0163e8c68..012cfe27d 100644 --- a/src/client/GameModeSelector.ts +++ b/src/client/GameModeSelector.ts @@ -17,6 +17,7 @@ import { PublicLobbySocket } from "./LobbySocket"; import { JoinLobbyEvent } from "./Main"; import { SinglePlayerModal } from "./SinglePlayerModal"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; +import { UsernameInput } from "./UsernameInput"; import { calculateServerTimeOffset, getMapName, @@ -48,20 +49,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/LocalServer.ts b/src/client/LocalServer.ts index e2805235f..aeefc1581 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"; @@ -273,10 +272,10 @@ export class LocalServer { { persistentID: getPersistentID(), username: this.lobbyConfig.playerName, + clanTag: this.lobbyConfig.playerClanTag ?? null, 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 8499d84dd..97460ced4 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -732,6 +732,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"); @@ -753,8 +757,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 37f023ca4..c93fa9edd 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -654,9 +654,6 @@ export class SinglePlayerModal extends BaseModal { const usernameInput = document.querySelector( "username-input", ) as UsernameInput; - if (!usernameInput) { - console.warn("Username input element not found"); - } await crazyGamesSDK.requestMidgameAd(); @@ -669,7 +666,8 @@ export class SinglePlayerModal extends BaseModal { players: [ { clientID, - username: usernameInput.getCurrentUsername(), + username: usernameInput.getUsername(), + clanTag: usernameInput.getClanTag() ?? null, cosmetics: await getPlayerCosmetics(), }, ], diff --git a/src/client/Transport.ts b/src/client/Transport.ts index d86f0fe82..1641b614a 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 ?? null, cosmetics: this.lobbyConfig.cosmetics, turnstileToken: this.lobbyConfig.turnstileToken, token: await getPlayToken(), diff --git a/src/client/UsernameInput.ts b/src/client/UsernameInput.ts index 04670a5c7..cb1030705 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,45 @@ 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 && + validateClanTag(this.clanTag).isValid + ? 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 +80,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-medium tracking-wider 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,59 +152,51 @@ 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) { + const trimmedBase = this.getUsername(); + + const clanTagResult = validateClanTag(this.clanTag); + if (!clanTagResult.isValid) { this._isValid = false; - this.validationError = translateText("username.too_short", { - min: MIN_USERNAME_LENGTH, - }); + this.validationError = clanTagResult.error ?? ""; return; } - // Validate clan tag if present - if (this.clanTag.length > 0 && this.clanTag.length < 2) { - this._isValid = false; - this.validationError = translateText("username.tag_too_short"); - 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); - } - } - - private generateNewUsername(): string { - const newUsername = genAnonUsername(); - this.storeUsername(newUsername); - return newUsername; - } - public isValid(): boolean { return this._isValid; } + + public showValidationFeedback(): void { + const message = + this.validationError || translateText("username.invalid_chars"); + window.dispatchEvent( + new CustomEvent("show-message", { + detail: { + message, + color: "red", + duration: 2500, + }, + }), + ); + } + + public validateOrShowError(): boolean { + if (this.isValid()) { + return true; + } + this.showValidationFeedback(); + return false; + } } export function genAnonUsername(): string { diff --git a/src/client/components/LobbyPlayerView.ts b/src/client/components/LobbyPlayerView.ts index 7ea87bab1..aeaff00b3 100644 --- a/src/client/components/LobbyPlayerView.ts +++ b/src/client/components/LobbyPlayerView.ts @@ -16,7 +16,7 @@ import { import { assignTeamsLobbyPreview } from "../../core/game/TeamAssignment"; import { UserSettings } from "../../core/game/UserSettings"; import { ClientInfo, TeamCountConfig } from "../../core/Schemas"; -import { createRandomName } from "../../core/Util"; +import { createRandomName, formatPlayerDisplayName } from "../../core/Util"; import { getTranslatedPlayerTeamLabel, translateText } from "../Utils"; export interface TeamPreviewData { @@ -122,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`
@@ -167,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 @@ -226,7 +226,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`
@@ -318,7 +318,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, @@ -358,17 +365,17 @@ export class LobbyTeamView extends LitElement { })); } - 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 dff65db0f..e3be802a7 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 | null; 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 ed3cfc6ba..612569192 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/leaderboard/LeaderboardPlayerList.ts b/src/client/components/leaderboard/LeaderboardPlayerList.ts index 86fa945d7..7ddd375f8 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 fa2165512..ea26ce122 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 b76c435b7..6c52c085a 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")}...)` @@ -282,7 +282,7 @@ export class AttacksDisplay extends LitElement implements Layer { ${( this.game.playerBySmallID(attack.targetID) as PlayerView - )?.name()} `, onClick: async () => this.attackWarningOnClick(attack), className: @@ -346,7 +346,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) { @@ -409,7 +409,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 5f673e2f9..9448c5ec8 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 a45e57e84..6a76c956d 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, @@ -641,8 +641,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 9f49f6f26..ac0b973f9 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -239,7 +239,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); @@ -338,7 +338,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 d936f921e..ac12f4c34 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -380,7 +380,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer { src=${"/flags/" + player.cosmetics.flag! + ".svg"} />` : html``} - ${player.name()} + ${player.displayName()} ${playerTeam !== "" && 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 c51f9efc1..e08230ce2 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 f1df0750d..0fb3b304f 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -505,9 +505,9 @@ export class PlayerPanel extends LitElement implements Layer {

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

${chip @@ -626,7 +626,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` @@ -669,9 +669,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 e4fcd3286..d47ec4fe2 100644 --- a/src/client/graphics/layers/WinModal.ts +++ b/src/client/graphics/layers/WinModal.ts @@ -334,7 +334,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 32153359f..3076fe4c5 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -1,8 +1,21 @@ import { z } from "zod"; import { base64urlToUuid } from "./Base64"; +import { ClanTagSchema } from "./Schemas"; import { BigIntStringSchema, PlayerStatsSchema } from "./StatsSchemas"; import { Difficulty, GameMode, GameType, 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(), }); @@ -114,7 +127,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(), @@ -137,8 +150,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(), @@ -166,8 +179,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 ddf4da20d..c5ad6477f 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -52,6 +52,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 07b7f263e..e0dbdd5fc 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -141,9 +141,21 @@ export type PublicGameType = z.infer; export const PublicGameTypeSchema = z.enum(["ffa", "team", "special"]); +export const UsernameSchema = z + .string() + .regex(/^(?=.*\S)[a-zA-Z0-9_ üÜ.]+$/u) + .min(3) + .max(27); + +export const ClanTagSchema = z + .string() + .regex(/^[a-zA-Z0-9]{2,5}$/) + .nullable(); + const ClientInfoSchema = z.object({ clientID: z.string(), - username: z.string(), + username: UsernameSchema, + clanTag: ClanTagSchema, }); export const GameInfoSchema = z.object({ @@ -179,6 +191,7 @@ export class LobbyInfoEvent implements GameEvent { export interface ClientInfo { clientID: ClientID; username: string; + clanTag: string | null; } export enum LogSeverity { Debug = "DEBUG", @@ -279,11 +292,6 @@ export const ID = z.string().regex(GAME_ID_REGEX); export const AllPlayersStatsSchema = z.record(ID, PlayerStatsSchema); -export const UsernameSchema = z - .string() - .regex(/^[a-zA-Z0-9_ [\]üÜ.]+$/u) - .min(3) - .max(27); const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code); export const QuickChatKeySchema = z.enum( @@ -510,6 +518,7 @@ export const PlayerCosmeticsSchema = z.object({ export const PlayerSchema = z.object({ clientID: ID, username: UsernameSchema, + clanTag: ClanTagSchema, cosmetics: PlayerCosmeticsSchema.optional(), isLobbyCreator: z.boolean().optional(), }); @@ -630,6 +639,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(), @@ -659,7 +669,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 6273824ad..d099e0197 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -340,29 +340,17 @@ 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 getClanTagOriginalCase(name: string): string | null { - const clanTag = clanMatch(name); - return clanTag ? clanTag[1] : null; +export function formatPlayerDisplayName( + username: string, + clanTag?: string | null, +): string { + return clanTag ? `[${clanTag}] ${username}` : username; } 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 b840976b9..3fef4c894 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 2bf2055b7..9f443474f 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 d77d46069..dcfd6cf21 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, @@ -503,7 +503,7 @@ export interface MutableAlliance extends Alliance { } export class PlayerInfo { - public readonly clan: string | null; + public readonly displayName: string; constructor( public readonly name: string, @@ -513,8 +513,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); } } @@ -706,7 +707,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 d3e3ad87e..6db2a4857 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 { BuildableUnit, @@ -482,7 +482,7 @@ export class PlayerView { displayName(): string { return this.anonymousName !== null && userSettings.anonymousNames() ? this.anonymousName - : this.data.name; + : this.data.displayName; } clientID(): ClientID | null { @@ -659,21 +659,15 @@ export class GameView implements GameMap { private _mapData: TerrainMapData, private _myClientID: ClientID | undefined, 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. @@ -763,25 +757,38 @@ export class GameView implements GameMap { if (gu.updates === null) { throw new Error("lastUpdate.updates not initialized"); } + const myDisplayName = formatPlayerDisplayName( + this._myUsername, + this._myClanTag, + ); + gu.updates[GameUpdateType.Player].forEach((pu) => { + // Replace the local player's name/displayName with their own stored values. + // This way the user does not know they are being censored. + if (pu.clientID === this._myClientID) { + pu.name = this._myUsername; + pu.displayName = myDisplayName; + } + 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); } }); diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index f8c5b88cb..3d915194c 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -84,9 +84,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[] = []; @@ -115,10 +112,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)); } @@ -193,10 +188,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 { @@ -211,10 +206,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) { @@ -760,14 +751,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; } @@ -784,14 +775,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 0251c4466..c8b8607e9 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 cb55390f2..15ac2660a 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 07b918db8..ca57acc3e 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -18,7 +18,7 @@ export class Client { public readonly flares: string[] | undefined, public readonly ip: string, public username: string, - public readonly uncensoredUsername: 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 fde2678c2..11e2a87b9 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -46,11 +46,11 @@ export class GameManager { persistentID: string, gameID: GameID, lastTurn: number = 0, - newUsername?: string, + identityUpdate?: { username: string; clanTag: string | null }, ): boolean { const game = this.games.get(gameID); if (!game) return false; - return game.rejoinClient(ws, persistentID, lastTurn, newUsername); + return game.rejoinClient(ws, persistentID, lastTurn, identityUpdate); } createGame( diff --git a/src/server/GamePreviewBuilder.ts b/src/server/GamePreviewBuilder.ts index 93e8c0d29..217edefd1 100644 --- a/src/server/GamePreviewBuilder.ts +++ b/src/server/GamePreviewBuilder.ts @@ -1,10 +1,12 @@ import { z } from "zod"; -import { GameInfo } from "../core/Schemas"; +import { ClanTagSchema, GameInfo, UsernameSchema } from "../core/Schemas"; +import { formatPlayerDisplayName } from "../core/Util"; import { GameMode } from "../core/game/Game"; export const PlayerInfoSchema = z.object({ clientID: z.string().optional(), - username: z.string().optional(), + username: UsernameSchema.optional(), + clanTag: ClanTagSchema, stats: z.unknown().optional(), }); @@ -85,7 +87,10 @@ function parseWinner( if (!winnerArray || winnerArray.length < 2) return undefined; const idToName = new Map( - (players ?? []).map((p) => [p.clientID, p.username]), + (players ?? []).map((p) => [ + p.clientID, + p.username ? formatPlayerDisplayName(p.username, p.clanTag) : undefined, + ]), ); if (winnerArray[0] === "team" && winnerArray.length >= 3) { @@ -228,7 +233,9 @@ export function buildPreview( // Show host const hostClient = lobby.clients?.[0]; if (hostClient?.username) { - sections.push(`Host: ${hostClient.username}`); + sections.push( + `Host: ${formatPlayerDisplayName(hostClient.username, hostClient.clanTag)}`, + ); } const gameOptions: string[] = []; diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 49305432e..8a41d4edb 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"; import { ClientMsgRateLimiter } from "./ClientMsgRateLimiter"; @@ -266,15 +266,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. - // Exception: in the pre-game lobby, the username is updated so players can - // rename between leaving and rejoining. + // WebSocket is always updated. Optional identity updates are applied only + // before the game has started. public rejoinClient( ws: WebSocket, persistentID: string, lastTurn: number = 0, - newUsername?: string, + identityUpdate?: { username: string; clanTag: string | null }, ): boolean { const clientID = this.getClientIdForPersistentId(persistentID); if (!clientID) return false; @@ -294,14 +292,13 @@ 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); - // Allow username updates in the pre-game lobby - if (!this._hasStarted && newUsername !== undefined) { - client.username = newUsername; - } - client.ws = ws; this.addListeners(client); this.startLobbyInfoBroadcast(); @@ -662,6 +659,7 @@ export class GameServer { config: this.gameConfig, players: this.activeClients.map((c) => ({ username: c.username, + clanTag: c.clanTag ?? null, clientID: c.clientID, cosmetics: c.cosmetics, isLobbyCreator: this.lobbyCreatorID === c.clientID, @@ -873,6 +871,7 @@ export class GameServer { gameID: this.id, clients: this.activeClients.map((c) => ({ username: c.username, + clanTag: c.clanTag ?? null, clientID: c.clientID, })), lobbyCreatorClientID: this.lobbyCreatorID, @@ -983,11 +982,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 b0584232d..7a2f52518 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", @@ -72,7 +72,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: @@ -80,36 +80,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( - username: string, - matcher: RegExpMatcher, -): string { - const clanTag = getClanTagOriginalCase(username); - const nameWithoutClan = clanTag - ? username.replace(`[${clanTag}]`, "").trim() +function censorWithMatcher( + username: string, + clanTag: string | null, + matcher: RegExpMatcher, +): { 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 censoredClanTag = + clanTag && !clanTagIsProfane ? clanTag.toUpperCase() : null; - 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}`; - } - - return censoredName; + return { username: censoredName, clanTag: censoredClanTag }; } type CosmeticResult = @@ -118,7 +110,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 { @@ -217,8 +212,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); } } @@ -230,8 +228,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 8e19c3474..68e3bcb61 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -358,20 +358,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 { clanTag: censoredClanTag, username: censoredUsername } = + privilegeRefresher + .get() + .censor(clientMsg.username, clientMsg.clanTag ?? null); + // Try to reconnect an existing client (e.g., page refresh) - // If successful, skip all authorization (but pass updated username - // so players can rename in the pre-game lobby) - const censoredUsername = privilegeRefresher - .get() - .censorUsername(clientMsg.username); + // If successful, skip all authorization if ( - gm.rejoinClient( - ws, - persistentId, - clientMsg.gameID, - 0, - censoredUsername, - ) + gm.rejoinClient(ws, persistentId, clientMsg.gameID, 0, { + username: censoredUsername, + clanTag: censoredClanTag, + }) ) { return; } @@ -463,7 +464,7 @@ export async function startWorker() { flares, ip, censoredUsername, - clientMsg.username, + censoredClanTag, ws, cosmeticResult.cosmetics, ); diff --git a/tests/Censor.test.ts b/tests/Censor.test.ts index 4c5253d72..7faf9bd98 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 d8ba217e4..104a4b6a4 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 37abb76a3..f77350078 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: { @@ -69,6 +69,7 @@ describe("Ranking class", () => { { clientID: "p2", username: "Bob", + clanTag: null, stats: { units: { city: [2n, 0n, 0n, 2n] }, conquests: [8n], @@ -84,6 +85,7 @@ describe("Ranking class", () => { { clientID: "p3", username: "Charlie", + clanTag: null, stats: { // no units, but has conquests/killedAt to count as played conquests: [8n], diff --git a/tests/NationCounterWarshipInfestation.test.ts b/tests/NationCounterWarshipInfestation.test.ts index ecb9858bb..ceef241c4 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 abaeb60aa..0264620ca 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 c1fa8b559..20ee5d4c0 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]", () => { + describe("clanTag from explicit clanTag parameter", () => { + test("should set clanTag from clanTag parameter", () => { const playerInfo = new PlayerInfo( - "[CL]PlayerName", + "PlayerName", PlayerType.Human, null, "player_id", + false, + "abc", ); - expect(playerInfo.clan).toBe("CL"); + expect(playerInfo.clanTag).toBe("abc"); }); - test("should extract clan from name when format contains [XXX]", () => { + test("should preserve already-uppercase clan tag", () => { const playerInfo = new PlayerInfo( - "[ABC]PlayerName", + "PlayerName", PlayerType.Human, null, "player_id", + false, + "CLAN", ); - expect(playerInfo.clan).toBe("ABC"); + expect(playerInfo.clanTag).toBe("CLAN"); }); - 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 [", () => { + test("should set clan to null when clanTag is not provided", () => { const playerInfo = new PlayerInfo( "PlayerName", PlayerType.Human, null, "player_id", ); - expect(playerInfo.clan).toBeNull(); + expect(playerInfo.clanTag).toBeNull(); }); - test("should return null when name doesn't contain ]", () => { + test("should set clan to null when clanTag is null", () => { const playerInfo = new PlayerInfo( - "[ABCPlayerName", + "PlayerName", + PlayerType.Human, + null, + "player_id", + false, + null, + ); + expect(playerInfo.clanTag).toBeNull(); + }); + + test("should set clan to null when clanTag is undefined", () => { + const playerInfo = new PlayerInfo( + "PlayerName", + PlayerType.Human, + null, + "player_id", + false, + undefined, + ); + expect(playerInfo.clanTag).toBeNull(); + }); + }); + + describe("displayName", () => { + test("should construct display name with clan tag", () => { + const playerInfo = new PlayerInfo( + "PlayerName", + PlayerType.Human, + null, + "player_id", + false, + "CLAN", + ); + expect(playerInfo.displayName).toBe("[CLAN] PlayerName"); + }); + + test("should return just name when no clan tag", () => { + const playerInfo = new PlayerInfo( + "PlayerName", PlayerType.Human, null, "player_id", ); - expect(playerInfo.clan).toBeNull(); + expect(playerInfo.displayName).toBe("PlayerName"); }); - test("should return null when clan tag is not 2-5 alphanumeric letters", () => { + test("should preserve clan tag casing in display name", () => { const playerInfo = new PlayerInfo( - "[A]PlayerName", + "PlayerName", PlayerType.Human, null, "player_id", + false, + "abc", ); - 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", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("AB1CD"); - }); - - test("should extract only the first occurrence of a valid clan name match and extract as uppercase", () => { - const playerInfo = new PlayerInfo( - "[Ab1cDEF]Player[aa]Name", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("AA"); - }); - - test("should extract numeric-only clan names", () => { - const playerInfo = new PlayerInfo( - "[012]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, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("012"); - }); - - test("should extract numeric-only clan names from anywhere within the name", () => { - const playerInfo = new PlayerInfo( - "Player[012]Name", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("012"); - }); - - test("should extract numeric-only clan names from the end of the name", () => { - const playerInfo = new PlayerInfo( - "PlayerName[012]", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("012"); - }); - - test("should extract uppercase alphanumeric clan names from anywhere within the name", () => { - const playerInfo = new PlayerInfo( - "Player[0a1B2]Name", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("0A1B2"); - }); - - test("should extract uppercase alphanumeric clan names from the end of the name", () => { - const playerInfo = new PlayerInfo( - "PlayerName[0a1B2]", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("0A1B2"); + expect(playerInfo.displayName).toBe("[abc] PlayerName"); }); }); }); diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts index e3acc62b3..97d5922c2 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 c3e11671b..999f02592 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 ea87feed3..e5a37bfef 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;