mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:40:44 +00:00
Nations can overwhelm SAMs now 💥 (+ 3 little nation improvements) (#3246)
## Description: ### SAM Overwhelming (`NationNukeBehavior.ts`) On Impossible difficulty, nations can now destroy enemy SAMs by overwhelming them with coordinated atom bomb salvos. When no good nuke target is found (all trajectories intercepted by SAMs), the nations will: - Identify the easiest enemy SAM to destroy (lowest level first) - Calculate the total interception capacity of all covering SAMs and send enough bombs to overwhelm them (+1 extra per 5 needed to account for enemy building more SAMs during flight) - Plan launches in NukeExecution's Manhattan-distance silo order, tracking which silos have interceptable trajectories (wasted bombs) - Use a sliding window over parabolic flight times to find the best cluster of bombs that can arrive within half the SAM cooldown window - Compute per-bomb wait ticks to synchronize arrivals from silos at different distances - Skip launching if a salvo is already in flight - Upgrade the best SAM-protected silo when silo capacity is insufficient; wait and save gold when only gold is lacking https://github.com/user-attachments/assets/14fa592f-2902-4604-8e37-1eba2b2f0b85 ### 2-Player Endgame Handling (`NationNukeBehavior.ts`) - On Hard/Impossible with only 2 players remaining, `findBestNukeTarget()` directly targets the other player (bypasses all priority logic) - `getPerceivedNukeCost()` returns actual cost (no MIRV saving inflation) when only 2 players are left ### SAM Build Rate (`NationStructureBehavior.ts`) - Reduced SAM perceived cost increase per owned from 1.0 to 0.5, so nations build more SAMs ### Island Attack Variety (`AiAttackBehavior.ts`) - `findNearestIslandEnemy()` now collects up to 2 reachable candidates and has a 33% chance to pick the second-nearest, adding variety to boat attack targeting ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin
This commit is contained in:
@@ -15,6 +15,7 @@ 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";
|
||||
@@ -137,12 +138,29 @@ export class NationNukeBehavior {
|
||||
bestValue = value;
|
||||
}
|
||||
}
|
||||
if (bestTile !== null) {
|
||||
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) {
|
||||
@@ -349,6 +367,11 @@ export class NationNukeBehavior {
|
||||
|
||||
// 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);
|
||||
@@ -455,6 +478,7 @@ export class NationNukeBehavior {
|
||||
private isTrajectoryInterceptableBySam(
|
||||
spawnTile: TileRef,
|
||||
targetTile: TileRef,
|
||||
excludedSamIds?: Set<number>,
|
||||
): boolean {
|
||||
const speed = this.game.config().defaultNukeSpeed();
|
||||
const pathFinder = UniversalPathFinding.Parabola(this.game, {
|
||||
@@ -520,6 +544,10 @@ export class NationNukeBehavior {
|
||||
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;
|
||||
@@ -654,6 +682,7 @@ export class NationNukeBehavior {
|
||||
tile: TileRef,
|
||||
nukeType: UnitType.AtomBomb | UnitType.HydrogenBomb,
|
||||
targetPlayer: Player,
|
||||
waitTicks = 0,
|
||||
) {
|
||||
const tick = this.game.ticks();
|
||||
this.recentlySentNukes.push([tick, tile, nukeType]);
|
||||
@@ -667,10 +696,300 @@ export class NationNukeBehavior {
|
||||
this.hydrogenBombPerceivedCost =
|
||||
(this.hydrogenBombPerceivedCost * 125n) / 100n;
|
||||
}
|
||||
this.game.addExecution(new NukeExecution(nukeType, this.player, tile));
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ function getStructureRatios(
|
||||
},
|
||||
[UnitType.SAMLauncher]: {
|
||||
ratioPerCity: SAM_RATIO_BY_DIFFICULTY[difficulty],
|
||||
perceivedCostIncreasePerOwned: 1,
|
||||
perceivedCostIncreasePerOwned: 0.5,
|
||||
},
|
||||
[UnitType.MissileSilo]: {
|
||||
ratioPerCity: 0.2,
|
||||
|
||||
@@ -608,7 +608,8 @@ export class AiAttackBehavior {
|
||||
})
|
||||
.sort((a, b) => a.distance - b.distance); // Sort by distance (ascending)
|
||||
|
||||
// Try players in order of distance until we find one reachable by boat
|
||||
// Try players in order of distance until we find reachable candidates
|
||||
const reachablePlayers: Player[] = [];
|
||||
for (const entry of sortedPlayers) {
|
||||
const closest = closestTwoTiles(
|
||||
this.game,
|
||||
@@ -622,11 +623,20 @@ export class AiAttackBehavior {
|
||||
if (closest === null) continue;
|
||||
|
||||
if (canBuildTransportShip(this.game, this.player, closest.y)) {
|
||||
return entry.player;
|
||||
reachablePlayers.push(entry.player);
|
||||
// We only need up to 2 reachable candidates
|
||||
if (reachablePlayers.length >= 2) break;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
if (reachablePlayers.length === 0) return null;
|
||||
|
||||
// 33% chance to pick the second-nearest player if available
|
||||
if (reachablePlayers.length >= 2 && this.random.chance(3)) {
|
||||
return reachablePlayers[1];
|
||||
}
|
||||
|
||||
return reachablePlayers[0];
|
||||
}
|
||||
|
||||
private getPlayerCenter(player: Player) {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { MissileSiloExecution } from "../src/core/execution/MissileSiloExecution";
|
||||
import { NationExecution } from "../src/core/execution/NationExecution";
|
||||
import { SAMLauncherExecution } from "../src/core/execution/SAMLauncherExecution";
|
||||
import {
|
||||
Cell,
|
||||
Difficulty,
|
||||
Nation,
|
||||
PlayerInfo,
|
||||
PlayerType,
|
||||
UnitType,
|
||||
} from "../src/core/game/Game";
|
||||
import { setup } from "./util/Setup";
|
||||
import { executeTicks } from "./util/utils";
|
||||
|
||||
describe("NationNukeBehavior - maybeDestroyEnemySam", () => {
|
||||
test("nation overwhelms enemy SAM with atom bomb salvo on Impossible difficulty", async () => {
|
||||
// Impossible difficulty with 2 players forces findBestNukeTarget to
|
||||
// return the human. The SAM covers all human territory so every nuke
|
||||
// trajectory is interceptable, keeping bestValue ≤ 0 and triggering
|
||||
// maybeDestroyEnemySam.
|
||||
const game = await setup("big_plains", {
|
||||
difficulty: Difficulty.Impossible,
|
||||
infiniteGold: true,
|
||||
instantBuild: true,
|
||||
});
|
||||
|
||||
const nationInfo = new PlayerInfo(
|
||||
"nation",
|
||||
PlayerType.Nation,
|
||||
null,
|
||||
"nation_id",
|
||||
);
|
||||
const humanInfo = new PlayerInfo(
|
||||
"human",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"human_id",
|
||||
);
|
||||
|
||||
game.addPlayer(nationInfo);
|
||||
game.addPlayer(humanInfo);
|
||||
|
||||
while (game.inSpawnPhase()) {
|
||||
game.executeNextTick();
|
||||
}
|
||||
|
||||
const nation = game.player("nation_id");
|
||||
const human = game.player("human_id");
|
||||
|
||||
// Assign territory blocks (30×30 each, well separated)
|
||||
for (let x = 10; x < 40; x++) {
|
||||
for (let y = 10; y < 40; y++) {
|
||||
const tile = game.ref(x, y);
|
||||
if (game.map().isLand(tile)) nation.conquer(tile);
|
||||
}
|
||||
}
|
||||
for (let x = 60; x < 90; x++) {
|
||||
for (let y = 60; y < 90; y++) {
|
||||
const tile = game.ref(x, y);
|
||||
if (game.map().isLand(tile)) human.conquer(tile);
|
||||
}
|
||||
}
|
||||
|
||||
// Level-1 SAM at center of human territory (samRange = 20 in TestConfig,
|
||||
// covering the entire 60-90 block and intercepting all trajectories).
|
||||
const samTile = game.ref(75, 75);
|
||||
const sam = human.buildUnit(UnitType.SAMLauncher, samTile, {});
|
||||
game.addExecution(new SAMLauncherExecution(human, null, sam));
|
||||
|
||||
// 3 level-1 missile silos (1 slot each). Overwhelming a level-1 SAM
|
||||
// requires 2 bombs (1 intercepted + 1 passes through).
|
||||
for (const [x, y] of [
|
||||
[20, 20],
|
||||
[25, 25],
|
||||
[30, 30],
|
||||
] as const) {
|
||||
const silo = nation.buildUnit(UnitType.MissileSilo, game.ref(x, y), {});
|
||||
game.addExecution(new MissileSiloExecution(silo));
|
||||
}
|
||||
|
||||
// infiniteGold only applies to Human players, so the nation needs gold
|
||||
nation.addGold(1_000_000_000n);
|
||||
nation.addTroops(100_000);
|
||||
human.addTroops(100_000);
|
||||
|
||||
expect(nation.units(UnitType.MissileSilo)).toHaveLength(3);
|
||||
expect(human.units(UnitType.SAMLauncher)).toHaveLength(1);
|
||||
expect(nation.units(UnitType.AtomBomb)).toHaveLength(0);
|
||||
|
||||
// Try multiple game IDs to account for random attack-tick alignment
|
||||
// (attackRate ∈ [30,50] on Impossible). 150 inner ticks guarantees ≥2
|
||||
// attack ticks for the worst-case seed: 1st initializes behaviors, 2nd
|
||||
// fires maybeSendNuke → maybeDestroyEnemySam.
|
||||
const testNation = new Nation(new Cell(25, 25), nation.info());
|
||||
let salvoLaunched = false;
|
||||
|
||||
for (let i = 0; i < 10 && !salvoLaunched; i++) {
|
||||
// Let any executions from a prior iteration settle
|
||||
if (i > 0) executeTicks(game, 50);
|
||||
|
||||
const exec = new NationExecution(`game_${i}`, testNation);
|
||||
exec.init(game);
|
||||
|
||||
for (let tick = 0; tick < 150; tick++) {
|
||||
exec.tick(tick);
|
||||
// Advance the game sparingly so NukeExecution creates atom-bomb units
|
||||
// but they don't complete their flight before we detect them.
|
||||
if (tick % 10 === 0) game.executeNextTick();
|
||||
|
||||
if (nation.units(UnitType.AtomBomb).length > 0) {
|
||||
salvoLaunched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(salvoLaunched).toBe(true);
|
||||
|
||||
// At least 2 atom bombs to overwhelm the level-1 SAM
|
||||
const atomBombs = nation.units(UnitType.AtomBomb);
|
||||
expect(atomBombs.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// All bombs should target the SAM tile
|
||||
for (const bomb of atomBombs) {
|
||||
expect(bomb.targetTile()).toBe(samTile);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user