mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-05 13:52:10 +00:00
9e9c608053
## 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>
1143 lines
37 KiB
TypeScript
1143 lines
37 KiB
TypeScript
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);
|
||
}
|
||
}
|