[Feature] Test a skin (#2857)

## Description:

It lets users test skins :)
<img width="847" height="734" alt="image"
src="https://github.com/user-attachments/assets/55ed112c-a401-417d-bc39-06deb7f4817e"
/>

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)

<img width="1165" height="703" alt="image"
src="https://github.com/user-attachments/assets/037add18-95b4-46cc-9627-3eabfb51c631"
/>

<img width="618" height="647" alt="image"
src="https://github.com/user-attachments/assets/8d1d6afe-8107-4472-a143-181eabaa67c6"
/>

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 <evanpelle@gmail.com>
Co-authored-by: iamlewis <lewismmmm@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: VariableVince <24507472+VariableVince@users.noreply.github.com>
This commit is contained in:
Ryan
2026-05-26 23:31:07 +01:00
committed by GitHub
parent 2d6342cd22
commit 3c0b5149b5
15 changed files with 982 additions and 2 deletions
+1
View File
@@ -333,6 +333,7 @@
<emoji-table></emoji-table>
<build-menu></build-menu>
<win-modal></win-modal>
<skin-test-win-modal></skin-test-win-modal>
<game-starting-modal></game-starting-modal>
<div
class="flex flex-col items-end fixed top-0 right-0 min-[1200px]:top-4 min-[1200px]:right-4 z-1000 gap-2"
+7
View File
@@ -1034,6 +1034,13 @@
"join_server": "Join Server",
"youtube_tutorial": "Need some help?"
},
"skin_test_modal": {
"title": "Preview Complete",
"rate_skin": "Rate this Skin",
"rate_up": "Rate up",
"rate_down": "Rate down",
"preview_skin": "Preview Skin"
},
"leaderboard": {
"player": "Player",
"team": "Team",
+53 -1
View File
@@ -12,6 +12,7 @@ import {
ServerMessage,
} from "../core/Schemas";
import { createPartialGameRecord, findClosestBy, replacer } from "../core/Util";
import { TestSkinExecution } from "../core/execution/TestSkinExecution";
import {
BuildableUnit,
PlayerType,
@@ -46,6 +47,7 @@ import {
InputHandler,
MouseMoveEvent,
MouseUpEvent,
ReplaySpeedChangeEvent,
TickMetricsEvent,
} from "./InputHandler";
import { endGame, startGame, startTime } from "./LocalPersistantStats";
@@ -66,9 +68,11 @@ 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";
import { ReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier";
export interface LobbyConfig {
cosmetics: PlayerCosmeticRefs;
@@ -81,6 +85,7 @@ export interface LobbyConfig {
gameStartInfo?: GameStartInfo;
// GameRecord exists when replaying an archived game.
gameRecord?: GameRecord;
isSkinTest?: boolean;
}
export interface JoinLobbyResult {
@@ -441,6 +446,7 @@ async function createClientGame(
lobbyConfig.gameStartInfo.gameID,
lobbyConfig.gameStartInfo.players,
);
gameView.isSkinTest = lobbyConfig.isSkinTest ?? false;
// Transparent fullscreen overlay used purely as the pointer-event /
// bounding-rect target for InputHandler + TransformHandler. The actual
@@ -529,6 +535,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 lastTickReceiveTime: number = 0;
private currentTickDelay: number | undefined = undefined;
@@ -549,6 +556,15 @@ export class ClientGameRunner {
this.lastMessageTime = Date.now();
}
private stopSkinTest() {
if (this.testSkinExecution !== null) {
try {
this.testSkinExecution.stop();
} finally {
this.testSkinExecution = null;
}
}
}
/**
* Determines whether window closing should be prevented.
*
@@ -609,6 +625,35 @@ 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.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.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) {
+2
View File
@@ -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) {
+92 -1
View File
@@ -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 {
<cosmetic-button
.resolved=${r}
.onPurchase=${purchaseCosmetic}
.onTest=${this.userMeResponse !== false
? (resolved: ResolvedCosmetic) => this.startTestGame(resolved)
: undefined}
></cosmetic-button>
`,
)}
+14
View File
@@ -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()}
</div>
</button>
${isPurchasable && isPattern && this.onTest
? html`<button
class="w-full mt-1 px-4 py-2 bg-blue-500/20 text-blue-400 border border-blue-500/30 rounded-lg text-xs font-bold uppercase tracking-wider cursor-pointer transition-all duration-200 hover:bg-blue-500/30 hover:shadow-[0_0_15px_rgba(59,130,246,0.2)]"
@click=${(e: Event) => {
e.stopPropagation();
this.onTest?.(this.resolved);
}}
>
${translateText("skin_test_modal.preview_skin")}
</button>`
: nothing}
${isOwnedSubscription
? html`<div
class="w-full mt-2 px-4 py-2 bg-amber-500/20 text-amber-300 border border-amber-500/40 rounded-lg text-xs font-bold uppercase tracking-wider text-center"
+9
View File
@@ -35,6 +35,7 @@ import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
import { PlayerPanel } from "./layers/PlayerPanel";
import { ReplayPanel } from "./layers/ReplayPanel";
import { SettingsModal } from "./layers/SettingsModal";
import { SkinTestWinModal } from "./layers/SkinTestWinModal";
import { SpawnTimer } from "./layers/SpawnTimer";
import { TeamStats } from "./layers/TeamStats";
import { UnitDisplay } from "./layers/UnitDisplay";
@@ -165,6 +166,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");
@@ -307,6 +315,7 @@ export function createRenderer(
controlPanel,
playerInfo,
winModal,
skinTestWinModal,
replayPanel,
settingsModal,
teamStats,
+211
View File
@@ -0,0 +1,211 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { ColorPalette, Pattern } from "../../../core/CosmeticSchemas";
import { EventBus } from "../../../core/EventBus";
import {
fetchCosmetics,
purchaseCosmetic,
ResolvedCosmetic,
} from "../../Cosmetics";
import { translateText } from "../../Utils";
import "../../components/CosmeticButton";
import { Controller } from "../../Controller";
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;
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`
<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"
>
<h2
class="m-0 mb-6 text-2xl font-bold text-center text-white uppercase tracking-wider"
>
${translateText("skin_test_modal.title")}
</h2>
<div class="flex flex-col items-center gap-6 mb-6">
<div class="text-center">
<h3 class="text-lg font-semibold text-white/90 mb-2">
${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>
</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``}
</div>
<button
@click=${this._handleExit}
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")}
</button>
</div>
<style>
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-out;
}
</style>
`;
}
}
+5
View File
@@ -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 &&
+2
View File
@@ -117,6 +117,8 @@ export class GameView implements GameMap {
private _map: GameMap;
public isSkinTest: boolean = false;
constructor(
public worker: WorkerClient,
private _config: Config,
+1
View File
@@ -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(),
+137
View File
@@ -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<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));
}
}
+243
View File
@@ -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",
);
});
});
+123
View File
@@ -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 = '<button data-testid="cosmetic-btn">mock</button>';
}
},
);
}
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<string, string> = {};
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 <o-button> custom element with translationKey="main.store"
const storeBtn = modal.querySelector(
'o-button[translationKey="main.store"]',
);
expect(storeBtn).toBeTruthy();
});
});
+82
View File
@@ -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);
});
});