From 58ec8b280f8afe8d602318a85ed8fdd41597a31f Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Sun, 3 May 2026 17:33:01 +0200 Subject: [PATCH] =?UTF-8?q?Nations:=20Fix=20city=20farming=20+=20reactive?= =?UTF-8?q?=20defense=20posts=20+=20fix=20nuked=20territory=20capture=20?= =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20(#3814)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .../nation/NationStructureBehavior.ts | 334 +++++++---- src/core/execution/utils/AiAttackBehavior.ts | 6 +- tests/NationStructureBehavior.test.ts | 537 ++++++++++++++++++ 3 files changed, 777 insertions(+), 100 deletions(-) diff --git a/src/core/execution/nation/NationStructureBehavior.ts b/src/core/execution/nation/NationStructureBehavior.ts index 6db34587c..03f3db17d 100644 --- a/src/core/execution/nation/NationStructureBehavior.ts +++ b/src/core/execution/nation/NationStructureBehavior.ts @@ -1,11 +1,11 @@ import { + Attack, Difficulty, Game, GameMode, Gold, Player, PlayerType, - Relation, Structures, Unit, UnitType, @@ -52,10 +52,6 @@ function getStructureRatios( ratioPerCity: 0.75, perceivedCostIncreasePerOwned: 1, }, - [UnitType.DefensePost]: { - ratioPerCity: 0.25, - perceivedCostIncreasePerOwned: 1, - }, [UnitType.SAMLauncher]: { ratioPerCity: SAM_RATIO_BY_DIFFICULTY[difficulty], perceivedCostIncreasePerOwned: 0.3, @@ -82,9 +78,6 @@ const FIRST_MISSILE_SILO_RATIO = 0.4; /** If we have more than this many structures per tiles, prefer upgrading over building */ const UPGRADE_DENSITY_THRESHOLD = 1 / 1500; -/** Maximum density of defense posts (per tile owned) before no more can be built */ -const DEFENSE_POST_DENSITY_THRESHOLD = 1 / 5000; - /** Estimated number of tiles per city equivalent, used when cities are disabled */ const TILES_PER_CITY_EQUIVALENT = 2000; @@ -116,6 +109,19 @@ const HIGH_GOLD_STRUCTURE_COOLDOWN_TICKS: readonly number[] = [ /** Length in ticks of each on/off phase after the team-mode save-up target is first reached */ const TEAM_POST_SAVE_UP_PHASE_TICKS = 150; // 15s +/** + * Incoming land-attack troop count as a fraction of own troops below which + * the nation does not build defensive structures. + */ +const UNDER_ATTACK_THREAT_RATIO = 0.35; + +/** + * Hard / Impossible: one additional defense post is allowed per this fraction + * of the incoming-to-own-troop ratio (e.g. 0.4 → 1 post at 0–40%, 2 at + * 40–80%, 3 at 80–120%, …). + */ +const DEFENSE_POST_RATIO_PER_POST = 0.4; + export class NationStructureBehavior { private reachableStationsCache: Array<{ tile: TileRef; @@ -135,6 +141,23 @@ export class NationStructureBehavior { ) {} handleStructures(): boolean { + // Defense posts are handled outside the normal pacing/counter system: + // they don't increment placementsCount or lastStructureTick, and they + // are never built as the very first structure. + if ( + this.placementsCount > 0 && + !this.game.config().isUnitDisabled(UnitType.DefensePost) + ) { + if (this.tryBuildDefensePost()) { + return true; + } + // If the attack threshold is met, block other structures even when + // placement failed (no tile found / can't afford). + if (this.defensePostNeeded()) { + return false; + } + } + if (this.isOnStructureCooldown()) { return false; } @@ -149,6 +172,214 @@ export class NationStructureBehavior { return built; } + /** + * Tries to place one defense post near an active land-attack front. + * Not called on Easy. Medium: 50% chance per call, 1 post total. Hard/Impossible: + * ceil(ratio / 0.4) posts total. Boat attacks (sourceTile != null) are ignored. + * Does not touch placementsCount or lastStructureTick. + */ + private tryBuildDefensePost(): boolean { + const { difficulty } = this.game.config().gameConfig(); + if (difficulty === Difficulty.Easy) return false; + if (difficulty === Difficulty.Medium && !this.random.chance(2)) + return false; + + const player = this.player; + const landAttacks = player + .incomingAttacks() + .filter((a) => a.sourceTile() === null); + if (landAttacks.length === 0) return false; + + const ourTroops = player.troops(); + if (ourTroops <= 0) return false; + + const incomingTroops = landAttacks.reduce((sum, a) => sum + a.troops(), 0); + const ratio = incomingTroops / ourTroops; + if (ratio < UNDER_ATTACK_THREAT_RATIO) return false; + + let allowed: number; + if (difficulty === Difficulty.Medium) { + allowed = 1; + } else { + allowed = Math.ceil(ratio / DEFENSE_POST_RATIO_PER_POST); + } + + const frontTiles = this.getAttackFrontTiles(landAttacks); + if (this.countDefensePostsNearFront(frontTiles, allowed) >= allowed) + return false; + + const cost = this.cost(UnitType.DefensePost); + if (player.gold() < cost) return false; + + const tiles = this.sampleTilesNearFront( + frontTiles, + 25, + UnitType.DefensePost, + ); + for (const tile of tiles) { + if (!player.canBuild(UnitType.DefensePost, tile)) continue; + this.game.addExecution( + new ConstructionExecution(player, UnitType.DefensePost, tile), + ); + return true; + } + return false; + } + + private defensePostNeeded(): boolean { + const { difficulty } = this.game.config().gameConfig(); + if (difficulty === Difficulty.Easy) return false; + const landAttacks = this.player + .incomingAttacks() + .filter((a) => a.sourceTile() === null); + if (landAttacks.length === 0) return false; + const ourTroops = this.player.troops(); + if (ourTroops <= 0) return false; + const incomingTroops = landAttacks.reduce((sum, a) => sum + a.troops(), 0); + return incomingTroops / ourTroops >= UNDER_ATTACK_THREAT_RATIO; + } + + /** + * Returns our border tiles that are adjacent to a tile owned by any of the + * attacking players. + */ + private getAttackFrontTiles(landAttacks: Attack[]): TileRef[] { + const game = this.game; + const player = this.player; + const attackerSet = new Set(landAttacks.map((a) => a.attacker())); + if (attackerSet.size === 0) return []; + + const frontTiles: TileRef[] = []; + outer: for (const borderTile of player.borderTiles()) { + for (const neighbor of game.neighbors(borderTile)) { + const owner = game.owner(neighbor); + if (attackerSet.has(owner as Player)) { + frontTiles.push(borderTile); + continue outer; + } + } + } + return frontTiles; + } + + /** + * Counts defense posts within 1.5 × borderSpacing of any front tile. + * `cap` short-circuits the scan once that many are found. + */ + private countDefensePostsNearFront( + frontTiles: TileRef[], + cap?: number, + ): number { + if (frontTiles.length === 0) return 0; + + const game = this.game; + const { borderSpacing } = this.spacingConstants(); + const rangeSquared = (borderSpacing * 1.5) ** 2; + + let count = 0; + for (const dp of this.player.units(UnitType.DefensePost)) { + for (const frontTile of frontTiles) { + if (game.euclideanDistSquared(dp.tile(), frontTile) <= rangeSquared) { + count++; + if (cap !== undefined && count >= cap) return count; + break; + } + } + } + return count; + } + + /** + * Samples territory tiles for defense-post placement, using the full attack + * front as anchors. Only tiles where canBuild passes are collected. + * Anchors near existing defense posts are filtered out first so successive + * posts spread along the front rather than clustering together. + * + * Phase 1: tiles at depth [0.75×, 1.5×] borderSpacing from any border. + * Fallback 1: relax depth constraint (territory smaller than borderSpacing). + * Fallback 2: pure random territory sampling (canBuild checked by caller). + */ + private sampleTilesNearFront( + frontTiles: TileRef[], + count: number, + unitType: UnitType, + ): TileRef[] { + const game = this.game; + const player = this.player; + + if (frontTiles.length === 0) { + return []; + } + + const { borderSpacing } = this.spacingConstants(); + const searchRadius = Math.ceil(borderSpacing * 1.5); + const minBorderDist = Math.ceil(borderSpacing * 0.75); + const maxBorderDist = Math.ceil(borderSpacing * 1.5); + const borderTiles = player.borderTiles(); + + // Spread: prefer front tiles far from existing defense posts so successive + // posts don't cluster at the same spot along the attack line. + const spreadRangeSquared = (borderSpacing * 1.5) ** 2; + const existingDPTiles = player + .units(UnitType.DefensePost) + .map((u) => u.tile()); + + let anchors: TileRef[]; + if (existingDPTiles.length > 0) { + anchors = frontTiles.filter( + (ft) => + !existingDPTiles.some( + (dp) => game.euclideanDistSquared(ft, dp) < spreadRangeSquared, + ), + ); + if (anchors.length === 0) anchors = frontTiles; + } else { + anchors = frontTiles; + } + + const result: TileRef[] = []; + for ( + let attempt = 0; + attempt < count * 6 && result.length < count; + attempt++ + ) { + const anchor = this.random.randElement(anchors); + const ax = game.x(anchor); + const ay = game.y(anchor); + const x = this.random.nextInt(ax - searchRadius, ax + searchRadius + 1); + const y = this.random.nextInt(ay - searchRadius, ay + searchRadius + 1); + if (!game.isValidCoord(x, y)) continue; + const t = game.ref(x, y); + if (game.owner(t) !== player) continue; + const [, borderDist] = closestTile(game, borderTiles, t); + if (borderDist < minBorderDist || borderDist > maxBorderDist) continue; + if (!player.canBuild(unitType, t)) continue; + result.push(t); + } + + if (result.length > 0) return result; + + // Fallback: relax border-depth constraint (territory too small for depth ring) + const fallback: TileRef[] = []; + for ( + let attempt = 0; + attempt < count * 4 && fallback.length < count; + attempt++ + ) { + const anchor = this.random.randElement(anchors); + const ax = game.x(anchor); + const ay = game.y(anchor); + const x = this.random.nextInt(ax - searchRadius, ax + searchRadius + 1); + const y = this.random.nextInt(ay - searchRadius, ay + searchRadius + 1); + if (!game.isValidCoord(x, y)) continue; + const t = game.ref(x, y); + if (game.owner(t) !== player) continue; + fallback.push(t); + } + + return fallback; + } + private isOnStructureCooldown(): boolean { // Only high-starting-gold nations pause if (this.lastStructureTick === null || !this.hasHighStartingGold()) { @@ -235,7 +466,6 @@ export class NationStructureBehavior { // Build order for non-city structures (priority order) const buildOrder: UnitType[] = [ - UnitType.DefensePost, UnitType.Port, UnitType.Factory, UnitType.SAMLauncher, @@ -343,17 +573,6 @@ export class NationStructureBehavior { ratio = FIRST_MISSILE_SILO_RATIO; } - // Density cap on defense posts (can't be upgraded so a new one would be built - problematic if it's a game with high starting gold) - if (type === UnitType.DefensePost) { - const tilesOwned = this.player.numTilesOwned(); - if ( - tilesOwned > 0 && - owned / tilesOwned >= DEFENSE_POST_DENSITY_THRESHOLD - ) { - return false; - } - } - const targetCount = Math.floor(cityCount * ratio); return owned < targetCount; @@ -659,8 +878,6 @@ export class NationStructureBehavior { return this.factoryValue(); case UnitType.Port: return this.portValue(); - case UnitType.DefensePost: - return this.defensePostValue(); case UnitType.SAMLauncher: return this.samLauncherValue(); default: @@ -1008,79 +1225,6 @@ export class NationStructureBehavior { }; } - /** - * Value function for defense posts. - * Returns null if there are no hostile non-bot neighbors. - * Prefers elevation, proximity to border with hostile neighbors, and spacing. - */ - private defensePostValue(): ((tile: TileRef) => number) | null { - const game = this.game; - const player = this.player; - const borderTiles = player.borderTiles(); - const otherUnits = player.units(UnitType.DefensePost); - const { borderSpacing, structureSpacing } = this.spacingConstants(); - - // Check if we have any non-friendly non-bot neighbors with more troops - const hasHostileNeighbor = - player - .nearby() - .filter( - (n): n is Player => - n.isPlayer() && - player.isFriendly(n) === false && - n.type() !== PlayerType.Bot && - n.troops() > player.troops(), - ).length > 0; - - // Don't build defense posts if there is no danger - if (!hasHostileNeighbor) { - return null; - } - - return (tile) => { - let w = 0; - - // Prefer higher elevations - w += game.magnitude(tile); - - const [closest, closestBorderDist] = closestTile(game, borderTiles, tile); - if (closest !== null) { - // Prefer to be borderSpacing tiles from the border - w += Math.max( - 0, - borderSpacing - Math.abs(borderSpacing - closestBorderDist), - ); - - // Prefer adjacent players who are hostile and have more troops - const neighbors: Set = new Set(); - for (const neighborTile of game.neighbors(closest)) { - if (!game.isLand(neighborTile)) continue; - const id = game.ownerID(neighborTile); - if (id === player.smallID()) continue; - const neighbor = game.playerBySmallID(id); - if (!neighbor.isPlayer()) continue; - if (neighbor.type() === PlayerType.Bot) continue; - if (neighbor.troops() <= player.troops()) continue; - neighbors.add(neighbor); - } - for (const neighbor of neighbors) { - w += borderSpacing * (Relation.Friendly - player.relation(neighbor)); - } - } - - // Prefer to be away from other structures of the same type - const otherTiles: Set = new Set(otherUnits.map((u) => u.tile())); - otherTiles.delete(tile); - const closestOther = closestTwoTiles(game, otherTiles, [tile]); - if (closestOther !== null) { - const d = game.manhattanDist(closestOther.x, tile); - w += Math.min(d, structureSpacing); - } - - return w; - }; - } - /** * Value function for SAM launchers. * Prefers elevation, distance from border, spacing, and proximity to protectable structures. diff --git a/src/core/execution/utils/AiAttackBehavior.ts b/src/core/execution/utils/AiAttackBehavior.ts index 44525623f..e87644065 100644 --- a/src/core/execution/utils/AiAttackBehavior.ts +++ b/src/core/execution/utils/AiAttackBehavior.ts @@ -755,11 +755,7 @@ export class AiAttackBehavior { private hasLandBorderWithTerraNullius(): boolean { for (const border of this.player.borderTiles()) { for (const neighbor of this.game.neighbors(border)) { - if ( - this.game.isLand(neighbor) && - !this.game.hasOwner(neighbor) && - !this.game.hasFallout(neighbor) - ) { + if (this.game.isLand(neighbor) && !this.game.hasOwner(neighbor)) { return true; } } diff --git a/tests/NationStructureBehavior.test.ts b/tests/NationStructureBehavior.test.ts index 579422ab3..bd909a1d0 100644 --- a/tests/NationStructureBehavior.test.ts +++ b/tests/NationStructureBehavior.test.ts @@ -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", () => {