mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 08:20:50 +00:00
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:
@@ -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,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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -52,6 +52,7 @@ export async function createGameRunner(
|
||||
p.clientID,
|
||||
random.nextID(),
|
||||
p.isLobbyCreator ?? false,
|
||||
p.clanTag,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+16
-7
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user