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:
Mattia Migliorini
2026-02-22 04:12:44 +01:00
committed by GitHub
parent 90978c0e92
commit 6a30d2b38b
2 changed files with 653 additions and 5 deletions
+357
View File
@@ -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);
});
});