From 8323a1ec2ead57bc41c0534299b86390e19f632d Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 27 May 2026 00:17:25 +0100 Subject: [PATCH] TestSkin->SkinTest --- src/client/ClientGameRunner.ts | 42 +--- src/client/SkinTestController.ts | 88 ++++++++ src/client/Store.ts | 102 +++++----- src/client/hud/GameRenderer.ts | 12 +- src/client/hud/layers/SkinTestWinModal.ts | 213 ++++++++------------ src/core/configuration/Config.ts | 4 + src/core/execution/TestSkinExecution.ts | 137 ------------- tests/client/SkinTestController.test.ts | 71 +++++++ tests/client/SkinTestGameFlow.test.ts | 47 ++--- tests/client/TerritoryPatternsModal.test.ts | 11 +- tests/client/TestSkinExecution.test.ts | 82 -------- 11 files changed, 339 insertions(+), 470 deletions(-) create mode 100644 src/client/SkinTestController.ts delete mode 100644 src/core/execution/TestSkinExecution.ts create mode 100644 tests/client/SkinTestController.test.ts delete mode 100644 tests/client/TestSkinExecution.test.ts diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 874b88ade..f443745a7 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -12,7 +12,6 @@ import { ServerMessage, } from "../core/Schemas"; import { createPartialGameRecord, findClosestBy, replacer } from "../core/Util"; -import { TestSkinExecution } from "../core/execution/TestSkinExecution"; import { BuildableUnit, PlayerType, @@ -51,6 +50,7 @@ import { TickMetricsEvent, } from "./InputHandler"; import { endGame, startGame, startTime } from "./LocalPersistantStats"; +import { SkinTestController } from "./SkinTestController"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; import { GoToPlayerEvent } from "./TransformHandler"; import { @@ -68,7 +68,6 @@ import { import { createCanvas } from "./Utils"; import { WebGLFrameBuilder } from "./WebGLFrameBuilder"; import { createRenderer, GameRenderer } from "./hud/GameRenderer"; -import { ShowSkinTestModalEvent } from "./hud/layers/SkinTestWinModal"; import { GameView as WebGLGameView } from "./render/gl"; import { ALL_UNIT_TYPES, UnitState } from "./render/types"; import { SoundManager } from "./sound/SoundManager"; @@ -535,7 +534,7 @@ export class ClientGameRunner { private lastMessageTime: number = 0; private connectionCheckInterval: NodeJS.Timeout | null = null; private goToPlayerTimeout: NodeJS.Timeout | null = null; - private testSkinExecution: TestSkinExecution | null = null; + private skinTestController: SkinTestController | null = null; private lastTickReceiveTime: number = 0; private currentTickDelay: number | undefined = undefined; @@ -557,13 +556,8 @@ export class ClientGameRunner { } private stopSkinTest() { - if (this.testSkinExecution !== null) { - try { - this.testSkinExecution.stop(); - } finally { - this.testSkinExecution = null; - } - } + this.skinTestController?.stop(); + this.skinTestController = null; } /** * Determines whether window closing should be prevented. @@ -626,32 +620,18 @@ export class ClientGameRunner { }, 20000); if (this.lobby.isSkinTest) { - // Set game speed to maximum this.eventBus.emit( new ReplaySpeedChangeEvent(ReplaySpeedMultiplier.fastest), ); - - // Clean up any prior skin test resources, then set a new timeout and start a fresh execution this.stopSkinTest(); - - // Start a fresh TestSkinExecution which manages its own modal timeout - this.testSkinExecution = new TestSkinExecution( + this.skinTestController = new SkinTestController( this.gameView, this.clientID!, - () => 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.eventBus, + this.renderer.skinTestWinModal, + () => this.stop(), ); - this.testSkinExecution.start(); + this.skinTestController.start(); } this.eventBus.on(MouseUpEvent, this.inputEvent.bind(this)); @@ -713,8 +693,8 @@ export class ClientGameRunner { if (gu.updates[GameUpdateType.Win].length > 0) { if (this.lobby.isSkinTest) { - // For skin tests, show the modal immediately on win instead of waiting - this.testSkinExecution?.showModal(); + // Skin tests: show the modal immediately on win instead of saving the game. + this.skinTestController?.showModal(); } else { this.saveGame(gu.updates[GameUpdateType.Win][0]); } diff --git a/src/client/SkinTestController.ts b/src/client/SkinTestController.ts new file mode 100644 index 000000000..94c5fe8a6 --- /dev/null +++ b/src/client/SkinTestController.ts @@ -0,0 +1,88 @@ +import { EventBus } from "../core/EventBus"; +import { GameView, PlayerView } from "../core/game/GameView"; +import { ClientID } from "../core/Schemas"; +import { SkinTestWinModal } from "./hud/layers/SkinTestWinModal"; +import { SendAttackIntentEvent } from "./Transport"; + +const INITIAL_ATTACK_DELAY_MS = 100; +const MAX_PLAYER_LOOKUP_RETRIES = 50; +const MODAL_TIMEOUT_MS = 120_000; + +/** + * Client-side controller for the "preview a skin" singleplayer game. + * + * Spawns the player, fires an initial attack so the skin is visible on the map, + * then shows the rate/buy modal after a fixed timeout (or sooner if the game + * ends). Lives on the client because it depends on wall-clock timing and on + * the EventBus + DOM — neither of which belong in src/core. + */ +export class SkinTestController { + private myPlayer: PlayerView | null = null; + private attackTimer: ReturnType | null = null; + private modalTimer: ReturnType | null = null; + private lookupRetries = 0; + private active = true; + + constructor( + private readonly gameView: GameView, + private readonly clientID: ClientID, + private readonly eventBus: EventBus, + private readonly modal: SkinTestWinModal | null, + private readonly onPreviewEnded: () => void, + ) {} + + start(): void { + this.scheduleAttack(); + this.modalTimer = setTimeout(() => this.showModal(), MODAL_TIMEOUT_MS); + } + + stop(): void { + this.active = false; + if (this.attackTimer !== null) { + clearTimeout(this.attackTimer); + this.attackTimer = null; + } + if (this.modalTimer !== null) { + clearTimeout(this.modalTimer); + this.modalTimer = null; + } + } + + showModal(): void { + if (!this.active) return; + const player = this.gameView.playerByClientID(this.clientID); + const pattern = player?.cosmetics?.pattern; + this.stop(); + this.onPreviewEnded(); + if (!pattern) { + console.error("Skin test: no pattern on player", this.clientID); + return; + } + this.modal?.showByName(pattern.name, pattern.colorPalette ?? null); + } + + private scheduleAttack(): void { + this.attackTimer = setTimeout(() => { + this.attackTimer = null; + this.runAttack(); + }, INITIAL_ATTACK_DELAY_MS); + } + + private runAttack(): void { + if (!this.active) return; + if (this.myPlayer === null) { + const found = this.gameView.playerByClientID(this.clientID); + if (found === null) { + if (++this.lookupRetries >= MAX_PLAYER_LOOKUP_RETRIES) { + console.error("Skin test: gave up finding player"); + return; + } + this.scheduleAttack(); + return; + } + this.myPlayer = found; + } + const troops = Math.floor(this.myPlayer.troops() / 2); + this.eventBus.emit(new SendAttackIntentEvent(null, troops)); + } +} diff --git a/src/client/Store.ts b/src/client/Store.ts index 7f18b6183..cde47cc28 100644 --- a/src/client/Store.ts +++ b/src/client/Store.ts @@ -12,6 +12,7 @@ import { UnitType, } from "../core/game/Game"; import { UserSettings } from "../core/game/UserSettings"; +import { GameConfig } from "../core/Schemas"; import { BaseModal } from "./components/BaseModal"; import "./components/CosmeticButton"; import "./components/NotLoggedInWarning"; @@ -26,6 +27,52 @@ import { translateText } from "./Utils"; type StoreTab = "patterns" | "flags" | "packs" | "subscriptions"; +// Units the player cannot build during a skin preview — keeps focus on the +// pattern itself rather than late-game mechanics. +const SKIN_TEST_DISABLED_UNITS: UnitType[] = [ + UnitType.City, + UnitType.Factory, + UnitType.Port, + UnitType.MissileSilo, + UnitType.DefensePost, + UnitType.SAMLauncher, + UnitType.AtomBomb, + UnitType.HydrogenBomb, + UnitType.MIRV, + UnitType.Warship, +]; + +function buildSkinTestGameConfig(): GameConfig { + return { + gameMap: GameMapType.Iceland, + gameMapSize: GameMapSize.Compact, + gameType: GameType.Singleplayer, + gameMode: GameMode.FFA, + difficulty: Difficulty.Easy, + nations: "disabled", + bots: 0, + donateGold: false, + donateTroops: false, + instantBuild: false, + randomSpawn: true, + infiniteGold: true, + infiniteTroops: true, + startingTroops: 10_000_000, + percentageTilesOwnedToWin: 99, + disabledUnits: SKIN_TEST_DISABLED_UNITS, + }; +} + +function patternDisplayName(name: string): string { + const translation = translateText(`territory_patterns.pattern.${name}`); + if (!translation.startsWith("territory_patterns.pattern.")) + return translation; + return name + .split("_") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); +} + @customElement("store-modal") export class StoreModal extends BaseModal { protected routerName = "store"; @@ -71,22 +118,6 @@ export class StoreModal extends BaseModal { 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: { @@ -99,42 +130,17 @@ export class StoreModal extends BaseModal { players: [ { clientID, - username: displayName, + username: patternDisplayName(pattern.name), cosmetics: { - pattern: selectedPattern, + pattern: { + name: pattern.name, + patternData: pattern.pattern, + colorPalette: colorPalette ?? undefined, + }, }, }, ], - 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, - startingTroops: 10_000_000, - percentageTilesOwnedToWin: 99, - disabledUnits: [ - UnitType.City, - UnitType.Factory, - UnitType.Port, - UnitType.MissileSilo, - UnitType.DefensePost, - UnitType.SAMLauncher, - UnitType.AtomBomb, - UnitType.HydrogenBomb, - UnitType.MIRV, - UnitType.Warship, - ], - }, + config: buildSkinTestGameConfig(), lobbyCreatedAt: Date.now(), }, }, diff --git a/src/client/hud/GameRenderer.ts b/src/client/hud/GameRenderer.ts index 423d8b747..a12c53903 100644 --- a/src/client/hud/GameRenderer.ts +++ b/src/client/hud/GameRenderer.ts @@ -166,12 +166,9 @@ 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 skinTestWinModalEl = document.querySelector("skin-test-win-modal"); + const skinTestWinModal = + skinTestWinModalEl instanceof SkinTestWinModal ? skinTestWinModalEl : null; const replayPanel = document.querySelector("replay-panel") as ReplayPanel; if (!(replayPanel instanceof ReplayPanel)) { @@ -315,7 +312,6 @@ export function createRenderer( controlPanel, playerInfo, winModal, - skinTestWinModal, replayPanel, settingsModal, teamStats, @@ -332,6 +328,7 @@ export function createRenderer( uiState, layers, performanceOverlay, + skinTestWinModal, ); } @@ -343,6 +340,7 @@ export class GameRenderer { public uiState: UIState, private layers: Controller[], private performanceOverlay: PerformanceOverlay, + public readonly skinTestWinModal: SkinTestWinModal | null, ) {} initialize() { diff --git a/src/client/hud/layers/SkinTestWinModal.ts b/src/client/hud/layers/SkinTestWinModal.ts index 2d0ece9bb..1268e5ae6 100644 --- a/src/client/hud/layers/SkinTestWinModal.ts +++ b/src/client/hud/layers/SkinTestWinModal.ts @@ -1,9 +1,7 @@ -import { html, LitElement } from "lit"; +import { html, LitElement, nothing } from "lit"; import { customElement, state } from "lit/decorators.js"; import { ColorPalette, Pattern } from "../../../core/CosmeticSchemas"; -import { EventBus } from "../../../core/EventBus"; import "../../components/CosmeticButton"; -import { Controller } from "../../Controller"; import { fetchCosmetics, purchaseCosmetic, @@ -11,96 +9,109 @@ import { } from "../../Cosmetics"; import { translateText } from "../../Utils"; -export class ShowSkinTestModalEvent { - constructor( - public patternName: string, - public colorPalette: ColorPalette | null, - ) {} -} - @customElement("skin-test-win-modal") -export class SkinTestWinModal extends LitElement implements Controller { - 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; +export class SkinTestWinModal extends LitElement { + @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 by pattern name — fetches the full Pattern object from the cosmetics API. */ + async showByName( + patternName: string, + colorPalette: ColorPalette | null, + ): Promise { + const cosmetics = await fetchCosmetics(); + const pattern = cosmetics?.patterns[patternName]; + if (!pattern) { + console.error("Skin test: pattern not found", patternName); + return; + } + this.show(pattern, colorPalette); } - show(pattern: Pattern, colorPalette: ColorPalette | null) { + show(pattern: Pattern, colorPalette: ColorPalette | null): void { this.pattern = pattern; this.colorPalette = colorPalette; this.isVisible = true; } - hide() { + hide(): void { this.isVisible = false; this.rated = null; } - private _handleExit() { + private exit(): void { this.hide(); window.location.href = "/"; } - private _handleRate(rating: "up" | "down") { + private rate(rating: "up" | "down"): void { this.rated = rating; // TODO: send rating event to the server } - render() { - if (!this.isVisible) return html``; + private renderRateButton(rating: "up" | "down") { + const isSelected = this.rated === rating; + const selectedClass = + rating === "up" + ? "bg-green-500 text-white shadow-[0_0_15px_rgba(34,197,94,0.5)]" + : "bg-red-500 text-white shadow-[0_0_15px_rgba(239,68,68,0.5)]"; + const path = + rating === "up" + ? "M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5" + : "M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.095c.5 0 .905-.405.905-.905 0-.714.211-1.412.608-2.006L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"; + return html` + + `; + } + private renderCosmetic() { + if (!this.pattern) return nothing; + const resolved: ResolvedCosmetic = { + type: "pattern", + cosmetic: this.pattern, + colorPalette: this.colorPalette, + relationship: "purchasable", + key: `pattern:${this.pattern.name}${this.colorPalette ? `:${this.colorPalette.name}` : ""}`, + }; + return html` +
+ +
+ `; + } + + render() { + if (!this.isVisible) return nothing; return html`
- - + ${this.renderRateButton("up")} ${this.renderRateButton("down")}
- - - ${this.pattern - ? html` -
- -
- ` - : html``} + ${this.renderCosmetic()}