From f7da20ddfd80a61a05e183a61860e31c2e181c32 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 10 Feb 2026 01:18:13 +0100 Subject: [PATCH] =?UTF-8?q?Nation=20build=20order=20improvements=20+=20Nat?= =?UTF-8?q?ion=20structure=20upgrading=20=F0=9F=8F=A0=20(#3152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #2997 ## Description: ### New stuff - Nations can upgrade structures now. They do it if they have too many structures compared to their territory size - They prefer to upgrade stuff thats protected by SAMs (based on difficulty) - Updated the build order, it also depends a bit on the difficulty now (easy nations build less SAMs) - Nations can handle extreme amounts of gold now. 500M starting gold? no problem. Previously they only built cities - They stop saving up for MIRV if they can afford it (in some old Enzo "impossible difficulty experiment" videos you could see nations with like 300M gold...) - The save-up-target changes when bombs / hydros / MIRVs are disabled - Added many checks for disabled units. For example: Don't build SAMs when missile silos are disabled, focus on factories when ports are disabled - Updated the `structureSpawnTileValue` method, SAM-placement depends a bit on the difficulty now ### Refactor - Moved all structure related nation code into `NationStructureBehavior.ts` - Split up the good old `structureSpawnTileValue` method to make it more readable - Cleaned up NationExecution a bit ### A screenshot Screenshot 2026-02-08 001108 ## 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: FloPinguin --- src/core/execution/NationExecution.ts | 248 ++---- .../nation/NationStructureBehavior.ts | 740 ++++++++++++++++++ .../nation/structureSpawnTileValue.ts | 171 ---- 3 files changed, 823 insertions(+), 336 deletions(-) create mode 100644 src/core/execution/nation/NationStructureBehavior.ts delete mode 100644 src/core/execution/nation/structureSpawnTileValue.ts diff --git a/src/core/execution/NationExecution.ts b/src/core/execution/NationExecution.ts index 25cc06b5a..466adcdaa 100644 --- a/src/core/execution/NationExecution.ts +++ b/src/core/execution/NationExecution.ts @@ -2,39 +2,36 @@ import { Difficulty, Execution, Game, - GameMode, - Gold, Nation, Player, PlayerID, Relation, TerrainType, - UnitType, } from "../game/Game"; import { TileRef } from "../game/GameMap"; import { PseudoRandom } from "../PseudoRandom"; import { GameID } from "../Schemas"; import { assertNever, simpleHash } from "../Util"; -import { ConstructionExecution } from "./ConstructionExecution"; import { NationAllianceBehavior } from "./nation/NationAllianceBehavior"; import { NationEmojiBehavior } from "./nation/NationEmojiBehavior"; import { NationMIRVBehavior } from "./nation/NationMIRVBehavior"; import { NationNukeBehavior } from "./nation/NationNukeBehavior"; -import { randTerritoryTileArray } from "./nation/NationUtils"; +import { NationStructureBehavior } from "./nation/NationStructureBehavior"; import { NationWarshipBehavior } from "./nation/NationWarshipBehavior"; -import { structureSpawnTileValue } from "./nation/structureSpawnTileValue"; import { SpawnExecution } from "./SpawnExecution"; import { AiAttackBehavior } from "./utils/AiAttackBehavior"; export class NationExecution implements Execution { private active = true; private random: PseudoRandom; - private emojiBehavior: NationEmojiBehavior | null = null; - private mirvBehavior: NationMIRVBehavior | null = null; - private attackBehavior: AiAttackBehavior | null = null; - private allianceBehavior: NationAllianceBehavior | null = null; - private warshipBehavior: NationWarshipBehavior | null = null; - private nukeBehavior: NationNukeBehavior | null = null; + private behaviorsInitialized = false; + private emojiBehavior!: NationEmojiBehavior; + private mirvBehavior!: NationMIRVBehavior; + private attackBehavior!: AiAttackBehavior; + private allianceBehavior!: NationAllianceBehavior; + private warshipBehavior!: NationWarshipBehavior; + private nukeBehavior!: NationNukeBehavior; + private structureBehavior!: NationStructureBehavior; private mg: Game; private player: Player | null = null; @@ -89,7 +86,7 @@ export class NationExecution implements Execution { tick(ticks: number) { // Ship tracking if ( - this.warshipBehavior !== null && + this.behaviorsInitialized && this.player !== null && this.player.isAlive() && this.mg.config().gameConfig().difficulty !== Difficulty.Easy @@ -98,6 +95,24 @@ export class NationExecution implements Execution { } if (ticks % this.attackRate !== this.attackTick) { + // Call handleStructures twice between regular attack ticks (at 1/3 and 2/3 of the interval) + // Otherwise it is possible that we earn more gold than we can spend + // The alternative is placing multiple structures in handleStructures, but that causes problems + if ( + this.behaviorsInitialized && + this.player !== null && + this.player.isAlive() + ) { + const offset = ticks % this.attackRate; + const oneThird = + (this.attackTick + Math.floor(this.attackRate / 3)) % this.attackRate; + const twoThirds = + (this.attackTick + Math.floor((this.attackRate * 2) / 3)) % + this.attackRate; + if (offset === oneThird || offset === twoThirds) { + this.structureBehavior.handleStructures(); + } + } return; } @@ -133,56 +148,8 @@ export class NationExecution implements Execution { return; } - if ( - this.emojiBehavior === null || - this.mirvBehavior === null || - this.attackBehavior === null || - this.allianceBehavior === null || - this.warshipBehavior === null || - this.nukeBehavior === null - ) { - this.emojiBehavior = new NationEmojiBehavior( - this.random, - this.mg, - this.player, - ); - this.mirvBehavior = new NationMIRVBehavior( - this.random, - this.mg, - this.player, - this.emojiBehavior, - ); - this.allianceBehavior = new NationAllianceBehavior( - this.random, - this.mg, - this.player, - this.emojiBehavior, - ); - this.warshipBehavior = new NationWarshipBehavior( - this.random, - this.mg, - this.player, - this.emojiBehavior, - ); - this.attackBehavior = new AiAttackBehavior( - this.random, - this.mg, - this.player, - this.triggerRatio, - this.reserveRatio, - this.expandRatio, - this.allianceBehavior, - this.emojiBehavior, - ); - this.nukeBehavior = new NationNukeBehavior( - this.random, - this.mg, - this.player, - this.attackBehavior, - this.emojiBehavior, - ); - - // Send an attack on the first tick + if (!this.behaviorsInitialized) { + this.initializeBehaviors(); this.attackBehavior.forceSendAttack(this.mg.terraNullius()); return; } @@ -192,13 +159,65 @@ export class NationExecution implements Execution { this.allianceBehavior.handleAllianceRequests(); this.allianceBehavior.handleAllianceExtensionRequests(); this.mirvBehavior.considerMIRV(); - this.handleUnits(); + this.structureBehavior.handleStructures(); + this.warshipBehavior.maybeSpawnWarship(); this.handleEmbargoesToHostileNations(); this.attackBehavior.maybeAttack(); this.warshipBehavior.counterWarshipInfestation(); this.nukeBehavior.maybeSendNuke(); } + private initializeBehaviors(): void { + if (this.player === null) throw new Error("Player not initialized"); + + this.emojiBehavior = new NationEmojiBehavior( + this.random, + this.mg, + this.player, + ); + this.mirvBehavior = new NationMIRVBehavior( + this.random, + this.mg, + this.player, + this.emojiBehavior, + ); + this.allianceBehavior = new NationAllianceBehavior( + this.random, + this.mg, + this.player, + this.emojiBehavior, + ); + this.warshipBehavior = new NationWarshipBehavior( + this.random, + this.mg, + this.player, + this.emojiBehavior, + ); + this.attackBehavior = new AiAttackBehavior( + this.random, + this.mg, + this.player, + this.triggerRatio, + this.reserveRatio, + this.expandRatio, + this.allianceBehavior, + this.emojiBehavior, + ); + this.nukeBehavior = new NationNukeBehavior( + this.random, + this.mg, + this.player, + this.attackBehavior, + this.emojiBehavior, + ); + this.structureBehavior = new NationStructureBehavior( + this.random, + this.mg, + this.player, + ); + this.behaviorsInitialized = true; + } + private randomSpawnLand(): TileRef | null { if (this.nation.spawnCell === undefined) throw new Error("not initialized"); @@ -249,102 +268,6 @@ export class NationExecution implements Execution { }); } - private handleUnits() { - if (this.warshipBehavior === null) throw new Error("not initialized"); - const hasCoastalTiles = this.hasCoastalTiles(); - const isTeamGame = this.mg.config().gameConfig().gameMode === GameMode.Team; - return ( - this.maybeSpawnStructure(UnitType.City, (num) => num) || - this.maybeSpawnStructure(UnitType.Port, (num) => num) || - this.warshipBehavior.maybeSpawnWarship() || - this.maybeSpawnStructure(UnitType.Factory, (num) => - hasCoastalTiles ? num * 3 : num, - ) || - this.maybeSpawnStructure(UnitType.DefensePost, (num) => (num + 2) ** 2) || - this.maybeSpawnStructure(UnitType.SAMLauncher, (num) => - isTeamGame ? num : num ** 2, - ) || - this.maybeSpawnStructure(UnitType.MissileSilo, (num) => num ** 2) - ); - } - - private hasCoastalTiles(): boolean { - if (this.player === null) throw new Error("not initialized"); - for (const tile of this.player.borderTiles()) { - if (this.mg.isOceanShore(tile)) return true; - } - return false; - } - - private maybeSpawnStructure( - type: UnitType, - multiplier: (num: number) => number, - ) { - if (this.player === null) throw new Error("not initialized"); - const owned = this.player.unitsOwned(type); - const perceivedCostMultiplier = multiplier(owned + 1); - const realCost = this.cost(type); - const perceivedCost = realCost * BigInt(perceivedCostMultiplier); - if (this.player.gold() < perceivedCost) { - return false; - } - const tile = this.structureSpawnTile(type); - if (tile === null) { - return false; - } - const canBuild = this.player.canBuild(type, tile); - if (canBuild === false) { - return false; - } - this.mg.addExecution(new ConstructionExecution(this.player, type, tile)); - return true; - } - - private structureSpawnTile(type: UnitType): TileRef | null { - if (this.mg === undefined) throw new Error("Not initialized"); - if (this.player === null) throw new Error("Not initialized"); - const tiles = - type === UnitType.Port - ? this.randCoastalTileArray(25) - : randTerritoryTileArray(this.random, this.mg, this.player, 25); - if (tiles.length === 0) return null; - const valueFunction = structureSpawnTileValue(this.mg, this.player, 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.mg.isOceanShore(t), - ); - return Array.from(this.arraySampler(tiles, numTiles)); - } - - private *arraySampler(a: T[], sampleSize: number): Generator { - if (a.length <= sampleSize) { - // Return all elements - yield* a; - } else { - // Sample `sampleSize` elements - const remaining = new Set(a); - while (sampleSize--) { - const t = this.random.randFromSet(remaining); - remaining.delete(t); - yield t; - } - } - } - private handleEmbargoesToHostileNations() { const player = this.player; if (player === null) return; @@ -375,11 +298,6 @@ export class NationExecution implements Execution { }); } - private cost(type: UnitType): Gold { - if (this.player === null) throw new Error("not initialized"); - return this.mg.unitInfo(type).cost(this.mg, this.player); - } - isActive(): boolean { return this.active; } diff --git a/src/core/execution/nation/NationStructureBehavior.ts b/src/core/execution/nation/NationStructureBehavior.ts new file mode 100644 index 000000000..c6e937985 --- /dev/null +++ b/src/core/execution/nation/NationStructureBehavior.ts @@ -0,0 +1,740 @@ +import { + Difficulty, + Game, + Gold, + Player, + PlayerType, + Relation, + StructureTypes, + Unit, + UnitType, +} from "../../game/Game"; +import { TileRef } from "../../game/GameMap"; +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.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> { + 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: 1, + }, + [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 { + constructor( + private random: PseudoRandom, + private game: Game, + private player: Player, + ) {} + + handleStructures(): boolean { + 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 { difficulty } = this.game.config().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 && + !this.game.config().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 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 && + type !== UnitType.DefensePost + ) { + 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; + } + this.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 && + this.player.canUpgradeUnit(structureToUpgrade) + ) { + this.game.addExecution( + new UpgradeStructureExecution(this.player, structureToUpgrade.id()), + ); + return true; + } + return false; + } + + /** + * Calculates total structure density across player's territory. + */ + private getTotalStructureDensity(): number { + let totalStructures = 0; + for (const type of StructureTypes) { + totalStructures += this.player.units(type).length; // ignoring levels + } + const tilesOwned = this.player.numTilesOwned(); + return tilesOwned > 0 ? totalStructures / tilesOwned : 0; + } + + /** + * 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 { + 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 } = this.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 = this.game.config().samRange(sam.level()); + const samRangeSquared = samRange * samRange; + const distSquared = this.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(a: T[], sampleSize: number): Generator { + if (a.length <= sampleSize) { + // Return all elements + yield* a; + } else { + // Sample `sampleSize` elements + const remaining = new Set(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: + case UnitType.Factory: + case UnitType.MissileSilo: + return this.interiorStructureValue(type); + 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 interior structures (City, Factory, MissileSilo). + * Prefers high elevation, distance from border, and spacing from same-type structures. + */ + private interiorStructureValue(type: UnitType): (tile: TileRef) => number { + const game = this.game; + const borderTiles = this.player.borderTiles(); + const otherUnits = this.player.units(type); + 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 = 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 = 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 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 = 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 = 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 | null = null; + if (useCoverageWeighting) { + structureCoverage = new Map(); + 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 = 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 }; + } +} diff --git a/src/core/execution/nation/structureSpawnTileValue.ts b/src/core/execution/nation/structureSpawnTileValue.ts deleted file mode 100644 index a882ca664..000000000 --- a/src/core/execution/nation/structureSpawnTileValue.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { Game, Player, PlayerType, Relation, UnitType } from "../../game/Game"; -import { TileRef } from "../../game/GameMap"; -import { closestTile, closestTwoTiles } from "../Util"; - -export function structureSpawnTileValue( - mg: Game, - player: Player, - type: UnitType, -): ((tile: TileRef) => number) | null { - const borderTiles = player.borderTiles(); - const otherUnits = player.units(type); - // Prefer spacing structures out of atom bomb range - const borderSpacing = mg.config().nukeMagnitudes(UnitType.AtomBomb).outer; - const structureSpacing = borderSpacing * 2; - switch (type) { - case UnitType.City: - case UnitType.Factory: - case UnitType.MissileSilo: { - return (tile) => { - let w = 0; - - // Prefer higher elevations - w += mg.magnitude(tile); - - // Prefer to be away from the border - const [, closestBorderDist] = closestTile(mg, borderTiles, tile); - w += Math.min(closestBorderDist, borderSpacing); - - // Prefer to be away from other structures of the same type - const otherTiles: Set = new Set( - otherUnits.map((u) => u.tile()), - ); - otherTiles.delete(tile); - const closestOther = closestTwoTiles(mg, otherTiles, [tile]); - if (closestOther !== null) { - const d = mg.manhattanDist(closestOther.x, tile); - w += Math.min(d, structureSpacing); - } - - // TODO: Cities and factories should consider train range limits - return w; - }; - } - case UnitType.Port: { - return (tile) => { - let w = 0; - - // Prefer to be away from other structures of the same type - const otherTiles: Set = new Set( - otherUnits.map((u) => u.tile()), - ); - otherTiles.delete(tile); - const [, closestOtherDist] = closestTile(mg, otherTiles, tile); - w += Math.min(closestOtherDist, structureSpacing); - - return w; - }; - } - case UnitType.DefensePost: { - // Check if we have any non-friendly non-bot neighbors - const hasHostileNeighbor = - player - .neighbors() - .filter( - (n): n is Player => - n.isPlayer() && - player.isFriendly(n) === false && - n.type() !== PlayerType.Bot, - ).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 += mg.magnitude(tile); - - const [closest, closestBorderDist] = closestTile(mg, 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 - const neighbors: Set = new Set(); - for (const tile of mg.neighbors(closest)) { - if (!mg.isLand(tile)) continue; - const id = mg.ownerID(tile); - if (id === player.smallID()) continue; - const neighbor = mg.playerBySmallID(id); - if (!neighbor.isPlayer()) continue; - if (neighbor.type() === PlayerType.Bot) 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 = new Set( - otherUnits.map((u) => u.tile()), - ); - otherTiles.delete(tile); - const closestOther = closestTwoTiles(mg, otherTiles, [tile]); - if (closestOther !== null) { - const d = mg.manhattanDist(closestOther.x, tile); - w += Math.min(d, structureSpacing); - } - - return w; - }; - } - case UnitType.SAMLauncher: { - const protectTiles: Set = new Set(); - for (const unit of player.units()) { - switch (unit.type()) { - case UnitType.City: - case UnitType.Factory: - case UnitType.MissileSilo: - case UnitType.Port: - protectTiles.add(unit.tile()); - } - } - const range = mg.config().defaultSamRange(); - const rangeSquared = range * range; - return (tile) => { - let w = 0; - - // Prefer higher elevations - w += mg.magnitude(tile); - - // Prefer to be away from the border - const closestBorder = closestTwoTiles(mg, borderTiles, [tile]); - if (closestBorder !== null) { - const d = mg.manhattanDist(closestBorder.x, tile); - w += Math.min(d, borderSpacing); - } - - // Prefer to be away from other structures of the same type - const otherTiles: Set = new Set( - otherUnits.map((u) => u.tile()), - ); - otherTiles.delete(tile); - const closestOther = closestTwoTiles(mg, otherTiles, [tile]); - if (closestOther !== null) { - const d = mg.manhattanDist(closestOther.x, tile); - w += Math.min(d, structureSpacing); - } - - // Prefer to be in range of other structures - for (const maybeProtected of protectTiles) { - const distanceSquared = mg.euclideanDistSquared(tile, maybeProtected); - if (distanceSquared > rangeSquared) continue; - w += structureSpacing; - } - - return w; - }; - } - default: - throw new Error(`Value function not implemented for ${type}`); - } -}