diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index c6fd7dab3..cd285dcfa 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -301,7 +301,7 @@ export class HostLobbyModal extends BaseModal { .inputPlaceholder=${translateText( "host_modal.invasion_grace_placeholder", )} - .defaultInputValue=${2} + .defaultInputValue=${5} .minValidOnEnable=${0} .onToggle=${this.handleInvasionToggle} .onInput=${this.handleInvasionGraceChanges} @@ -1083,7 +1083,7 @@ export class HostLobbyModal extends BaseModal { waterNukes: this.waterNukes ? true : null, invasionMode: this.invasionMode || undefined, invasionGracePeriod: this.invasionMode - ? Math.max(0, Math.min(15, this.invasionGracePeriod ?? 0)) + ? Math.max(0, Math.min(15, this.invasionGracePeriod ?? 5)) : null, hostCheats: this.hostCheatsEnabled ? { diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 322bb2738..977c49dfa 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -267,7 +267,7 @@ export class SinglePlayerModal extends BaseModal { .inputPlaceholder=${translateText( "single_modal.invasion_grace_placeholder", )} - .defaultInputValue=${2} + .defaultInputValue=${5} .minValidOnEnable=${0} .onToggle=${this.handleInvasionToggle} .onInput=${this.handleInvasionGraceChanges} @@ -751,7 +751,7 @@ export class SinglePlayerModal extends BaseModal { invasionMode: true, invasionGracePeriod: Math.max( 0, - Math.min(15, this.invasionGracePeriod ?? 0), + Math.min(15, this.invasionGracePeriod ?? 5), ), } : {}), diff --git a/src/core/execution/MIRVExecution.ts b/src/core/execution/MIRVExecution.ts index d06d9e23c..1beafc770 100644 --- a/src/core/execution/MIRVExecution.ts +++ b/src/core/execution/MIRVExecution.ts @@ -44,6 +44,11 @@ export class MirvExecution implements Execution { constructor( private player: Player, private dst: TileRef, + // When provided, launch directly from this tile and skip the usual + // silo/ownership checks in `canBuild`. Used by Invasion Mode to fire from an + // open-water spawn tile. Undefined for every existing caller, leaving their + // behavior unchanged. + private srcOverride?: TileRef | null, ) {} init(mg: Game, ticks: number): void { @@ -70,7 +75,8 @@ export class MirvExecution implements Execution { tick(ticks: number): void { if (this.nuke === null) { - const spawn = this.player.canBuild(UnitType.MIRV, this.dst); + const spawn = + this.srcOverride ?? this.player.canBuild(UnitType.MIRV, this.dst); if (spawn === false) { console.warn(`cannot build MIRV`); this.active = false; diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 395ec8767..7bfd2060c 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -34,6 +34,11 @@ export class NukeExecution implements Execution { private speed: number = -1, private waitTicks = 0, private rocketDirectionUp: boolean = true, + // When true, launch directly from `src` and skip the usual silo/ownership + // checks in `canBuild`. Used by Invasion Mode to fire from an open-water + // spawn tile (the invader owns no silo and no land). Defaults to false so + // every existing caller is byte-for-byte unchanged. + private forceSrc: boolean = false, ) {} init(mg: Game, ticks: number): void { @@ -177,7 +182,10 @@ export class NukeExecution implements Execution { tick(ticks: number): void { if (this.nuke === null) { - const spawn = this.player.canBuild(this.nukeType, this.dst); + const spawn = + this.forceSrc && this.src !== undefined && this.src !== null + ? this.src + : this.player.canBuild(this.nukeType, this.dst); if (spawn === false) { console.warn(`cannot build Nuke`); this.active = false; diff --git a/src/core/execution/WinCheckExecution.ts b/src/core/execution/WinCheckExecution.ts index 9f707ad26..c66da5968 100644 --- a/src/core/execution/WinCheckExecution.ts +++ b/src/core/execution/WinCheckExecution.ts @@ -74,6 +74,13 @@ export class WinCheckExecution implements Execution { timeElapsed - this.mg.config().gameConfig().maxTimerValue! * 60 >= 0) || timeElapsed >= WinCheckExecution.HARD_TIME_LIMIT_SECONDS ) { + // Invasion Mode: a player can only win once no other player is left + // alive. The invader horde respawns forever, so this never happens — the + // "You Won" screen is unreachable while "You Died" (per-player, handled + // by WinModal) still fires when the human is wiped out. + if (this.mg.config().invasionMode() && this.mg.players().length > 1) { + return; + } this.mg.setWinner(max, this.mg.stats().stats()); console.log(`${max.name()} has won the game`); this.active = false; @@ -111,6 +118,12 @@ export class WinCheckExecution implements Execution { ) { if (max[0] === ColoredTeams.Bot || max[0] === ColoredTeams.Invaders) return; + // Invasion Mode: only declare a winning team once it is the sole team + // left alive. The invaders are always present, so the win never lands — + // see checkWinnerFFA for the rationale. + if (this.mg.config().invasionMode() && sorted.length > 1) { + 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 index 38cd86105..0afc8dc67 100644 --- a/src/core/execution/invasion/InvasionConfig.ts +++ b/src/core/execution/invasion/InvasionConfig.ts @@ -1,233 +1,213 @@ -import { Difficulty } from "../../game/Game"; +import { Gold } 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. + * period). There is a single, ever-escalating difficulty curve — the lobby + * `Difficulty` setting deliberately does NOT influence the invasion. 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; +// Up to this many distinct invader nations exist at once; once the cap is hit +// they reuse existing nations (each fielding up to INVADER_BOAT_MAX boats) +// rather than spawning more. +export const MAX_INVADER_NATIONS = 10; +export const INVADER_BOAT_MAX = 3; + // 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, plus a -// small bump for each successive boat sent (waves get a little stronger). -const TROOPS_START = 30_000; -const TROOPS_GROWTH_PER_MINUTE = 8_000; -const TROOPS_PER_WAVE_BONUS = 600; -const TROOPS_CAP = 350_000; +// Transport population follows a growth curve: a few thousand early, +// accelerating to ~350k by minute 20, then a slow linear climb forever after +// (the game never plateaus — it always gets harder). +const TROOPS_START = 3_000; +const TROOPS_AT_PEAK = 350_000; // reached at minute 20 +const TROOPS_PEAK_TICKS = 20 * TICKS_PER_MINUTE; +const TROOPS_LINEAR_PER_MINUTE = 10_000; // growth past minute 20 +const TROOPS_PER_WAVE_BONUS = 200; // each successive boat lands a touch more -// 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 +// Per-nation starting gold follows a saturating (plateau) curve: a few thousand +// early, asymptotically approaching the 1m cap. Captured wholesale when the +// nation is conquered, so later invaders are richer prizes. +const GOLD_START = 3_000; +const GOLD_CAP = 1_000_000; +const GOLD_HALF_TICKS = 6 * TICKS_PER_MINUTE; // ~half the cap reached by min 6 -// Scheduled bombardment cadence once nukes are unlocked. -const BOMB_INTERVAL_START = 350; // 35s -const BOMB_INTERVAL_FLOOR = 120; // 12s +// Missile escalation. Missiles begin one minute into the invasion and a single +// strike "package" (the highest tier that passes its roll) is launched per +// boat. Chances/counts ramp early then plateau; the relentless troop growth is +// what keeps the late game lethal. +const STRIKE_START_TICKS = 1 * TICKS_PER_MINUTE; +const ATOM_CHANCE = 50; // constant % once strikes begin +const HYDROGEN_MAX_CHANCE = 12; +const HYDROGEN_MAX_COUNT = 3; +const ATOM_MAX_COUNT = 8; +const MIRV_START_TICKS = 4 * TICKS_PER_MINUTE; // MIRVs only after minute 4 +const MIRV_MAX_CHANCE = 5; -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, +/** + * Integer linear interpolation of `v0`→`v1` as `elapsed` moves `t0`→`t1`, + * clamped to the endpoints outside that window. Used to build the escalation + * ramps without floating-point state. + */ +function ramp( + elapsed: number, + t0: number, + v0: number, + t1: number, + v1: number, ): number { + if (elapsed <= t0) return v0; + if (elapsed >= t1) return v1; + return v0 + Math.floor(((v1 - v0) * (elapsed - t0)) / (t1 - t0)); +} + +/** Ticks between transport launches at the given elapsed time. */ +export function boatIntervalTicks(elapsedTicks: number): 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); + return ( + BOAT_INTERVAL_START - Math.floor((BOAT_RAMP_DELTA * t) / BOAT_RAMP_TICKS) + ); } /** - * Troop count carried by a transport. Grows with elapsed time (scaled by - * difficulty) and gets a small cumulative bump per boat already sent, so each - * successive wave lands slightly more troops. + * Troop count carried by a transport. Accelerates from a few thousand to ~350k + * over the first 20 minutes, then grows linearly forever. A small cumulative + * bump per boat already sent makes each successive wave land slightly more. */ -export function boatTroops( - elapsedTicks: number, - difficulty: Difficulty, - waveIndex = 0, -): number { - const minutesTimesGrowth = Math.floor( - (TROOPS_GROWTH_PER_MINUTE * Math.max(0, elapsedTicks)) / TICKS_PER_MINUTE, - ); - const scaledGrowth = Math.floor( - (minutesTimesGrowth * difficultyIntensity(difficulty)) / 100, - ); - const waveBonus = Math.max(0, waveIndex) * TROOPS_PER_WAVE_BONUS; - return Math.min(TROOPS_CAP, TROOPS_START + scaledGrowth + waveBonus); -} - -/** - * Maximum number of concurrent invader nations, capped by difficulty. Keeps the - * total faction count down (each nation can field up to `boatMaxNumber` boats), - * so the invasion arrives as fewer, busier nations rather than a swarm of one- - * boat nations. - */ -export function maxInvaderNations(difficulty: Difficulty): number { - switch (difficulty) { - case Difficulty.Easy: - return 5; - case Difficulty.Medium: - return 10; - case Difficulty.Hard: - return 15; - case Difficulty.Impossible: - return 20; - default: - assertNever(difficulty); +export function boatTroops(elapsedTicks: number, waveIndex = 0): number { + const t = Math.max(0, elapsedTicks); + let base: number; + if (t <= TROOPS_PEAK_TICKS) { + // Quadratic ease-in: slow at first, steepening toward the peak. + base = + TROOPS_START + + Math.floor( + ((TROOPS_AT_PEAK - TROOPS_START) * t * t) / + (TROOPS_PEAK_TICKS * TROOPS_PEAK_TICKS), + ); + } else { + base = + TROOPS_AT_PEAK + + Math.floor( + (TROOPS_LINEAR_PER_MINUTE * (t - TROOPS_PEAK_TICKS)) / TICKS_PER_MINUTE, + ); } + return base + Math.max(0, waveIndex) * TROOPS_PER_WAVE_BONUS; +} + +/** Starting gold handed to a freshly spawned invader nation. */ +export function invaderStartingGold(elapsedTicks: number): Gold { + const t = Math.max(0, elapsedTicks); + // Saturating curve gold = cap * t / (t + k): GOLD_START at t=0, → GOLD_CAP. + const value = Math.floor((GOLD_CAP * t) / (t + GOLD_HALF_TICKS)); + return BigInt(Math.max(GOLD_START, Math.min(GOLD_CAP, value))); } /** - * 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. + * Number of escort warships (0-3) accompanying a wave. Time-independent and + * weighted heavily toward 0 and 1 so most waves arrive lightly escorted. */ -export function warshipCount( +export function warshipCount(random: PseudoRandom): number { + const roll = random.nextInt(0, 100); + if (roll < 45) return 0; // 45% + if (roll < 75) return 1; // 30% + if (roll < 92) return 2; // 17% + return 3; // 8% +} + +function atomCount(elapsedTicks: number): number { + // 1 at minute 1 → 5 at minute 10, then +1 per 4 min, capped. + let c = ramp(elapsedTicks, STRIKE_START_TICKS, 1, 10 * TICKS_PER_MINUTE, 5); + if (elapsedTicks > 10 * TICKS_PER_MINUTE) { + c = + 5 + + Math.floor( + (elapsedTicks - 10 * TICKS_PER_MINUTE) / (4 * TICKS_PER_MINUTE), + ); + } + return clamp(c, 1, ATOM_MAX_COUNT); +} + +function hydrogenChance(elapsedTicks: number): number { + // 5% at minute 1 → 10% at minute 10, then drifting up to the cap. + let c = ramp(elapsedTicks, STRIKE_START_TICKS, 5, 10 * TICKS_PER_MINUTE, 10); + if (elapsedTicks > 10 * TICKS_PER_MINUTE) { + c = + 10 + + Math.floor( + (elapsedTicks - 10 * TICKS_PER_MINUTE) / (5 * TICKS_PER_MINUTE), + ); + } + return clamp(c, 0, HYDROGEN_MAX_CHANCE); +} + +function hydrogenCount(elapsedTicks: number): number { + // 1 at minute 1 → 2 at minute 10 → 3 by minute 20, capped. + let c = ramp(elapsedTicks, STRIKE_START_TICKS, 1, 10 * TICKS_PER_MINUTE, 2); + if (elapsedTicks > 10 * TICKS_PER_MINUTE) { + c = + 2 + + Math.floor( + (elapsedTicks - 10 * TICKS_PER_MINUTE) / (10 * TICKS_PER_MINUTE), + ); + } + return clamp(c, 1, HYDROGEN_MAX_COUNT); +} + +function mirvChance(elapsedTicks: number): number { + // 0% until minute 4, ramping to 3% at minute 10, then up to the cap. + if (elapsedTicks < MIRV_START_TICKS) return 0; + let c = ramp(elapsedTicks, MIRV_START_TICKS, 0, 10 * TICKS_PER_MINUTE, 3); + if (elapsedTicks > 10 * TICKS_PER_MINUTE) { + c = + 3 + + Math.floor( + (elapsedTicks - 10 * TICKS_PER_MINUTE) / (10 * TICKS_PER_MINUTE), + ); + } + return clamp(c, 0, MIRV_MAX_CHANCE); +} + +/** + * The missile package launched by a single boat as it spawns, or an empty list + * if it carries none. The highest tier that passes its roll wins: a rare MIRV, + * else a small hydrogen salvo, else an atom barrage. Returns concrete warhead + * types so the caller can fire one execution each from the spawn tile. + */ +export function selectInvasionStrike( 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; -} +): InvasionNuke[] { + if (elapsedTicks < STRIKE_START_TICKS) return []; -/** 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); + const mc = mirvChance(elapsedTicks); + if (mc > 0 && random.nextInt(0, 100) < mc) { + return ["mirv"]; } -} - -/** 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)), - ); + if (random.nextInt(0, 100) < hydrogenChance(elapsedTicks)) { + return new Array(hydrogenCount(elapsedTicks)).fill( + "hydrogen", + ); + } + if (random.nextInt(0, 100) < ATOM_CHANCE) { + return new Array(atomCount(elapsedTicks)).fill("atom"); + } + return []; } diff --git a/src/core/execution/invasion/InvasionExecution.ts b/src/core/execution/invasion/InvasionExecution.ts index ad5bdd589..75e90affa 100644 --- a/src/core/execution/invasion/InvasionExecution.ts +++ b/src/core/execution/invasion/InvasionExecution.ts @@ -1,6 +1,5 @@ import { ColoredTeams, - Difficulty, Execution, Game, Nation, @@ -23,39 +22,64 @@ import { WarshipExecution } from "../WarshipExecution"; import { boatIntervalTicks, boatTroops, - bombIntervalTicks, - maxInvaderNations, - nukeTier, - selectInvasionNuke, + INVADER_BOAT_MAX, + invaderStartingGold, + InvasionNuke, + MAX_INVADER_NATIONS, + selectInvasionStrike, warshipCount, } from "./InvasionConfig"; -// Escort warships are released ~0.5s apart so they don't stack on the map. -const WARSHIP_STAGGER_TICKS = 5; +// Formation geometry (tiles), measured from the transport's spawn tile toward +// the landing shore. The lead escort sits ahead; flankers sit out to either +// side and slightly forward, so a wave reads as an arrowhead. +const FRONT_DIST = 6; +const FLANK_SIDE = 4; +const FLANK_FORWARD = 2; -interface PendingEscort { +// Formation timing (ticks; 10 ticks = 1s). Escorts deploy first and the +// transport follows so the wave assembles into formation before advancing. +const FLANK_DELAY = 10; // 1s after the lead escort (3-warship waves) +const TRANSPORT_DELAY_ESCORTED = 20; // 2s after escorts (1- and 2-warship waves) +const TRANSPORT_DELAY_TRIPLE = 30; // 1s + 2s after the lead (3-warship waves) + +interface PendingWarship { + kind: "warship"; fireTick: number; ownerId: PlayerID; spawnTile: TileRef; patrolTile: TileRef; } +interface PendingTransport { + kind: "transport"; + fireTick: number; + ownerId: PlayerID; + spawnTile: TileRef; + target: TileRef; + troops: number; + strike: InvasionNuke[]; +} + +type PendingSpawn = PendingWarship | PendingTransport; + /** * 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 — boats ferried in from a random map-edge water tile - * to a nearby shore. The number of distinct invader `Nation`s (all on the - * shared `Invaders` team) is capped by difficulty; once that cap is reached, - * existing invaders send additional boats (up to `boatMaxNumber` each) instead - * of new nations spawning. 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. + * `config.invasionMode()` is set). After the grace period it launches periodic + * waves — boats ferried from a random map-edge water tile to a nearby shore, + * arriving in an escorted formation. Up to `MAX_INVADER_NATIONS` distinct + * invader `Nation`s (all on the shared `Invaders` team) exist at once; beyond + * that cap existing invaders send extra boats (up to `INVADER_BOAT_MAX` each). + * Every boat may launch a missile strike straight from its open-water spawn + * tile the instant it appears, with intensity rising over time. Once an invader + * makes landfall it is handed a stock `NationExecution`, so it then builds and + * attacks like any AI nation. * - * Determinism: all randomness comes from a single seeded `PseudoRandom`; the - * escalation clock is derived from integer tick counts. + * The invasion ignores the lobby `Difficulty` entirely — there is one ever- + * escalating curve. 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; @@ -67,7 +91,6 @@ export class InvasionExecution implements Execution { private startTick = -1; private graceTicks = 0; private nextWaveTick = -1; - private nextBombTick = -1; private invaderCounter = 0; private wavesLaunched = 0; // Minimum manhattan distance an invader must travel before landfall. @@ -75,7 +98,7 @@ export class InvasionExecution implements Execution { private readonly invaders: PlayerInfo[] = []; private readonly aiAttached = new Set(); - private readonly pendingEscorts: PendingEscort[] = []; + private readonly pendingSpawns: PendingSpawn[] = []; constructor(private gameID: GameID) { this.random = new PseudoRandom(simpleHash(gameID) + 7919); @@ -102,32 +125,19 @@ export class InvasionExecution implements Execution { return; } - const difficulty = this.mg.config().gameConfig().difficulty; - // Bring landed invaders to life: per-player upkeep + normal nation AI. this.activateLandedInvaders(); - // Release any escort warships whose staggered launch tick has arrived. - this.processPendingEscorts(ticks); + // Release any formation spawns whose scheduled tick has arrived. + this.processPendingSpawns(ticks); // Waves. if (this.nextWaveTick < 0) { this.nextWaveTick = ticks; // first wave fires immediately } if (ticks >= this.nextWaveTick) { - this.launchWave(ticks, 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); - } + this.launchWave(ticks, elapsed); + this.nextWaveTick = ticks + boatIntervalTicks(elapsed); } } @@ -149,11 +159,7 @@ export class InvasionExecution implements Execution { } } - private launchWave( - ticks: number, - elapsed: number, - difficulty: Difficulty, - ): void { + private launchWave(ticks: number, elapsed: number): void { // Resolve the route before committing a launcher, so a failed geometry // lookup doesn't leave a freshly-created invader stranded with no boat. const src = this.pickEdgeWaterTile(); @@ -161,140 +167,196 @@ export class InvasionExecution implements Execution { const target = this.pickLandingShore(src); if (target === null) return; - const launcher = this.selectWaveLauncher(difficulty); + const launcher = this.selectWaveLauncher(elapsed); if (launcher === null) return; + const ownerId = launcher.id(); - const troops = boatTroops(elapsed, difficulty, this.wavesLaunched); - // The boat's troops are drawn from the player's pool on creation, so top - // the launcher up by exactly that many troops first. - launcher.addTroops(troops); - this.mg.addExecution( - new TransportShipExecution(launcher, target, troops, src), + const warships = this.mg.config().isUnitDisabled(UnitType.Warship) + ? 0 + : warshipCount(this.random); + + // Schedule the escort formation, then the transport behind it. Each boat + // rolls its own missile package, fired from its spawn tile on arrival. + const transportDelay = this.scheduleEscorts( + ticks, + ownerId, + src, + target, + warships, ); + this.pendingSpawns.push({ + kind: "transport", + fireTick: ticks + transportDelay, + ownerId, + spawnTile: src, + target, + troops: boatTroops(elapsed, this.wavesLaunched), + strike: this.mg.config().isUnitDisabled(UnitType.AtomBomb) + ? [] + : selectInvasionStrike(elapsed, this.random), + }); this.wavesLaunched++; + } - // Escort warships (weighted 0-3, only from minute 2 onward). They are - // released ~0.5s apart, each heading to a slightly different point near the - // landing, so they read as an escort rather than a stacked convoy. - if (!this.mg.config().isUnitDisabled(UnitType.Warship)) { - const escorts = warshipCount(elapsed, this.random, difficulty); - const ownerId = launcher.id(); - for (let i = 0; i < escorts; i++) { - this.pendingEscorts.push({ - fireTick: ticks + i * WARSHIP_STAGGER_TICKS, - ownerId, - spawnTile: this.randomWaterNear(src, 3), - patrolTile: this.randomWaterNear(target, 6), + /** + * Queues the escort warships for a wave and returns how long (ticks) to delay + * the transport so it slots in behind the assembled formation. + * + * - 1 escort: a single lead boat out front. + * - 2 escorts: a flanker on each side of the transport's spawn. + * - 3 escorts: a lead boat, then the two flankers 1s later. + */ + private scheduleEscorts( + ticks: number, + ownerId: PlayerID, + src: TileRef, + target: TileRef, + warships: number, + ): number { + const escort = (fireTick: number, spawnTile: TileRef) => + this.pendingSpawns.push({ + kind: "warship", + fireTick, + ownerId, + spawnTile, + patrolTile: this.randomWaterNear(target, 6), + }); + + switch (warships) { + case 0: + return 0; + case 1: + escort(ticks, this.formationTile(src, target, FRONT_DIST, 0)); + return TRANSPORT_DELAY_ESCORTED; + case 2: + escort( + ticks, + this.formationTile(src, target, FLANK_FORWARD, FLANK_SIDE), + ); + escort( + ticks, + this.formationTile(src, target, FLANK_FORWARD, -FLANK_SIDE), + ); + return TRANSPORT_DELAY_ESCORTED; + default: { + escort(ticks, this.formationTile(src, target, FRONT_DIST, 0)); + const flankTick = ticks + FLANK_DELAY; + escort( + flankTick, + this.formationTile(src, target, FLANK_FORWARD, FLANK_SIDE), + ); + escort( + flankTick, + this.formationTile(src, target, FLANK_FORWARD, -FLANK_SIDE), + ); + return TRANSPORT_DELAY_TRIPLE; + } + } + } + + private processPendingSpawns(ticks: number): void { + if (this.pendingSpawns.length === 0) return; + for (let i = this.pendingSpawns.length - 1; i >= 0; i--) { + const spawn = this.pendingSpawns[i]; + if (ticks < spawn.fireTick) continue; + this.pendingSpawns.splice(i, 1); + if (!this.mg.hasPlayer(spawn.ownerId)) continue; + const owner = this.mg.player(spawn.ownerId); + if (spawn.kind === "warship") { + if (this.mg.config().isUnitDisabled(UnitType.Warship)) continue; + const warship = owner.buildUnit(UnitType.Warship, spawn.spawnTile, { + patrolTile: spawn.patrolTile, }); + this.mg.addExecution(new WarshipExecution(warship)); + } else { + // The boat's troops are drawn from the player's pool on creation, so top + // the launcher up by exactly that many troops first. + owner.addTroops(spawn.troops); + this.mg.addExecution( + new TransportShipExecution( + owner, + spawn.target, + spawn.troops, + spawn.spawnTile, + ), + ); + // Missiles launch from the very tile the boat spawned at. + this.launchStrike(owner, spawn.spawnTile, spawn.strike); + } + } + } + + /** + * Fires a boat's missile package from its open-water spawn tile. Each warhead + * targets independently so an atom barrage rains across enemy territory. The + * launcher is funded exactly for each warhead (its starting gold — the prize + * for conquering it — is left intact). + */ + private launchStrike( + owner: Player, + srcTile: TileRef, + strike: InvasionNuke[], + ): void { + for (const nuke of strike) { + const dst = this.pickEnemyLandTarget(); + if (dst === null) return; + if (nuke === "mirv") { + if (this.mg.config().isUnitDisabled(UnitType.MIRV)) continue; + owner.addGold(this.mg.unitInfo(UnitType.MIRV).cost(this.mg, owner)); + this.mg.addExecution(new MirvExecution(owner, dst, srcTile)); + } else { + const type = + nuke === "atom" ? UnitType.AtomBomb : UnitType.HydrogenBomb; + if (this.mg.config().isUnitDisabled(type)) continue; + owner.addGold(this.mg.unitInfo(type).cost(this.mg, owner)); + this.mg.addExecution( + new NukeExecution(type, owner, dst, srcTile, -1, 0, true, true), + ); } } } /** * Chooses who launches the next wave: a brand-new invader nation while below - * the difficulty cap, otherwise an existing active invader that still has room - * for another boat. Returns null if every invader is at its boat limit. + * the cap, otherwise an existing active invader that still has room for + * another boat. Returns null if every invader is at its boat limit. */ - private selectWaveLauncher(difficulty: Difficulty): Player | null { + private selectWaveLauncher(elapsed: number): Player | null { const active = this.invaders.filter((info) => this.isActiveInvader(info)); - if (active.length < maxInvaderNations(difficulty)) { + if (active.length < MAX_INVADER_NATIONS) { const info = this.createInvaderInfo(); const invader = this.mg.addPlayer(info, ColoredTeams.Invaders); + invader.addGold(invaderStartingGold(elapsed)); this.invaders.push(info); return invader; } - const boatMax = this.mg.config().boatMaxNumber(); const candidates = active .map((info) => this.mg.player(info.id)) - .filter((p) => p.unitCount(UnitType.TransportShip) < boatMax); + .filter((p) => this.boatLoad(p) < INVADER_BOAT_MAX); if (candidates.length === 0) return null; return this.random.randElement(candidates); } - /** An invader still in play: landed (alive) or with a boat still inbound. */ + /** Boats a player currently has in play: in flight plus scheduled to spawn. */ + private boatLoad(player: Player): number { + let pending = 0; + for (const spawn of this.pendingSpawns) { + if (spawn.kind === "transport" && spawn.ownerId === player.id()) + pending++; + } + return player.unitCount(UnitType.TransportShip) + pending; + } + + /** An invader still in play: landed (alive), boat inbound, or boat pending. */ private isActiveInvader(info: PlayerInfo): boolean { if (!this.mg.hasPlayer(info.id)) return false; const player = this.mg.player(info.id); - return player.isAlive() || player.unitCount(UnitType.TransportShip) > 0; - } - - private processPendingEscorts(ticks: number): void { - if (this.pendingEscorts.length === 0) return; - if (this.mg.config().isUnitDisabled(UnitType.Warship)) { - this.pendingEscorts.length = 0; - return; + if (player.isAlive() || player.unitCount(UnitType.TransportShip) > 0) { + return true; } - for (let i = this.pendingEscorts.length - 1; i >= 0; i--) { - const escort = this.pendingEscorts[i]; - if (ticks < escort.fireTick) continue; - this.pendingEscorts.splice(i, 1); - if (!this.mg.hasPlayer(escort.ownerId)) continue; - const owner = this.mg.player(escort.ownerId); - const warship = owner.buildUnit(UnitType.Warship, escort.spawnTile, { - patrolTile: escort.patrolTile, - }); - 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); + return this.pendingSpawns.some( + (s) => s.kind === "transport" && s.ownerId === info.id, + ); } private createInvaderInfo(): PlayerInfo { @@ -379,9 +441,56 @@ export class InvasionExecution implements Execution { } /** - * A water tile randomly offset within `radius` of `center`, so staggered - * escorts spawn and patrol at spread-out points rather than the same tile. - * Falls back to `center` if no nearby water is found. + * A water tile `forward` tiles toward `target` and `side` tiles perpendicular + * (positive = left of the heading) from `src`, snapped to the nearest water. + * Used to place escorts into an arrowhead formation around the transport. + */ + private formationTile( + src: TileRef, + target: TileRef, + forward: number, + side: number, + ): TileRef { + const sx = this.mg.x(src); + const sy = this.mg.y(src); + let ux = this.mg.x(target) - sx; + let uy = this.mg.y(target) - sy; + const len = Math.sqrt(ux * ux + uy * uy); + if (len > 0) { + ux /= len; + uy /= len; + } else { + ux = 0; + uy = -1; + } + // Perpendicular (rotate the heading 90°): left of travel. + const px = -uy; + const py = ux; + const tx = Math.round(sx + ux * forward + px * side); + const ty = Math.round(sy + uy * forward + py * side); + return this.nearestWater(tx, ty, src); + } + + /** Nearest water tile to (cx, cy) via an expanding ring search. */ + private nearestWater(cx: number, cy: number, fallback: TileRef): TileRef { + for (let r = 0; r <= 6; r++) { + for (let dx = -r; dx <= r; dx++) { + for (let dy = -r; dy <= r; dy++) { + if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue; + const nx = cx + dx; + const ny = cy + dy; + if (!this.mg.isValidCoord(nx, ny)) continue; + const ref = this.mg.ref(nx, ny); + if (this.mg.isWater(ref)) return ref; + } + } + } + return fallback; + } + + /** + * A water tile randomly offset within `radius` of `center`, so escorts patrol + * at spread-out points rather than the same tile. Falls back to `center`. */ private randomWaterNear(center: TileRef, radius: number): TileRef { const cx = this.mg.x(center); @@ -398,13 +507,6 @@ export class InvasionExecution implements Execution { return center; } - private firstOwnedTile(player: Player): TileRef | null { - for (const tile of player.tiles()) { - return tile; - } - return null; - } - isActive(): boolean { return this.active; } diff --git a/tests/InvasionConfig.test.ts b/tests/InvasionConfig.test.ts index aa3d7ac02..44d36ea40 100644 --- a/tests/InvasionConfig.test.ts +++ b/tests/InvasionConfig.test.ts @@ -1,178 +1,166 @@ import { boatIntervalTicks, boatTroops, - bombIntervalTicks, - maxInvaderNations, - nukeTier, - selectInvasionNuke, + INVADER_BOAT_MAX, + invaderStartingGold, + MAX_INVADER_NATIONS, + selectInvasionStrike, 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", () => { + test("starts at 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); + const interval = boatIntervalTicks(m * MIN); 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); + expect(boatIntervalTicks(0)).toBe(150); + expect(boatIntervalTicks(20 * MIN)).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); + expect(boatIntervalTicks(40 * MIN)).toBeGreaterThanOrEqual(20); }); }); 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("starts at a few thousand and accelerates to ~350k by minute 20", () => { + expect(boatTroops(0)).toBe(3_000); + expect(boatTroops(5 * MIN)).toBeGreaterThan(boatTroops(0)); + expect(boatTroops(10 * MIN)).toBeGreaterThan(boatTroops(5 * MIN)); + const peak = boatTroops(20 * MIN); + expect(peak).toBeGreaterThanOrEqual(300_000); + expect(peak).toBeLessThanOrEqual(400_000); }); - 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, - ); + test("keeps growing linearly past minute 20 (never plateaus)", () => { + expect(boatTroops(30 * MIN)).toBeGreaterThan(boatTroops(20 * MIN)); + expect(boatTroops(60 * MIN)).toBeGreaterThan(boatTroops(30 * MIN)); }); test("each successive boat carries slightly more troops", () => { - const first = boatTroops(0, Difficulty.Medium, 0); - const later = boatTroops(0, Difficulty.Medium, 10); - expect(later).toBeGreaterThan(first); - // Still bounded by the cap even for huge wave indices. - expect(boatTroops(0, Difficulty.Medium, 100_000)).toBeLessThanOrEqual( - 350_000, - ); + expect(boatTroops(5 * MIN, 10)).toBeGreaterThan(boatTroops(5 * MIN, 0)); }); }); -describe("InvasionConfig.maxInvaderNations", () => { - test("caps fewer nations on easier difficulties (5 easy, 20 impossible)", () => { - expect(maxInvaderNations(Difficulty.Easy)).toBe(5); - expect(maxInvaderNations(Difficulty.Impossible)).toBe(20); - expect(maxInvaderNations(Difficulty.Easy)).toBeLessThan( - maxInvaderNations(Difficulty.Medium), - ); - expect(maxInvaderNations(Difficulty.Medium)).toBeLessThan( - maxInvaderNations(Difficulty.Hard), - ); - expect(maxInvaderNations(Difficulty.Hard)).toBeLessThan( - maxInvaderNations(Difficulty.Impossible), +describe("InvasionConfig.invaderStartingGold", () => { + test("starts at a few thousand, rises, and is capped at 1m", () => { + expect(invaderStartingGold(0)).toBe(3_000n); + expect(invaderStartingGold(10 * MIN)).toBeGreaterThan( + invaderStartingGold(1 * MIN), ); + expect(invaderStartingGold(10_000 * MIN)).toBeLessThanOrEqual(1_000_000n); + }); + + test("rises monotonically toward the cap", () => { + let prev = -1n; + for (let m = 0; m <= 60; m += 5) { + const gold = invaderStartingGold(m * MIN); + expect(gold).toBeGreaterThanOrEqual(prev); + prev = gold; + } + }); +}); + +describe("InvasionConfig.maxima", () => { + test("ten nations, three boats each", () => { + expect(MAX_INVADER_NATIONS).toBe(10); + expect(INVADER_BOAT_MAX).toBe(3); }); }); 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", () => { + test("stays within 0-3", () => { const rng = new PseudoRandom(42); - for (let i = 0; i < 200; i++) { - const n = warshipCount(10 * MIN, rng, Difficulty.Hard); + for (let i = 0; i < 500; i++) { + const n = warshipCount(rng); 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)); + test("is weighted toward 0 and 1", () => { + const rng = new PseudoRandom(7); + const counts = [0, 0, 0, 0]; + const samples = 4000; + for (let i = 0; i < samples; i++) { + counts[warshipCount(rng)]++; + } + // Most waves should arrive with 0 or 1 escorts. + expect((counts[0] + counts[1]) / samples).toBeGreaterThan(0.6); + expect(counts[0]).toBeGreaterThan(counts[3]); + const avg = (counts[1] + 2 * counts[2] + 3 * counts[3]) / samples; + expect(avg).toBeLessThan(1.5); }); }); -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", () => { +describe("InvasionConfig.selectInvasionStrike", () => { + test("launches nothing in the first minute", () => { 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"); + for (let i = 0; i < 100; i++) { + expect(selectInvasionStrike(0, rng)).toEqual([]); + expect(selectInvasionStrike(30 * 10, rng)).toEqual([]); // 30s } }); - test("MIRVs appear (~10%) only once the mirv tier is reached", () => { - const rng = new PseudoRandom(99); - let mirvs = 0; - let total = 0; + test("at minute 1: only single atoms/hydrogens, never a MIRV", () => { + const rng = new PseudoRandom(11); 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 strike = selectInvasionStrike(1 * MIN, rng); + expect(strike).not.toContain("mirv"); + if (strike[0] === "atom") expect(strike.length).toBe(1); + if (strike[0] === "hydrogen") expect(strike.length).toBe(1); } - 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); + test("MIRVs never appear before minute 4", () => { + const rng = new PseudoRandom(99); + for (let i = 0; i < 3000; i++) { + expect(selectInvasionStrike(3 * MIN, rng)).not.toContain("mirv"); + } + }); + + test("by minute 10: atoms barrage (5), hydrogens (2), rare MIRVs appear", () => { + const rng = new PseudoRandom(123); + let mirvs = 0; + let sawHydrogen2 = false; + let sawAtom5 = false; + const samples = 8000; + for (let i = 0; i < samples; i++) { + const strike = selectInvasionStrike(10 * MIN, rng); + if (strike.length === 0) continue; + if (strike[0] === "mirv") { + expect(strike.length).toBe(1); + mirvs++; + } else if (strike[0] === "hydrogen") { + expect(strike.length).toBe(2); + sawHydrogen2 = true; + } else { + expect(strike.length).toBe(5); + sawAtom5 = true; + } + } + expect(sawAtom5).toBe(true); + expect(sawHydrogen2).toBe(true); + // ~3% of boats fire a MIRV once the tier is reached. + expect(mirvs).toBeGreaterThan(0); + expect(mirvs / samples).toBeLessThan(0.1); + }); + + test("missile counts never exceed their caps deep into the game", () => { + const rng = new PseudoRandom(55); + for (let i = 0; i < 4000; i++) { + const strike = selectInvasionStrike(120 * MIN, rng); + if (strike[0] === "atom") expect(strike.length).toBeLessThanOrEqual(8); + if (strike[0] === "hydrogen") + expect(strike.length).toBeLessThanOrEqual(3); + if (strike[0] === "mirv") expect(strike.length).toBe(1); + } }); }); diff --git a/tests/InvasionMode.test.ts b/tests/InvasionMode.test.ts index 5262be3a7..94e058490 100644 --- a/tests/InvasionMode.test.ts +++ b/tests/InvasionMode.test.ts @@ -1,6 +1,7 @@ -import { maxInvaderNations } from "../src/core/execution/invasion/InvasionConfig"; +import { MAX_INVADER_NATIONS } from "../src/core/execution/invasion/InvasionConfig"; import { InvasionExecution } from "../src/core/execution/invasion/InvasionExecution"; import { NukeExecution } from "../src/core/execution/NukeExecution"; +import { WinCheckExecution } from "../src/core/execution/WinCheckExecution"; import { ColoredTeams, Game, @@ -81,16 +82,13 @@ describe("Invasion Mode waves", () => { expect(invaders.length).toBe(0); }); - test("never exceeds the difficulty cap of concurrent invader nations", async () => { - const difficulty = "Impossible"; + test("never exceeds the cap of concurrent invader nations and escorts arrive", async () => { const game = await setup("ocean_and_land", { invasionMode: true, invasionGracePeriod: 0, - difficulty: difficulty as never, }); game.addExecution(new InvasionExecution(gameID)); - const cap = maxInvaderNations(difficulty as never); let maxConcurrent = 0; let sawWarship = false; for (let i = 0; i < 1600; i++) { @@ -107,8 +105,7 @@ describe("Invasion Mode waves", () => { } expect(maxConcurrent).toBeGreaterThan(0); - expect(maxConcurrent).toBeLessThanOrEqual(cap); - // Escorts arrive once the invasion passes minute 2. + expect(maxConcurrent).toBeLessThanOrEqual(MAX_INVADER_NATIONS); expect(sawWarship).toBe(true); }); }); @@ -134,10 +131,10 @@ describe("Invasion Mode faction", () => { 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 () => { + // The core of the new "missiles from the ocean" mechanic: a landless invader + // (owns no tiles, no silo, not even "alive") can still fire a nuke straight + // from an open-water spawn tile via NukeExecution's forced-src launch. + test("a landless invader launches a nuke from open water", async () => { const game = await setup("half_land_half_ocean"); const invaderInfo = new PlayerInfo( "Invader 1", @@ -152,29 +149,91 @@ describe("Invasion Mode faction", () => { const victim = game.player("vic"); const land: number[] = []; - for (let x = 2; x < 14; x++) { - for (let y = 2; y < 14; y++) { + let water = -1; + for (let x = 0; x < game.width(); x++) { + for (let y = 0; y < game.height(); y++) { + const r = game.ref(x, y); + if (game.isLand(r)) land.push(r); + else if (game.isWater(r) && water < 0) water = r; + } + } + expect(water).toBeGreaterThanOrEqual(0); + expect(land.length).toBeGreaterThan(10); + for (let i = 0; i < 10; i++) victim.conquer(land[i]); + + // The invader holds no territory — the normal canBuild path would refuse. + expect(invader.isAlive()).toBe(false); + + invader.addGold(game.unitInfo(UnitType.AtomBomb).cost(game, invader)); + game.addExecution( + new NukeExecution( + UnitType.AtomBomb, + invader, + land[5], // target a victim tile + water, // launch from open water + -1, + 0, + true, + true, // forceSrc + ), + ); + + const tilesBefore = victim.numTilesOwned(); + let sawAtomBomb = false; + let bombStartedFromWater = false; + for (let i = 0; i < 200; i++) { + game.executeNextTick(); + for (const bomb of game.units(UnitType.AtomBomb)) { + sawAtomBomb = true; + if (game.isWater(bomb.tile())) bombStartedFromWater = true; + } + } + + expect(sawAtomBomb).toBe(true); + expect(bombStartedFromWater).toBe(true); + expect(victim.numTilesOwned()).toBeLessThan(tilesBefore); + }); +}); + +describe("Invasion Mode win conditions", () => { + test("no player can win while the invader horde remains", async () => { + const game = await setup("half_land_half_ocean", { + invasionMode: true, + maxTimerValue: 1, // game-length win condition fires after 60s + }); + 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"); + + const land: number[] = []; + for (let x = 0; x < game.width(); x++) { + for (let y = 0; y < game.height(); y++) { const r = game.ref(x, y); if (game.isLand(r)) land.push(r); } } + expect(land.length).toBeGreaterThan(1); + // Human dominates the map; a single invader tile keeps the horde "alive". + for (let i = 1; i < land.length; i++) human.conquer(land[i]); 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])); + game.addExecution(new WinCheckExecution()); - 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; - } + // Run well past the 60s game-length condition. + for (let i = 0; i < 700; i++) game.executeNextTick(); - expect(sawAtomBomb).toBe(true); - expect(victim.numTilesOwned()).toBeLessThan(tilesBefore); + expect(human.isAlive()).toBe(true); + expect(invader.isAlive()).toBe(true); + // The "You Won" path never triggers: an opposing (invader) player remains. + expect(game.getWinner()).toBeNull(); }); });