mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
Nations: Fix city farming + reactive defense posts + fix nuked territory capture 🛡️ (#3814)
## Description: Would be very good to get these fixes last minute into v31. - **Farming nations for cities is fixed**: Here you can see city farming in action, vari farms Finland: https://www.youtube.com/watch?v=J4J0ajlnSHs&t=137s - Lots of 125k gold cities... Now nations will build defense posts instead of cities: - **Nations now build defense posts reactively** instead of through the normal structure build order. When a nation is under significant land attack (incoming/own troops >= 35%), it places a defense post near the attack front -- skipped on Easy, capped at 1 on Medium, and scaled by the incoming troop ratio on Hard/Impossible. Posts spread along the front. The old `defensePostValue()` placement path is removed. - **Nations now capture nuked territory.** `hasLandBorderWithTerraNullius()` was incorrectly filtering out tiles with fallout, causing the `nuked` attack strategy to never dispatch a attack. Big bug . - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin
This commit is contained in:
@@ -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<Player> = 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<TileRef> = 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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user