mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 16:50:15 +00:00
4cd6f50c03
## 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
895 lines
31 KiB
TypeScript
895 lines
31 KiB
TypeScript
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);
|
||
});
|
||
});
|