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;