mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-01 05:43:25 +00:00
8742940194
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
338 lines
12 KiB
TypeScript
338 lines
12 KiB
TypeScript
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);
|
||
});
|
||
});
|
||
});
|