Files
OpenFrontIO/tests/NationStructureBehavior.test.ts
FloPinguin 58ec8b280f 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
2026-05-03 09:33:01 -06:00

895 lines
31 KiB
TypeScript
Raw Permalink 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 { 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";
import { PseudoRandom } from "../src/core/PseudoRandom";
// ── Fixed trade-gold values matching DefaultConfig ──────────────────────────
const TRAIN_GOLD: Record<string, bigint> = {
self: 10_000n,
team: 25_000n,
ally: 35_000n,
other: 25_000n,
};
const MAX_TRADE_GOLD = Number(TRAIN_GOLD.ally); // denominator
// ── Factory helpers ──────────────────────────────────────────────────────────
function makeUnit(tile: number): any {
return { tile: () => tile };
}
function makeStation(unit: any, cluster: Cluster | null = null): any {
return { unit, getCluster: () => cluster };
}
function makeGame(stations: any[] = []): any {
return {
config: () => ({
trainGold: (rel: string, _citiesVisited: number) => TRAIN_GOLD[rel] ?? 0n,
}),
railNetwork: () => ({
stationManager: () => ({ getAll: () => new Set(stations) }),
}),
};
}
function makePlayer(
ownUnits: any[],
neighborList: any[],
opts: {
canTrade?: (n: any) => boolean;
isOnSameTeam?: (n: any) => boolean;
isAlliedWith?: (n: any) => boolean;
} = {},
): any {
return {
units: vi.fn(() => ownUnits),
nearby: vi.fn(() => neighborList),
canTrade: vi.fn((n: any) => opts.canTrade?.(n) ?? true),
isOnSameTeam: vi.fn((n: any) => opts.isOnSameTeam?.(n) ?? false),
isAlliedWith: vi.fn((n: any) => opts.isAlliedWith?.(n) ?? false),
};
}
function makeNeighbor(
opts: {
isPlayer?: boolean;
type?: PlayerType;
units?: any[];
} = {},
): any {
return {
isPlayer: () => opts.isPlayer ?? true,
type: () => opts.type ?? PlayerType.Human,
units: vi.fn(() => opts.units ?? []),
};
}
function makeBehavior(
game: any,
player: any,
random: PseudoRandom = new PseudoRandom(0),
): NationStructureBehavior {
return new NationStructureBehavior(random, game, player);
}
// ── shouldUseConnectivityScore ───────────────────────────────────────────────
describe("NationStructureBehavior.shouldUseConnectivityScore", () => {
afterEach(() => {
vi.restoreAllMocks();
});
function behaviorWithNextInt(returnValue: number): {
behavior: NationStructureBehavior;
random: PseudoRandom;
} {
const random = new PseudoRandom(0);
vi.spyOn(random, "nextInt").mockReturnValue(returnValue);
const behavior = makeBehavior(makeGame(), makePlayer([], []), random);
return { behavior, random };
}
it("always returns false for Easy (randomChance = 0)", () => {
for (const v of [0, 50, 99]) {
const { behavior, random } = behaviorWithNextInt(v);
vi.spyOn(random, "nextInt").mockReturnValue(v);
expect(
(behavior as any).shouldUseConnectivityScore(Difficulty.Easy),
).toBe(false);
}
});
it("returns true for Medium when nextInt < 60", () => {
const { behavior } = behaviorWithNextInt(59);
expect(
(behavior as any).shouldUseConnectivityScore(Difficulty.Medium),
).toBe(true);
});
it("returns false for Medium when nextInt === 60 (boundary)", () => {
const { behavior } = behaviorWithNextInt(60);
expect(
(behavior as any).shouldUseConnectivityScore(Difficulty.Medium),
).toBe(false);
});
it("returns true for Hard when nextInt < 75", () => {
const { behavior } = behaviorWithNextInt(74);
expect((behavior as any).shouldUseConnectivityScore(Difficulty.Hard)).toBe(
true,
);
});
it("returns false for Hard when nextInt === 75 (boundary)", () => {
const { behavior } = behaviorWithNextInt(75);
expect((behavior as any).shouldUseConnectivityScore(Difficulty.Hard)).toBe(
false,
);
});
it("always returns true for Impossible (randomChance = 100)", () => {
for (const v of [0, 50, 99]) {
const { behavior, random } = behaviorWithNextInt(v);
vi.spyOn(random, "nextInt").mockReturnValue(v);
expect(
(behavior as any).shouldUseConnectivityScore(Difficulty.Impossible),
).toBe(true);
}
});
});
// ── buildReachableStations ───────────────────────────────────────────────────
describe("NationStructureBehavior.buildReachableStations", () => {
const selfWeight = Number(TRAIN_GOLD.self) / MAX_TRADE_GOLD;
const allyWeight = Number(TRAIN_GOLD.ally) / MAX_TRADE_GOLD;
const teamWeight = Number(TRAIN_GOLD.team) / MAX_TRADE_GOLD;
const otherWeight = Number(TRAIN_GOLD.other) / MAX_TRADE_GOLD;
it("includes own registered units with self weight and correct cluster", () => {
const cluster = new Cluster();
const unit = makeUnit(10);
const station = makeStation(unit, cluster);
const player = makePlayer([unit], []);
const behavior = makeBehavior(makeGame([station]), player);
const result = (behavior as any).buildReachableStations();
expect(result).toHaveLength(1);
expect(result[0].tile).toBe(10);
expect(result[0].cluster).toBe(cluster);
expect(result[0].weight).toBeCloseTo(selfWeight);
});
it("assigns null cluster when own unit is a station with no cluster", () => {
const unit = makeUnit(11);
const station = makeStation(unit, null);
const player = makePlayer([unit], []);
const behavior = makeBehavior(makeGame([station]), player);
const result = (behavior as any).buildReachableStations();
expect(result).toHaveLength(1);
expect(result[0].cluster).toBeNull();
expect(result[0].weight).toBeCloseTo(selfWeight);
});
it("excludes own units not registered in the station manager", () => {
const unit = makeUnit(20);
// No stations in station manager
const player = makePlayer([unit], []);
const behavior = makeBehavior(makeGame([]), player);
const result = (behavior as any).buildReachableStations();
expect(result).toHaveLength(0);
});
it("excludes bot neighbors", () => {
const unit = makeUnit(30);
const station = makeStation(unit, null);
const bot = makeNeighbor({ type: PlayerType.Bot, units: [unit] });
const player = makePlayer([], [bot]);
const behavior = makeBehavior(makeGame([station]), player);
const result = (behavior as any).buildReachableStations();
expect(result).toHaveLength(0);
});
it("excludes non-player neighbors", () => {
const unit = makeUnit(40);
const station = makeStation(unit, null);
const nonPlayer = makeNeighbor({ isPlayer: false, units: [unit] });
const player = makePlayer([], [nonPlayer]);
const behavior = makeBehavior(makeGame([station]), player);
const result = (behavior as any).buildReachableStations();
expect(result).toHaveLength(0);
});
it("excludes embargoed (canTrade = false) neighbors", () => {
const unit = makeUnit(50);
const station = makeStation(unit, null);
const neighbor = makeNeighbor({ units: [unit] });
const player = makePlayer([], [neighbor], { canTrade: () => false });
const behavior = makeBehavior(makeGame([station]), player);
const result = (behavior as any).buildReachableStations();
expect(result).toHaveLength(0);
});
it("includes non-embargoed neutral neighbor with 'other' weight", () => {
const unit = makeUnit(60);
const cluster = new Cluster();
const station = makeStation(unit, cluster);
const neighbor = makeNeighbor({ units: [unit] });
const player = makePlayer([], [neighbor], {
canTrade: () => true,
isOnSameTeam: () => false,
isAlliedWith: () => false,
});
const behavior = makeBehavior(makeGame([station]), player);
const result = (behavior as any).buildReachableStations();
expect(result).toHaveLength(1);
expect(result[0].tile).toBe(60);
expect(result[0].cluster).toBe(cluster);
expect(result[0].weight).toBeCloseTo(otherWeight);
});
it("uses 'ally' weight for allied neighbor", () => {
const unit = makeUnit(70);
const station = makeStation(unit, null);
const neighbor = makeNeighbor({ units: [unit] });
const player = makePlayer([], [neighbor], {
canTrade: () => true,
isOnSameTeam: () => false,
isAlliedWith: (n) => n === neighbor,
});
const behavior = makeBehavior(makeGame([station]), player);
const result = (behavior as any).buildReachableStations();
expect(result).toHaveLength(1);
expect(result[0].weight).toBeCloseTo(allyWeight);
});
it("uses 'team' weight for team neighbor (team check precedes ally)", () => {
const unit = makeUnit(80);
const station = makeStation(unit, null);
const neighbor = makeNeighbor({ units: [unit] });
const player = makePlayer([], [neighbor], {
canTrade: () => true,
isOnSameTeam: (n) => n === neighbor,
isAlliedWith: () => false,
});
const behavior = makeBehavior(makeGame([station]), player);
const result = (behavior as any).buildReachableStations();
expect(result).toHaveLength(1);
expect(result[0].weight).toBeCloseTo(teamWeight);
});
it("excludes neighbor units not registered in the station manager", () => {
const unit = makeUnit(90);
// Station manager has no stations, so unit is unknown
const neighbor = makeNeighbor({ units: [unit] });
const player = makePlayer([], [neighbor]);
const behavior = makeBehavior(makeGame([]), player);
const result = (behavior as any).buildReachableStations();
expect(result).toHaveLength(0);
});
it("collects own and neighbor units together", () => {
const ownUnit = makeUnit(100);
const ownStation = makeStation(ownUnit, null);
const neighborUnit = makeUnit(200);
const neighborStation = makeStation(neighborUnit, null);
const neighbor = makeNeighbor({ units: [neighborUnit] });
const player = makePlayer([ownUnit], [neighbor]);
const behavior = makeBehavior(
makeGame([ownStation, neighborStation]),
player,
);
const result = (behavior as any).buildReachableStations();
expect(result).toHaveLength(2);
const tiles = result.map((r: any) => r.tile).sort();
expect(tiles).toEqual([100, 200]);
});
});
// ── 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", () => {
let behavior: NationStructureBehavior;
let buildSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
const player = makePlayer([], []);
behavior = makeBehavior(makeGame(), player);
buildSpy = vi.spyOn(behavior as any, "buildReachableStations");
});
afterEach(() => {
vi.restoreAllMocks();
});
it("calls buildReachableStations exactly once on first access", () => {
(behavior as any).getOrBuildReachableStations();
expect(buildSpy).toHaveBeenCalledTimes(1);
});
it("returns the same array instance on repeated calls", () => {
const first = (behavior as any).getOrBuildReachableStations();
const second = (behavior as any).getOrBuildReachableStations();
expect(first).toBe(second);
});
it("does not call buildReachableStations a second time when cache is warm", () => {
(behavior as any).getOrBuildReachableStations();
(behavior as any).getOrBuildReachableStations();
expect(buildSpy).toHaveBeenCalledTimes(1);
});
it("rebuilds after the cache is reset to null", () => {
(behavior as any).getOrBuildReachableStations();
(behavior as any).reachableStationsCache = null;
(behavior as any).getOrBuildReachableStations();
expect(buildSpy).toHaveBeenCalledTimes(2);
});
});