mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-22 05:01:21 +00:00
Nation build order improvements + Nation structure upgrading 🏠 (#3152)
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 <img width="1681" height="755" alt="Screenshot 2026-02-08 001108" src="https://github.com/user-attachments/assets/c9b3df01-41ca-4c68-b450-b20e7d7d910a" /> ## 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
This commit is contained in:
@@ -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<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 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;
|
||||
}
|
||||
|
||||
@@ -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, 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: 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<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:
|
||||
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<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 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 };
|
||||
}
|
||||
}
|
||||
@@ -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<TileRef> = 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<TileRef> = 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<Player> = 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<TileRef> = 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<TileRef> = 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<TileRef> = 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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user