mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
Smarter factory placement for Nation AI 🤖 (#3244)
## Description: Introduces a dedicated `factoryValue()` scoring function for AI factory placement, replacing the generic `interiorStructureValue()` previously shared with cities and missile silos. Scoring criteria: - High elevation and spacing from other factories (unchanged from city/silo logic) - Rail connectivity: bonus per distinct rail cluster reachable within `trainStationMaxRange`, weighted by trade gold potential — allied clusters score highest (1.0), team/neutral clusters score ~0.71, own clusters ~0.29 (based on `config.tradeGold()` values). Based on difficulty - Cluster deduplication: connecting to the same cluster multiple times does not inflate the score - Embargoed and bot neighbors are excluded; all other non-embargoed neighbors are included The result is that the AI tends to place factories where they can bridge separate rail networks or connect to high-value trade partners, rather than deep in its own interior. ### EDIT Added a dedicated `cityValue()` scoring function that takes into account the connectivity score. This allows placement of cities in a "factory-aware" way, while also enforcing spreading structures (we want the network to grow, not a cluster of cities and factories all together). ## Please complete the following: - [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: deshack_82603 --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
90978c0e92
commit
6a30d2b38b
@@ -10,6 +10,7 @@ import {
|
||||
UnitType,
|
||||
} from "../../game/Game";
|
||||
import { TileRef } from "../../game/GameMap";
|
||||
import { Cluster } from "../../game/TrainStation";
|
||||
import { PseudoRandom } from "../../PseudoRandom";
|
||||
import { assertNever } from "../../Util";
|
||||
import { ConstructionExecution } from "../ConstructionExecution";
|
||||
@@ -84,6 +85,12 @@ const DEFENSE_POST_DENSITY_THRESHOLD = 1 / 5000;
|
||||
const TILES_PER_CITY_EQUIVALENT = 2000;
|
||||
|
||||
export class NationStructureBehavior {
|
||||
private reachableStationsCache: Array<{
|
||||
tile: TileRef;
|
||||
cluster: Cluster | null;
|
||||
weight: number;
|
||||
}> | null = null;
|
||||
|
||||
constructor(
|
||||
private random: PseudoRandom,
|
||||
private game: Game,
|
||||
@@ -91,6 +98,7 @@ export class NationStructureBehavior {
|
||||
) {}
|
||||
|
||||
handleStructures(): boolean {
|
||||
this.reachableStationsCache = null;
|
||||
const config = this.game.config();
|
||||
const citiesDisabled = config.isUnitDisabled(UnitType.City);
|
||||
const cityCount = citiesDisabled
|
||||
@@ -490,9 +498,11 @@ export class NationStructureBehavior {
|
||||
): ((tile: TileRef) => number) | null {
|
||||
switch (type) {
|
||||
case UnitType.City:
|
||||
case UnitType.Factory:
|
||||
return this.cityValue();
|
||||
case UnitType.MissileSilo:
|
||||
return this.interiorStructureValue(type);
|
||||
return this.missileSiloValue();
|
||||
case UnitType.Factory:
|
||||
return this.factoryValue();
|
||||
case UnitType.Port:
|
||||
return this.portValue();
|
||||
case UnitType.DefensePost:
|
||||
@@ -505,13 +515,13 @@ export class NationStructureBehavior {
|
||||
}
|
||||
|
||||
/**
|
||||
* Value function for interior structures (City, Factory, MissileSilo).
|
||||
* Value function for MissileSilo.
|
||||
* Prefers high elevation, distance from border, and spacing from same-type structures.
|
||||
*/
|
||||
private interiorStructureValue(type: UnitType): (tile: TileRef) => number {
|
||||
private missileSiloValue(): (tile: TileRef) => number {
|
||||
const game = this.game;
|
||||
const borderTiles = this.player.borderTiles();
|
||||
const otherUnits = this.player.units(type);
|
||||
const otherUnits = this.player.units(UnitType.MissileSilo);
|
||||
const { borderSpacing, structureSpacing } = this.spacingConstants();
|
||||
|
||||
return (tile) => {
|
||||
@@ -559,6 +569,287 @@ export class NationStructureBehavior {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Value function for factories.
|
||||
* Prefers high elevation, spacing from other factories, and distance from border.
|
||||
* Based on difficulty, scores connectivity by the number of distinct rail
|
||||
* clusters within train-station range, weighted by trade gold:
|
||||
* ally (1.0) > team/neutral (~0.71) > self (~0.29).
|
||||
* Embargoed and bot neighbors are excluded. Per cluster, the best reachable
|
||||
* trade relationship determines the weight.
|
||||
*/
|
||||
private factoryValue(): (tile: TileRef) => number {
|
||||
const game = this.game;
|
||||
const player = this.player;
|
||||
const borderTiles = this.player.borderTiles();
|
||||
const otherUnits = player.units(UnitType.Factory);
|
||||
const { borderSpacing, structureSpacing } = this.spacingConstants();
|
||||
const stationRange = game.config().trainStationMaxRange();
|
||||
const stationRangeSquared = stationRange * stationRange;
|
||||
const { difficulty } = game.config().gameConfig();
|
||||
const useConnectionScore = this.shouldUseConnectivityScore(difficulty);
|
||||
|
||||
const reachableStations = useConnectionScore
|
||||
? this.getOrBuildReachableStations()
|
||||
: [];
|
||||
const minRangeSquared = game.config().trainStationMinRange() ** 2;
|
||||
|
||||
// Cross-type spacing: prefer to be away from cities.
|
||||
const cityTiles: Set<TileRef> = new Set(
|
||||
player.units(UnitType.City).map((u) => u.tile()),
|
||||
);
|
||||
|
||||
return (tile) => {
|
||||
let w = 0;
|
||||
|
||||
// Prefer higher elevations
|
||||
w += game.magnitude(tile);
|
||||
|
||||
// Prefer to be away from the border
|
||||
const [, closestBorderDist] = closestTile(game, borderTiles, tile);
|
||||
w += Math.min(closestBorderDist, borderSpacing);
|
||||
|
||||
// Prefer to be away from other factories
|
||||
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, stationRange);
|
||||
}
|
||||
|
||||
// Prefer to be away from cities (cross-type spacing)
|
||||
const closestCity = closestTwoTiles(game, cityTiles, [tile]);
|
||||
if (closestCity !== null) {
|
||||
const d = game.manhattanDist(closestCity.x, tile);
|
||||
w += Math.min(d, structureSpacing);
|
||||
}
|
||||
|
||||
if (!useConnectionScore) {
|
||||
return w;
|
||||
}
|
||||
|
||||
w +=
|
||||
this.computeConnectivityScore(
|
||||
tile,
|
||||
reachableStations,
|
||||
minRangeSquared,
|
||||
stationRangeSquared,
|
||||
) * structureSpacing;
|
||||
|
||||
return w;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the game difficulty, decide if we should use connectivity scoring
|
||||
* to determine the best placement for factories and cities.
|
||||
*/
|
||||
private shouldUseConnectivityScore(difficulty: Difficulty): boolean {
|
||||
let randomChance: number;
|
||||
switch (difficulty) {
|
||||
case Difficulty.Easy:
|
||||
randomChance = 0;
|
||||
break;
|
||||
case Difficulty.Medium:
|
||||
randomChance = 60;
|
||||
break;
|
||||
case Difficulty.Hard:
|
||||
randomChance = 75;
|
||||
break;
|
||||
case Difficulty.Impossible:
|
||||
randomChance = 100;
|
||||
break;
|
||||
default:
|
||||
assertNever(difficulty);
|
||||
}
|
||||
|
||||
return this.random.nextInt(0, 100) < randomChance;
|
||||
}
|
||||
|
||||
private getOrBuildReachableStations(): Array<{
|
||||
tile: TileRef;
|
||||
cluster: Cluster | null;
|
||||
weight: number;
|
||||
}> {
|
||||
this.reachableStationsCache ??= this.buildReachableStations();
|
||||
return this.reachableStationsCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Precomputes trade-weighted station entries for connectivity scoring.
|
||||
* Iterates all stations once (O(total_stations)) to build a unit→cluster map,
|
||||
* then collects own and non-embargoed non-bot neighbor structures with a
|
||||
* normalized weight derived from config.trainGold().
|
||||
*/
|
||||
private buildReachableStations(): Array<{
|
||||
tile: TileRef;
|
||||
cluster: Cluster | null;
|
||||
weight: number;
|
||||
}> {
|
||||
const game = this.game;
|
||||
const player = this.player;
|
||||
|
||||
// Build unit → cluster lookup in one O(total_stations) pass.
|
||||
const stationManager = game.railNetwork().stationManager();
|
||||
const unitToCluster = new Map<Unit, Cluster | null>();
|
||||
for (const station of stationManager.getAll()) {
|
||||
unitToCluster.set(station.unit, station.getCluster());
|
||||
}
|
||||
|
||||
const maxTradeGold = Math.max(Number(game.config().trainGold("ally")), 1);
|
||||
const result: Array<{
|
||||
tile: TileRef;
|
||||
cluster: Cluster | null;
|
||||
weight: number;
|
||||
}> = [];
|
||||
|
||||
// Own structures — weighted by "self" trade gold.
|
||||
const selfWeight = Number(game.config().trainGold("self")) / maxTradeGold;
|
||||
for (const unit of player.units(
|
||||
UnitType.City,
|
||||
UnitType.Port,
|
||||
UnitType.Factory,
|
||||
)) {
|
||||
if (unitToCluster.has(unit)) {
|
||||
result.push({
|
||||
tile: unit.tile(),
|
||||
cluster: unitToCluster.get(unit)!,
|
||||
weight: selfWeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Neighbor structures — all non-embargoed non-bot neighbors.
|
||||
for (const neighbor of player.neighbors()) {
|
||||
if (!neighbor.isPlayer()) continue;
|
||||
if (neighbor.type() === PlayerType.Bot) continue;
|
||||
if (!player.canTrade(neighbor)) continue;
|
||||
const relType = player.isOnSameTeam(neighbor)
|
||||
? "team"
|
||||
: player.isAlliedWith(neighbor)
|
||||
? "ally"
|
||||
: "other";
|
||||
const weight = Number(game.config().trainGold(relType)) / maxTradeGold;
|
||||
for (const unit of neighbor.units(
|
||||
UnitType.City,
|
||||
UnitType.Port,
|
||||
UnitType.Factory,
|
||||
)) {
|
||||
if (unitToCluster.has(unit)) {
|
||||
result.push({
|
||||
tile: unit.tile(),
|
||||
cluster: unitToCluster.get(unit)!,
|
||||
weight,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the summed cluster-deduplicated connectivity weight for a candidate
|
||||
* tile. Stations outside [minRangeSquared, stationRangeSquared] are ignored.
|
||||
* Per cluster the max weight of any station in range is taken; isolated
|
||||
* stations (no cluster) contribute their individual weights.
|
||||
*/
|
||||
private computeConnectivityScore(
|
||||
tile: TileRef,
|
||||
reachableStations: Array<{
|
||||
tile: TileRef;
|
||||
cluster: Cluster | null;
|
||||
weight: number;
|
||||
}>,
|
||||
minRangeSquared: number,
|
||||
stationRangeSquared: number,
|
||||
): number {
|
||||
const clustersInRange = new Map<Cluster, number>();
|
||||
let isolatedWeight = 0;
|
||||
for (const { tile: stationTile, cluster, weight } of reachableStations) {
|
||||
const dist = this.game.euclideanDistSquared(tile, stationTile);
|
||||
if (dist < minRangeSquared || dist > stationRangeSquared) continue;
|
||||
if (cluster !== null) {
|
||||
clustersInRange.set(
|
||||
cluster,
|
||||
Math.max(clustersInRange.get(cluster) ?? 0, weight),
|
||||
);
|
||||
} else {
|
||||
isolatedWeight += weight;
|
||||
}
|
||||
}
|
||||
let score = isolatedWeight;
|
||||
for (const cw of clustersInRange.values()) score += cw;
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Value function for cities.
|
||||
* Inherits interior placement criteria (elevation, border distance, spacing)
|
||||
* and adds cluster-connectivity scoring so cities prefer positions that extend
|
||||
* or bridge the existing rail network. Connectivity is difficulty-gated.
|
||||
*/
|
||||
private cityValue(): (tile: TileRef) => number {
|
||||
const game = this.game;
|
||||
const player = this.player;
|
||||
const borderTiles = player.borderTiles();
|
||||
const otherUnits = player.units(UnitType.City);
|
||||
const { borderSpacing, structureSpacing } = this.spacingConstants();
|
||||
const stationRange = game.config().trainStationMaxRange();
|
||||
const stationRangeSquared = stationRange * stationRange;
|
||||
const { difficulty } = game.config().gameConfig();
|
||||
const useConnectionScore = this.shouldUseConnectivityScore(difficulty);
|
||||
|
||||
const reachableStations = useConnectionScore
|
||||
? this.getOrBuildReachableStations()
|
||||
: [];
|
||||
const minRangeSquared = game.config().trainStationMinRange() ** 2;
|
||||
|
||||
// Cross-type spacing: prefer to be away from factories.
|
||||
const factoryTiles: Set<TileRef> = new Set(
|
||||
player.units(UnitType.Factory).map((u) => u.tile()),
|
||||
);
|
||||
|
||||
return (tile) => {
|
||||
let w = 0;
|
||||
|
||||
w += game.magnitude(tile);
|
||||
|
||||
const [, closestBorderDist] = closestTile(game, borderTiles, tile);
|
||||
w += Math.min(closestBorderDist, borderSpacing);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Prefer to be away from factories (cross-type spacing)
|
||||
const closestFactory = closestTwoTiles(game, factoryTiles, [tile]);
|
||||
if (closestFactory !== null) {
|
||||
const d = game.manhattanDist(closestFactory.x, tile);
|
||||
w += Math.min(d, structureSpacing);
|
||||
}
|
||||
|
||||
if (!useConnectionScore) {
|
||||
return w;
|
||||
}
|
||||
|
||||
w +=
|
||||
this.computeConnectivityScore(
|
||||
tile,
|
||||
reachableStations,
|
||||
minRangeSquared,
|
||||
stationRangeSquared,
|
||||
) * structureSpacing;
|
||||
|
||||
return w;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Value function for defense posts.
|
||||
* Returns null if there are no hostile non-bot neighbors.
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import { vi } from "vitest";
|
||||
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) => 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),
|
||||
neighbors: 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]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user