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-21 21:12:44 -06:00
committed by GitHub
co-authored by coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
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.