diff --git a/src/core/execution/nation/NationNukeBehavior.ts b/src/core/execution/nation/NationNukeBehavior.ts index 7ab12df5e..1b49214c0 100644 --- a/src/core/execution/nation/NationNukeBehavior.ts +++ b/src/core/execution/nation/NationNukeBehavior.ts @@ -15,6 +15,7 @@ import { UniversalPathFinding } from "../../pathfinding/PathFinder"; import { PseudoRandom } from "../../PseudoRandom"; import { assertNever, boundingBoxTiles } from "../../Util"; import { NukeExecution } from "../NukeExecution"; +import { UpgradeStructureExecution } from "../UpgradeStructureExecution"; import { closestTwoTiles } from "../Util"; import { AiAttackBehavior } from "../utils/AiAttackBehavior"; import { EMOJI_NUKE, NationEmojiBehavior } from "./NationEmojiBehavior"; @@ -137,12 +138,29 @@ export class NationNukeBehavior { bestValue = value; } } - if (bestTile !== null) { + if ( + bestTile !== null && + (bestValue > 0 || difficulty !== Difficulty.Impossible) + ) { this.sendNuke(bestTile, nukeType, nukeTarget); + } else if (difficulty === Difficulty.Impossible) { + this.maybeDestroyEnemySam(nukeTarget); } } findBestNukeTarget(): Player | null { + // On Hard & Impossible with only 2 players left, target the only other one + const { difficulty: diff } = this.game.config().gameConfig(); + if ( + (diff === Difficulty.Hard || diff === Difficulty.Impossible) && + this.game.players().length === 2 + ) { + const other = this.game.players().find((p) => p !== this.player); + if (other) { + return other; + } + } + // Retaliate against incoming attacks (Most important!) const incomingAttackPlayer = this.attackBehavior.findIncomingAttackPlayer(); if (incomingAttackPlayer) { @@ -349,6 +367,11 @@ export class NationNukeBehavior { // Simulate saving up for a MIRV private getPerceivedNukeCost(type: UnitType): Gold { + // If only 2 players left, use actual cost (no point saving for MIRV) + if (this.game.players().length === 2) { + return this.cost(type); + } + // If MIRVs are disabled, return the actual cost if (this.game.config().isUnitDisabled(UnitType.MIRV)) { return this.cost(type); @@ -455,6 +478,7 @@ export class NationNukeBehavior { private isTrajectoryInterceptableBySam( spawnTile: TileRef, targetTile: TileRef, + excludedSamIds?: Set, ): boolean { const speed = this.game.config().defaultNukeSpeed(); const pathFinder = UniversalPathFinding.Parabola(this.game, { @@ -520,6 +544,10 @@ export class NationNukeBehavior { if (owner === this.player || this.player.isFriendly(owner)) { continue; } + // Skip SAMs we're intentionally overwhelming + if (excludedSamIds?.has(sam.unit.id())) { + continue; + } const rangeSquared = this.game.config().samRange(sam.unit.level()) ** 2; if (sam.distSquared <= rangeSquared) { return true; @@ -654,6 +682,7 @@ export class NationNukeBehavior { tile: TileRef, nukeType: UnitType.AtomBomb | UnitType.HydrogenBomb, targetPlayer: Player, + waitTicks = 0, ) { const tick = this.game.ticks(); this.recentlySentNukes.push([tick, tile, nukeType]); @@ -667,10 +696,300 @@ export class NationNukeBehavior { this.hydrogenBombPerceivedCost = (this.hydrogenBombPerceivedCost * 125n) / 100n; } - this.game.addExecution(new NukeExecution(nukeType, this.player, tile)); + this.game.addExecution( + new NukeExecution(nukeType, this.player, tile, null, -1, waitTicks), + ); this.emojiBehavior.maybeSendEmoji(targetPlayer, EMOJI_NUKE); } + /** + * On Impossible difficulty, when no good nuke target is available (score <= 0), + * attempt to destroy enemy SAMs by overwhelming them with atom bombs. + * A SAM of level N can intercept N nukes before going on cooldown, + * so we need N+1 bombs to destroy it (accounting for all covering SAMs). + */ + private maybeDestroyEnemySam(nukeTarget: Player): void { + if (this.game.config().isUnitDisabled(UnitType.AtomBomb)) { + return; + } + + // Don't launch another salvo if we already have atom bombs in flight + const ourAtomBombs = this.player.units(UnitType.AtomBomb); + if (ourAtomBombs.length > 0) { + return; + } + + const atomCost = this.cost(UnitType.AtomBomb); + const enemySams = nukeTarget.units(UnitType.SAMLauncher); + if (enemySams.length === 0) { + return; + } + + const ourSilos = this.player + .units(UnitType.MissileSilo) + .filter((silo) => !silo.isUnderConstruction()); + if (ourSilos.length === 0) { + return; + } + + // Try each enemy SAM as a target, easiest (lowest level) first + const sortedSams = enemySams.slice().sort((a, b) => a.level() - b.level()); + let needsMoreSilos = false; + + for (const targetSam of sortedSams) { + const targetTile = targetSam.tile(); + + // Find all enemy SAMs whose range covers the target tile (they will all try to intercept) + const coveringSams = this.findEnemySamsCoveringTile(targetTile); + const coveringSamIds = new Set(coveringSams.map((s) => s.id())); + + // Total interception capacity = sum of covering SAM levels + const totalInterceptions = coveringSams.reduce( + (sum, sam) => sum + sam.level(), + 0, + ); + const bombsNeeded = totalInterceptions + 1; + + // NukeExecution always picks the closest non-cooldown silo by Manhattan + // distance to target (via nukeSpawn). Our planning must mirror that order. + // Silos with interceptable trajectories will still be picked first by + // NukeExecution — their bombs launch but get intercepted, "wasting" slots. + const nukeSpeed = this.game.config().defaultNukeSpeed(); + const allAvailableSilos: { + silo: Unit; + slots: number; + flightTicks: number; + interceptable: boolean; + }[] = []; + for (const silo of ourSilos) { + const availableSlots = silo.level() - silo.missileTimerQueue().length; + if (availableSlots <= 0) { + continue; + } + const interceptable = this.isTrajectoryInterceptableBySam( + silo.tile(), + targetTile, + coveringSamIds, + ); + // Compute actual parabolic flight time in ticks + const pathFinder = UniversalPathFinding.Parabola(this.game, { + increment: nukeSpeed, + distanceBasedHeight: true, + directionUp: true, + }); + const trajectory = pathFinder.findPath(silo.tile(), targetTile) ?? []; + if (trajectory.length === 0) continue; + allAvailableSilos.push({ + silo, + slots: availableSlots, + flightTicks: trajectory.length, + interceptable, + }); + } + + // Sort by Manhattan distance to target (matching nukeSpawn's pick order) + allAvailableSilos.sort( + (a, b) => + this.game.manhattanDist(a.silo.tile(), targetTile) - + this.game.manhattanDist(b.silo.tile(), targetTile), + ); + + // Flatten into a per-bomb launch sequence matching NukeExecution's order. + // Each silo contributes `slots` consecutive bombs before NukeExecution + // moves to the next silo. + const launchSequence: { + flightTicks: number; + interceptable: boolean; + }[] = []; + for (const entry of allAvailableSilos) { + for (let s = 0; s < entry.slots; s++) { + launchSequence.push({ + flightTicks: entry.flightTicks, + interceptable: entry.interceptable, + }); + } + } + + // Use half the SAM cooldown as the max total arrival spread to be safe. + const samCooldown = this.game.config().SAMCooldown(); + const maxTotalArrivalSpread = Math.floor(samCooldown / 2); + + // Add extra bombs: 1 for every 5 to account for enemy building more SAMs + // while our bombs are in flight + const extraBombs = Math.floor(bombsNeeded / 5); + const totalBombs = bombsNeeded + extraBombs; + + // Collect bombs from silos whose trajectory to the target is NOT blocked + // by enemy SAMs other than the covering SAMs we're trying to overwhelm. + const unblockedBombs: { index: number; flightTicks: number }[] = []; + for (let i = 0; i < launchSequence.length; i++) { + if (!launchSequence[i].interceptable) { + unblockedBombs.push({ + index: i, + flightTicks: launchSequence[i].flightTicks, + }); + } + } + + if (unblockedBombs.length < totalBombs) { + needsMoreSilos = true; + continue; + } + + // Sort unblocked bombs by flight time to find a sliding window + // of maxTotalArrivalSpread that captures the most bombs. + const sortedByFlight = [...unblockedBombs].sort( + (a, b) => a.flightTicks - b.flightTicks, + ); + + let bestWindowStart = 0; + let bestWindowCount = 0; + for (let start = 0; start < sortedByFlight.length; start++) { + let end = start; + while ( + end < sortedByFlight.length && + sortedByFlight[end].flightTicks - sortedByFlight[start].flightTicks <= + maxTotalArrivalSpread + ) { + end++; + } + if (end - start > bestWindowCount) { + bestWindowCount = end - start; + bestWindowStart = start; + } + } + + if (bestWindowCount < totalBombs) { + needsMoreSilos = true; + continue; + } + + // From the window, pick totalBombs with the lowest launch-sequence + // indices to minimise how many bombs we need to fire (minimise gold cost). + const windowBombs = sortedByFlight.slice( + bestWindowStart, + bestWindowStart + bestWindowCount, + ); + const windowByIndex = [...windowBombs].sort((a, b) => a.index - b.index); + const selected = windowByIndex.slice(0, totalBombs); + const selectedSet = new Set(selected.map((b) => b.index)); + const lastSelectedIndex = selected[selected.length - 1].index; + const bombsToFire = lastSelectedIndex + 1; + + // Compute per-bomb waitTicks so all selected bombs arrive in the window. + // Target: spread arrivals evenly, anchored at the earliest flight time + // in the selected set. + const selectedFlightMin = Math.min(...selected.map((b) => b.flightTicks)); + const staggerInterval = Math.max( + 1, + Math.floor(maxTotalArrivalSpread / totalBombs), + ); + let selectedIdx = 0; + const waitTicksPerBomb: number[] = []; + for (let i = 0; i < bombsToFire; i++) { + if (selectedSet.has(i)) { + const targetArrival = + selectedFlightMin + selectedIdx * staggerInterval; + waitTicksPerBomb.push( + Math.max(0, targetArrival - launchSequence[i].flightTicks), + ); + selectedIdx++; + } else { + // Wasted bomb (interceptable or out-of-window) — launch immediately + waitTicksPerBomb.push(0); + } + } + + // Check gold for all fired bombs (including wasted ones) + const totalCost = atomCost * BigInt(bombsToFire); + if (this.player.gold() < totalCost) { + continue; + } + + // Fire the salvo — NukeExecution will pick silos in the same + // Manhattan distance order we planned. + for (let i = 0; i < bombsToFire; i++) { + this.sendNuke( + targetTile, + UnitType.AtomBomb, + nukeTarget, + waitTicksPerBomb[i], + ); + } + return; + } + + // Couldn't destroy any SAM — upgrade silos only if capacity was the bottleneck. + // If we only lack gold, don't waste it upgrading silos — just wait and save. + if (needsMoreSilos) { + this.maybeUpgradeBestProtectedSilo(); + } + } + + /** + * Find all enemy SAMs whose range covers a given tile. + */ + private findEnemySamsCoveringTile(tile: TileRef): Unit[] { + const nearbySams = this.game.nearbyUnits( + tile, + this.game.config().maxSamRange(), + UnitType.SAMLauncher, + ); + + const result: Unit[] = []; + for (const sam of nearbySams) { + const owner = sam.unit.owner(); + if (owner === this.player || this.player.isFriendly(owner)) { + continue; + } + const range = this.game.config().samRange(sam.unit.level()); + if (sam.distSquared <= range * range) { + result.push(sam.unit); + } + } + return result; + } + + /** + * Upgrade the missile silo that is best protected by our own SAMs. + * Called when we need more silo capacity to overwhelm enemy SAMs. + */ + private maybeUpgradeBestProtectedSilo(): void { + const silos = this.player.units(UnitType.MissileSilo); + if (silos.length === 0) return; + + const ourSams = this.player.units(UnitType.SAMLauncher); + let bestSilo: Unit | null = null; + let bestProtection = -1; + + for (const silo of silos) { + if (!this.player.canUpgradeUnit(silo)) continue; + + let protection = 0; + for (const sam of ourSams) { + const range = this.game.config().samRange(sam.level()); + const distSquared = this.game.euclideanDistSquared( + silo.tile(), + sam.tile(), + ); + if (distSquared <= range * range) { + protection += sam.level(); + } + } + + if (protection > bestProtection) { + bestProtection = protection; + bestSilo = silo; + } + } + + if (bestSilo !== null) { + this.game.addExecution( + new UpgradeStructureExecution(this.player, bestSilo.id()), + ); + } + } + private cost(type: UnitType): Gold { return this.game.unitInfo(type).cost(this.game, this.player); } diff --git a/src/core/execution/nation/NationStructureBehavior.ts b/src/core/execution/nation/NationStructureBehavior.ts index fcd30353d..fe9a522b6 100644 --- a/src/core/execution/nation/NationStructureBehavior.ts +++ b/src/core/execution/nation/NationStructureBehavior.ts @@ -56,7 +56,7 @@ function getStructureRatios( }, [UnitType.SAMLauncher]: { ratioPerCity: SAM_RATIO_BY_DIFFICULTY[difficulty], - perceivedCostIncreasePerOwned: 1, + perceivedCostIncreasePerOwned: 0.5, }, [UnitType.MissileSilo]: { ratioPerCity: 0.2, diff --git a/src/core/execution/utils/AiAttackBehavior.ts b/src/core/execution/utils/AiAttackBehavior.ts index af39c2028..8d393b148 100644 --- a/src/core/execution/utils/AiAttackBehavior.ts +++ b/src/core/execution/utils/AiAttackBehavior.ts @@ -608,7 +608,8 @@ export class AiAttackBehavior { }) .sort((a, b) => a.distance - b.distance); // Sort by distance (ascending) - // Try players in order of distance until we find one reachable by boat + // Try players in order of distance until we find reachable candidates + const reachablePlayers: Player[] = []; for (const entry of sortedPlayers) { const closest = closestTwoTiles( this.game, @@ -622,11 +623,20 @@ export class AiAttackBehavior { if (closest === null) continue; if (canBuildTransportShip(this.game, this.player, closest.y)) { - return entry.player; + reachablePlayers.push(entry.player); + // We only need up to 2 reachable candidates + if (reachablePlayers.length >= 2) break; } } - return null; + if (reachablePlayers.length === 0) return null; + + // 33% chance to pick the second-nearest player if available + if (reachablePlayers.length >= 2 && this.random.chance(3)) { + return reachablePlayers[1]; + } + + return reachablePlayers[0]; } private getPlayerCenter(player: Player) { diff --git a/tests/NationNukeSamOverwhelm.test.ts b/tests/NationNukeSamOverwhelm.test.ts new file mode 100644 index 000000000..b595cd591 --- /dev/null +++ b/tests/NationNukeSamOverwhelm.test.ts @@ -0,0 +1,128 @@ +import { MissileSiloExecution } from "../src/core/execution/MissileSiloExecution"; +import { NationExecution } from "../src/core/execution/NationExecution"; +import { SAMLauncherExecution } from "../src/core/execution/SAMLauncherExecution"; +import { + Cell, + Difficulty, + Nation, + PlayerInfo, + PlayerType, + UnitType, +} from "../src/core/game/Game"; +import { setup } from "./util/Setup"; +import { executeTicks } from "./util/utils"; + +describe("NationNukeBehavior - maybeDestroyEnemySam", () => { + test("nation overwhelms enemy SAM with atom bomb salvo on Impossible difficulty", async () => { + // Impossible difficulty with 2 players forces findBestNukeTarget to + // return the human. The SAM covers all human territory so every nuke + // trajectory is interceptable, keeping bestValue ≤ 0 and triggering + // maybeDestroyEnemySam. + const game = await setup("big_plains", { + difficulty: Difficulty.Impossible, + infiniteGold: true, + instantBuild: true, + }); + + const nationInfo = new PlayerInfo( + "nation", + PlayerType.Nation, + null, + "nation_id", + ); + const humanInfo = new PlayerInfo( + "human", + PlayerType.Human, + null, + "human_id", + ); + + game.addPlayer(nationInfo); + game.addPlayer(humanInfo); + + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + const nation = game.player("nation_id"); + const human = game.player("human_id"); + + // Assign territory blocks (30×30 each, well separated) + for (let x = 10; x < 40; x++) { + for (let y = 10; y < 40; y++) { + const tile = game.ref(x, y); + if (game.map().isLand(tile)) nation.conquer(tile); + } + } + for (let x = 60; x < 90; x++) { + for (let y = 60; y < 90; y++) { + const tile = game.ref(x, y); + if (game.map().isLand(tile)) human.conquer(tile); + } + } + + // Level-1 SAM at center of human territory (samRange = 20 in TestConfig, + // covering the entire 60-90 block and intercepting all trajectories). + const samTile = game.ref(75, 75); + const sam = human.buildUnit(UnitType.SAMLauncher, samTile, {}); + game.addExecution(new SAMLauncherExecution(human, null, sam)); + + // 3 level-1 missile silos (1 slot each). Overwhelming a level-1 SAM + // requires 2 bombs (1 intercepted + 1 passes through). + for (const [x, y] of [ + [20, 20], + [25, 25], + [30, 30], + ] as const) { + const silo = nation.buildUnit(UnitType.MissileSilo, game.ref(x, y), {}); + game.addExecution(new MissileSiloExecution(silo)); + } + + // infiniteGold only applies to Human players, so the nation needs gold + nation.addGold(1_000_000_000n); + nation.addTroops(100_000); + human.addTroops(100_000); + + expect(nation.units(UnitType.MissileSilo)).toHaveLength(3); + expect(human.units(UnitType.SAMLauncher)).toHaveLength(1); + expect(nation.units(UnitType.AtomBomb)).toHaveLength(0); + + // Try multiple game IDs to account for random attack-tick alignment + // (attackRate ∈ [30,50] on Impossible). 150 inner ticks guarantees ≥2 + // attack ticks for the worst-case seed: 1st initializes behaviors, 2nd + // fires maybeSendNuke → maybeDestroyEnemySam. + const testNation = new Nation(new Cell(25, 25), nation.info()); + let salvoLaunched = false; + + for (let i = 0; i < 10 && !salvoLaunched; i++) { + // Let any executions from a prior iteration settle + if (i > 0) executeTicks(game, 50); + + const exec = new NationExecution(`game_${i}`, testNation); + exec.init(game); + + for (let tick = 0; tick < 150; tick++) { + exec.tick(tick); + // Advance the game sparingly so NukeExecution creates atom-bomb units + // but they don't complete their flight before we detect them. + if (tick % 10 === 0) game.executeNextTick(); + + if (nation.units(UnitType.AtomBomb).length > 0) { + salvoLaunched = true; + break; + } + } + } + + expect(salvoLaunched).toBe(true); + + // At least 2 atom bombs to overwhelm the level-1 SAM + const atomBombs = nation.units(UnitType.AtomBomb); + expect(atomBombs.length).toBeGreaterThanOrEqual(2); + + // All bombs should target the SAM tile + for (const bomb of atomBombs) { + expect(bomb.targetTile()).toBe(samTile); + } + }); +});