From 3c0b5149b54d664f8dd82dea3253f3c5666c5833 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 26 May 2026 23:31:07 +0100 Subject: [PATCH] [Feature] Test a skin (#2857) ## Description: It lets users test skins :) image They are added to Iceland Map no bots, no nations, max troops, max speed, auto attack wilderness, win victory at 99%, and then the testing complete modal appears with the skin they tested is present (rating system not developed as OOS) image image Game does not save, the username of the player is the name of the skin, the ID of the game is the name of the skin too. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n --------- Co-authored-by: Evan Co-authored-by: iamlewis Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: VariableVince <24507472+VariableVince@users.noreply.github.com> --- index.html | 1 + resources/lang/en.json | 7 + src/client/ClientGameRunner.ts | 54 ++++- src/client/Main.ts | 2 + src/client/Store.ts | 93 +++++++- src/client/components/CosmeticButton.ts | 14 ++ src/client/hud/GameRenderer.ts | 9 + src/client/hud/layers/SkinTestWinModal.ts | 211 +++++++++++++++++ src/client/hud/layers/WinModal.ts | 5 + src/client/view/GameView.ts | 2 + src/core/Schemas.ts | 1 + src/core/execution/TestSkinExecution.ts | 137 +++++++++++ tests/client/SkinTestGameFlow.test.ts | 243 ++++++++++++++++++++ tests/client/TerritoryPatternsModal.test.ts | 123 ++++++++++ tests/client/TestSkinExecution.test.ts | 82 +++++++ 15 files changed, 982 insertions(+), 2 deletions(-) create mode 100644 src/client/hud/layers/SkinTestWinModal.ts create mode 100644 src/core/execution/TestSkinExecution.ts create mode 100644 tests/client/SkinTestGameFlow.test.ts create mode 100644 tests/client/TerritoryPatternsModal.test.ts create mode 100644 tests/client/TestSkinExecution.test.ts 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); + }); +});