mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-03 12:40:47 +00:00
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:
@@ -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>,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user