Files
OpenFrontIO/src/core/execution/nation/NationNukeBehavior.ts
T
FloPinguin f09d9a3a5f Nations can overwhelm SAMs now 💥 (+ 3 little nation improvements) (#3246)
## Description:

### SAM Overwhelming (`NationNukeBehavior.ts`)

On Impossible difficulty, nations can now destroy enemy SAMs by
overwhelming them with coordinated atom bomb salvos. When no good nuke
target is found (all trajectories intercepted by SAMs), the nations
will:

- Identify the easiest enemy SAM to destroy (lowest level first)
- Calculate the total interception capacity of all covering SAMs and
send enough bombs to overwhelm them (+1 extra per 5 needed to account
for enemy building more SAMs during flight)
- Plan launches in NukeExecution's Manhattan-distance silo order,
tracking which silos have interceptable trajectories (wasted bombs)
- Use a sliding window over parabolic flight times to find the best
cluster of bombs that can arrive within half the SAM cooldown window
- Compute per-bomb wait ticks to synchronize arrivals from silos at
different distances
- Skip launching if a salvo is already in flight
- Upgrade the best SAM-protected silo when silo capacity is
insufficient; wait and save gold when only gold is lacking


https://github.com/user-attachments/assets/14fa592f-2902-4604-8e37-1eba2b2f0b85

### 2-Player Endgame Handling (`NationNukeBehavior.ts`)

- On Hard/Impossible with only 2 players remaining,
`findBestNukeTarget()` directly targets the other player (bypasses all
priority logic)
- `getPerceivedNukeCost()` returns actual cost (no MIRV saving
inflation) when only 2 players are left

### SAM Build Rate (`NationStructureBehavior.ts`)

- Reduced SAM perceived cost increase per owned from 1.0 to 0.5, so
nations build more SAMs

### Island Attack Variety (`AiAttackBehavior.ts`)

- `findNearestIslandEnemy()` now collects up to 2 reachable candidates
and has a 33% chance to pick the second-nearest, adding variety to boat
attack targeting

## Please complete the following:

- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory
- [X] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

FloPinguin
2026-02-20 23:16:03 -06:00

997 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Difficulty,
Game,
GameMode,
Gold,
Player,
PlayerType,
Relation,
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";
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 nukeTarget = this.findBestNukeTarget();
if (nukeTarget === null) {
return;
}
const silos = this.player.units(UnitType.MissileSilo);
if (
silos.length === 0 ||
nukeTarget.type() === PlayerType.Bot || // Don't nuke bots (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(
UnitType.City,
UnitType.DefensePost,
UnitType.MissileSilo,
UnitType.Port,
UnitType.SAMLauncher,
UnitType.Factory,
);
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;
}
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 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 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<string, number>();
const teamPlayers = new Map<string, Player[]>();
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);
}
// Return the actual cost in team games (saving up for a MIRV is not relevant, the game will be finished before that)
// or if we already have enough gold to buy both a MIRV and a hydro
if (
this.game.config().gameConfig().gameMode === GameMode.Team ||
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<number>,
): 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;
}
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;
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);
}
}