diff --git a/src/core/execution/invasion/InvasionConfig.ts b/src/core/execution/invasion/InvasionConfig.ts index a2e19cda5..38cd86105 100644 --- a/src/core/execution/invasion/InvasionConfig.ts +++ b/src/core/execution/invasion/InvasionConfig.ts @@ -21,9 +21,11 @@ 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. +// 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; // Escalation onsets (before difficulty time-shift). @@ -105,10 +107,15 @@ export function boatIntervalTicks( return Math.max(BOAT_INTERVAL_FLOOR, scaled); } -/** Troop count carried by a transport launched at the given elapsed time. */ +/** + * 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. + */ 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, @@ -116,7 +123,29 @@ export function boatTroops( const scaledGrowth = Math.floor( (minutesTimesGrowth * difficultyIntensity(difficulty)) / 100, ); - return Math.min(TROOPS_CAP, TROOPS_START + scaledGrowth); + 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); + } } /** diff --git a/src/core/execution/invasion/InvasionExecution.ts b/src/core/execution/invasion/InvasionExecution.ts index 3dbf6b1de..ad5bdd589 100644 --- a/src/core/execution/invasion/InvasionExecution.ts +++ b/src/core/execution/invasion/InvasionExecution.ts @@ -24,22 +24,35 @@ import { boatIntervalTicks, boatTroops, bombIntervalTicks, + maxInvaderNations, nukeTier, selectInvasionNuke, warshipCount, } from "./InvasionConfig"; +// Escort warships are released ~0.5s apart so they don't stack on the map. +const WARSHIP_STAGGER_TICKS = 5; + +interface PendingEscort { + fireTick: number; + ownerId: PlayerID; + spawnTile: TileRef; + patrolTile: TileRef; +} + /** * 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. + * 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. * * Determinism: all randomness comes from a single seeded `PseudoRandom`; the * escalation clock is derived from integer tick counts. @@ -56,11 +69,13 @@ export class InvasionExecution implements Execution { private nextWaveTick = -1; private nextBombTick = -1; private invaderCounter = 0; + private wavesLaunched = 0; // Minimum manhattan distance an invader must travel before landfall. private minLandingDist = 0; private readonly invaders: PlayerInfo[] = []; private readonly aiAttached = new Set(); + private readonly pendingEscorts: PendingEscort[] = []; constructor(private gameID: GameID) { this.random = new PseudoRandom(simpleHash(gameID) + 7919); @@ -92,12 +107,15 @@ export class InvasionExecution implements Execution { // 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); + // Waves. if (this.nextWaveTick < 0) { this.nextWaveTick = ticks; // first wave fires immediately } if (ticks >= this.nextWaveTick) { - this.launchWave(elapsed, difficulty); + this.launchWave(ticks, elapsed, difficulty); this.nextWaveTick = ticks + boatIntervalTicks(elapsed, difficulty); } @@ -131,37 +149,94 @@ export class InvasionExecution implements Execution { } } - private launchWave(elapsed: number, difficulty: Difficulty): void { + private launchWave( + ticks: number, + elapsed: number, + difficulty: Difficulty, + ): 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(); 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); + const launcher = this.selectWaveLauncher(difficulty); + if (launcher === null) return; - // 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); + 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(invader, target, troops, src), + new TransportShipExecution(launcher, target, troops, src), ); + this.wavesLaunched++; - // Escort warships (weighted 0-3, only from minute 2 onward). + // 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 patrol = this.waterNear(target) ?? src; + const ownerId = launcher.id(); for (let i = 0; i < escorts; i++) { - const warship = invader.buildUnit(UnitType.Warship, src, { - patrolTile: patrol, + this.pendingEscorts.push({ + fireTick: ticks + i * WARSHIP_STAGGER_TICKS, + ownerId, + spawnTile: this.randomWaterNear(src, 3), + patrolTile: this.randomWaterNear(target, 6), }); - this.mg.addExecution(new WarshipExecution(warship)); } } } + /** + * 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. + */ + private selectWaveLauncher(difficulty: Difficulty): Player | null { + const active = this.invaders.filter((info) => this.isActiveInvader(info)); + if (active.length < maxInvaderNations(difficulty)) { + const info = this.createInvaderInfo(); + const invader = this.mg.addPlayer(info, ColoredTeams.Invaders); + 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); + if (candidates.length === 0) return null; + return this.random.randElement(candidates); + } + + /** An invader still in play: landed (alive) or with a boat still inbound. */ + 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; + } + 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; @@ -303,29 +378,24 @@ export class InvasionExecution implements Execution { 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; + /** + * 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. + */ + private randomWaterNear(center: TileRef, radius: number): TileRef { + const cx = this.mg.x(center); + const cy = this.mg.y(center); + for (let i = 0; i < 20; i++) { + const nx = cx + this.random.nextInt(-radius, radius + 1); + const ny = cy + this.random.nextInt(-radius, radius + 1); if (!this.mg.isValidCoord(nx, ny)) continue; const ref = this.mg.ref(nx, ny); if (this.mg.isWater(ref)) { return ref; } } - return null; + return center; } private firstOwnedTile(player: Player): TileRef | null { diff --git a/tests/InvasionConfig.test.ts b/tests/InvasionConfig.test.ts index 2b249c7d9..aa3d7ac02 100644 --- a/tests/InvasionConfig.test.ts +++ b/tests/InvasionConfig.test.ts @@ -2,6 +2,7 @@ import { boatIntervalTicks, boatTroops, bombIntervalTicks, + maxInvaderNations, nukeTier, selectInvasionNuke, warshipCount, @@ -61,6 +62,32 @@ describe("InvasionConfig.boatTroops", () => { 350_000, ); }); + + 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, + ); + }); +}); + +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.warshipCount", () => { diff --git a/tests/InvasionMode.test.ts b/tests/InvasionMode.test.ts index ab652c75d..5262be3a7 100644 --- a/tests/InvasionMode.test.ts +++ b/tests/InvasionMode.test.ts @@ -1,3 +1,4 @@ +import { maxInvaderNations } from "../src/core/execution/invasion/InvasionConfig"; import { InvasionExecution } from "../src/core/execution/invasion/InvasionExecution"; import { NukeExecution } from "../src/core/execution/NukeExecution"; import { @@ -79,6 +80,37 @@ describe("Invasion Mode waves", () => { .filter((p) => p.team() === ColoredTeams.Invaders); expect(invaders.length).toBe(0); }); + + test("never exceeds the difficulty cap of concurrent invader nations", async () => { + const difficulty = "Impossible"; + 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++) { + game.executeNextTick(); + if (game.units(UnitType.Warship).length > 0) sawWarship = true; + const live = game + .players() + .filter( + (p) => + p.team() === ColoredTeams.Invaders && + (p.isAlive() || p.unitCount(UnitType.TransportShip) > 0), + ).length; + maxConcurrent = Math.max(maxConcurrent, live); + } + + expect(maxConcurrent).toBeGreaterThan(0); + expect(maxConcurrent).toBeLessThanOrEqual(cap); + // Escorts arrive once the invasion passes minute 2. + expect(sawWarship).toBe(true); + }); }); describe("Invasion Mode faction", () => {