mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-21 11:07:18 +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:
co-authored by
coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
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.
|
||||
|
||||
Reference in New Issue
Block a user