From 6a30d2b38b9996768d90f9b564540727a26bcce6 Mon Sep 17 00:00:00 2001 From: Mattia Migliorini Date: Sun, 22 Feb 2026 04:12:44 +0100 Subject: [PATCH] =?UTF-8?q?Smarter=20factory=20placement=20for=20Nation=20?= =?UTF-8?q?AI=20=F0=9F=A4=96=20(#3244)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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> --- .../nation/NationStructureBehavior.ts | 301 ++++++++++++++- tests/NationStructureBehavior.test.ts | 357 ++++++++++++++++++ 2 files changed, 653 insertions(+), 5 deletions(-) create mode 100644 tests/NationStructureBehavior.test.ts diff --git a/src/core/execution/nation/NationStructureBehavior.ts b/src/core/execution/nation/NationStructureBehavior.ts index fe9a522b6..29b606816 100644 --- a/src/core/execution/nation/NationStructureBehavior.ts +++ b/src/core/execution/nation/NationStructureBehavior.ts @@ -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 = 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 = 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(); + 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(); + 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 = 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 = 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. diff --git a/tests/NationStructureBehavior.test.ts b/tests/NationStructureBehavior.test.ts new file mode 100644 index 000000000..e9862c49b --- /dev/null +++ b/tests/NationStructureBehavior.test.ts @@ -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 = { + 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; + + 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); + }); +});