diff --git a/index.html b/index.html
index d2e4e9270..8c603a817 100644
--- a/index.html
+++ b/index.html
@@ -333,6 +333,7 @@
+
this.isActive,
+ () => {
+ // Called when execution requests the modal be shown — stop the game and
+ // clean up resources first.
+ this.stop();
+ },
+ (targetID, troops) =>
+ this.eventBus.emit(new SendAttackIntentEvent(targetID, troops)),
+ (patternName, colorPalette) =>
+ this.eventBus.emit(
+ new ShowSkinTestModalEvent(patternName, colorPalette),
+ ),
+ );
+ this.testSkinExecution.start();
+ }
+
this.eventBus.on(MouseUpEvent, this.inputEvent.bind(this));
this.eventBus.on(MouseMoveEvent, this.onMouseMove.bind(this));
this.eventBus.on(AutoUpgradeEvent, this.autoUpgradeEvent.bind(this));
@@ -667,7 +712,12 @@ export class ClientGameRunner {
this.currentTickDelay = undefined;
if (gu.updates[GameUpdateType.Win].length > 0) {
- this.saveGame(gu.updates[GameUpdateType.Win][0]);
+ if (this.lobby.isSkinTest) {
+ // For skin tests, show the modal immediately on win instead of waiting
+ this.testSkinExecution?.showModal();
+ } else {
+ this.saveGame(gu.updates[GameUpdateType.Win][0]);
+ }
}
});
@@ -804,6 +854,8 @@ export class ClientGameRunner {
if (!this.isActive) return;
this.isActive = false;
+ // Clean up skin test resources
+ this.stopSkinTest();
this.worker.cleanup();
this.transport.leaveGame();
if (this.connectionCheckInterval) {
diff --git a/src/client/Main.ts b/src/client/Main.ts
index bd73b5f46..148949407 100644
--- a/src/client/Main.ts
+++ b/src/client/Main.ts
@@ -240,6 +240,7 @@ export interface JoinLobbyEvent {
gameStartInfo?: GameStartInfo;
// GameRecord exists when replaying an archived game.
gameRecord?: GameRecord;
+ isSkinTest?: boolean;
source?: "public" | "private" | "host" | "matchmaking" | "singleplayer";
publicLobbyInfo?: GameInfo | PublicGameInfo;
}
@@ -845,6 +846,7 @@ class Client {
playerRole,
gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info,
gameRecord: lobby.gameRecord,
+ isSkinTest: lobby.isSkinTest,
});
if (this.mostRecentJoinEvent !== event.timeStamp) {
diff --git a/src/client/Store.ts b/src/client/Store.ts
index 97e403d72..65219f17a 100644
--- a/src/client/Store.ts
+++ b/src/client/Store.ts
@@ -2,7 +2,15 @@ import type { TemplateResult } from "lit";
import { html } from "lit";
import { customElement } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
-import { Cosmetics } from "../core/CosmeticSchemas";
+import { ColorPalette, Cosmetics, Pattern } from "../core/CosmeticSchemas";
+import {
+ Difficulty,
+ GameMapSize,
+ GameMapType,
+ GameMode,
+ GameType,
+ UnitType,
+} from "../core/game/Game";
import { UserSettings } from "../core/game/UserSettings";
import { BaseModal } from "./components/BaseModal";
import "./components/CosmeticButton";
@@ -12,6 +20,7 @@ import {
fetchCosmetics,
purchaseCosmetic,
resolveCosmetics,
+ ResolvedCosmetic,
} from "./Cosmetics";
import { translateText } from "./Utils";
@@ -55,6 +64,85 @@ export class StoreModal extends BaseModal {
this.refresh();
}
+ private startTestGame(resolved: ResolvedCosmetic) {
+ if (!this.userMeResponse || resolved.type !== "pattern") return;
+ const pattern = resolved.cosmetic as Pattern;
+ const colorPalette = resolved.colorPalette as ColorPalette | null;
+ const clientID = this.userMeResponse.player.publicId;
+ const gameID = pattern.name;
+
+ const selectedPattern = {
+ name: pattern.name,
+ patternData: pattern.pattern,
+ colorPalette: colorPalette ?? undefined,
+ };
+
+ const translation = translateText(
+ `territory_patterns.pattern.${pattern.name}`,
+ );
+ const displayName = translation.startsWith("territory_patterns.pattern.")
+ ? pattern.name
+ .split("_")
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
+ .join(" ")
+ : translation;
+
+ this.dispatchEvent(
+ new CustomEvent("join-lobby", {
+ detail: {
+ clientID,
+ gameID,
+ isSkinTest: true,
+ source: "singleplayer",
+ gameStartInfo: {
+ gameID,
+ players: [
+ {
+ clientID,
+ username: displayName,
+ cosmetics: {
+ pattern: selectedPattern,
+ },
+ },
+ ],
+ config: {
+ gameMap: GameMapType.Iceland,
+ gameMapSize: GameMapSize.Compact,
+ gameType: GameType.Singleplayer,
+ gameMode: GameMode.FFA,
+ playerTeams: 1,
+ bots: 0,
+ difficulty: Difficulty.Easy,
+ donateGold: false,
+ donateTroops: false,
+ instantBuild: false,
+ randomSpawn: true,
+ disableNations: true,
+ infiniteGold: true,
+ infiniteTroops: true,
+ percentageTilesOwnedToWin: 99,
+ disabledUnits: [
+ UnitType.City,
+ UnitType.Factory,
+ UnitType.Port,
+ UnitType.MissileSilo,
+ UnitType.DefensePost,
+ UnitType.SAMLauncher,
+ UnitType.AtomBomb,
+ UnitType.HydrogenBomb,
+ UnitType.MIRV,
+ UnitType.Warship,
+ ],
+ },
+ lobbyCreatedAt: Date.now(),
+ },
+ },
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ }
+
private renderHeader(): TemplateResult {
return modalHeader({
title: translateText("store.title"),
@@ -93,6 +181,9 @@ export class StoreModal extends BaseModal {
this.startTestGame(resolved)
+ : undefined}
>
`,
)}
diff --git a/src/client/components/CosmeticButton.ts b/src/client/components/CosmeticButton.ts
index 0374fa2a7..1084c30d5 100644
--- a/src/client/components/CosmeticButton.ts
+++ b/src/client/components/CosmeticButton.ts
@@ -29,6 +29,9 @@ export class CosmeticButton extends LitElement {
@property({ type: Function })
onPurchase?: (resolved: ResolvedCosmetic, method: PaymentMethod) => void;
+ @property({ type: Function })
+ onTest?: (resolved: ResolvedCosmetic) => void;
+
/** True if the user already has a subscription (any tier). */
@property({ type: Boolean })
userHasSubscription: boolean = false;
@@ -211,6 +214,17 @@ export class CosmeticButton extends LitElement {
${this.renderPreview()}
+ ${isPurchasable && isPattern && this.onTest
+ ? html``
+ : nothing}
${isOwnedSubscription
? html` void;
+
+ public set eventBus(eb: EventBus | undefined) {
+ // Unsubscribe previous listener to avoid duplicates on re-assignment
+ if (this._eventBus && this._onShowEvent) {
+ this._eventBus.off(ShowSkinTestModalEvent, this._onShowEvent);
+ }
+
+ this._eventBus = eb;
+ if (!this._eventBus) return;
+
+ // Subscribe to show requests and handle fetch/display logic here so
+ // ClientGameRunner doesn't need to know implementation details.
+ this._onShowEvent = async (e: ShowSkinTestModalEvent) => {
+ try {
+ const cosmetics = await fetchCosmetics();
+ if (!cosmetics) {
+ console.error("Failed to fetch cosmetics");
+ return;
+ }
+ const pattern = cosmetics.patterns[e.patternName];
+ if (pattern) {
+ this.show(pattern, e.colorPalette ?? null);
+ } else {
+ console.error("Pattern not found in cosmetics:", e.patternName);
+ }
+ } catch (err) {
+ console.error("Error showing skin test modal", err);
+ }
+ };
+ this._eventBus.on(ShowSkinTestModalEvent, this._onShowEvent);
+ }
+
+ public get eventBus(): EventBus | undefined {
+ return this._eventBus;
+ }
+
+ @state()
+ isVisible = false;
+
+ @state()
+ private pattern: Pattern | null = null;
+ @state()
+ private colorPalette: ColorPalette | null = null;
+
+ @state()
+ private rated: "up" | "down" | null = null;
+
+ createRenderRoot() {
+ return this;
+ }
+
+ init() {
+ // Layer interface implementation - LitElement handles its own rendering
+ }
+
+ show(pattern: Pattern, colorPalette: ColorPalette | null) {
+ this.pattern = pattern;
+ this.colorPalette = colorPalette;
+ this.isVisible = true;
+ }
+
+ hide() {
+ this.isVisible = false;
+ this.rated = null;
+ }
+
+ private _handleExit() {
+ this.hide();
+ window.location.href = "/";
+ }
+
+ private _handleRate(rating: "up" | "down") {
+ this.rated = rating;
+ // TODO: send rating event to the server
+ }
+
+ render() {
+ if (!this.isVisible) return html``;
+
+ return html`
+
+
+ ${translateText("skin_test_modal.title")}
+
+
+
+
+
+ ${translateText("skin_test_modal.rate_skin")}
+
+
+
+
+
+
+
+
+ ${this.pattern
+ ? html`
+
+
+
+ `
+ : html``}
+
+
+
+
+
+
+ `;
+ }
+}
diff --git a/src/client/hud/layers/WinModal.ts b/src/client/hud/layers/WinModal.ts
index 7c7b8fdd6..26438c8b2 100644
--- a/src/client/hud/layers/WinModal.ts
+++ b/src/client/hud/layers/WinModal.ts
@@ -257,6 +257,11 @@ export class WinModal extends LitElement implements Controller {
init() {}
tick() {
+ // Don't show win modal during skin tests
+ if (this.game.isSkinTest) {
+ return;
+ }
+
const myPlayer = this.game.myPlayer();
if (
!this.hasShownDeathModal &&
diff --git a/src/client/view/GameView.ts b/src/client/view/GameView.ts
index 9db7f6052..71a4b28ff 100644
--- a/src/client/view/GameView.ts
+++ b/src/client/view/GameView.ts
@@ -117,6 +117,8 @@ export class GameView implements GameMap {
private _map: GameMap;
+ public isSkinTest: boolean = false;
+
constructor(
public worker: WorkerClient,
private _config: Config,
diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts
index 3b18c9656..cd6fad15f 100644
--- a/src/core/Schemas.ts
+++ b/src/core/Schemas.ts
@@ -259,6 +259,7 @@ export const GameConfigSchema = z.object({
maxPlayers: z.number().optional(),
maxTimerValue: z.number().int().min(1).max(120).nullable().optional(), // In minutes
spawnImmunityDuration: z.number().int().min(0).nullable().optional(), // In ticks
+ percentageTilesOwnedToWin: z.number().int().min(1).max(100).optional(),
disabledUnits: z.enum(UnitType).array().optional(),
playerTeams: TeamCountConfigSchema.optional(),
goldMultiplier: z.number().min(0.1).max(1000).nullable().optional(),
diff --git a/src/core/execution/TestSkinExecution.ts b/src/core/execution/TestSkinExecution.ts
new file mode 100644
index 000000000..f590cfd61
--- /dev/null
+++ b/src/core/execution/TestSkinExecution.ts
@@ -0,0 +1,137 @@
+import { ColorPalette } from "../CosmeticSchemas";
+import { Execution, Game, PlayerID } from "../game/Game";
+import { GameView, PlayerView } from "../game/GameView";
+import { ClientID } from "../Schemas";
+
+export class TestSkinExecution implements Execution {
+ private static readonly MAX_INITIAL_ATTACK_RETRIES = 50;
+
+ private myPlayer: PlayerView | null = null;
+ private initialAttackTimeoutId: ReturnType
| null = null;
+ private modalTimeoutId: ReturnType | null = null;
+ private initialAttackRetries = 0;
+ private active = true;
+
+ constructor(
+ private gameView: GameView,
+ private clientID: ClientID,
+ private isRunnerActive: () => boolean,
+ private onShowModalRequested: () => void,
+ private onAttackIntent: (targetID: PlayerID | null, troops: number) => void,
+ private onShowModal: (
+ patternName: string,
+ colorPalette: ColorPalette | null,
+ ) => void,
+ ) {}
+
+ isActive(): boolean {
+ return this.active;
+ }
+
+ activeDuringSpawnPhase(): boolean {
+ return false;
+ }
+
+ // Not driven by the game engine tick loop — managed externally via start()/stop().
+ init(_mg: Game, _ticks: number): void {}
+ tick(_ticks: number): void {}
+
+ public start() {
+ // schedule the initial attack
+ this.scheduleInitialAttack(100);
+
+ // schedule the modal after 2 minutes
+ if (this.modalTimeoutId !== null) {
+ clearTimeout(this.modalTimeoutId);
+ this.modalTimeoutId = null;
+ }
+ this.modalTimeoutId = setTimeout(() => {
+ this.modalTimeoutId = null;
+ if (!this.isRunnerActive()) return;
+ this.showModal();
+ }, 120000);
+ }
+
+ public stop() {
+ this.active = false;
+ if (this.initialAttackTimeoutId !== null) {
+ clearTimeout(this.initialAttackTimeoutId);
+ this.initialAttackTimeoutId = null;
+ }
+ if (this.modalTimeoutId !== null) {
+ clearTimeout(this.modalTimeoutId);
+ this.modalTimeoutId = null;
+ }
+ }
+
+ public showModal() {
+ try {
+ this.onShowModalRequested();
+ } catch (e) {
+ // ignore
+ }
+
+ // Safety net: clear our own timeouts in case onShowModalRequested threw
+ this.stop();
+
+ // Resolve player and emit modal event
+ const myPlayer = this.gameView.playerByClientID(this.clientID);
+ if (!myPlayer) {
+ console.error(
+ "No player found to show skin test modal for",
+ this.clientID,
+ );
+ return;
+ }
+
+ if (!myPlayer?.cosmetics?.pattern) {
+ console.error("No pattern found on player", myPlayer?.cosmetics);
+ return;
+ }
+
+ const patternName = myPlayer.cosmetics.pattern.name;
+ const colorPalette = myPlayer.cosmetics.pattern.colorPalette ?? null;
+
+ this.onShowModal(patternName, colorPalette);
+ }
+
+ private scheduleInitialAttack(delayMs: number) {
+ if (this.initialAttackTimeoutId !== null) {
+ clearTimeout(this.initialAttackTimeoutId);
+ this.initialAttackTimeoutId = null;
+ }
+ this.initialAttackTimeoutId = setTimeout(() => {
+ this.initialAttackTimeoutId = null;
+ if (!this.isRunnerActive()) return;
+ this.initialAttack();
+ }, delayMs);
+ }
+
+ private initialAttack() {
+ if (!this.isRunnerActive()) return;
+
+ if (this.myPlayer === null) {
+ const myPlayer = this.gameView.playerByClientID(this.clientID);
+ if (myPlayer === null) {
+ this.initialAttackRetries++;
+ if (
+ this.initialAttackRetries >=
+ TestSkinExecution.MAX_INITIAL_ATTACK_RETRIES
+ ) {
+ console.error(
+ "TestSkinExecution: gave up finding player after",
+ this.initialAttackRetries,
+ "retries",
+ );
+ return;
+ }
+ this.scheduleInitialAttack(100);
+ return;
+ }
+ this.myPlayer = myPlayer;
+ }
+
+ const troopCount = this.myPlayer.troops() ?? 1000000;
+ this.onAttackIntent(null, Math.floor(troopCount / 2));
+ }
+}
diff --git a/tests/client/SkinTestGameFlow.test.ts b/tests/client/SkinTestGameFlow.test.ts
new file mode 100644
index 000000000..d8accbed2
--- /dev/null
+++ b/tests/client/SkinTestGameFlow.test.ts
@@ -0,0 +1,243 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("../../src/client/Utils", () => ({
+ translateText: (k: string) => k,
+ getSvgAspectRatio: async () => 1,
+}));
+
+// Avoid any audio side effects.
+vi.mock("../../src/client/sound/SoundManager", () => ({
+ SoundManager: vi.fn().mockImplementation(() => ({
+ playBackgroundMusic: vi.fn(),
+ stopBackgroundMusic: vi.fn(),
+ })),
+}));
+
+const fetchCosmeticsMock = vi.fn();
+const purchaseCosmeticMock = vi.fn();
+vi.mock("../../src/client/Cosmetics", () => ({
+ fetchCosmetics: (...args: any[]) => fetchCosmeticsMock(...args),
+ purchaseCosmetic: (...args: any[]) => purchaseCosmeticMock(...args),
+ // Not needed in this suite
+ patternRelationship: () => "blocked",
+ resolveCosmetics: () => [],
+}));
+
+// Mock CosmeticButton so SkinTestWinModal can render a purchase click target in JSDOM.
+vi.mock("../../src/client/components/CosmeticButton", () => {
+ class CosmeticButton extends HTMLElement {
+ private _resolved: any = null;
+ private _onPurchase?: (resolved: any, method: string) => void;
+
+ get resolved() {
+ return this._resolved;
+ }
+ set resolved(v: any) {
+ this._resolved = v;
+ this.renderBtn();
+ }
+
+ get onPurchase() {
+ return this._onPurchase;
+ }
+ set onPurchase(v: ((resolved: any, method: string) => void) | undefined) {
+ this._onPurchase = v;
+ this.renderBtn();
+ }
+
+ connectedCallback() {
+ this.renderBtn();
+ }
+
+ renderBtn() {
+ this.innerHTML = "";
+ if (this._resolved && this._onPurchase) {
+ const btn = document.createElement("button");
+ btn.setAttribute("data-testid", "buy-skin");
+ btn.textContent = "territory_patterns.purchase";
+ btn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this._onPurchase?.(this._resolved, "dollar");
+ });
+ this.appendChild(btn);
+ }
+ }
+ }
+
+ if (!customElements.get("cosmetic-button")) {
+ customElements.define("cosmetic-button", CosmeticButton);
+ }
+
+ return {
+ CosmeticButton,
+ };
+});
+
+import { ClientGameRunner } from "../../src/client/ClientGameRunner";
+import { SkinTestWinModal } from "../../src/client/hud/layers/SkinTestWinModal";
+import { GameUpdateType } from "../../src/core/game/GameUpdates";
+
+const makeCosmetics = () =>
+ ({
+ patterns: {
+ purch_pattern: {
+ name: "purch_pattern",
+ affiliateCode: "aff",
+ pattern: "AQID",
+ product: { price: "$1.00", priceId: "price_test" },
+ colorPalettes: [],
+ },
+ },
+ colorPalettes: {},
+ }) as any;
+
+describe("Skin test game flow", () => {
+ let modal: SkinTestWinModal;
+
+ beforeEach(async () => {
+ fetchCosmeticsMock.mockResolvedValue(makeCosmetics());
+
+ // Ensure the skin test win modal exists in DOM.
+ if (!customElements.get("skin-test-win-modal")) {
+ customElements.define("skin-test-win-modal", SkinTestWinModal);
+ }
+
+ modal = document.createElement("skin-test-win-modal") as SkinTestWinModal;
+ document.body.appendChild(modal);
+ await modal.updateComplete;
+ });
+
+ afterEach(() => {
+ document.body.removeChild(modal);
+ vi.clearAllMocks();
+ });
+
+ it("when a skin-test game ends (win update), it shows the buy modal and purchase calls handlePurchase", async () => {
+ // Minimal stubs for runner dependencies.
+ // Use a real EventBus so the modal can subscribe to events.
+ const { EventBus } = await import("../../src/core/EventBus");
+ const eventBus = new EventBus();
+ modal.eventBus = eventBus;
+
+ const renderer = {
+ initialize: vi.fn(),
+ tick: vi.fn(),
+ } as any;
+
+ const input = {
+ initialize: vi.fn(),
+ } as any;
+
+ const transport = {
+ turnComplete: vi.fn(),
+ updateCallback: vi.fn(),
+ rejoinGame: vi.fn(),
+ leaveGame: vi.fn(),
+ } as any;
+
+ let workerCallback: any;
+ const worker = {
+ start: (cb: any) => {
+ workerCallback = cb;
+ },
+ sendHeartbeat: vi.fn(),
+ sendTurn: vi.fn(),
+ cleanup: vi.fn(),
+ } as any;
+
+ const myPlayer = {
+ cosmetics: {
+ pattern: {
+ name: "purch_pattern",
+ colorPalette: null,
+ },
+ },
+ troops: () => 1000,
+ clientID: () => "client123",
+ } as any;
+
+ const gameView = {
+ update: vi.fn(),
+ playerByClientID: vi.fn(() => myPlayer),
+ config: () => ({ isRandomSpawn: () => false }),
+ inSpawnPhase: () => false,
+ myPlayer: () => myPlayer,
+ } as any;
+
+ const lobby = {
+ clientID: "client123",
+ gameID: "purch_pattern",
+ playerName: "Tester",
+ cosmetics: {},
+ serverConfig: {} as any,
+ turnstileToken: null,
+ isSkinTest: true,
+ gameStartInfo: {
+ gameID: "purch_pattern",
+ players: [],
+ config: { isRandomSpawn: () => false },
+ lobbyCreatedAt: Date.now(),
+ },
+ } as any;
+
+ const runner = new ClientGameRunner(
+ lobby,
+ "client123",
+ eventBus,
+ renderer,
+ input,
+ transport,
+ worker,
+ gameView,
+ { playBackgroundMusic: vi.fn() } as any,
+ {} as any, // userSettings
+ ) as any;
+
+ // Seed the private myPlayer field so showSkinTestModal can resolve the pattern.
+ runner.myPlayer = myPlayer;
+
+ // Start the runner so it registers the worker callback.
+ runner.start();
+ expect(workerCallback).toBeTruthy();
+
+ // Simulate the game ending via a Win update.
+ const updates: any[] = [];
+ updates[GameUpdateType.Hash] = [];
+ updates[GameUpdateType.Win] = [
+ {
+ type: GameUpdateType.Win,
+ winner: ["player", "client123"],
+ allPlayersStats: {},
+ },
+ ];
+
+ workerCallback({
+ tick: 1,
+ updates,
+ packedTileUpdates: new BigUint64Array(),
+ playerNameViewData: {},
+ tickExecutionDuration: 0,
+ });
+
+ // showSkinTestModal() is async (fetchCosmetics + lit updates). Give the
+ // microtask queue a moment, then await the next render.
+ await new Promise((r) => setTimeout(r, 0));
+ await modal.updateComplete;
+ expect(modal.isVisible).toBe(true);
+
+ // PatternButton is also a custom element; give it a tick to render.
+ await new Promise((r) => setTimeout(r, 0));
+
+ const buyBtn = modal.querySelector(
+ 'button[data-testid="buy-skin"]',
+ ) as HTMLButtonElement | null;
+ expect(buyBtn).toBeTruthy();
+
+ buyBtn!.click();
+
+ expect(purchaseCosmeticMock).toHaveBeenCalledTimes(1);
+ expect(purchaseCosmeticMock.mock.calls[0][0].cosmetic.name).toBe(
+ "purch_pattern",
+ );
+ });
+});
diff --git a/tests/client/TerritoryPatternsModal.test.ts b/tests/client/TerritoryPatternsModal.test.ts
new file mode 100644
index 000000000..cacb7640d
--- /dev/null
+++ b/tests/client/TerritoryPatternsModal.test.ts
@@ -0,0 +1,123 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+// Keep translations deterministic in tests
+vi.mock("../../src/client/Utils", () => ({
+ translateText: (k: string) => k,
+ getSvgAspectRatio: async () => 1,
+}));
+
+// Mock cosmetics fetch so we can deterministically render owned patterns.
+const fetchCosmeticsMock = vi.fn();
+const getPlayerCosmeticsMock = vi.fn();
+const resolveCosmetics = vi.fn();
+const resolvedToPlayerPatternMock = vi.fn();
+vi.mock("../../src/client/Cosmetics", () => ({
+ fetchCosmetics: (...args: any[]) => fetchCosmeticsMock(...args),
+ getPlayerCosmetics: (...args: any[]) => getPlayerCosmeticsMock(...args),
+ resolveCosmetics: (...args: any[]) => resolveCosmetics(...args),
+ resolvedToPlayerPattern: (...args: any[]) =>
+ resolvedToPlayerPatternMock(...args),
+ purchaseCosmetic: vi.fn(),
+}));
+
+// Stub CosmeticButton to avoid canvas rendering in JSDOM.
+vi.mock("../../src/client/components/CosmeticButton", () => {
+ if (!customElements.get("cosmetic-button")) {
+ customElements.define(
+ "cosmetic-button",
+ class extends HTMLElement {
+ connectedCallback() {
+ this.innerHTML = '';
+ }
+ },
+ );
+ }
+ return {};
+});
+
+import { TerritoryPatternsModal } from "../../src/client/TerritoryPatternsModal";
+
+const makeUserMe = () =>
+ ({
+ user: { discord: { id: "d" } },
+ player: { publicId: "client123", flares: [] },
+ }) as any;
+
+const makeOwnedPattern = () =>
+ ({
+ type: "pattern",
+ cosmetic: { name: "owned_pattern", pattern: "AQID" },
+ colorPalette: null,
+ relationship: "owned",
+ key: "pattern:owned_pattern",
+ }) as any;
+
+describe("TerritoryPatternsModal", () => {
+ let modal: TerritoryPatternsModal;
+
+ beforeEach(async () => {
+ if (typeof (globalThis as any).localStorage?.getItem !== "function") {
+ let store: Record = {};
+ Object.defineProperty(globalThis, "localStorage", {
+ value: {
+ getItem: (k: string) => (k in store ? store[k] : null),
+ setItem: (k: string, v: string) => {
+ store[k] = String(v);
+ },
+ removeItem: (k: string) => {
+ delete store[k];
+ },
+ clear: () => {
+ store = {};
+ },
+ },
+ configurable: true,
+ });
+ }
+
+ if (!customElements.get("territory-patterns-modal")) {
+ customElements.define("territory-patterns-modal", TerritoryPatternsModal);
+ }
+
+ fetchCosmeticsMock.mockResolvedValue({
+ patterns: {},
+ colorPalettes: {},
+ });
+ getPlayerCosmeticsMock.mockResolvedValue({ pattern: null, color: null });
+ resolveCosmetics.mockReturnValue([makeOwnedPattern()]);
+
+ modal = document.createElement(
+ "territory-patterns-modal",
+ ) as TerritoryPatternsModal;
+ modal.inline = true;
+ document.body.appendChild(modal);
+ await modal.updateComplete;
+
+ await modal.onUserMe(makeUserMe());
+ await modal.updateComplete;
+ });
+
+ afterEach(() => {
+ document.body.removeChild(modal);
+ vi.clearAllMocks();
+ });
+
+ it("renders owned patterns via cosmetic-button", async () => {
+ await modal.open();
+ await modal.updateComplete;
+
+ const buttons = modal.querySelectorAll("cosmetic-button");
+ expect(buttons.length).toBeGreaterThan(0);
+ });
+
+ it("shows the Store navigation button", async () => {
+ await modal.open();
+ await modal.updateComplete;
+
+ // The store button is rendered as an custom element with translationKey="main.store"
+ const storeBtn = modal.querySelector(
+ 'o-button[translationKey="main.store"]',
+ );
+ expect(storeBtn).toBeTruthy();
+ });
+});
diff --git a/tests/client/TestSkinExecution.test.ts b/tests/client/TestSkinExecution.test.ts
new file mode 100644
index 000000000..a62f8ccbf
--- /dev/null
+++ b/tests/client/TestSkinExecution.test.ts
@@ -0,0 +1,82 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { TestSkinExecution } from "../../src/core/execution/TestSkinExecution";
+
+describe("TestSkinExecution", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it("showModal calls onShowModal and prevents scheduled initial attack", () => {
+ const fakePlayer = {
+ cosmetics: { pattern: { name: "pattern1", colorPalette: { name: "p" } } },
+ troops: () => 100,
+ } as any;
+
+ const gameView = {
+ playerByClientID: (_: any) => fakePlayer,
+ } as any;
+
+ const onShowModalRequested = vi.fn();
+ const onAttackIntent = vi.fn();
+ const onShowModal = vi.fn();
+
+ const exec = new TestSkinExecution(
+ gameView,
+ "client1" as any,
+ () => true,
+ onShowModalRequested,
+ onAttackIntent,
+ onShowModal,
+ );
+
+ exec.start();
+
+ // Immediately show modal which should clear timeouts
+ exec.showModal();
+
+ // Should have requested runner to stop
+ expect(onShowModalRequested).toHaveBeenCalled();
+
+ // Should have called onShowModal with the right payload
+ expect(onShowModal).toHaveBeenCalledWith("pattern1", { name: "p" });
+
+ // Advance timers past the initial attack delay; since showModal cleared timeouts, no attack should fire
+ vi.advanceTimersByTime(500);
+ expect(onAttackIntent).not.toHaveBeenCalled();
+ });
+
+ it("start schedules initial attack if not cancelled", () => {
+ const fakePlayer = {
+ cosmetics: { pattern: { name: "pattern1", colorPalette: null } },
+ troops: () => 100,
+ } as any;
+
+ const gameView = {
+ playerByClientID: (_: any) => fakePlayer,
+ } as any;
+
+ const onAttackIntent = vi.fn();
+
+ const exec = new TestSkinExecution(
+ gameView,
+ "client1" as any,
+ () => true,
+ () => {},
+ onAttackIntent,
+ () => {},
+ );
+
+ exec.start();
+
+ // advance past initial attack delay
+ vi.advanceTimersByTime(200);
+
+ // initial attack should have called the onAttackIntent callback
+ expect(onAttackIntent).toHaveBeenCalledWith(null, 50);
+ });
+});