mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 13:50:43 +00:00
TestSkin->SkinTest
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user