mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-30 01:32:12 +00:00
f09d9a3a5f
## 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
997 lines
32 KiB
TypeScript
997 lines
32 KiB
TypeScript
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);
|
||
}
|
||
}
|