Files
OpenFrontIO/tests/AiAttackBehaviorNukedTerritory.test.ts
T
FloPinguin 8742940194 Fix nations always attacking nuked territory instead of waiting for the correct strategy 🤖 (#4422)
Nations always rushed nuked (fallout) TerraNullius instead of
retaliating or attacking enemies. The bug needed two commits to compose:

**#3786** introduced `PlayerImpl.nearby()` (renamed from `neighbors()`)
and wired it into the early expansion gate in
`AiAttackBehavior.maybeAttack()` via a second disjunct:

```ts
const hasNonNukedTerraNullius =
  border.some((t) => !hasOwner(t) && !hasFallout(t)) ||  // already filtered
  playerNeighbors.some((n) => !n.isPlayer());             // via nearby()
```

The first disjunct correctly excludes fallout, but the second one went
through `nearby()`, whose direct-neighbor loop never filtered fallout
(unlike the `shoreReachableNeighbors()` sibling introduced in the same
commit). So a nation bordering directly-adjacent nuked TN reported it as
plain TerraNullius and set the gate true. The bug stayed **dormant**
because #3786 also introduced `hasLandBorderWithTerraNullius()` *with* a
fallout filter, so `sendAttack(terraNullius())` still rejected nuked TN
and the early `return` never fired.

**#3814** removed the fallout filter from
`hasLandBorderWithTerraNullius()` so the `nuked` strategy could capture
fallout tiles. That unblocked the land path of `sendAttack`: now the
early gate fired on nuked-only borders *and* `sendAttack` succeeded,
pre-empting every attack strategy (retaliate, bots, assist, ...) on
every difficulty.

Fix: filter nuked (fallout) unowned tiles in `nearby()`'s
direct-neighbor loop, making it consistent with
`shoreReachableNeighbors()`. The early gate now only fires for non-nuked
TerraNullius, and the `nuked` strategy still fires (and captures
territory) when the nation has nothing better to do, preserving the
behaviour #3814 intended.

Added `tests/AiAttackBehaviorNukedTerritory.test.ts` covering:

- `nearby()` excludes directly-adjacent nuked TerraNullius
- `maybeAttack` retaliates against an incoming attacker instead of nuked
TN
- the early gate is bypassed when only nuked TN borders the nation
- the `nuked` strategy still captures tiles when the nation is idle
(Impossible and Easy difficulties)
- `isUnitDisabled(MissileSilo)` short-circuits the `nuked` strategy

- [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

regression is found:

FloPinguin
2026-06-27 08:58:30 -07:00

338 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { AttackExecution } from "../src/core/execution/AttackExecution";
import { NationAllianceBehavior } from "../src/core/execution/nation/NationAllianceBehavior";
import { NationEmojiBehavior } from "../src/core/execution/nation/NationEmojiBehavior";
import { AiAttackBehavior } from "../src/core/execution/utils/AiAttackBehavior";
import {
Difficulty,
Game,
Player,
PlayerInfo,
PlayerType,
UnitType,
} from "../src/core/game/Game";
import { PseudoRandom } from "../src/core/PseudoRandom";
import { setup } from "./util/Setup";
import { executeTicks } from "./util/utils";
// ─── Helpers ────────────────────────────────────────────────────────────────
/** Conquer a rectangular region of land tiles for `player`. Skips water. */
function conquerRect(
game: Game,
player: Player,
x0: number,
y0: number,
x1: number, // exclusive
y1: number, // exclusive
) {
for (let x = x0; x < x1; x++) {
for (let y = y0; y < y1; y++) {
const tile = game.ref(x, y);
if (game.map().isLand(tile)) player.conquer(tile);
}
}
}
/**
* Mark a rectangular region of unowned land as nuked (fallout).
* `setFallout` throws on owned tiles, so already-conquered tiles are
* naturally skipped.
*/
function nukeRect(game: Game, x0: number, y0: number, x1: number, y1: number) {
for (let x = x0; x < x1; x++) {
for (let y = y0; y < y1; y++) {
const tile = game.ref(x, y);
if (game.map().isLand(tile) && !game.hasOwner(tile)) {
game.setFallout(tile, true);
}
}
}
}
interface BehaviorEnv {
game: Game;
nation: Player;
enemy: Player;
attackBehavior: AiAttackBehavior;
}
/**
* Build a nation surrounded by nuked TerraNullius, optionally with an enemy
* sharing a land border on the west.
*
* big_plains (200×200, all land).
*
* ┌────────────────────────────────────┐
* │ NUKED TN (ring) │
* │ ┌────────┐ ┌───────┐ ┌──────────┐ │
* │ │ ENEMY │ │ NATION │ │ NUKED TN │ │
* │ │40..60 │ │60..80 │ │ 80..120 │ │
* │ └────────┘ └───────┘ └──────────┘ │
* │ NUKED TN (ring) │
* └────────────────────────────────────┘
* x=40 x=60 x=80 x=120 (y from 40..100)
*
* - Nation: x∈[60,80), y∈[60,80)
* - Enemy: x∈[40,60), y∈[60,80) → shares a land border with the nation
* - Nuked TN ring: every other unowned tile in x∈[40,120), y∈[40,100)
*
* Layout invariants (asserted below):
* - With `withNuke`: every exposed nation border is nuked TN (no non-nuked TN).
* - With `withEnemy`: the nation's ONLY non-nuked border is the enemy.
*/
async function setupBehavior(
difficulty: Difficulty = Difficulty.Impossible,
opts: {
withEnemy?: boolean;
withNuke?: boolean;
nationTroops?: number;
enemyTroops?: number;
disabledUnits?: UnitType[];
} = {},
): Promise<BehaviorEnv> {
const withEnemy = opts.withEnemy ?? true;
const withNuke = opts.withNuke ?? true;
const nationTroops = opts.nationTroops ?? 5_000_000;
const enemyTroops = opts.enemyTroops ?? 50_000;
const game = await setup(
"big_plains",
{
difficulty,
infiniteGold: true,
instantBuild: true,
infiniteTroops: true,
...(opts.disabledUnits ? { disabledUnits: opts.disabledUnits } : {}),
},
[
new PlayerInfo("nation", PlayerType.Nation, null, "nation_id"),
new PlayerInfo("enemy", PlayerType.Human, null, "enemy_id"),
],
);
const nation = game.player("nation_id");
const enemy = game.player("enemy_id");
conquerRect(game, nation, 60, 60, 80, 80);
if (withEnemy) conquerRect(game, enemy, 40, 60, 60, 80);
if (withNuke) nukeRect(game, 40, 40, 120, 100);
nation.addTroops(nationTroops);
enemy.addTroops(enemyTroops);
// Layout invariants.
expect(nation.tiles().size).toBeGreaterThan(0);
if (withNuke) {
const bordersNuked = Array.from(nation.borderTiles()).some((t) =>
game
.neighbors(t)
.some((n) => game.isLand(n) && !game.hasOwner(n) && game.hasFallout(n)),
);
expect(bordersNuked).toBe(true);
// No non-nuked TN borders the nation (its only non-nuked neighbour is
// the enemy, when present).
const bordersNonNukedTN = Array.from(nation.borderTiles()).some((t) =>
game
.neighbors(t)
.some(
(n) => game.isLand(n) && !game.hasOwner(n) && !game.hasFallout(n),
),
);
expect(bordersNonNukedTN).toBe(false);
}
if (withEnemy) {
expect(nation.sharesBorderWith(enemy)).toBe(true);
}
const emojiBehavior = new NationEmojiBehavior(
new PseudoRandom(42),
game,
nation,
);
const allianceBehavior = new NationAllianceBehavior(
new PseudoRandom(42),
game,
nation,
emojiBehavior,
);
const attackBehavior = new AiAttackBehavior(
new PseudoRandom(42),
game,
nation,
0.0, // triggerRatio — always ready so strategy selection is deterministic
0.0, // reserveRatio
0.0, // expandRatio
allianceBehavior,
emojiBehavior,
);
return { game, nation, enemy, attackBehavior };
}
/** Count new outgoing attacks created since `before`. */
function newAttacks(player: Player, before: number) {
return player.outgoingAttacks().slice(before);
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("AiAttackBehavior - nuked territory early-out", () => {
// The bug: `maybeAttack()` has an early expansion gate
// hasNonNukedTerraNullius =
// border.some((t) => !hasOwner(t) && !hasFallout(t)) ||
// playerNeighbors.some((n) => !n.isPlayer());
// The second disjunct uses `nearby()`, whose direct-neighbor loop did NOT
// filter fallout — so a nation bordering *nuked* TerraNullius reported it
// as a plain TerraNullius neighbour, making the gate fire and dispatch
// `sendAttack(terraNullius())` *before* any attack strategy
// (retaliate/bots/...) could run. The fix: `nearby()`'s direct-neighbor
// loop now skips nuked (fallout) unowned tiles, matching
// `shoreReachableNeighbors()`.
describe("regression: early gate no longer fires on nuked-only borders", () => {
test("nearby() excludes directly-adjacent nuked TerraNullius", async () => {
const { game, nation } = await setupBehavior(Difficulty.Impossible, {
withEnemy: false,
});
// Sanity: the nation really borders nuked land.
const bordersNuked = Array.from(nation.borderTiles()).some((t) =>
game
.neighbors(t)
.some(
(n) => game.isLand(n) && !game.hasOwner(n) && game.hasFallout(n),
),
);
expect(bordersNuked).toBe(true);
// nearby() must NOT report TerraNullius (it's all nuked), and with no
// enemy there are no player neighbours either.
const nearby = nation.nearby();
expect(nearby.some((n) => !n.isPlayer())).toBe(false);
expect(nearby.filter((n) => n.isPlayer())).toHaveLength(0);
});
test("maybeAttack does NOT pre-empt retaliation with a nuked-TN attack", async () => {
// Nation borders nuked TN (east/north/south) and an enemy (west). The
// enemy attacks the nation. On Impossible `retaliate` is the first
// strategy, but with the bug the early gate fires first and attacks
// TerraNullius, so retaliation never runs.
//
// The nation has far more troops than the enemy so `retaliate`'s
// attack is not rejected as "too weak".
const { game, nation, enemy, attackBehavior } = await setupBehavior(
Difficulty.Impossible,
{ withEnemy: true, nationTroops: 5_000_000, enemyTroops: 50_000 },
);
// Enemy launches an attack on the nation.
game.addExecution(new AttackExecution(100_000, enemy, nation.id()));
executeTicks(game, 1);
expect(nation.incomingAttacks().length).toBeGreaterThan(0);
const before = nation.outgoingAttacks().length;
attackBehavior.maybeAttack();
executeTicks(game, 1);
const attacks = newAttacks(nation, before);
expect(attacks.length).toBeGreaterThan(0);
// Every new attack must target the enemy (retaliation), NOT
// TerraNullius (the nuked territory).
for (const attack of attacks) {
expect(attack.target()).toBe(enemy);
}
});
test("maybeAttack early gate is bypassed when only nuked TN borders the nation", async () => {
// No enemy, no incoming attack. The early gate must NOT fire (there is
// no non-nuked TN). `attackBestTarget` falls through to the `nuked`
// strategy, which dispatches a land attack on TerraNullius — the
// intended behaviour from commit 58ec8b280.
const { game, nation, attackBehavior } = await setupBehavior(
Difficulty.Impossible,
{ withEnemy: false },
);
expect(nation.incomingAttacks()).toHaveLength(0);
const before = nation.outgoingAttacks().length;
attackBehavior.maybeAttack();
executeTicks(game, 1);
const attacks = newAttacks(nation, before);
expect(attacks.length).toBeGreaterThan(0);
for (const attack of attacks) {
expect(attack.target().isPlayer()).toBe(false);
}
});
});
describe("intended: nations still capture nuked territory when idle", () => {
test("`nuked` strategy captures tiles when the nation has nothing better to do", async () => {
const { game, nation, attackBehavior } = await setupBehavior(
Difficulty.Impossible,
{ withEnemy: false },
);
const before = nation.outgoingAttacks().length;
attackBehavior.maybeAttack();
executeTicks(game, 1);
const attacks = newAttacks(nation, before);
expect(attacks.length).toBeGreaterThan(0);
for (const attack of attacks) {
expect(attack.target().isPlayer()).toBe(false);
}
// Let the AttackExecution make progress. The nation should conquer at
// least one previously-nuked tile east of its territory (x >= 80).
executeTicks(game, 60);
const conqueredEast = Array.from(nation.tiles()).filter((t) => {
return game.x(t) >= 80 && game.y(t) >= 60 && game.y(t) < 100;
}).length;
expect(conqueredEast).toBeGreaterThan(0);
});
test("Easy difficulty: `nuked` strategy still fires when idle", async () => {
const { game, nation, attackBehavior } = await setupBehavior(
Difficulty.Easy,
{ withEnemy: false },
);
const before = nation.outgoingAttacks().length;
attackBehavior.maybeAttack();
executeTicks(game, 1);
const attacks = newAttacks(nation, before);
// On Easy the `nuked` strategy is first, so it dispatches a TN attack.
expect(attacks.length).toBeGreaterThan(0);
for (const attack of attacks) {
expect(attack.target().isPlayer()).toBe(false);
}
});
});
describe("MissileSilo disabled disables the `nuked` strategy", () => {
test("isUnitDisabled(MissileSilo) short-circuits isBorderingNukedTerritory", async () => {
// `isBorderingNukedTerritory` returns false when MissileSilo is
// disabled, so even with nuked TN on the border the `nuked` strategy
// does NOT fire and no attack is created.
const { game, nation, attackBehavior } = await setupBehavior(
Difficulty.Impossible,
{
withEnemy: false,
disabledUnits: [UnitType.MissileSilo],
},
);
const before = nation.outgoingAttacks().length;
attackBehavior.maybeAttack();
executeTicks(game, 1);
const attacks = newAttacks(nation, before);
expect(attacks).toHaveLength(0);
});
});
});