From b045608c89dc542c8c859eb0265903c1b3ea7fd8 Mon Sep 17 00:00:00 2001 From: HulKiora <111693579+hkio120@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:59:34 +0100 Subject: [PATCH 01/29] ui: reduce HUD transparency for control, attacks, events, and hover panel (#3429) ## Description: Reduce HUD transparency for better readability by making the Control Panel, Attacks panel, Events/Chat panel, and Hover panel more opaque while keeping a subtle blur effect. ## Please complete the following: - [ x] I have added screenshots for all UI updates - [ ] I process any text displayed to the user through translateText() and I've added it to the en.json file - [ ] 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: HulKiora Before : image After : image --- index.html | 2 +- src/client/graphics/layers/AttacksDisplay.ts | 10 +++++----- src/client/graphics/layers/EventsDisplay.ts | 6 +++--- src/client/graphics/layers/GameLeftSidebar.ts | 2 +- src/client/graphics/layers/GameRightSidebar.ts | 2 +- src/client/graphics/layers/PlayerInfoOverlay.ts | 2 +- src/client/graphics/layers/ReplayPanel.ts | 2 +- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/index.html b/index.html index f6a07f4ed..ab3609a02 100644 --- a/index.html +++ b/index.html @@ -284,7 +284,7 @@ class="w-full pointer-events-auto order-1 sm:order-none" >
diff --git a/src/client/graphics/layers/AttacksDisplay.ts b/src/client/graphics/layers/AttacksDisplay.ts index b76c435b7..597985d37 100644 --- a/src/client/graphics/layers/AttacksDisplay.ts +++ b/src/client/graphics/layers/AttacksDisplay.ts @@ -222,7 +222,7 @@ export class AttacksDisplay extends LitElement implements Layer { return this.incomingAttacks.map( (attack) => html`
${this.renderButton({ content: html` html`
${this.renderButton({ content: html` html`
${this.renderButton({ content: html` html`
${this.renderButton({ content: html`${this.renderBoatIcon(boat)} @@ -401,7 +401,7 @@ export class AttacksDisplay extends LitElement implements Layer { return this.incomingBoats.map( (boat) => html`
${this.renderButton({ content: html`${this.renderBoatIcon(boat)} diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index a45e57e84..76a9b24c1 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -809,7 +809,7 @@ export class EventsDisplay extends LitElement implements Layer { `, onClick: this.toggleHidden, className: - "text-white cursor-pointer pointer-events-auto w-fit p-2 lg:p-3 min-[1200px]:rounded-lg sm:rounded-tl-lg bg-gray-800/70 backdrop-blur-xs", + "text-white cursor-pointer pointer-events-auto w-fit p-2 lg:p-3 min-[1200px]:rounded-lg sm:rounded-tl-lg bg-gray-800/92 backdrop-blur-sm", })}
` @@ -820,7 +820,7 @@ export class EventsDisplay extends LitElement implements Layer { >
@@ -864,7 +864,7 @@ export class EventsDisplay extends LitElement implements Layer {
e.preventDefault()} diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index d936f921e..fe9ed185a 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -524,7 +524,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer { @contextmenu=${(e: MouseEvent) => e.preventDefault()} >
${this.player !== null ? this.renderPlayerInfo(this.player) : ""} ${this.unit !== null ? this.renderUnitInfo(this.unit) : ""} diff --git a/src/client/graphics/layers/ReplayPanel.ts b/src/client/graphics/layers/ReplayPanel.ts index fbef9051d..ff289fd19 100644 --- a/src/client/graphics/layers/ReplayPanel.ts +++ b/src/client/graphics/layers/ReplayPanel.ts @@ -68,7 +68,7 @@ export class ReplayPanel extends LitElement implements Layer { return html`
e.preventDefault()} >
+ + + + + + + @@ -434,14 +432,18 @@ export class LeaderboardPlayerList extends LitElement { "leaderboard_modal.your_ranking", )} - ${this.currentUserEntry.clanTag - ? this.currentUserEntry.username.replace( - /^\[.*?\]\s*/, - "", - ) - : this.currentUserEntry.username} +
+ ${this.currentUserEntry.clanTag + ? html`
+ ${this.currentUserEntry.clanTag} +
` + : ""} + ${this.currentUserEntry.username} +
diff --git a/src/client/graphics/NameBoxCalculator.ts b/src/client/graphics/NameBoxCalculator.ts index fa2165512..ea26ce122 100644 --- a/src/client/graphics/NameBoxCalculator.ts +++ b/src/client/graphics/NameBoxCalculator.ts @@ -52,7 +52,7 @@ export function placeName(game: Game, player: Player): NameViewData { ), ); - const fontSize = calculateFontSize(largestRectangle, player.name()); + const fontSize = calculateFontSize(largestRectangle, player.displayName()); center = new Cell(center.x, center.y - fontSize / 3); return { diff --git a/src/client/graphics/layers/AttacksDisplay.ts b/src/client/graphics/layers/AttacksDisplay.ts index b76c435b7..6c52c085a 100644 --- a/src/client/graphics/layers/AttacksDisplay.ts +++ b/src/client/graphics/layers/AttacksDisplay.ts @@ -235,7 +235,7 @@ export class AttacksDisplay extends LitElement implements Layer { ${( this.game.playerBySmallID(attack.attackerID) as PlayerView - )?.name()} ${attack.retreating ? `(${translateText("events_display.retreating")}...)` @@ -282,7 +282,7 @@ export class AttacksDisplay extends LitElement implements Layer { ${( this.game.playerBySmallID(attack.targetID) as PlayerView - )?.name()} `, onClick: async () => this.attackWarningOnClick(attack), className: @@ -346,7 +346,7 @@ export class AttacksDisplay extends LitElement implements Layer { const ownerID = this.game.ownerID(target); if (ownerID === 0) return ""; const player = this.game.playerBySmallID(ownerID) as PlayerView; - return player?.name() ?? ""; + return player?.displayName() ?? ""; } private renderBoatIcon(boat: UnitView) { @@ -409,7 +409,7 @@ export class AttacksDisplay extends LitElement implements Layer { >${renderTroops(boat.troops())} ${boat.owner()?.name()}${boat.owner()?.displayName()}`, onClick: () => this.eventBus.emit(new GoToUnitEvent(boat)), className: diff --git a/src/client/graphics/layers/ChatModal.ts b/src/client/graphics/layers/ChatModal.ts index 5f673e2f9..9448c5ec8 100644 --- a/src/client/graphics/layers/ChatModal.ts +++ b/src/client/graphics/layers/ChatModal.ts @@ -147,7 +147,7 @@ export class ChatModal extends LitElement { .toHex()};" @click=${() => this.selectPlayer(player)} > - ${player.name()} + ${player.displayName()} `, )} @@ -216,7 +216,8 @@ export class ChatModal extends LitElement { private selectPlayer(player: PlayerView) { if (this.previewText) { this.previewText = - this.selectedPhraseTemplate?.replace("[P1]", player.name()) ?? null; + this.selectedPhraseTemplate?.replace("[P1]", player.displayName()) ?? + null; this.selectedPlayer = player; this.requiresPlayerSelection = false; this.requestUpdate(); @@ -255,13 +256,13 @@ export class ChatModal extends LitElement { private getSortedFilteredPlayers(): PlayerView[] { const sorted = [...this.players].sort((a, b) => - a.name().localeCompare(b.name()), + a.displayName().localeCompare(b.displayName()), ); const filtered = sorted.filter((p) => - p.name().toLowerCase().includes(this.playerSearchQuery), + p.displayName().toLowerCase().includes(this.playerSearchQuery), ); const others = sorted.filter( - (p) => !p.name().toLowerCase().includes(this.playerSearchQuery), + (p) => !p.displayName().toLowerCase().includes(this.playerSearchQuery), ); return [...filtered, ...others]; } diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index a45e57e84..6a76c956d 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -283,7 +283,7 @@ export class EventsDisplay extends LitElement implements Layer { this.addEvent({ description: translateText("events_display.about_to_expire", { - name: other.name(), + name: other.displayName(), }), type: MessageType.RENEW_ALLIANCE, duration: this.game.config().allianceExtensionPromptOffset() - 3 * 10, // 3 second buffer @@ -296,7 +296,7 @@ export class EventsDisplay extends LitElement implements Layer { }, { text: translateText("events_display.renew_alliance", { - name: other.name(), + name: other.displayName(), }), className: "btn", action: () => @@ -460,7 +460,7 @@ export class EventsDisplay extends LitElement implements Layer { this.addEvent({ description: translateText("events_display.request_alliance", { - name: requestor.name(), + name: requestor.displayName(), }), buttons: [ { @@ -525,7 +525,7 @@ export class EventsDisplay extends LitElement implements Layer { ) as PlayerView; this.addEvent({ description: translateText("events_display.alliance_request_status", { - name: recipient.name(), + name: recipient.displayName(), status: update.accepted ? translateText("events_display.alliance_accepted") : translateText("events_display.alliance_rejected"), @@ -569,7 +569,7 @@ export class EventsDisplay extends LitElement implements Layer { this.addEvent({ description: translateText("events_display.betrayal_description", { - name: betrayed.name(), + name: betrayed.displayName(), malusPercent: malusPercent, durationText: durationText, }), @@ -589,7 +589,7 @@ export class EventsDisplay extends LitElement implements Layer { ]; this.addEvent({ description: translateText("events_display.betrayed_you", { - name: traitor.name(), + name: traitor.displayName(), }), type: MessageType.ALLIANCE_BROKEN, highlight: true, @@ -616,7 +616,7 @@ export class EventsDisplay extends LitElement implements Layer { this.addEvent({ description: translateText("events_display.alliance_expired", { - name: other.name(), + name: other.displayName(), }), type: MessageType.ALLIANCE_EXPIRED, highlight: true, @@ -641,8 +641,8 @@ export class EventsDisplay extends LitElement implements Layer { this.addEvent({ description: translateText("events_display.attack_request", { - name: other.name(), - target: target.name(), + name: other.displayName(), + target: target.displayName(), }), type: MessageType.ATTACK_REQUEST, highlight: true, diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index 9f49f6f26..ac0b973f9 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -239,7 +239,7 @@ export class NameLayer implements Layer { const nameSpan = document.createElement("span"); nameSpan.className = "player-name-span"; - nameSpan.innerHTML = player.name(); + nameSpan.textContent = player.displayName(); nameDiv.appendChild(nameSpan); element.appendChild(nameDiv); @@ -338,7 +338,7 @@ export class NameLayer implements Layer { nameDiv.style.color = render.fontColor; const span = nameDiv.querySelector(".player-name-span"); if (span) { - span.innerHTML = render.player.name(); + span.textContent = render.player.displayName(); } if (flagDiv) { flagDiv.style.height = `${render.fontSize}px`; diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index d936f921e..ac12f4c34 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -380,7 +380,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer { src=${"/flags/" + player.cosmetics.flag! + ".svg"} />` : html``} - ${player.name()} + ${player.displayName()} ${playerTeam !== "" && player.type() !== PlayerType.Bot ? html`
- ${unit.owner().name()} + ${unit.owner().displayName()}
${unit.type()}
diff --git a/src/client/graphics/layers/PlayerModerationModal.ts b/src/client/graphics/layers/PlayerModerationModal.ts index c51f9efc1..e08230ce2 100644 --- a/src/client/graphics/layers/PlayerModerationModal.ts +++ b/src/client/graphics/layers/PlayerModerationModal.ts @@ -65,7 +65,7 @@ export class PlayerModerationModal extends LitElement { if (!targetClientID || targetClientID.length === 0) return; const confirmed = confirm( - translateText("player_panel.kick_confirm", { name: other.name() }), + translateText("player_panel.kick_confirm", { name: other.displayName() }), ); if (!confirmed) return; @@ -142,9 +142,9 @@ export class PlayerModerationModal extends LitElement { >
- ${other.name()} + ${other.displayName()}
diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index f1df0750d..0fb3b304f 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -505,9 +505,9 @@ export class PlayerPanel extends LitElement implements Layer {

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

${chip @@ -626,7 +626,7 @@ export class PlayerPanel extends LitElement implements Layer { const nameCollator = new Intl.Collator(undefined, { sensitivity: "base" }); const alliesSorted = [...allies].sort((a, b) => - nameCollator.compare(a.name(), b.name()), + nameCollator.compare(a.displayName(), b.displayName()), ); return html` @@ -669,9 +669,9 @@ export class PlayerPanel extends LitElement implements Layer { rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-[14px] text-zinc-100 hover:bg-white/8 active:scale-[0.99] transition" - title=${p.name()} + title=${p.displayName()} > - ${p.name()} + ${p.displayName()} `, )} diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts index e4fcd3286..d47ec4fe2 100644 --- a/src/client/graphics/layers/WinModal.ts +++ b/src/client/graphics/layers/WinModal.ts @@ -334,7 +334,7 @@ export class WinModal extends LitElement implements Layer { crazyGamesSDK.happytime(); } else { this._title = translateText("win_modal.other_won", { - player: winner.name(), + player: winner.displayName(), }); this.isWin = false; } diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 32153359f..3076fe4c5 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -1,8 +1,21 @@ import { z } from "zod"; import { base64urlToUuid } from "./Base64"; +import { ClanTagSchema } from "./Schemas"; import { BigIntStringSchema, PlayerStatsSchema } from "./StatsSchemas"; import { Difficulty, GameMode, GameType, RankedType } from "./game/Game"; +function stripClanTagFromUsername(username: string): string { + return username.replace(/^\s*\[[a-zA-Z0-9]{2,5}\]\s*/u, "").trim(); +} + +// Historical leaderboard rows can include legacy usernames +// that predate current strict join-time validation rules. +const LeaderboardUsernameSchema = z + .string() + .transform(stripClanTagFromUsername) + .pipe(z.string().min(1).max(64)); +const LeaderboardClanTagSchema = ClanTagSchema.unwrap(); + export const RefreshResponseSchema = z.object({ token: z.string(), }); @@ -114,7 +127,7 @@ export const PlayerProfileSchema = z.object({ export type PlayerProfile = z.infer; export const ClanLeaderboardEntrySchema = z.object({ - clanTag: z.string(), + clanTag: LeaderboardClanTagSchema, games: z.number(), wins: z.number(), losses: z.number(), @@ -137,8 +150,8 @@ export type ClanLeaderboardResponse = z.infer< export const PlayerLeaderboardEntrySchema = z.object({ rank: z.number(), playerId: z.string(), - username: z.string(), - clanTag: z.string().optional(), + username: LeaderboardUsernameSchema, + clanTag: LeaderboardClanTagSchema.nullable().optional(), flag: z.string().optional(), elo: z.number(), games: z.number(), @@ -166,8 +179,8 @@ export const RankedLeaderboardEntrySchema = z.object({ total: z.number(), public_id: z.string(), user: DiscordUserSchema.nullable().optional(), - username: z.string(), - clanTag: z.string().nullable().optional(), + username: LeaderboardUsernameSchema, + clanTag: LeaderboardClanTagSchema.nullable().optional(), }); export type RankedLeaderboardEntry = z.infer< typeof RankedLeaderboardEntrySchema diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index ddf4da20d..c5ad6477f 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -52,6 +52,7 @@ export async function createGameRunner( p.clientID, random.nextID(), p.isLobbyCreator ?? false, + p.clanTag, ); }); diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 07b7f263e..e0dbdd5fc 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -141,9 +141,21 @@ export type PublicGameType = z.infer; export const PublicGameTypeSchema = z.enum(["ffa", "team", "special"]); +export const UsernameSchema = z + .string() + .regex(/^(?=.*\S)[a-zA-Z0-9_ üÜ.]+$/u) + .min(3) + .max(27); + +export const ClanTagSchema = z + .string() + .regex(/^[a-zA-Z0-9]{2,5}$/) + .nullable(); + const ClientInfoSchema = z.object({ clientID: z.string(), - username: z.string(), + username: UsernameSchema, + clanTag: ClanTagSchema, }); export const GameInfoSchema = z.object({ @@ -179,6 +191,7 @@ export class LobbyInfoEvent implements GameEvent { export interface ClientInfo { clientID: ClientID; username: string; + clanTag: string | null; } export enum LogSeverity { Debug = "DEBUG", @@ -279,11 +292,6 @@ export const ID = z.string().regex(GAME_ID_REGEX); export const AllPlayersStatsSchema = z.record(ID, PlayerStatsSchema); -export const UsernameSchema = z - .string() - .regex(/^[a-zA-Z0-9_ [\]üÜ.]+$/u) - .min(3) - .max(27); const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code); export const QuickChatKeySchema = z.enum( @@ -510,6 +518,7 @@ export const PlayerCosmeticsSchema = z.object({ export const PlayerSchema = z.object({ clientID: ID, username: UsernameSchema, + clanTag: ClanTagSchema, cosmetics: PlayerCosmeticsSchema.optional(), isLobbyCreator: z.boolean().optional(), }); @@ -630,6 +639,7 @@ export const ClientJoinMessageSchema = z.object({ token: TokenSchema, // WARNING: PII - server extracts persistentID from this gameID: ID, username: UsernameSchema, + clanTag: ClanTagSchema, // Server replaces the refs with the actual cosmetic data. cosmetics: PlayerCosmeticRefsSchema.optional(), turnstileToken: z.string().nullable(), @@ -659,7 +669,6 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [ export const PlayerRecordSchema = PlayerSchema.extend({ persistentID: PersistentIdSchema.nullable(), // WARNING: PII - clanTag: z.string().optional(), stats: PlayerStatsSchema, }); export type PlayerRecord = z.infer; diff --git a/src/core/Util.ts b/src/core/Util.ts index 6273824ad..d099e0197 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -340,29 +340,17 @@ export function sigmoid( return 1 / (1 + Math.exp(-decayRate * (value - midpoint))); } -// Compute clan from name -export function getClanTag(name: string): string | null { - const clanTag = clanMatch(name); - return clanTag ? clanTag[1].toUpperCase() : null; -} - -export function getClanTagOriginalCase(name: string): string | null { - const clanTag = clanMatch(name); - return clanTag ? clanTag[1] : null; +export function formatPlayerDisplayName( + username: string, + clanTag?: string | null, +): string { + return clanTag ? `[${clanTag}] ${username}` : username; } const CLAN_TAG_CHARS = "a-zA-Z0-9"; const CLAN_TAG_INVALID_CHARS = new RegExp(`[^${CLAN_TAG_CHARS}]`, "g"); -const CLAN_TAG_REGEX = new RegExp(`\\[([${CLAN_TAG_CHARS}]{2,5})\\]`); export function sanitizeClanTag(tag: string): string { return tag.replace(CLAN_TAG_INVALID_CHARS, "").substring(0, 5).toUpperCase(); } - -function clanMatch(name: string): RegExpMatchArray | null { - if (!name.includes("[") || !name.includes("]")) { - return null; - } - return name.match(CLAN_TAG_REGEX); -} diff --git a/src/core/execution/MIRVExecution.ts b/src/core/execution/MIRVExecution.ts index b840976b9..3fef4c894 100644 --- a/src/core/execution/MIRVExecution.ts +++ b/src/core/execution/MIRVExecution.ts @@ -90,7 +90,7 @@ export class MirvExecution implements Execution { this.mg.displayIncomingUnit( this.nuke.id(), // TODO TranslateText - `⚠️⚠️⚠️ ${this.player.name()} - MIRV INBOUND ⚠️⚠️⚠️`, + `⚠️⚠️⚠️ ${this.player.displayName()} - MIRV INBOUND ⚠️⚠️⚠️`, MessageType.MIRV_INBOUND, this.targetPlayer.id(), ); diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 2bf2055b7..9f443474f 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -150,7 +150,7 @@ export class NukeExecution implements Execution { this.mg.displayIncomingUnit( this.nuke.id(), // TODO TranslateText - `${this.player.name()} - atom bomb inbound`, + `${this.player.displayName()} - atom bomb inbound`, MessageType.NUKE_INBOUND, target.id(), ); @@ -158,7 +158,7 @@ export class NukeExecution implements Execution { this.mg.displayIncomingUnit( this.nuke.id(), // TODO TranslateText - `${this.player.name()} - hydrogen bomb inbound`, + `${this.player.displayName()} - hydrogen bomb inbound`, MessageType.HYDROGEN_BOMB_INBOUND, target.id(), ); diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index d77d46069..dcfd6cf21 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -2,7 +2,7 @@ import { Config } from "../configuration/Config"; import { AbstractGraph } from "../pathfinding/algorithms/AbstractGraph"; import { PathFinder } from "../pathfinding/types"; import { AllPlayersStats, ClientID } from "../Schemas"; -import { getClanTag } from "../Util"; +import { formatPlayerDisplayName } from "../Util"; import { GameMap, TileRef } from "./GameMap"; import { GameUpdate, @@ -503,7 +503,7 @@ export interface MutableAlliance extends Alliance { } export class PlayerInfo { - public readonly clan: string | null; + public readonly displayName: string; constructor( public readonly name: string, @@ -513,8 +513,9 @@ export class PlayerInfo { // TODO: make player id the small id public readonly id: PlayerID, public readonly isLobbyCreator: boolean = false, + public readonly clanTag: string | null = null, ) { - this.clan = getClanTag(name); + this.displayName = formatPlayerDisplayName(this.name, this.clanTag); } } @@ -706,7 +707,6 @@ export interface Player { // Either allied or on same team. isFriendly(other: Player, treatAFKFriendly?: boolean): boolean; team(): Team | null; - clan(): string | null; incomingAllianceRequests(): AllianceRequest[]; outgoingAllianceRequests(): AllianceRequest[]; alliances(): MutableAlliance[]; diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index d3e3ad87e..6db2a4857 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -4,7 +4,7 @@ import { Config } from "../configuration/Config"; import { ColorPalette } from "../CosmeticSchemas"; import { PatternDecoder } from "../PatternDecoder"; import { ClientID, GameID, Player, PlayerCosmetics } from "../Schemas"; -import { createRandomName } from "../Util"; +import { createRandomName, formatPlayerDisplayName } from "../Util"; import { WorkerClient } from "../worker/WorkerClient"; import { BuildableUnit, @@ -482,7 +482,7 @@ export class PlayerView { displayName(): string { return this.anonymousName !== null && userSettings.anonymousNames() ? this.anonymousName - : this.data.name; + : this.data.displayName; } clientID(): ClientID | null { @@ -659,21 +659,15 @@ export class GameView implements GameMap { private _mapData: TerrainMapData, private _myClientID: ClientID | undefined, private _myUsername: string, + private _myClanTag: string | null, private _gameID: GameID, - private humans: Player[], + humans: Player[], ) { this._map = this._mapData.gameMap; this.lastUpdate = null; this.unitGrid = new UnitGrid(this._map); - // Replace the local player's username with their own stored username. - // This way the user does not know they are being censored. - for (const h of this.humans) { - if (h.clientID === this._myClientID) { - h.username = this._myUsername; - } - } this._cosmetics = new Map( - this.humans.map((h) => [h.clientID, h.cosmetics ?? {}]), + humans.map((h) => [h.clientID, h.cosmetics ?? {}]), ); for (const nation of this._mapData.nations) { // Nations don't have client ids, so we use their name as the key instead. @@ -763,25 +757,38 @@ export class GameView implements GameMap { if (gu.updates === null) { throw new Error("lastUpdate.updates not initialized"); } + const myDisplayName = formatPlayerDisplayName( + this._myUsername, + this._myClanTag, + ); + gu.updates[GameUpdateType.Player].forEach((pu) => { + // Replace the local player's name/displayName with their own stored values. + // This way the user does not know they are being censored. + if (pu.clientID === this._myClientID) { + pu.name = this._myUsername; + pu.displayName = myDisplayName; + } + this.smallIDToID.set(pu.smallID, pu.id); - const player = this._players.get(pu.id); + let player = this._players.get(pu.id); if (player !== undefined) { player.data = pu; - player.nameData = gu.playerNameViewData[pu.id]; + const nextNameData = gu.playerNameViewData[pu.id]; + if (nextNameData !== undefined) { + player.nameData = nextNameData; + } } else { - this._players.set( - pu.id, - new PlayerView( - this, - pu, - gu.playerNameViewData[pu.id], - // First check human by clientID, then check nation by name. - this._cosmetics.get(pu.clientID ?? "") ?? - this._cosmetics.get(pu.name) ?? - {}, - ), + player = new PlayerView( + this, + pu, + gu.playerNameViewData[pu.id], + // First check human by clientID, then check nation by name. + this._cosmetics.get(pu.clientID ?? "") ?? + this._cosmetics.get(pu.name) ?? + {}, ); + this._players.set(pu.id, player); } }); diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index f8c5b88cb..3d915194c 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -84,9 +84,6 @@ export class PlayerImpl implements Player { public _units: Unit[] = []; public _tiles: Set = new Set(); - private _name: string; - private _displayName: string; - public pastOutgoingAllianceRequests: AllianceRequest[] = []; private _expiredAlliances: Alliance[] = []; @@ -115,10 +112,8 @@ export class PlayerImpl implements Player { startTroops: number, private readonly _team: Team | null, ) { - this._name = playerInfo.name; this._troops = toInt(startTroops); this._gold = mg.config().startingGold(playerInfo); - this._displayName = this._name; this._pseudo_random = new PseudoRandom(simpleHash(this.playerInfo.id)); } @@ -193,10 +188,10 @@ export class PlayerImpl implements Player { } name(): string { - return this._name; + return this.playerInfo.name; } displayName(): string { - return this._displayName; + return this.playerInfo.displayName; } clientID(): ClientID | null { @@ -211,10 +206,6 @@ export class PlayerImpl implements Player { return this.playerInfo.playerType; } - clan(): string | null { - return this.playerInfo.clan; - } - units(...types: UnitType[]): Unit[] { const len = types.length; if (len === 0) { @@ -760,14 +751,14 @@ export class PlayerImpl implements Player { MessageType.SENT_TROOPS_TO_PLAYER, this.id(), undefined, - { troops: renderTroops(troops), name: recipient.name() }, + { troops: renderTroops(troops), name: recipient.displayName() }, ); this.mg.displayMessage( "events_display.received_troops_from_player", MessageType.RECEIVED_TROOPS_FROM_PLAYER, recipient.id(), undefined, - { troops: renderTroops(troops), name: this.name() }, + { troops: renderTroops(troops), name: this.displayName() }, ); return true; } @@ -784,14 +775,14 @@ export class PlayerImpl implements Player { MessageType.SENT_GOLD_TO_PLAYER, this.id(), undefined, - { gold: renderNumber(gold), name: recipient.name() }, + { gold: renderNumber(gold), name: recipient.displayName() }, ); this.mg.displayMessage( "events_display.received_gold_from_player", MessageType.RECEIVED_GOLD_FROM_PLAYER, recipient.id(), gold, - { gold: renderNumber(gold), name: this.name() }, + { gold: renderNumber(gold), name: this.displayName() }, ); return true; } diff --git a/src/core/game/TeamAssignment.ts b/src/core/game/TeamAssignment.ts index 0251c4466..c8b8607e9 100644 --- a/src/core/game/TeamAssignment.ts +++ b/src/core/game/TeamAssignment.ts @@ -16,24 +16,24 @@ export function assignTeams( // Sort players into clan groups or no-clan list for (const player of players) { - if (player.clan) { - if (!clanGroups.has(player.clan)) { - clanGroups.set(player.clan, []); + const clanTag = player.clanTag; + if (clanTag) { + if (!clanGroups.has(clanTag)) { + clanGroups.set(clanTag, []); } - clanGroups.get(player.clan)!.push(player); + clanGroups.get(clanTag)!.push(player); } else { noClanPlayers.push(player); } } // Sort clans by size (largest first) - const sortedClans = Array.from(clanGroups.entries()).sort( - (a, b) => b[1].length - a[1].length, + const sortedClanPlayers = Array.from(clanGroups.values()).sort( + (a, b) => b.length - a.length, ); // First, assign clan players - // eslint-disable-next-line @typescript-eslint/no-unused-vars - for (const [_, clanPlayers] of sortedClans) { + for (const clanPlayers of sortedClanPlayers) { // Try to keep the clan together on the team with fewer players let team: Team | null = null; let teamSize = 0; diff --git a/src/core/validations/username.ts b/src/core/validations/username.ts index cb55390f2..15ac2660a 100644 --- a/src/core/validations/username.ts +++ b/src/core/validations/username.ts @@ -1,8 +1,10 @@ import { translateText } from "../../client/Utils"; -import { UsernameSchema } from "../Schemas"; +import { ClanTagSchema, UsernameSchema } from "../Schemas"; export const MIN_USERNAME_LENGTH = 3; export const MAX_USERNAME_LENGTH = 27; +export const MIN_CLAN_TAG_LENGTH = 2; +export const MAX_CLAN_TAG_LENGTH = 5; export function validateUsername(username: string): { isValid: boolean; @@ -44,3 +46,28 @@ export function validateUsername(username: string): { // All checks passed return { isValid: true }; } + +export function validateClanTag(clanTag: string): { + isValid: boolean; + error?: string; +} { + if (clanTag.length === 0) { + return { isValid: true }; + } + if (clanTag.length < MIN_CLAN_TAG_LENGTH) { + return { isValid: false, error: translateText("username.tag_too_short") }; + } + if (clanTag.length > MAX_CLAN_TAG_LENGTH) { + return { isValid: false, error: translateText("username.tag_too_short") }; + } + + const parsed = ClanTagSchema.safeParse(clanTag); + if (!parsed.success) { + return { + isValid: false, + error: translateText("username.tag_invalid_chars"), + }; + } + + return { isValid: true }; +} diff --git a/src/server/Client.ts b/src/server/Client.ts index 07b918db8..ca57acc3e 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -18,7 +18,7 @@ export class Client { public readonly flares: string[] | undefined, public readonly ip: string, public username: string, - public readonly uncensoredUsername: string, + public clanTag: string | null, public ws: WebSocket, public readonly cosmetics: PlayerCosmetics | undefined, ) {} diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index fde2678c2..11e2a87b9 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -46,11 +46,11 @@ export class GameManager { persistentID: string, gameID: GameID, lastTurn: number = 0, - newUsername?: string, + identityUpdate?: { username: string; clanTag: string | null }, ): boolean { const game = this.games.get(gameID); if (!game) return false; - return game.rejoinClient(ws, persistentID, lastTurn, newUsername); + return game.rejoinClient(ws, persistentID, lastTurn, identityUpdate); } createGame( diff --git a/src/server/GamePreviewBuilder.ts b/src/server/GamePreviewBuilder.ts index 93e8c0d29..217edefd1 100644 --- a/src/server/GamePreviewBuilder.ts +++ b/src/server/GamePreviewBuilder.ts @@ -1,10 +1,12 @@ import { z } from "zod"; -import { GameInfo } from "../core/Schemas"; +import { ClanTagSchema, GameInfo, UsernameSchema } from "../core/Schemas"; +import { formatPlayerDisplayName } from "../core/Util"; import { GameMode } from "../core/game/Game"; export const PlayerInfoSchema = z.object({ clientID: z.string().optional(), - username: z.string().optional(), + username: UsernameSchema.optional(), + clanTag: ClanTagSchema, stats: z.unknown().optional(), }); @@ -85,7 +87,10 @@ function parseWinner( if (!winnerArray || winnerArray.length < 2) return undefined; const idToName = new Map( - (players ?? []).map((p) => [p.clientID, p.username]), + (players ?? []).map((p) => [ + p.clientID, + p.username ? formatPlayerDisplayName(p.username, p.clanTag) : undefined, + ]), ); if (winnerArray[0] === "team" && winnerArray.length >= 3) { @@ -228,7 +233,9 @@ export function buildPreview( // Show host const hostClient = lobby.clients?.[0]; if (hostClient?.username) { - sections.push(`Host: ${hostClient.username}`); + sections.push( + `Host: ${formatPlayerDisplayName(hostClient.username, hostClient.clanTag)}`, + ); } const gameOptions: string[] = []; diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 49305432e..8a41d4edb 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -23,7 +23,7 @@ import { StampedIntent, Turn, } from "../core/Schemas"; -import { createPartialGameRecord, getClanTag } from "../core/Util"; +import { createPartialGameRecord } from "../core/Util"; import { archive, finalizeGameRecord } from "./Archive"; import { Client } from "./Client"; import { ClientMsgRateLimiter } from "./ClientMsgRateLimiter"; @@ -266,15 +266,13 @@ export class GameServer { } // Attempt to reconnect a client by persistentID. Returns true if successful. - // Only the WebSocket is updated — username, cosmetics, etc. are preserved - // from the original join to maintain consistency throughout the game session. - // Exception: in the pre-game lobby, the username is updated so players can - // rename between leaving and rejoining. + // WebSocket is always updated. Optional identity updates are applied only + // before the game has started. public rejoinClient( ws: WebSocket, persistentID: string, lastTurn: number = 0, - newUsername?: string, + identityUpdate?: { username: string; clanTag: string | null }, ): boolean { const clientID = this.getClientIdForPersistentId(persistentID); if (!clientID) return false; @@ -294,14 +292,13 @@ export class GameServer { (c) => c.clientID !== client.clientID, ); this.activeClients.push(client); + if (identityUpdate && !this.hasStarted()) { + client.username = identityUpdate.username; + client.clanTag = identityUpdate.clanTag; + } client.lastPing = Date.now(); this.markClientDisconnected(client.clientID, false); - // Allow username updates in the pre-game lobby - if (!this._hasStarted && newUsername !== undefined) { - client.username = newUsername; - } - client.ws = ws; this.addListeners(client); this.startLobbyInfoBroadcast(); @@ -662,6 +659,7 @@ export class GameServer { config: this.gameConfig, players: this.activeClients.map((c) => ({ username: c.username, + clanTag: c.clanTag ?? null, clientID: c.clientID, cosmetics: c.cosmetics, isLobbyCreator: this.lobbyCreatorID === c.clientID, @@ -873,6 +871,7 @@ export class GameServer { gameID: this.id, clients: this.activeClients.map((c) => ({ username: c.username, + clanTag: c.clanTag ?? null, clientID: c.clientID, })), lobbyCreatorClientID: this.lobbyCreatorID, @@ -983,11 +982,11 @@ export class GameServer { return { clientID: player.clientID, username: player.username, + clanTag: player.clanTag, persistentID: this.allClients.get(player.clientID)?.persistentID ?? "", stats, cosmetics: player.cosmetics, - clanTag: getClanTag(player.username) ?? undefined, } satisfies PlayerRecord; }, ); diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts index b0584232d..7a2f52518 100644 --- a/src/server/Privilege.ts +++ b/src/server/Privilege.ts @@ -18,7 +18,7 @@ import { PlayerCosmetics, PlayerPattern, } from "../core/Schemas"; -import { getClanTagOriginalCase, simpleHash } from "../core/Util"; +import { simpleHash } from "../core/Util"; export const shadowNames = [ "UnhuggedToday", @@ -72,7 +72,7 @@ export function createMatcher(bannedWords: string[]): RegExpMatcher { } /** - * Sanitizes and censors profane usernames and clan tags. + * Sanitizes and censors profane usernames and clan tags separately. * Profane username is overwritten, profane clan tag is removed. * * Removing bad clan tags won't hurt existing clans nor cause desyncs: @@ -80,36 +80,28 @@ export function createMatcher(bannedWords: string[]): RegExpMatcher { * - only each separate local player name with a profane clan tag will remain, no clan team assignment * * Examples: - * - "GoodName" -> "GoodName" - * - "BadName" -> "Censored" - * - "[CLAN]GoodName" -> "[CLAN]GoodName" - * - "[CLaN]BadName" -> "[CLAN] Censored" - * - "[BAD]GoodName" -> "GoodName" - * - "[BAD]BadName" -> "Censored" + * - username="GoodName", clanTag=null -> { username: "GoodName", clanTag: null } + * - username="BadName", clanTag=null -> { username: "Censored", clanTag: null } + * - username="GoodName", clanTag="CLaN" -> { username: "GoodName", clanTag: "CLAN" } + * - username="GoodName", clanTag="BAD" -> { username: "GoodName", clanTag: null } + * - username="BadName", clanTag="BAD" -> { username: "Censored", clanTag: null } */ -function censorUsernameWithMatcher( - username: string, - matcher: RegExpMatcher, -): string { - const clanTag = getClanTagOriginalCase(username); - const nameWithoutClan = clanTag - ? username.replace(`[${clanTag}]`, "").trim() +function censorWithMatcher( + username: string, + clanTag: string | null, + matcher: RegExpMatcher, +): { username: string; clanTag: string | null } { + const usernameIsProfane = matcher.hasMatch(username); + const censoredName = usernameIsProfane + ? shadowNames[simpleHash(username) % shadowNames.length] : username; const clanTagIsProfane = clanTag ? matcher.hasMatch(clanTag) : false; - const usernameIsProfane = matcher.hasMatch(nameWithoutClan); + const censoredClanTag = + clanTag && !clanTagIsProfane ? clanTag.toUpperCase() : null; - const censoredName = usernameIsProfane - ? shadowNames[simpleHash(nameWithoutClan) % shadowNames.length] - : nameWithoutClan; - - // Restore clan tag only if it's clean, otherwise remove it entirely - if (clanTag && !clanTagIsProfane) { - return `[${clanTag.toUpperCase()}] ${censoredName}`; - } - - return censoredName; + return { username: censoredName, clanTag: censoredClanTag }; } type CosmeticResult = @@ -118,7 +110,10 @@ type CosmeticResult = export interface PrivilegeChecker { isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult; - censorUsername(username: string): string; + censor( + username: string, + clanTag: string | null, + ): { username: string; clanTag: string | null }; } export class PrivilegeCheckerImpl implements PrivilegeChecker { @@ -217,8 +212,11 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker { return { color }; } - censorUsername(username: string): string { - return censorUsernameWithMatcher(username, this.matcher); + censor( + username: string, + clanTag: string | null, + ): { username: string; clanTag: string | null } { + return censorWithMatcher(username, clanTag, this.matcher); } } @@ -230,8 +228,10 @@ export class FailOpenPrivilegeChecker implements PrivilegeChecker { return { type: "allowed", cosmetics: {} }; } - censorUsername(username: string): string { - // Fail open: use matcher with just the built-in English profanity dataset - return censorUsernameWithMatcher(username, defaultMatcher); + censor( + username: string, + clanTag: string | null, + ): { username: string; clanTag: string | null } { + return censorWithMatcher(username, clanTag, defaultMatcher); } } diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 8e19c3474..68e3bcb61 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -358,20 +358,21 @@ export async function startWorker() { return; } + // Normalize username and clan tag before any rejoin/join handling. + // If this connection maps to an existing lobby client, we still want + // the latest pre-join identity to be reflected. + const { clanTag: censoredClanTag, username: censoredUsername } = + privilegeRefresher + .get() + .censor(clientMsg.username, clientMsg.clanTag ?? null); + // Try to reconnect an existing client (e.g., page refresh) - // If successful, skip all authorization (but pass updated username - // so players can rename in the pre-game lobby) - const censoredUsername = privilegeRefresher - .get() - .censorUsername(clientMsg.username); + // If successful, skip all authorization if ( - gm.rejoinClient( - ws, - persistentId, - clientMsg.gameID, - 0, - censoredUsername, - ) + gm.rejoinClient(ws, persistentId, clientMsg.gameID, 0, { + username: censoredUsername, + clanTag: censoredClanTag, + }) ) { return; } @@ -463,7 +464,7 @@ export async function startWorker() { flares, ip, censoredUsername, - clientMsg.username, + censoredClanTag, ws, cosmeticResult.cosmetics, ); diff --git a/tests/Censor.test.ts b/tests/Censor.test.ts index 4c5253d72..7faf9bd98 100644 --- a/tests/Censor.test.ts +++ b/tests/Censor.test.ts @@ -5,7 +5,9 @@ vi.mock("../src/client/Utils", () => ({ })); import { + MAX_CLAN_TAG_LENGTH, MAX_USERNAME_LENGTH, + validateClanTag, validateUsername, } from "../src/core/validations/username"; @@ -39,4 +41,34 @@ describe("username.ts functions", () => { expect(res.isValid).toBe(true); }); }); + + describe("validateClanTag", () => { + test("accepts empty clan tag", () => { + const res = validateClanTag(""); + expect(res.isValid).toBe(true); + }); + + test("rejects too short clan tag", () => { + const res = validateClanTag("A"); + expect(res.isValid).toBe(false); + expect(res.error).toBe("username.tag_too_short"); + }); + + test("rejects invalid clan tag characters", () => { + const res = validateClanTag("A!"); + expect(res.isValid).toBe(false); + expect(res.error).toBe("username.tag_invalid_chars"); + }); + + test("rejects too long clan tag", () => { + const res = validateClanTag("A".repeat(MAX_CLAN_TAG_LENGTH + 1)); + expect(res.isValid).toBe(false); + expect(res.error).toBe("username.tag_too_short"); + }); + + test("accepts valid clan tag", () => { + const res = validateClanTag("AB12"); + expect(res.isValid).toBe(true); + }); + }); }); diff --git a/tests/Disconnected.test.ts b/tests/Disconnected.test.ts index d8ba217e4..104a4b6a4 100644 --- a/tests/Disconnected.test.ts +++ b/tests/Disconnected.test.ts @@ -179,16 +179,20 @@ describe("Disconnected", () => { beforeEach(async () => { const player1Info = new PlayerInfo( - "[CLAN]Player1", + "Player1", PlayerType.Human, null, "player_1_id", + false, + "CLAN", ); const player2Info = new PlayerInfo( - "[CLAN]Player2", + "Player2", PlayerType.Human, null, "player_2_id", + false, + "CLAN", ); game = await setup( diff --git a/tests/GameInfoRanking.test.ts b/tests/GameInfoRanking.test.ts index 37abb76a3..f77350078 100644 --- a/tests/GameInfoRanking.test.ts +++ b/tests/GameInfoRanking.test.ts @@ -51,7 +51,7 @@ describe("Ranking class", () => { players: [ { clientID: "p1", - username: "[X] Alice", + username: "Alice", clanTag: "X", cosmetics: { flag: "USA" }, stats: { @@ -69,6 +69,7 @@ describe("Ranking class", () => { { clientID: "p2", username: "Bob", + clanTag: null, stats: { units: { city: [2n, 0n, 0n, 2n] }, conquests: [8n], @@ -84,6 +85,7 @@ describe("Ranking class", () => { { clientID: "p3", username: "Charlie", + clanTag: null, stats: { // no units, but has conquests/killedAt to count as played conquests: [8n], diff --git a/tests/NationCounterWarshipInfestation.test.ts b/tests/NationCounterWarshipInfestation.test.ts index ecb9858bb..ceef241c4 100644 --- a/tests/NationCounterWarshipInfestation.test.ts +++ b/tests/NationCounterWarshipInfestation.test.ts @@ -141,28 +141,36 @@ describe("Counter Warship Infestation", () => { test("rich nation sends counter-warship in Team game when enemy team has too many warships", async () => { // Create players with team setup - use clan tags to group players const nationInfo = new PlayerInfo( - "[ALPHA]defender_nation", + "defender_nation", PlayerType.Nation, null, "nation_id", + false, + "ALPHA", ); const allyInfo = new PlayerInfo( - "[ALPHA]ally_player", + "ally_player", PlayerType.Human, null, "ally_id", + false, + "ALPHA", ); const enemy1Info = new PlayerInfo( - "[BETA]enemy_player_1", + "enemy_player_1", PlayerType.Human, null, "enemy1_id", + false, + "BETA", ); const enemy2Info = new PlayerInfo( - "[BETA]enemy_player_2", + "enemy_player_2", PlayerType.Human, null, "enemy2_id", + false, + "BETA", ); const game = await setup( diff --git a/tests/NationMIRV.test.ts b/tests/NationMIRV.test.ts index abaeb60aa..0264620ca 100644 --- a/tests/NationMIRV.test.ts +++ b/tests/NationMIRV.test.ts @@ -602,16 +602,20 @@ describe("Nation MIRV Retaliation", () => { test("nation launches MIRV to prevent team victory when team approaches victory denial threshold (targets biggest team member)", async () => { // Setup game const teamPlayer1Info = new PlayerInfo( - "[ALPHA]team_player_1", + "team_player_1", PlayerType.Human, null, "team1_id", + false, + "ALPHA", ); const teamPlayer2Info = new PlayerInfo( - "[ALPHA]team_player_2", + "team_player_2", PlayerType.Human, null, "team2_id", + false, + "ALPHA", ); const nationInfo = new PlayerInfo( "defender_nation", diff --git a/tests/PlayerInfo.test.ts b/tests/PlayerInfo.test.ts index c1fa8b559..20ee5d4c0 100644 --- a/tests/PlayerInfo.test.ts +++ b/tests/PlayerInfo.test.ts @@ -1,215 +1,99 @@ import { PlayerInfo, PlayerType } from "../src/core/game/Game"; describe("PlayerInfo", () => { - describe("clan", () => { - test("should extract clan from name when format contains [XX]", () => { + describe("clanTag from explicit clanTag parameter", () => { + test("should set clanTag from clanTag parameter", () => { const playerInfo = new PlayerInfo( - "[CL]PlayerName", + "PlayerName", PlayerType.Human, null, "player_id", + false, + "abc", ); - expect(playerInfo.clan).toBe("CL"); + expect(playerInfo.clanTag).toBe("abc"); }); - test("should extract clan from name when format contains [XXX]", () => { + test("should preserve already-uppercase clan tag", () => { const playerInfo = new PlayerInfo( - "[ABC]PlayerName", + "PlayerName", PlayerType.Human, null, "player_id", + false, + "CLAN", ); - expect(playerInfo.clan).toBe("ABC"); + expect(playerInfo.clanTag).toBe("CLAN"); }); - test("should extract clan from name when format contains [XXXX]", () => { - const playerInfo = new PlayerInfo( - "[ABCD]PlayerName", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("ABCD"); - }); - - test("should extract clan from name when format contains [XXXXX]", () => { - const playerInfo = new PlayerInfo( - "[ABCDE]PlayerName", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("ABCDE"); - }); - - test("should extract uppercase clan from name when format contains [xxxxx]", () => { - const playerInfo = new PlayerInfo( - "[abcde]PlayerName", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("ABCDE"); - }); - - test("should extract uppercase clan from name when format contains [XxXxX]", () => { - const playerInfo = new PlayerInfo( - "[AbCdE]PlayerName", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("ABCDE"); - }); - - test("should extract uppercase clan from name when format contains [Xx#xX]", () => { - const playerInfo = new PlayerInfo( - "[Ab1cD]PlayerName", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("AB1CD"); - }); - - test("should return null when name doesn't contain [", () => { + test("should set clan to null when clanTag is not provided", () => { const playerInfo = new PlayerInfo( "PlayerName", PlayerType.Human, null, "player_id", ); - expect(playerInfo.clan).toBeNull(); + expect(playerInfo.clanTag).toBeNull(); }); - test("should return null when name doesn't contain ]", () => { + test("should set clan to null when clanTag is null", () => { const playerInfo = new PlayerInfo( - "[ABCPlayerName", + "PlayerName", + PlayerType.Human, + null, + "player_id", + false, + null, + ); + expect(playerInfo.clanTag).toBeNull(); + }); + + test("should set clan to null when clanTag is undefined", () => { + const playerInfo = new PlayerInfo( + "PlayerName", + PlayerType.Human, + null, + "player_id", + false, + undefined, + ); + expect(playerInfo.clanTag).toBeNull(); + }); + }); + + describe("displayName", () => { + test("should construct display name with clan tag", () => { + const playerInfo = new PlayerInfo( + "PlayerName", + PlayerType.Human, + null, + "player_id", + false, + "CLAN", + ); + expect(playerInfo.displayName).toBe("[CLAN] PlayerName"); + }); + + test("should return just name when no clan tag", () => { + const playerInfo = new PlayerInfo( + "PlayerName", PlayerType.Human, null, "player_id", ); - expect(playerInfo.clan).toBeNull(); + expect(playerInfo.displayName).toBe("PlayerName"); }); - test("should return null when clan tag is not 2-5 alphanumeric letters", () => { + test("should preserve clan tag casing in display name", () => { const playerInfo = new PlayerInfo( - "[A]PlayerName", + "PlayerName", PlayerType.Human, null, "player_id", + false, + "abc", ); - expect(playerInfo.clan).toBeNull(); - }); - - test("should return null when clan tag contains non alphanumeric characters", () => { - const playerInfo = new PlayerInfo( - "[A?c]PlayerName", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBeNull(); - }); - - test("should return null when clan tag is too long", () => { - const playerInfo = new PlayerInfo( - "[ABCDEF]PlayerName", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBeNull(); - }); - - test("should extract uppercase clan name from any location in the player name", () => { - const playerInfo = new PlayerInfo( - "Player[aa]Name", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("AA"); - }); - - test("should extract only the first occurrence of a clan name match", () => { - const playerInfo = new PlayerInfo( - "[Ab1cD]Player[aa]Name", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("AB1CD"); - }); - - test("should extract only the first occurrence of a valid clan name match and extract as uppercase", () => { - const playerInfo = new PlayerInfo( - "[Ab1cDEF]Player[aa]Name", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("AA"); - }); - - test("should extract numeric-only clan names", () => { - const playerInfo = new PlayerInfo( - "[012]PlayerName", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("012"); - }); - - test("should extract numeric-only clan names and only the first valid clan name", () => { - const playerInfo = new PlayerInfo( - "[012]Player[aa]Name", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("012"); - }); - - test("should extract numeric-only clan names from anywhere within the name", () => { - const playerInfo = new PlayerInfo( - "Player[012]Name", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("012"); - }); - - test("should extract numeric-only clan names from the end of the name", () => { - const playerInfo = new PlayerInfo( - "PlayerName[012]", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("012"); - }); - - test("should extract uppercase alphanumeric clan names from anywhere within the name", () => { - const playerInfo = new PlayerInfo( - "Player[0a1B2]Name", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("0A1B2"); - }); - - test("should extract uppercase alphanumeric clan names from the end of the name", () => { - const playerInfo = new PlayerInfo( - "PlayerName[0a1B2]", - PlayerType.Human, - null, - "player_id", - ); - expect(playerInfo.clan).toBe("0A1B2"); + expect(playerInfo.displayName).toBe("[abc] PlayerName"); }); }); }); diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts index e3acc62b3..97d5922c2 100644 --- a/tests/Privilege.test.ts +++ b/tests/Privilege.test.ts @@ -17,7 +17,7 @@ const bannedWords = [ const matcher = createMatcher(bannedWords); -// Create a minimal PrivilegeCheckerImpl for testing censorUsername +// Create a minimal PrivilegeCheckerImpl for testing censor const mockCosmetics = { patterns: {}, colorPalettes: {} }; const mockDecoder = () => new Uint8Array(); const checker = new PrivilegeCheckerImpl( @@ -75,73 +75,82 @@ describe("UsernameCensor", () => { }); }); - describe("censorUsername", () => { + describe("censor", () => { test("returns clean usernames unchanged", () => { - expect(checker.censorUsername("CoolPlayer")).toBe("CoolPlayer"); - expect(checker.censorUsername("GameMaster")).toBe("GameMaster"); + expect(checker.censor("CoolPlayer", null).username).toBe("CoolPlayer"); + expect(checker.censor("GameMaster", null).username).toBe("GameMaster"); }); test("replaces profane usernames with a shadow name", () => { - const result = checker.censorUsername("hitler"); - expect(shadowNames).toContain(result); + const result = checker.censor("hitler", null); + expect(shadowNames).toContain(result.username); }); test("replaces leet speak profane usernames with a shadow name", () => { - const result = checker.censorUsername("h1tl3r"); - expect(shadowNames).toContain(result); + const result = checker.censor("h1tl3r", null); + expect(shadowNames).toContain(result.username); }); test("preserves clean clan tag when username is profane", () => { - const result = checker.censorUsername("[COOL]hitler"); - expect(result).toMatch(/^\[COOL\] /); - const nameAfterTag = result.replace("[COOL] ", ""); - expect(shadowNames).toContain(nameAfterTag); + const result = checker.censor("hitler", "COOL"); + expect(result.clanTag).toBe("COOL"); + expect(shadowNames).toContain(result.username); }); test("removes profane clan tag but keeps clean username", () => { - expect(checker.censorUsername("[NAZI]CoolPlayer")).toBe("CoolPlayer"); + const result = checker.censor("CoolPlayer", "NAZI"); + expect(result.username).toBe("CoolPlayer"); + expect(result.clanTag).toBeNull(); }); test("removes clan tag with leet speak profanity", () => { - expect(checker.censorUsername("[N4Z1]CoolPlayer")).toBe("CoolPlayer"); + const result = checker.censor("CoolPlayer", "N4Z1"); + expect(result.username).toBe("CoolPlayer"); + expect(result.clanTag).toBeNull(); }); test("removes clan tag with uppercased banned word", () => { - expect(checker.censorUsername("[ADOLF]CoolPlayer")).toBe("CoolPlayer"); + const result = checker.censor("CoolPlayer", "ADOLF"); + expect(result.username).toBe("CoolPlayer"); + expect(result.clanTag).toBeNull(); }); test("removes clan tag containing banned word substring", () => { - expect(checker.censorUsername("[JEWS]CoolPlayer")).toBe("CoolPlayer"); + const result = checker.censor("CoolPlayer", "JEWS"); + expect(result.username).toBe("CoolPlayer"); + expect(result.clanTag).toBeNull(); }); test("removes profane clan tag and censors profane username", () => { - const result = checker.censorUsername("[NAZI]hitler"); - // No clan tag prefix, just a shadow name - expect(shadowNames).toContain(result); + const result = checker.censor("hitler", "NAZI"); + expect(result.clanTag).toBeNull(); + expect(shadowNames).toContain(result.username); }); test("removes leet speak profane clan tag and censors leet speak username", () => { - const result = checker.censorUsername("[N4Z1]h1tl3r"); - // No clan tag prefix, just a shadow name - expect(shadowNames).toContain(result); + const result = checker.censor("h1tl3r", "N4Z1"); + expect(result.clanTag).toBeNull(); + expect(shadowNames).toContain(result.username); }); test("returns deterministic shadow name for same input", () => { - const a = checker.censorUsername("hitler"); - const b = checker.censorUsername("hitler"); - expect(a).toBe(b); + const a = checker.censor("hitler", null); + const b = checker.censor("hitler", null); + expect(a.username).toBe(b.username); }); test("handles username with no clan tag", () => { - expect(checker.censorUsername("NormalPlayer")).toBe("NormalPlayer"); + expect(checker.censor("NormalPlayer", null).username).toBe( + "NormalPlayer", + ); }); test("empty banned words list still catches englishDataset profanity", () => { - // The emptyChecker still uses englishDataset, so common profanity is caught - expect(emptyChecker.censorUsername("CoolPlayer")).toBe("CoolPlayer"); - // Verify a known english profanity gets censored even without custom banned words - const result = emptyChecker.censorUsername("fuck"); - expect(shadowNames).toContain(result); + expect(emptyChecker.censor("CoolPlayer", null).username).toBe( + "CoolPlayer", + ); + const result = emptyChecker.censor("fuck", null); + expect(shadowNames).toContain(result.username); }); }); }); diff --git a/tests/TeamAssignment.test.ts b/tests/TeamAssignment.test.ts index c3e11671b..999f02592 100644 --- a/tests/TeamAssignment.test.ts +++ b/tests/TeamAssignment.test.ts @@ -5,12 +5,13 @@ const teams = [ColoredTeams.Red, ColoredTeams.Blue]; describe("assignTeams", () => { const createPlayer = (id: string, clan?: string): PlayerInfo => { - const name = clan ? `[${clan}]Player ${id}` : `Player ${id}`; return new PlayerInfo( - name, + `Player ${id}`, PlayerType.Human, null, // clientID (null for testing) id, + false, + clan, ); }; diff --git a/tests/client/graphics/layers/PlayerPanelKick.test.ts b/tests/client/graphics/layers/PlayerPanelKick.test.ts index ea87feed3..e5a37bfef 100644 --- a/tests/client/graphics/layers/PlayerPanelKick.test.ts +++ b/tests/client/graphics/layers/PlayerPanelKick.test.ts @@ -53,6 +53,7 @@ describe("PlayerPanel - kick player moderation", () => { const other = { id: () => 2, name: () => "Other", + displayName: () => "[TAG] Other", type: () => PlayerType.Human, clientID: () => "client-2", } as unknown as PlayerView; @@ -84,6 +85,7 @@ describe("PlayerPanel - kick player moderation", () => { const other = { id: () => 2, name: () => "Other", + displayName: () => "[TAG] Other", type: () => PlayerType.Human, clientID: () => "client-2", } as unknown as PlayerView; @@ -119,6 +121,7 @@ describe("PlayerModerationModal - kick confirmation", () => { const other = { id: () => 2, name: () => "Other", + displayName: () => "[TAG] Other", type: () => PlayerType.Human, clientID: () => "client-2", } as unknown as PlayerView; @@ -151,6 +154,7 @@ describe("PlayerModerationModal - kick confirmation", () => { const other = { id: () => 2, name: () => "Other", + displayName: () => "[TAG] Other", type: () => PlayerType.Human, clientID: () => "client-2", } as unknown as PlayerView; From afdb04ae5d072c52a86d40fd013f105e8ee85329 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:56:52 +0100 Subject: [PATCH 23/29] =?UTF-8?q?Increase=20spawn=20immunity=20from=2030s?= =?UTF-8?q?=20to=2045s=20for=205M=20starting=20gold=20maps=20=F0=9F=9B=A1?= =?UTF-8?q?=EF=B8=8F=20(#3457)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Increases the spawn immunity duration from 30s to 45s for maps with 5M starting gold. The previous 30s was too short - 45s gives players 15s longer than it takes to build a SAM, allowing them to establish basic defenses before becoming vulnerable. ## 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: FloPinguin --- src/core/configuration/DefaultConfig.ts | 7 ++++++- src/server/MapPlaylist.ts | 7 ++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index c77e886ac..85d3b661f 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -136,6 +136,9 @@ export abstract class DefaultServerConfig implements ServerConfig { } } +/** SAM launcher construction duration in ticks (non-instant-build). */ +export const SAM_CONSTRUCTION_TICKS = 30 * 10; + export class DefaultConfig implements Config { private pastelTheme: PastelTheme = new PastelTheme(); private pastelThemeDark: PastelThemeDark = new PastelThemeDark(); @@ -430,7 +433,9 @@ export class DefaultConfig implements Config { Math.min(3_000_000, (numUnits + 1) * 1_500_000), UnitType.SAMLauncher, ), - constructionDuration: this.instantBuild() ? 0 : 30 * 10, + constructionDuration: this.instantBuild() + ? 0 + : SAM_CONSTRUCTION_TICKS, upgradable: true, }; break; diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 3959fe68a..5dd7dbaa7 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -1,3 +1,4 @@ +import { SAM_CONSTRUCTION_TICKS } from "../core/configuration/DefaultConfig"; import { Difficulty, Duos, @@ -606,8 +607,8 @@ export class MapPlaylist { /** * Centralised spawn-immunity duration logic. * - HumansVsNations: always 5s (nations can't benefit from longer PVP immunity) - * - 25M starting gold: 2:30 (extra time to compensate for high gold) - * - 5M starting gold: 30s + * - 25M starting gold: 2:30min (extra time to compensate for high gold) + * - 5M starting gold: SAM build time + 15s (enough to build a SAM) * - Default: 5s */ private getSpawnImmunityDuration( @@ -617,7 +618,7 @@ export class MapPlaylist { if (playerTeams === HumansVsNations) return 5 * 10; if (startingGold !== undefined && startingGold >= 25_000_000) return 150 * 10; - if (startingGold) return 30 * 10; + if (startingGold) return SAM_CONSTRUCTION_TICKS + 15 * 10; return 5 * 10; } From 5b0b9dfd311441b01dc171589125a53a6984e319 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:56:52 +0100 Subject: [PATCH 24/29] =?UTF-8?q?Increase=20spawn=20immunity=20from=2030s?= =?UTF-8?q?=20to=2045s=20for=205M=20starting=20gold=20maps=20=F0=9F=9B=A1?= =?UTF-8?q?=EF=B8=8F=20(#3457)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Increases the spawn immunity duration from 30s to 45s for maps with 5M starting gold. The previous 30s was too short - 45s gives players 15s longer than it takes to build a SAM, allowing them to establish basic defenses before becoming vulnerable. ## 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: FloPinguin --- src/core/configuration/DefaultConfig.ts | 7 ++++++- src/server/MapPlaylist.ts | 7 ++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index c77e886ac..85d3b661f 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -136,6 +136,9 @@ export abstract class DefaultServerConfig implements ServerConfig { } } +/** SAM launcher construction duration in ticks (non-instant-build). */ +export const SAM_CONSTRUCTION_TICKS = 30 * 10; + export class DefaultConfig implements Config { private pastelTheme: PastelTheme = new PastelTheme(); private pastelThemeDark: PastelThemeDark = new PastelThemeDark(); @@ -430,7 +433,9 @@ export class DefaultConfig implements Config { Math.min(3_000_000, (numUnits + 1) * 1_500_000), UnitType.SAMLauncher, ), - constructionDuration: this.instantBuild() ? 0 : 30 * 10, + constructionDuration: this.instantBuild() + ? 0 + : SAM_CONSTRUCTION_TICKS, upgradable: true, }; break; diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 3959fe68a..5dd7dbaa7 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -1,3 +1,4 @@ +import { SAM_CONSTRUCTION_TICKS } from "../core/configuration/DefaultConfig"; import { Difficulty, Duos, @@ -606,8 +607,8 @@ export class MapPlaylist { /** * Centralised spawn-immunity duration logic. * - HumansVsNations: always 5s (nations can't benefit from longer PVP immunity) - * - 25M starting gold: 2:30 (extra time to compensate for high gold) - * - 5M starting gold: 30s + * - 25M starting gold: 2:30min (extra time to compensate for high gold) + * - 5M starting gold: SAM build time + 15s (enough to build a SAM) * - Default: 5s */ private getSpawnImmunityDuration( @@ -617,7 +618,7 @@ export class MapPlaylist { if (playerTeams === HumansVsNations) return 5 * 10; if (startingGold !== undefined && startingGold >= 25_000_000) return 150 * 10; - if (startingGold) return 30 * 10; + if (startingGold) return SAM_CONSTRUCTION_TICKS + 15 * 10; return 5 * 10; } From cb7164a52fb60b1e7251cf855777e151805e7890 Mon Sep 17 00:00:00 2001 From: Evan Date: Tue, 17 Mar 2026 19:02:55 -0700 Subject: [PATCH 25/29] Place footer ad during spawn phase (#3458) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Show the "footer_ad" ad type during the spawn phase. Screenshot 2026-03-17 at 7 01 37 PM ## 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: evan --- src/client/Main.ts | 9 +++- src/client/graphics/layers/InGamePromo.ts | 55 ++++++++++++++++++++++- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/client/Main.ts b/src/client/Main.ts index 55406ed30..1241fe060 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -180,12 +180,17 @@ declare global { ramp: { que: Array<() => void>; passiveMode: boolean; - spaAddAds: (ads: Array<{ type: string; selectorId: string }>) => void; - destroyUnits: (adType: string) => void; + spaAddAds: (ads: Array<{ type: string; selectorId?: string }>) => void; + destroyUnits: (adType: string | string[]) => Promise; settings?: { slots?: any; }; spaNewPage: (url?: string) => void; + spaAds: (config?: { + ads?: Array<{ type: string; selectorId?: string }>; + countPageview?: boolean; + path?: string; + }) => void; // Video ad methods onPlayerReady: (() => void) | null; addUnits: (units: Array<{ type: string }>) => Promise; diff --git a/src/client/graphics/layers/InGamePromo.ts b/src/client/graphics/layers/InGamePromo.ts index a160e5924..097bf5bd6 100644 --- a/src/client/graphics/layers/InGamePromo.ts +++ b/src/client/graphics/layers/InGamePromo.ts @@ -5,19 +5,71 @@ import { Layer } from "./Layer"; const AD_TYPE = "standard_iab_left1"; const AD_CONTAINER_ID = "in-game-bottom-left-ad"; +const BOTTOM_RAIL_TYPE = "bottom_rail"; @customElement("in-game-promo") export class InGamePromo extends LitElement implements Layer { public game: GameView; private shouldShow: boolean = false; + private bottomRailActive: boolean = false; + private cornerAdShown: boolean = false; createRenderRoot() { return this; } init() { - this.showAd(); + this.showBottomRail(); + } + + tick() { + if (!this.game.inSpawnPhase()) { + if (this.bottomRailActive) { + this.destroyBottomRail(); + } + if (!this.cornerAdShown) { + this.cornerAdShown = true; + this.showAd(); + } + } + } + + private showBottomRail(): void { + if (!window.adsEnabled) return; + if (!this.game.inSpawnPhase()) return; + if (!window.ramp) { + console.warn("Playwire RAMP not available for bottom_rail ad"); + return; + } + + this.bottomRailActive = true; + try { + window.ramp.que.push(() => { + try { + window.ramp.spaAddAds([{ type: BOTTOM_RAIL_TYPE }]); + console.log("Bottom rail ad loaded during spawn phase"); + } catch (e) { + console.error("Failed to add bottom_rail ad:", e); + } + }); + } catch (error) { + console.error("Failed to load bottom_rail ad:", error); + } + } + + private destroyBottomRail(): void { + if (!this.bottomRailActive) return; + this.bottomRailActive = false; + + if (!window.ramp) return; + + try { + window.ramp.spaAds({ ads: [], countPageview: false }); + console.log("Bottom rail ad destroyed via spaAds after spawn phase"); + } catch (e) { + console.error("Error destroying bottom_rail ad:", e); + } } private showAd(): void { @@ -59,6 +111,7 @@ export class InGamePromo extends LitElement implements Layer { } public hideAd(): void { + this.destroyBottomRail(); if (!window.ramp) { console.warn("Playwire RAMP not available for in-game ad"); return; From 3e7088cb2a2b52a78b5e1e316746edf949cc67d9 Mon Sep 17 00:00:00 2001 From: Evan Date: Tue, 17 Mar 2026 19:20:13 -0700 Subject: [PATCH 26/29] Close join error (#3454) ## Description: When joining a game after it fills up, the server rejects the player join and the player leaves the lobby, but the join modal stays up. ## 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: evan --- src/client/ClientGameRunner.ts | 2 +- src/client/Main.ts | 17 ++++++++++++++++- src/client/components/BaseModal.ts | 4 ++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 0e2eb960c..5ff162c5f 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -166,7 +166,7 @@ export function joinLobby( if (message.error === "full-lobby") { document.dispatchEvent( new CustomEvent("leave-lobby", { - detail: { lobby: lobbyConfig.gameID }, + detail: { lobby: lobbyConfig.gameID, cause: "full-lobby" }, bubbles: true, composed: true, }), diff --git a/src/client/Main.ts b/src/client/Main.ts index 1241fe060..837887c7a 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -866,7 +866,7 @@ class Client { } } - private async handleLeaveLobby(/* event: CustomEvent */) { + private async handleLeaveLobby(event?: CustomEvent) { if (this.lobbyHandle === null) { return; } @@ -883,6 +883,21 @@ class Client { document.body.classList.remove("in-game"); + if (this.joinModal.isOpen()) { + this.joinModal.close(); + if (event?.detail.cause === "full-lobby") { + window.dispatchEvent( + new CustomEvent("show-message", { + detail: { + message: translateText("public_lobby.join_timeout"), + color: "red", + duration: 3500, + }, + }), + ); + } + } + crazyGamesSDK.gameplayStop(); } diff --git a/src/client/components/BaseModal.ts b/src/client/components/BaseModal.ts index b73eb9470..c8eff0230 100644 --- a/src/client/components/BaseModal.ts +++ b/src/client/components/BaseModal.ts @@ -35,6 +35,10 @@ export abstract class BaseModal extends LitElement { return this; } + public isOpen(): boolean { + return this.isModalOpen; + } + protected firstUpdated(): void { if (this.modalEl) { this.modalEl.onClose = () => { From 6a5b2089e7c0a8c51031d1bc2e5bdf9feac54531 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Thu, 19 Mar 2026 03:25:44 +0100 Subject: [PATCH 27/29] =?UTF-8?q?teamGameSpawnAreas=20for=20Baikal=20?= =?UTF-8?q?=E2=9A=A1=20(#3467)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: "Baikal (Nuke Wars)" has teamGameSpawnAreas (for Random Spawn), but Baikal not. Because teamGameSpawnAreas was intended for a "Nuke Wars" modifier. Let's add teamGameSpawnAreas also to Baikal to improve random spawning (special rotation) a bit (until we have a better random spawn algo). ## 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: FloPinguin --- map-generator/assets/maps/baikal/info.json | 18 +++++++++++++++++- resources/maps/baikal/manifest.json | 18 +++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/map-generator/assets/maps/baikal/info.json b/map-generator/assets/maps/baikal/info.json index 59c531d84..6cebf3aab 100644 --- a/map-generator/assets/maps/baikal/info.json +++ b/map-generator/assets/maps/baikal/info.json @@ -56,5 +56,21 @@ "name": "Listvyanka", "flag": "ru" } - ] + ], + "teamGameSpawnAreas": { + "2": [ + { + "height": 1564, + "width": 1330, + "x": 0, + "y": 0 + }, + { + "height": 1564, + "width": 1070, + "x": 1430, + "y": 0 + } + ] + } } diff --git a/resources/maps/baikal/manifest.json b/resources/maps/baikal/manifest.json index 3abc577fd..0fc4b8dba 100644 --- a/resources/maps/baikal/manifest.json +++ b/resources/maps/baikal/manifest.json @@ -71,5 +71,21 @@ "flag": "ru", "name": "Listvyanka" } - ] + ], + "teamGameSpawnAreas": { + "2": [ + { + "height": 1564, + "width": 1330, + "x": 0, + "y": 0 + }, + { + "height": 1564, + "width": 1070, + "x": 1430, + "y": 0 + } + ] + } } From 951efd4591ca7bb253159c1587f03a70db839999 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Thu, 19 Mar 2026 03:25:44 +0100 Subject: [PATCH 28/29] =?UTF-8?q?teamGameSpawnAreas=20for=20Baikal=20?= =?UTF-8?q?=E2=9A=A1=20(#3467)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: "Baikal (Nuke Wars)" has teamGameSpawnAreas (for Random Spawn), but Baikal not. Because teamGameSpawnAreas was intended for a "Nuke Wars" modifier. Let's add teamGameSpawnAreas also to Baikal to improve random spawning (special rotation) a bit (until we have a better random spawn algo). ## 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: FloPinguin --- map-generator/assets/maps/baikal/info.json | 18 +++++++++++++++++- resources/maps/baikal/manifest.json | 18 +++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/map-generator/assets/maps/baikal/info.json b/map-generator/assets/maps/baikal/info.json index 59c531d84..6cebf3aab 100644 --- a/map-generator/assets/maps/baikal/info.json +++ b/map-generator/assets/maps/baikal/info.json @@ -56,5 +56,21 @@ "name": "Listvyanka", "flag": "ru" } - ] + ], + "teamGameSpawnAreas": { + "2": [ + { + "height": 1564, + "width": 1330, + "x": 0, + "y": 0 + }, + { + "height": 1564, + "width": 1070, + "x": 1430, + "y": 0 + } + ] + } } diff --git a/resources/maps/baikal/manifest.json b/resources/maps/baikal/manifest.json index 3abc577fd..0fc4b8dba 100644 --- a/resources/maps/baikal/manifest.json +++ b/resources/maps/baikal/manifest.json @@ -71,5 +71,21 @@ "flag": "ru", "name": "Listvyanka" } - ] + ], + "teamGameSpawnAreas": { + "2": [ + { + "height": 1564, + "width": 1330, + "x": 0, + "y": 0 + }, + { + "height": 1564, + "width": 1070, + "x": 1430, + "y": 0 + } + ] + } } From c5dad1f850551af8adc4bc665a07f7aef38bd442 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Wed, 18 Mar 2026 19:27:33 -0700 Subject: [PATCH 29/29] on purchase redirect, check cosmetic param instead of pattern param --- src/client/Main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/Main.ts b/src/client/Main.ts index 837887c7a..07fda48cd 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -647,7 +647,7 @@ class Client { return; } - const patternName = params.get("pattern"); + const patternName = params.get("cosmetic"); if (!patternName) { alert("Something went wrong. Please contact support."); console.error("purchase-completed but no pattern name");
+ ${this.renderKey(keybinds.pauseGame)} + + ${translateText("help_modal.action_pause_game")} +
+
+ ${this.renderKey(keybinds.gameSpeedDown)} + ${this.renderKey(keybinds.gameSpeedUp)} +
+
+ ${translateText("help_modal.action_game_speed")} +
diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 778d284c4..e42d62241 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -123,6 +123,12 @@ export class ReplaySpeedChangeEvent implements GameEvent { constructor(public readonly replaySpeedMultiplier: ReplaySpeedMultiplier) {} } +export class TogglePauseIntentEvent implements GameEvent {} + +export class GameSpeedUpIntentEvent implements GameEvent {} + +export class GameSpeedDownIntentEvent implements GameEvent {} + export class CenterCameraEvent implements GameEvent { constructor() {} } @@ -236,6 +242,9 @@ export class InputHandler { buildAtomBomb: "Digit8", buildHydrogenBomb: "Digit9", buildMIRV: "Digit0", + pauseGame: "KeyP", + gameSpeedUp: "Period", + gameSpeedDown: "Comma", ...saved, }; @@ -433,8 +442,20 @@ export class InputHandler { this.eventBus.emit(new SwapRocketDirectionEvent(nextDirection)); } + if (!e.repeat && e.code === this.keybinds.pauseGame) { + e.preventDefault(); + this.eventBus.emit(new TogglePauseIntentEvent()); + } + if (!e.repeat && e.code === this.keybinds.gameSpeedUp) { + e.preventDefault(); + this.eventBus.emit(new GameSpeedUpIntentEvent()); + } + if (!e.repeat && e.code === this.keybinds.gameSpeedDown) { + e.preventDefault(); + this.eventBus.emit(new GameSpeedDownIntentEvent()); + } + // Shift-D to toggle performance overlay - console.log(e.code, e.shiftKey, e.ctrlKey, e.altKey, e.metaKey); if (e.code === "KeyD" && e.shiftKey) { e.preventDefault(); console.log("TogglePerformanceOverlayEvent"); diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 58c43306c..e2805235f 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -20,12 +20,24 @@ import { } from "../core/Util"; import { getPersistentID } from "./Auth"; import { LobbyConfig } from "./ClientGameRunner"; -import { ReplaySpeedChangeEvent } from "./InputHandler"; +import { + GameSpeedDownIntentEvent, + GameSpeedUpIntentEvent, + ReplaySpeedChangeEvent, +} from "./InputHandler"; import { defaultReplaySpeedMultiplier, ReplaySpeedMultiplier, } from "./utilities/ReplaySpeedMultiplier"; +// Order: 0.5, 1, 2, max (same as ReplayPanel) +const SPEED_ORDER: ReplaySpeedMultiplier[] = [ + ReplaySpeedMultiplier.slow, + ReplaySpeedMultiplier.normal, + ReplaySpeedMultiplier.fast, + ReplaySpeedMultiplier.fastest, +]; + // build a small backlog so MAX can catch up. const MAX_REPLAY_BACKLOG_TURNS = 60; @@ -94,6 +106,26 @@ export class LocalServer { this.replaySpeedMultiplier = event.replaySpeedMultiplier; }); + if (!this.isReplay) { + this.eventBus.on(GameSpeedUpIntentEvent, () => { + const idx = SPEED_ORDER.indexOf(this.replaySpeedMultiplier); + if (idx < 0 || idx >= SPEED_ORDER.length - 1) return; + this.replaySpeedMultiplier = SPEED_ORDER[idx + 1]; + this.eventBus.emit( + new ReplaySpeedChangeEvent(this.replaySpeedMultiplier), + ); + }); + + this.eventBus.on(GameSpeedDownIntentEvent, () => { + const idx = SPEED_ORDER.indexOf(this.replaySpeedMultiplier); + if (idx <= 0) return; + this.replaySpeedMultiplier = SPEED_ORDER[idx - 1]; + this.eventBus.emit( + new ReplaySpeedChangeEvent(this.replaySpeedMultiplier), + ); + }); + } + this.startedAt = Date.now(); this.clientConnect(); if (this.lobbyConfig.gameRecord) { diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 7bf6612d3..cb47ee320 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -47,6 +47,9 @@ const DefaultKeybinds: Record = { moveRight: "KeyD", modifierKey: isMac ? "MetaLeft" : "ControlLeft", altKey: "AltLeft", + pauseGame: "KeyP", + gameSpeedUp: "Period", + gameSpeedDown: "Comma", }; @customElement("user-setting") @@ -634,6 +637,36 @@ export class UserSettingModal extends BaseModal { @change=${this.handleKeybindChange} > + + + + + +

diff --git a/src/client/graphics/layers/GameRightSidebar.ts b/src/client/graphics/layers/GameRightSidebar.ts index 2d2f78891..f3ddc8278 100644 --- a/src/client/graphics/layers/GameRightSidebar.ts +++ b/src/client/graphics/layers/GameRightSidebar.ts @@ -4,6 +4,7 @@ import { EventBus } from "../../../core/EventBus"; import { GameType } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; import { crazyGamesSDK } from "../../CrazyGamesSDK"; +import { TogglePauseIntentEvent } from "../../InputHandler"; import { PauseGameIntentEvent, SendWinnerEvent } from "../../Transport"; import { translateText } from "../../Utils"; import { ImmunityBarVisibleEvent } from "./ImmunityTimer"; @@ -67,6 +68,14 @@ export class GameRightSidebar extends LitElement implements Layer { this.requestUpdate(); }); + this.eventBus.on(TogglePauseIntentEvent, () => { + const isReplayOrSingleplayer = + this._isSinglePlayer || this.game?.config()?.isReplay(); + if (isReplayOrSingleplayer || this.isLobbyCreator) { + this.onPauseButtonClick(); + } + }); + this.requestUpdate(); } diff --git a/src/client/graphics/layers/ReplayPanel.ts b/src/client/graphics/layers/ReplayPanel.ts index fbef9051d..8c214c2ef 100644 --- a/src/client/graphics/layers/ReplayPanel.ts +++ b/src/client/graphics/layers/ReplayPanel.ts @@ -41,6 +41,13 @@ export class ReplayPanel extends LitElement implements Layer { this.visible = event.visible; this.isSingleplayer = event.isSingleplayer; }); + this.eventBus.on( + ReplaySpeedChangeEvent, + (event: ReplaySpeedChangeEvent) => { + this._replaySpeedMultiplier = event.replaySpeedMultiplier; + this.requestUpdate(); + }, + ); } } From 51db6a2772490432297ceaef07e4a4288ecafaf7 Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 16 Mar 2026 19:36:02 -0700 Subject: [PATCH 08/29] Add in game add to bottom left corner (#3446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Remove the header ad because that's where the player info overlay is, and instead put it on the bottom left Ad is not displayed on small screens. Screenshot 2026-03-16 at 7 27 58 PM ## 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: evan --- index.html | 2 +- src/client/graphics/GameRenderer.ts | 14 +-- src/client/graphics/layers/InGameHeaderAd.ts | 119 ------------------- src/client/graphics/layers/InGamePromo.ts | 93 +++++++++++++++ 4 files changed, 100 insertions(+), 128 deletions(-) delete mode 100644 src/client/graphics/layers/InGameHeaderAd.ts create mode 100644 src/client/graphics/layers/InGamePromo.ts diff --git a/index.html b/index.html index ab3609a02..46eca8f14 100644 --- a/index.html +++ b/index.html @@ -319,7 +319,7 @@ - + diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 8956214d6..fb0c96810 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -21,7 +21,7 @@ import { GameLeftSidebar } from "./layers/GameLeftSidebar"; import { GameRightSidebar } from "./layers/GameRightSidebar"; import { HeadsUpMessage } from "./layers/HeadsUpMessage"; import { ImmunityTimer } from "./layers/ImmunityTimer"; -import { InGameHeaderAd } from "./layers/InGameHeaderAd"; +import { InGamePromo } from "./layers/InGamePromo"; import { Layer } from "./layers/Layer"; import { Leaderboard } from "./layers/Leaderboard"; import { MainRadialMenu } from "./layers/MainRadialMenu"; @@ -262,13 +262,11 @@ export function createRenderer( immunityTimer.game = game; immunityTimer.eventBus = eventBus; - const inGameHeaderAd = document.querySelector( - "in-game-header-ad", - ) as InGameHeaderAd; - if (!(inGameHeaderAd instanceof InGameHeaderAd)) { - console.error("in-game header ad not found"); + const inGamePromo = document.querySelector("in-game-promo") as InGamePromo; + if (!(inGamePromo instanceof InGamePromo)) { + console.error("in-game promo not found"); } - inGameHeaderAd.game = game; + inGamePromo.game = game; const spawnVideoAd = document.querySelector("spawn-video-ad") as SpawnVideoAd; if (!(spawnVideoAd instanceof SpawnVideoAd)) { @@ -321,7 +319,7 @@ export function createRenderer( playerPanel, headsUpMessage, multiTabModal, - inGameHeaderAd, + inGamePromo, spawnVideoAd, alertFrame, performanceOverlay, diff --git a/src/client/graphics/layers/InGameHeaderAd.ts b/src/client/graphics/layers/InGameHeaderAd.ts deleted file mode 100644 index 52585cdba..000000000 --- a/src/client/graphics/layers/InGameHeaderAd.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { LitElement, html } from "lit"; -import { customElement } from "lit/decorators.js"; -import { GameView } from "../../../core/game/GameView"; -import { Layer } from "./Layer"; - -const AD_SHOW_TICKS = 10 * 60 * 10; // 2 minutes -const HEADER_AD_TYPE = "standard_iab_head1"; -const HEADER_AD_CONTAINER_ID = "header-ad-container"; -const TWO_XL_BREAKPOINT = 1536; - -@customElement("in-game-header-ad") -export class InGameHeaderAd extends LitElement implements Layer { - public game: GameView; - - private isHidden: boolean = false; - private adLoaded: boolean = false; - private shouldShow: boolean = false; - - createRenderRoot() { - return this; - } - - init() { - // TODO: move ad and re-enable. - // this.showHeaderAd(); - } - - private showHeaderAd(): void { - // Don't show header ad on screens smaller than 2xl - if (window.innerWidth < TWO_XL_BREAKPOINT) { - return; - } - if (!window.adsEnabled) { - return; - } - - this.shouldShow = true; - this.requestUpdate(); - - // Wait for the element to render before loading the ad - this.updateComplete.then(() => { - this.loadAd(); - }); - } - - private loadAd(): void { - if (!window.ramp) { - console.warn("Playwire RAMP not available for header ad"); - return; - } - - try { - window.ramp.que.push(() => { - try { - window.ramp.spaAddAds([ - { - type: HEADER_AD_TYPE, - selectorId: HEADER_AD_CONTAINER_ID, - }, - ]); - this.adLoaded = true; - console.log("Header ad loaded:", HEADER_AD_TYPE); - } catch (e) { - console.error("Failed to add header ad:", e); - } - }); - } catch (error) { - console.error("Failed to load header ad:", error); - } - } - - private hideHeaderAd(): void { - this.shouldShow = false; - this.adLoaded = false; - try { - window.ramp.destroyUnits(HEADER_AD_TYPE); - console.log("successfully destroyed in game header ad"); - } catch (e) { - console.error("error destroying in game header ad", e); - } - this.requestUpdate(); - } - - public tick() { - if (this.isHidden) { - return; - } - - const gameTicks = - this.game.ticks() - this.game.config().numSpawnPhaseTurns(); - if (gameTicks > AD_SHOW_TICKS) { - console.log("destroying header ad and refreshing PageOS"); - this.hideHeaderAd(); - this.isHidden = true; - - if (window.PageOS?.session?.newPageView) { - window.PageOS.session.newPageView(); - } - return; - } - } - - shouldTransform(): boolean { - return false; - } - - render() { - if (!this.shouldShow) { - return html``; - } - - return html` - - `; - } -} diff --git a/src/client/graphics/layers/InGamePromo.ts b/src/client/graphics/layers/InGamePromo.ts new file mode 100644 index 000000000..a160e5924 --- /dev/null +++ b/src/client/graphics/layers/InGamePromo.ts @@ -0,0 +1,93 @@ +import { LitElement, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { GameView } from "../../../core/game/GameView"; +import { Layer } from "./Layer"; + +const AD_TYPE = "standard_iab_left1"; +const AD_CONTAINER_ID = "in-game-bottom-left-ad"; + +@customElement("in-game-promo") +export class InGamePromo extends LitElement implements Layer { + public game: GameView; + + private shouldShow: boolean = false; + + createRenderRoot() { + return this; + } + + init() { + this.showAd(); + } + + private showAd(): void { + if (!window.adsEnabled) return; + if (window.innerWidth < 1100) return; + if (window.innerHeight < 750) return; + + this.shouldShow = true; + this.requestUpdate(); + + this.updateComplete.then(() => { + this.loadAd(); + }); + } + + private loadAd(): void { + if (!window.ramp) { + console.warn("Playwire RAMP not available for in-game ad"); + return; + } + + try { + window.ramp.que.push(() => { + try { + window.ramp.spaAddAds([ + { + type: AD_TYPE, + selectorId: AD_CONTAINER_ID, + }, + ]); + console.log("In-game bottom-left ad loaded:", AD_TYPE); + } catch (e) { + console.error("Failed to add in-game ad:", e); + } + }); + } catch (error) { + console.error("Failed to load in-game ad:", error); + } + } + + public hideAd(): void { + if (!window.ramp) { + console.warn("Playwire RAMP not available for in-game ad"); + return; + } + this.shouldShow = false; + try { + window.ramp.destroyUnits(AD_TYPE); + console.log("successfully destroyed in-game bottom-left ad"); + } catch (e) { + console.error("error destroying in-game ad:", e); + } + this.requestUpdate(); + } + + shouldTransform(): boolean { + return false; + } + + render() { + if (!this.shouldShow) { + return html``; + } + + return html` +
+ `; + } +} From 194a288ce1bf9a68072dc4729757480128f5cbb6 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Mon, 16 Mar 2026 20:13:14 -0700 Subject: [PATCH 09/29] reduce ws max size to 1 mb --- src/server/Worker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 8e19c3474..e72276819 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -50,7 +50,7 @@ export async function startWorker() { const server = http.createServer(app); const wss = new WebSocketServer({ noServer: true, - maxPayload: 2 * 1024 * 1024, + maxPayload: 1024 * 1024, // 1MB }); const gm = new GameManager(config, log); From 5e7317a818178e66f66a2f83b8d0624830111fa5 Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 16 Mar 2026 20:26:56 -0700 Subject: [PATCH 10/29] Update socket rate limiting (#3447) ## Description: On replays, there can be a burst of traffic from hashes, so instead just have a 2MB limit per client for the entire game. Also the winner message can be 100s of kb on a large game with many players, so now we don't need to put a special case for that. ## 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: evan --- src/server/ClientMsgRateLimiter.ts | 52 +++++++++------------ tests/server/ClientMsgRateLimiter.test.ts | 56 +++++++++++++---------- 2 files changed, 53 insertions(+), 55 deletions(-) diff --git a/src/server/ClientMsgRateLimiter.ts b/src/server/ClientMsgRateLimiter.ts index 986149da9..5177eb79c 100644 --- a/src/server/ClientMsgRateLimiter.ts +++ b/src/server/ClientMsgRateLimiter.ts @@ -3,18 +3,14 @@ import { ClientID } from "../core/Schemas"; const INTENTS_PER_SECOND = 10; const INTENTS_PER_MINUTE = 150; -const MAX_BYTES_PER_MINUTE = 25 * 1024; // 25KB/min per client -const MAX_INTENT_BYTES = 500; // intents are stored in turns, keep them small +const MAX_INTENT_SIZE = 500; +const TOTAL_BYTES = 2 * 1024 * 1024; // 2MB per client export type RateLimitResult = "ok" | "limit" | "kick"; -// Allow 3 winner messages per client since a player can rejoin and resend. -const MAX_WINNER_MSGS = 3; - interface ClientBucket { perSecond: RateLimiter; perMinute: RateLimiter; - bytesPerMinute: RateLimiter; - winnerMsgCount: number; + totalBytes: number; } export class ClientMsgRateLimiter { @@ -22,27 +18,27 @@ export class ClientMsgRateLimiter { check(clientID: ClientID, type: string, bytes: number): RateLimitResult { const bucket = this.getOrCreate(clientID); + bucket.totalBytes += bytes; - // Winner message contains stats for all players and can be large (100s of KB). - // It bypasses the byte rate limit but is strictly limited to one per client. - if (type === "winner") { - if (bucket.winnerMsgCount >= MAX_WINNER_MSGS) return "kick"; - bucket.winnerMsgCount++; - return "ok"; + if (bucket.totalBytes >= TOTAL_BYTES) return "kick"; + + if (type === "intent") { + // Intents are stored in turn history for the duration of the game, so + // oversized intents would accumulate and fill up server RAM. + // Intents are also sent to all players, so it increase outgoing + // data. + // Intents should never be larger than MAX_INTENT_SIZE, so we assume the client is malicious. + if (bytes > MAX_INTENT_SIZE) { + return "kick"; + } + if ( + !bucket.perSecond.tryRemoveTokens(1) || + !bucket.perMinute.tryRemoveTokens(1) + ) { + return "limit"; + } } - // Intents are stored in turn history for the duration of the game, so - // oversized intents would accumulate and fill up server RAM. - if (type === "intent" && bytes > MAX_INTENT_BYTES) return "kick"; - - if (!bucket.bytesPerMinute.tryRemoveTokens(bytes)) return "kick"; - - if ( - !bucket.perSecond.tryRemoveTokens(1) || - !bucket.perMinute.tryRemoveTokens(1) - ) - return "limit"; - return "ok"; } @@ -60,11 +56,7 @@ export class ClientMsgRateLimiter { tokensPerInterval: INTENTS_PER_MINUTE, interval: "minute", }), - bytesPerMinute: new RateLimiter({ - tokensPerInterval: MAX_BYTES_PER_MINUTE, - interval: "minute", - }), - winnerMsgCount: 0, + totalBytes: 0, }; this.buckets.set(clientID, bucket); return bucket; diff --git a/tests/server/ClientMsgRateLimiter.test.ts b/tests/server/ClientMsgRateLimiter.test.ts index 263464485..9732d3c40 100644 --- a/tests/server/ClientMsgRateLimiter.test.ts +++ b/tests/server/ClientMsgRateLimiter.test.ts @@ -5,7 +5,6 @@ const CLIENT_A = "clientA" as any; const CLIENT_B = "clientB" as any; const SMALL = 100; -const LARGE = 501; // over MAX_INTENT_BYTES describe("ClientMsgRateLimiter", () => { describe("intent messages", () => { @@ -14,11 +13,6 @@ describe("ClientMsgRateLimiter", () => { expect(limiter.check(CLIENT_A, "intent", SMALL)).toBe("ok"); }); - it("kicks on oversized intent", () => { - const limiter = new ClientMsgRateLimiter(); - expect(limiter.check(CLIENT_A, "intent", LARGE)).toBe("kick"); - }); - it("limits when per-second count exceeded", () => { const limiter = new ClientMsgRateLimiter(); for (let i = 0; i < 10; i++) { @@ -36,34 +30,46 @@ describe("ClientMsgRateLimiter", () => { }); }); - describe("winner messages", () => { - it("allows first winner message", () => { + describe("non-intent messages", () => { + it("does not rate-limit non-intent messages", () => { const limiter = new ClientMsgRateLimiter(); - expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("ok"); + for (let i = 0; i < 20; i++) { + expect(limiter.check(CLIENT_A, "winner", 50)).toBe("ok"); + } }); - it("allows up to 3 winner messages", () => { + it("does not rate-limit ping messages", () => { const limiter = new ClientMsgRateLimiter(); - expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("ok"); - expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("ok"); - expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("ok"); - expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("kick"); - }); - - it("winner does not consume intent rate limit", () => { - const limiter = new ClientMsgRateLimiter(); - limiter.check(CLIENT_A, "winner", 50000); - expect(limiter.check(CLIENT_A, "intent", SMALL)).toBe("ok"); + for (let i = 0; i < 20; i++) { + expect(limiter.check(CLIENT_A, "ping", 50)).toBe("ok"); + } }); }); - describe("other messages", () => { - it("applies rate limiting to other message types", () => { + describe("total bytes limit", () => { + it("kicks when cumulative bytes reach 2MB", () => { const limiter = new ClientMsgRateLimiter(); - for (let i = 0; i < 10; i++) { - expect(limiter.check(CLIENT_A, "ping", 50)).toBe("ok"); + const chunkSize = 512 * 1024; // 512KB + // Send 3 chunks = 1.5MB, should be ok + for (let i = 0; i < 3; i++) { + expect(limiter.check(CLIENT_A, "other", chunkSize)).toBe("ok"); } - expect(limiter.check(CLIENT_A, "ping", 50)).toBe("limit"); + // 4th chunk pushes to 2MB, should kick + expect(limiter.check(CLIENT_A, "other", chunkSize)).toBe("kick"); + }); + + it("byte tracking is per client", () => { + const limiter = new ClientMsgRateLimiter(); + const almostFull = 2 * 1024 * 1024 - 1; + expect(limiter.check(CLIENT_A, "other", almostFull)).toBe("ok"); + // CLIENT_B should still be fine + expect(limiter.check(CLIENT_B, "other", 100)).toBe("ok"); + }); + + it("kicks on bytes regardless of message type", () => { + const limiter = new ClientMsgRateLimiter(); + const twoMB = 2 * 1024 * 1024; + expect(limiter.check(CLIENT_A, "intent", twoMB)).toBe("kick"); }); }); }); From dd2c239aa1665c46731c964cea53af61448bf8fe Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 16 Mar 2026 20:45:05 -0700 Subject: [PATCH 11/29] Have Worker rate limit ws messages (#3449) ## Description: Prevent client from spamming ws messages before joining a game server. ## 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: evan --- src/server/Worker.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/server/Worker.ts b/src/server/Worker.ts index e72276819..497904334 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -3,6 +3,7 @@ import express, { NextFunction, Request, Response } from "express"; import rateLimit from "express-rate-limit"; import http from "http"; import ipAnonymize from "ip-anonymize"; +import { RateLimiter } from "limiter"; import path from "path"; import { fileURLToPath } from "url"; import { WebSocket, WebSocketServer } from "ws"; @@ -289,6 +290,11 @@ export async function startWorker() { : // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing forwarded || req.socket.remoteAddress || "unknown"; + if (!getWsIpLimiter(ip).tryRemoveTokens(1)) { + ws.close(1008, "Rate limit exceeded"); + return; + } + try { // Parse and handle client messages const parsed = ClientMessageSchema.safeParse( @@ -609,3 +615,21 @@ function generateGameIdForWorker(): GameID | null { log.warn(`Failed to generate game ID for worker ${workerId}`); return null; } + +// Per-IP rate limiter for pre-join WebSocket messages. +// Prevents unauthenticated connections from spamming messages +// (e.g. pings) before joining a game. +const wsIpLimiters = new Map(); +function getWsIpLimiter(ip: string): RateLimiter { + let limiter = wsIpLimiters.get(ip); + if (!limiter) { + limiter = new RateLimiter({ + tokensPerInterval: 5, + interval: "second", + }); + wsIpLimiters.set(ip, limiter); + } + return limiter; +} +// Clean up stale IP limiters every 10 minutes +setInterval(() => wsIpLimiters.clear(), 10 * 60 * 1000); From cce46ef126fe4ceea09d5a254c180ede0f84bc01 Mon Sep 17 00:00:00 2001 From: Evan Date: Tue, 17 Mar 2026 10:08:30 -0700 Subject: [PATCH 12/29] Refactor: use promises instead of callbacks for joining a game (#3452) ## Description: Simplifies the interface a bit. ## 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: evan --- src/client/ClientGameRunner.ts | 47 +++++--- src/client/Main.ts | 208 ++++++++++++++++----------------- 2 files changed, 131 insertions(+), 124 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 09271afda..0e2eb960c 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -64,15 +64,24 @@ export interface LobbyConfig { gameRecord?: GameRecord; } +export interface JoinLobbyResult { + stop: (force?: boolean) => boolean; + prestart: Promise; + join: Promise; +} + export function joinLobby( eventBus: EventBus, lobbyConfig: LobbyConfig, - onPrestart: () => void, - onJoin: () => void, -): (force?: boolean) => boolean { +): JoinLobbyResult { // Mutable clientID state — assigned by server (multiplayer) or derived from gameStartInfo (singleplayer) let clientID: ClientID | undefined; + let resolvePrestart: () => void; + let resolveJoin: () => void; + const prestartPromise = new Promise((r) => (resolvePrestart = r)); + const joinPromise = new Promise((r) => (resolveJoin = r)); + console.log(`joining lobby: gameID: ${lobbyConfig.gameID}`); const userSettings: UserSettings = new UserSettings(); @@ -105,17 +114,17 @@ export function joinLobby( message.gameMapSize, terrainMapFileLoader, ); - onPrestart(); + resolvePrestart(); } if (message.type === "start") { // Trigger prestart for singleplayer games - onPrestart(); + resolvePrestart(); console.log( `lobby: game started: ${JSON.stringify(message, replacer, 2)}`, ); // Server tells us our assigned clientID (also sent on start for late joins) clientID = message.myClientID; - onJoin(); + resolveJoin(); // For multiplayer games, GameStartInfo is not known until game starts. lobbyConfig.gameStartInfo = message.gameStartInfo; createClientGame( @@ -176,19 +185,19 @@ export function joinLobby( } }; transport.connect(onconnect, onmessage); - return (force: boolean = false) => { - if (!force && currentGameRunner?.shouldPreventWindowClose()) { - console.log("Player is active, prevent leaving game"); - - return false; - } - - console.log("leaving game"); - - currentGameRunner = null; - transport.leaveGame(); - - return true; + return { + stop: (force: boolean = false) => { + if (!force && currentGameRunner?.shouldPreventWindowClose()) { + console.log("Player is active, prevent leaving game"); + return false; + } + console.log("leaving game"); + currentGameRunner = null; + transport.leaveGame(); + return true; + }, + prestart: prestartPromise, + join: joinPromise, }; } diff --git a/src/client/Main.ts b/src/client/Main.ts index 8499d84dd..55406ed30 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -15,7 +15,7 @@ import { UserSettings } from "../core/game/UserSettings"; import "./AccountModal"; import { getUserMe } from "./Api"; import { userAuth } from "./Auth"; -import { joinLobby } from "./ClientGameRunner"; +import { joinLobby, type JoinLobbyResult } from "./ClientGameRunner"; import { getPlayerCosmeticsRefs } from "./Cosmetics"; import { crazyGamesSDK } from "./CrazyGamesSDK"; import "./FlagInput"; @@ -230,7 +230,7 @@ export interface JoinLobbyEvent { } class Client { - private gameStop: ((force?: boolean) => boolean) | null = null; + private lobbyHandle: JoinLobbyResult | null = null; private eventBus: EventBus = new EventBus(); private currentUrl: string | null = null; @@ -300,8 +300,8 @@ class Client { window.addEventListener("beforeunload", async () => { console.log("Browser is closing"); - if (this.gameStop !== null) { - this.gameStop(true); + if (this.lobbyHandle !== null) { + this.lobbyHandle.stop(true); await crazyGamesSDK.gameplayStop(); } }); @@ -521,10 +521,10 @@ class Client { }; const onPopState = () => { - if (this.currentUrl !== null && this.gameStop !== null) { + if (this.currentUrl !== null && this.lobbyHandle !== null) { console.info("Game is active"); - if (!this.gameStop()) { + if (!this.lobbyHandle.stop()) { console.info("Player is active, ask before leaving game"); const isConfirmed = confirm( @@ -552,7 +552,7 @@ class Client { }; const onJoinChanged = () => { - if (this.gameStop !== null) { + if (this.lobbyHandle !== null) { this.handleLeaveLobby(); } @@ -733,9 +733,9 @@ class Client { private async handleJoinLobby(event: CustomEvent) { const lobby = event.detail; console.log(`joining lobby ${lobby.gameID}`); - if (this.gameStop !== null) { + if (this.lobbyHandle !== null) { console.log("joining lobby, stopping existing game"); - this.gameStop(true); + this.lobbyHandle.stop(true); document.body.classList.remove("in-game"); } if (lobby.source === "public") { @@ -746,106 +746,104 @@ class Client { if (lobby.source !== "public") { this.updateJoinUrlForShare(lobby.gameID, config); } - this.gameStop = joinLobby( - this.eventBus, - { - gameID: lobby.gameID, - serverConfig: config, - cosmetics: await getPlayerCosmeticsRefs(), - turnstileToken: await this.getTurnstileToken(lobby), - playerName: - this.usernameInput?.getCurrentUsername() ?? genAnonUsername(), - gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info, - gameRecord: lobby.gameRecord, - }, - () => { - console.log("Closing modals"); - document.getElementById("settings-button")?.classList.add("hidden"); - if (this.usernameInput) { - // fix edge case where username-validation-error is re-rendered and hidden tag removed - this.usernameInput.validationError = ""; + this.lobbyHandle = joinLobby(this.eventBus, { + gameID: lobby.gameID, + serverConfig: config, + cosmetics: await getPlayerCosmeticsRefs(), + turnstileToken: await this.getTurnstileToken(lobby), + playerName: this.usernameInput?.getCurrentUsername() ?? genAnonUsername(), + gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info, + gameRecord: lobby.gameRecord, + }); + + this.lobbyHandle.prestart.then(() => { + console.log("Closing modals"); + document.getElementById("settings-button")?.classList.add("hidden"); + if (this.usernameInput) { + // fix edge case where username-validation-error is re-rendered and hidden tag removed + this.usernameInput.validationError = ""; + } + document + .getElementById("username-validation-error") + ?.classList.add("hidden"); + this.joinModal?.closeWithoutLeaving(); + [ + "single-player-modal", + "host-lobby-modal", + "game-starting-modal", + "game-top-bar", + "help-modal", + "user-setting", + "troubleshooting-modal", + "territory-patterns-modal", + "language-modal", + "news-modal", + "flag-input-modal", + "account-button", + "leaderboard-button", + "token-login", + "matchmaking-modal", + "lang-selector", + "gutter-ads", + ].forEach((tag) => { + const modal = document.querySelector(tag) as HTMLElement & { + close?: () => void; + isModalOpen?: boolean; + }; + if (modal?.close) { + modal.close(); + } else if (modal && "isModalOpen" in modal) { + modal.isModalOpen = false; } - document - .getElementById("username-validation-error") - ?.classList.add("hidden"); - this.joinModal?.closeWithoutLeaving(); - [ - "single-player-modal", - "host-lobby-modal", - "game-starting-modal", - "game-top-bar", - "help-modal", - "user-setting", - "troubleshooting-modal", - "territory-patterns-modal", - "language-modal", - "news-modal", - "flag-input-modal", - "account-button", - "leaderboard-button", - "token-login", - "matchmaking-modal", - "lang-selector", - "gutter-ads", - ].forEach((tag) => { - const modal = document.querySelector(tag) as HTMLElement & { - close?: () => void; - isModalOpen?: boolean; - }; - if (modal?.close) { - modal.close(); - } else if (modal && "isModalOpen" in modal) { - modal.isModalOpen = false; - } - }); - this.gameModeSelector.stop(); - document.querySelectorAll(".ad").forEach((ad) => { - (ad as HTMLElement).style.display = "none"; - }); + }); + this.gameModeSelector.stop(); + document.querySelectorAll(".ad").forEach((ad) => { + (ad as HTMLElement).style.display = "none"; + }); - crazyGamesSDK.loadingStart(); + crazyGamesSDK.loadingStart(); - // show when the game loads - const startingModal = document.querySelector( - "game-starting-modal", - ) as GameStartingModal; - if (startingModal && startingModal instanceof GameStartingModal) { - startingModal.show(); - } - }, - () => { - this.joinModal?.closeWithoutLeaving(); - this.gameModeSelector.stop(); - incrementGamesPlayed(); + // show when the game loads + const startingModal = document.querySelector( + "game-starting-modal", + ) as GameStartingModal; + if (startingModal && startingModal instanceof GameStartingModal) { + startingModal.show(); + } + }); - document.querySelectorAll(".ad").forEach((ad) => { - (ad as HTMLElement).style.display = "none"; - }); + this.lobbyHandle.join.then(() => { + this.joinModal?.closeWithoutLeaving(); + this.gameModeSelector.stop(); + incrementGamesPlayed(); - if (window.PageOS?.session?.newPageView) { - window.PageOS.session.newPageView(); - } - crazyGamesSDK.loadingStop(); - crazyGamesSDK.gameplayStart(); - document.body.classList.add("in-game"); + document.querySelectorAll(".ad").forEach((ad) => { + (ad as HTMLElement).style.display = "none"; + }); - // Ensure there's a homepage entry in history before adding the lobby entry - if (window.location.hash === "" || window.location.hash === "#") { - history.replaceState(null, "", window.location.origin + "#refresh"); - } - const lobbyIdHidden = !this.userSettings.lobbyIdVisibility(); - history.pushState( - null, - "", - lobbyIdHidden - ? "/streamer-mode" - : `/${config.workerPath(lobby.gameID)}/game/${lobby.gameID}?live`, - ); + if (window.PageOS?.session?.newPageView) { + window.PageOS.session.newPageView(); + } + crazyGamesSDK.loadingStop(); + crazyGamesSDK.gameplayStart(); + document.body.classList.add("in-game"); - // Store current URL for popstate confirmation - this.currentUrl = window.location.href; - }, - ); + // Ensure there's a homepage entry in history before adding the lobby entry + if (window.location.hash === "" || window.location.hash === "#") { + history.replaceState(null, "", window.location.origin + "#refresh"); + } + const lobbyIdHidden = !this.userSettings.lobbyIdVisibility(); + history.pushState( + null, + "", + lobbyIdHidden + ? "/streamer-mode" + : `/${config.workerPath(lobby.gameID)}/game/${lobby.gameID}?live`, + ); + + // Store current URL for popstate confirmation + this.currentUrl = window.location.href; + }); } private updateJoinUrlForShare( @@ -864,12 +862,12 @@ class Client { } private async handleLeaveLobby(/* event: CustomEvent */) { - if (this.gameStop === null) { + if (this.lobbyHandle === null) { return; } console.log("leaving lobby, cancelling game"); - this.gameStop(true); - this.gameStop = null; + this.lobbyHandle.stop(true); + this.lobbyHandle = null; this.currentUrl = null; try { From 0dc8fbf1298984c0fb2b0db4b48853a00a5c3f97 Mon Sep 17 00:00:00 2001 From: VariableVince <24507472+VariableVince@users.noreply.github.com> Date: Tue, 17 Mar 2026 19:15:18 +0100 Subject: [PATCH 13/29] Fix inverse annexation (#3448) ## Description: An inverse annexation could happen where the small player (even with 0,01% tiles owned) could fully annex the large player. **TL;DR:** basically wrong use of calculateBoundingBox in surroundedBySamePlayer, feeding it all bordertiles, making enemyBox far bigger than it actually was in some cases. Which resulted in enemyBox of small player with two small clusters at some distance from each other, being seen as inscribing the largest cluster of the bigger player. While that largest cluster is actually the border tiles of the bigger player surrounding the main cluster of the small player. Instead of an annexation of small by bigger, small would incorrectly annex bigger completely. **Situation:** bigger player fully surrounds main cluster of smaller player. Those border tiles are also the largest cluster of the bigger player, for which surroundedBySamePlayer is called. SurroundedBySamePlayer finds the small player as the only bordering enemy of this cluster. Then it needs to check which of the two players is surrounded by the other one. EnemyBox uses calculateBoundingBox with all border tiles of the small player as argument. The small player also has at least one seperate cluster elsewhere, could be on another island, which count as border tiles too. The enemyBox from the main cluster of the small player to the seperate cluster elsewhere, can be huge. Now inscribed() is called and it determines that largest cluster box of the bigger player (which was in fact calculated correctly, also making use of calculateBoundingBox) is surrounded by the bigger enemyBox. And so the small surrounded player fully annexes the bigger player. **Fix:** instead of a global enemyBox, we only need the localEnemyBox that touches the largest cluster of the bigger player. With that, inscribed() can correctly conclude that largest cluster box surrounds the localEnemyBox. As a matter of fact isSurrounded() already used the same method to calculate its enemyBox as introduced by @scamiv for v30: https://github.com/openfrontio/OpenFrontIO/pull/3127/changes#diff-fb1101a2b50dd7c353d082ff7a3351cff5469b8249b3ebca91c10573a3dfaaf1 - Change in PlayerExecution - Added test NoInverseAnnexation.test.ts, which fails before and passes after the fix The bug was introduced in this commit 10 months ago: https://github.com/openfrontio/OpenFrontIO/commit/c4381a9ad3828b06764ab1a21fc1514e37aacfd7 It has probably led to some weird annexations happening since then. The bug could seemingly happen on any map. But was noted recently a few times on square islands (Sierpinski) or maps (The Box/The Alps), where the circumstances probably highten the chances of the bug occuring. **Bug reports:** https://discord.com/channels/1359946986937258015/1481916231689703477/1481916231689703477 https://discord.com/channels/1359946986937258015/1481916231689703477/1481963273367851030 https://discord.com/channels/1284581928254701718/1479993924432171008/1479995658302652496 https://discord.com/channels/1284581928254701718/1479993924432171008/1481865495492956182 https://discord.com/channels/1284581928254701718/1483047153571201034 **BEFORE:** https://github.com/user-attachments/assets/4440182b-f696-45cf-bb01-b10159df8763 **AFTER**, on the same replay but with the bugfix: https://github.com/user-attachments/assets/5f461ab2-eb62-4cc3-ae07-e2224adbbc6a ## 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: tryout33 --- src/core/execution/PlayerExecution.ts | 20 +++++- .../executions/NoInverseAnnexation.test.ts | 72 +++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 tests/core/executions/NoInverseAnnexation.test.ts diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index 28c734d12..45959fbc8 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -157,6 +157,12 @@ export class PlayerExecution implements Execution { clusterBox: { min: Cell; max: Cell }, ): false | Player { const enemies = new Set(); + + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (const tile of cluster) { let hasUnownedNeighbor = false; if (this.mg.isOceanShore(tile) || this.mg.isOnEdgeOfMap(tile)) { @@ -170,6 +176,12 @@ export class PlayerExecution implements Execution { const ownerId = this.mg.ownerID(n); if (ownerId !== this.player.smallID()) { enemies.add(ownerId); + const px = this.mg.x(n); + const py = this.mg.y(n); + minX = Math.min(minX, px); + minY = Math.min(minY, py); + maxX = Math.max(maxX, px); + maxY = Math.max(maxY, py); } }); if (hasUnownedNeighbor) { @@ -182,9 +194,13 @@ export class PlayerExecution implements Execution { if (enemies.size !== 1) { return false; } + const enemy = this.mg.playerBySmallID(Array.from(enemies)[0]) as Player; - const enemyBox = calculateBoundingBox(this.mg, enemy.borderTiles()); - if (inscribed(enemyBox, clusterBox)) { + const localEnemyBox = { + min: new Cell(minX, minY), + max: new Cell(maxX, maxY), + }; + if (inscribed(localEnemyBox, clusterBox)) { return enemy; } return false; diff --git a/tests/core/executions/NoInverseAnnexation.test.ts b/tests/core/executions/NoInverseAnnexation.test.ts new file mode 100644 index 000000000..e72bc9938 --- /dev/null +++ b/tests/core/executions/NoInverseAnnexation.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { PlayerExecution } from "../../../src/core/execution/PlayerExecution"; +import { + Game, + Player, + PlayerInfo, + PlayerType, +} from "../../../src/core/game/Game"; +import { setup } from "../../util/Setup"; +import { executeTicks } from "../../util/utils"; + +let game: Game; +let largePlayer: Player; +let smallPlayer: Player; + +describe("PlayerExecution Annexation Bug", () => { + beforeEach(async () => { + game = await setup( + "big_plains", + { + infiniteGold: true, + instantBuild: true, + }, + [ + new PlayerInfo("large", PlayerType.Human, "client1", "large_id"), + new PlayerInfo("small", PlayerType.Human, "client2", "small_id"), + ], + ); + + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + largePlayer = game.player("large_id"); + smallPlayer = game.player("small_id"); + + game.addExecution(new PlayerExecution(largePlayer)); + game.addExecution(new PlayerExecution(smallPlayer)); + }); + + test("A large player is not reverse-annexed by surrounded smaller player", () => { + // Cluster A + smallPlayer.conquer(game.ref(50, 50)); + smallPlayer.conquer(game.ref(50, 51)); + smallPlayer.conquer(game.ref(51, 50)); + smallPlayer.conquer(game.ref(51, 51)); + // Cluster B + smallPlayer.conquer(game.ref(10, 10)); + smallPlayer.conquer(game.ref(90, 90)); + + // Larger player gets the rest + game.map().forEachTile((tile) => { + if (game.ownerID(tile) !== smallPlayer.smallID()) { + largePlayer.conquer(tile); + } + }); + + const initialLargeTiles = largePlayer.numTilesOwned(); + expect(largePlayer.numTilesOwned()).toBe(initialLargeTiles); + expect(smallPlayer.numTilesOwned()).toBeGreaterThan(0); + + // Keep ticksPerClusterCalc and lastTileChange in mind + executeTicks(game, 20); + largePlayer.conquer(game.ref(49, 49)); + smallPlayer.conquer(game.ref(50, 50)); + + // Annexation happens here + executeTicks(game, 50); + expect(largePlayer.numTilesOwned()).toBeGreaterThan(initialLargeTiles); + expect(smallPlayer.numTilesOwned()).toBe(0); + }); +}); From 11e183366fff62a951572049254e43c126b9b02c Mon Sep 17 00:00:00 2001 From: VariableVince <24507472+VariableVince@users.noreply.github.com> Date: Tue, 17 Mar 2026 19:15:18 +0100 Subject: [PATCH 14/29] Fix inverse annexation (#3448) ## Description: An inverse annexation could happen where the small player (even with 0,01% tiles owned) could fully annex the large player. **TL;DR:** basically wrong use of calculateBoundingBox in surroundedBySamePlayer, feeding it all bordertiles, making enemyBox far bigger than it actually was in some cases. Which resulted in enemyBox of small player with two small clusters at some distance from each other, being seen as inscribing the largest cluster of the bigger player. While that largest cluster is actually the border tiles of the bigger player surrounding the main cluster of the small player. Instead of an annexation of small by bigger, small would incorrectly annex bigger completely. **Situation:** bigger player fully surrounds main cluster of smaller player. Those border tiles are also the largest cluster of the bigger player, for which surroundedBySamePlayer is called. SurroundedBySamePlayer finds the small player as the only bordering enemy of this cluster. Then it needs to check which of the two players is surrounded by the other one. EnemyBox uses calculateBoundingBox with all border tiles of the small player as argument. The small player also has at least one seperate cluster elsewhere, could be on another island, which count as border tiles too. The enemyBox from the main cluster of the small player to the seperate cluster elsewhere, can be huge. Now inscribed() is called and it determines that largest cluster box of the bigger player (which was in fact calculated correctly, also making use of calculateBoundingBox) is surrounded by the bigger enemyBox. And so the small surrounded player fully annexes the bigger player. **Fix:** instead of a global enemyBox, we only need the localEnemyBox that touches the largest cluster of the bigger player. With that, inscribed() can correctly conclude that largest cluster box surrounds the localEnemyBox. As a matter of fact isSurrounded() already used the same method to calculate its enemyBox as introduced by @scamiv for v30: https://github.com/openfrontio/OpenFrontIO/pull/3127/changes#diff-fb1101a2b50dd7c353d082ff7a3351cff5469b8249b3ebca91c10573a3dfaaf1 - Change in PlayerExecution - Added test NoInverseAnnexation.test.ts, which fails before and passes after the fix The bug was introduced in this commit 10 months ago: https://github.com/openfrontio/OpenFrontIO/commit/c4381a9ad3828b06764ab1a21fc1514e37aacfd7 It has probably led to some weird annexations happening since then. The bug could seemingly happen on any map. But was noted recently a few times on square islands (Sierpinski) or maps (The Box/The Alps), where the circumstances probably highten the chances of the bug occuring. **Bug reports:** https://discord.com/channels/1359946986937258015/1481916231689703477/1481916231689703477 https://discord.com/channels/1359946986937258015/1481916231689703477/1481963273367851030 https://discord.com/channels/1284581928254701718/1479993924432171008/1479995658302652496 https://discord.com/channels/1284581928254701718/1479993924432171008/1481865495492956182 https://discord.com/channels/1284581928254701718/1483047153571201034 **BEFORE:** https://github.com/user-attachments/assets/4440182b-f696-45cf-bb01-b10159df8763 **AFTER**, on the same replay but with the bugfix: https://github.com/user-attachments/assets/5f461ab2-eb62-4cc3-ae07-e2224adbbc6a ## 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: tryout33 --- src/core/execution/PlayerExecution.ts | 20 +++++- .../executions/NoInverseAnnexation.test.ts | 72 +++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 tests/core/executions/NoInverseAnnexation.test.ts diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index 28c734d12..45959fbc8 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -157,6 +157,12 @@ export class PlayerExecution implements Execution { clusterBox: { min: Cell; max: Cell }, ): false | Player { const enemies = new Set(); + + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (const tile of cluster) { let hasUnownedNeighbor = false; if (this.mg.isOceanShore(tile) || this.mg.isOnEdgeOfMap(tile)) { @@ -170,6 +176,12 @@ export class PlayerExecution implements Execution { const ownerId = this.mg.ownerID(n); if (ownerId !== this.player.smallID()) { enemies.add(ownerId); + const px = this.mg.x(n); + const py = this.mg.y(n); + minX = Math.min(minX, px); + minY = Math.min(minY, py); + maxX = Math.max(maxX, px); + maxY = Math.max(maxY, py); } }); if (hasUnownedNeighbor) { @@ -182,9 +194,13 @@ export class PlayerExecution implements Execution { if (enemies.size !== 1) { return false; } + const enemy = this.mg.playerBySmallID(Array.from(enemies)[0]) as Player; - const enemyBox = calculateBoundingBox(this.mg, enemy.borderTiles()); - if (inscribed(enemyBox, clusterBox)) { + const localEnemyBox = { + min: new Cell(minX, minY), + max: new Cell(maxX, maxY), + }; + if (inscribed(localEnemyBox, clusterBox)) { return enemy; } return false; diff --git a/tests/core/executions/NoInverseAnnexation.test.ts b/tests/core/executions/NoInverseAnnexation.test.ts new file mode 100644 index 000000000..e72bc9938 --- /dev/null +++ b/tests/core/executions/NoInverseAnnexation.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { PlayerExecution } from "../../../src/core/execution/PlayerExecution"; +import { + Game, + Player, + PlayerInfo, + PlayerType, +} from "../../../src/core/game/Game"; +import { setup } from "../../util/Setup"; +import { executeTicks } from "../../util/utils"; + +let game: Game; +let largePlayer: Player; +let smallPlayer: Player; + +describe("PlayerExecution Annexation Bug", () => { + beforeEach(async () => { + game = await setup( + "big_plains", + { + infiniteGold: true, + instantBuild: true, + }, + [ + new PlayerInfo("large", PlayerType.Human, "client1", "large_id"), + new PlayerInfo("small", PlayerType.Human, "client2", "small_id"), + ], + ); + + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + largePlayer = game.player("large_id"); + smallPlayer = game.player("small_id"); + + game.addExecution(new PlayerExecution(largePlayer)); + game.addExecution(new PlayerExecution(smallPlayer)); + }); + + test("A large player is not reverse-annexed by surrounded smaller player", () => { + // Cluster A + smallPlayer.conquer(game.ref(50, 50)); + smallPlayer.conquer(game.ref(50, 51)); + smallPlayer.conquer(game.ref(51, 50)); + smallPlayer.conquer(game.ref(51, 51)); + // Cluster B + smallPlayer.conquer(game.ref(10, 10)); + smallPlayer.conquer(game.ref(90, 90)); + + // Larger player gets the rest + game.map().forEachTile((tile) => { + if (game.ownerID(tile) !== smallPlayer.smallID()) { + largePlayer.conquer(tile); + } + }); + + const initialLargeTiles = largePlayer.numTilesOwned(); + expect(largePlayer.numTilesOwned()).toBe(initialLargeTiles); + expect(smallPlayer.numTilesOwned()).toBeGreaterThan(0); + + // Keep ticksPerClusterCalc and lastTileChange in mind + executeTicks(game, 20); + largePlayer.conquer(game.ref(49, 49)); + smallPlayer.conquer(game.ref(50, 50)); + + // Annexation happens here + executeTicks(game, 50); + expect(largePlayer.numTilesOwned()).toBeGreaterThan(initialLargeTiles); + expect(smallPlayer.numTilesOwned()).toBe(0); + }); +}); From 79c3deabd802016c1b101057d97d3cf60fe17f31 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Tue, 17 Mar 2026 11:40:42 -0700 Subject: [PATCH 15/29] remove spawn video ad --- index.html | 1 - src/client/components/VideoPromo.ts | 213 ------------------ src/client/graphics/GameRenderer.ts | 8 - .../graphics/layers/SpawnVideoReward.ts | 67 ------ 4 files changed, 289 deletions(-) delete mode 100644 src/client/components/VideoPromo.ts delete mode 100644 src/client/graphics/layers/SpawnVideoReward.ts diff --git a/index.html b/index.html index 46eca8f14..5db9deb74 100644 --- a/index.html +++ b/index.html @@ -320,7 +320,6 @@ - diff --git a/src/client/components/VideoPromo.ts b/src/client/components/VideoPromo.ts deleted file mode 100644 index 6fa108e8b..000000000 --- a/src/client/components/VideoPromo.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { LitElement, html } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; - -const VIDEO_AD_UNIT_TYPE = "precontent_ad_video"; - -@customElement("video-ad") -export class VideoAd extends LitElement { - @state() - private isVisible: boolean = true; - - @property({ attribute: false }) - onComplete?: () => void; - - @property({ attribute: false }) - onMidpoint?: () => void; - - @property({ attribute: false }) - onAdBlocked?: () => void; - - private adLoadTimeout: ReturnType | null = null; - private rampCheckInterval: ReturnType | null = null; - private rampWaitTimeout: ReturnType | null = null; - private adStarted = false; - - // How long to wait for ad to start before assuming it's blocked - private static readonly AD_LOAD_TIMEOUT_MS = 8000; - - createRenderRoot() { - return this; - } - - connectedCallback() { - super.connectedCallback(); - // Set dimensions on the custom element itself (required by Playwire) - // Playwire requires explicit pixel dimensions, use max-width for responsiveness - this.style.display = "block"; - this.style.width = "100%"; - this.style.maxWidth = "800px"; - this.style.aspectRatio = "16/9"; - this.showVideoAd(); - } - - disconnectedCallback() { - super.disconnectedCallback(); - // Clean up timeout if component is removed - if (this.adLoadTimeout) { - clearTimeout(this.adLoadTimeout); - this.adLoadTimeout = null; - } - if (this.rampCheckInterval) { - clearInterval(this.rampCheckInterval); - this.rampCheckInterval = null; - } - if (this.rampWaitTimeout) { - clearTimeout(this.rampWaitTimeout); - this.rampWaitTimeout = null; - } - } - - public showVideoAd(): void { - if (!window.ramp) { - // Wait for ramp to be available, but give up after timeout - this.rampCheckInterval = setInterval(() => { - if (window.ramp && window.ramp.que) { - if (this.rampCheckInterval) { - clearInterval(this.rampCheckInterval); - this.rampCheckInterval = null; - } - if (this.rampWaitTimeout) { - clearTimeout(this.rampWaitTimeout); - this.rampWaitTimeout = null; - } - this.loadVideoAd(); - } - }, 100); - - // Stop polling after timeout (e.g. adblocker preventing ramp from loading) - this.rampWaitTimeout = setTimeout(() => { - if (this.rampCheckInterval) { - clearInterval(this.rampCheckInterval); - this.rampCheckInterval = null; - } - console.log("[VideoAd] Ramp SDK never loaded - possible adblocker"); - this.handleAdBlocked(); - }, VideoAd.AD_LOAD_TIMEOUT_MS); - return; - } - - this.loadVideoAd(); - } - - private loadVideoAd(): void { - // Start timeout to detect if ad doesn't load (e.g., due to adblocker) - this.adLoadTimeout = setTimeout(() => { - if (!this.adStarted) { - console.log("[VideoAd] Ad load timeout - possible adblocker detected"); - this.handleAdBlocked(); - } - }, VideoAd.AD_LOAD_TIMEOUT_MS); - - // Set up event listeners when player is ready, chaining any existing handler - const prevOnPlayerReady = window.ramp.onPlayerReady; - window.ramp.onPlayerReady = () => { - if (prevOnPlayerReady) prevOnPlayerReady(); - if (window.Bolt) { - // Listen for ad start to know ad is loading successfully - window.Bolt.on( - VIDEO_AD_UNIT_TYPE, - window.Bolt.BOLT_AD_STARTED ?? "boltAdStarted", - () => { - console.log("[VideoAd] Ad started"); - this.adStarted = true; - // Clear the timeout since ad is playing - if (this.adLoadTimeout) { - clearTimeout(this.adLoadTimeout); - this.adLoadTimeout = null; - } - }, - ); - - window.Bolt.on(VIDEO_AD_UNIT_TYPE, window.Bolt.BOLT_AD_COMPLETE, () => { - console.log("[VideoAd] Ad completed"); - this.hideElement(); - }); - - window.Bolt.on(VIDEO_AD_UNIT_TYPE, window.Bolt.BOLT_AD_ERROR, () => { - console.log("[VideoAd] Ad error/no fill"); - this.handleAdBlocked(); - }); - - window.Bolt.on(VIDEO_AD_UNIT_TYPE, window.Bolt.BOLT_MIDPOINT, () => { - console.log("[VideoAd] Ad midpoint"); - if (this.onMidpoint) { - this.onMidpoint(); - } - }); - - window.Bolt.on( - VIDEO_AD_UNIT_TYPE, - window.Bolt.SHOW_HIDDEN_CONTAINER ?? "showHiddenContainer", - () => { - console.log("[VideoAd] Ad finished"); - this.hideElement(); - }, - ); - } - }; - - // Queue the video ad initialization - window.ramp.que.push(() => { - const pwUnits = [{ type: VIDEO_AD_UNIT_TYPE }]; - - window.ramp - .addUnits(pwUnits) - .then(() => { - window.ramp.displayUnits(); - }) - .catch((e: Error) => { - console.error("[VideoAd] Error adding units:", e); - window.ramp.displayUnits(); - }); - }); - } - - private handleAdBlocked(): void { - // Clear timeout if still pending - if (this.adLoadTimeout) { - clearTimeout(this.adLoadTimeout); - this.adLoadTimeout = null; - } - - // Call the callback if provided - if (this.onAdBlocked) { - this.onAdBlocked(); - } - } - - private hideElement(): void { - this.style.display = "none"; - this.isVisible = false; - // Call the callback if provided - if (this.onComplete) { - this.onComplete(); - } - // Also dispatch event for backwards compatibility - this.dispatchEvent( - new CustomEvent("ad-complete", { - bubbles: true, - composed: true, - }), - ); - } - - render() { - if (!this.isVisible) { - return html``; - } - - // Provide a container for the Playwire video player to render into - // Structure matches Playwire example: wrapper > game-video-ad > precontent-video-location - return html` -
-
-
- `; - } -} diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index fb0c96810..81d12fc79 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -36,7 +36,6 @@ import { ReplayPanel } from "./layers/ReplayPanel"; import { SAMRadiusLayer } from "./layers/SAMRadiusLayer"; import { SettingsModal } from "./layers/SettingsModal"; import { SpawnTimer } from "./layers/SpawnTimer"; -import { SpawnVideoAd } from "./layers/SpawnVideoReward"; import { StructureIconsLayer } from "./layers/StructureIconsLayer"; import { StructureLayer } from "./layers/StructureLayer"; import { TeamStats } from "./layers/TeamStats"; @@ -268,12 +267,6 @@ export function createRenderer( } inGamePromo.game = game; - const spawnVideoAd = document.querySelector("spawn-video-ad") as SpawnVideoAd; - if (!(spawnVideoAd instanceof SpawnVideoAd)) { - console.error("spawn video ad not found"); - } - spawnVideoAd.game = game; - // When updating these layers please be mindful of the order. // Try to group layers by the return value of shouldTransform. // Not grouping the layers may cause excessive calls to context.save() and context.restore(). @@ -320,7 +313,6 @@ export function createRenderer( headsUpMessage, multiTabModal, inGamePromo, - spawnVideoAd, alertFrame, performanceOverlay, ]; diff --git a/src/client/graphics/layers/SpawnVideoReward.ts b/src/client/graphics/layers/SpawnVideoReward.ts deleted file mode 100644 index 028ec89bc..000000000 --- a/src/client/graphics/layers/SpawnVideoReward.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { LitElement, html } from "lit"; -import { customElement, state } from "lit/decorators.js"; -import { crazyGamesSDK } from "src/client/CrazyGamesSDK"; -import { Platform } from "src/client/Platform"; -import { getGamesPlayed } from "src/client/Utils"; -import { GameType } from "src/core/game/Game"; -import { GameView } from "../../../core/game/GameView"; -import "../../components/VideoPromo"; -import { Layer } from "./Layer"; - -@customElement("spawn-video-ad") -export class SpawnVideoAd extends LitElement implements Layer { - public game: GameView; - - @state() private shouldShow = false; - @state() private adComplete = false; - - createRenderRoot() { - return this; - } - - init() { - if ( - !window.adsEnabled || - Platform.isMobileWidth || - crazyGamesSDK.isOnCrazyGames() || - this.game.config().gameConfig().gameType === GameType.Singleplayer || - getGamesPlayed() < 3 // Don't show to new players - ) { - return; - } - this.shouldShow = true; - } - - tick() { - if (this.adComplete) return; - // Hide when spawn phase ends - if (this.shouldShow && !this.game.inSpawnPhase()) { - this.shouldShow = false; - this.requestUpdate(); - } - } - - private handleComplete = () => { - this.adComplete = true; - this.shouldShow = false; - }; - - shouldTransform(): boolean { - return false; - } - - render() { - if (!this.shouldShow || this.adComplete) { - return html``; - } - - return html` -
- -
- `; - } -} From 82d0fb385d73f18d31a255c6f2e851d7bf02ec2c Mon Sep 17 00:00:00 2001 From: VariableVince <24507472+VariableVince@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:52:35 +0100 Subject: [PATCH 16/29] Fix "you didn't enter the lobby in time" when device clock isn't synced (#3451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: If the time on the local device differs from the server time, users may see the message “You did not join the lobby on time.” Resolve this by accounting for the time difference, reusing the logic in `JoinLobbyModal` that was previously in `GameModeSelector`, and centralizing it into `ServerTime.ts`. Bug reports: https://github.com/openfrontio/OpenFrontIO/issues/3428 https://discord.com/channels/1284581928254701718/1482511096597315815 https://discord.com/channels/1284581928254701718/1482382264011591781 Resolves #3428 ## 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: tryout33 --- src/client/GameModeSelector.ts | 11 ++--- src/client/JoinLobbyModal.ts | 16 ++++++- src/client/Utils.ts | 27 +++++++++++ tests/client/JoinLobbyModal.test.ts | 74 +++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 tests/client/JoinLobbyModal.test.ts diff --git a/src/client/GameModeSelector.ts b/src/client/GameModeSelector.ts index 6cf39a873..0163e8c68 100644 --- a/src/client/GameModeSelector.ts +++ b/src/client/GameModeSelector.ts @@ -18,8 +18,10 @@ import { JoinLobbyEvent } from "./Main"; import { SinglePlayerModal } from "./SinglePlayerModal"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; import { + calculateServerTimeOffset, getMapName, getModifierLabels, + getSecondsUntilServerTimestamp, renderDuration, translateText, } from "./Utils"; @@ -81,7 +83,7 @@ export class GameModeSelector extends LitElement { private handleLobbiesUpdate(lobbies: PublicGames) { this.lobbies = lobbies; - this.serverTimeOffset = lobbies.serverTime - Date.now(); + this.serverTimeOffset = calculateServerTimeOffset(lobbies.serverTime); document.dispatchEvent( new CustomEvent("public-lobbies-update", { detail: { payload: lobbies }, @@ -279,12 +281,7 @@ export class GameModeSelector extends LitElement { const useContain = aspectRatio !== undefined && (aspectRatio > 4 || aspectRatio < 0.25); const timeRemaining = lobby.startsAt - ? Math.max( - 0, - Math.floor( - (lobby.startsAt - this.serverTimeOffset - Date.now()) / 1000, - ), - ) + ? getSecondsUntilServerTimestamp(lobby.startsAt, this.serverTimeOffset) : undefined; let timeDisplay: string = ""; diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts index a05d768c9..4bea53d43 100644 --- a/src/client/JoinLobbyModal.ts +++ b/src/client/JoinLobbyModal.ts @@ -1,9 +1,12 @@ import { html, TemplateResult } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { + calculateServerTimeOffset, getActiveModifiers, getGameModeLabel, getMapName, + getSecondsUntilServerTimestamp, + getServerNow, renderDuration, renderNumber, translateText, @@ -44,6 +47,7 @@ export class JoinLobbyModal extends BaseModal { @state() private currentClientID: string = ""; @state() private nationCount: number = 0; @state() private lobbyStartAt: number | null = null; + @state() private serverTimeOffset: number = 0; @state() private isConnecting: boolean = true; @state() private lobbyCreatorClientID: string | null = null; @@ -77,7 +81,10 @@ export class JoinLobbyModal extends BaseModal { // Post-join state: show lobby info (identical for public & private) const secondsRemaining = this.lobbyStartAt !== null - ? Math.max(0, Math.floor((this.lobbyStartAt - Date.now()) / 1000)) + ? getSecondsUntilServerTimestamp( + this.lobbyStartAt, + this.serverTimeOffset, + ) : null; const statusLabel = secondsRemaining === null @@ -328,6 +335,7 @@ export class JoinLobbyModal extends BaseModal { this.players = []; this.nationCount = 0; this.lobbyStartAt = null; + this.serverTimeOffset = 0; this.lobbyCreatorClientID = null; this.isConnecting = true; this.handledJoinTimeout = false; @@ -377,6 +385,7 @@ export class JoinLobbyModal extends BaseModal { this.currentClientID = ""; this.nationCount = 0; this.lobbyStartAt = null; + this.serverTimeOffset = 0; this.lobbyCreatorClientID = null; this.isConnecting = true; this.leaveLobbyOnClose = true; @@ -513,6 +522,9 @@ export class JoinLobbyModal extends BaseModal { private updateFromLobby(lobby: GameInfo | PublicGameInfo) { this.players = "clients" in lobby ? (lobby.clients ?? []) : []; + if ("serverTime" in lobby && typeof lobby.serverTime === "number") { + this.serverTimeOffset = calculateServerTimeOffset(lobby.serverTime); + } this.lobbyStartAt = lobby.startsAt ?? null; this.syncCountdownTimer(); if (lobby.gameConfig) { @@ -577,7 +589,7 @@ export class JoinLobbyModal extends BaseModal { ) { return; } - if (Date.now() < this.lobbyStartAt) { + if (getServerNow(this.serverTimeOffset) < this.lobbyStartAt) { return; } this.handledJoinTimeout = true; diff --git a/src/client/Utils.ts b/src/client/Utils.ts index c9323bead..a775a6ae6 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -628,3 +628,30 @@ export function getDiscordAvatarUrl(user: { return null; } +export function calculateServerTimeOffset( + serverTimeMs: number, + localNowMs: number = Date.now(), +): number { + return serverTimeMs - localNowMs; +} + +export function getServerNow( + serverTimeOffsetMs: number, + localNowMs: number = Date.now(), +): number { + return localNowMs + serverTimeOffsetMs; +} + +export function getSecondsUntilServerTimestamp( + targetServerTimestampMs: number, + serverTimeOffsetMs: number, + localNowMs: number = Date.now(), +): number { + return Math.max( + 0, + Math.floor( + (targetServerTimestampMs - getServerNow(serverTimeOffsetMs, localNowMs)) / + 1000, + ), + ); +} diff --git a/tests/client/JoinLobbyModal.test.ts b/tests/client/JoinLobbyModal.test.ts new file mode 100644 index 000000000..53e9b35de --- /dev/null +++ b/tests/client/JoinLobbyModal.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { JoinLobbyModal } from "../../src/client/JoinLobbyModal"; + +describe("JoinLobbyModal server time offset", () => { + let nowMs = 0; + + beforeEach(() => { + vi.spyOn(Date, "now").mockImplementation(() => nowMs); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("updates serverTimeOffset from lobby serverTime", () => { + const modal = new JoinLobbyModal(); + (modal as any).syncCountdownTimer = vi.fn(); + + nowMs = 220_000; + (modal as any).updateFromLobby({ + gameID: "g1", + serverTime: 200_000, + startsAt: 230_000, + clients: [], + }); + + expect((modal as any).serverTimeOffset).toBe(-20_000); + expect((modal as any).lobbyStartAt).toBe(230_000); + }); + + it("does not trigger join timeout early when local clock is ahead", () => { + const modal = new JoinLobbyModal(); + const closeSpy = vi + .spyOn(modal, "closeAndLeave") + .mockImplementation(() => undefined); + const dispatchSpy = vi.spyOn(window, "dispatchEvent"); + + (modal as any).isModalOpen = true; + (modal as any).isConnecting = true; + (modal as any).handledJoinTimeout = false; + + // Local clock is +60s ahead of server clock. + nowMs = 160_000; + (modal as any).lobbyStartAt = 105_000; + (modal as any).serverTimeOffset = -60_000; + + (modal as any).checkForJoinTimeout(); + + expect(closeSpy).not.toHaveBeenCalled(); + expect(dispatchSpy).not.toHaveBeenCalled(); + expect((modal as any).handledJoinTimeout).toBe(false); + }); + + it("triggers join timeout once adjusted server time reaches lobbyStartAt", () => { + const modal = new JoinLobbyModal(); + const closeSpy = vi + .spyOn(modal, "closeAndLeave") + .mockImplementation(() => undefined); + const dispatchSpy = vi.spyOn(window, "dispatchEvent"); + + (modal as any).isModalOpen = true; + (modal as any).isConnecting = true; + (modal as any).handledJoinTimeout = false; + (modal as any).lobbyStartAt = 105_000; + (modal as any).serverTimeOffset = -60_000; + + nowMs = 165_000; + (modal as any).checkForJoinTimeout(); + + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect((modal as any).handledJoinTimeout).toBe(true); + }); +}); From 69525500141ce7f3ced0027f7bcd6acda5884562 Mon Sep 17 00:00:00 2001 From: VariableVince <24507472+VariableVince@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:08:52 +0100 Subject: [PATCH 17/29] Fix: player name and location on wrong spot on the map (#3455) ## Description: Fixes https://github.com/openfrontio/OpenFrontIO/issues/1021 Fixes issue that has been there since the beginning. Player name and location and conquest FX (swords) not being in the right place. It can happen at any time during a game and can be game-breaking in that regard. This makes it hard to find players, especially when trying to eliminate their last few tiles on some island. So when clicking name in leaderboard > wrong tiles. And when seeing name > above wrong tiles. Bug report: https://discord.com/channels/1284581928254701718/1444669324571967680 Also, when removing those last tiles, the wait time between updates of player location can make it frustrating to find and eliminate them fast. You need 2-3 clicks on their name in leaderboard, before finally being moved to their current location. **Cause:** largestClusterBoundingBox not being changed when last attack happened in same tick removeClusters last ran. **Fix:** Also call removeClusters, and therefore update largestClusterBoundingBox , when LastTileChange was AT lastCalc tick. **Also:** Run removeClusters if player owns less than 100 tiles, don't wait for ticksPerClusterCalc in that case. This way, sniping off the last couple of island tiles of the player is easier. So it doesn't take 2-3 clicks bbut just 1 click on the player name in the Leaderboard before the camera moves to the next little island they are on. Also their last clusters are annexed faster, only helping with the faster cleanup. I think this is an optional to the fix in this PR, but still an important QoL fix for sniping those last tiles quickly. **BEFORE:** https://github.com/user-attachments/assets/0960a4d3-7f8b-4368-9531-8244356bff17 **AFTER:** (also notice how it now just takes 1 click in the leaderboard to immediately go to their next location, not 2-3 clicks) https://youtu.be/qXJPekjsrP4 ## 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: tryout33 --- src/core/execution/PlayerExecution.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index 45959fbc8..32cbcbe1e 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -97,8 +97,11 @@ export class PlayerExecution implements Execution { } } - if (ticks - this.lastCalc > this.ticksPerClusterCalc) { - if (this.player.lastTileChange() > this.lastCalc) { + if ( + ticks - this.lastCalc > this.ticksPerClusterCalc || + this.player.numTilesOwned() < 100 + ) { + if (this.player.lastTileChange() >= this.lastCalc) { this.lastCalc = ticks; const start = performance.now(); this.removeClusters(); From 5c01e7a0c92417dfb582ae113de6077d7fa79831 Mon Sep 17 00:00:00 2001 From: VariableVince <24507472+VariableVince@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:08:52 +0100 Subject: [PATCH 18/29] Fix: player name and location on wrong spot on the map (#3455) ## Description: Fixes https://github.com/openfrontio/OpenFrontIO/issues/1021 Fixes issue that has been there since the beginning. Player name and location and conquest FX (swords) not being in the right place. It can happen at any time during a game and can be game-breaking in that regard. This makes it hard to find players, especially when trying to eliminate their last few tiles on some island. So when clicking name in leaderboard > wrong tiles. And when seeing name > above wrong tiles. Bug report: https://discord.com/channels/1284581928254701718/1444669324571967680 Also, when removing those last tiles, the wait time between updates of player location can make it frustrating to find and eliminate them fast. You need 2-3 clicks on their name in leaderboard, before finally being moved to their current location. **Cause:** largestClusterBoundingBox not being changed when last attack happened in same tick removeClusters last ran. **Fix:** Also call removeClusters, and therefore update largestClusterBoundingBox , when LastTileChange was AT lastCalc tick. **Also:** Run removeClusters if player owns less than 100 tiles, don't wait for ticksPerClusterCalc in that case. This way, sniping off the last couple of island tiles of the player is easier. So it doesn't take 2-3 clicks bbut just 1 click on the player name in the Leaderboard before the camera moves to the next little island they are on. Also their last clusters are annexed faster, only helping with the faster cleanup. I think this is an optional to the fix in this PR, but still an important QoL fix for sniping those last tiles quickly. **BEFORE:** https://github.com/user-attachments/assets/0960a4d3-7f8b-4368-9531-8244356bff17 **AFTER:** (also notice how it now just takes 1 click in the leaderboard to immediately go to their next location, not 2-3 clicks) https://youtu.be/qXJPekjsrP4 ## 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: tryout33 --- src/core/execution/PlayerExecution.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index 45959fbc8..32cbcbe1e 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -97,8 +97,11 @@ export class PlayerExecution implements Execution { } } - if (ticks - this.lastCalc > this.ticksPerClusterCalc) { - if (this.player.lastTileChange() > this.lastCalc) { + if ( + ticks - this.lastCalc > this.ticksPerClusterCalc || + this.player.numTilesOwned() < 100 + ) { + if (this.player.lastTileChange() >= this.lastCalc) { this.lastCalc = ticks; const start = performance.now(); this.removeClusters(); From 9f8a2d2d84cffe801822c9cb240b7209a892704d Mon Sep 17 00:00:00 2001 From: VariableVince <24507472+VariableVince@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:52:35 +0100 Subject: [PATCH 19/29] Fix "you didn't enter the lobby in time" when device clock isn't synced (#3451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: If the time on the local device differs from the server time, users may see the message “You did not join the lobby on time.” Resolve this by accounting for the time difference, reusing the logic in `JoinLobbyModal` that was previously in `GameModeSelector`, and centralizing it into `ServerTime.ts`. Bug reports: https://github.com/openfrontio/OpenFrontIO/issues/3428 https://discord.com/channels/1284581928254701718/1482511096597315815 https://discord.com/channels/1284581928254701718/1482382264011591781 Resolves #3428 ## 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: tryout33 --- src/client/GameModeSelector.ts | 11 ++--- src/client/JoinLobbyModal.ts | 16 ++++++- src/client/Utils.ts | 27 +++++++++++ tests/client/JoinLobbyModal.test.ts | 74 +++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 tests/client/JoinLobbyModal.test.ts diff --git a/src/client/GameModeSelector.ts b/src/client/GameModeSelector.ts index 6cf39a873..0163e8c68 100644 --- a/src/client/GameModeSelector.ts +++ b/src/client/GameModeSelector.ts @@ -18,8 +18,10 @@ import { JoinLobbyEvent } from "./Main"; import { SinglePlayerModal } from "./SinglePlayerModal"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; import { + calculateServerTimeOffset, getMapName, getModifierLabels, + getSecondsUntilServerTimestamp, renderDuration, translateText, } from "./Utils"; @@ -81,7 +83,7 @@ export class GameModeSelector extends LitElement { private handleLobbiesUpdate(lobbies: PublicGames) { this.lobbies = lobbies; - this.serverTimeOffset = lobbies.serverTime - Date.now(); + this.serverTimeOffset = calculateServerTimeOffset(lobbies.serverTime); document.dispatchEvent( new CustomEvent("public-lobbies-update", { detail: { payload: lobbies }, @@ -279,12 +281,7 @@ export class GameModeSelector extends LitElement { const useContain = aspectRatio !== undefined && (aspectRatio > 4 || aspectRatio < 0.25); const timeRemaining = lobby.startsAt - ? Math.max( - 0, - Math.floor( - (lobby.startsAt - this.serverTimeOffset - Date.now()) / 1000, - ), - ) + ? getSecondsUntilServerTimestamp(lobby.startsAt, this.serverTimeOffset) : undefined; let timeDisplay: string = ""; diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts index a05d768c9..4bea53d43 100644 --- a/src/client/JoinLobbyModal.ts +++ b/src/client/JoinLobbyModal.ts @@ -1,9 +1,12 @@ import { html, TemplateResult } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { + calculateServerTimeOffset, getActiveModifiers, getGameModeLabel, getMapName, + getSecondsUntilServerTimestamp, + getServerNow, renderDuration, renderNumber, translateText, @@ -44,6 +47,7 @@ export class JoinLobbyModal extends BaseModal { @state() private currentClientID: string = ""; @state() private nationCount: number = 0; @state() private lobbyStartAt: number | null = null; + @state() private serverTimeOffset: number = 0; @state() private isConnecting: boolean = true; @state() private lobbyCreatorClientID: string | null = null; @@ -77,7 +81,10 @@ export class JoinLobbyModal extends BaseModal { // Post-join state: show lobby info (identical for public & private) const secondsRemaining = this.lobbyStartAt !== null - ? Math.max(0, Math.floor((this.lobbyStartAt - Date.now()) / 1000)) + ? getSecondsUntilServerTimestamp( + this.lobbyStartAt, + this.serverTimeOffset, + ) : null; const statusLabel = secondsRemaining === null @@ -328,6 +335,7 @@ export class JoinLobbyModal extends BaseModal { this.players = []; this.nationCount = 0; this.lobbyStartAt = null; + this.serverTimeOffset = 0; this.lobbyCreatorClientID = null; this.isConnecting = true; this.handledJoinTimeout = false; @@ -377,6 +385,7 @@ export class JoinLobbyModal extends BaseModal { this.currentClientID = ""; this.nationCount = 0; this.lobbyStartAt = null; + this.serverTimeOffset = 0; this.lobbyCreatorClientID = null; this.isConnecting = true; this.leaveLobbyOnClose = true; @@ -513,6 +522,9 @@ export class JoinLobbyModal extends BaseModal { private updateFromLobby(lobby: GameInfo | PublicGameInfo) { this.players = "clients" in lobby ? (lobby.clients ?? []) : []; + if ("serverTime" in lobby && typeof lobby.serverTime === "number") { + this.serverTimeOffset = calculateServerTimeOffset(lobby.serverTime); + } this.lobbyStartAt = lobby.startsAt ?? null; this.syncCountdownTimer(); if (lobby.gameConfig) { @@ -577,7 +589,7 @@ export class JoinLobbyModal extends BaseModal { ) { return; } - if (Date.now() < this.lobbyStartAt) { + if (getServerNow(this.serverTimeOffset) < this.lobbyStartAt) { return; } this.handledJoinTimeout = true; diff --git a/src/client/Utils.ts b/src/client/Utils.ts index c9323bead..a775a6ae6 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -628,3 +628,30 @@ export function getDiscordAvatarUrl(user: { return null; } +export function calculateServerTimeOffset( + serverTimeMs: number, + localNowMs: number = Date.now(), +): number { + return serverTimeMs - localNowMs; +} + +export function getServerNow( + serverTimeOffsetMs: number, + localNowMs: number = Date.now(), +): number { + return localNowMs + serverTimeOffsetMs; +} + +export function getSecondsUntilServerTimestamp( + targetServerTimestampMs: number, + serverTimeOffsetMs: number, + localNowMs: number = Date.now(), +): number { + return Math.max( + 0, + Math.floor( + (targetServerTimestampMs - getServerNow(serverTimeOffsetMs, localNowMs)) / + 1000, + ), + ); +} diff --git a/tests/client/JoinLobbyModal.test.ts b/tests/client/JoinLobbyModal.test.ts new file mode 100644 index 000000000..53e9b35de --- /dev/null +++ b/tests/client/JoinLobbyModal.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { JoinLobbyModal } from "../../src/client/JoinLobbyModal"; + +describe("JoinLobbyModal server time offset", () => { + let nowMs = 0; + + beforeEach(() => { + vi.spyOn(Date, "now").mockImplementation(() => nowMs); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("updates serverTimeOffset from lobby serverTime", () => { + const modal = new JoinLobbyModal(); + (modal as any).syncCountdownTimer = vi.fn(); + + nowMs = 220_000; + (modal as any).updateFromLobby({ + gameID: "g1", + serverTime: 200_000, + startsAt: 230_000, + clients: [], + }); + + expect((modal as any).serverTimeOffset).toBe(-20_000); + expect((modal as any).lobbyStartAt).toBe(230_000); + }); + + it("does not trigger join timeout early when local clock is ahead", () => { + const modal = new JoinLobbyModal(); + const closeSpy = vi + .spyOn(modal, "closeAndLeave") + .mockImplementation(() => undefined); + const dispatchSpy = vi.spyOn(window, "dispatchEvent"); + + (modal as any).isModalOpen = true; + (modal as any).isConnecting = true; + (modal as any).handledJoinTimeout = false; + + // Local clock is +60s ahead of server clock. + nowMs = 160_000; + (modal as any).lobbyStartAt = 105_000; + (modal as any).serverTimeOffset = -60_000; + + (modal as any).checkForJoinTimeout(); + + expect(closeSpy).not.toHaveBeenCalled(); + expect(dispatchSpy).not.toHaveBeenCalled(); + expect((modal as any).handledJoinTimeout).toBe(false); + }); + + it("triggers join timeout once adjusted server time reaches lobbyStartAt", () => { + const modal = new JoinLobbyModal(); + const closeSpy = vi + .spyOn(modal, "closeAndLeave") + .mockImplementation(() => undefined); + const dispatchSpy = vi.spyOn(window, "dispatchEvent"); + + (modal as any).isModalOpen = true; + (modal as any).isConnecting = true; + (modal as any).handledJoinTimeout = false; + (modal as any).lobbyStartAt = 105_000; + (modal as any).serverTimeOffset = -60_000; + + nowMs = 165_000; + (modal as any).checkForJoinTimeout(); + + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect((modal as any).handledJoinTimeout).toBe(true); + }); +}); From 9785666b98f04150be3c3be1c084a50ccd119506 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:30:52 +0100 Subject: [PATCH 20/29] =?UTF-8?q?Fix=20labels=20in=20public=20lobby=20modi?= =?UTF-8?q?fier=20display=20=F0=9F=8F=B7=EF=B8=8F=20(#3456)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Fixes two issues in the join lobby modal's modifier display: 1. **Starting Gold**: Label showed "Starting Gold (Millions)" with value "5M", duplicating "millions". Now shows "Starting Gold" as the label, keeping "5M" as the value. 2. **Disable Alliances**: Label showed "Disable Alliances" with value "Enabled", which is confusing. Now shows "Alliances" as the label with "Disabled" as the value. Join Lobby Modal needs a general rework, I will probably make an PR ## 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: FloPinguin --- resources/lang/en.json | 5 ++++- src/client/Utils.ts | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index fdb3e53b5..7e1fbf8fe 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -24,6 +24,7 @@ "copied": "Copied!", "click_to_copy": "Click to copy", "enabled": "Enabled", + "disabled": "Disabled", "map_default": "Map default" }, "main": { @@ -488,8 +489,10 @@ "crowded": "Crowded", "hard_nations": "Hard Nations", "starting_gold": "{amount}M Starting Gold", + "starting_gold_label": "Starting Gold", "gold_multiplier": "x{amount} Gold Multiplier", - "disable_alliances": "Alliances Disabled" + "disable_alliances": "Alliances Disabled", + "disable_alliances_label": "Alliances" }, "select_lang": { "title": "Select Language" diff --git a/src/client/Utils.ts b/src/client/Utils.ts index a775a6ae6..e3f61d00d 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -159,7 +159,7 @@ export function getActiveModifiers( (modifiers.startingGold / 1_000_000).toPrecision(12), ); result.push({ - labelKey: "host_modal.starting_gold", + labelKey: "public_game_modifier.starting_gold_label", badgeKey: "public_game_modifier.starting_gold", badgeParams: { amount: millions, @@ -181,8 +181,9 @@ export function getActiveModifiers( } if (modifiers.isAlliancesDisabled) { result.push({ - labelKey: "host_modal.disable_alliances", + labelKey: "public_game_modifier.disable_alliances_label", badgeKey: "public_game_modifier.disable_alliances", + formattedValue: translateText("common.disabled"), }); } return result; From a30859d132fa9a9c4901ce4824fc55bb028c2d17 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:30:52 +0100 Subject: [PATCH 21/29] =?UTF-8?q?Fix=20labels=20in=20public=20lobby=20modi?= =?UTF-8?q?fier=20display=20=F0=9F=8F=B7=EF=B8=8F=20(#3456)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Fixes two issues in the join lobby modal's modifier display: 1. **Starting Gold**: Label showed "Starting Gold (Millions)" with value "5M", duplicating "millions". Now shows "Starting Gold" as the label, keeping "5M" as the value. 2. **Disable Alliances**: Label showed "Disable Alliances" with value "Enabled", which is confusing. Now shows "Alliances" as the label with "Disabled" as the value. Join Lobby Modal needs a general rework, I will probably make an PR ## 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: FloPinguin --- resources/lang/en.json | 5 ++++- src/client/Utils.ts | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index f0ad48559..145dd331b 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -24,6 +24,7 @@ "copied": "Copied!", "click_to_copy": "Click to copy", "enabled": "Enabled", + "disabled": "Disabled", "map_default": "Map default" }, "main": { @@ -486,8 +487,10 @@ "crowded": "Crowded", "hard_nations": "Hard Nations", "starting_gold": "{amount}M Starting Gold", + "starting_gold_label": "Starting Gold", "gold_multiplier": "x{amount} Gold Multiplier", - "disable_alliances": "Alliances Disabled" + "disable_alliances": "Alliances Disabled", + "disable_alliances_label": "Alliances" }, "select_lang": { "title": "Select Language" diff --git a/src/client/Utils.ts b/src/client/Utils.ts index a775a6ae6..e3f61d00d 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -159,7 +159,7 @@ export function getActiveModifiers( (modifiers.startingGold / 1_000_000).toPrecision(12), ); result.push({ - labelKey: "host_modal.starting_gold", + labelKey: "public_game_modifier.starting_gold_label", badgeKey: "public_game_modifier.starting_gold", badgeParams: { amount: millions, @@ -181,8 +181,9 @@ export function getActiveModifiers( } if (modifiers.isAlliancesDisabled) { result.push({ - labelKey: "host_modal.disable_alliances", + labelKey: "public_game_modifier.disable_alliances_label", badgeKey: "public_game_modifier.disable_alliances", + formattedValue: translateText("common.disabled"), }); } return result; From 1049b7e7dcd112f86e2e9d5a53c5785e66253ce7 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:55:47 +0000 Subject: [PATCH 22/29] Clan System Part 1 (#3276) ## Description: Properly split out clantags and usernames, a clantag should not be part of a username. image https://api.openfront.dev/game/ojkqZFb2 image 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 --- src/client/ClientGameRunner.ts | 3 + src/client/GameInfoModal.ts | 19 +- src/client/GameModeSelector.ts | 19 +- src/client/LocalServer.ts | 3 +- src/client/Main.ts | 8 +- src/client/SinglePlayerModal.ts | 6 +- src/client/Transport.ts | 1 + src/client/UsernameInput.ts | 121 +++++---- src/client/components/LobbyPlayerView.ts | 33 ++- .../baseComponents/ranking/GameInfoRanking.ts | 13 +- .../baseComponents/ranking/PlayerRow.ts | 2 +- .../leaderboard/LeaderboardPlayerList.ts | 24 +- src/client/graphics/NameBoxCalculator.ts | 2 +- src/client/graphics/layers/AttacksDisplay.ts | 8 +- src/client/graphics/layers/ChatModal.ts | 11 +- src/client/graphics/layers/EventsDisplay.ts | 18 +- src/client/graphics/layers/NameLayer.ts | 4 +- .../graphics/layers/PlayerInfoOverlay.ts | 4 +- .../graphics/layers/PlayerModerationModal.ts | 6 +- src/client/graphics/layers/PlayerPanel.ts | 10 +- src/client/graphics/layers/WinModal.ts | 2 +- src/core/ApiSchemas.ts | 23 +- src/core/GameRunner.ts | 1 + src/core/Schemas.ts | 23 +- src/core/Util.ts | 22 +- src/core/execution/MIRVExecution.ts | 2 +- src/core/execution/NukeExecution.ts | 4 +- src/core/game/Game.ts | 8 +- src/core/game/GameView.ts | 55 ++-- src/core/game/PlayerImpl.ts | 21 +- src/core/game/TeamAssignment.ts | 16 +- src/core/validations/username.ts | 29 ++- src/server/Client.ts | 2 +- src/server/GameManager.ts | 4 +- src/server/GamePreviewBuilder.ts | 15 +- src/server/GameServer.ts | 23 +- src/server/Privilege.ts | 64 ++--- src/server/Worker.ts | 27 +- tests/Censor.test.ts | 32 +++ tests/Disconnected.test.ts | 8 +- tests/GameInfoRanking.test.ts | 4 +- tests/NationCounterWarshipInfestation.test.ts | 16 +- tests/NationMIRV.test.ts | 8 +- tests/PlayerInfo.test.ts | 234 +++++------------- tests/Privilege.test.ts | 71 +++--- tests/TeamAssignment.test.ts | 5 +- .../graphics/layers/PlayerPanelKick.test.ts | 4 + 47 files changed, 507 insertions(+), 531 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 09271afda..78e589d91 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -56,6 +56,7 @@ export interface LobbyConfig { serverConfig: ServerConfig; cosmetics: PlayerCosmeticRefs; playerName: string; + playerClanTag: string | null; gameID: GameID; turnstileToken: string | null; // GameStartInfo only exists when playing a singleplayer game. @@ -228,6 +229,7 @@ async function createClientGame( gameMap, clientID, lobbyConfig.playerName, + lobbyConfig.playerClanTag, lobbyConfig.gameStartInfo.gameID, lobbyConfig.gameStartInfo.players, ); @@ -301,6 +303,7 @@ export class ClientGameRunner { { persistentID: getPersistentID(), username: this.lobby.playerName, + clanTag: this.lobby.playerClanTag ?? null, clientID: this.clientID, stats: update.allPlayersStats[this.clientID], }, diff --git a/src/client/GameInfoModal.ts b/src/client/GameInfoModal.ts index 9dc8bf395..198a292d3 100644 --- a/src/client/GameInfoModal.ts +++ b/src/client/GameInfoModal.ts @@ -4,7 +4,6 @@ import { GameEndInfo } from "../core/Schemas"; import { GameMapType } from "../core/game/Game"; import { fetchGameById } from "./Api"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; -import { UsernameInput } from "./UsernameInput"; import { renderDuration, translateText } from "./Utils"; import { PlayerInfo, @@ -28,7 +27,7 @@ export class GameInfoModal extends LitElement { @property({ type: String }) gameId: string | null = null; @property({ type: String }) rankType = RankType.Lifetime; - @state() private username: string | null = null; + @state() private currentClientID: string | null = null; @state() private isLoadingGame: boolean = true; private ranking: Ranking | null = null; @@ -152,7 +151,7 @@ export class GameInfoModal extends LitElement { .score=${this.ranking?.score(player, this.rankType) ?? 0} .rankType=${this.rankType} .bestScore=${bestScore} - .currentPlayer=${this.username === player.rawUsername} + .currentPlayer=${this.currentClientID === player.id} > `, )} @@ -183,26 +182,16 @@ export class GameInfoModal extends LitElement { } } - public loadUserName() { - const usernameInput = document.querySelector( - "username-input", - ) as UsernameInput; - if (usernameInput) { - this.username = usernameInput.getCurrentUsername(); - } - } - - public async loadGame(gameId: string) { + public async loadGame(gameId: string, currentClientID: string | null = null) { try { this.isLoadingGame = true; - this.loadUserName(); + this.currentClientID = currentClientID; const session = await fetchGameById(gameId); if (!session) return; this.gameInfo = session.info; this.ranking = new Ranking(session); this.updateRanking(); - this.isLoadingGame = false; await this.loadMapImage(session.info.config.gameMap); } catch (err) { console.error("Failed to load game:", err); diff --git a/src/client/GameModeSelector.ts b/src/client/GameModeSelector.ts index 0163e8c68..012cfe27d 100644 --- a/src/client/GameModeSelector.ts +++ b/src/client/GameModeSelector.ts @@ -17,6 +17,7 @@ import { PublicLobbySocket } from "./LobbySocket"; import { JoinLobbyEvent } from "./Main"; import { SinglePlayerModal } from "./SinglePlayerModal"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; +import { UsernameInput } from "./UsernameInput"; import { calculateServerTimeOffset, getMapName, @@ -48,20 +49,10 @@ export class GameModeSelector extends LitElement { * Returns true if valid, false otherwise. */ private validateUsername(): boolean { - const usernameInput = document.querySelector("username-input") as any; - if (usernameInput?.isValid?.() === false) { - window.dispatchEvent( - new CustomEvent("show-message", { - detail: { - message: usernameInput.validationError, - color: "red", - duration: 3000, - }, - }), - ); - return false; - } - return true; + const usernameInput = document.querySelector( + "username-input", + ) as UsernameInput | null; + return usernameInput ? usernameInput.validateOrShowError() : true; } connectedCallback() { diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index e2805235f..aeefc1581 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -15,7 +15,6 @@ import { import { createPartialGameRecord, decompressGameRecord, - getClanTag, replacer, } from "../core/Util"; import { getPersistentID } from "./Auth"; @@ -273,10 +272,10 @@ export class LocalServer { { persistentID: getPersistentID(), username: this.lobbyConfig.playerName, + clanTag: this.lobbyConfig.playerClanTag ?? null, clientID: this.clientID!, stats: this.allPlayersStats[this.clientID!], cosmetics: this.lobbyConfig.gameStartInfo?.players[0].cosmetics, - clanTag: getClanTag(this.lobbyConfig.playerName) ?? undefined, }, ]; if (this.lobbyConfig.gameStartInfo === undefined) { diff --git a/src/client/Main.ts b/src/client/Main.ts index 8499d84dd..97460ced4 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -732,6 +732,10 @@ class Client { private async handleJoinLobby(event: CustomEvent) { const lobby = event.detail; + if (this.usernameInput && !this.usernameInput.validateOrShowError()) { + return; + } + console.log(`joining lobby ${lobby.gameID}`); if (this.gameStop !== null) { console.log("joining lobby, stopping existing game"); @@ -753,8 +757,8 @@ class Client { serverConfig: config, cosmetics: await getPlayerCosmeticsRefs(), turnstileToken: await this.getTurnstileToken(lobby), - playerName: - this.usernameInput?.getCurrentUsername() ?? genAnonUsername(), + playerName: this.usernameInput?.getUsername() ?? genAnonUsername(), + playerClanTag: this.usernameInput?.getClanTag() ?? null, gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info, gameRecord: lobby.gameRecord, }, diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 37f023ca4..c93fa9edd 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -654,9 +654,6 @@ export class SinglePlayerModal extends BaseModal { const usernameInput = document.querySelector( "username-input", ) as UsernameInput; - if (!usernameInput) { - console.warn("Username input element not found"); - } await crazyGamesSDK.requestMidgameAd(); @@ -669,7 +666,8 @@ export class SinglePlayerModal extends BaseModal { players: [ { clientID, - username: usernameInput.getCurrentUsername(), + username: usernameInput.getUsername(), + clanTag: usernameInput.getClanTag() ?? null, cosmetics: await getPlayerCosmetics(), }, ], diff --git a/src/client/Transport.ts b/src/client/Transport.ts index d86f0fe82..1641b614a 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -399,6 +399,7 @@ export class Transport { gameID: this.lobbyConfig.gameID, // Note: clientID is not sent - server assigns it based on persistentID username: this.lobbyConfig.playerName, + clanTag: this.lobbyConfig.playerClanTag ?? null, cosmetics: this.lobbyConfig.cosmetics, turnstileToken: this.lobbyConfig.turnstileToken, token: await getPlayToken(), diff --git a/src/client/UsernameInput.ts b/src/client/UsernameInput.ts index 04670a5c7..cb1030705 100644 --- a/src/client/UsernameInput.ts +++ b/src/client/UsernameInput.ts @@ -2,15 +2,19 @@ import { LitElement, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { v4 as uuidv4 } from "uuid"; import { translateText } from "../client/Utils"; -import { getClanTagOriginalCase, sanitizeClanTag } from "../core/Util"; +import { sanitizeClanTag } from "../core/Util"; import { + MAX_CLAN_TAG_LENGTH, MAX_USERNAME_LENGTH, + MIN_CLAN_TAG_LENGTH, MIN_USERNAME_LENGTH, + validateClanTag, validateUsername, } from "../core/validations/username"; import { crazyGamesSDK } from "./CrazyGamesSDK"; const usernameKey: string = "username"; +const clanTagKey: string = "clanTag"; @customElement("username-input") export class UsernameInput extends LitElement { @@ -27,46 +31,45 @@ export class UsernameInput extends LitElement { return this; } - public getCurrentUsername(): string { - return this.constructFullUsername(); + public getUsername(): string { + return this.baseUsername.trim(); } - private constructFullUsername(): string { - if (this.clanTag.length >= 2) { - return `[${this.clanTag}] ${this.baseUsername}`; - } - return this.baseUsername; + public getClanTag(): string | null { + return this.clanTag.length >= MIN_CLAN_TAG_LENGTH && + this.clanTag.length <= MAX_CLAN_TAG_LENGTH && + validateClanTag(this.clanTag).isValid + ? this.clanTag + : null; } connectedCallback() { super.connectedCallback(); - const stored = this.getUsername(); - this.parseAndSetUsername(stored); + this.loadStoredUsername(); crazyGamesSDK.getUsername().then((username) => { if (username) { - this.parseAndSetUsername(username ?? genAnonUsername()); - this.requestUpdate(); + this.baseUsername = username; + this.validateAndStore(); } }); crazyGamesSDK.addAuthListener((user) => { if (user) { - this.parseAndSetUsername(user?.username); + this.baseUsername = user.username; + this.validateAndStore(); } - this.requestUpdate(); }); } - private parseAndSetUsername(fullUsername: string) { - const tag = getClanTagOriginalCase(fullUsername); - if (tag) { - this.clanTag = tag.toUpperCase(); - this.baseUsername = fullUsername.replace(`[${tag}]`, "").trim(); + private loadStoredUsername() { + const storedUsername = localStorage.getItem(usernameKey); + if (storedUsername) { + this.clanTag = localStorage.getItem(clanTagKey) ?? ""; + this.baseUsername = storedUsername; + this.validateAndStore(); } else { - this.clanTag = ""; - this.baseUsername = fullUsername; + this.baseUsername = genAnonUsername(); + this.validateAndStore(); } - - this.validateAndStore(); } render() { @@ -77,7 +80,8 @@ export class UsernameInput extends LitElement { .value=${this.clanTag} @input=${this.handleClanTagChange} placeholder="${translateText("username.tag")}" - maxlength="5" + minlength="${MIN_CLAN_TAG_LENGTH}" + maxlength="${MAX_CLAN_TAG_LENGTH}" class="w-[6rem] text-xl font-medium tracking-wider text-center uppercase shrink-0 bg-transparent text-white placeholder-white/70 focus:placeholder-transparent border-0 border-b border-white/40 focus:outline-none focus:border-white/60" /> @@ -147,59 +152,51 @@ export class UsernameInput extends LitElement { } private validateAndStore() { - // Prevent empty username even if clan tag is present - const trimmedBase = this.baseUsername.trim(); - if (!trimmedBase || trimmedBase.length < MIN_USERNAME_LENGTH) { + const trimmedBase = this.getUsername(); + + const clanTagResult = validateClanTag(this.clanTag); + if (!clanTagResult.isValid) { this._isValid = false; - this.validationError = translateText("username.too_short", { - min: MIN_USERNAME_LENGTH, - }); + this.validationError = clanTagResult.error ?? ""; return; } - // Validate clan tag if present - if (this.clanTag.length > 0 && this.clanTag.length < 2) { - this._isValid = false; - this.validationError = translateText("username.tag_too_short"); - return; - } - - const full = this.constructFullUsername(); - const trimmedFull = full.trim(); - - const result = validateUsername(trimmedFull); + const result = validateUsername(trimmedBase); this._isValid = result.isValid; if (result.isValid) { - this.storeUsername(trimmedFull); + localStorage.setItem(usernameKey, trimmedBase); + localStorage.setItem(clanTagKey, this.getClanTag() ?? ""); this.validationError = ""; } else { this.validationError = result.error ?? ""; } } - private getUsername(): string { - const storedUsername = localStorage.getItem(usernameKey); - if (storedUsername) { - return storedUsername; - } - return this.generateNewUsername(); - } - - private storeUsername(username: string) { - if (username) { - localStorage.setItem(usernameKey, username); - } - } - - private generateNewUsername(): string { - const newUsername = genAnonUsername(); - this.storeUsername(newUsername); - return newUsername; - } - public isValid(): boolean { return this._isValid; } + + public showValidationFeedback(): void { + const message = + this.validationError || translateText("username.invalid_chars"); + window.dispatchEvent( + new CustomEvent("show-message", { + detail: { + message, + color: "red", + duration: 2500, + }, + }), + ); + } + + public validateOrShowError(): boolean { + if (this.isValid()) { + return true; + } + this.showValidationFeedback(); + return false; + } } export function genAnonUsername(): string { diff --git a/src/client/components/LobbyPlayerView.ts b/src/client/components/LobbyPlayerView.ts index 7ea87bab1..aeaff00b3 100644 --- a/src/client/components/LobbyPlayerView.ts +++ b/src/client/components/LobbyPlayerView.ts @@ -16,7 +16,7 @@ import { import { assignTeamsLobbyPreview } from "../../core/game/TeamAssignment"; import { UserSettings } from "../../core/game/UserSettings"; import { ClientInfo, TeamCountConfig } from "../../core/Schemas"; -import { createRandomName } from "../../core/Util"; +import { createRandomName, formatPlayerDisplayName } from "../../core/Util"; import { getTranslatedPlayerTeamLabel, translateText } from "../Utils"; export interface TeamPreviewData { @@ -122,7 +122,7 @@ export class LobbyTeamView extends LitElement { this.clients, (c) => c.clientID ?? c.username, (client) => { - const displayName = this.displayUsername(client); + const displayName = this.getClientDisplayName(client); return html`
@@ -167,7 +167,7 @@ export class LobbyTeamView extends LitElement { this.clients, (c) => c.clientID ?? c.username, (client) => { - const displayName = this.displayUsername(client); + const displayName = this.getClientDisplayName(client); return html` ${displayName} ${client.clientID === this.lobbyCreatorClientID @@ -226,7 +226,7 @@ export class LobbyTeamView extends LitElement { preview.players, (p) => p.clientID ?? p.username, (p) => { - const displayName = this.displayUsername(p); + const displayName = this.getClientDisplayName(p); return html`
@@ -318,7 +318,14 @@ export class LobbyTeamView extends LitElement { const players = this.clients.map( (c) => - new PlayerInfo(c.username, PlayerType.Human, c.clientID, c.clientID), + new PlayerInfo( + c.username, + PlayerType.Human, + c.clientID, + c.clientID, + false, + c.clanTag, + ), ); const assignment = assignTeamsLobbyPreview( players, @@ -358,17 +365,17 @@ export class LobbyTeamView extends LitElement { })); } - private displayUsername(client: ClientInfo): string { + private getClientDisplayName(client: ClientInfo): string { + const full = formatPlayerDisplayName(client.username, client.clanTag); if (!this.userSettings.anonymousNames()) { - return client.username; + return full; } - if (this.currentClientID && client.clientID === this.currentClientID) { - return client.username; + return full; } - - return ( - createRandomName(client.username, PlayerType.Human) ?? client.username - ); + // Keep clan tag visible while anonymizing only the username. + const anonymizedUsername = + createRandomName(client.username, PlayerType.Human) ?? client.username; + return formatPlayerDisplayName(anonymizedUsername, client.clanTag); } } diff --git a/src/client/components/baseComponents/ranking/GameInfoRanking.ts b/src/client/components/baseComponents/ranking/GameInfoRanking.ts index dff65db0f..e3be802a7 100644 --- a/src/client/components/baseComponents/ranking/GameInfoRanking.ts +++ b/src/client/components/baseComponents/ranking/GameInfoRanking.ts @@ -26,9 +26,8 @@ export enum RankType { export interface PlayerInfo { id: string; - rawUsername: string; username: string; - tag?: string; + clanTag: string | null; killedAt?: number; gold: bigint[]; conquests: bigint[]; @@ -77,18 +76,12 @@ export class Ranking { for (const player of session.info.players) { if (player === undefined || !hasPlayed(player)) continue; const stats = player.stats!; - const match = player.username.match(/^\[(.*?)\]\s*(.*)$/); - let username = player.username; - if (player.clanTag && match) { - username = match[2]; - } const gold = (stats.gold ?? []).map((v) => BigInt(v ?? 0)); const conquests = (stats.conquests ?? []).map((v) => BigInt(v ?? 0)); players[player.clientID] = { id: player.clientID, - rawUsername: player.username, - username, - tag: player.clanTag, + username: player.username, + clanTag: player.clanTag, conquests, flag: player.cosmetics?.flag ?? undefined, killedAt: stats.killedAt !== null ? Number(stats.killedAt) : undefined, diff --git a/src/client/components/baseComponents/ranking/PlayerRow.ts b/src/client/components/baseComponents/ranking/PlayerRow.ts index ed3cfc6ba..612569192 100644 --- a/src/client/components/baseComponents/ranking/PlayerRow.ts +++ b/src/client/components/baseComponents/ranking/PlayerRow.ts @@ -220,7 +220,7 @@ export class PlayerRow extends LitElement { private renderPlayerName() { return html`
- ${this.player.tag ? this.renderTag(this.player.tag) : ""} + ${this.player.clanTag ? this.renderTag(this.player.clanTag) : ""}
diff --git a/src/client/components/leaderboard/LeaderboardPlayerList.ts b/src/client/components/leaderboard/LeaderboardPlayerList.ts index 86fa945d7..7ddd375f8 100644 --- a/src/client/components/leaderboard/LeaderboardPlayerList.ts +++ b/src/client/components/leaderboard/LeaderboardPlayerList.ts @@ -249,9 +249,7 @@ export class LeaderboardPlayerList extends LitElement {
` : ""} ${player.clanTag - ? player.username.replace(/^\[.*?\]\s*/, "") - : player.username}${player.username}