mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 10:00:44 +00:00
[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:
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
`,
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
@@ -117,6 +117,8 @@ export class GameView implements GameMap {
|
||||
|
||||
private _map: GameMap;
|
||||
|
||||
public isSkinTest: boolean = false;
|
||||
|
||||
constructor(
|
||||
public worker: WorkerClient,
|
||||
private _config: Config,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user