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`).
+ */
+@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);
+ });
+});