mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 17:36:44 +00:00
71010ab14e
## Description: AI nukes avoid SAM launchers ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> Co-authored-by: evanpelle <evanpelle@gmail.com>
644 lines
18 KiB
TypeScript
644 lines
18 KiB
TypeScript
import {
|
|
Cell,
|
|
Difficulty,
|
|
Execution,
|
|
Game,
|
|
Gold,
|
|
Nation,
|
|
Player,
|
|
PlayerID,
|
|
PlayerType,
|
|
Relation,
|
|
TerrainType,
|
|
Tick,
|
|
Unit,
|
|
UnitType,
|
|
} from "../game/Game";
|
|
import { euclDistFN, manhattanDistFN, TileRef } from "../game/GameMap";
|
|
import { PseudoRandom } from "../PseudoRandom";
|
|
import { GameID } from "../Schemas";
|
|
import { calculateBoundingBox, flattenedEmojiTable, simpleHash } from "../Util";
|
|
import { ConstructionExecution } from "./ConstructionExecution";
|
|
import { EmojiExecution } from "./EmojiExecution";
|
|
import { NukeExecution } from "./NukeExecution";
|
|
import { SpawnExecution } from "./SpawnExecution";
|
|
import { TransportShipExecution } from "./TransportShipExecution";
|
|
import { closestTwoTiles } from "./Util";
|
|
import { BotBehavior } from "./utils/BotBehavior";
|
|
|
|
export class FakeHumanExecution implements Execution {
|
|
private firstMove = true;
|
|
|
|
private active = true;
|
|
private random: PseudoRandom;
|
|
private behavior: BotBehavior | null = null;
|
|
private mg: Game;
|
|
private player: Player | null = null;
|
|
|
|
private attackRate: number;
|
|
private attackTick: number;
|
|
private triggerRatio: number;
|
|
private reserveRatio: number;
|
|
|
|
private lastEmojiSent = new Map<Player, Tick>();
|
|
private lastNukeSent: [Tick, TileRef][] = [];
|
|
private embargoMalusApplied = new Set<PlayerID>();
|
|
private heckleEmoji: number[];
|
|
|
|
constructor(
|
|
gameID: GameID,
|
|
private nation: Nation,
|
|
) {
|
|
this.random = new PseudoRandom(
|
|
simpleHash(nation.playerInfo.id) + simpleHash(gameID),
|
|
);
|
|
this.attackRate = this.random.nextInt(40, 80);
|
|
this.attackTick = this.random.nextInt(0, this.attackRate);
|
|
this.triggerRatio = this.random.nextInt(70, 90) / 100;
|
|
this.reserveRatio = this.random.nextInt(50, 60) / 100;
|
|
this.heckleEmoji = ["🤡", "😡"].map((e) => flattenedEmojiTable.indexOf(e));
|
|
}
|
|
|
|
init(mg: Game) {
|
|
this.mg = mg;
|
|
if (this.random.chance(10)) {
|
|
// this.isTraitor = true
|
|
}
|
|
}
|
|
|
|
private updateRelationsFromEmbargos() {
|
|
const player = this.player;
|
|
if (player === null) return;
|
|
const others = this.mg.players().filter((p) => p.id() !== player.id());
|
|
|
|
others.forEach((other: Player) => {
|
|
const embargoMalus = -20;
|
|
if (
|
|
other.hasEmbargoAgainst(player) &&
|
|
!this.embargoMalusApplied.has(other.id())
|
|
) {
|
|
player.updateRelation(other, embargoMalus);
|
|
this.embargoMalusApplied.add(other.id());
|
|
} else if (
|
|
!other.hasEmbargoAgainst(player) &&
|
|
this.embargoMalusApplied.has(other.id())
|
|
) {
|
|
player.updateRelation(other, -embargoMalus);
|
|
this.embargoMalusApplied.delete(other.id());
|
|
}
|
|
});
|
|
}
|
|
|
|
private handleEmbargoesToHostileNations() {
|
|
const player = this.player;
|
|
if (player === null) return;
|
|
const others = this.mg.players().filter((p) => p.id() !== player.id());
|
|
|
|
others.forEach((other: Player) => {
|
|
/* When player is hostile starts embargo. Do not stop until neutral again */
|
|
if (
|
|
player.relation(other) <= Relation.Hostile &&
|
|
!player.hasEmbargoAgainst(other)
|
|
) {
|
|
player.addEmbargo(other.id(), false);
|
|
} else if (
|
|
player.relation(other) >= Relation.Neutral &&
|
|
player.hasEmbargoAgainst(other)
|
|
) {
|
|
player.stopEmbargo(other.id());
|
|
}
|
|
});
|
|
}
|
|
|
|
tick(ticks: number) {
|
|
if (ticks % this.attackRate !== this.attackTick) return;
|
|
|
|
if (this.mg.inSpawnPhase()) {
|
|
const rl = this.randomLand();
|
|
if (rl === null) {
|
|
console.warn(`cannot spawn ${this.nation.playerInfo.name}`);
|
|
return;
|
|
}
|
|
this.mg.addExecution(new SpawnExecution(this.nation.playerInfo, rl));
|
|
return;
|
|
}
|
|
|
|
if (this.player === null) {
|
|
this.player =
|
|
this.mg.players().find((p) => p.id() === this.nation.playerInfo.id) ??
|
|
null;
|
|
if (this.player === null) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!this.player.isAlive()) {
|
|
this.active = false;
|
|
return;
|
|
}
|
|
|
|
if (this.behavior === null) {
|
|
// Player is unavailable during init()
|
|
this.behavior = new BotBehavior(
|
|
this.random,
|
|
this.mg,
|
|
this.player,
|
|
this.triggerRatio,
|
|
this.reserveRatio,
|
|
);
|
|
}
|
|
|
|
if (this.firstMove) {
|
|
this.firstMove = false;
|
|
this.behavior.sendAttack(this.mg.terraNullius());
|
|
return;
|
|
}
|
|
|
|
if (
|
|
this.player.troops() > 100_000 &&
|
|
this.player.targetTroopRatio() > 0.7
|
|
) {
|
|
this.player.setTargetTroopRatio(0.7);
|
|
}
|
|
|
|
this.updateRelationsFromEmbargos();
|
|
this.behavior.handleAllianceRequests();
|
|
this.handleEnemies();
|
|
this.handleUnits();
|
|
this.handleEmbargoesToHostileNations();
|
|
this.maybeAttack();
|
|
}
|
|
|
|
private maybeAttack() {
|
|
if (this.player === null || this.behavior === null) {
|
|
throw new Error("not initialized");
|
|
}
|
|
const enemyborder = Array.from(this.player.borderTiles())
|
|
.flatMap((t) => this.mg.neighbors(t))
|
|
.filter(
|
|
(t) =>
|
|
this.mg.isLand(t) && this.mg.ownerID(t) !== this.player?.smallID(),
|
|
);
|
|
|
|
if (enemyborder.length === 0) {
|
|
if (this.random.chance(10)) {
|
|
this.sendBoatRandomly();
|
|
}
|
|
return;
|
|
}
|
|
if (this.random.chance(20)) {
|
|
this.sendBoatRandomly();
|
|
return;
|
|
}
|
|
|
|
const enemiesWithTN = enemyborder.map((t) =>
|
|
this.mg.playerBySmallID(this.mg.ownerID(t)),
|
|
);
|
|
if (enemiesWithTN.filter((o) => !o.isPlayer()).length > 0) {
|
|
this.behavior.sendAttack(this.mg.terraNullius());
|
|
return;
|
|
}
|
|
|
|
const enemies = enemiesWithTN
|
|
.filter((o) => o.isPlayer())
|
|
.sort((a, b) => a.troops() - b.troops());
|
|
|
|
// 5% chance to send a random alliance request
|
|
if (this.random.chance(20)) {
|
|
const toAlly = this.random.randElement(enemies);
|
|
if (this.player.canSendAllianceRequest(toAlly)) {
|
|
this.player.createAllianceRequest(toAlly);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 50-50 attack weakest player vs random player
|
|
const toAttack = this.random.chance(2)
|
|
? enemies[0]
|
|
: this.random.randElement(enemies);
|
|
if (this.shouldAttack(toAttack)) {
|
|
this.behavior.sendAttack(toAttack);
|
|
}
|
|
}
|
|
|
|
private shouldAttack(other: Player): boolean {
|
|
if (this.player === null) throw new Error("not initialized");
|
|
if (this.player.isOnSameTeam(other)) {
|
|
return false;
|
|
}
|
|
if (this.player.isFriendly(other)) {
|
|
if (this.shouldDiscourageAttack(other)) {
|
|
return this.random.chance(200);
|
|
}
|
|
return this.random.chance(50);
|
|
} else {
|
|
if (this.shouldDiscourageAttack(other)) {
|
|
return this.random.chance(4);
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private shouldDiscourageAttack(other: Player) {
|
|
if (other.isTraitor()) {
|
|
return false;
|
|
}
|
|
const difficulty = this.mg.config().gameConfig().difficulty;
|
|
if (
|
|
difficulty === Difficulty.Hard ||
|
|
difficulty === Difficulty.Impossible
|
|
) {
|
|
return false;
|
|
}
|
|
if (other.type() !== PlayerType.Human) {
|
|
return false;
|
|
}
|
|
// Only discourage attacks on Humans who are not traitors on easy or medium difficulty.
|
|
return true;
|
|
}
|
|
|
|
handleEnemies() {
|
|
if (this.player === null || this.behavior === null) {
|
|
throw new Error("not initialized");
|
|
}
|
|
this.behavior.forgetOldEnemies();
|
|
this.behavior.assistAllies();
|
|
const enemy = this.behavior.selectEnemy();
|
|
if (!enemy) return;
|
|
this.maybeSendEmoji(enemy);
|
|
this.maybeSendNuke(enemy);
|
|
if (this.player.sharesBorderWith(enemy)) {
|
|
this.behavior.sendAttack(enemy);
|
|
} else {
|
|
this.maybeSendBoatAttack(enemy);
|
|
}
|
|
}
|
|
|
|
private maybeSendEmoji(enemy: Player) {
|
|
if (this.player === null) throw new Error("not initialized");
|
|
if (enemy.type() !== PlayerType.Human) return;
|
|
const lastSent = this.lastEmojiSent.get(enemy) ?? -300;
|
|
if (this.mg.ticks() - lastSent <= 300) return;
|
|
this.lastEmojiSent.set(enemy, this.mg.ticks());
|
|
this.mg.addExecution(
|
|
new EmojiExecution(
|
|
this.player.id(),
|
|
enemy.id(),
|
|
this.random.randElement(this.heckleEmoji),
|
|
),
|
|
);
|
|
}
|
|
|
|
private maybeSendNuke(other: Player) {
|
|
if (this.player === null) throw new Error("not initialized");
|
|
const silos = this.player.units(UnitType.MissileSilo);
|
|
if (
|
|
silos.length === 0 ||
|
|
this.player.gold() < this.cost(UnitType.AtomBomb) ||
|
|
other.type() === PlayerType.Bot ||
|
|
this.player.isOnSameTeam(other)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const structures = other.units(
|
|
UnitType.City,
|
|
UnitType.DefensePost,
|
|
UnitType.MissileSilo,
|
|
UnitType.Port,
|
|
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 allTiles = randomTiles.concat(structureTiles);
|
|
|
|
let bestTile: TileRef | null = null;
|
|
let bestValue = 0;
|
|
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
|
|
if (this.mg.owner(t) !== other) {
|
|
continue outer;
|
|
}
|
|
}
|
|
if (!this.player.canBuild(UnitType.AtomBomb, tile)) continue;
|
|
const value = this.nukeTileScore(tile, silos, structures);
|
|
if (value > bestValue) {
|
|
bestTile = tile;
|
|
bestValue = value;
|
|
}
|
|
}
|
|
if (bestTile !== null) {
|
|
this.sendNuke(bestTile);
|
|
}
|
|
}
|
|
|
|
private removeOldNukeEvents() {
|
|
const maxAge = 500;
|
|
const tick = this.mg.ticks();
|
|
while (
|
|
this.lastNukeSent.length > 0 &&
|
|
this.lastNukeSent[0][0] + maxAge < tick
|
|
) {
|
|
this.lastNukeSent.shift();
|
|
}
|
|
}
|
|
|
|
private sendNuke(tile: TileRef) {
|
|
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.id(), tile),
|
|
);
|
|
}
|
|
|
|
private nukeTileScore(tile: TileRef, silos: Unit[], targets: Unit[]): number {
|
|
// Potential damage in a 25-tile radius
|
|
const dist = euclDistFN(tile, 25, false);
|
|
let tileValue = targets
|
|
.filter((unit) => dist(this.mg, unit.tile()))
|
|
.map((unit) => {
|
|
switch (unit.type()) {
|
|
case UnitType.City:
|
|
return 25_000;
|
|
case UnitType.DefensePost:
|
|
return 5_000;
|
|
case UnitType.MissileSilo:
|
|
return 50_000;
|
|
case UnitType.Port:
|
|
return 10_000;
|
|
default:
|
|
return 0;
|
|
}
|
|
})
|
|
.reduce((prev, cur) => prev + cur, 0);
|
|
|
|
// Avoid areas defended by SAM launchers
|
|
const dist50 = euclDistFN(tile, 50, false);
|
|
tileValue -=
|
|
50_000 *
|
|
targets.filter(
|
|
(unit) =>
|
|
unit.type() === UnitType.SAMLauncher && dist50(this.mg, unit.tile()),
|
|
).length;
|
|
|
|
// Prefer tiles that are closer to a silo
|
|
const siloTiles = silos.map((u) => u.tile());
|
|
const result = closestTwoTiles(this.mg, siloTiles, [tile]);
|
|
if (result === null) throw new Error("Missing result");
|
|
const { x: closestSilo } = result;
|
|
const distanceSquared = this.mg.euclideanDistSquared(tile, closestSilo);
|
|
const distanceToClosestSilo = Math.sqrt(distanceSquared);
|
|
tileValue -= distanceToClosestSilo * 30;
|
|
|
|
// Don't target near recent targets
|
|
tileValue -= this.lastNukeSent
|
|
.filter(([_tick, tile]) => dist(this.mg, tile))
|
|
.map((_) => 1_000_000)
|
|
.reduce((prev, cur) => prev + cur, 0);
|
|
|
|
return tileValue;
|
|
}
|
|
|
|
private maybeSendBoatAttack(other: Player) {
|
|
if (this.player === null) throw new Error("not initialized");
|
|
if (this.player.isOnSameTeam(other)) return;
|
|
const closest = closestTwoTiles(
|
|
this.mg,
|
|
Array.from(this.player.borderTiles()).filter((t) =>
|
|
this.mg.isOceanShore(t),
|
|
),
|
|
Array.from(other.borderTiles()).filter((t) => this.mg.isOceanShore(t)),
|
|
);
|
|
if (closest === null) {
|
|
return;
|
|
}
|
|
this.mg.addExecution(
|
|
new TransportShipExecution(
|
|
this.player.id(),
|
|
other.id(),
|
|
closest.y,
|
|
this.player.troops() / 5,
|
|
null,
|
|
),
|
|
);
|
|
}
|
|
|
|
private handleUnits() {
|
|
const player = this.player;
|
|
if (player === null) return;
|
|
const ports = player.units(UnitType.Port);
|
|
if (ports.length === 0 && player.gold() > this.cost(UnitType.Port)) {
|
|
const oceanTiles = Array.from(player.borderTiles()).filter((t) =>
|
|
this.mg.isOceanShore(t),
|
|
);
|
|
if (oceanTiles.length > 0) {
|
|
const buildTile = this.random.randElement(oceanTiles);
|
|
this.mg.addExecution(
|
|
new ConstructionExecution(player.id(), buildTile, UnitType.Port),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
this.maybeSpawnStructure(UnitType.City, 2);
|
|
if (this.maybeSpawnWarship()) {
|
|
return;
|
|
}
|
|
this.maybeSpawnStructure(UnitType.MissileSilo, 1);
|
|
}
|
|
|
|
private maybeSpawnStructure(type: UnitType, maxNum: number) {
|
|
if (this.player === null) throw new Error("not initialized");
|
|
const units = this.player.units(type);
|
|
if (units.length >= maxNum) {
|
|
return;
|
|
}
|
|
if (this.player.gold() < this.cost(type)) {
|
|
return;
|
|
}
|
|
const tile = this.randTerritoryTile(this.player);
|
|
if (tile === null) {
|
|
return;
|
|
}
|
|
const canBuild = this.player.canBuild(type, tile);
|
|
if (canBuild === false) {
|
|
return;
|
|
}
|
|
this.mg.addExecution(
|
|
new ConstructionExecution(this.player.id(), tile, type),
|
|
);
|
|
}
|
|
|
|
private maybeSpawnWarship(): boolean {
|
|
if (this.player === null) throw new Error("not initialized");
|
|
if (!this.random.chance(50)) {
|
|
return false;
|
|
}
|
|
const ports = this.player.units(UnitType.Port);
|
|
const ships = this.player.units(UnitType.Warship);
|
|
if (
|
|
ports.length > 0 &&
|
|
ships.length === 0 &&
|
|
this.player.gold() > this.cost(UnitType.Warship)
|
|
) {
|
|
const port = this.random.randElement(ports);
|
|
const targetTile = this.warshipSpawnTile(port.tile());
|
|
if (targetTile === null) {
|
|
return false;
|
|
}
|
|
const canBuild = this.player.canBuild(UnitType.Warship, targetTile);
|
|
if (canBuild === false) {
|
|
console.warn("cannot spawn destroyer");
|
|
return false;
|
|
}
|
|
this.mg.addExecution(
|
|
new ConstructionExecution(
|
|
this.player.id(),
|
|
targetTile,
|
|
UnitType.Warship,
|
|
),
|
|
);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private randTerritoryTile(p: Player): TileRef | null {
|
|
const 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);
|
|
if (!this.mg.isOnMap(new Cell(randX, randY))) {
|
|
// Sanity check should never happen
|
|
continue;
|
|
}
|
|
const randTile = this.mg.ref(randX, randY);
|
|
if (this.mg.owner(randTile) === p) {
|
|
return randTile;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private warshipSpawnTile(portTile: TileRef): TileRef | null {
|
|
const radius = 250;
|
|
for (let attempts = 0; attempts < 50; attempts++) {
|
|
const randX = this.random.nextInt(
|
|
this.mg.x(portTile) - radius,
|
|
this.mg.x(portTile) + radius,
|
|
);
|
|
const randY = this.random.nextInt(
|
|
this.mg.y(portTile) - radius,
|
|
this.mg.y(portTile) + radius,
|
|
);
|
|
if (!this.mg.isValidCoord(randX, randY)) {
|
|
continue;
|
|
}
|
|
const tile = this.mg.ref(randX, randY);
|
|
// Sanity check
|
|
if (!this.mg.isOcean(tile)) {
|
|
continue;
|
|
}
|
|
return tile;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private cost(type: UnitType): Gold {
|
|
if (this.player === null) throw new Error("not initialized");
|
|
return this.mg.unitInfo(type).cost(this.player);
|
|
}
|
|
|
|
sendBoatRandomly() {
|
|
if (this.player === null) throw new Error("not initialized");
|
|
const oceanShore = Array.from(this.player.borderTiles()).filter((t) =>
|
|
this.mg.isOceanShore(t),
|
|
);
|
|
if (oceanShore.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const src = this.random.randElement(oceanShore);
|
|
|
|
const dst = this.randOceanShoreTile(src, 150);
|
|
if (dst === null) {
|
|
return;
|
|
}
|
|
|
|
this.mg.addExecution(
|
|
new TransportShipExecution(
|
|
this.player.id(),
|
|
this.mg.owner(dst).id(),
|
|
dst,
|
|
this.player.troops() / 5,
|
|
null,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
randomLand(): TileRef | null {
|
|
const delta = 25;
|
|
let tries = 0;
|
|
while (tries < 50) {
|
|
tries++;
|
|
const cell = this.nation.spawnCell;
|
|
const x = this.random.nextInt(cell.x - delta, cell.x + delta);
|
|
const y = this.random.nextInt(cell.y - delta, cell.y + delta);
|
|
if (!this.mg.isValidCoord(x, y)) {
|
|
continue;
|
|
}
|
|
const tile = this.mg.ref(x, y);
|
|
if (this.mg.isLand(tile) && !this.mg.hasOwner(tile)) {
|
|
if (
|
|
this.mg.terrainType(tile) === TerrainType.Mountain &&
|
|
this.random.chance(2)
|
|
) {
|
|
continue;
|
|
}
|
|
return tile;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private randOceanShoreTile(tile: TileRef, dist: number): TileRef | null {
|
|
if (this.player === null) throw new Error("not initialized");
|
|
const x = this.mg.x(tile);
|
|
const y = this.mg.y(tile);
|
|
for (let i = 0; i < 500; i++) {
|
|
const randX = this.random.nextInt(x - dist, x + dist);
|
|
const randY = this.random.nextInt(y - dist, y + dist);
|
|
if (!this.mg.isValidCoord(randX, randY)) {
|
|
continue;
|
|
}
|
|
const randTile = this.mg.ref(randX, randY);
|
|
if (!this.mg.isOceanShore(randTile)) {
|
|
continue;
|
|
}
|
|
const owner = this.mg.owner(randTile);
|
|
if (!owner.isPlayer()) {
|
|
return randTile;
|
|
}
|
|
if (!owner.isFriendly(this.player)) {
|
|
return randTile;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
isActive(): boolean {
|
|
return this.active;
|
|
}
|
|
|
|
activeDuringSpawnPhase(): boolean {
|
|
return true;
|
|
}
|
|
}
|