Clan System Part 1 (#3276)

## Description:

Properly split out clantags and usernames, a clantag should not be part
of a username.

<img width="285" height="286" alt="image"
src="https://github.com/user-attachments/assets/8ac56e82-b12c-4fc0-9774-e445252a6e61"
/>

https://api.openfront.dev/game/ojkqZFb2


<img width="296" height="596" alt="image"
src="https://github.com/user-attachments/assets/85152f80-c111-4f87-b85b-8516c9c6137b"
/>


https://api.openfront.dev/game/MF32BkVc


requires;
https://github.com/openfrontio/infra/pull/264

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

w.o.n
This commit is contained in:
Ryan
2026-03-17 22:55:47 +00:00
committed by GitHub
parent 9785666b98
commit 1049b7e7dc
47 changed files with 507 additions and 531 deletions
+3
View File
@@ -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],
},
+4 -15
View File
@@ -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}
></player-row>
`,
)}
@@ -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);
+5 -14
View File
@@ -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() {
+1 -2
View File
@@ -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) {
+6 -2
View File
@@ -732,6 +732,10 @@ class Client {
private async handleJoinLobby(event: CustomEvent<JoinLobbyEvent>) {
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,
},
+2 -4
View File
@@ -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(),
},
],
+1
View File
@@ -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(),
+60 -63
View File
@@ -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();
} else {
this.clanTag = "";
this.baseUsername = fullUsername;
}
private loadStoredUsername() {
const storedUsername = localStorage.getItem(usernameKey);
if (storedUsername) {
this.clanTag = localStorage.getItem(clanTagKey) ?? "";
this.baseUsername = storedUsername;
this.validateAndStore();
} else {
this.baseUsername = genAnonUsername();
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"
/>
<input
@@ -85,6 +89,7 @@ export class UsernameInput extends LitElement {
.value=${this.baseUsername}
@input=${this.handleUsernameChange}
placeholder="${translateText("username.enter_username")}"
minlength="${MIN_USERNAME_LENGTH}"
maxlength="${MAX_USERNAME_LENGTH}"
class="flex-1 min-w-0 border-0 text-2xl font-medium tracking-wider text-left text-white placeholder-white/70 focus:outline-none focus:ring-0 overflow-x-auto whitespace-nowrap text-ellipsis pr-2 bg-transparent"
/>
@@ -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 {
+20 -13
View File
@@ -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`<div
class="px-2 py-1 rounded-sm bg-gray-700/70 mb-1 text-xs text-white"
>
@@ -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`<span class="player-tag">
<span class="text-white">${displayName}</span>
${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` <div
class="bg-gray-700/70 px-2 py-1 rounded-sm text-xs flex items-center justify-between"
>
@@ -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);
}
}
@@ -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,
@@ -220,7 +220,7 @@ export class PlayerRow extends LitElement {
private renderPlayerName() {
return html`
<div class="flex gap-1 items-center w-50 shrink-0">
${this.player.tag ? this.renderTag(this.player.tag) : ""}
${this.player.clanTag ? this.renderTag(this.player.clanTag) : ""}
<div
class="text-xs sm:text-sm font-bold tracking-wide text-white/80 text-ellipsis w-37.5 shrink-0 overflow-hidden whitespace-nowrap"
>
@@ -249,9 +249,7 @@ export class LeaderboardPlayerList extends LitElement {
</div>`
: ""}
<span class="font-bold text-blue-300 truncate text-base"
>${player.clanTag
? player.username.replace(/^\[.*?\]\s*/, "")
: player.username}</span
>${player.username}</span
>
</div>
</td>
@@ -434,14 +432,18 @@ export class LeaderboardPlayerList extends LitElement {
"leaderboard_modal.your_ranking",
)}</span
>
<span class="font-bold text-white text-base"
>${this.currentUserEntry.clanTag
? this.currentUserEntry.username.replace(
/^\[.*?\]\s*/,
"",
)
: this.currentUserEntry.username}</span
<div class="flex items-center gap-2">
${this.currentUserEntry.clanTag
? html`<div
class="px-2 py-0.5 rounded bg-blue-500/10 border border-blue-300/40 text-[10px] font-bold text-blue-100 shrink-0"
>
${this.currentUserEntry.clanTag}
</div>`
: ""}
<span class="font-bold text-white text-base"
>${this.currentUserEntry.username}</span
>
</div>
</div>
<div class="flex flex-col items-end w-20">
<div class="font-mono text-white font-bold text-lg">
+1 -1
View File
@@ -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 {
+4 -4
View File
@@ -235,7 +235,7 @@ export class AttacksDisplay extends LitElement implements Layer {
<span class="truncate ml-1"
>${(
this.game.playerBySmallID(attack.attackerID) as PlayerView
)?.name()}</span
)?.displayName()}</span
>
${attack.retreating
? `(${translateText("events_display.retreating")}...)`
@@ -282,7 +282,7 @@ export class AttacksDisplay extends LitElement implements Layer {
<span class="truncate ml-1"
>${(
this.game.playerBySmallID(attack.targetID) as PlayerView
)?.name()}</span
)?.displayName()}</span
> `,
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())}</span
>
<span class="truncate text-xs ml-1"
>${boat.owner()?.name()}</span
>${boat.owner()?.displayName()}</span
>`,
onClick: () => this.eventBus.emit(new GoToUnitEvent(boat)),
className:
+6 -5
View File
@@ -147,7 +147,7 @@ export class ChatModal extends LitElement {
.toHex()};"
@click=${() => this.selectPlayer(player)}
>
${player.name()}
${player.displayName()}
</button>
`,
)}
@@ -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];
}
+9 -9
View File
@@ -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,
+2 -2
View File
@@ -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`;
@@ -380,7 +380,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
src=${"/flags/" + player.cosmetics.flag! + ".svg"}
/>`
: html``}
<span>${player.name()}</span>
<span>${player.displayName()}</span>
${playerTeam !== "" && player.type() !== PlayerType.Bot
? html`<div class="flex flex-col leading-tight">
<span class="text-gray-400 text-xs font-normal"
@@ -488,7 +488,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
return html`
<div class="p-2">
<div class="font-bold mb-1 ${isAlly ? "text-green-500" : "text-white"}">
${unit.owner().name()}
${unit.owner().displayName()}
</div>
<div class="mt-1">
<div class="text-sm opacity-80">${unit.type()}</div>
@@ -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 {
>
<div
class="text-sm font-semibold text-zinc-100 truncate"
title=${other.name()}
title=${other.displayName()}
>
${other.name()}
${other.displayName()}
</div>
</div>
+5 -5
View File
@@ -505,9 +505,9 @@ export class PlayerPanel extends LitElement implements Layer {
<div class="flex-1 min-w-0">
<h2
class="text-xl font-bold tracking-[-0.01em] text-zinc-50 truncate"
title=${other.name()}
title=${other.displayName()}
>
${other.name()}
${other.displayName()}
</h2>
</div>
${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()}
>
<span class="truncate">${p.name()}</span>
<span class="truncate">${p.displayName()}</span>
</li>`,
)}
</ul>
+1 -1
View File
@@ -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;
}
+18 -5
View File
@@ -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<typeof PlayerProfileSchema>;
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
+1
View File
@@ -52,6 +52,7 @@ export async function createGameRunner(
p.clientID,
random.nextID(),
p.isLobbyCreator ?? false,
p.clanTag,
);
});
+16 -7
View File
@@ -141,9 +141,21 @@ export type PublicGameType = z.infer<typeof PublicGameTypeSchema>;
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<typeof PlayerRecordSchema>;
+5 -17
View File
@@ -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);
}
+1 -1
View File
@@ -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(),
);
+2 -2
View File
@@ -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(),
);
+4 -4
View File
@@ -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[];
+24 -17
View File
@@ -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,16 +757,29 @@ 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(
player = new PlayerView(
this,
pu,
gu.playerNameViewData[pu.id],
@@ -780,8 +787,8 @@ export class GameView implements GameMap {
this._cosmetics.get(pu.clientID ?? "") ??
this._cosmetics.get(pu.name) ??
{},
),
);
this._players.set(pu.id, player);
}
});
+6 -15
View File
@@ -84,9 +84,6 @@ export class PlayerImpl implements Player {
public _units: Unit[] = [];
public _tiles: Set<TileRef> = 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;
}
+8 -8
View File
@@ -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;
+28 -1
View File
@@ -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 };
}
+1 -1
View File
@@ -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,
) {}
+2 -2
View File
@@ -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(
+11 -4
View File
@@ -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[] = [];
+11 -12
View File
@@ -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;
},
);
+32 -32
View File
@@ -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);
}
}
+14 -13
View File
@@ -358,20 +358,21 @@ export async function startWorker() {
return;
}
// 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
// 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()
.censorUsername(clientMsg.username);
.censor(clientMsg.username, clientMsg.clanTag ?? null);
// Try to reconnect an existing client (e.g., page refresh)
// If successful, skip all authorization
if (
gm.rejoinClient(
ws,
persistentId,
clientMsg.gameID,
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,
);
+32
View File
@@ -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);
});
});
});
+6 -2
View File
@@ -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(
+3 -1
View File
@@ -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],
+12 -4
View File
@@ -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(
+6 -2
View File
@@ -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",
+59 -175
View File
@@ -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");
});
});
});
+40 -31
View File
@@ -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);
});
});
});
+3 -2
View File
@@ -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,
);
};
@@ -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;