From 136cfa1316dd584bece48f7ad9f8f17e8603f1c7 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sat, 11 Oct 2025 19:35:11 -0700 Subject: [PATCH] improve nation ai (#2172) ## Description: 1. Create forceSendAttack function so nations expand faster at the start (their reserve troop ratio was too low, causing them to skip the first attack 2. modify the perceived cost modifier to reduce the number of defense posts built. 3. Updated how random land is selected to avoid player.tiles() since that can be millions of entries. 4. Improve performance of valueFunction by using closestTile and reducing the number of tiles checked. 5. Nations now launch hydros if they have enough gold. 6. used boundBox instead of bfs because doing a large bfs for h-bombs can get expensive. 7. Modified perceived multiplayer to remove cap and scale super-linearly to discourage nations from spamming too many building. Instead they are more likely to spend that money on nukes. ## 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: evan --- src/core/Util.ts | 38 +++++++ src/core/execution/FakeHumanExecution.ts | 100 ++++++++++++------ src/core/execution/Util.ts | 17 +++ .../nation/structureSpawnTileValue.ts | 28 +++-- src/core/execution/utils/BotBehavior.ts | 10 ++ 5 files changed, 143 insertions(+), 50 deletions(-) diff --git a/src/core/Util.ts b/src/core/Util.ts index ef0f70765..69317accf 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -91,6 +91,44 @@ export function calculateBoundingBox( return { min: new Cell(minX, minY), max: new Cell(maxX, maxY) }; } +export function boundingBoxTiles( + gm: GameMap, + center: TileRef, + radius: number, +): TileRef[] { + const tiles: TileRef[] = []; + + const centerX = gm.x(center); + const centerY = gm.y(center); + + const minX = centerX - radius; + const maxX = centerX + radius; + const minY = centerY - radius; + const maxY = centerY + radius; + + // Top and bottom edges (full width) + for (let x = minX; x <= maxX; x++) { + if (gm.isValidCoord(x, minY)) { + tiles.push(gm.ref(x, minY)); + } + if (gm.isValidCoord(x, maxY) && minY !== maxY) { + tiles.push(gm.ref(x, maxY)); + } + } + + // Left and right edges (exclude corners already added) + for (let y = minY + 1; y < maxY; y++) { + if (gm.isValidCoord(minX, y)) { + tiles.push(gm.ref(minX, y)); + } + if (gm.isValidCoord(maxX, y) && minX !== maxX) { + tiles.push(gm.ref(maxX, y)); + } + } + + return tiles; +} + export function calculateBoundingBoxCenter( gm: GameMap, borderTiles: ReadonlySet, diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index aaefa84de..edeab7417 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -13,10 +13,10 @@ import { Unit, UnitType, } from "../game/Game"; -import { TileRef, euclDistFN, manhattanDistFN } from "../game/GameMap"; +import { TileRef, euclDistFN } from "../game/GameMap"; import { PseudoRandom } from "../PseudoRandom"; import { GameID } from "../Schemas"; -import { calculateBoundingBox, simpleHash } from "../Util"; +import { boundingBoxTiles, calculateBoundingBox, simpleHash } from "../Util"; import { ConstructionExecution } from "./ConstructionExecution"; import { EmojiExecution } from "./EmojiExecution"; import { structureSpawnTileValue } from "./nation/structureSpawnTileValue"; @@ -113,7 +113,7 @@ export class FakeHumanExecution implements Execution { if (ticks % this.attackRate !== this.attackTick) return; if (this.mg.inSpawnPhase()) { - const rl = this.randomLand(); + const rl = this.randomSpawnLand(); if (rl === null) { console.warn(`cannot spawn ${this.nation.playerInfo.name}`); return; @@ -148,7 +148,7 @@ export class FakeHumanExecution implements Execution { ); // Send an attack on the first tick - this.behavior.sendAttack(this.mg.terraNullius()); + this.behavior.forceSendAttack(this.mg.terraNullius()); return; } @@ -265,6 +265,12 @@ export class FakeHumanExecution implements Execution { return; } + const nukeType = + this.player.gold() > this.cost(UnitType.HydrogenBomb) + ? UnitType.HydrogenBomb + : UnitType.AtomBomb; + const range = nukeType === UnitType.HydrogenBomb ? 60 : 15; + const structures = other.units( UnitType.City, UnitType.DefensePost, @@ -273,10 +279,7 @@ export class FakeHumanExecution implements Execution { UnitType.SAMLauncher, ); const structureTiles = structures.map((u) => u.tile()); - const randomTiles: (TileRef | null)[] = new Array(10); - for (let i = 0; i < randomTiles.length; i++) { - randomTiles[i] = this.randTerritoryTile(other); - } + const randomTiles = this.randTerritoryTileArray(10); const allTiles = randomTiles.concat(structureTiles); let bestTile: TileRef | null = null; @@ -284,13 +287,16 @@ export class FakeHumanExecution implements Execution { this.removeOldNukeEvents(); outer: for (const tile of new Set(allTiles)) { if (tile === null) continue; - for (const t of this.mg.bfs(tile, manhattanDistFN(tile, 15))) { - // Make sure we nuke at least 15 tiles in border + const boundingBox = boundingBoxTiles(this.mg, tile, range) + // Add radius / 2 in case there is a piece of unwanted territory inside the outer radius that we miss. + .concat(boundingBoxTiles(this.mg, tile, Math.floor(range / 2))); + for (const t of boundingBox) { + // Make sure we nuke away from the border if (this.mg.owner(t) !== other) { continue outer; } } - if (!this.player.canBuild(UnitType.AtomBomb, tile)) continue; + if (!this.player.canBuild(nukeType, tile)) continue; const value = this.nukeTileScore(tile, silos, structures); if (value > bestValue) { bestTile = tile; @@ -298,7 +304,7 @@ export class FakeHumanExecution implements Execution { } } if (bestTile !== null) { - this.sendNuke(bestTile); + this.sendNuke(bestTile, nukeType); } } @@ -313,13 +319,14 @@ export class FakeHumanExecution implements Execution { } } - private sendNuke(tile: TileRef) { + private sendNuke( + tile: TileRef, + nukeType: UnitType.AtomBomb | UnitType.HydrogenBomb, + ) { if (this.player === null) throw new Error("not initialized"); const tick = this.mg.ticks(); this.lastNukeSent.push([tick, tile]); - this.mg.addExecution( - new NukeExecution(UnitType.AtomBomb, this.player, tile), - ); + this.mg.addExecution(new NukeExecution(nukeType, this.player, tile)); } private nukeTileScore(tile: TileRef, silos: Unit[], targets: Unit[]): number { @@ -396,20 +403,23 @@ export class FakeHumanExecution implements Execution { private handleUnits() { return ( - this.maybeSpawnStructure(UnitType.City) || - this.maybeSpawnStructure(UnitType.Port) || + this.maybeSpawnStructure(UnitType.City, (num) => num) || + this.maybeSpawnStructure(UnitType.Port, (num) => num) || this.maybeSpawnWarship() || - this.maybeSpawnStructure(UnitType.Factory) || - this.maybeSpawnStructure(UnitType.DefensePost) || - this.maybeSpawnStructure(UnitType.SAMLauncher) || - this.maybeSpawnStructure(UnitType.MissileSilo) + this.maybeSpawnStructure(UnitType.Factory, (num) => num) || + this.maybeSpawnStructure(UnitType.DefensePost, (num) => (num + 2) ** 2) || + this.maybeSpawnStructure(UnitType.SAMLauncher, (num) => num ** 2) || + this.maybeSpawnStructure(UnitType.MissileSilo, (num) => num ** 2) ); } - private maybeSpawnStructure(type: UnitType): boolean { + 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 = Math.min(owned + 1, 5); + const perceivedCostMultiplier = multiplier(owned + 1); const realCost = this.cost(type); const perceivedCost = realCost * BigInt(perceivedCostMultiplier); if (this.player.gold() < perceivedCost) { @@ -432,16 +442,13 @@ export class FakeHumanExecution implements Execution { if (this.player === null) throw new Error("Not initialized"); const tiles = type === UnitType.Port - ? Array.from(this.player.borderTiles()).filter((t) => - this.mg.isOceanShore(t), - ) - : Array.from(this.player.tiles()); + ? this.randCoastalTileArray(25) + : this.randTerritoryTileArray(25); if (tiles.length === 0) return null; const valueFunction = structureSpawnTileValue(this.mg, this.player, type); let bestTile: TileRef | null = null; let bestValue = 0; - const sampledTiles = this.arraySampler(tiles); - for (const t of sampledTiles) { + for (const t of tiles) { const v = valueFunction(t); if (v <= bestValue && bestTile !== null) continue; if (!this.player.canBuild(type, t)) continue; @@ -452,7 +459,14 @@ export class FakeHumanExecution implements Execution { return bestTile; } - private *arraySampler(a: T[], sampleSize = 50): Generator { + 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; @@ -497,8 +511,26 @@ export class FakeHumanExecution implements Execution { return false; } - private randTerritoryTile(p: Player): TileRef | null { - const boundingBox = calculateBoundingBox(this.mg, p.borderTiles()); + private randTerritoryTileArray(numTiles: number): TileRef[] { + const boundingBox = calculateBoundingBox( + this.mg, + this.player!.borderTiles(), + ); + const tiles: TileRef[] = []; + for (let i = 0; i < numTiles; i++) { + const tile = this.randTerritoryTile(this.player!, boundingBox); + if (tile !== null) { + tiles.push(tile); + } + } + return tiles; + } + + private randTerritoryTile( + p: Player, + boundingBox: { min: Cell; max: Cell } | null = null, + ): TileRef | null { + boundingBox ??= calculateBoundingBox(this.mg, p.borderTiles()); for (let i = 0; i < 100; i++) { const randX = this.random.nextInt(boundingBox.min.x, boundingBox.max.x); const randY = this.random.nextInt(boundingBox.min.y, boundingBox.max.y); @@ -571,7 +603,7 @@ export class FakeHumanExecution implements Execution { return; } - randomLand(): TileRef | null { + randomSpawnLand(): TileRef | null { const delta = 25; let tries = 0; while (tries < 50) { diff --git a/src/core/execution/Util.ts b/src/core/execution/Util.ts index f1b6e87eb..6135c319f 100644 --- a/src/core/execution/Util.ts +++ b/src/core/execution/Util.ts @@ -6,6 +6,23 @@ export function getSpawnTiles(gm: GameMap, tile: TileRef): TileRef[] { ); } +export function closestTile( + gm: GameMap, + refs: Iterable, + tile: TileRef, +): [TileRef | null, number] { + let minDistance = Infinity; + let minRef: TileRef | null = null; + for (const ref of refs) { + const distance = gm.manhattanDist(ref, tile); + if (distance < minDistance) { + minDistance = distance; + minRef = ref; + } + } + return [minRef, minDistance]; +} + export function closestTwoTiles( gm: GameMap, x: Iterable, diff --git a/src/core/execution/nation/structureSpawnTileValue.ts b/src/core/execution/nation/structureSpawnTileValue.ts index 5c8edae45..b01df682a 100644 --- a/src/core/execution/nation/structureSpawnTileValue.ts +++ b/src/core/execution/nation/structureSpawnTileValue.ts @@ -1,6 +1,6 @@ import { Game, Player, Relation, UnitType } from "../../game/Game"; import { TileRef } from "../../game/GameMap"; -import { closestTwoTiles } from "../Util"; +import { closestTile, closestTwoTiles } from "../Util"; export function structureSpawnTileValue( mg: Game, @@ -23,11 +23,8 @@ export function structureSpawnTileValue( 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); - } + 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( @@ -53,11 +50,8 @@ export function structureSpawnTileValue( 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); - } + const [, closestOtherDist] = closestTile(mg, otherTiles, tile); + w += Math.min(closestOtherDist, structureSpacing); return w; }; @@ -69,15 +63,17 @@ export function structureSpawnTileValue( // Prefer higher elevations w += mg.magnitude(tile); - const closestBorder = closestTwoTiles(mg, borderTiles, [tile]); - if (closestBorder !== null) { + const [closest, closestBorderDist] = closestTile(mg, borderTiles, tile); + if (closest !== null) { // Prefer to be borderSpacing tiles from the border - const d = mg.manhattanDist(closestBorder.x, tile); - w += Math.max(0, borderSpacing - Math.abs(borderSpacing - d)); + 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(closestBorder.x)) { + for (const tile of mg.neighbors(closest)) { if (!mg.isLand(tile)) continue; const id = mg.ownerID(tile); if (id === player.smallID()) continue; diff --git a/src/core/execution/utils/BotBehavior.ts b/src/core/execution/utils/BotBehavior.ts index 2fd64cc71..868d55f74 100644 --- a/src/core/execution/utils/BotBehavior.ts +++ b/src/core/execution/utils/BotBehavior.ts @@ -308,6 +308,16 @@ export class BotBehavior { return this.enemy; } + forceSendAttack(target: Player | TerraNullius) { + this.game.addExecution( + new AttackExecution( + this.player.troops() / 2, + this.player, + target.isPlayer() ? target.id() : this.game.terraNullius().id(), + ), + ); + } + sendAttack(target: Player | TerraNullius) { // Skip attacking friendly targets (allies or teammates) - decision to break alliances should be made by caller if (target.isPlayer() && this.player.isFriendly(target)) return;