diff --git a/index.html b/index.html
index b53683ca3a..a7e8eabd4e 100644
--- a/index.html
+++ b/index.html
@@ -260,6 +260,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));
@@ -381,7 +426,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]);
+ }
}
});
@@ -515,6 +565,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 2dd19eab26..948b318b12 100644
--- a/src/client/Main.ts
+++ b/src/client/Main.ts
@@ -222,6 +222,7 @@ export interface JoinLobbyEvent {
gameStartInfo?: GameStartInfo;
// GameRecord exists when replaying an archived game.
gameRecord?: GameRecord;
+ isSkinTest?: boolean;
source?: "public" | "private" | "host" | "matchmaking" | "singleplayer";
}
@@ -815,6 +816,7 @@ class Client {
this.usernameInput?.getCurrentUsername() ?? genAnonUsername(),
gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info,
gameRecord: lobby.gameRecord,
+ isSkinTest: lobby.isSkinTest,
},
() => {
console.log("Closing modals");
diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts
index 105e3fb631..c68127681a 100644
--- a/src/client/TerritoryPatternsModal.ts
+++ b/src/client/TerritoryPatternsModal.ts
@@ -3,6 +3,14 @@ import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
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 { PlayerPattern } from "../core/Schemas";
import { hasLinkedAccount } from "./Api";
@@ -176,6 +184,10 @@ export class TerritoryPatternsModal extends BaseModal {
.onSelect=${(p: PlayerPattern | null) => this.selectPattern(p)}
.onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) =>
handlePurchase(p, colorPalette)}
+ .onTest=${hasLinkedAccount(this.userMeResponse)
+ ? (p: Pattern, colorPalette: ColorPalette | null) =>
+ this.startTestGame(p, colorPalette)
+ : undefined}
>
`);
}
@@ -205,6 +217,94 @@ export class TerritoryPatternsModal extends BaseModal {
`;
}
+ private startTestGame(pattern: Pattern, colorPalette: ColorPalette | null) {
+ if (!this.userMeResponse) {
+ window.dispatchEvent(
+ new CustomEvent("show-message", {
+ detail: {
+ message: translateText("territory_patterns.not_logged_in"),
+ duration: 3000,
+ },
+ }),
+ );
+ return;
+ }
+ const clientID = this.userMeResponse.player.publicId;
+ const gameID = pattern.name;
+
+ const selectedPattern = {
+ name: pattern.name,
+ patternData: pattern.pattern,
+ colorPalette: colorPalette ?? undefined,
+ };
+
+ // Use translation if available, otherwise format the name
+ 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: clientID,
+ gameID: gameID,
+ isSkinTest: true,
+ source: "singleplayer",
+ gameStartInfo: {
+ gameID: 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 renderMySkinsButton(): TemplateResult {
return html`
+ `
+ : null}
`
: null}
diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts
index 5f4ef4a37d..f260d823cd 100644
--- a/src/client/graphics/GameRenderer.ts
+++ b/src/client/graphics/GameRenderer.ts
@@ -34,6 +34,7 @@ import { RailroadLayer } from "./layers/RailroadLayer";
import { ReplayPanel } from "./layers/ReplayPanel";
import { SAMRadiusLayer } from "./layers/SAMRadiusLayer";
import { SettingsModal } from "./layers/SettingsModal";
+import { SkinTestWinModal } from "./layers/SkinTestWinModal";
import { SpawnTimer } from "./layers/SpawnTimer";
import { SpawnVideoAd } from "./layers/SpawnVideoReward";
import { StructureIconsLayer } from "./layers/StructureIconsLayer";
@@ -159,6 +160,13 @@ export function createRenderer(
winModal.eventBus = eventBus;
winModal.game = game;
+ const skinTestWinModal = document.querySelector(
+ "skin-test-win-modal",
+ ) as SkinTestWinModal;
+ if (skinTestWinModal instanceof SkinTestWinModal) {
+ skinTestWinModal.eventBus = eventBus;
+ }
+
const replayPanel = document.querySelector("replay-panel") as ReplayPanel;
if (!(replayPanel instanceof ReplayPanel)) {
console.error("replay panel not found");
@@ -311,6 +319,7 @@ export function createRenderer(
controlPanel,
playerInfo,
winModal,
+ skinTestWinModal,
replayPanel,
settingsModal,
teamStats,
diff --git a/src/client/graphics/layers/SkinTestWinModal.ts b/src/client/graphics/layers/SkinTestWinModal.ts
new file mode 100644
index 0000000000..d1cdbc36ca
--- /dev/null
+++ b/src/client/graphics/layers/SkinTestWinModal.ts
@@ -0,0 +1,204 @@
+import { LitElement, html } from "lit";
+import { customElement, state } from "lit/decorators.js";
+import { ColorPalette, Pattern } from "../../../core/CosmeticSchemas";
+import { EventBus } from "../../../core/EventBus";
+import { fetchCosmetics, handlePurchase } from "../../Cosmetics";
+import { translateText } from "../../Utils";
+import "../../components/PatternButton";
+import { Layer } from "./Layer";
+
+export class ShowSkinTestModalEvent {
+ constructor(
+ public patternName: string,
+ public colorPalette: ColorPalette | null,
+ ) {}
+}
+
+@customElement("skin-test-win-modal")
+export class SkinTestWinModal extends LitElement implements Layer {
+ private _eventBus?: EventBus;
+ private _onShowEvent?: (e: ShowSkinTestModalEvent) => 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`
+
+
+ handlePurchase(p, c)}
+ >
+
+ `
+ : html``}
+
+
+
+
+
+
+ `;
+ }
+}
diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts
index c3f34630e3..629d3e3310 100644
--- a/src/client/graphics/layers/WinModal.ts
+++ b/src/client/graphics/layers/WinModal.ts
@@ -297,6 +297,11 @@ export class WinModal extends LitElement implements Layer {
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/core/Schemas.ts b/src/core/Schemas.ts
index 10b97969f1..217b18d48c 100644
--- a/src/core/Schemas.ts
+++ b/src/core/Schemas.ts
@@ -230,6 +230,7 @@ export const GameConfigSchema = z.object({
maxPlayers: z.number().optional(),
maxTimerValue: z.number().int().min(1).max(120).optional(), // In minutes
spawnImmunityDuration: z.number().int().min(0).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).optional(),
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts
index 5a672f296e..9b05bdb8ea 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -539,6 +539,9 @@ export class DefaultConfig implements Config {
}
percentageTilesOwnedToWin(): number {
+ if (this._gameConfig.percentageTilesOwnedToWin !== undefined) {
+ return this._gameConfig.percentageTilesOwnedToWin;
+ }
if (this._gameConfig.gameMode === GameMode.Team) {
return 95;
}
@@ -742,7 +745,7 @@ export class DefaultConfig implements Config {
assertNever(this._gameConfig.difficulty);
}
}
- return this.infiniteTroops() ? 1_000_000 : 25_000;
+ return this.infiniteTroops() ? 1_000_000_000 : 25_000;
}
maxTroops(player: Player | PlayerView): number {
diff --git a/src/core/execution/TestSkinExecution.ts b/src/core/execution/TestSkinExecution.ts
new file mode 100644
index 0000000000..f590cfd61f
--- /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/src/core/game/GameView.ts b/src/core/game/GameView.ts
index 07d2841e8d..df6aee0f1f 100644
--- a/src/core/game/GameView.ts
+++ b/src/core/game/GameView.ts
@@ -599,6 +599,8 @@ export class GameView implements GameMap {
private _map: GameMap;
+ public isSkinTest: boolean = false;
+
constructor(
public worker: WorkerClient,
private _config: Config,
@@ -607,7 +609,9 @@ export class GameView implements GameMap {
private _myUsername: string,
private _gameID: GameID,
private humans: Player[],
+ isSkinTest?: boolean,
) {
+ this.isSkinTest = isSkinTest ?? false;
this._map = this._mapData.gameMap;
this.lastUpdate = null;
this.unitGrid = new UnitGrid(this._map);
diff --git a/tests/client/SkinTestGameFlow.test.ts b/tests/client/SkinTestGameFlow.test.ts
new file mode 100644
index 0000000000..0315274cbe
--- /dev/null
+++ b/tests/client/SkinTestGameFlow.test.ts
@@ -0,0 +1,257 @@
+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", () => ({
+ default: {
+ playBackgroundMusic: vi.fn(),
+ stopBackgroundMusic: vi.fn(),
+ },
+}));
+
+const fetchCosmeticsMock = vi.fn();
+const handlePurchaseMock = vi.fn();
+vi.mock("../../src/client/Cosmetics", () => ({
+ fetchCosmetics: (...args: any[]) => fetchCosmeticsMock(...args),
+ handlePurchase: (...args: any[]) => handlePurchaseMock(...args),
+ // Not needed in this suite
+ patternRelationship: () => "blocked",
+}));
+
+// Mock PatternButton so SkinTestWinModal can render a purchase click target in JSDOM.
+vi.mock("../../src/client/components/PatternButton", () => {
+ class PatternButton extends HTMLElement {
+ private _pattern: any = null;
+ private _colorPalette: any = null;
+ private _requiresPurchase = false;
+ private _onPurchase?: (pattern: any, colorPalette: any) => void;
+
+ get pattern() {
+ return this._pattern;
+ }
+ set pattern(v: any) {
+ this._pattern = v;
+ this.render();
+ }
+
+ get colorPalette() {
+ return this._colorPalette;
+ }
+ set colorPalette(v: any) {
+ this._colorPalette = v;
+ this.render();
+ }
+
+ get requiresPurchase() {
+ return this._requiresPurchase;
+ }
+ set requiresPurchase(v: boolean) {
+ this._requiresPurchase = v;
+ this.render();
+ }
+
+ get onPurchase() {
+ return this._onPurchase;
+ }
+ set onPurchase(v: ((pattern: any, colorPalette: any) => void) | undefined) {
+ this._onPurchase = v;
+ this.render();
+ }
+
+ connectedCallback() {
+ this.render();
+ }
+
+ render() {
+ this.innerHTML = "";
+ if (this.requiresPurchase && this.onPurchase && this.pattern) {
+ 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.pattern, this.colorPalette ?? null);
+ });
+ this.appendChild(btn);
+ }
+ }
+ }
+
+ if (!customElements.get("pattern-button")) {
+ customElements.define("pattern-button", PatternButton);
+ }
+
+ return {
+ PatternButton,
+ renderPatternPreview: () => "",
+ };
+});
+
+import { ClientGameRunner } from "../../src/client/ClientGameRunner";
+import { SkinTestWinModal } from "../../src/client/graphics/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,
+ ) 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(handlePurchaseMock).toHaveBeenCalledTimes(1);
+ expect(handlePurchaseMock.mock.calls[0][0].name).toBe("purch_pattern");
+ });
+});
diff --git a/tests/client/TerritoryPatternsModal.test.ts b/tests/client/TerritoryPatternsModal.test.ts
new file mode 100644
index 0000000000..d0f2cfbb60
--- /dev/null
+++ b/tests/client/TerritoryPatternsModal.test.ts
@@ -0,0 +1,225 @@
+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 + relationship logic so we can deterministically render
+// purchasable vs owned patterns without depending on real server data.
+const fetchCosmeticsMock = vi.fn();
+const patternRelationshipMock = vi.fn();
+const handlePurchaseMock = vi.fn();
+vi.mock("../../src/client/Cosmetics", () => ({
+ fetchCosmetics: (...args: any[]) => fetchCosmeticsMock(...args),
+ patternRelationship: (...args: any[]) => patternRelationshipMock(...args),
+ handlePurchase: (...args: any[]) => handlePurchaseMock(...args),
+}));
+
+// Mock PatternButton to avoid canvas + pattern decoding in JSDOM, while still
+// allowing us to simulate a user clicking the "Preview Skin" button.
+vi.mock("../../src/client/components/PatternButton", () => {
+ class PatternButton extends HTMLElement {
+ private _pattern: any = null;
+ private _colorPalette: any = null;
+ private _requiresPurchase = false;
+ private _onTest?: (pattern: any, colorPalette: any) => void;
+
+ get pattern() {
+ return this._pattern;
+ }
+ set pattern(v: any) {
+ this._pattern = v;
+ this.render();
+ }
+
+ get colorPalette() {
+ return this._colorPalette;
+ }
+ set colorPalette(v: any) {
+ this._colorPalette = v;
+ this.render();
+ }
+
+ get requiresPurchase() {
+ return this._requiresPurchase;
+ }
+ set requiresPurchase(v: boolean) {
+ this._requiresPurchase = v;
+ this.render();
+ }
+
+ get onTest() {
+ return this._onTest;
+ }
+ set onTest(v: ((pattern: any, colorPalette: any) => void) | undefined) {
+ this._onTest = v;
+ this.render();
+ }
+
+ connectedCallback() {
+ this.render();
+ }
+
+ render() {
+ this.innerHTML = "";
+ if (this.requiresPurchase && this.onTest && this.pattern) {
+ const btn = document.createElement("button");
+ btn.setAttribute("data-testid", "preview-skin");
+ btn.textContent = "skin_test_modal.preview_skin";
+ btn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.onTest?.(this.pattern, this.colorPalette ?? null);
+ });
+ this.appendChild(btn);
+ }
+ }
+ }
+
+ if (!customElements.get("pattern-button")) {
+ customElements.define("pattern-button", PatternButton);
+ }
+
+ return {
+ PatternButton,
+ // TerritoryPatternsModal.refresh() calls this; returning a simple string keeps
+ // lit's render() happy without invoking canvas.
+ renderPatternPreview: () => "",
+ };
+});
+
+import { TerritoryPatternsModal } from "../../src/client/TerritoryPatternsModal";
+
+const makeCosmetics = () =>
+ ({
+ patterns: {
+ // purchasable: no palettes => exactly 1 rendered button (null palette only)
+ purch_pattern: {
+ name: "purch_pattern",
+ affiliateCode: "aff",
+ pattern: "AQID",
+ product: { price: "$1.00", priceId: "price_test" },
+ colorPalettes: [],
+ },
+ // owned: has one palette => 2 rendered buttons (palette + null)
+ owned_pattern: {
+ name: "owned_pattern",
+ affiliateCode: "aff",
+ pattern: "BAUG",
+ product: null,
+ colorPalettes: [{ name: "pal1" }],
+ },
+ },
+ colorPalettes: {
+ pal1: {
+ name: "pal1",
+ primaryColor: "#ffffff",
+ secondaryColor: "#000000",
+ },
+ },
+ }) as any;
+
+const makeUserMe = (overrides?: Partial) =>
+ ({
+ user: { discord: { id: "d" } },
+ player: { publicId: "client123", flares: [] },
+ ...overrides,
+ }) as any;
+
+describe("TerritoryPatternsModal skin button simulation", () => {
+ let modal: TerritoryPatternsModal;
+
+ beforeEach(async () => {
+ // Some test environments inject a non-standard localStorage. The modal uses
+ // UserSettings which expects the Storage API.
+ 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(makeCosmetics());
+ patternRelationshipMock.mockImplementation((pattern: any) => {
+ if (pattern?.name === "owned_pattern") return "owned";
+ if (pattern?.name === "purch_pattern") return "purchasable";
+ return "blocked";
+ });
+
+ modal = document.createElement(
+ "territory-patterns-modal",
+ ) as TerritoryPatternsModal;
+ modal.inline = true;
+ document.body.appendChild(modal);
+ await modal.updateComplete;
+
+ // Load user + cosmetics so the modal can render the store grid.
+ await modal.onUserMe(makeUserMe());
+ await modal.updateComplete;
+
+ // Ensure we're in store mode (showOnlyOwned=false) and using the expected store code.
+ await modal.open({ affiliateCode: "aff", showOnlyOwned: false });
+ await modal.updateComplete;
+ });
+
+ afterEach(() => {
+ document.body.removeChild(modal);
+ vi.clearAllMocks();
+ });
+
+ it("toggles the 'My Skins' (show only owned) button", async () => {
+ // Store mode hides owned items => only purchasable should render (1 element)
+ expect(modal.querySelectorAll("pattern-button").length).toBe(1);
+
+ const toggleBtn = Array.from(modal.querySelectorAll("button")).find((b) =>
+ (b.textContent ?? "").includes("territory_patterns.show_only_owned"),
+ );
+ expect(toggleBtn).toBeTruthy();
+
+ toggleBtn!.click();
+ await modal.updateComplete;
+
+ // Owned-only mode shows owned items including the default pattern (null),
+ // so we expect: 1 default + 2 owned_pattern variants = 3.
+ expect(modal.querySelectorAll("pattern-button").length).toBe(3);
+ });
+
+ it("clicking 'Preview Skin' dispatches a join-lobby event with isSkinTest=true", async () => {
+ const joinLobbyHandler = vi.fn();
+ modal.addEventListener("join-lobby", joinLobbyHandler as any);
+
+ const testBtn = modal.querySelector(
+ 'button[data-testid="preview-skin"]',
+ ) as HTMLButtonElement | null;
+ expect(testBtn).toBeTruthy();
+
+ testBtn!.click();
+
+ expect(joinLobbyHandler).toHaveBeenCalledTimes(1);
+ const event = joinLobbyHandler.mock.calls[0][0] as CustomEvent;
+ expect(event.detail.isSkinTest).toBe(true);
+ expect(event.detail.clientID).toBe("client123");
+ expect(event.detail.gameID).toBe("purch_pattern");
+
+ const player0 = event.detail.gameStartInfo.players[0];
+ expect(player0.clientID).toBe("client123");
+ expect(player0.cosmetics.pattern.name).toBe("purch_pattern");
+ });
+});
diff --git a/tests/client/TestSkinExecution.test.ts b/tests/client/TestSkinExecution.test.ts
new file mode 100644
index 0000000000..a62f8ccbf9
--- /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);
+ });
+});