Files
OpenFrontIO/src/core/execution/nation/NationNukeBehavior.ts
T
Evan 9e9c608053 perf: cut core-sim GC churn another 36% (61% cumulative) (#4496)
## Summary

Round 2 of GC-churn reduction, attacking the next tier of allocation
sources found by the profiling harness from #4494. All changes are
**behavior-preserving** — the simulation is bit-identical (final hash
unchanged on three seeded runs).

### Changes

| Site | Change | Churn target |
|---|---|---|
| `Player.units()` / `Game.units()` | Rest parameter → fixed-arity +
array overloads (`units()`, `units(types[])`, `units(t1, t2?, t3?)`).
The rest array was allocated on **every call** of one of the hottest
functions in the sim. Spread call sites (`units(...Structures.types)`)
now pass the array directly. `GameImpl.units()` builds one flat array
instead of `Array.from().flatMap()` per-player intermediates. | ~18 GB |
| `PlayerExecution` cluster flood fill | Results are plain `TileRef[]`
in mark order instead of `Set<TileRef>` — the generation-stamped visited
array already deduplicates, and consumers only iterate/measure. DFS
stack reused across fills. | ~3.7 GB |
| `SpatialQuery.bfsNearest` | Fused generation-stamped BFS with per-game
scratch buffers (`WeakMap`-keyed, same pattern as `PlayerExecution`)
instead of materializing a `Set` of the entire search area per query.
Identical traversal and tie-breaking. | ~2.2 GB |
| `NationWarshipBehavior` ship tracking | Single-pass loops instead of
`filter().forEach()`; dropped defensive `Array.from(set)` copies
(deleting the current entry while iterating a `Set` is well-defined). |
~1.4 GB |

### Results (Giant World Map, 400 bots, 12,000 ticks ≈ 20 game-min, seed
`perf-default`)

| Metric | Before | After | vs. pre-#4494 |
|---|---|---|---|
| Sampled allocations (incl. collected) | 59.2 GB | **37.8 GB (−36%)** |
97.7 GB (**−61%**) |
| GC count / total pause | 1,076 / 1,830 ms | 772 / 1,442 ms | 1,682 /
3,313 ms |
| Ticks/sec | 73 | **82** | 66 (+24%) |
| Mean / p99 tick | 13.6 / 39.2 ms | 12.2 / 36.0 ms | 15.2 / 49.9 ms |

`units()` no longer appears in the top-30 allocator list at all. The
remaining leaders (possible round 3): the minimap pathfinding `Cell`
pipeline (~8.5 GB), `diffPlayerUpdate`/`toFullUpdate` per-tick
serialization (~4.6 GB), and iterator allocations (~3.3 GB).

## Determinism

Final game-state hash unchanged on all three reference runs:
- Giant World Map 12,000 ticks: `57830793797434300` ✓
- Giant World Map 2,000 ticks: `55125379638382860` ✓
- World 1,800 ticks: `32337437717390864` ✓

## Test plan

- [x] Full suite green (1,905 tests), including updated `units()`
semantics tests (array overload, snapshot isolation, insertion order)
- [x] Hash equality on 3 seeded headless runs (2 maps)
- [x] Before/after 20-min GC benchmarks on the same commit base

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:02:36 -07:00

1143 lines
37 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,
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<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);
}
// 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<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;
}
/**
* 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<number>;
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<number>;
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);
}
}