mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:40:42 +00:00
6a30d2b38b
## 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>
1031 lines
33 KiB
TypeScript
1031 lines
33 KiB
TypeScript
import {
|
|
Difficulty,
|
|
Game,
|
|
Gold,
|
|
Player,
|
|
PlayerType,
|
|
Relation,
|
|
StructureTypes,
|
|
Unit,
|
|
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";
|
|
import { UpgradeStructureExecution } from "../UpgradeStructureExecution";
|
|
import { closestTile, closestTwoTiles } from "../Util";
|
|
import { randTerritoryTileArray } from "./NationUtils";
|
|
|
|
/**
|
|
* Configuration for how many structures of each type a nation should build
|
|
* relative to the number of cities it owns.
|
|
*/
|
|
interface StructureRatioConfig {
|
|
/** How many of this structure per city (e.g., 0.75 means 3 ports for every 4 cities) */
|
|
ratioPerCity: number;
|
|
/** Perceived cost increase percentage per owned structure (e.g., 0.1 = 10% more expensive per owned) */
|
|
perceivedCostIncreasePerOwned: number;
|
|
}
|
|
|
|
/** SAM launcher ratio per city, keyed by difficulty */
|
|
const SAM_RATIO_BY_DIFFICULTY: Record<Difficulty, number> = {
|
|
[Difficulty.Easy]: 0.15,
|
|
[Difficulty.Medium]: 0.2,
|
|
[Difficulty.Hard]: 0.25,
|
|
[Difficulty.Impossible]: 0.3,
|
|
};
|
|
|
|
/**
|
|
* Returns structure ratios relative to city count, adjusted by difficulty.
|
|
* Cities are always prioritized and built first.
|
|
* When cities are disabled, we use TILES_PER_CITY_EQUIVALENT. That's not ideal, nations won't properly upgrade structures, but it's better than nothing. Probably 99.9% of players won't disable cities anyway.
|
|
*/
|
|
function getStructureRatios(
|
|
difficulty: Difficulty,
|
|
): Partial<Record<UnitType, StructureRatioConfig>> {
|
|
return {
|
|
[UnitType.Port]: { ratioPerCity: 0.75, perceivedCostIncreasePerOwned: 1 },
|
|
[UnitType.Factory]: {
|
|
ratioPerCity: 0.75,
|
|
perceivedCostIncreasePerOwned: 1,
|
|
},
|
|
[UnitType.DefensePost]: {
|
|
ratioPerCity: 0.25,
|
|
perceivedCostIncreasePerOwned: 1,
|
|
},
|
|
[UnitType.SAMLauncher]: {
|
|
ratioPerCity: SAM_RATIO_BY_DIFFICULTY[difficulty],
|
|
perceivedCostIncreasePerOwned: 0.5,
|
|
},
|
|
[UnitType.MissileSilo]: {
|
|
ratioPerCity: 0.2,
|
|
perceivedCostIncreasePerOwned: 1,
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Perceived cost increase percentage per city owned */
|
|
const CITY_PERCEIVED_COST_INCREASE_PER_OWNED = 1;
|
|
|
|
/** Factory ratio multiplier when the nation has coastal tiles */
|
|
const FACTORY_COASTAL_RATIO_MULTIPLIER = 0.33;
|
|
|
|
/** Maximum number of missile silos a nation will build */
|
|
const MAX_MISSILE_SILOS = 3;
|
|
|
|
/** If we have more than this many structures per tiles, prefer upgrading over building */
|
|
const UPGRADE_DENSITY_THRESHOLD = 1 / 1500;
|
|
|
|
/** Maximum density of defense posts (per tile owned) before no more can be built */
|
|
const DEFENSE_POST_DENSITY_THRESHOLD = 1 / 5000;
|
|
|
|
/** Estimated number of tiles per city equivalent, used when cities are disabled */
|
|
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,
|
|
private player: Player,
|
|
) {}
|
|
|
|
handleStructures(): boolean {
|
|
this.reachableStationsCache = null;
|
|
const config = this.game.config();
|
|
const citiesDisabled = config.isUnitDisabled(UnitType.City);
|
|
const cityCount = citiesDisabled
|
|
? Math.max(
|
|
1,
|
|
Math.floor(this.player.numTilesOwned() / TILES_PER_CITY_EQUIVALENT),
|
|
)
|
|
: this.player.unitsOwned(UnitType.City);
|
|
const hasCoastalTiles = this.hasCoastalTiles();
|
|
|
|
// Build order for non-city structures (priority order)
|
|
const buildOrder: UnitType[] = [
|
|
UnitType.DefensePost,
|
|
UnitType.Port,
|
|
UnitType.Factory,
|
|
UnitType.SAMLauncher,
|
|
UnitType.MissileSilo,
|
|
];
|
|
|
|
const nukesEnabled =
|
|
!config.isUnitDisabled(UnitType.AtomBomb) ||
|
|
!config.isUnitDisabled(UnitType.HydrogenBomb) ||
|
|
!config.isUnitDisabled(UnitType.MIRV);
|
|
const missileSilosEnabled = !config.isUnitDisabled(UnitType.MissileSilo);
|
|
|
|
for (const structureType of buildOrder) {
|
|
// Skip disabled structure types
|
|
if (config.isUnitDisabled(structureType)) {
|
|
continue;
|
|
}
|
|
|
|
// Skip ports if no coastal tiles
|
|
if (structureType === UnitType.Port && !hasCoastalTiles) {
|
|
continue;
|
|
}
|
|
|
|
// Skip missile silos and SAM launchers if all nukes are disabled
|
|
if (
|
|
!nukesEnabled &&
|
|
(structureType === UnitType.MissileSilo ||
|
|
structureType === UnitType.SAMLauncher)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
// Skip SAM launchers if missile silos are disabled
|
|
if (!missileSilosEnabled && structureType === UnitType.SAMLauncher) {
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
this.shouldBuildStructure(structureType, cityCount, hasCoastalTiles)
|
|
) {
|
|
if (this.maybeSpawnStructure(structureType)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!citiesDisabled && this.maybeSpawnStructure(UnitType.City)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private hasCoastalTiles(): boolean {
|
|
for (const tile of this.player.borderTiles()) {
|
|
if (this.game.isOceanShore(tile)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Determines if we should build more of this structure type based on
|
|
* the current city count and the configured ratio.
|
|
*/
|
|
private shouldBuildStructure(
|
|
type: UnitType,
|
|
cityCount: number,
|
|
hasCoastalTiles: boolean,
|
|
): boolean {
|
|
const gameConfig = this.game.config();
|
|
const { difficulty } = gameConfig.gameConfig();
|
|
const ratios = getStructureRatios(difficulty);
|
|
const config = ratios[type];
|
|
if (config === undefined) {
|
|
return false;
|
|
}
|
|
|
|
let ratio = config.ratioPerCity;
|
|
|
|
// Heavily reduce factory spawning if we have coastal tiles
|
|
if (
|
|
type === UnitType.Factory &&
|
|
hasCoastalTiles &&
|
|
!gameConfig.isUnitDisabled(UnitType.Port)
|
|
) {
|
|
ratio *= FACTORY_COASTAL_RATIO_MULTIPLIER;
|
|
}
|
|
|
|
const owned = this.player.unitsOwned(type);
|
|
|
|
// Hard cap on missile silos
|
|
if (type === UnitType.MissileSilo && owned >= MAX_MISSILE_SILOS) {
|
|
return false;
|
|
}
|
|
|
|
// Density cap on defense posts (can't be upgraded so a new one would be built - problematic if it's a game with high starting gold)
|
|
if (type === UnitType.DefensePost) {
|
|
const tilesOwned = this.player.numTilesOwned();
|
|
if (
|
|
tilesOwned > 0 &&
|
|
owned / tilesOwned >= DEFENSE_POST_DENSITY_THRESHOLD
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const targetCount = Math.floor(cityCount * ratio);
|
|
|
|
return owned < targetCount;
|
|
}
|
|
|
|
private cost(type: UnitType): Gold {
|
|
return this.game.unitInfo(type).cost(this.game, this.player);
|
|
}
|
|
|
|
private maybeSpawnStructure(type: UnitType): boolean {
|
|
const game = this.game;
|
|
const perceivedCost = this.getPerceivedCost(type);
|
|
if (this.player.gold() < perceivedCost) {
|
|
return false;
|
|
}
|
|
|
|
// Check if we should upgrade instead of building new
|
|
const structures = this.player.units(type);
|
|
if (
|
|
this.getTotalStructureDensity() > UPGRADE_DENSITY_THRESHOLD &&
|
|
game.config().unitInfo(type).upgradable
|
|
) {
|
|
if (this.maybeUpgradeStructure(structures)) {
|
|
return true;
|
|
}
|
|
// Density too high but couldn't upgrade (e.g. all under construction) — don't build new, wait for construction (most relevant for SAMs)
|
|
if (structures.length > 0) {
|
|
return false;
|
|
}
|
|
// No structures of this type exist yet — fall through to build the first one
|
|
// (even if density is high - the nation is probably on a tiny island and we need to use all building spots we can find)
|
|
}
|
|
|
|
const tile = this.structureSpawnTile(type);
|
|
if (tile === null) {
|
|
return false;
|
|
}
|
|
const canBuild = this.player.canBuild(type, tile);
|
|
if (canBuild === false) {
|
|
return false;
|
|
}
|
|
game.addExecution(new ConstructionExecution(this.player, type, tile));
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Calculates the perceived cost for a structure type.
|
|
* The perceived cost increases by a percentage for each structure of that type already owned.
|
|
* This makes nations save up gold for nukes.
|
|
* Once the nation can afford its target stockpile, stop inflating costs.
|
|
*/
|
|
private getPerceivedCost(type: UnitType): Gold {
|
|
const realCost = this.cost(type);
|
|
|
|
const saveUpTarget = this.getSaveUpTarget();
|
|
if (saveUpTarget === 0n || this.player.gold() >= saveUpTarget) {
|
|
return realCost;
|
|
}
|
|
|
|
const owned = this.player.unitsOwned(type);
|
|
|
|
let increasePerOwned: number;
|
|
if (type === UnitType.City) {
|
|
increasePerOwned = CITY_PERCEIVED_COST_INCREASE_PER_OWNED;
|
|
} else {
|
|
const { difficulty } = this.game.config().gameConfig();
|
|
const ratios = getStructureRatios(difficulty);
|
|
const config = ratios[type];
|
|
increasePerOwned = config?.perceivedCostIncreasePerOwned ?? 0.1;
|
|
}
|
|
|
|
// Each owned structure makes the next one feel more expensive
|
|
// Formula: realCost * (1 + increasePerOwned * owned)
|
|
const multiplier = 1 + increasePerOwned * owned;
|
|
return BigInt(Math.ceil(Number(realCost) * multiplier));
|
|
}
|
|
|
|
/**
|
|
* Determines the gold target we want to save up for based on which nukes are enabled.
|
|
* Returns 0 if no saving is needed.
|
|
*/
|
|
private getSaveUpTarget(): Gold {
|
|
const config = this.game.config();
|
|
|
|
// No need to save up if missile silos are disabled
|
|
if (config.isUnitDisabled(UnitType.MissileSilo)) {
|
|
return 0n;
|
|
}
|
|
|
|
const mirvEnabled = !config.isUnitDisabled(UnitType.MIRV);
|
|
const hydroEnabled = !config.isUnitDisabled(UnitType.HydrogenBomb);
|
|
const atomEnabled = !config.isUnitDisabled(UnitType.AtomBomb);
|
|
|
|
if (mirvEnabled) {
|
|
// Save up for MIRV + Hydrogen Bomb
|
|
return this.cost(UnitType.MIRV) + this.cost(UnitType.HydrogenBomb);
|
|
}
|
|
if (hydroEnabled) {
|
|
// Save up for 5 hydrogen bombs
|
|
return this.cost(UnitType.HydrogenBomb) * 5n;
|
|
}
|
|
if (atomEnabled) {
|
|
// Save up for 20 atom bombs
|
|
return this.cost(UnitType.AtomBomb) * 20n;
|
|
}
|
|
// No nukes enabled, no need to save up
|
|
return 0n;
|
|
}
|
|
|
|
/**
|
|
* Tries to upgrade an existing structure if density threshold is exceeded.
|
|
* @param structures The pool of structures to consider for upgrading
|
|
* @returns true if an upgrade was initiated, false otherwise
|
|
*/
|
|
private maybeUpgradeStructure(structures: Unit[]): boolean {
|
|
if (this.getTotalStructureDensity() <= UPGRADE_DENSITY_THRESHOLD) {
|
|
return false;
|
|
}
|
|
if (structures.length === 0) {
|
|
return false;
|
|
}
|
|
const structureToUpgrade = this.findBestStructureToUpgrade(structures);
|
|
if (structureToUpgrade !== null) {
|
|
//canUpgradeUnit already checked in findBestStructureToUpgrade and again in UpgradeStructureExecution
|
|
this.game.addExecution(
|
|
new UpgradeStructureExecution(this.player, structureToUpgrade.id()),
|
|
);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Calculates total structure density across player's territory.
|
|
*/
|
|
private getTotalStructureDensity(): number {
|
|
const tilesOwned = this.player.numTilesOwned();
|
|
return tilesOwned > 0
|
|
? this.player.units(...StructureTypes).length / tilesOwned
|
|
: 0; //ignoring levels for structures
|
|
}
|
|
|
|
/**
|
|
* Finds the best structure to upgrade, preferring structures protected by a SAM.
|
|
* In 50% of cases, picks the second or third best to add variety.
|
|
*/
|
|
private findBestStructureToUpgrade(structures: Unit[]): Unit | null {
|
|
const game = this.game;
|
|
if (structures.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// Filter to only upgradable structures
|
|
const upgradable = structures.filter((s) => this.player.canUpgradeUnit(s));
|
|
if (upgradable.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// Based on difficulty, chance to just pick a random structure
|
|
const { difficulty } = game.config().gameConfig();
|
|
let randomChance: number;
|
|
switch (difficulty) {
|
|
case Difficulty.Easy:
|
|
randomChance = 70;
|
|
break;
|
|
case Difficulty.Medium:
|
|
randomChance = 40;
|
|
break;
|
|
case Difficulty.Hard:
|
|
randomChance = 25;
|
|
break;
|
|
case Difficulty.Impossible:
|
|
randomChance = 10;
|
|
break;
|
|
default:
|
|
assertNever(difficulty);
|
|
}
|
|
|
|
if (this.random.nextInt(0, 100) < randomChance) {
|
|
return this.random.randElement(upgradable);
|
|
}
|
|
|
|
const samLaunchers = this.player.units(UnitType.SAMLauncher);
|
|
|
|
// Score each structure based on SAM protection
|
|
const scored: { structure: Unit; score: number }[] = [];
|
|
|
|
for (const structure of upgradable) {
|
|
let score = 0;
|
|
|
|
// Check if protected by any SAM, using per-SAM level-based range
|
|
for (const sam of samLaunchers) {
|
|
const samRange = game.config().samRange(sam.level());
|
|
const samRangeSquared = samRange * samRange;
|
|
const distSquared = game.euclideanDistSquared(
|
|
structure.tile(),
|
|
sam.tile(),
|
|
);
|
|
if (distSquared <= samRangeSquared) {
|
|
// Protected by this SAM, add score based on SAM level
|
|
score += 10;
|
|
if (sam.level() > 1) {
|
|
score += (sam.level() - 1) * 7.5;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add small random factor to break ties
|
|
score += this.random.nextInt(0, 5);
|
|
|
|
scored.push({ structure, score });
|
|
}
|
|
|
|
if (scored.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// Sort descending by score
|
|
scored.sort((a, b) => b.score - a.score);
|
|
|
|
// 50% of the time, pick the second or third best for variety
|
|
if (scored.length >= 2 && this.random.chance(2)) {
|
|
const pickIndex =
|
|
scored.length >= 3
|
|
? this.random.nextInt(1, 3) // pick index 1 or 2
|
|
: 1; // only index 1 available
|
|
return scored[pickIndex].structure;
|
|
}
|
|
|
|
return scored[0].structure;
|
|
}
|
|
|
|
private structureSpawnTile(type: UnitType): TileRef | null {
|
|
const tiles =
|
|
type === UnitType.Port
|
|
? this.randCoastalTileArray(25)
|
|
: randTerritoryTileArray(this.random, this.game, this.player, 25);
|
|
if (tiles.length === 0) return null;
|
|
const valueFunction = this.structureSpawnTileValue(type);
|
|
if (valueFunction === null) return null;
|
|
let bestTile: TileRef | null = null;
|
|
let bestValue = 0;
|
|
for (const t of tiles) {
|
|
const v = valueFunction(t);
|
|
if (v <= bestValue && bestTile !== null) continue;
|
|
if (!this.player.canBuild(type, t)) continue;
|
|
// Found a better tile
|
|
bestTile = t;
|
|
bestValue = v;
|
|
}
|
|
return bestTile;
|
|
}
|
|
|
|
private randCoastalTileArray(numTiles: number): TileRef[] {
|
|
const tiles = Array.from(this.player.borderTiles()).filter((t) =>
|
|
this.game.isOceanShore(t),
|
|
);
|
|
return Array.from(this.arraySampler(tiles, numTiles));
|
|
}
|
|
|
|
private *arraySampler<T>(a: T[], sampleSize: number): Generator<T> {
|
|
if (a.length <= sampleSize) {
|
|
// Return all elements
|
|
yield* a;
|
|
} else {
|
|
// Sample `sampleSize` elements
|
|
const remaining = new Set<T>(a);
|
|
while (sampleSize--) {
|
|
const t = this.random.randFromSet(remaining);
|
|
remaining.delete(t);
|
|
yield t;
|
|
}
|
|
}
|
|
}
|
|
|
|
private structureSpawnTileValue(
|
|
type: UnitType,
|
|
): ((tile: TileRef) => number) | null {
|
|
switch (type) {
|
|
case UnitType.City:
|
|
return this.cityValue();
|
|
case UnitType.MissileSilo:
|
|
return this.missileSiloValue();
|
|
case UnitType.Factory:
|
|
return this.factoryValue();
|
|
case UnitType.Port:
|
|
return this.portValue();
|
|
case UnitType.DefensePost:
|
|
return this.defensePostValue();
|
|
case UnitType.SAMLauncher:
|
|
return this.samLauncherValue();
|
|
default:
|
|
throw new Error(`Value function not implemented for ${type}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Value function for MissileSilo.
|
|
* Prefers high elevation, distance from border, and spacing from same-type structures.
|
|
*/
|
|
private missileSiloValue(): (tile: TileRef) => number {
|
|
const game = this.game;
|
|
const borderTiles = this.player.borderTiles();
|
|
const otherUnits = this.player.units(UnitType.MissileSilo);
|
|
const { borderSpacing, structureSpacing } = this.spacingConstants();
|
|
|
|
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 structures of the same type
|
|
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);
|
|
}
|
|
|
|
return w;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Value function for ports.
|
|
* Prefers spacing from other ports.
|
|
*/
|
|
private portValue(): (tile: TileRef) => number {
|
|
const game = this.game;
|
|
const otherUnits = this.player.units(UnitType.Port);
|
|
const { structureSpacing } = this.spacingConstants();
|
|
|
|
return (tile) => {
|
|
let w = 0;
|
|
|
|
// Prefer to be away from other structures of the same type
|
|
const otherTiles: Set<TileRef> = new Set(otherUnits.map((u) => u.tile()));
|
|
otherTiles.delete(tile);
|
|
const [, closestOtherDist] = closestTile(game, otherTiles, tile);
|
|
w += Math.min(closestOtherDist, structureSpacing);
|
|
|
|
return w;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
* Prefers elevation, proximity to border with hostile neighbors, and spacing.
|
|
*/
|
|
private defensePostValue(): ((tile: TileRef) => number) | null {
|
|
const game = this.game;
|
|
const player = this.player;
|
|
const borderTiles = player.borderTiles();
|
|
const otherUnits = player.units(UnitType.DefensePost);
|
|
const { borderSpacing, structureSpacing } = this.spacingConstants();
|
|
|
|
// Check if we have any non-friendly non-bot neighbors with more troops
|
|
const hasHostileNeighbor =
|
|
player
|
|
.neighbors()
|
|
.filter(
|
|
(n): n is Player =>
|
|
n.isPlayer() &&
|
|
player.isFriendly(n) === false &&
|
|
n.type() !== PlayerType.Bot &&
|
|
n.troops() > player.troops(),
|
|
).length > 0;
|
|
|
|
// Don't build defense posts if there is no danger
|
|
if (!hasHostileNeighbor) {
|
|
return null;
|
|
}
|
|
|
|
return (tile) => {
|
|
let w = 0;
|
|
|
|
// Prefer higher elevations
|
|
w += game.magnitude(tile);
|
|
|
|
const [closest, closestBorderDist] = closestTile(game, borderTiles, tile);
|
|
if (closest !== null) {
|
|
// Prefer to be borderSpacing tiles from the border
|
|
w += Math.max(
|
|
0,
|
|
borderSpacing - Math.abs(borderSpacing - closestBorderDist),
|
|
);
|
|
|
|
// Prefer adjacent players who are hostile and have more troops
|
|
const neighbors: Set<Player> = new Set();
|
|
for (const neighborTile of game.neighbors(closest)) {
|
|
if (!game.isLand(neighborTile)) continue;
|
|
const id = game.ownerID(neighborTile);
|
|
if (id === player.smallID()) continue;
|
|
const neighbor = game.playerBySmallID(id);
|
|
if (!neighbor.isPlayer()) continue;
|
|
if (neighbor.type() === PlayerType.Bot) continue;
|
|
if (neighbor.troops() <= player.troops()) continue;
|
|
neighbors.add(neighbor);
|
|
}
|
|
for (const neighbor of neighbors) {
|
|
w += borderSpacing * (Relation.Friendly - player.relation(neighbor));
|
|
}
|
|
}
|
|
|
|
// Prefer to be away from other structures of the same type
|
|
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);
|
|
}
|
|
|
|
return w;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Value function for SAM launchers.
|
|
* Prefers elevation, distance from border, spacing, and proximity to protectable structures.
|
|
* On harder difficulties, weights by structure level and considers existing SAM coverage.
|
|
*/
|
|
private samLauncherValue(): (tile: TileRef) => number {
|
|
const game = this.game;
|
|
const player = this.player;
|
|
const borderTiles = player.borderTiles();
|
|
const otherUnits = player.units(UnitType.SAMLauncher);
|
|
const { borderSpacing, structureSpacing } = this.spacingConstants();
|
|
|
|
const { difficulty } = game.config().gameConfig();
|
|
const weightByLevel =
|
|
difficulty === Difficulty.Hard || difficulty === Difficulty.Impossible;
|
|
|
|
const protectEntries: { tile: TileRef; weight: number }[] = [];
|
|
for (const unit of player.units()) {
|
|
switch (unit.type()) {
|
|
case UnitType.City:
|
|
case UnitType.Factory:
|
|
case UnitType.MissileSilo:
|
|
case UnitType.Port:
|
|
protectEntries.push({
|
|
tile: unit.tile(),
|
|
weight: weightByLevel ? unit.level() : 1,
|
|
});
|
|
}
|
|
}
|
|
const range = game.config().defaultSamRange();
|
|
const rangeSquared = range * range;
|
|
|
|
const useCoverageWeighting =
|
|
difficulty !== Difficulty.Easy && this.random.nextInt(0, 100) < 25;
|
|
|
|
// Pre-compute existing SAM coverage for each protectable structure
|
|
let structureCoverage: Map<TileRef, number> | null = null;
|
|
if (useCoverageWeighting) {
|
|
structureCoverage = new Map<TileRef, number>();
|
|
const existingSams = player.units(UnitType.SAMLauncher);
|
|
for (const entry of protectEntries) {
|
|
let coverageScore = 0;
|
|
for (const sam of existingSams) {
|
|
const samRange = game.config().samRange(sam.level());
|
|
const dist = game.euclideanDistSquared(entry.tile, sam.tile());
|
|
if (dist <= samRange * samRange) {
|
|
coverageScore += sam.level();
|
|
}
|
|
}
|
|
structureCoverage.set(entry.tile, coverageScore);
|
|
}
|
|
}
|
|
|
|
return (tile) => {
|
|
let w = 0;
|
|
|
|
// Prefer higher elevations
|
|
w += game.magnitude(tile);
|
|
|
|
// Prefer to be away from the border
|
|
const closestBorder = closestTwoTiles(game, borderTiles, [tile]);
|
|
if (closestBorder !== null) {
|
|
const d = game.manhattanDist(closestBorder.x, tile);
|
|
w += Math.min(d, borderSpacing);
|
|
}
|
|
|
|
// Prefer to be away from other structures of the same type
|
|
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 in range of other structures (skip on easy difficulty)
|
|
if (difficulty !== Difficulty.Easy) {
|
|
for (const entry of protectEntries) {
|
|
const distanceSquared = game.euclideanDistSquared(tile, entry.tile);
|
|
if (distanceSquared > rangeSquared) continue;
|
|
if (useCoverageWeighting && structureCoverage !== null) {
|
|
const coverage = structureCoverage.get(entry.tile) ?? 0;
|
|
const coverageWeight = 1 / (1 + coverage);
|
|
w += structureSpacing * entry.weight * coverageWeight;
|
|
} else {
|
|
w += structureSpacing * entry.weight;
|
|
}
|
|
}
|
|
}
|
|
|
|
return w;
|
|
};
|
|
}
|
|
|
|
/** Shared spacing constants derived from atom bomb range. */
|
|
private spacingConstants(): {
|
|
borderSpacing: number;
|
|
structureSpacing: number;
|
|
} {
|
|
const borderSpacing = this.game
|
|
.config()
|
|
.nukeMagnitudes(UnitType.AtomBomb).outer;
|
|
return { borderSpacing, structureSpacing: borderSpacing * 2 };
|
|
}
|
|
}
|