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;