diff --git a/src/client/ClanModal.ts b/src/client/ClanModal.ts index f838f7dba..a0989811b 100644 --- a/src/client/ClanModal.ts +++ b/src/client/ClanModal.ts @@ -60,8 +60,20 @@ export class ClanModal extends BaseModal { } | null = null; render() { - const content = this.renderInner(); - if (this.inline) return content; + const onListView = this.view === "list" && !this.selectedClanTag; + const tabs = onListView + ? [ + { key: "my-clans", label: translateText("clan_modal.my_clans") }, + { key: "browse", label: translateText("clan_modal.browse") }, + ] + : []; + const header = onListView + ? modalHeader({ + title: translateText("clan_modal.title"), + onBack: () => this.close(), + ariaLabel: translateText("common.back"), + }) + : null; return html` this.handleTabChange(key as Tab)} > - ${content} + ${header ? html`
${header}
` : ""} + ${this.renderInner()}
`; } + private handleTabChange(tab: Tab) { + this.activeTab = tab; + this.view = "list"; + this.selectedClan = null; + this.selectedClanTag = ""; + if (tab === "my-clans") { + this.loadMyClans(); + } + } + protected onOpen(): void { this.loadMyClans(); } @@ -131,16 +157,7 @@ export class ClanModal extends BaseModal { private renderInner() { if (this.loading) { - return html` -
- ${modalHeader({ - title: translateText("clan_modal.title"), - onBack: () => this.close(), - ariaLabel: translateText("common.back"), - })} - ${this.renderLoadingSpinner()} -
- `; + return this.renderLoadingSpinner(); } if (this.view === "my-requests") { @@ -289,30 +306,20 @@ export class ClanModal extends BaseModal { >`; } - // List view (tabs + my clans / browse) + // List view (my clans / browse) — header + tabs are rendered by o-modal return html` -
- ${modalHeader({ - title: translateText("clan_modal.title"), - onBack: () => this.close(), - ariaLabel: translateText("common.back"), - })} - ${this.renderTabs()} -
- ${this.activeTab === "my-clans" - ? this.renderMyClans() - : html`) => { - this.browseCache = e.detail; - }} - @clan-select=${(e: CustomEvent<{ tag: string }>) => - this.openDetail(e.detail.tag)} - >`} -
-
+ ${this.activeTab === "my-clans" + ? this.renderMyClans() + : html`) => { + this.browseCache = e.detail; + }} + @clan-select=${(e: CustomEvent<{ tag: string }>) => + this.openDetail(e.detail.tag)} + >`} `; } @@ -321,44 +328,6 @@ export class ClanModal extends BaseModal { this.view = "detail"; } - private renderTabs() { - const tabs: { key: Tab; label: string }[] = [ - { key: "my-clans", label: translateText("clan_modal.my_clans") }, - { key: "browse", label: translateText("clan_modal.browse") }, - ]; - - return html` -
- ${tabs.map( - (tab) => html` - - `, - )} -
- `; - } - private renderMyClans() { const hasClans = this.myClans.length > 0; const hasRequests = this.myPendingRequests.length > 0; diff --git a/src/client/FlagInput.ts b/src/client/FlagInput.ts index 423c8608a..ce7790508 100644 --- a/src/client/FlagInput.ts +++ b/src/client/FlagInput.ts @@ -73,7 +73,7 @@ export class FlagInput extends LitElement { return html` - - - - `; + return modalHeader({ + title: translateText("store.title"), + onBack: () => this.close(), + ariaLabel: translateText("common.back"), + rightContent: html``, + }); } private renderPatternGrid(): TemplateResult { @@ -188,22 +157,18 @@ export class StoreModal extends BaseModal { render() { if (!this.isActive && !this.inline) return html``; - const content = html` -
- ${this.renderHeader()} -
- ${this.activeTab === "patterns" - ? this.renderPatternGrid() - : this.activeTab === "flags" - ? this.renderFlagGrid() - : this.renderPackGrid()} -
-
- `; + const tabs = [ + { key: "packs", label: translateText("store.packs") }, + { key: "patterns", label: translateText("store.patterns") }, + { key: "flags", label: translateText("store.flags") }, + ]; - if (this.inline) { - return content; - } + const grid = + this.activeTab === "patterns" + ? this.renderPatternGrid() + : this.activeTab === "flags" + ? this.renderFlagGrid() + : this.renderPackGrid(); return html` + (this.activeTab = key as "patterns" | "flags" | "packs")} > - ${content} +
${this.renderHeader()}
+ ${grid}
`; } diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index bbf31cca9..8f51ff425 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -319,51 +319,10 @@ export class UserSettingModal extends BaseModal { ? this.renderBasicSettings() : this.renderKeybindSettings(); - const content = html` -
-
- ${modalHeader({ - title: translateText("user_setting.title"), - onBack: () => this.close(), - ariaLabel: translateText("common.back"), - showDivider: true, - })} - - -
- -
-
${activeContent}
-
-
- `; - - if (this.inline) { - return content; - } + const tabs = [ + { key: "basic", label: translateText("user_setting.tab_basic") }, + { key: "keybinds", label: translateText("user_setting.tab_keybinds") }, + ]; return html` + (this.activeTab = key as "basic" | "keybinds")} > - ${content} +
+ ${modalHeader({ + title: translateText("user_setting.title"), + onBack: () => this.close(), + ariaLabel: translateText("common.back"), + showDivider: true, + })} +
+
${activeContent}
`; } diff --git a/src/client/components/baseComponents/Modal.ts b/src/client/components/baseComponents/Modal.ts index 33bc7e861..fc4801064 100644 --- a/src/client/components/baseComponents/Modal.ts +++ b/src/client/components/baseComponents/Modal.ts @@ -2,6 +2,8 @@ import { LitElement, html, unsafeCSS } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import tailwindStyles from "../../styles.css?inline"; +export type OModalTab = { key: string; label: string }; + @customElement("o-modal") export class OModal extends LitElement { static styles = [unsafeCSS(tailwindStyles)]; @@ -28,6 +30,15 @@ export class OModal extends LitElement { @property({ type: String }) public maxWidth = ""; + @property({ type: Array }) + public tabs: OModalTab[] = []; + + @property({ type: String }) + public activeTab = ""; + + @property({ attribute: false }) + public onTabChange?: (key: string) => void; + public onClose?: () => void; public open() { @@ -60,7 +71,48 @@ export class OModal extends LitElement { super.disconnectedCallback(); } + private handleTabClick(key: string) { + this.onTabChange?.(key); + } + + private renderTabs() { + return html` +
+ ${this.tabs.map((tab) => { + const active = this.activeTab === tab.key; + return html` + + `; + })} +
+ `; + } + render() { + const shouldRender = this.isModalOpen || this.inline; + if (!shouldRender) { + return html``; + } + const backdropClass = this.inline ? "relative z-10 w-full h-full flex items-stretch bg-transparent" : "fixed inset-0 z-[9999] bg-black/60 flex items-center justify-center overflow-hidden"; @@ -73,42 +125,45 @@ export class OModal extends LitElement { const wrapperStyle = !this.inline && this.maxWidth ? `max-width: ${this.maxWidth};` : ""; + const hasTabs = this.tabs.length > 0; + const sectionClass = hasTabs + ? "relative flex-1 min-h-0 flex flex-col text-white bg-black/70 backdrop-blur-xl lg:rounded-2xl lg:border border-white/10 overflow-hidden" + : "relative flex-1 min-h-0 flex flex-col text-white bg-[#23232382] backdrop-blur-md lg:rounded-lg overflow-hidden"; + return html` - ${this.isModalOpen - ? html` - `; } } diff --git a/src/client/components/leaderboard/LeaderboardTabs.ts b/src/client/components/leaderboard/LeaderboardTabs.ts deleted file mode 100644 index 9401e200d..000000000 --- a/src/client/components/leaderboard/LeaderboardTabs.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { html, LitElement } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; -import { translateText } from "../../Utils"; - -export type LeaderboardTab = "players" | "clans"; - -@customElement("leaderboard-tabs") -export class LeaderboardTabs extends LitElement { - @property({ type: String }) activeTab: LeaderboardTab = "players"; - - createRenderRoot() { - return this; - } - - private baseTabClass = - "px-6 py-2 rounded-full text-sm font-bold uppercase tracking-wider transition-all cursor-pointer select-none"; - private activeTabClass = "bg-blue-600 text-white"; - private inactiveTabClass = - "text-white/40 hover:text-white/60 hover:bg-white/5"; - - private getTabClass(active: boolean) { - return [ - this.baseTabClass, - active ? this.activeTabClass : this.inactiveTabClass, - ].join(" "); - } - - @state() - private playerClass = this.getTabClass(this.activeTab === "players"); - @state() - private clanClass = this.getTabClass(this.activeTab === "clans"); - - private handleTabChange(tab: LeaderboardTab) { - this.dispatchEvent( - new CustomEvent("tab-change", { - detail: tab, - bubbles: true, - composed: true, - }), - ); - - this.playerClass = this.getTabClass(tab === "players"); - this.clanClass = this.getTabClass(tab === "clans"); - } - - render() { - return html` -
- - -
- `; - } -} diff --git a/src/client/graphics/layers/AttackingTroopsOverlay.ts b/src/client/graphics/layers/AttackingTroopsOverlay.ts index fb94ada77..b279489a0 100644 --- a/src/client/graphics/layers/AttackingTroopsOverlay.ts +++ b/src/client/graphics/layers/AttackingTroopsOverlay.ts @@ -1,54 +1,33 @@ -import { assetUrl } from "../../../core/AssetUrls"; import { EventBus } from "../../../core/EventBus"; -import { Cell } from "../../../core/game/Game"; +import { Cell, PlayerType } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; import { UserSettings } from "../../../core/game/UserSettings"; import { AlternateViewEvent } from "../../InputHandler"; import { renderTroops } from "../../Utils"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; -const soldierIcon = assetUrl("images/SoldierIcon.svg"); // Match AttacksDisplay: aquarius for outgoing, red-400 for incoming. const OUTGOING_COLOR = "var(--color-aquarius)"; const INCOMING_COLOR = "var(--color-red-400)"; -// At/above this zoom, the label stays at its full screen size. Below it the -// label shrinks linearly with zoom-out, floored so it never disappears. -const LABEL_FULL_SIZE_ZOOM = 1.5; -const LABEL_MIN_SCREEN_SCALE = 0.5; -const OUTGOING_ICON_FILTER = - "brightness(0) saturate(100%) invert(62%) sepia(80%) saturate(500%) hue-rotate(175deg) brightness(100%)"; -const INCOMING_ICON_FILTER = - "brightness(0) saturate(100%) invert(27%) sepia(91%) saturate(4551%) hue-rotate(348deg) brightness(89%) contrast(97%)"; +// At/above this zoom the label is rendered at full size; below it shrinks +// linearly toward LABEL_MIN_RENDERED_SIZE as zoom→0. +const LABEL_FULL_SIZE_ZOOM = 4.0; +const LABEL_MIN_RENDERED_SIZE = 0.63; +// Overall size multiplier applied to the rendered label. +const LABEL_SIZE_MULTIPLIER = 1.0; -// Vertical strength bar to the left of the icon: grows in height as the -// attacker outnumbers the opposition. Maxes out at BAR_MAX_HEIGHT_PX when the -// attacker has BAR_FULL_HEIGHT_RATIO× the opposing troops. -const BAR_FULL_HEIGHT_RATIO = 2; -const BAR_MAX_HEIGHT_PX = 13; - -// Element scale factor that, combined with the container's `scale(zoom)`, -// yields the desired on-screen label size: constant screen size when zoomed -// in past LABEL_FULL_SIZE_ZOOM, then shrinking linearly as zoom drops, with a -// floor at LABEL_MIN_SCREEN_SCALE so the label never disappears. +// Counter-scale against the container's `scale(zoom)`. At/above +// LABEL_FULL_SIZE_ZOOM the rendered size is capped at LABEL_SIZE_MULTIPLIER; +// below it the rendered size shrinks linearly toward +// LABEL_SIZE_MULTIPLIER * LABEL_MIN_RENDERED_SIZE as zoom→0. export function computeLabelScale(zoom: number): number { - const netScale = Math.max( - LABEL_MIN_SCREEN_SCALE, - Math.min(1, zoom / LABEL_FULL_SIZE_ZOOM), - ); - return netScale / zoom; -} - -// Fraction (0–1) of BAR_MAX_HEIGHT_PX the strength bar should occupy. 0 means -// the attacker is harmless; 1 means they have BAR_FULL_HEIGHT_RATIO× or more -// of the opposing troops. -export function computeBarStrength( - attackerTroops: number, - opposingTroops: number, -): number { - if (opposingTroops <= 0) return 1; - return Math.min(1, attackerTroops / opposingTroops / BAR_FULL_HEIGHT_RATIO); + const t = Math.min(1, zoom / LABEL_FULL_SIZE_ZOOM); + const renderedSize = + LABEL_SIZE_MULTIPLIER * + (LABEL_MIN_RENDERED_SIZE + (1 - LABEL_MIN_RENDERED_SIZE) * t); + return renderedSize / zoom; } // Worker returns clusters sorted by size; two near-equal-size fronts can flip @@ -70,7 +49,6 @@ interface AttackLabel { positions: (Cell | null)[]; isIncoming: boolean; attackerTroops: number; - barStrength: number; } export class AttackingTroopsOverlay implements Layer { @@ -144,7 +122,7 @@ export class AttackingTroopsOverlay implements Layer { const activeIDs = new Set(); - // Outgoing: cyan bar widens as our attack outnumbers the defender. + // Outgoing: only label attacks targeting another player. for (const attack of myPlayer.outgoingAttacks()) { activeIDs.add(attack.id); if (!attack.targetID) { @@ -156,20 +134,22 @@ export class AttackingTroopsOverlay implements Layer { this.removeLabel(attack.id); continue; } - const barStrength = computeBarStrength(attack.troops, defender.troops()); - this.ensureLabel(attack.id, attack.troops, false, barStrength); + this.ensureLabel(attack.id, attack.troops, false); } - // Incoming: red bar widens as the attacker outnumbers the player. + // Incoming: only label attacks coming from another player; skip tribes. for (const attack of myPlayer.incomingAttacks()) { activeIDs.add(attack.id); const attacker = this.game.playerBySmallID(attack.attackerID); - if (!attacker || !attacker.isPlayer()) { + if ( + !attacker || + !attacker.isPlayer() || + attacker.type() === PlayerType.Bot + ) { this.removeLabel(attack.id); continue; } - const barStrength = computeBarStrength(attack.troops, myPlayer.troops()); - this.ensureLabel(attack.id, attack.troops, true, barStrength); + this.ensureLabel(attack.id, attack.troops, true); } for (const [id] of this.labels) { @@ -202,7 +182,6 @@ export class AttackingTroopsOverlay implements Layer { attackID: string, attackerTroops: number, isIncoming: boolean, - barStrength: number, ) { let label = this.labels.get(attackID); if (!label) { @@ -211,15 +190,13 @@ export class AttackingTroopsOverlay implements Layer { positions: [], isIncoming, attackerTroops, - barStrength, }; this.labels.set(attackID, label); } else { label.attackerTroops = attackerTroops; - label.barStrength = barStrength; } for (const el of label.elements) { - this.updateLabelContent(el, attackerTroops, barStrength); + this.updateLabelContent(el, attackerTroops); } } @@ -235,6 +212,7 @@ export class AttackingTroopsOverlay implements Layer { // Hoist the per-frame label scale once; zoom is constant within a frame. const scale = this.labelScale(); + const innerTransform = `scale(${scale})`; for (const label of this.labels.values()) { for (let i = 0; i < label.elements.length; i++) { const el = label.elements[i]; @@ -245,15 +223,17 @@ export class AttackingTroopsOverlay implements Layer { continue; } - el.style.display = "inline-flex"; - // Centre the label on its world position; counter-scale keeps the - // label at constant screen size while zoomed in, then it shrinks - // (floored) as zoom drops below LABEL_FULL_SIZE_ZOOM. - const transform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%) scale(${scale})`; - if (this.lastTransform.get(el) !== transform) { - el.style.transform = transform; - this.lastTransform.set(el, transform); + el.style.display = ""; + const inner = el.children[0] as HTMLDivElement; + // Outer: world position only — the 0.25s transition smooths cluster + // shifts. Inner: scale only — applied without transition so zoom is + // instant. + const outerTransform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%)`; + if (this.lastTransform.get(el) !== outerTransform) { + el.style.transform = outerTransform; + this.lastTransform.set(el, outerTransform); } + inner.style.transform = innerTransform; } } } @@ -262,11 +242,7 @@ export class AttackingTroopsOverlay implements Layer { // Add elements for new clusters. while (lbl.elements.length < positions.length) { lbl.elements.push( - this.createLabelElement( - lbl.attackerTroops, - lbl.isIncoming, - lbl.barStrength, - ), + this.createLabelElement(lbl.attackerTroops, lbl.isIncoming), ); lbl.positions.push(null); } @@ -286,9 +262,9 @@ export class AttackingTroopsOverlay implements Layer { if (old && Math.hypot(next.x - old.x, next.y - old.y) > 200) { const el = lbl.elements[i]; el.style.transition = "none"; - const transform = `translate(${next.x}px, ${next.y}px) translate(-50%, -50%) scale(${this.labelScale()})`; - el.style.transform = transform; - this.lastTransform.set(el, transform); + const outerTransform = `translate(${next.x}px, ${next.y}px) translate(-50%, -50%)`; + el.style.transform = outerTransform; + this.lastTransform.set(el, outerTransform); requestAnimationFrame(() => { el.style.transition = "transform 0.25s linear"; }); @@ -297,73 +273,48 @@ export class AttackingTroopsOverlay implements Layer { } } + // Outer wraps position+transition (animates cluster moves). Inner holds the + // scale (instant on zoom) plus all visual chrome. Splitting them keeps the + // 0.25s transition off zoom changes. private createLabelTemplate(): HTMLDivElement { - const el = document.createElement("div"); - el.style.position = "absolute"; - el.style.display = "none"; - el.style.alignItems = "center"; - el.style.gap = "3px"; - el.style.whiteSpace = "nowrap"; - el.style.fontSize = "14px"; - el.style.fontWeight = "bold"; - el.style.padding = "2px 5px"; - el.style.borderRadius = "3px"; - el.style.backgroundColor = "rgba(0,0,0,0.85)"; - el.style.pointerEvents = "none"; - el.style.lineHeight = "1.3"; - el.style.transition = "transform 0.25s linear"; - el.style.width = "max-content"; + const outer = document.createElement("div"); + outer.style.position = "absolute"; + outer.style.display = "none"; + outer.style.pointerEvents = "none"; + outer.style.transition = "transform 0.25s linear"; - const bar = document.createElement("div"); - bar.style.width = "2px"; - bar.style.borderRadius = "1px"; - bar.style.alignSelf = "flex-end"; - bar.style.transition = "height 0.25s linear"; - el.appendChild(bar); + const inner = document.createElement("div"); + inner.style.whiteSpace = "nowrap"; + inner.style.fontSize = "17px"; + inner.style.fontWeight = "bold"; + inner.style.lineHeight = "1.3"; + inner.style.width = "max-content"; + // No background — let the territory border show through. Stacked black + // text-shadows form a soft dark glow so the number stays readable over + // any terrain. + inner.style.textShadow = + "0 0 2px rgba(0,0,0,1), 0 0 3px rgba(0,0,0,0.85), 0 0 5px rgba(0,0,0,0.5)"; + outer.appendChild(inner); - const icon = document.createElement("img"); - icon.style.width = "13px"; - icon.style.height = "13px"; - el.appendChild(icon); - - const span = document.createElement("span"); - span.style.minWidth = "25px"; - el.appendChild(span); - - return el; + return outer; } private createLabelElement( attackerTroops: number, isIncoming: boolean, - barStrength: number, ): HTMLDivElement { const el = this.labelTemplate.cloneNode(true) as HTMLDivElement; - el.style.fontFamily = this.game.config().theme().font(); - const bar = el.children[0] as HTMLDivElement; - const icon = el.children[1] as HTMLImageElement; - const span = el.children[2] as HTMLSpanElement; - icon.src = soldierIcon; - icon.style.filter = isIncoming - ? INCOMING_ICON_FILTER - : OUTGOING_ICON_FILTER; - span.style.color = isIncoming ? INCOMING_COLOR : OUTGOING_COLOR; - span.textContent = renderTroops(attackerTroops); - bar.style.backgroundColor = isIncoming ? INCOMING_COLOR : OUTGOING_COLOR; - bar.style.height = `${barStrength * BAR_MAX_HEIGHT_PX}px`; + const inner = el.children[0] as HTMLDivElement; + inner.style.fontFamily = this.game.config().theme().font(); + inner.style.color = isIncoming ? INCOMING_COLOR : OUTGOING_COLOR; + inner.textContent = renderTroops(attackerTroops); this.container.appendChild(el); return el; } - private updateLabelContent( - el: HTMLDivElement, - attackerTroops: number, - barStrength: number, - ) { - const bar = el.children[0] as HTMLDivElement; - const span = el.children[2] as HTMLSpanElement; - span.textContent = renderTroops(attackerTroops); - bar.style.height = `${barStrength * BAR_MAX_HEIGHT_PX}px`; + private updateLabelContent(el: HTMLDivElement, attackerTroops: number) { + const inner = el.children[0] as HTMLDivElement; + inner.textContent = renderTroops(attackerTroops); } private removeLabel(attackID: string) { diff --git a/src/client/styles.css b/src/client/styles.css index a7549e6e1..234d2aece 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -33,6 +33,8 @@ --shadow-malibu-blue-ring-sm: 0 0 0 4px rgba(0, 132, 209, 0.2); --shadow-malibu-blue-ring-lg: 0 0 0 6px rgba(0, 132, 209, 0.3); --shadow-lobby-card-hover: 0 0 0 2px #0084d1, 0 0 20px rgba(0, 132, 209, 0.5); + --shadow-action-card-hover: + 0 0 0 1px #0084d1, 0 0 12px rgba(0, 132, 209, 0.35); } @layer base { diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts index 84cdc33d2..7ce0573ce 100644 --- a/src/core/worker/WorkerClient.ts +++ b/src/core/worker/WorkerClient.ts @@ -13,45 +13,10 @@ import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates"; import { ClientID, GameStartInfo, Turn } from "../Schemas"; import { generateID } from "../Util"; import { WorkerMessage } from "./WorkerMessages"; -// ?worker&url returns the worker bundle's URL as a string. We load it via a -// same-origin Blob trampoline because browsers refuse cross-origin -// `new Worker(url)` even with valid CORS+CORP. A Blob URL is same-origin to -// the page so the constructor accepts it, and dynamic `import()` inside the -// Blob IS CORS-checked and can fetch the real worker module from the CDN. -// R2 must serve the worker bundle with `Access-Control-Allow-Origin`. -import workerUrl from "./Worker.worker.ts?worker&url"; - -function createGameWorker(): Worker { - const cdnBase = getCdnBase().replace(/\/+$/, ""); - // Same-origin path (dev, or any deploy without CDN_BASE set): construct the - // worker directly. The Blob trampoline below is only needed for cross-origin - // loads — browsers refuse `new Worker(url)` cross-origin even with valid - // CORS+CORP, and Vite's dev server doesn't serve `?worker&url` URLs as - // regular ES modules so the trampoline's dynamic `import()` would hang. - if (!cdnBase) { - return new Worker(workerUrl, { type: "module" }); - } - const fullUrl = `${cdnBase}${workerUrl}`; - // Buffer-and-replay: the worker's port enables when the trampoline script - // starts, so any messages posted before the imported module attaches its - // `message` handler would dispatch to no listener and be dropped. Capture - // them here, then re-dispatch after the import resolves. - const trampoline = ` -const buffered = []; -const buffer = (e) => buffered.push(e); -self.addEventListener("message", buffer); -import(${JSON.stringify(fullUrl)}).then(() => { - self.removeEventListener("message", buffer); - for (const e of buffered) self.dispatchEvent(new MessageEvent("message", { data: e.data })); -}).catch((e) => self.postMessage({ type: "trampoline_error", message: String((e && e.message) || e) })); -`; - const blobUrl = URL.createObjectURL( - new Blob([trampoline], { type: "application/javascript" }), - ); - const worker = new Worker(blobUrl, { type: "module" }); - URL.revokeObjectURL(blobUrl); - return worker; -} +// Inlined into the main bundle as a same-origin Blob, sidestepping the +// cross-origin `new Worker(url)` restriction that would otherwise apply when +// the worker bundle is served from the CDN. +import GameWorker from "./Worker.worker.ts?worker&inline"; export class WorkerClient { private worker: Worker; @@ -65,7 +30,7 @@ export class WorkerClient { private gameStartInfo: GameStartInfo, private clientID: ClientID | undefined, ) { - this.worker = createGameWorker(); + this.worker = new GameWorker(); this.messageHandlers = new Map(); // Set up global message handler @@ -112,21 +77,8 @@ export class WorkerClient { return new Promise((resolve, reject) => { const messageId = generateID(); - const onTrampolineError = (event: MessageEvent) => { - if (event.data?.type !== "trampoline_error") return; - this.worker.removeEventListener("message", onTrampolineError); - this.messageHandlers.delete(messageId); - reject( - new Error( - `Worker trampoline import failed: ${event.data.message ?? "unknown error"}`, - ), - ); - }; - this.worker.addEventListener("message", onTrampolineError); - this.messageHandlers.set(messageId, (message) => { if (message.type === "initialized") { - this.worker.removeEventListener("message", onTrampolineError); this.isInitialized = true; resolve(); } @@ -140,15 +92,12 @@ export class WorkerClient { cdnBase: getCdnBase(), }); - // Backstop for the worker hanging after a successful import (the - // trampoline_error path handles the cross-origin / CORS load failure). setTimeout(() => { if (!this.isInitialized) { - this.worker.removeEventListener("message", onTrampolineError); this.messageHandlers.delete(messageId); reject(new Error("Worker initialization timeout")); } - }, 20000); + }, 60000); }); } diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts index 938318b29..c3d662995 100644 --- a/src/core/worker/WorkerMessages.ts +++ b/src/core/worker/WorkerMessages.ts @@ -28,8 +28,7 @@ export type WorkerMessageType = | "attack_clustered_positions" | "attack_clustered_positions_result" | "transport_ship_spawn" - | "transport_ship_spawn_result" - | "trampoline_error"; + | "transport_ship_spawn_result"; // Base interface for all messages interface BaseWorkerMessage { @@ -138,15 +137,6 @@ export interface TransportShipSpawnResultMessage extends BaseWorkerMessage { result: TileRef | false; } -// Posted by the Blob trampoline (see WorkerClient.createGameWorker) when the -// dynamic import of the real worker module fails. The real worker module -// never loaded, so no other message will ever arrive — initialize() must -// reject on this rather than wait out its timeout. -export interface TrampolineErrorMessage extends BaseWorkerMessage { - type: "trampoline_error"; - message: string; -} - // Union types for type safety export type MainThreadMessage = | InitMessage @@ -169,5 +159,4 @@ export type WorkerMessage = | PlayerProfileResultMessage | PlayerBorderTilesResultMessage | AttackClusteredPositionsResultMessage - | TransportShipSpawnResultMessage - | TrampolineErrorMessage; + | TransportShipSpawnResultMessage; diff --git a/src/server/ClientMsgRateLimiter.ts b/src/server/ClientMsgRateLimiter.ts index d243b6460..99e88beb4 100644 --- a/src/server/ClientMsgRateLimiter.ts +++ b/src/server/ClientMsgRateLimiter.ts @@ -3,8 +3,7 @@ import { ClientID } from "../core/Schemas"; const INTENTS_PER_SECOND = 10; const INTENTS_PER_MINUTE = 150; -const MAX_INTENT_SIZE = 500; -const MAX_CONFIG_INTENT_SIZE = 2000; +const MAX_INTENT_SIZE = 2000; const TOTAL_BYTES = 2 * 1024 * 1024; // 2MB per client export type RateLimitResult = "ok" | "limit" | "kick"; @@ -17,30 +16,19 @@ interface ClientBucket { export class ClientMsgRateLimiter { private buckets = new Map(); - check( - clientID: ClientID, - type: string, - bytes: number, - intentType?: string, - ): RateLimitResult { + check(clientID: ClientID, type: string, bytes: number): RateLimitResult { const bucket = this.getOrCreate(clientID); bucket.totalBytes += bytes; if (bucket.totalBytes >= TOTAL_BYTES) return "kick"; if (type === "intent") { - // Config updates are lobby-only and not stored in turn history, - // so they can be larger than regular intents. - const maxSize = - intentType === "update_game_config" - ? MAX_CONFIG_INTENT_SIZE - : MAX_INTENT_SIZE; // 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 > maxSize) { + if (bytes > MAX_INTENT_SIZE) { return "kick"; } if ( diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 169c15972..471ea3904 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -349,13 +349,10 @@ export class GameServer { } const clientMsg = parsed.data; const bytes = Buffer.byteLength(message, "utf8"); - const intentType = - clientMsg.type === "intent" ? clientMsg.intent.type : undefined; const rateResult = this.intentRateLimiter.check( client.clientID, clientMsg.type, bytes, - intentType, ); if (rateResult === "kick") { this.log.warn(`Client rate limit exceeded, kicking`, { diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts index 1caf1b67f..83b8493a1 100644 --- a/src/server/Privilege.ts +++ b/src/server/Privilege.ts @@ -6,6 +6,7 @@ import { pattern, resolveConfusablesTransformer, resolveLeetSpeakTransformer, + skipNonAlphabeticTransformer, toAsciiLowerCaseTransformer, } from "obscenity"; import countries from "resources/countries.json"; @@ -71,15 +72,21 @@ export function createMatcher(bannedWords: string[]): RegExpMatcher { ]; // substringMatcher: literal patterns, no collapse — catches "niggertesting" as a substring // collapseMatcher: deduped patterns + collapse transformer — catches "niiiigger", "hiiitler" + // skipNonAlphabeticTransformer is applied last to catch punctuation-separated bypasses + // like "n.i.g.g.e.r". const substringMatcher = new RegExpMatcher({ ...buildDataset(bannedWords, false), - blacklistMatcherTransformers: baseTransformers, + blacklistMatcherTransformers: [ + ...baseTransformers, + skipNonAlphabeticTransformer(), + ], }); const collapseMatcher = new RegExpMatcher({ ...buildDataset(bannedWords, true), blacklistMatcherTransformers: [ ...baseTransformers, collapseDuplicatesTransformer(), + skipNonAlphabeticTransformer(), ], }); return { diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts index 4e8e644d5..aec957282 100644 --- a/tests/Privilege.test.ts +++ b/tests/Privilege.test.ts @@ -114,11 +114,9 @@ describe("UsernameCensor", () => { expect(matcher.hasMatch("MyChairName")).toBe(true); }); - test("detects banned words with underscores/dots/numbers mixed in", () => { - // These should NOT bypass the filter (skipNonAlphabetic was intentionally removed) - // Words separated by non-alpha chars are treated as separate tokens - expect(matcher.hasMatch("n.i.g.g.e.r")).toBe(false); // dots break the word - expect(matcher.hasMatch("hi_tler")).toBe(false); // underscore breaks it + test("detects banned words with non-alphabetic characters mixed in", () => { + expect(matcher.hasMatch("n.i.g.g.e.r")).toBe(true); + expect(matcher.hasMatch("hi_tler")).toBe(true); }); test("allows clean usernames", () => { @@ -141,6 +139,19 @@ describe("UsernameCensor", () => { expect(matcher.hasMatch("kkklover")).toBe(true); expect(matcher.hasMatch("ilovekkkboys")).toBe(true); }); + + test("catches slurs separated by periods (bypass attempt)", () => { + expect(matcher.hasMatch("n.i.g.g.e.r")).toBe(true); + expect(matcher.hasMatch("N.I.G.G.E.R")).toBe(true); + expect(matcher.hasMatch("n.i.g.g.a")).toBe(true); + expect(matcher.hasMatch("h.i.t.l.e.r")).toBe(true); + expect(matcher.hasMatch("hello n.i.g.g.e.r world")).toBe(true); + }); + + test("censor replaces period-separated slur usernames", () => { + const result = checker.censor("n.i.g.g.e.r", null); + expect(shadowNames).toContain(result.username); + }); }); describe("censor", () => { diff --git a/tests/client/LeaderboardModal.test.ts b/tests/client/LeaderboardModal.test.ts index a46ef3f5e..2bef31f1b 100644 --- a/tests/client/LeaderboardModal.test.ts +++ b/tests/client/LeaderboardModal.test.ts @@ -101,6 +101,7 @@ beforeEach(() => { ); }); +import "../../src/client/components/baseComponents/Modal"; import { LeaderboardModal } from "../../src/client/LeaderboardModal"; describe("LeaderboardModal", () => { @@ -334,7 +335,14 @@ describe("LeaderboardModal", () => { }), }); - const tab = modal.querySelector("#clan-leaderboard-tab"); + modal.inline = true; + await modal.updateComplete; + const oModal = modal.querySelector("o-modal"); + await (oModal as unknown as { updateComplete: Promise }) + .updateComplete; + const tab = oModal!.shadowRoot!.querySelector( + 'button[role="tab"][data-key="clans"]', + ); expect(tab).toBeTruthy(); tab!.dispatchEvent(new MouseEvent("click", { bubbles: true })); diff --git a/tests/client/graphics/layers/AttackingTroopsOverlay.test.ts b/tests/client/graphics/layers/AttackingTroopsOverlay.test.ts index 9b691bddf..de8a78e93 100644 --- a/tests/client/graphics/layers/AttackingTroopsOverlay.test.ts +++ b/tests/client/graphics/layers/AttackingTroopsOverlay.test.ts @@ -1,66 +1,37 @@ import { describe, expect, test } from "vitest"; import { alignClusterOrder, - computeBarStrength, computeLabelScale, } from "../../../../src/client/graphics/layers/AttackingTroopsOverlay"; import { Cell } from "../../../../src/core/game/Game"; describe("computeLabelScale", () => { - test("counter-scales the zoom when above the full-size threshold", () => { - // zoom = 2 → label rendered at 1/2 to stay at full screen size. - expect(computeLabelScale(2)).toBeCloseTo(0.5); + // LABEL_FULL_SIZE_ZOOM = 4, LABEL_MIN_RENDERED_SIZE = 0.63, + // LABEL_SIZE_MULTIPLIER = 1.0. Rendered size at zoom z: + // 1.0 * (0.63 + 0.37 * min(1, z/4)). + test("at the full-size threshold, rendered size is capped at the multiplier", () => { + // zoom = 4 → rendered = 1.0 → scale = 1.0 / 4. + expect(computeLabelScale(4)).toBeCloseTo(1.0 / 4); }); - test("counter-scales exactly at the full-size threshold", () => { - // zoom = 1.5 → label rendered at 1/1.5 ≈ 0.6667. - expect(computeLabelScale(1.5)).toBeCloseTo(1 / 1.5); + test("above the threshold, rendered size stays capped (counter-scales zoom)", () => { + // zoom = 8 → rendered still 1.0 → scale = 1.0 / 8. + expect(computeLabelScale(8)).toBeCloseTo(1.0 / 8); }); - test("rides the world transform between the floor and the threshold", () => { - // Below the threshold, netScale = zoom / 1.5, so the factor is constant 1/1.5. - expect(computeLabelScale(1)).toBeCloseTo(1 / 1.5); - expect(computeLabelScale(0.9)).toBeCloseTo(1 / 1.5); + test("at zoom = 0+, rendered size approaches the floor", () => { + // As zoom→0, t→0, rendered → 1.0 * 0.63 (the floor). + // At zoom = 0.001, rendered ≈ floor, so scale ≈ floor / zoom = huge. + const scale = computeLabelScale(0.001); + const floorRendered = 1.0 * 0.63; + // Within 1% of the floor-divided-by-zoom value. + expect(scale).toBeGreaterThan((floorRendered / 0.001) * 0.99); + expect(scale).toBeLessThan((floorRendered / 0.001) * 1.01); }); - test("floor engages exactly at zoom = 0.75 (LABEL_MIN_SCREEN_SCALE * LABEL_FULL_SIZE_ZOOM)", () => { - expect(computeLabelScale(0.75)).toBeCloseTo(1 / 1.5); - }); - - test("grows in screen space when zoomed out past the floor", () => { - // zoom = 0.5 → netScale clamped to 0.5, factor = 0.5 / 0.5 = 1. - expect(computeLabelScale(0.5)).toBeCloseTo(1); - // zoom = 0.25 → factor = 0.5 / 0.25 = 2. - expect(computeLabelScale(0.25)).toBeCloseTo(2); - }); -}); - -describe("computeBarStrength", () => { - test("equal troops sit at the midpoint", () => { - // 1000 vs 1000 → ratio 1, divided by full-height ratio of 2 → 0.5. - expect(computeBarStrength(1000, 1000)).toBeCloseTo(0.5); - }); - - test("attacker with no troops yields a zero-height bar", () => { - expect(computeBarStrength(0, 1000)).toBe(0); - }); - - test("scales linearly between zero and the full-height threshold", () => { - // 500 vs 1000 → ratio 0.5 → 0.25. - expect(computeBarStrength(500, 1000)).toBeCloseTo(0.25); - // 1500 vs 1000 → ratio 1.5 → 0.75. - expect(computeBarStrength(1500, 1000)).toBeCloseTo(0.75); - }); - - test("clamps at full height when attacker has 2× the opposition", () => { - expect(computeBarStrength(2000, 1000)).toBeCloseTo(1); - expect(computeBarStrength(10_000, 1000)).toBeCloseTo(1); - }); - - test("returns full height when the opposing side has no troops", () => { - // Avoids division-by-zero: an undefended target is maximum strength. - expect(computeBarStrength(500, 0)).toBe(1); - expect(computeBarStrength(0, 0)).toBe(1); + test("interpolates linearly between floor and full-size threshold", () => { + // zoom = 2 → t = 0.5 → rendered = 1.0 * (0.63 + 0.185) = 0.815. + expect(computeLabelScale(2)).toBeCloseTo(0.815 / 2); }); }); diff --git a/tests/server/ClientMsgRateLimiter.test.ts b/tests/server/ClientMsgRateLimiter.test.ts index 9732d3c40..31c5a6db8 100644 --- a/tests/server/ClientMsgRateLimiter.test.ts +++ b/tests/server/ClientMsgRateLimiter.test.ts @@ -28,6 +28,16 @@ describe("ClientMsgRateLimiter", () => { } expect(limiter.check(CLIENT_B, "intent", SMALL)).toBe("ok"); }); + + it("allows intents up to MAX_INTENT_SIZE", () => { + const limiter = new ClientMsgRateLimiter(); + expect(limiter.check(CLIENT_A, "intent", 2000)).toBe("ok"); + }); + + it("kicks intents exceeding MAX_INTENT_SIZE", () => { + const limiter = new ClientMsgRateLimiter(); + expect(limiter.check(CLIENT_A, "intent", 2001)).toBe("kick"); + }); }); describe("non-intent messages", () => {