From a5e504c3e98210a0a13a01fbd90c84afb506b39f Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 28 May 2026 21:38:42 +0100 Subject: [PATCH] init --- index.html | 2 + resources/lang/en.json | 10 +- src/client/ClientGameRunner.ts | 12 +- src/client/Cosmetics.ts | 15 ++- src/client/LocalServer.ts | 4 + src/client/Main.ts | 7 ++ src/client/Preview.ts | 111 ++++++++++++++++++ src/client/Store.ts | 3 + src/client/components/CosmeticButton.ts | 16 +++ src/client/hud/GameRenderer.ts | 34 +++++- src/client/hud/layers/PreviewCompleteModal.ts | 104 ++++++++++++++++ src/client/hud/layers/PreviewFinishButton.ts | 51 ++++++++ src/client/hud/layers/WinModal.ts | 5 + src/client/styles.css | 29 +++++ src/core/GameRunner.ts | 34 ++++-- src/core/Schemas.ts | 4 + src/core/configuration/Config.ts | 4 + src/core/execution/ExecutionManager.ts | 15 ++- .../execution/PreviewAutoExpandExecution.ts | 72 ++++++++++++ src/core/execution/Util.ts | 43 +++++++ tests/PreviewMode.test.ts | 83 +++++++++++++ 21 files changed, 638 insertions(+), 20 deletions(-) create mode 100644 src/client/Preview.ts create mode 100644 src/client/hud/layers/PreviewCompleteModal.ts create mode 100644 src/client/hud/layers/PreviewFinishButton.ts create mode 100644 src/core/execution/PreviewAutoExpandExecution.ts create mode 100644 tests/PreviewMode.test.ts diff --git a/index.html b/index.html index d2e4e9270..b0003964a 100644 --- a/index.html +++ b/index.html @@ -333,6 +333,8 @@ + +
{ if (!resolved.cosmetic) return; const c = resolved.cosmetic; @@ -145,7 +150,7 @@ export async function purchaseCosmetic( : (userMe.player.currency?.soft ?? 0); if (balance < price) { alert(translateText("store.not_enough_currency")); - if (method === "hard") { + if (method === "hard" && !opts.fromGame) { // Send the user to the packs tab so they can top up plutonium. window.location.hash = "#modal=store&tab=packs"; } @@ -165,7 +170,13 @@ export async function purchaseCosmetic( } alert(translateText("store.purchase_success", { name: c.name })); invalidateUserMe(); - window.location.reload(); + if (opts.fromGame) { + // Reloading the game URL would try to rejoin a game that no longer exists, + // so go home; the newly-owned cosmetic applies on the fresh load. + window.location.href = "/"; + } else { + window.location.reload(); + } } function simpleHash(str: string): string { diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 0304224bf..7080467ba 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -268,6 +268,10 @@ export class LocalServer { if (this.isReplay) { return; } + // Skin previews are throwaway sandboxes — never archive them. + if (this.lobbyConfig.gameStartInfo?.config.isPreview) { + return; + } const players: PlayerRecord[] = [ { persistentID: getPersistentID(), diff --git a/src/client/Main.ts b/src/client/Main.ts index bd73b5f46..9ecbb15e5 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -823,6 +823,7 @@ class Client { console.log("joining lobby, stopping existing game"); this.lobbyHandle.stop(true); document.body.classList.remove("in-game"); + document.body.classList.remove("preview-mode"); } if (lobby.source === "public") { this.joinModal?.open({ @@ -928,6 +929,11 @@ class Client { crazyGamesSDK.loadingStop(); crazyGamesSDK.gameplayStart(); document.body.classList.add("in-game"); + // Cinematic skin-preview mode hides the normal HUD (see styles.css). + document.body.classList.toggle( + "preview-mode", + lobby.gameStartInfo?.config.isPreview === true, + ); // Ensure there's a homepage entry in history before adding the lobby entry if (window.location.hash === "" || window.location.hash === "#") { @@ -975,6 +981,7 @@ class Client { } document.body.classList.remove("in-game"); + document.body.classList.remove("preview-mode"); if (this.joinModal.isOpen()) { this.joinModal.close(); diff --git a/src/client/Preview.ts b/src/client/Preview.ts new file mode 100644 index 000000000..1d9975bbc --- /dev/null +++ b/src/client/Preview.ts @@ -0,0 +1,111 @@ +import { Pattern, Skin } from "../core/CosmeticSchemas"; +import { + Difficulty, + GameMapSize, + GameMapType, + GameMode, + GameType, +} from "../core/game/Game"; +import { GameConfig, PlayerCosmetics } from "../core/Schemas"; +import { generateID } from "../core/Util"; +import { ResolvedCosmetic } from "./Cosmetics"; +import { JoinLobbyEvent } from "./Main"; +import { UsernameInput } from "./UsernameInput"; + +// The cosmetic currently being previewed, stashed so the "Preview Complete" +// modal can show it (and its buy button) when the user finishes. Set the moment +// a preview is launched and read by . +let previewCosmetic: ResolvedCosmetic | null = null; + +export function getPreviewCosmetic(): ResolvedCosmetic | null { + return previewCosmetic; +} + +/** Build a PlayerCosmetics that forces the previewed pattern/skin onto the + * player, regardless of ownership. Returns null for non-previewable types. */ +function playerCosmeticsFor( + resolved: ResolvedCosmetic, +): PlayerCosmetics | null { + const c = resolved.cosmetic; + if (c === null) return null; + if (resolved.type === "pattern") { + return { + pattern: { + name: c.name, + patternData: (c as Pattern).pattern, + colorPalette: resolved.colorPalette ?? undefined, + }, + }; + } + if (resolved.type === "skin") { + return { + skin: { + name: c.name, + url: (c as Skin).url, + }, + }; + } + return null; +} + +/** + * Launches a singleplayer skin-preview sandbox for the given cosmetic: the + * player auto-spawns in the centre of Australia with a 100M-strong army and + * floods their (skinned) territory across the empty map. Nothing is saved. + * + * Patterns and image skins are previewable; other cosmetic types are ignored. + */ +export function launchSkinPreview(resolved: ResolvedCosmetic): void { + const cosmetics = playerCosmeticsFor(resolved); + if (cosmetics === null) return; + + previewCosmetic = resolved; + + const clientID = generateID(); + const gameID = generateID(); + const usernameInput = document.querySelector( + "username-input", + ) as UsernameInput | null; + + const config: GameConfig = { + gameMap: GameMapType.Australia, + gameMapSize: GameMapSize.Normal, + gameType: GameType.Singleplayer, + gameMode: GameMode.FFA, + difficulty: Difficulty.Easy, + nations: "disabled", + bots: 0, + donateGold: false, + donateTroops: false, + infiniteGold: true, + infiniteTroops: true, + instantBuild: true, + randomSpawn: false, + disableAlliances: true, + isPreview: true, + }; + + document.dispatchEvent( + new CustomEvent("join-lobby", { + detail: { + gameID, + gameStartInfo: { + gameID, + players: [ + { + clientID, + username: usernameInput?.getUsername() ?? "Preview", + clanTag: usernameInput?.getClanTag() ?? null, + cosmetics, + }, + ], + config, + lobbyCreatedAt: Date.now(), + }, + source: "singleplayer", + } satisfies JoinLobbyEvent, + bubbles: true, + composed: true, + }), + ); +} diff --git a/src/client/Store.ts b/src/client/Store.ts index a239860c4..d25f0ed5a 100644 --- a/src/client/Store.ts +++ b/src/client/Store.ts @@ -13,6 +13,7 @@ import { purchaseCosmetic, resolveCosmetics, } from "./Cosmetics"; +import { launchSkinPreview } from "./Preview"; import { translateText } from "./Utils"; type StoreTab = "patterns" | "flags" | "packs" | "subscriptions"; @@ -93,6 +94,7 @@ export class StoreModal extends BaseModal { `, )} @@ -261,6 +263,7 @@ export class StoreModal extends BaseModal { `, )} diff --git a/src/client/components/CosmeticButton.ts b/src/client/components/CosmeticButton.ts index 84fce3d23..16d7888dc 100644 --- a/src/client/components/CosmeticButton.ts +++ b/src/client/components/CosmeticButton.ts @@ -35,6 +35,11 @@ export class CosmeticButton extends LitElement { @property({ type: Function }) onPurchase?: (resolved: ResolvedCosmetic, method: PaymentMethod) => void; + /** When set (patterns/skins only), shows a "Preview" button that launches a + * singleplayer sandbox to try the cosmetic on the map before buying. */ + @property({ type: Function }) + onPreview?: (resolved: ResolvedCosmetic) => void; + /** True if the user already has a subscription (any tier). */ @property({ type: Boolean }) userHasSubscription: boolean = false; @@ -238,6 +243,17 @@ export class CosmeticButton extends LitElement { ${this.renderPreview()}
+ ${(isPattern || isSkin) && this.onPreview + ? html`` + : nothing} ${isOwnedSubscription ? html`
). + */ +@customElement("preview-complete-modal") +export class PreviewCompleteModal extends LitElement implements Controller { + public game: GameView; + public eventBus: EventBus; + + @state() + isVisible = false; + + createRenderRoot() { + return this; + } + + init() {} + + tick() {} + + show() { + this.isVisible = true; + this.requestUpdate(); + } + + hide() { + this.isVisible = false; + this.requestUpdate(); + } + + private handleExit() { + this.hide(); + // Navigate home; the reload clears the preview-mode body class. + window.location.href = "/"; + } + + // Buy the previewed skin right here. `fromGame` keeps the purchase clean + // inside the live preview: a successful buy navigates home (full teardown), + // a Stripe purchase redirects out, and insufficient funds just shows the + // "not enough currency" message and leaves the preview running. + private handlePurchase = ( + resolved: ResolvedCosmetic, + method: PaymentMethod, + ) => { + purchaseCosmetic(resolved, method, { fromGame: true }); + }; + + render() { + if (!this.isVisible) return html``; + const resolved = getPreviewCosmetic(); + return html` +
+

+ ${translateText("preview.complete_title")} +

+

+ ${translateText("preview.complete_subtitle")} +

+ ${resolved + ? html`
+ +
` + : nothing} +
+ + +
+
+ `; + } +} diff --git a/src/client/hud/layers/PreviewFinishButton.ts b/src/client/hud/layers/PreviewFinishButton.ts new file mode 100644 index 000000000..26c1325b1 --- /dev/null +++ b/src/client/hud/layers/PreviewFinishButton.ts @@ -0,0 +1,51 @@ +import { html, LitElement } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { EventBus } from "../../../core/EventBus"; +import { GameView } from "../../../core/game/GameView"; +import "../../components/baseComponents/Button"; +import { Controller } from "../../Controller"; + +/** + * Always-on overlay button shown only during a skin preview. Clicking it ends + * the preview by opening . The preview itself never + * auto-ends, so this is the player's way out. + */ +@customElement("preview-finish-button") +export class PreviewFinishButton extends LitElement implements Controller { + public game: GameView; + public eventBus: EventBus; + + @state() + private isPreview = false; + + createRenderRoot() { + return this; + } + + init() { + this.isPreview = this.game?.config().isPreview() ?? false; + this.requestUpdate(); + } + + tick() {} + + private onFinish() { + const modal = document.querySelector("preview-complete-modal") as + | (HTMLElement & { show?: () => void }) + | null; + modal?.show?.(); + } + + render() { + if (!this.isPreview) return html``; + return html` +
+ +
+ `; + } +} diff --git a/src/client/hud/layers/WinModal.ts b/src/client/hud/layers/WinModal.ts index 7c7b8fdd6..5bfd00aa9 100644 --- a/src/client/hud/layers/WinModal.ts +++ b/src/client/hud/layers/WinModal.ts @@ -257,6 +257,11 @@ export class WinModal extends LitElement implements Controller { init() {} tick() { + // Skin previews use their own ; never show the + // win/death modal (and there's no win check in preview games anyway). + if (this.game.config().isPreview()) { + return; + } const myPlayer = this.game.myPlayer(); if ( !this.hasShownDeathModal && diff --git a/src/client/styles.css b/src/client/styles.css index 234d2aece..16d9ca26e 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -664,3 +664,32 @@ news-button .active button::after { .remove-player-btn:hover { background: #ff6666; } + +/* Cinematic skin-preview mode: hide the normal in-game HUD so the player can + just watch their previewed cosmetic spread across the map. The game canvas, + , and stay visible. */ +body.preview-mode build-menu, +body.preview-mode control-panel, +body.preview-mode unit-display, +body.preview-mode attacks-display, +body.preview-mode chat-display, +body.preview-mode events-display, +body.preview-mode actionable-events, +body.preview-mode game-right-sidebar, +body.preview-mode replay-panel, +body.preview-mode player-panel, +body.preview-mode spawn-timer, +body.preview-mode immunity-timer, +body.preview-mode in-game-promo, +body.preview-mode alert-frame, +body.preview-mode chat-modal, +body.preview-mode multi-tab-modal, +body.preview-mode game-left-sidebar, +body.preview-mode performance-overlay, +body.preview-mode player-info-overlay, +body.preview-mode leader-board, +body.preview-mode team-stats, +body.preview-mode heads-up-message, +body.preview-mode emoji-table { + display: none !important; +} diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 702abf110..811d27b37 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -1,6 +1,7 @@ import { placeName, placeSpawnName } from "../client/hud/NameBoxCalculator"; import { Config } from "./configuration/Config"; import { Executor } from "./execution/ExecutionManager"; +import { PreviewAutoExpandExecution } from "./execution/PreviewAutoExpandExecution"; import { RecomputeRailClusterExecution } from "./execution/RecomputeRailClusterExecution"; import { SpawnTimerExecution } from "./execution/SpawnTimerExecution"; import { WinCheckExecution } from "./execution/WinCheckExecution"; @@ -97,22 +98,29 @@ export class GameRunner { ) {} init() { - if (this.game.config().gameConfig().gameType !== GameType.Singleplayer) { + const config = this.game.config(); + if (config.gameConfig().gameType !== GameType.Singleplayer) { this.game.addExecution(new SpawnTimerExecution()); } - if (this.game.config().isRandomSpawn()) { - this.game.addExecution(...this.execManager.spawnPlayers()); + if (config.isPreview()) { + // Skin-preview sandbox: auto-spawn the human in the map centre and let + // PreviewAutoExpandExecution expand them across the wilderness. No bots, + // nations, or win check — the preview ends only when the user finishes. + this.game.addExecution(...this.execManager.spawnPreviewPlayers()); + this.game.addExecution(new PreviewAutoExpandExecution()); + } else { + if (config.isRandomSpawn()) { + this.game.addExecution(...this.execManager.spawnPlayers()); + } + if (config.bots() > 0) { + this.game.addExecution(...this.execManager.spawnTribes(config.bots())); + } + if (config.spawnNations()) { + this.game.addExecution(...this.execManager.nationExecutions()); + } + this.game.addExecution(new WinCheckExecution()); } - if (this.game.config().bots() > 0) { - this.game.addExecution( - ...this.execManager.spawnTribes(this.game.config().bots()), - ); - } - if (this.game.config().spawnNations()) { - this.game.addExecution(...this.execManager.nationExecutions()); - } - this.game.addExecution(new WinCheckExecution()); - if (!this.game.config().isUnitDisabled(UnitType.Factory)) { + if (!config.isUnitDisabled(UnitType.Factory)) { this.game.addExecution( new RecomputeRailClusterExecution(this.game.railNetwork()), ); diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 39f207e19..0eb8f0cdf 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -257,6 +257,10 @@ export const GameConfigSchema = z.object({ disableClanTags: z.boolean().optional(), waterNukes: z.boolean().nullable().optional(), randomSpawn: z.boolean(), + // Singleplayer-only "skin preview" sandbox: the human auto-spawns in the + // map centre with a huge army and auto-expands into the wilderness so the + // player can watch a cosmetic spread across their territory. Never saved. + isPreview: z.boolean().optional(), 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 diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 9ea5dd3cb..f955f696c 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -97,6 +97,10 @@ export class Config { return this._isReplay; } + isPreview(): boolean { + return this._gameConfig.isPreview ?? false; + } + traitorDefenseDebuff(): number { return 0.5; } diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index ccdb792d6..9f27c1ca8 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -1,4 +1,4 @@ -import { Execution, Game } from "../game/Game"; +import { Execution, Game, PlayerType } from "../game/Game"; import { PseudoRandom } from "../PseudoRandom"; import { ClientID, GameID, StampedIntent, Turn } from "../Schemas"; import { simpleHash } from "../Util"; @@ -27,6 +27,7 @@ import { TargetPlayerExecution } from "./TargetPlayerExecution"; import { TransportShipExecution } from "./TransportShipExecution"; import { TribeSpawner } from "./TribeSpawner"; import { UpgradeStructureExecution } from "./UpgradeStructureExecution"; +import { findCenterSpawnTile } from "./Util"; import { PlayerSpawner } from "./utils/PlayerSpawner"; export class Executor { @@ -134,6 +135,18 @@ export class Executor { return new PlayerSpawner(this.mg, this.gameID).spawnPlayers(); } + // Spawn every human player in the centre of the map. Used by the singleplayer + // skin-preview sandbox so the player starts "in the middle" without clicking. + spawnPreviewPlayers(): SpawnExecution[] { + const center = findCenterSpawnTile(this.mg) ?? undefined; + const execs: SpawnExecution[] = []; + for (const player of this.mg.allPlayers()) { + if (player.type() !== PlayerType.Human) continue; + execs.push(new SpawnExecution(this.gameID, player.info(), center)); + } + return execs; + } + nationExecutions(): Execution[] { const execs: Execution[] = []; for (const nation of this.mg.nations()) { diff --git a/src/core/execution/PreviewAutoExpandExecution.ts b/src/core/execution/PreviewAutoExpandExecution.ts new file mode 100644 index 000000000..fbd4502c6 --- /dev/null +++ b/src/core/execution/PreviewAutoExpandExecution.ts @@ -0,0 +1,72 @@ +import { Execution, Game, Player, PlayerType } from "../game/Game"; +import { TileRef } from "../game/GameMap"; + +// The previewing player is given (and kept topped up to) a huge army, matching +// the "100 million troops poured into the wilderness" framing of the feature. +const PREVIEW_TROOPS = 100_000_000; + +// How many rings of wilderness to swallow per tick. Higher = faster spread. +const RINGS_PER_TICK = 10; + +/** + * Drives the singleplayer skin-preview sandbox: every tick it tops the human + * player up to {@link PREVIEW_TROOPS} and floods their territory outward by one + * ring, conquering every unclaimed land tile bordering them. + * + * The normal attack mechanic throttles expansion into terra nullius to a slow + * crawl no matter how many troops are involved, which is the opposite of what a + * "watch your skin spread across the map" preview wants. Since this only ever + * runs in a throwaway singleplayer sandbox (no opponents, never saved), we + * expand by conquering directly — a smooth radial flood-fill that visibly + * covers the continent with the previewed cosmetic. + * + * Runs until the user clicks "Finish preview"; it naturally goes quiet once + * there's no unclaimed land left to take. + */ +export class PreviewAutoExpandExecution implements Execution { + private active = true; + private mg: Game; + private player: Player | null = null; + + init(mg: Game, ticks: number) { + this.mg = mg; + } + + tick(ticks: number) { + if (this.player === null) { + this.player = + this.mg.players().find((p) => p.type() === PlayerType.Human) ?? null; + if (this.player === null) return; + } + const player = this.player; + if (!player.isAlive()) return; + + // Keep the army huge so the HUD (if shown) reflects the giant force. + player.setTroops(PREVIEW_TROOPS); + + // Flood outward by several rings per tick: each pass conquers every + // unclaimed land tile touching the player's current border. + for (let ring = 0; ring < RINGS_PER_TICK; ring++) { + const frontier = new Set(); + for (const border of player.borderTiles()) { + this.mg.forEachNeighbor(border, (n) => { + if (this.mg.isLand(n) && !this.mg.hasOwner(n)) { + frontier.add(n); + } + }); + } + if (frontier.size === 0) break; // fully expanded — nothing left to take + for (const tile of frontier) { + player.conquer(tile); + } + } + } + + isActive(): boolean { + return this.active; + } + + activeDuringSpawnPhase(): boolean { + return false; + } +} diff --git a/src/core/execution/Util.ts b/src/core/execution/Util.ts index 1b51a41fe..246792c29 100644 --- a/src/core/execution/Util.ts +++ b/src/core/execution/Util.ts @@ -156,6 +156,49 @@ export function getSpawnTiles( return spawnTiles; } +/** + * Finds the unowned land tile nearest the geometric centre of the map. + * + * Used by the singleplayer skin-preview sandbox to spawn the player "in the + * middle of the map". Scans outward in square rings (Chebyshev distance) from + * the centre, so the result is deterministic. Returns null only if the map has + * no unowned land at all. + */ +export function findCenterSpawnTile(game: Game): TileRef | null { + const width = game.width(); + const height = game.height(); + const cx = Math.floor(width / 2); + const cy = Math.floor(height / 2); + + const valid = (x: number, y: number): TileRef | null => { + if (x < 0 || y < 0 || x >= width || y >= height) return null; + const t = game.ref(x, y); + return game.isLand(t) && !game.hasOwner(t) ? t : null; + }; + + const center = valid(cx, cy); + if (center !== null) return center; + + const maxR = Math.max(width, height); + for (let r = 1; r <= maxR; r++) { + // Top and bottom edges of the ring. + for (let dx = -r; dx <= r; dx++) { + const top = valid(cx + dx, cy - r); + if (top !== null) return top; + const bottom = valid(cx + dx, cy + r); + if (bottom !== null) return bottom; + } + // Left and right edges (excluding the corners already checked above). + for (let dy = -r + 1; dy <= r - 1; dy++) { + const left = valid(cx - r, cy + dy); + if (left !== null) return left; + const right = valid(cx + r, cy + dy); + if (right !== null) return right; + } + } + return null; +} + export function closestTile( gm: GameMap, refs: Iterable, diff --git a/tests/PreviewMode.test.ts b/tests/PreviewMode.test.ts new file mode 100644 index 000000000..40c569f2f --- /dev/null +++ b/tests/PreviewMode.test.ts @@ -0,0 +1,83 @@ +import { PreviewAutoExpandExecution } from "../src/core/execution/PreviewAutoExpandExecution"; +import { SpawnExecution } from "../src/core/execution/SpawnExecution"; +import { findCenterSpawnTile } from "../src/core/execution/Util"; +import { Game, Player, PlayerInfo, PlayerType } from "../src/core/game/Game"; +import { GameID } from "../src/core/Schemas"; +import { setup } from "./util/Setup"; + +const gameID: GameID = "game_id"; + +describe("findCenterSpawnTile", () => { + test("returns an unowned land tile at/near the geometric centre", async () => { + const game = await setup("plains", { isPreview: true }); + + const tile = findCenterSpawnTile(game); + expect(tile).not.toBeNull(); + if (tile === null) return; + + // The chosen tile must be spawnable land. + expect(game.isLand(tile)).toBe(true); + expect(game.hasOwner(tile)).toBe(false); + + // ...and it should be the centre tile (or very close to it) on an + // all-land map. + const cx = Math.floor(game.width() / 2); + const cy = Math.floor(game.height() / 2); + expect(Math.abs(game.x(tile) - cx)).toBeLessThanOrEqual(2); + expect(Math.abs(game.y(tile) - cy)).toBeLessThanOrEqual(2); + }); +}); + +describe("PreviewAutoExpandExecution", () => { + let game: Game; + let player: Player; + + beforeEach(async () => { + game = await setup("plains", { isPreview: true, infiniteTroops: true }); + const info = new PlayerInfo( + "previewer", + PlayerType.Human, + null, + "preview_id", + ); + game.addPlayer(info); + + const center = findCenterSpawnTile(game); + expect(center).not.toBeNull(); + game.addExecution(new SpawnExecution(gameID, info, center!)); + game.executeNextTick(); + game.executeNextTick(); + player = game.player(info.id); + }); + + test("floods the player across the wilderness and keeps the army huge", async () => { + const tilesAfterSpawn = player.numTilesOwned(); + expect(player.isAlive()).toBe(true); + + game.addExecution(new PreviewAutoExpandExecution()); + // Several rings per tick, so just a few ticks balloons the territory. + for (let i = 0; i < 3; i++) { + game.executeNextTick(); + } + + expect(player.numTilesOwned()).toBeGreaterThan(tilesAfterSpawn * 10); + + // The army is kept topped up rather than left at the ~100k natural start. + expect(player.troops()).toBe(100_000_000); + }); + + test("stops growing once the whole map is owned", async () => { + game.addExecution(new PreviewAutoExpandExecution()); + // ~10 rings/tick fills a 100x100 all-land map from the centre quickly. + for (let i = 0; i < 20; i++) { + game.executeNextTick(); + } + const filled = player.numTilesOwned(); + + game.executeNextTick(); + game.executeNextTick(); + // No unclaimed land left, so the count is stable. + expect(player.numTilesOwned()).toBe(filled); + expect(filled).toBeGreaterThan(9000); + }); +});