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
This commit is contained in:
evanpelle
2025-10-11 19:35:11 -07:00
committed by GitHub
parent 730d492475
commit 136cfa1316
5 changed files with 143 additions and 50 deletions
+38
View File
@@ -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<TileRef>,
+66 -34
View File
@@ -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<T>(a: T[], sampleSize = 50): Generator<T> {
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;
@@ -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) {
+17
View File
@@ -6,6 +6,23 @@ export function getSpawnTiles(gm: GameMap, tile: TileRef): TileRef[] {
);
}
export function closestTile(
gm: GameMap,
refs: Iterable<TileRef>,
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<TileRef>,
@@ -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<TileRef> = 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<Player> = 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;
+10
View File
@@ -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;