import { Difficulty, Game, GameMode, Gold, Player, PlayerType, Relation, Structures, Tick, Unit, UnitType, } from "../../game/Game"; import { TileRef, euclDistFN } from "../../game/GameMap"; 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"; import { randTerritoryTileArray } from "./NationUtils"; /** Cap on silo levels reachable via maybeDestroyEnemySam's upgrade fallback. */ const MAX_NATION_SILO_UPGRADE_LEVEL = 5; /** * Level-weighted structure density (sum of structure levels per tile owned) * above which the richest impossible nation will pre-emptively nuke a player. */ const HIGH_DENSITY_NUKE_THRESHOLD = 1 / 75; /** Minimum sum of structure levels a player needs to qualify as a high-density nuke target. */ const MIN_LEVEL_SUM_FOR_HIGH_DENSITY_NUKE = 5; export class NationNukeBehavior { private readonly recentlySentNukes: [ Tick, TileRef, UnitType.AtomBomb | UnitType.HydrogenBomb, ][] = []; private atomBombsLaunched = 0; private atomBombPerceivedCost = this.cost(UnitType.AtomBomb); private hydrogenBombsLaunched = 0; private hydrogenBombPerceivedCost = this.cost(UnitType.HydrogenBomb); // Make 1/3 of nations "hydro-nations" that only throw hydrogen bombs (to reduce atom bomb spam) private readonly isHydroNation: boolean = this.random.chance(3); constructor( private random: PseudoRandom, private game: Game, private player: Player, private attackBehavior: AiAttackBehavior, private emojiBehavior: NationEmojiBehavior, ) {} maybeSendNuke() { const silos = this.player.units(UnitType.MissileSilo); const config = this.game.config(); if ( silos.length === 0 || config.isUnitDisabled(UnitType.MissileSilo) || (config.isUnitDisabled(UnitType.AtomBomb) && config.isUnitDisabled(UnitType.HydrogenBomb)) ) { return; } const nukeTarget = this.findBestNukeTarget(); if (nukeTarget === null) { return; } if ( nukeTarget.type() === PlayerType.Bot || // Don't nuke tribes (as opposed to nations and humans) this.player.isOnSameTeam(nukeTarget) || this.attackBehavior.shouldAttack(nukeTarget) === false ) { return; } const hydroCost = this.getPerceivedNukeCost(UnitType.HydrogenBomb); const atomCost = this.getPerceivedNukeCost(UnitType.AtomBomb); let nukeType: UnitType; if ( !this.game.config().isUnitDisabled(UnitType.HydrogenBomb) && this.player.gold() >= hydroCost ) { nukeType = UnitType.HydrogenBomb; } else if ( !this.game.config().isUnitDisabled(UnitType.AtomBomb) && (!this.isHydroNation || this.isUnderHeavyAttack()) && this.player.gold() >= atomCost ) { nukeType = UnitType.AtomBomb; } else { return; } const range = this.game.config().nukeMagnitudes(nukeType).outer; const structures = nukeTarget.units(Structures.types); const structureTiles = structures.map((u) => u.tile()); const difficulty = this.game.config().gameConfig().difficulty; // Use more random tiles on Impossible difficulty to improve chances of finding a perfect SAM outranging spot const numRandomTiles = difficulty === Difficulty.Impossible ? 30 : 10; const randomTiles = randTerritoryTileArray( this.random, this.game, nukeTarget, numRandomTiles, ); const allTiles = randomTiles.concat(structureTiles); let bestTile: TileRef | null = null; let bestValue = -1; // -1 is important, so that we can also nuke land without structures this.removeOldNukeEvents(); outer: for (const tile of new Set(allTiles)) { if (tile === null) continue; const boundingBox = boundingBoxTiles(this.game, tile, range) // Add radius / 2 in case there is a piece of unwanted territory inside the outer radius that we miss. .concat(boundingBoxTiles(this.game, tile, Math.floor(range / 2))); for (const t of boundingBox) { if (!this.isValidNukeTile(t, nukeTarget)) { continue outer; } } const spawnTile = this.player.canBuild(nukeType, tile); if (spawnTile === false) continue; // In team games, avoid nuking the same position as a teammate if ( this.game.config().gameConfig().gameMode === GameMode.Team && difficulty !== Difficulty.Easy && this.isTeammateAlreadyNukingThisSpot(tile, nukeType) ) { continue; } // On Hard & Impossible, avoid trajectories that can be intercepted by enemy SAMs if ( (difficulty === Difficulty.Hard || difficulty === Difficulty.Impossible) && this.isTrajectoryInterceptableBySam(spawnTile, tile) ) { continue; } // On all difficulties, avoid trajectories that cross impassable terrain // (the simulation aborts such launches — see NukeExecution). if (this.isTrajectoryBlockedByImpassable(spawnTile, tile)) { continue; } const value = this.nukeTileScore(tile, silos, structures, nukeType); if (value > bestValue) { bestTile = tile; bestValue = value; } } 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) { return incomingAttackPlayer; } // On Impossible, the richest nation hunts very high structure density targets // Restricting to the richest nation prevents every impossible nation // from piling onto the same compact player. if ( diff === Difficulty.Impossible && this.isRichestNation() && this.random.chance(2) ) { const denseTarget = this.findHighDensityTarget(); if (denseTarget !== null) { return denseTarget; } } // On impossible difficulty, prioritize nuking the crown if they have more than 50% of the map const { difficulty, gameMode } = this.game.config().gameConfig(); if (difficulty === Difficulty.Impossible && gameMode === GameMode.FFA) { const numTilesWithoutFallout = this.game.numLandTiles() - this.game.numTilesWithFallout(); if (numTilesWithoutFallout > 0) { const sortedByTiles = this.game .players() .slice() .sort((a, b) => b.numTilesOwned() - a.numTilesOwned()); const crown = sortedByTiles[0]; if (crown && crown !== this.player && !this.player.isFriendly(crown)) { const crownShare = crown.numTilesOwned() / numTilesWithoutFallout; if (crownShare > 0.5) { return crown; } } } } // Assist allies, check their targets (this is basically the same as in assistAllies, but without sending emojis) for (const ally of this.player.allies()) { if (ally.targets().length === 0) continue; if (this.player.relation(ally) < Relation.Friendly) continue; for (const target of ally.targets()) { if (target === this.player) continue; if (this.player.isFriendly(target)) continue; // Found a valid ally target to nuke return target; } } // Find the most hated player // Ignore much weaker players (we don't need nukes to deal with them) const myMaxTroops = this.game.config().maxTroops(this.player); for (const relation of this.player.allRelationsSorted()) { if (relation.relation !== Relation.Hostile) continue; const other = relation.player; if (this.player.isFriendly(other)) continue; const otherMaxTroops = this.game.config().maxTroops(other); if (myMaxTroops >= otherMaxTroops * 2) continue; return other; } // In FFAs, nuke the crown if they're far enough ahead const crownTarget = this.findFFACrownTarget(); if (crownTarget) { return crownTarget; } // In Teams, nuke the strongest team const teamTarget = this.findStrongestTeamTarget(); if (teamTarget) { return teamTarget; } return null; } private isRichestNation(): boolean { const myGold = this.player.gold(); for (const other of this.game.players()) { if (other === this.player) continue; if (other.type() !== PlayerType.Nation) continue; if (other.gold() > myGold) return false; } return true; } private findHighDensityTarget(): Player | null { let bestTarget: Player | null = null; let bestDensity = HIGH_DENSITY_NUKE_THRESHOLD; for (const other of this.game.players()) { if (other === this.player) continue; if (other.type() === PlayerType.Bot) continue; if (this.player.isFriendly(other)) continue; const tilesOwned = other.numTilesOwned(); if (tilesOwned === 0) continue; const structures = other.units(Structures.types); let levelSum = 0; for (const s of structures) levelSum += s.level(); // Skip players with too few structures regardless of density if (levelSum < MIN_LEVEL_SUM_FOR_HIGH_DENSITY_NUKE) continue; const density = levelSum / tilesOwned; if (density > bestDensity) { bestDensity = density; bestTarget = other; } } return bestTarget; } private findFFACrownTarget(): Player | null { const { difficulty, gameMode } = this.game.config().gameConfig(); if (gameMode !== GameMode.FFA) { return null; } if (this.game.players().length <= 1) { return null; } const sortedByTiles = this.game .players() .slice() .sort((a, b) => b.numTilesOwned() - a.numTilesOwned()); const firstPlace = sortedByTiles[0]; // If we're the crown on Impossible difficulty, target 2nd place if ( difficulty === Difficulty.Impossible && firstPlace === this.player && sortedByTiles.length >= 2 ) { const secondPlace = sortedByTiles[1]; if (!this.player.isFriendly(secondPlace)) { return secondPlace; } } // Don't target ourselves or allies if (firstPlace === this.player || this.player.isFriendly(firstPlace)) { return null; } const numTilesWithoutFallout = this.game.numLandTiles() - this.game.numTilesWithFallout(); if (numTilesWithoutFallout <= 0) { return null; } const firstPlaceShare = firstPlace.numTilesOwned() / numTilesWithoutFallout; const myShare = this.player.numTilesOwned() / numTilesWithoutFallout; let threshold: number; switch (difficulty) { case Difficulty.Easy: threshold = 0.4; // 40% break; case Difficulty.Medium: threshold = 0.3; // 30% break; case Difficulty.Hard: threshold = 0.2; // 20% break; case Difficulty.Impossible: threshold = 0.1; // 10% break; default: assertNever(difficulty); } // Check if first place has threshold% more tile-percentage of the map than us if (firstPlaceShare - myShare > threshold) { return firstPlace; } return null; } private findStrongestTeamTarget(): Player | null { if (this.game.config().gameConfig().gameMode !== GameMode.Team) { return null; } if (this.game.players().length <= 1) { return null; } const teamTiles = new Map(); const teamPlayers = new Map(); for (const p of this.game.players()) { const team = p.team(); if (team === null) continue; teamTiles.set(team, (teamTiles.get(team) ?? 0) + p.numTilesOwned()); let players = teamPlayers.get(team); if (!players) { players = []; teamPlayers.set(team, players); } players.push(p); } const sortedTeams = Array.from(teamTiles.entries()).sort( (a, b) => b[1] - a[1], ); if (sortedTeams.length === 0) { return null; } let strongestTeam = sortedTeams[0][0]; if (strongestTeam === this.player.team()) { if (sortedTeams.length > 1) { strongestTeam = sortedTeams[1][0]; } else { return null; } } const targetTeamPlayers = teamPlayers.get(strongestTeam)!; // Filter out friendly players const validTargets = targetTeamPlayers.filter( (p) => !this.player.isFriendly(p), ); if (validTargets.length === 0) { return null; } if (this.random.chance(2)) { // Strongest player return validTargets.reduce((prev, current) => this.game.config().maxTroops(prev) > this.game.config().maxTroops(current) ? prev : current, ); } else { // Random player return this.random.randElement(validTargets); } } // 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); } // Save up a limited amount in team games, synced with NationStructureBehavior // Saving up for a MIRV is not relevant if ( this.game.config().gameConfig().gameMode === GameMode.Team && this.player.gold() > this.cost(UnitType.HydrogenBomb) ) { return this.cost(type); } // Return the actual cost if we already have enough gold to buy both a MIRV and a hydro if ( this.player.gold() > this.cost(UnitType.MIRV) + this.cost(UnitType.HydrogenBomb) ) { return this.cost(type); } // On Hard & Impossible, ignore perceived cost when under heavy attack // The nation is probably going to get destroyed soon, so go all-in on nukes const difficulty = this.game.config().gameConfig().difficulty; if ( (difficulty === Difficulty.Hard || difficulty === Difficulty.Impossible) && this.isUnderHeavyAttack() ) { return this.cost(type); } if (type === UnitType.AtomBomb) { return this.atomBombPerceivedCost; } else { return this.hydrogenBombPerceivedCost; } } private isUnderHeavyAttack(): boolean { // Get the total incoming attack troops const incomingAttacks = this.player.incomingAttacks(); let totalIncomingTroops = 0; for (const attack of incomingAttacks) { totalIncomingTroops += attack.troops(); } const myTroops = this.player.troops(); return totalIncomingTroops >= myTroops; } private removeOldNukeEvents() { const maxAge = 600; // 600 ticks = 1 minute const tick = this.game.ticks(); while ( this.recentlySentNukes.length > 0 && this.recentlySentNukes[0][0] + maxAge < tick ) { this.recentlySentNukes.shift(); } } private isTeammateAlreadyNukingThisSpot( tile: TileRef, nukeType: UnitType.AtomBomb | UnitType.HydrogenBomb, ): boolean { // Get the inner radius for our nuke type const ourInnerRadius = this.game.config().nukeMagnitudes(nukeType).inner; // Get all active nukes in the game const activeNukes = this.game.units( UnitType.AtomBomb, UnitType.HydrogenBomb, ); // Check if any teammate's nuke blast radius overlaps with ours for (const nuke of activeNukes) { const nukeOwner = nuke.owner(); // Skip our own nukes and non-teammate nukes if (nukeOwner === this.player || !this.player.isFriendly(nukeOwner)) { continue; } // Get the target tile of the teammate's nuke const targetTile = nuke.targetTile(); if (!targetTile) continue; // Get the blast radius of the teammate's nuke const teammateInnerRadius = this.game .config() .nukeMagnitudes(nuke.type()).inner; // Check if the blast zones overlap // They overlap if distance between targets < sum of the two radii const distSquared = this.game.euclideanDistSquared(tile, targetTile); const sumRadius = ourInnerRadius + teammateInnerRadius; const sumRadiusSquared = sumRadius * sumRadius; if (distSquared <= sumRadiusSquared) { return true; } } return false; } // mirroring NukeTrajectoryPreviewLayer.ts logic a bit private isTrajectoryInterceptableBySam( spawnTile: TileRef, targetTile: TileRef, excludedSamIds?: Set, ): boolean { const speed = this.game.config().defaultNukeSpeed(); const pathFinder = UniversalPathFinding.Parabola(this.game, { increment: speed, distanceBasedHeight: true, // Atom/Hydrogen bombs use distance-based height directionUp: true, // AI nukes always go "up" for now }); const trajectory = pathFinder.findPath(spawnTile, targetTile) ?? []; if (trajectory.length === 0) { return false; } const targetRangeSquared = this.game.config().defaultNukeTargetableRange() ** 2; let untargetableStart = -1; let untargetableEnd = -1; for (let i = 0; i < trajectory.length; i++) { const tile = trajectory[i]; if (untargetableStart === -1) { if ( this.game.euclideanDistSquared(tile, spawnTile) > targetRangeSquared ) { if ( this.game.euclideanDistSquared(tile, targetTile) < targetRangeSquared ) { // Overlapping spawn & target range – no untargetable segment. break; } else { untargetableStart = i; } } } else if ( this.game.euclideanDistSquared(tile, targetTile) < targetRangeSquared ) { untargetableEnd = i; break; } } for (let i = 0; i < trajectory.length; i++) { // Skip the mid-air untargetable portion if ( untargetableStart !== -1 && untargetableEnd !== -1 && i === untargetableStart ) { i = untargetableEnd - 1; continue; } const tile = trajectory[i]; const nearbySams = this.game.nearbyUnits( tile, this.game.config().maxSamRange(), UnitType.SAMLauncher, ); for (const sam of nearbySams) { const owner = sam.unit.owner(); 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; } } } return false; } /** * Check if the parabolic nuke trajectory from spawnTile to targetTile * crosses any impassable terrain. Mirrors the check in NukeExecution that * aborts such launches */ private isTrajectoryBlockedByImpassable( spawnTile: TileRef, targetTile: TileRef, ): boolean { const pathFinder = UniversalPathFinding.Parabola(this.game, { increment: this.game.config().defaultNukeSpeed(), distanceBasedHeight: true, directionUp: true, }); const path = pathFinder.findPath(spawnTile, targetTile) ?? []; for (const tile of path) { if (this.game.isImpassable(tile)) { return true; } } return false; } private isValidNukeTile(t: TileRef, nukeTarget: Player | null): boolean { const difficulty = this.game.config().gameConfig().difficulty; const owner = this.game.owner(t); if (owner === nukeTarget) return true; // On Hard & Impossible, allow TerraNullius (hit small islands) and in team games other non-friendly players if ( (difficulty === Difficulty.Hard || difficulty === Difficulty.Impossible) && (!owner.isPlayer() || (this.game.config().gameConfig().gameMode === GameMode.Team && owner.isPlayer() && !this.player.isFriendly(owner))) ) { return true; } // On Easy & Medium, only allow tiles owned by the target player (=> nuke away from the border) to reduce nuke usage return false; } private nukeTileScore( tile: TileRef, silos: Unit[], targets: Unit[], nukeType: UnitType.AtomBomb | UnitType.HydrogenBomb, ): number { const magnitude = this.game.config().nukeMagnitudes(nukeType); const dist = euclDistFN(tile, magnitude.outer, false); let tileValue = targets .filter((unit) => dist(this.game, unit.tile())) .map((unit): number => { const level = unit.level(); switch (unit.type()) { case UnitType.City: return 25_000 * level; case UnitType.DefensePost: return 5_000 * level; case UnitType.MissileSilo: return 50_000 * level; case UnitType.Port: return 15_000 * level; case UnitType.Factory: return 15_000 * level; default: return 0; } }) .reduce((prev, cur) => prev + cur, 0); const difficulty = this.game.config().gameConfig().difficulty; // On Easy, ignore SAMs entirely. // On Medium, apply a simple local SAM penalty. // On Hard & Impossible we rely on trajectory-based interception checks instead. See maybeSendNuke(). if (difficulty === Difficulty.Medium) { const dist50 = euclDistFN(tile, 50, false); const hasSam = targets.some( (unit) => unit.type() === UnitType.SAMLauncher && dist50(this.game, unit.tile()), ); if (hasSam) return -1; } // On Impossible difficulty and a hydrogen bomb, add value for SAMs that can be outranged if ( difficulty === Difficulty.Impossible && nukeType === UnitType.HydrogenBomb ) { const hydroMagnitude = this.game .config() .nukeMagnitudes(UnitType.HydrogenBomb); const nearbySams = this.game.nearbyUnits( tile, hydroMagnitude.outer, UnitType.SAMLauncher, ); for (const sam of nearbySams) { const samLevel = sam.unit.level(); if (samLevel >= 5) continue; // Can't outrange level 5+ SAMs const samRange = this.game.config().samRange(samLevel); const distToSam = Math.sqrt( this.game.euclideanDistSquared(tile, sam.unit.tile()), ); // Check if we can outrange this SAM if (distToSam > samRange) { // Add significant value for destroying a SAM that we can outrange tileValue += 100_000 * samLevel; } } } // Prefer tiles that are closer to a silo (but preserve structure value) const siloTiles = silos.map((u) => u.tile()); const result = closestTwoTiles(this.game, siloTiles, [tile]); if (result === null) throw new Error("Missing result"); const { x: closestSilo } = result; const distanceSquared = this.game.euclideanDistSquared(tile, closestSilo); const distanceToClosestSilo = Math.sqrt(distanceSquared); const distancePenalty = distanceToClosestSilo * 30; const baseTileValue = tileValue; tileValue = Math.max(baseTileValue * 0.2, tileValue - distancePenalty); // Keep at least 20% of structure value // Don't target near recent targets tileValue -= this.recentlySentNukes .filter(([_tick, recentTile, recentNukeType]) => { const recentInnerRadius = this.game .config() .nukeMagnitudes(recentNukeType).inner; const distSquared = this.game.euclideanDistSquared(tile, recentTile); return distSquared <= recentInnerRadius * recentInnerRadius; }) .map((_) => 1_000_000) .reduce((prev, cur) => prev + cur, 0); return tileValue; } private sendNuke( tile: TileRef, nukeType: UnitType.AtomBomb | UnitType.HydrogenBomb, targetPlayer: Player, waitTicks = 0, ) { const tick = this.game.ticks(); this.recentlySentNukes.push([tick, tile, nukeType]); if (nukeType === UnitType.AtomBomb) { this.atomBombsLaunched++; // Increase perceived cost by 50% each time to simulate saving up for a MIRV (higher than hydro to make atom bombs less attractive for the lategame) this.atomBombPerceivedCost = (this.atomBombPerceivedCost * 150n) / 100n; } else if (nukeType === UnitType.HydrogenBomb) { this.hydrogenBombsLaunched++; // Increase perceived cost by 25% each time to simulate saving up for a MIRV this.hydrogenBombPerceivedCost = (this.hydrogenBombPerceivedCost * 125n) / 100n; } 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; // Track the first failed attempt so we can upgrade a silo that would // actually have helped that plan (rather than an unrelated silo). let failedTarget: { targetTile: TileRef; coveringSamIds: Set; totalBombs: number; } | null = null; 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; // Skip silos whose trajectory crosses impassable terrain — the // simulation would abort these launches (see NukeExecution). if (this.isTrajectoryBlockedByImpassable(silo.tile(), targetTile)) 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) { failedTarget ??= { targetTile, coveringSamIds, 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) { failedTarget ??= { targetTile, coveringSamIds, 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 && failedTarget !== null) { this.maybeUpgradeHelpfulSilo(failedTarget); } } /** * 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 a missile silo that would actually have helped the failed * overwhelm attempt: trajectory to the failed target is not blocked by * non-covering enemy SAMs, and the silo is below the upgrade cap. Among * those, picks the one best protected by our own SAMs. */ private maybeUpgradeHelpfulSilo(failedTarget: { targetTile: TileRef; coveringSamIds: Set; totalBombs: number; }): void { const silos = this.player.units(UnitType.MissileSilo); if (silos.length === 0) return; // First pass: find silos with an unblocked trajectory to the failed // target. Only these contribute slots to the overwhelm plan. // "Unblocked" means not interceptable by non-covering enemy SAMs AND // not crossing impassable terrain (the sim aborts those launches). const unblockedSilos: Unit[] = []; for (const silo of silos) { if ( !this.isTrajectoryInterceptableBySam( silo.tile(), failedTarget.targetTile, failedTarget.coveringSamIds, ) && !this.isTrajectoryBlockedByImpassable( silo.tile(), failedTarget.targetTile, ) ) { unblockedSilos.push(silo); } } if (unblockedSilos.length === 0) return; // Bail out if the target is unreachable even at max silo level — // crazy amounts of covering SAMs, upgrading is wasted gold. const maxAchievableSlots = unblockedSilos.length * MAX_NATION_SILO_UPGRADE_LEVEL; if (maxAchievableSlots < failedTarget.totalBombs) return; const ourSams = this.player.units(UnitType.SAMLauncher); let bestSilo: Unit | null = null; let bestProtection = -1; for (const silo of unblockedSilos) { if (silo.level() >= MAX_NATION_SILO_UPGRADE_LEVEL) continue; 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); } }