Nations: Fix city farming + reactive defense posts + fix nuked territory capture 🛡️ (#3814)

## Description:

Would be very good to get these fixes last minute into v31.

- **Farming nations for cities is fixed**: Here you can see city farming
in action, vari farms Finland:
https://www.youtube.com/watch?v=J4J0ajlnSHs&t=137s - Lots of 125k gold
cities... Now nations will build defense posts instead of cities:

- **Nations now build defense posts reactively** instead of through the
normal structure build order. When a nation is under significant land
attack (incoming/own troops >= 35%), it places a defense post near the
attack front -- skipped on Easy, capped at 1 on Medium, and scaled by
the incoming troop ratio on Hard/Impossible. Posts spread along the
front. The old `defensePostValue()` placement path is removed.

- **Nations now capture nuked territory.**
`hasLandBorderWithTerraNullius()` was incorrectly filtering out tiles
with fallout, causing the `nuked` attack strategy to never dispatch a
attack. Big bug

.

- [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:
FloPinguin
2026-05-03 17:33:01 +02:00
committed by GitHub
parent 213ddd36c9
commit 58ec8b280f
3 changed files with 777 additions and 100 deletions
+537
View File
@@ -1,4 +1,5 @@
import { vi } from "vitest";
import { ConstructionExecution } from "../src/core/execution/ConstructionExecution";
import { NationStructureBehavior } from "../src/core/execution/nation/NationStructureBehavior";
import { Difficulty, PlayerType } from "../src/core/game/Game";
import { Cluster } from "../src/core/game/TrainStation";
@@ -311,6 +312,542 @@ describe("NationStructureBehavior.buildReachableStations", () => {
});
});
// ── tryBuildDefensePost — early-exit guards ──────────────────────────────────
describe("NationStructureBehavior.tryBuildDefensePost", () => {
function makeLandAttack(troops: number, attackerId: string = "a"): any {
return {
troops: () => troops,
sourceTile: () => null,
clusteredPositions: () => [],
attacker: () => ({ id: () => attackerId }),
};
}
function makeBoatAttack(troops: number): any {
return {
troops: () => troops,
sourceTile: () => 999, // non-null → boat
clusteredPositions: () => [],
attacker: () => ({ id: () => "boat" }),
};
}
function makeMinimalGame(difficulty: Difficulty): any {
return {
config: () => ({
gameConfig: () => ({ difficulty }),
isUnitDisabled: () => false,
nukeMagnitudes: () => ({ outer: 50 }),
}),
unitInfo: () => ({ cost: () => 0n }),
euclideanDistSquared: () => Number.MAX_VALUE,
};
}
function makeMinimalPlayer(troops: number, attacks: any[]): any {
return {
troops: () => troops,
incomingAttacks: () => attacks,
gold: () => 1_000_000n,
units: () => [],
};
}
function callTryBuild(
difficulty: Difficulty,
troops: number,
attacks: any[],
): boolean {
const game = makeMinimalGame(difficulty);
const player = makeMinimalPlayer(troops, attacks);
const behavior = makeBehavior(game, player);
(behavior as any).placementsCount = 1;
return (behavior as any).tryBuildDefensePost();
}
it("returns false on Easy regardless of ratio", () => {
expect(callTryBuild(Difficulty.Easy, 100, [makeLandAttack(5000)])).toBe(
false,
);
});
it("returns false when there are no incoming attacks", () => {
expect(callTryBuild(Difficulty.Hard, 1000, [])).toBe(false);
});
it("returns false when only boat attacks are incoming", () => {
expect(callTryBuild(Difficulty.Hard, 100, [makeBoatAttack(5000)])).toBe(
false,
);
});
it("returns false when land-attack ratio is below 0.35", () => {
expect(callTryBuild(Difficulty.Hard, 1000, [makeLandAttack(349)])).toBe(
false,
);
});
it("returns false when own troops are zero", () => {
expect(callTryBuild(Difficulty.Hard, 0, [makeLandAttack(500)])).toBe(false);
});
// ── Medium 50% gate ──────────────────────────────────────────────────────
it("Medium: returns false when random.chance(2) fails (50% gate closed)", () => {
const game = makeMinimalGame(Difficulty.Medium);
const player = makeMinimalPlayer(1000, [makeLandAttack(1000)]);
const random = new PseudoRandom(0);
vi.spyOn(random, "chance").mockReturnValue(false);
const behavior = makeBehavior(game, player, random);
(behavior as any).placementsCount = 1;
expect((behavior as any).tryBuildDefensePost()).toBe(false);
});
it("Medium: 50% gate consumes only the chance(2) call", () => {
const game = makeMinimalGame(Difficulty.Medium);
const player = makeMinimalPlayer(1000, [makeLandAttack(1000)]);
const random = new PseudoRandom(0);
const chanceSpy = vi.spyOn(random, "chance").mockReturnValue(false);
const behavior = makeBehavior(game, player, random);
(behavior as any).placementsCount = 1;
(behavior as any).tryBuildDefensePost();
expect(chanceSpy).toHaveBeenCalledWith(2);
});
it("Hard: skips chance gate (no chance(2) consumed)", () => {
const game = makeMinimalGame(Difficulty.Hard);
const player = {
...makeMinimalPlayer(1000, [makeLandAttack(1000)]),
borderTiles: () => [],
canBuild: () => false,
};
const random = new PseudoRandom(0);
const chanceSpy = vi.spyOn(random, "chance");
const behavior = makeBehavior(game, player, random);
(behavior as any).placementsCount = 1;
(behavior as any).tryBuildDefensePost();
expect(chanceSpy).not.toHaveBeenCalledWith(2);
});
// ── Cap enforcement ──────────────────────────────────────────────────────
it("Hard: returns false once countDefensePostsNearFront reaches the allowed cap", () => {
const game = makeMinimalGame(Difficulty.Hard);
// ratio = 1.0 → ceil(1.0 / 0.4) = 3 allowed
const player = makeMinimalPlayer(1000, [makeLandAttack(1000)]);
const behavior = makeBehavior(game, player);
(behavior as any).placementsCount = 1;
vi.spyOn(behavior as any, "getAttackFrontTiles").mockReturnValue([1]);
vi.spyOn(behavior as any, "countDefensePostsNearFront").mockReturnValue(3);
expect((behavior as any).tryBuildDefensePost()).toBe(false);
});
it("Hard: returns false once countDefensePostsNearFront exceeds the allowed cap", () => {
const game = makeMinimalGame(Difficulty.Hard);
// ratio = 0.4 → ceil(0.4 / 0.4) = 1 allowed
const player = makeMinimalPlayer(1000, [makeLandAttack(400)]);
const behavior = makeBehavior(game, player);
(behavior as any).placementsCount = 1;
vi.spyOn(behavior as any, "getAttackFrontTiles").mockReturnValue([1]);
vi.spyOn(behavior as any, "countDefensePostsNearFront").mockReturnValue(1);
expect((behavior as any).tryBuildDefensePost()).toBe(false);
});
// ── Successful build path ────────────────────────────────────────────────
it("Hard: dispatches a ConstructionExecution for DefensePost on successful build", () => {
const addExecution = vi.fn();
const game = {
...makeMinimalGame(Difficulty.Hard),
addExecution,
};
const canBuild = vi.fn(() => true);
const player = {
...makeMinimalPlayer(1000, [makeLandAttack(1000)]),
gold: () => 1_000_000n,
canBuild,
};
const behavior = makeBehavior(game, player);
(behavior as any).placementsCount = 1;
vi.spyOn(behavior as any, "getAttackFrontTiles").mockReturnValue([1]);
vi.spyOn(behavior as any, "countDefensePostsNearFront").mockReturnValue(0);
vi.spyOn(behavior as any, "sampleTilesNearFront").mockReturnValue([42]);
expect((behavior as any).tryBuildDefensePost()).toBe(true);
expect(addExecution).toHaveBeenCalledTimes(1);
const exec = addExecution.mock.calls[0][0];
expect(exec).toBeInstanceOf(ConstructionExecution);
});
it("returns false when player.gold() is below cost", () => {
const game = {
...makeMinimalGame(Difficulty.Hard),
// cost > 0 so gold check fails
unitInfo: () => ({ cost: () => 1_000_000n }),
};
const player = {
...makeMinimalPlayer(1000, [makeLandAttack(1000)]),
gold: () => 0n,
};
const behavior = makeBehavior(game, player);
(behavior as any).placementsCount = 1;
vi.spyOn(behavior as any, "getAttackFrontTiles").mockReturnValue([1]);
vi.spyOn(behavior as any, "countDefensePostsNearFront").mockReturnValue(0);
expect((behavior as any).tryBuildDefensePost()).toBe(false);
});
it("returns false when no sampled tile passes canBuild", () => {
const addExecution = vi.fn();
const game = {
...makeMinimalGame(Difficulty.Hard),
addExecution,
};
const player = {
...makeMinimalPlayer(1000, [makeLandAttack(1000)]),
gold: () => 1_000_000n,
canBuild: () => false,
};
const behavior = makeBehavior(game, player);
(behavior as any).placementsCount = 1;
vi.spyOn(behavior as any, "getAttackFrontTiles").mockReturnValue([1]);
vi.spyOn(behavior as any, "countDefensePostsNearFront").mockReturnValue(0);
vi.spyOn(behavior as any, "sampleTilesNearFront").mockReturnValue([42, 43]);
expect((behavior as any).tryBuildDefensePost()).toBe(false);
expect(addExecution).not.toHaveBeenCalled();
});
});
// ── defensePostNeeded ────────────────────────────────────────────────────────
describe("NationStructureBehavior.defensePostNeeded", () => {
function makeAttack(troops: number, sourceTile: number | null = null): any {
return {
troops: () => troops,
sourceTile: () => sourceTile,
attacker: () => ({ id: () => "a" }),
};
}
function makeGame(difficulty: Difficulty): any {
return {
config: () => ({ gameConfig: () => ({ difficulty }) }),
};
}
function makePlayer(troops: number, attacks: any[]): any {
return {
troops: () => troops,
incomingAttacks: () => attacks,
};
}
function call(
difficulty: Difficulty,
troops: number,
attacks: any[],
): boolean {
const behavior = makeBehavior(
makeGame(difficulty),
makePlayer(troops, attacks),
);
return (behavior as any).defensePostNeeded();
}
it("returns false on Easy", () => {
expect(call(Difficulty.Easy, 1000, [makeAttack(1000)])).toBe(false);
});
it("returns false when there are no incoming attacks", () => {
expect(call(Difficulty.Hard, 1000, [])).toBe(false);
});
it("returns false when own troops are zero", () => {
expect(call(Difficulty.Hard, 0, [makeAttack(1000)])).toBe(false);
});
it("returns false when ratio is below threshold (0.35)", () => {
expect(call(Difficulty.Hard, 1000, [makeAttack(349)])).toBe(false);
});
it("returns true when ratio is exactly at threshold (0.35)", () => {
expect(call(Difficulty.Hard, 1000, [makeAttack(350)])).toBe(true);
});
it("returns true when ratio is above threshold", () => {
expect(call(Difficulty.Medium, 1000, [makeAttack(700)])).toBe(true);
});
it("ignores boat attacks (sourceTile != null)", () => {
expect(call(Difficulty.Hard, 1000, [makeAttack(5000, 999)])).toBe(false);
});
it("sums troops across multiple land attacks for the ratio", () => {
expect(
call(Difficulty.Hard, 1000, [makeAttack(200), makeAttack(200)]),
).toBe(true);
});
});
// ── sampleTilesNearFront ─────────────────────────────────────────────────────
describe("NationStructureBehavior.sampleTilesNearFront", () => {
// The non-empty path uses random.randElement / random.nextInt, closestTile
// (manhattanDist), and several game/player accessors. We test the empty
// short-circuit and the canBuild filter via a controlled mock environment.
it("returns [] when no front tiles are supplied", () => {
const behavior = makeBehavior({} as any, { units: () => [] } as any);
expect(
(behavior as any).sampleTilesNearFront([], 25, 0 /* DefensePost */),
).toEqual([]);
});
it("respects the requested sample size cap", () => {
// Build an environment that always produces a valid candidate so the loop
// collects exactly `count` tiles before stopping. Each iteration produces
// a distinct ref so we verify the cap, not deduplication.
const player: any = {
borderTiles: () => [0],
units: () => [],
canBuild: () => true,
};
let nextRef = 1;
const game: any = {
config: () => ({
nukeMagnitudes: () => ({ outer: 50 }),
}),
x: (t: number) => t,
y: () => 0,
isValidCoord: () => true,
ref: () => nextRef++, // unique ref per call
owner: () => player,
manhattanDist: () => 50, // within [0.75×50, 1.5×50] = [38, 75]
};
const random = new PseudoRandom(0);
vi.spyOn(random, "randElement").mockImplementation((arr: any[]) => arr[0]);
vi.spyOn(random, "nextInt").mockReturnValue(0);
const behavior = makeBehavior(game, player, random);
const tiles = (behavior as any).sampleTilesNearFront(
[0],
3,
0 /* DefensePost */,
);
expect(tiles.length).toBe(3);
expect(new Set(tiles).size).toBe(3); // all distinct
});
it("filters out tiles where canBuild returns false (phase 1 rejects all → falls through to fallback)", () => {
const canBuild = vi.fn((_unitType: any, _tile: any) => false);
const player: any = {
borderTiles: () => [0],
units: () => [],
canBuild,
};
const game: any = {
config: () => ({ nukeMagnitudes: () => ({ outer: 50 }) }),
x: (t: number) => t,
y: () => 0,
isValidCoord: () => true,
ref: (x: number) => x,
owner: () => player,
manhattanDist: () => 50,
};
const random = new PseudoRandom(0);
vi.spyOn(random, "randElement").mockImplementation((arr: any[]) => arr[0]);
vi.spyOn(random, "nextInt").mockReturnValue(0);
const behavior = makeBehavior(game, player, random);
(behavior as any).sampleTilesNearFront([0], 3, 0 /* DefensePost */);
// canBuild should have been queried for every tile sampled in phase 1
expect(canBuild).toHaveBeenCalled();
expect(canBuild.mock.calls[0][0]).toBe(0); // unitType arg passed through
});
it("calls canBuild with the supplied unitType", () => {
const canBuild = vi.fn((_unitType: any, _tile: any) => true);
const player: any = {
borderTiles: () => [0],
units: () => [],
canBuild,
};
const game: any = {
config: () => ({ nukeMagnitudes: () => ({ outer: 50 }) }),
x: (t: number) => t,
y: () => 0,
isValidCoord: () => true,
ref: (x: number) => x,
owner: () => player,
manhattanDist: () => 50,
};
const random = new PseudoRandom(0);
vi.spyOn(random, "randElement").mockImplementation((arr: any[]) => arr[0]);
vi.spyOn(random, "nextInt").mockReturnValue(0);
const behavior = makeBehavior(game, player, random);
const SENTINEL_UNIT_TYPE = 7 as any;
(behavior as any).sampleTilesNearFront([0], 1, SENTINEL_UNIT_TYPE);
expect(canBuild).toHaveBeenCalledWith(
SENTINEL_UNIT_TYPE,
expect.anything(),
);
});
});
// ── getAttackFrontTiles ──────────────────────────────────────────────────────
describe("NationStructureBehavior.getAttackFrontTiles", () => {
function makeGame(
neighborsFn: (tile: number) => number[],
ownerFn: (tile: number) => any,
): any {
return {
config: () => ({ nukeMagnitudes: () => ({ outer: 50 }) }),
neighbors: neighborsFn,
owner: ownerFn,
};
}
function makePlayer(borderTilesList: number[]): any {
return {
units: () => [],
borderTiles: () => borderTilesList,
};
}
function makeAttack(attacker: any): any {
return { attacker: () => attacker };
}
it("returns empty array for empty attack list", () => {
const game = makeGame(
() => [],
() => null,
);
const player = makePlayer([1, 2]);
const behavior = makeBehavior(game, player);
expect((behavior as any).getAttackFrontTiles([])).toEqual([]);
});
it("includes border tile whose neighbor is owned by an attacker", () => {
const attacker = { id: () => "atk" };
const game = makeGame(
(tile) => (tile === 10 ? [100] : []),
(tile) => (tile === 100 ? attacker : null),
);
const player = makePlayer([10, 20]);
const behavior = makeBehavior(game, player);
expect(
(behavior as any).getAttackFrontTiles([makeAttack(attacker)]),
).toEqual([10]);
});
it("excludes border tiles not adjacent to any attacker", () => {
const attacker = { id: () => "atk" };
const game = makeGame(
(tile) => (tile === 10 ? [100] : [200]),
(tile) => (tile === 100 ? attacker : null),
);
const player = makePlayer([10, 20]);
const behavior = makeBehavior(game, player);
const result = (behavior as any).getAttackFrontTiles([
makeAttack(attacker),
]);
expect(result).toContain(10);
expect(result).not.toContain(20);
});
it("handles multiple attackers from separate attacks", () => {
const atk1 = { id: () => "a1" };
const atk2 = { id: () => "a2" };
const game = makeGame(
(tile) => (tile === 10 ? [100] : tile === 20 ? [200] : []),
(tile) => (tile === 100 ? atk1 : tile === 200 ? atk2 : null),
);
const player = makePlayer([10, 20, 30]);
const behavior = makeBehavior(game, player);
const result = (behavior as any).getAttackFrontTiles([
makeAttack(atk1),
makeAttack(atk2),
]);
expect(result).toContain(10);
expect(result).toContain(20);
expect(result).not.toContain(30);
});
it("does not duplicate a border tile that has multiple attacker-owned neighbors", () => {
const attacker = { id: () => "atk" };
const game = makeGame(
(tile) => (tile === 10 ? [100, 101] : []),
(tile) => (tile === 100 || tile === 101 ? attacker : null),
);
const player = makePlayer([10]);
const behavior = makeBehavior(game, player);
const result = (behavior as any).getAttackFrontTiles([
makeAttack(attacker),
]);
expect(result).toEqual([10]);
});
});
// ── countDefensePostsNearFront ───────────────────────────────────────────────
describe("NationStructureBehavior.countDefensePostsNearFront", () => {
const OUTER_RANGE = 50;
function makeCountGame(distFn: (a: number, b: number) => number): any {
return {
config: () => ({ nukeMagnitudes: () => ({ outer: OUTER_RANGE }) }),
euclideanDistSquared: distFn,
};
}
function makeCountPlayer(postTiles: number[]): any {
return {
units: () => postTiles.map((t) => ({ tile: () => t })),
};
}
function count(
postTiles: number[],
frontTiles: number[],
distFn: (a: number, b: number) => number,
): number {
const game = makeCountGame(distFn);
const player = makeCountPlayer(postTiles);
const behavior = makeBehavior(game, player);
return (behavior as any).countDefensePostsNearFront(frontTiles);
}
it("returns 0 when there are no defense posts", () => {
const threshold = (OUTER_RANGE * 1.5) ** 2;
expect(count([], [1], () => threshold - 1)).toBe(0);
});
it("returns 0 when front tiles list is empty", () => {
expect(count([1, 2], [], () => 0)).toBe(0);
});
it("counts posts within 1.5 × borderSpacing of any front tile", () => {
const threshold = (OUTER_RANGE * 1.5) ** 2;
expect(count([10, 20], [1], () => threshold - 1)).toBe(2);
});
it("does not count posts outside 1.5 × borderSpacing", () => {
const threshold = (OUTER_RANGE * 1.5) ** 2;
expect(count([10, 20], [1], () => threshold + 1)).toBe(0);
});
it("counts a post only once even if near multiple front tiles", () => {
const threshold = (OUTER_RANGE * 1.5) ** 2;
expect(count([10], [1, 2], () => threshold - 1)).toBe(1);
});
it("sums posts near different sections of the front", () => {
const threshold = (OUTER_RANGE * 1.5) ** 2;
expect(count([10, 20], [1, 2], () => threshold - 1)).toBe(2);
});
});
// ── getOrBuildReachableStations cache behaviour ──────────────────────────────
describe("NationStructureBehavior.getOrBuildReachableStations", () => {