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); + }); +});