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
@@ -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.
+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);
});
});