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