TestSkin->SkinTest

This commit is contained in:
Ryan Barlow
2026-05-27 00:17:25 +01:00
parent 6c7a04014e
commit 8323a1ec2e
11 changed files with 339 additions and 470 deletions
+11 -31
View File
@@ -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]);
}
+88
View File
@@ -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<typeof setTimeout> | null = null;
private modalTimer: ReturnType<typeof setTimeout> | 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));
}
}
+54 -48
View File
@@ -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(),
},
},
+5 -7
View File
@@ -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() {
+81 -132
View File
@@ -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<void> {
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`
<button
@click=${() => this.rate(rating)}
aria-label=${translateText(`skin_test_modal.rate_${rating}`)}
class="p-3 rounded-full transition-all duration-200 hover:scale-110 flex items-center justify-center overflow-visible ${isSelected
? selectedClass
: "bg-white/10 text-white/50 hover:bg-white/20 hover:text-white"}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 block"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d=${path}
/>
</svg>
</button>
`;
}
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`
<div class="scale-110">
<cosmetic-button
.resolved=${resolved}
.onPurchase=${purchaseCosmetic}
></cosmetic-button>
</div>
`;
}
render() {
if (!this.isVisible) return nothing;
return html`
<div
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-gray-800/90 p-6 shrink-0 rounded-lg z-[10000] shadow-2xl backdrop-blur-md text-white w-96 animate-fadeIn border border-white/10"
@@ -117,76 +128,14 @@ export class SkinTestWinModal extends LitElement implements Controller {
${translateText("skin_test_modal.rate_skin")}
</h3>
<div class="flex gap-4 justify-center">
<button
@click=${() => this._handleRate("up")}
aria-label=${translateText("skin_test_modal.rate_up")}
class="p-3 rounded-full transition-all duration-200 hover:scale-110 flex items-center justify-center overflow-visible ${this
.rated === "up"
? "bg-green-500 text-white shadow-[0_0_15px_rgba(34,197,94,0.5)]"
: "bg-white/10 text-white/50 hover:bg-white/20 hover:text-white"}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 block"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="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"
/>
</svg>
</button>
<button
@click=${() => this._handleRate("down")}
aria-label=${translateText("skin_test_modal.rate_down")}
class="p-3 rounded-full transition-all duration-200 hover:scale-110 flex items-center justify-center overflow-visible ${this
.rated === "down"
? "bg-red-500 text-white shadow-[0_0_15px_rgba(239,68,68,0.5)]"
: "bg-white/10 text-white/50 hover:bg-white/20 hover:text-white"}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 block"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="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"
/>
</svg>
</button>
${this.renderRateButton("up")} ${this.renderRateButton("down")}
</div>
</div>
<!-- Display the skin with purchase option -->
${this.pattern
? html`
<div class="scale-110">
<cosmetic-button
.resolved=${{
type: "pattern",
cosmetic: this.pattern,
colorPalette: this.colorPalette,
relationship: "purchasable",
key: `pattern:${this.pattern.name}${this.colorPalette ? `:${this.colorPalette.name}` : ""}`,
} satisfies ResolvedCosmetic}
.onPurchase=${purchaseCosmetic}
></cosmetic-button>
</div>
`
: html``}
${this.renderCosmetic()}
</div>
<button
@click=${this._handleExit}
@click=${this.exit}
class="w-full py-3 text-sm font-bold uppercase tracking-wider cursor-pointer bg-white/10 text-white border border-white/10 rounded-lg transition-all duration-200 hover:bg-white/20 hover:border-white/30"
>
${translateText("win_modal.exit")}
+4
View File
@@ -533,6 +533,10 @@ export class Config {
}
percentageTilesOwnedToWin(): number {
const override = this._gameConfig.percentageTilesOwnedToWin;
if (override !== undefined && override !== null) {
return override;
}
if (this._gameConfig.gameMode === GameMode.Team) {
return 95;
}
-137
View File
@@ -1,137 +0,0 @@
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<typeof setTimeout> | null = null;
private modalTimeoutId: ReturnType<typeof setTimeout> | 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));
}
}
+71
View File
@@ -0,0 +1,71 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SkinTestController } from "../../src/client/SkinTestController";
import { SendAttackIntentEvent } from "../../src/client/Transport";
import { EventBus } from "../../src/core/EventBus";
describe("SkinTestController", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
const makePlayer = () =>
({
cosmetics: { pattern: { name: "pattern1", colorPalette: { name: "p" } } },
troops: () => 100,
}) as any;
function makeController(opts?: {
onPreviewEnded?: () => void;
modal?: { showByName: ReturnType<typeof vi.fn> } | null;
}) {
const player = makePlayer();
const gameView = { playerByClientID: () => player } as any;
const eventBus = new EventBus();
const onAttack = vi.fn();
eventBus.on(SendAttackIntentEvent, onAttack);
const modal = opts?.modal ?? { showByName: vi.fn() };
const onPreviewEnded = opts?.onPreviewEnded ?? vi.fn();
const controller = new SkinTestController(
gameView,
"client1" as any,
eventBus,
modal as any,
onPreviewEnded,
);
return { controller, onAttack, modal, onPreviewEnded };
}
it("schedules an initial attack with half the player's troops", () => {
const { controller, onAttack } = makeController();
controller.start();
vi.advanceTimersByTime(200);
expect(onAttack).toHaveBeenCalledTimes(1);
expect(onAttack.mock.calls[0][0]).toMatchObject({
targetID: null,
troops: 50,
});
});
it("showModal cancels the initial attack and shows the modal", () => {
const { controller, onAttack, modal, onPreviewEnded } = makeController();
controller.start();
controller.showModal();
expect(onPreviewEnded).toHaveBeenCalledOnce();
expect(modal!.showByName).toHaveBeenCalledWith("pattern1", { name: "p" });
vi.advanceTimersByTime(500);
expect(onAttack).not.toHaveBeenCalled();
});
it("stop() prevents the modal from firing on its own timer", () => {
const { controller, modal } = makeController();
controller.start();
controller.stop();
vi.advanceTimersByTime(200_000);
expect(modal!.showByName).not.toHaveBeenCalled();
});
});
+17 -30
View File
@@ -5,7 +5,6 @@ vi.mock("../../src/client/Utils", () => ({
getSvgAspectRatio: async () => 1,
}));
// Avoid any audio side effects.
vi.mock("../../src/client/sound/SoundManager", () => ({
SoundManager: vi.fn().mockImplementation(() => ({
playBackgroundMusic: vi.fn(),
@@ -18,12 +17,12 @@ 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.
// Mock CosmeticButton so the modal can render a clickable purchase target in
// JSDOM without dragging in real canvas rendering.
vi.mock("../../src/client/components/CosmeticButton", () => {
class CosmeticButton extends HTMLElement {
private _resolved: any = null;
@@ -68,9 +67,7 @@ vi.mock("../../src/client/components/CosmeticButton", () => {
customElements.define("cosmetic-button", CosmeticButton);
}
return {
CosmeticButton,
};
return { CosmeticButton };
});
import { ClientGameRunner } from "../../src/client/ClientGameRunner";
@@ -93,11 +90,11 @@ const makeCosmetics = () =>
describe("Skin test game flow", () => {
let modal: SkinTestWinModal;
let runner: ClientGameRunner | null = null;
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);
}
@@ -108,25 +105,23 @@ describe("Skin test game flow", () => {
});
afterEach(() => {
runner?.stop();
runner = null;
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.
it("shows the buy modal on game-end and routes purchase through the modal", async () => {
const { EventBus } = await import("../../src/core/EventBus");
const eventBus = new EventBus();
modal.eventBus = eventBus;
const renderer = {
initialize: vi.fn(),
tick: vi.fn(),
skinTestWinModal: modal,
} as any;
const input = {
initialize: vi.fn(),
} as any;
const input = { initialize: vi.fn() } as any;
const transport = {
turnComplete: vi.fn(),
@@ -147,10 +142,7 @@ describe("Skin test game flow", () => {
const myPlayer = {
cosmetics: {
pattern: {
name: "purch_pattern",
colorPalette: null,
},
pattern: { name: "purch_pattern", colorPalette: null },
},
troops: () => 1000,
clientID: () => "client123",
@@ -180,7 +172,7 @@ describe("Skin test game flow", () => {
},
} as any;
const runner = new ClientGameRunner(
runner = new ClientGameRunner(
lobby,
"client123",
eventBus,
@@ -189,18 +181,13 @@ describe("Skin test game flow", () => {
transport,
worker,
gameView,
{ playBackgroundMusic: vi.fn() } as any,
{} as any, // userSettings
) as any;
{ playBackgroundMusic: vi.fn(), dispose: vi.fn() } as any,
{} 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] = [
@@ -219,13 +206,13 @@ describe("Skin test game flow", () => {
tickExecutionDuration: 0,
});
// showSkinTestModal() is async (fetchCosmetics + lit updates). Give the
// microtask queue a moment, then await the next render.
// showByName() is async (fetchCosmetics + lit updates); wait for the
// microtask queue to drain, then for 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.
// The mock cosmetic-button is also a custom element; let it render.
await new Promise((r) => setTimeout(r, 0));
const buyBtn = modal.querySelector(
+8 -3
View File
@@ -56,8 +56,13 @@ describe("TerritoryPatternsModal", () => {
let modal: TerritoryPatternsModal;
beforeEach(async () => {
if (typeof (globalThis as any).localStorage?.getItem !== "function") {
let store: Record<string, string> = {};
// Reset between tests: JSDOM may persist its real localStorage across
// tests, and the previous fallback closure persists its own `store`.
// Either way, clear before each run.
if (typeof (globalThis as any).localStorage?.clear === "function") {
(globalThis as any).localStorage.clear();
} else {
const store: Record<string, string> = {};
Object.defineProperty(globalThis, "localStorage", {
value: {
getItem: (k: string) => (k in store ? store[k] : null),
@@ -68,7 +73,7 @@ describe("TerritoryPatternsModal", () => {
delete store[k];
},
clear: () => {
store = {};
for (const key of Object.keys(store)) delete store[key];
},
},
configurable: true,
-82
View File
@@ -1,82 +0,0 @@
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);
});
});