diff --git a/resources/lang/en.json b/resources/lang/en.json
index 5dfce1e70..042f0f18e 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -701,6 +701,9 @@
"infinite_gold": "Infinite gold",
"infinite_troops": "Infinite troops",
"instant_build": "Instant build",
+ "invasion_grace": "Invasion grace period (minutes)",
+ "invasion_grace_placeholder": "Mins",
+ "invasion_mode": "Invasion mode",
"leave_confirmation": "Are you sure you want to leave the lobby?",
"max_timer": "Game length (minutes)",
"mins_placeholder": "Mins",
@@ -1188,6 +1191,9 @@
"infinite_gold": "Infinite gold",
"infinite_troops": "Infinite troops",
"instant_build": "Instant build",
+ "invasion_grace": "Invasion grace period (minutes)",
+ "invasion_grace_placeholder": "Mins",
+ "invasion_mode": "Invasion mode",
"max_timer": "Game length (minutes)",
"max_timer_invalid": "Please enter a valid max timer value (1-120 minutes)",
"max_timer_placeholder": "Mins",
diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts
index ef67dd2a3..c6fd7dab3 100644
--- a/src/client/HostLobbyModal.ts
+++ b/src/client/HostLobbyModal.ts
@@ -81,6 +81,8 @@ export class HostLobbyModal extends BaseModal {
@state() private startingGoldValue: number | undefined = undefined;
@state() private disableAlliances: boolean = false;
@state() private waterNukes: boolean = false;
+ @state() private invasionMode: boolean = false;
+ @state() private invasionGracePeriod: number | undefined = undefined;
@state() private lobbyId = "";
@state() private lobbyUrlSuffix = "";
@state() private clients: ClientInfo[] = [];
@@ -288,6 +290,23 @@ export class HostLobbyModal extends BaseModal {
.onChange=${this.handleStartingGoldValueChanges}
.onKeyDown=${this.handleStartingGoldValueKeyDown}
>`,
+ html``,
];
const hostCheatInputCards = [
@@ -584,6 +603,8 @@ export class HostLobbyModal extends BaseModal {
this.startingGoldValue = undefined;
this.disableAlliances = false;
this.waterNukes = false;
+ this.invasionMode = false;
+ this.invasionGracePeriod = undefined;
this.hostCheatsEnabled = false;
this.hostCheatInfiniteGold = false;
this.hostCheatInfiniteTroops = false;
@@ -780,6 +801,29 @@ export class HostLobbyModal extends BaseModal {
this.putGameConfig();
};
+ private handleInvasionToggle = (
+ checked: boolean,
+ value: number | string | undefined,
+ ) => {
+ this.invasionMode = checked;
+ this.invasionGracePeriod = toOptionalNumber(value);
+ this.putGameConfig();
+ };
+
+ private handleInvasionGraceKeyDown = (e: KeyboardEvent) => {
+ preventDisallowedKeys(e, ["-", "+", "e", "E", "."]);
+ };
+
+ private handleInvasionGraceChanges = (e: Event) => {
+ const input = e.target as HTMLInputElement;
+ const value = parseBoundedIntegerFromInput(input, { min: 0, max: 15 });
+ if (value === undefined) {
+ return;
+ }
+ this.invasionGracePeriod = value;
+ this.putGameConfig();
+ };
+
private handleSpawnImmunityDurationKeyDown = (e: KeyboardEvent) => {
preventDisallowedKeys(e, ["-", "+", "e", "E"]);
};
@@ -1037,6 +1081,10 @@ export class HostLobbyModal extends BaseModal {
: null,
disableAlliances: this.disableAlliances || null,
waterNukes: this.waterNukes ? true : null,
+ invasionMode: this.invasionMode || undefined,
+ invasionGracePeriod: this.invasionMode
+ ? Math.max(0, Math.min(15, this.invasionGracePeriod ?? 0))
+ : null,
hostCheats: this.hostCheatsEnabled
? {
infiniteGold: this.hostCheatInfiniteGold || undefined,
diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts
index 994996ddd..322bb2738 100644
--- a/src/client/SinglePlayerModal.ts
+++ b/src/client/SinglePlayerModal.ts
@@ -59,6 +59,8 @@ const DEFAULT_OPTIONS = {
disabledUnits: [] as UnitType[],
disableAlliances: false,
waterNukes: false,
+ invasionMode: false,
+ invasionGracePeriod: undefined as number | undefined,
} as const;
@customElement("single-player-modal")
@@ -97,6 +99,9 @@ export class SinglePlayerModal extends BaseModal {
];
@state() private disableAlliances: boolean = DEFAULT_OPTIONS.disableAlliances;
@state() private waterNukes: boolean = DEFAULT_OPTIONS.waterNukes;
+ @state() private invasionMode: boolean = DEFAULT_OPTIONS.invasionMode;
+ @state() private invasionGracePeriod: number | undefined =
+ DEFAULT_OPTIONS.invasionGracePeriod;
private mapLoader = terrainMapFileLoader;
@@ -251,6 +256,23 @@ export class SinglePlayerModal extends BaseModal {
.onChange=${this.handleStartingGoldValueChanges}
.onKeyDown=${this.handleStartingGoldValueKeyDown}
>`,
+ html``,
];
return html`
@@ -377,6 +399,7 @@ export class SinglePlayerModal extends BaseModal {
this.startingGold !== DEFAULT_OPTIONS.startingGold ||
this.disableAlliances !== DEFAULT_OPTIONS.disableAlliances ||
this.waterNukes !== DEFAULT_OPTIONS.waterNukes ||
+ this.invasionMode !== DEFAULT_OPTIONS.invasionMode ||
this.disabledUnits.length > 0
);
}
@@ -405,6 +428,8 @@ export class SinglePlayerModal extends BaseModal {
this.startingGoldValue = DEFAULT_OPTIONS.startingGoldValue;
this.disableAlliances = DEFAULT_OPTIONS.disableAlliances;
this.waterNukes = DEFAULT_OPTIONS.waterNukes;
+ this.invasionMode = DEFAULT_OPTIONS.invasionMode;
+ this.invasionGracePeriod = DEFAULT_OPTIONS.invasionGracePeriod;
}
protected onOpen(): void {
@@ -547,6 +572,29 @@ export class SinglePlayerModal extends BaseModal {
this.startingGoldValue = toOptionalNumber(value);
};
+ private handleInvasionToggle = (
+ checked: boolean,
+ value: number | string | undefined,
+ ) => {
+ this.invasionMode = checked;
+ this.invasionGracePeriod = toOptionalNumber(value);
+ };
+
+ private handleInvasionGraceKeyDown = (e: KeyboardEvent) => {
+ preventDisallowedKeys(e, ["-", "+", "e", "E", "."]);
+ };
+
+ private handleInvasionGraceChanges = (e: Event) => {
+ const input = e.target as HTMLInputElement;
+ const value = parseBoundedIntegerFromInput(input, {
+ min: 0,
+ max: 15,
+ stripPattern: /[e+\-.]/gi,
+ });
+
+ this.invasionGracePeriod = value;
+ };
+
private handleMaxTimerValueKeyDown = (e: KeyboardEvent) => {
preventDisallowedKeys(e, ["-", "+", "e"]);
};
@@ -698,6 +746,15 @@ export class SinglePlayerModal extends BaseModal {
: {}),
...(this.disableAlliances ? { disableAlliances: true } : {}),
...(this.waterNukes ? { waterNukes: true } : {}),
+ ...(this.invasionMode
+ ? {
+ invasionMode: true,
+ invasionGracePeriod: Math.max(
+ 0,
+ Math.min(15, this.invasionGracePeriod ?? 0),
+ ),
+ }
+ : {}),
},
lobbyCreatedAt: Date.now(), // ms; server should be authoritative in MP
},
diff --git a/src/client/render/gl/colorblind-theme.json b/src/client/render/gl/colorblind-theme.json
index 0d60fbbfd..7f0cc3967 100644
--- a/src/client/render/gl/colorblind-theme.json
+++ b/src/client/render/gl/colorblind-theme.json
@@ -9,7 +9,8 @@
"Green": "#56b4e9",
"Bot": "#d1cdc7",
"Humans": "#0072b2",
- "Nations": "#d55e00"
+ "Nations": "#d55e00",
+ "Invaders": "#a00000"
},
"humanColors": [
"#b60056",
diff --git a/src/client/render/gl/default-theme.json b/src/client/render/gl/default-theme.json
index fd0b97ec9..00da0764d 100644
--- a/src/client/render/gl/default-theme.json
+++ b/src/client/render/gl/default-theme.json
@@ -9,7 +9,8 @@
"Green": "#41be52",
"Bot": "#d1cdc7",
"Humans": "#2962ff",
- "Nations": "#eb3333"
+ "Nations": "#eb3333",
+ "Invaders": "#b71c1c"
},
"humanColors": [
"#a3e635",
diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts
index 8f931502a..098de1cb0 100644
--- a/src/core/GameRunner.ts
+++ b/src/core/GameRunner.ts
@@ -111,6 +111,9 @@ export class GameRunner {
...this.execManager.spawnTribes(this.game.config().bots()),
);
}
+ if (this.game.config().invasionMode()) {
+ this.game.addExecution(this.execManager.invasionExecution());
+ }
this.game.addExecution(new WinCheckExecution());
if (!this.game.config().isUnitDisabled(UnitType.Factory)) {
this.game.addExecution(
diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts
index 68bd7f724..b02a9b720 100644
--- a/src/core/Schemas.ts
+++ b/src/core/Schemas.ts
@@ -281,6 +281,8 @@ export const GameConfigSchema = z.object({
disableClanTags: z.boolean().optional(),
waterNukes: z.boolean().nullable().optional(),
randomSpawn: z.boolean(),
+ invasionMode: z.boolean().optional(),
+ invasionGracePeriod: z.number().int().min(0).max(15).nullable().optional(), // In minutes
maxPlayers: z.number().optional(),
maxTimerValue: z.number().int().min(1).max(120).nullable().optional(), // In minutes
startDelay: z.number().int().min(0).max(600).nullable().optional(), // In seconds
diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts
index 16d7ebee6..33d98d0d3 100644
--- a/src/core/configuration/Config.ts
+++ b/src/core/configuration/Config.ts
@@ -185,6 +185,12 @@ export class Config {
isRandomSpawn(): boolean {
return this._gameConfig.randomSpawn;
}
+ invasionMode(): boolean {
+ return this._gameConfig.invasionMode ?? false;
+ }
+ invasionGracePeriodTicks(): Tick {
+ return (this._gameConfig.invasionGracePeriod ?? 0) * 60 * 10;
+ }
infiniteGold(): boolean {
return this._gameConfig.infiniteGold;
}
diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts
index ccdb792d6..e83b9e7ba 100644
--- a/src/core/execution/ExecutionManager.ts
+++ b/src/core/execution/ExecutionManager.ts
@@ -15,6 +15,7 @@ import { DonateTroopsExecution } from "./DonateTroopExecution";
import { EmbargoAllExecution } from "./EmbargoAllExecution";
import { EmbargoExecution } from "./EmbargoExecution";
import { EmojiExecution } from "./EmojiExecution";
+import { InvasionExecution } from "./invasion/InvasionExecution";
import { MarkDisconnectedExecution } from "./MarkDisconnectedExecution";
import { MoveWarshipExecution } from "./MoveWarshipExecution";
import { NationExecution } from "./NationExecution";
@@ -141,4 +142,8 @@ export class Executor {
}
return execs;
}
+
+ invasionExecution(): Execution {
+ return new InvasionExecution(this.gameID);
+ }
}
diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts
index 65ceb7ec1..f7e1c1fc6 100644
--- a/src/core/execution/TransportShipExecution.ts
+++ b/src/core/execution/TransportShipExecution.ts
@@ -44,6 +44,10 @@ export class TransportShipExecution implements Execution {
private attacker: Player,
private ref: TileRef,
private troops: number,
+ // Optional explicit launch tile (water). Used by Invasion Mode to launch
+ // a boat from a map-edge water tile for an attacker that owns no shore.
+ // When undefined, the source is resolved normally via canBuild().
+ private srcOverride?: TileRef,
) {
this.originalOwner = this.attacker;
}
@@ -116,7 +120,9 @@ export class TransportShipExecution implements Execution {
return;
}
- const src = this.attacker.canBuild(UnitType.TransportShip, this.dst);
+ const src =
+ this.srcOverride ??
+ this.attacker.canBuild(UnitType.TransportShip, this.dst);
if (src === false) {
console.warn(
diff --git a/src/core/execution/WinCheckExecution.ts b/src/core/execution/WinCheckExecution.ts
index 8b8664353..9f707ad26 100644
--- a/src/core/execution/WinCheckExecution.ts
+++ b/src/core/execution/WinCheckExecution.ts
@@ -109,7 +109,8 @@ export class WinCheckExecution implements Execution {
timeElapsed - this.mg.config().gameConfig().maxTimerValue! * 60 >= 0) ||
timeElapsed >= WinCheckExecution.HARD_TIME_LIMIT_SECONDS
) {
- if (max[0] === ColoredTeams.Bot) return;
+ if (max[0] === ColoredTeams.Bot || max[0] === ColoredTeams.Invaders)
+ return;
this.mg.setWinner(max[0], this.mg.stats().stats());
console.log(`${max[0]} has won the game`);
this.active = false;
diff --git a/src/core/execution/invasion/InvasionConfig.ts b/src/core/execution/invasion/InvasionConfig.ts
new file mode 100644
index 000000000..a2e19cda5
--- /dev/null
+++ b/src/core/execution/invasion/InvasionConfig.ts
@@ -0,0 +1,204 @@
+import { Difficulty } from "../../game/Game";
+import { PseudoRandom } from "../../PseudoRandom";
+import { assertNever } from "../../Util";
+
+/**
+ * Pure, deterministic tuning curves for Invasion Mode.
+ *
+ * All inputs are integer tick counts (10 ticks = 1 second, 600 ticks = 1
+ * minute) measured from the moment the invasion begins (i.e. after the grace
+ * period). Intensity escalates with time and is further scaled by the lobby
+ * `Difficulty`. Every function is side-effect free so it can be unit tested in
+ * isolation, and any randomness is taken from a caller-provided seeded
+ * `PseudoRandom` to preserve simulation determinism.
+ */
+
+const TICKS_PER_MINUTE = 600;
+
+// Boat cadence: ~1 every 15s early, ramping down to the 2s floor by 20 min.
+const BOAT_INTERVAL_START = 150; // 15s
+const BOAT_INTERVAL_FLOOR = 20; // 2s — the hard cap from the spec
+const BOAT_RAMP_TICKS = 20 * TICKS_PER_MINUTE; // reaches the floor at 20 min
+const BOAT_RAMP_DELTA = BOAT_INTERVAL_START - BOAT_INTERVAL_FLOOR;
+
+// Transport population: starts at 30k, grows with time and difficulty.
+const TROOPS_START = 30_000;
+const TROOPS_GROWTH_PER_MINUTE = 8_000;
+const TROOPS_CAP = 350_000;
+
+// Escalation onsets (before difficulty time-shift).
+const WARSHIP_START_TICKS = 2 * TICKS_PER_MINUTE; // minute 2
+const ATOM_TICKS = 4 * TICKS_PER_MINUTE; // minute 4
+const HYDROGEN_TICKS = 10 * TICKS_PER_MINUTE; // minute 10
+const MIRV_TICKS = 20 * TICKS_PER_MINUTE; // minute 20
+const WARSHIP_PRESSURE_SPAN = 13 * TICKS_PER_MINUTE; // ramps from min 2 to 15
+const MIRV_CHANCE_ODDS = 10; // 10% per eligible bomb
+
+// Scheduled bombardment cadence once nukes are unlocked.
+const BOMB_INTERVAL_START = 350; // 35s
+const BOMB_INTERVAL_FLOOR = 120; // 12s
+
+export type InvasionNukeTier = "none" | "atom" | "hydrogen" | "mirv";
+export type InvasionNuke = "atom" | "hydrogen" | "mirv";
+
+/** Intensity multiplier (percent) applied to wave size/cadence by difficulty. */
+function difficultyIntensity(difficulty: Difficulty): number {
+ switch (difficulty) {
+ case Difficulty.Easy:
+ return 70;
+ case Difficulty.Medium:
+ return 100;
+ case Difficulty.Hard:
+ return 130;
+ case Difficulty.Impossible:
+ return 160;
+ default:
+ assertNever(difficulty);
+ }
+}
+
+/** Per-trial warship probability bonus (percentage points) by difficulty. */
+function difficultyWarshipBonus(difficulty: Difficulty): number {
+ switch (difficulty) {
+ case Difficulty.Easy:
+ return -10;
+ case Difficulty.Medium:
+ return 0;
+ case Difficulty.Hard:
+ return 10;
+ case Difficulty.Impossible:
+ return 20;
+ default:
+ assertNever(difficulty);
+ }
+}
+
+/** How much earlier (in ticks) nukes unlock on higher difficulties. */
+function difficultyTimeShiftTicks(difficulty: Difficulty): number {
+ switch (difficulty) {
+ case Difficulty.Easy:
+ return TICKS_PER_MINUTE; // 1 min later
+ case Difficulty.Medium:
+ return 0;
+ case Difficulty.Hard:
+ return -TICKS_PER_MINUTE; // 1 min earlier
+ case Difficulty.Impossible:
+ return -2 * TICKS_PER_MINUTE; // 2 min earlier
+ default:
+ assertNever(difficulty);
+ }
+}
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.max(min, Math.min(max, value));
+}
+
+/** Ticks between transport launches at the given elapsed time/difficulty. */
+export function boatIntervalTicks(
+ elapsedTicks: number,
+ difficulty: Difficulty,
+): number {
+ const t = clamp(elapsedTicks, 0, BOAT_RAMP_TICKS);
+ const base =
+ BOAT_INTERVAL_START - Math.floor((BOAT_RAMP_DELTA * t) / BOAT_RAMP_TICKS);
+ const scaled = Math.round((base * 100) / difficultyIntensity(difficulty));
+ return Math.max(BOAT_INTERVAL_FLOOR, scaled);
+}
+
+/** Troop count carried by a transport launched at the given elapsed time. */
+export function boatTroops(
+ elapsedTicks: number,
+ difficulty: Difficulty,
+): number {
+ const minutesTimesGrowth = Math.floor(
+ (TROOPS_GROWTH_PER_MINUTE * Math.max(0, elapsedTicks)) / TICKS_PER_MINUTE,
+ );
+ const scaledGrowth = Math.floor(
+ (minutesTimesGrowth * difficultyIntensity(difficulty)) / 100,
+ );
+ return Math.min(TROOPS_CAP, TROOPS_START + scaledGrowth);
+}
+
+/**
+ * Number of escort warships (0-3) accompanying a wave. Always 0 before minute
+ * 2; afterward the count is weighted toward fewer early and toward 3 later,
+ * shifted by difficulty.
+ */
+export function warshipCount(
+ elapsedTicks: number,
+ random: PseudoRandom,
+ difficulty: Difficulty,
+): number {
+ if (elapsedTicks < WARSHIP_START_TICKS) {
+ return 0;
+ }
+ const pressure = clamp(
+ Math.floor(
+ ((elapsedTicks - WARSHIP_START_TICKS) * 90) / WARSHIP_PRESSURE_SPAN,
+ ),
+ 0,
+ 90,
+ );
+ const threshold = clamp(pressure + difficultyWarshipBonus(difficulty), 5, 95);
+ let count = 0;
+ for (let i = 0; i < 3; i++) {
+ if (random.nextInt(0, 100) < threshold) {
+ count++;
+ }
+ }
+ return count;
+}
+
+/** Highest weapon tier unlocked at the given elapsed time/difficulty. */
+export function nukeTier(
+ elapsedTicks: number,
+ difficulty: Difficulty,
+): InvasionNukeTier {
+ const shift = difficultyTimeShiftTicks(difficulty);
+ if (elapsedTicks >= MIRV_TICKS + shift) return "mirv";
+ if (elapsedTicks >= HYDROGEN_TICKS + shift) return "hydrogen";
+ if (elapsedTicks >= ATOM_TICKS + shift) return "atom";
+ return "none";
+}
+
+/**
+ * Picks the concrete weapon to launch for a scheduled bombardment, or null if
+ * nukes are not yet unlocked. MIRVs are a 10% roll once eligible; otherwise a
+ * hydrogen/atom mix weighted to the unlocked tier.
+ */
+export function selectInvasionNuke(
+ elapsedTicks: number,
+ random: PseudoRandom,
+ difficulty: Difficulty,
+): InvasionNuke | null {
+ const tier = nukeTier(elapsedTicks, difficulty);
+ switch (tier) {
+ case "none":
+ return null;
+ case "atom":
+ return "atom";
+ case "hydrogen":
+ return random.chance(3) ? "atom" : "hydrogen";
+ case "mirv":
+ if (random.chance(MIRV_CHANCE_ODDS)) return "mirv";
+ return random.chance(3) ? "atom" : "hydrogen";
+ default:
+ assertNever(tier);
+ }
+}
+
+/** Ticks between scheduled bombardments once nukes are unlocked. */
+export function bombIntervalTicks(
+ elapsedTicks: number,
+ difficulty: Difficulty,
+): number {
+ const minutes = Math.floor(elapsedTicks / TICKS_PER_MINUTE);
+ const base = Math.max(
+ BOMB_INTERVAL_FLOOR,
+ BOMB_INTERVAL_START - 10 * minutes,
+ );
+ return Math.max(
+ BOMB_INTERVAL_FLOOR,
+ Math.round((base * 100) / difficultyIntensity(difficulty)),
+ );
+}
diff --git a/src/core/execution/invasion/InvasionExecution.ts b/src/core/execution/invasion/InvasionExecution.ts
new file mode 100644
index 000000000..3dbf6b1de
--- /dev/null
+++ b/src/core/execution/invasion/InvasionExecution.ts
@@ -0,0 +1,345 @@
+import {
+ ColoredTeams,
+ Difficulty,
+ Execution,
+ Game,
+ Nation,
+ Player,
+ PlayerID,
+ PlayerInfo,
+ PlayerType,
+ UnitType,
+} from "../../game/Game";
+import { TileRef } from "../../game/GameMap";
+import { PseudoRandom } from "../../PseudoRandom";
+import { GameID } from "../../Schemas";
+import { simpleHash } from "../../Util";
+import { MirvExecution } from "../MIRVExecution";
+import { NationExecution } from "../NationExecution";
+import { NukeExecution } from "../NukeExecution";
+import { PlayerExecution } from "../PlayerExecution";
+import { TransportShipExecution } from "../TransportShipExecution";
+import { WarshipExecution } from "../WarshipExecution";
+import {
+ boatIntervalTicks,
+ boatTroops,
+ bombIntervalTicks,
+ nukeTier,
+ selectInvasionNuke,
+ warshipCount,
+} from "./InvasionConfig";
+
+/**
+ * Drives "Invasion Mode": an escalating hostile horde that arrives by sea.
+ *
+ * A single long-lived execution (added once in `GameRunner.init()` when
+ * `config.invasionMode()` is set). After the configured grace period it
+ * launches periodic waves — each a fresh invader `Nation` on the shared
+ * `Invaders` team, ferried in from a random map-edge water tile to a nearby
+ * shore. Escort warships join from minute 2, and scheduled atom/hydrogen/MIRV
+ * strikes begin at minutes 4/10/20 (shifted by difficulty). Once an invader
+ * makes landfall it is handed a stock `NationExecution`, so it then builds and
+ * attacks like any AI nation at the lobby difficulty.
+ *
+ * Determinism: all randomness comes from a single seeded `PseudoRandom`; the
+ * escalation clock is derived from integer tick counts.
+ */
+export class InvasionExecution implements Execution {
+ private active = true;
+ private mg: Game;
+ private random: PseudoRandom;
+
+ // Tick (absolute) at which the invasion clock starts, i.e. the first
+ // post-spawn-phase tick. The grace period is measured from here.
+ private startTick = -1;
+ private graceTicks = 0;
+ private nextWaveTick = -1;
+ private nextBombTick = -1;
+ private invaderCounter = 0;
+ // Minimum manhattan distance an invader must travel before landfall.
+ private minLandingDist = 0;
+
+ private readonly invaders: PlayerInfo[] = [];
+ private readonly aiAttached = new Set();
+
+ constructor(private gameID: GameID) {
+ this.random = new PseudoRandom(simpleHash(gameID) + 7919);
+ }
+
+ init(mg: Game, ticks: number): void {
+ this.mg = mg;
+ this.graceTicks = mg.config().invasionGracePeriodTicks();
+ // Far enough that the boat must visibly travel, scaled to the map but
+ // capped so huge maps don't send boats on endless voyages.
+ this.minLandingDist = Math.max(
+ 8,
+ Math.min(400, Math.floor(Math.min(mg.width(), mg.height()) * 0.18)),
+ );
+ }
+
+ tick(ticks: number): void {
+ if (this.startTick < 0) {
+ this.startTick = ticks;
+ }
+ const elapsed = ticks - this.startTick - this.graceTicks;
+ if (elapsed < 0) {
+ // Still within the grace period.
+ return;
+ }
+
+ const difficulty = this.mg.config().gameConfig().difficulty;
+
+ // Bring landed invaders to life: per-player upkeep + normal nation AI.
+ this.activateLandedInvaders();
+
+ // Waves.
+ if (this.nextWaveTick < 0) {
+ this.nextWaveTick = ticks; // first wave fires immediately
+ }
+ if (ticks >= this.nextWaveTick) {
+ this.launchWave(elapsed, difficulty);
+ this.nextWaveTick = ticks + boatIntervalTicks(elapsed, difficulty);
+ }
+
+ // Scheduled bombardment, once the tier is unlocked.
+ if (nukeTier(elapsed, difficulty) !== "none") {
+ if (this.nextBombTick < 0) {
+ this.nextBombTick = ticks; // first strike as soon as unlocked
+ }
+ if (ticks >= this.nextBombTick) {
+ this.launchBomb(elapsed, difficulty);
+ this.nextBombTick = ticks + bombIntervalTicks(elapsed, difficulty);
+ }
+ }
+ }
+
+ private activateLandedInvaders(): void {
+ for (const info of this.invaders) {
+ if (this.aiAttached.has(info.id)) continue;
+ if (!this.mg.hasPlayer(info.id)) continue;
+ const player = this.mg.player(info.id);
+ // Wait for landfall: PlayerExecution removes a landless player at once,
+ // and NationExecution self-deactivates post-spawn without territory.
+ if (!player.isAlive()) continue;
+ // PlayerExecution drives upkeep (income, troop growth, death handling)
+ // — normally added by SpawnExecution, which invaders never go through.
+ this.mg.addExecution(new PlayerExecution(player));
+ this.mg.addExecution(
+ new NationExecution(this.gameID, new Nation(undefined, info)),
+ );
+ this.aiAttached.add(info.id);
+ }
+ }
+
+ private launchWave(elapsed: number, difficulty: Difficulty): void {
+ const src = this.pickEdgeWaterTile();
+ if (src === null) return;
+ const target = this.pickLandingShore(src);
+ if (target === null) return;
+
+ const troops = boatTroops(elapsed, difficulty);
+ const info = this.createInvaderInfo();
+ const invader = this.mg.addPlayer(info, ColoredTeams.Invaders);
+ this.invaders.push(info);
+
+ // The boat's troops are drawn from the player's pool on creation, so seed
+ // the brand-new invader with exactly that many troops first.
+ invader.addTroops(troops);
+ this.mg.addExecution(
+ new TransportShipExecution(invader, target, troops, src),
+ );
+
+ // Escort warships (weighted 0-3, only from minute 2 onward).
+ if (!this.mg.config().isUnitDisabled(UnitType.Warship)) {
+ const escorts = warshipCount(elapsed, this.random, difficulty);
+ const patrol = this.waterNear(target) ?? src;
+ for (let i = 0; i < escorts; i++) {
+ const warship = invader.buildUnit(UnitType.Warship, src, {
+ patrolTile: patrol,
+ });
+ this.mg.addExecution(new WarshipExecution(warship));
+ }
+ }
+ }
+
+ private launchBomb(elapsed: number, difficulty: Difficulty): void {
+ const choice = selectInvasionNuke(elapsed, this.random, difficulty);
+ if (choice === null) return;
+
+ const nukeType =
+ choice === "atom"
+ ? UnitType.AtomBomb
+ : choice === "hydrogen"
+ ? UnitType.HydrogenBomb
+ : UnitType.MIRV;
+ if (this.mg.config().isUnitDisabled(nukeType)) return;
+
+ const launcher = this.pickLaunchInvader();
+ if (launcher === null) return;
+ const dst = this.pickEnemyLandTarget();
+ if (dst === null) return;
+
+ // A launch needs a usable missile silo and enough gold for the warhead.
+ // Gold is clamped to >= 0 on spend, so fund the strike explicitly.
+ if (!this.ensureSilo(launcher)) return;
+ launcher.addGold(this.mg.unitInfo(nukeType).cost(this.mg, launcher));
+
+ if (choice === "mirv") {
+ this.mg.addExecution(new MirvExecution(launcher, dst));
+ } else {
+ this.mg.addExecution(new NukeExecution(nukeType, launcher, dst));
+ }
+ }
+
+ /** Ensure the launcher has an immediately-usable missile silo. */
+ private ensureSilo(player: Player): boolean {
+ if (this.mg.config().isUnitDisabled(UnitType.MissileSilo)) {
+ return false;
+ }
+ const hasUsable = player
+ .units(UnitType.MissileSilo)
+ .some(
+ (s) => s.isActive() && !s.isInCooldown() && !s.isUnderConstruction(),
+ );
+ if (hasUsable) return true;
+ const tile = this.firstOwnedTile(player);
+ if (tile === null) return false;
+ // Built directly (not via ConstructionExecution) so it is usable at once.
+ player.buildUnit(UnitType.MissileSilo, tile, {});
+ return true;
+ }
+
+ private pickLaunchInvader(): Player | null {
+ const candidates: Player[] = [];
+ for (const info of this.invaders) {
+ if (!this.mg.hasPlayer(info.id)) continue;
+ const player = this.mg.player(info.id);
+ if (player.isAlive() && player.tiles().size > 0) {
+ candidates.push(player);
+ }
+ }
+ if (candidates.length === 0) return null;
+ return this.random.randElement(candidates);
+ }
+
+ private createInvaderInfo(): PlayerInfo {
+ this.invaderCounter++;
+ return new PlayerInfo(
+ `Invader ${this.invaderCounter}`,
+ PlayerType.Nation,
+ null,
+ this.random.nextID(),
+ );
+ }
+
+ /** Random map-edge tile that is water. */
+ private pickEdgeWaterTile(): TileRef | null {
+ const w = this.mg.width();
+ const h = this.mg.height();
+ for (let i = 0; i < 500; i++) {
+ let x: number;
+ let y: number;
+ switch (this.random.nextInt(0, 4)) {
+ case 0:
+ x = this.random.nextInt(0, w);
+ y = 0;
+ break;
+ case 1:
+ x = this.random.nextInt(0, w);
+ y = h - 1;
+ break;
+ case 2:
+ x = 0;
+ y = this.random.nextInt(0, h);
+ break;
+ default:
+ x = w - 1;
+ y = this.random.nextInt(0, h);
+ break;
+ }
+ const ref = this.mg.ref(x, y);
+ if (this.mg.isWater(ref)) {
+ return ref;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Nearest ocean shore beyond `minLandingDist` from `src`, sampled randomly —
+ * "closest, but not immediately near it" so the boat must travel first.
+ */
+ private pickLandingShore(src: TileRef): TileRef | null {
+ let best: TileRef | null = null;
+ let bestDist = Number.MAX_SAFE_INTEGER;
+ let found = 0;
+ for (let i = 0; i < 800 && found < 40; i++) {
+ const x = this.random.nextInt(0, this.mg.width());
+ const y = this.random.nextInt(0, this.mg.height());
+ const ref = this.mg.ref(x, y);
+ if (!this.mg.isOceanShore(ref)) continue;
+ const dist = this.mg.manhattanDist(src, ref);
+ if (dist < this.minLandingDist) continue;
+ found++;
+ if (dist < bestDist) {
+ bestDist = dist;
+ best = ref;
+ }
+ }
+ return best;
+ }
+
+ private pickEnemyLandTarget(): TileRef | null {
+ for (let i = 0; i < 400; i++) {
+ const x = this.random.nextInt(0, this.mg.width());
+ const y = this.random.nextInt(0, this.mg.height());
+ const ref = this.mg.ref(x, y);
+ if (!this.mg.isLand(ref)) continue;
+ const owner = this.mg.owner(ref);
+ if (!owner.isPlayer()) continue;
+ if ((owner as Player).team() === ColoredTeams.Invaders) continue;
+ return ref;
+ }
+ return null;
+ }
+
+ private waterNear(tile: TileRef): TileRef | null {
+ const x = this.mg.x(tile);
+ const y = this.mg.y(tile);
+ const offsets = [
+ [1, 0],
+ [-1, 0],
+ [0, 1],
+ [0, -1],
+ [2, 0],
+ [-2, 0],
+ [0, 2],
+ [0, -2],
+ ];
+ for (const [dx, dy] of offsets) {
+ const nx = x + dx;
+ const ny = y + dy;
+ if (!this.mg.isValidCoord(nx, ny)) continue;
+ const ref = this.mg.ref(nx, ny);
+ if (this.mg.isWater(ref)) {
+ return ref;
+ }
+ }
+ return null;
+ }
+
+ private firstOwnedTile(player: Player): TileRef | null {
+ for (const tile of player.tiles()) {
+ return tile;
+ }
+ return null;
+ }
+
+ isActive(): boolean {
+ return this.active;
+ }
+
+ activeDuringSpawnPhase(): boolean {
+ return false;
+ }
+}
diff --git a/src/core/execution/nation/NationAllianceBehavior.ts b/src/core/execution/nation/NationAllianceBehavior.ts
index 2fb858944..f3f008149 100644
--- a/src/core/execution/nation/NationAllianceBehavior.ts
+++ b/src/core/execution/nation/NationAllianceBehavior.ts
@@ -1,4 +1,5 @@
import {
+ ColoredTeams,
Difficulty,
Game,
GameMode,
@@ -26,9 +27,23 @@ export class NationAllianceBehavior {
private emojiBehavior: NationEmojiBehavior,
) {}
+ private isInvader(): boolean {
+ // Invaders are a hostile horde faction: they never form, accept, or
+ // request alliances. Inert when no invader player exists.
+ return this.player.team() === ColoredTeams.Invaders;
+ }
+
handleAllianceRequests() {
if (this.game.config().disableAlliances()) return;
+ // Invaders reject every incoming request outright.
+ if (this.isInvader()) {
+ for (const req of this.player.incomingAllianceRequests()) {
+ req.reject();
+ }
+ return;
+ }
+
for (const req of this.player.incomingAllianceRequests()) {
// Alliance Request intents created during the spawn phase are executed on
// the first tick post-spawn phase. With the following condition we reject
@@ -47,6 +62,7 @@ export class NationAllianceBehavior {
handleAllianceExtensionRequests() {
if (this.game.config().disableAlliances()) return;
+ if (this.isInvader()) return;
for (const alliance of this.player.alliances()) {
// Alliance expiration tracked by Events Panel, only human ally can click Request to Renew
@@ -64,6 +80,7 @@ export class NationAllianceBehavior {
maybeSendAllianceRequests(borderingEnemies: Player[]) {
if (this.game.config().disableAlliances()) return;
+ if (this.isInvader()) return;
// Only easy nations are allowed to send alliance requests to bots
const isAcceptablePlayerType = (p: Player) =>
diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts
index dfb751e0a..26aea320e 100644
--- a/src/core/game/Game.ts
+++ b/src/core/game/Game.ts
@@ -91,6 +91,7 @@ export const ColoredTeams: Record = {
Bot: "Bot",
Humans: "Humans",
Nations: "Nations",
+ Invaders: "Invaders",
} as const;
// GameMapType and the maps list are generated from
@@ -700,7 +701,7 @@ export interface Game extends GameMap {
playerByClientID(id: ClientID): Player | null;
playerBySmallID(id: number): Player | TerraNullius;
hasPlayer(id: PlayerID): boolean;
- addPlayer(playerInfo: PlayerInfo): Player;
+ addPlayer(playerInfo: PlayerInfo, team?: Team | null): Player;
terraNullius(): TerraNullius;
owner(ref: TileRef): Player | TerraNullius;
diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts
index 5d216f29b..137e5776d 100644
--- a/src/core/game/PlayerImpl.ts
+++ b/src/core/game/PlayerImpl.ts
@@ -668,6 +668,13 @@ export class PlayerImpl implements Player {
if (other === this) {
return false;
}
+ // Invaders (hostile horde faction) can neither send nor receive alliances.
+ if (
+ this.team() === ColoredTeams.Invaders ||
+ other.team() === ColoredTeams.Invaders
+ ) {
+ return false;
+ }
if (this.isDisconnected() || other.isDisconnected()) {
// Disconnected players are marked as not-friendly even if they are allies,
// so we need to return early if either player is disconnected.
diff --git a/tests/InvasionConfig.test.ts b/tests/InvasionConfig.test.ts
new file mode 100644
index 000000000..2b249c7d9
--- /dev/null
+++ b/tests/InvasionConfig.test.ts
@@ -0,0 +1,151 @@
+import {
+ boatIntervalTicks,
+ boatTroops,
+ bombIntervalTicks,
+ nukeTier,
+ selectInvasionNuke,
+ warshipCount,
+} from "../src/core/execution/invasion/InvasionConfig";
+import { Difficulty } from "../src/core/game/Game";
+import { PseudoRandom } from "../src/core/PseudoRandom";
+
+const MIN = 600; // ticks per minute
+
+describe("InvasionConfig.boatIntervalTicks", () => {
+ test("starts around 15s and decreases monotonically to the 2s floor", () => {
+ let prev = Infinity;
+ for (let m = 0; m <= 20; m++) {
+ const interval = boatIntervalTicks(m * MIN, Difficulty.Medium);
+ expect(interval).toBeLessThanOrEqual(prev);
+ expect(interval).toBeGreaterThanOrEqual(20);
+ prev = interval;
+ }
+ expect(boatIntervalTicks(0, Difficulty.Medium)).toBe(150);
+ expect(boatIntervalTicks(20 * MIN, Difficulty.Medium)).toBe(20);
+ });
+
+ test("never drops below the 2s (20 tick) floor, even past 20 min", () => {
+ expect(
+ boatIntervalTicks(40 * MIN, Difficulty.Impossible),
+ ).toBeGreaterThanOrEqual(20);
+ });
+
+ test("higher difficulty sends boats at least as often", () => {
+ const easy = boatIntervalTicks(5 * MIN, Difficulty.Easy);
+ const impossible = boatIntervalTicks(5 * MIN, Difficulty.Impossible);
+ expect(impossible).toBeLessThan(easy);
+ });
+});
+
+describe("InvasionConfig.boatTroops", () => {
+ test("starts at 30k for every difficulty and grows over time", () => {
+ for (const d of [
+ Difficulty.Easy,
+ Difficulty.Medium,
+ Difficulty.Hard,
+ Difficulty.Impossible,
+ ]) {
+ expect(boatTroops(0, d)).toBe(30_000);
+ }
+ expect(boatTroops(5 * MIN, Difficulty.Medium)).toBeGreaterThan(30_000);
+ expect(boatTroops(10 * MIN, Difficulty.Medium)).toBeGreaterThan(
+ boatTroops(5 * MIN, Difficulty.Medium),
+ );
+ });
+
+ test("harder difficulties field larger waves and the count is capped", () => {
+ expect(boatTroops(10 * MIN, Difficulty.Impossible)).toBeGreaterThan(
+ boatTroops(10 * MIN, Difficulty.Easy),
+ );
+ expect(boatTroops(1000 * MIN, Difficulty.Impossible)).toBeLessThanOrEqual(
+ 350_000,
+ );
+ });
+});
+
+describe("InvasionConfig.warshipCount", () => {
+ test("is always 0 before minute 2", () => {
+ const rng = new PseudoRandom(1);
+ for (let m = 0; m < 2; m++) {
+ expect(warshipCount(m * MIN, rng, Difficulty.Medium)).toBe(0);
+ }
+ });
+
+ test("stays within 0-3 once active", () => {
+ const rng = new PseudoRandom(42);
+ for (let i = 0; i < 200; i++) {
+ const n = warshipCount(10 * MIN, rng, Difficulty.Hard);
+ expect(n).toBeGreaterThanOrEqual(0);
+ expect(n).toBeLessThanOrEqual(3);
+ }
+ });
+
+ test("averages higher later in the game (weighted toward 3)", () => {
+ const avg = (elapsed: number) => {
+ const rng = new PseudoRandom(7);
+ let sum = 0;
+ const samples = 400;
+ for (let i = 0; i < samples; i++) {
+ sum += warshipCount(elapsed, rng, Difficulty.Medium);
+ }
+ return sum / samples;
+ };
+ expect(avg(15 * MIN)).toBeGreaterThan(avg(3 * MIN));
+ });
+});
+
+describe("InvasionConfig.nukeTier", () => {
+ test("unlocks atom/hydrogen/mirv at 4/10/20 min on Medium", () => {
+ expect(nukeTier(3 * MIN, Difficulty.Medium)).toBe("none");
+ expect(nukeTier(4 * MIN, Difficulty.Medium)).toBe("atom");
+ expect(nukeTier(9 * MIN, Difficulty.Medium)).toBe("atom");
+ expect(nukeTier(10 * MIN, Difficulty.Medium)).toBe("hydrogen");
+ expect(nukeTier(20 * MIN, Difficulty.Medium)).toBe("mirv");
+ });
+
+ test("higher difficulty unlocks tiers earlier", () => {
+ // Atom unlocks at minute 4 on Medium, minute 2 on Impossible.
+ expect(nukeTier(2 * MIN, Difficulty.Medium)).toBe("none");
+ expect(nukeTier(2 * MIN, Difficulty.Impossible)).toBe("atom");
+ });
+});
+
+describe("InvasionConfig.selectInvasionNuke", () => {
+ test("returns null before nukes unlock", () => {
+ const rng = new PseudoRandom(3);
+ expect(selectInvasionNuke(0, rng, Difficulty.Medium)).toBeNull();
+ });
+
+ test("only atoms in the atom tier", () => {
+ const rng = new PseudoRandom(3);
+ for (let i = 0; i < 50; i++) {
+ expect(selectInvasionNuke(5 * MIN, rng, Difficulty.Medium)).toBe("atom");
+ }
+ });
+
+ test("MIRVs appear (~10%) only once the mirv tier is reached", () => {
+ const rng = new PseudoRandom(99);
+ let mirvs = 0;
+ let total = 0;
+ for (let i = 0; i < 2000; i++) {
+ const pick = selectInvasionNuke(20 * MIN, rng, Difficulty.Medium);
+ expect(pick).not.toBeNull();
+ if (pick === "mirv") mirvs++;
+ total++;
+ }
+ const ratio = mirvs / total;
+ expect(ratio).toBeGreaterThan(0.03);
+ expect(ratio).toBeLessThan(0.2);
+ });
+});
+
+describe("InvasionConfig.bombIntervalTicks", () => {
+ test("ramps down with time and difficulty but respects the floor", () => {
+ expect(bombIntervalTicks(20 * MIN, Difficulty.Medium)).toBeLessThan(
+ bombIntervalTicks(4 * MIN, Difficulty.Medium),
+ );
+ expect(
+ bombIntervalTicks(20 * MIN, Difficulty.Impossible),
+ ).toBeGreaterThanOrEqual(120);
+ });
+});
diff --git a/tests/InvasionMode.test.ts b/tests/InvasionMode.test.ts
new file mode 100644
index 000000000..ab652c75d
--- /dev/null
+++ b/tests/InvasionMode.test.ts
@@ -0,0 +1,159 @@
+import { InvasionExecution } from "../src/core/execution/invasion/InvasionExecution";
+import { NukeExecution } from "../src/core/execution/NukeExecution";
+import {
+ ColoredTeams,
+ Game,
+ PlayerInfo,
+ PlayerType,
+ UnitType,
+} from "../src/core/game/Game";
+import { GameID } from "../src/core/Schemas";
+import { setup } from "./util/Setup";
+
+const gameID: GameID = "invasion_test_game";
+
+describe("Invasion Mode config", () => {
+ test("getters default to disabled / zero grace", async () => {
+ const game = await setup("half_land_half_ocean");
+ expect(game.config().invasionMode()).toBe(false);
+ expect(game.config().invasionGracePeriodTicks()).toBe(0);
+ });
+
+ test("grace period converts minutes to ticks (10 ticks/sec)", async () => {
+ const game = await setup("half_land_half_ocean", {
+ invasionMode: true,
+ invasionGracePeriod: 3,
+ });
+ expect(game.config().invasionMode()).toBe(true);
+ expect(game.config().invasionGracePeriodTicks()).toBe(3 * 60 * 10);
+ });
+});
+
+describe("Invasion Mode waves", () => {
+ test("launches an invader nation by sea from a water edge", async () => {
+ const game = await setup("half_land_half_ocean", {
+ invasionMode: true,
+ invasionGracePeriod: 0,
+ });
+ game.addExecution(new InvasionExecution(gameID));
+
+ // Transports land quickly on the tiny test map, so watch every tick.
+ let sawInvaderTransport = false;
+ for (let i = 0; i < 250; i++) {
+ game.executeNextTick();
+ for (const unit of game.units(UnitType.TransportShip)) {
+ if (unit.owner().team() === ColoredTeams.Invaders) {
+ sawInvaderTransport = true;
+ }
+ }
+ }
+
+ const invaders = game
+ .players()
+ .filter((p) => p.team() === ColoredTeams.Invaders);
+
+ expect(invaders.length).toBeGreaterThan(0);
+ expect(sawInvaderTransport).toBe(true);
+
+ const invader = invaders[0];
+ expect(invader.type()).toBe(PlayerType.Nation);
+ expect(invader.name().startsWith("Invader")).toBe(true);
+
+ // At least one invader has made landfall and now holds territory.
+ expect(invaders.some((p) => p.isAlive() && p.numTilesOwned() > 0)).toBe(
+ true,
+ );
+ });
+
+ test("respects the grace period before sending any waves", async () => {
+ const game = await setup("half_land_half_ocean", {
+ invasionMode: true,
+ invasionGracePeriod: 1, // 600 ticks
+ });
+ game.addExecution(new InvasionExecution(gameID));
+
+ for (let i = 0; i < 100; i++) game.executeNextTick();
+
+ const invaders = game
+ .players()
+ .filter((p) => p.team() === ColoredTeams.Invaders);
+ expect(invaders.length).toBe(0);
+ });
+});
+
+describe("Invasion Mode faction", () => {
+ test("invaders cannot form alliances with anyone", async () => {
+ const game = await setup("half_land_half_ocean");
+ const humanInfo = new PlayerInfo("human", PlayerType.Human, null, "human");
+ game.addPlayer(humanInfo);
+ const invaderInfo = new PlayerInfo(
+ "Invader 1",
+ PlayerType.Nation,
+ null,
+ "invader",
+ );
+ game.addPlayer(invaderInfo, ColoredTeams.Invaders);
+
+ const human = game.player("human");
+ const invader = game.player("invader");
+
+ expect(invader.team()).toBe(ColoredTeams.Invaders);
+ expect(human.canSendAllianceRequest(invader)).toBe(false);
+ expect(invader.canSendAllianceRequest(human)).toBe(false);
+ });
+
+ // Locks the assumption InvasionExecution.launchBomb relies on: a missile silo
+ // built directly via buildUnit is immediately usable, so a (normally silo-less)
+ // invader can be funded and fire a scheduled strike.
+ test("an invader can build a silo and launch a strike", async () => {
+ const game = await setup("half_land_half_ocean");
+ const invaderInfo = new PlayerInfo(
+ "Invader 1",
+ PlayerType.Nation,
+ null,
+ "invader",
+ );
+ game.addPlayer(invaderInfo, ColoredTeams.Invaders);
+ const victimInfo = new PlayerInfo("victim", PlayerType.Human, null, "vic");
+ game.addPlayer(victimInfo);
+ const invader = game.player("invader");
+ const victim = game.player("vic");
+
+ const land: number[] = [];
+ for (let x = 2; x < 14; x++) {
+ for (let y = 2; y < 14; y++) {
+ const r = game.ref(x, y);
+ if (game.isLand(r)) land.push(r);
+ }
+ }
+ invader.conquer(land[0]);
+ for (let i = 1; i < 6; i++) victim.conquer(land[i]);
+
+ // Mirror launchBomb: free instant silo, fund the warhead, fire.
+ invader.buildUnit(UnitType.MissileSilo, land[0], {});
+ invader.addGold(game.unitInfo(UnitType.AtomBomb).cost(game, invader));
+ game.addExecution(new NukeExecution(UnitType.AtomBomb, invader, land[3]));
+
+ const tilesBefore = victim.numTilesOwned();
+ let sawAtomBomb = false;
+ for (let i = 0; i < 120; i++) {
+ game.executeNextTick();
+ if (game.units(UnitType.AtomBomb).length > 0) sawAtomBomb = true;
+ }
+
+ expect(sawAtomBomb).toBe(true);
+ expect(victim.numTilesOwned()).toBeLessThan(tilesBefore);
+ });
+});
+
+describe("Invasion Mode is inert when disabled", () => {
+ test("no invaders appear without the execution", async () => {
+ const game: Game = await setup("half_land_half_ocean");
+ for (let i = 0; i < 250; i++) game.executeNextTick();
+ const invaders = game
+ .players()
+ .filter((p) => p.team() === ColoredTeams.Invaders);
+ expect(invaders.length).toBe(0);
+ expect(game.units(UnitType.TransportShip).length).toBe(0);
+ });
+});
diff --git a/tests/NationAllianceBehavior.test.ts b/tests/NationAllianceBehavior.test.ts
index f7b0ffc0f..6a9d2ef97 100644
--- a/tests/NationAllianceBehavior.test.ts
+++ b/tests/NationAllianceBehavior.test.ts
@@ -176,6 +176,7 @@ describe("AllianceBehavior.handleAllianceExtensionRequests", () => {
relation: vi.fn(),
id: vi.fn(() => "bot_id"),
type: vi.fn(() => PlayerType.Nation),
+ team: vi.fn(() => null),
};
allianceBehavior = new NationAllianceBehavior(