Refactor Nations AI (#427)

## Description:

Refactor AI troop management and strategic behavior based around two key
values: a trigger ratio and a reserve ratio.
- Reserve ratio: This determines the portion of the population the AI
will keep in reserve and will not send on attacks.
- Trigger ratio: This is the threshold at which the bot will initiate an
attack.

Additionally, when an incoming attack is detected, bots will now
prioritize retaliating by switching targets to the largest incoming
attacker.

Fixes #470 

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [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

## Please put your Discord username so you can be contacted if a bug or
regression is found:

fake.neo

---------

Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com>
This commit is contained in:
Scott Anderson
2025-04-27 17:15:45 -04:00
committed by GitHub
parent f12690808f
commit d9e8984df5
4 changed files with 126 additions and 64 deletions
+20 -11
View File
@@ -6,15 +6,21 @@ import { BotBehavior } from "./utils/BotBehavior";
export class BotExecution implements Execution {
private active = true;
private random: PseudoRandom;
private attackRate: number;
private mg: Game;
private neighborsTerraNullius = true;
private behavior: BotBehavior | null = null;
private attackRate: number;
private attackTick: number;
private triggerRatio: number;
private reserveRatio: number;
constructor(private bot: Player) {
this.random = new PseudoRandom(simpleHash(bot.id()));
this.attackRate = this.random.nextInt(10, 50);
this.attackRate = this.random.nextInt(40, 80);
this.attackTick = this.random.nextInt(0, this.attackRate);
this.triggerRatio = this.random.nextInt(60, 90) / 100;
this.reserveRatio = this.random.nextInt(30, 60) / 100;
}
activeDuringSpawnPhase(): boolean {
@@ -27,17 +33,21 @@ export class BotExecution implements Execution {
}
tick(ticks: number) {
if (ticks % this.attackRate != this.attackTick) return;
if (!this.bot.isAlive()) {
this.active = false;
return;
}
if (ticks % this.attackRate != 0) {
return;
}
if (this.behavior === null) {
this.behavior = new BotBehavior(this.random, this.mg, this.bot, 1 / 20);
this.behavior = new BotBehavior(
this.random,
this.mg,
this.bot,
this.triggerRatio,
this.reserveRatio,
);
}
this.behavior.handleAllianceRequests();
@@ -65,15 +75,14 @@ export class BotExecution implements Execution {
this.neighborsTerraNullius = false;
}
this.behavior.forgetOldEnemies();
this.behavior.checkIncomingAttacks();
const enemy = this.behavior.selectRandomEnemy();
if (!enemy) return;
if (!this.bot.sharesBorderWith(enemy)) return;
this.behavior.sendAttack(enemy);
}
owner(): Player {
return this.bot;
}
isActive(): boolean {
return this.active;
}
+39 -36
View File
@@ -35,6 +35,11 @@ export class FakeHumanExecution implements Execution {
private mg: Game;
private player: Player = 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>();
@@ -46,6 +51,10 @@ export class FakeHumanExecution implements Execution {
this.random = new PseudoRandom(
simpleHash(playerInfo.id) + simpleHash(gameID),
);
this.attackRate = this.random.nextInt(40, 80);
this.attackTick = this.random.nextInt(0, this.attackRate);
this.triggerRatio = this.random.nextInt(60, 90) / 100;
this.reserveRatio = this.random.nextInt(30, 60) / 100;
}
init(mg: Game) {
@@ -96,17 +105,18 @@ export class FakeHumanExecution implements Execution {
}
tick(ticks: number) {
if (ticks % this.attackRate != this.attackTick) return;
if (this.mg.inSpawnPhase()) {
if (ticks % this.random.nextInt(5, 30) == 0) {
const rl = this.randomLand();
if (rl == null) {
consolex.warn(`cannot spawn ${this.playerInfo.name}`);
return;
}
this.mg.addExecution(new SpawnExecution(this.playerInfo, rl));
const rl = this.randomLand();
if (rl == null) {
consolex.warn(`cannot spawn ${this.playerInfo.name}`);
return;
}
this.mg.addExecution(new SpawnExecution(this.playerInfo, rl));
return;
}
if (this.player == null) {
this.player = this.mg.players().find((p) => p.id() == this.playerInfo.id);
if (this.player == null) {
@@ -121,7 +131,13 @@ export class FakeHumanExecution implements Execution {
if (this.behavior === null) {
// Player is unavailable during init()
this.behavior = new BotBehavior(this.random, this.mg, this.player, 1 / 5);
this.behavior = new BotBehavior(
this.random,
this.mg,
this.player,
this.triggerRatio,
this.reserveRatio,
);
}
if (this.firstMove) {
@@ -130,10 +146,6 @@ export class FakeHumanExecution implements Execution {
return;
}
if (ticks % this.random.nextInt(40, 80) != 0) {
return;
}
if (
this.player.troops() > 100_000 &&
this.player.targetTroopRatio() > 0.7
@@ -146,7 +158,10 @@ export class FakeHumanExecution implements Execution {
this.handleEnemies();
this.handleUnits();
this.handleEmbargoesToHostileNations();
this.maybeAttack();
}
private maybeAttack() {
const enemyborder = Array.from(this.player.borderTiles())
.flatMap((t) => this.mg.neighbors(t))
.filter(
@@ -174,9 +189,9 @@ export class FakeHumanExecution implements Execution {
const enemies = enemiesWithTN
.filter((o) => o.isPlayer())
.map((o) => o as Player)
.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)) {
@@ -211,7 +226,7 @@ export class FakeHumanExecution implements Execution {
}
}
shouldDiscourageAttack(other: Player) {
private shouldDiscourageAttack(other: Player) {
if (other.isTraitor()) {
return false;
}
@@ -227,6 +242,8 @@ export class FakeHumanExecution implements Execution {
}
handleEnemies() {
this.behavior.forgetOldEnemies();
this.behavior.checkIncomingAttacks();
this.behavior.assistAllies();
const enemy = this.behavior.selectEnemy();
if (!enemy) return;
@@ -257,8 +274,7 @@ export class FakeHumanExecution implements Execution {
const silos = this.player.units(UnitType.MissileSilo);
if (
silos.length == 0 ||
this.player.gold() <
this.mg.config().unitInfo(UnitType.AtomBomb).cost(this.player) ||
this.player.gold() < this.cost(UnitType.AtomBomb) ||
other.type() == PlayerType.Bot ||
this.player.isOnSameTeam(other)
) {
@@ -397,36 +413,21 @@ export class FakeHumanExecution implements Execution {
}
return;
}
this.maybeSpawnStructure(
UnitType.City,
2,
(t) => new ConstructionExecution(this.player.id(), t, UnitType.City),
);
this.maybeSpawnStructure(UnitType.City, 2);
if (this.maybeSpawnWarship()) {
return;
}
if (!this.mg.config().disableNukes()) {
this.maybeSpawnStructure(
UnitType.MissileSilo,
1,
(t) =>
new ConstructionExecution(this.player.id(), t, UnitType.MissileSilo),
);
this.maybeSpawnStructure(UnitType.MissileSilo, 1);
}
}
private maybeSpawnStructure(
type: UnitType,
maxNum: number,
build: (tile: TileRef) => Execution,
) {
private maybeSpawnStructure(type: UnitType, maxNum: number) {
const units = this.player.units(type);
if (units.length >= maxNum) {
return;
}
if (
this.player.gold() < this.mg.config().unitInfo(type).cost(this.player)
) {
if (this.player.gold() < this.cost(type)) {
return;
}
const tile = this.randTerritoryTile(this.player);
@@ -437,7 +438,9 @@ export class FakeHumanExecution implements Execution {
if (canBuild == false) {
return;
}
this.mg.addExecution(build(tile));
this.mg.addExecution(
new ConstructionExecution(this.player.id(), tile, type),
);
}
private maybeSpawnWarship(): boolean {
+65 -14
View File
@@ -19,7 +19,8 @@ export class BotBehavior {
private random: PseudoRandom,
private game: Game,
private player: Player,
private attackRatio: number,
private triggerRatio: number,
private reserveRatio: number,
) {}
handleAllianceRequests() {
@@ -39,6 +40,24 @@ export class BotBehavior {
);
}
forgetOldEnemies() {
// Forget old enemies
if (this.game.ticks() - this.enemyUpdated > 100) {
this.enemy = null;
}
}
checkIncomingAttacks() {
// Switch enemies if we're under attack
const incomingAttacks = this.player.incomingAttacks();
if (incomingAttacks.length > 0) {
this.enemy = incomingAttacks
.sort((a, b) => b.troops() - a.troops())[0]
.attacker();
this.enemyUpdated = this.game.ticks();
}
}
assistAllies() {
outer: for (const ally of this.player.allies()) {
if (ally.targets().length === 0) continue;
@@ -66,9 +85,11 @@ export class BotBehavior {
}
selectEnemy(): Player | null {
// Forget old enemies
if (this.game.ticks() - this.enemyUpdated > 100) {
this.enemy = null;
if (this.enemy === null) {
// Save up troops until we reach the trigger ratio
const maxPop = this.game.config().maxPopulation(this.player);
const ratio = this.player.population() / maxPop;
if (ratio < this.triggerRatio) return null;
}
// Prefer neighboring bots
@@ -100,24 +121,54 @@ export class BotBehavior {
}
selectRandomEnemy(): Player | TerraNullius | null {
const neighbors = this.player.neighbors();
for (const neighbor of this.random.shuffleArray(neighbors)) {
if (neighbor.isPlayer()) {
if (this.player.isFriendly(neighbor)) continue;
if (neighbor.type() == PlayerType.FakeHuman) {
if (this.random.chance(2)) {
continue;
if (this.enemy === null) {
// Save up troops until we reach the trigger ratio
const maxPop = this.game.config().maxPopulation(this.player);
const ratio = this.player.population() / maxPop;
if (ratio < this.triggerRatio) return null;
// Choose a new enemy randomly
const neighbors = this.player.neighbors();
for (const neighbor of this.random.shuffleArray(neighbors)) {
if (neighbor.isPlayer()) {
if (this.player.isFriendly(neighbor)) continue;
if (neighbor.type() == PlayerType.FakeHuman) {
if (this.random.chance(2)) {
continue;
}
}
}
this.enemy = neighbor;
this.enemyUpdated = this.game.ticks();
}
// Select a traitor as an enemy
const traitors = this.player
.neighbors()
.filter((n) => n.isPlayer() && n.isTraitor()) as Player[];
if (traitors.length > 0) {
const toAttack = this.random.randElement(traitors);
const odds = this.player.isFriendly(toAttack) ? 6 : 3;
if (this.random.chance(odds)) {
this.enemy = toAttack;
this.enemyUpdated = this.game.ticks();
}
}
return neighbor;
}
return null;
// Sanity check, don't attack our allies or teammates
if (this.enemy && this.player.isFriendly(this.enemy)) {
this.enemy = null;
}
return this.enemy;
}
sendAttack(target: Player | TerraNullius) {
if (target.isPlayer() && this.player.isOnSameTeam(target)) return;
const troops = this.player.troops() * this.attackRatio;
const maxPop = this.game.config().maxPopulation(this.player);
const maxTroops = maxPop * this.player.targetTroopRatio();
const targetTroops = maxTroops * this.reserveRatio;
const troops = this.player.troops() - targetTroops;
if (troops < 1) return;
this.game.addExecution(
new AttackExecution(
+2 -3
View File
@@ -469,12 +469,12 @@ export class PlayerImpl implements Player {
this.mg.target(this, other);
}
targets(): PlayerImpl[] {
targets(): Player[] {
return this.targets_
.filter(
(t) => this.mg.ticks() - t.tick < this.mg.config().targetDuration(),
)
.map((t) => t.target as PlayerImpl);
.map((t) => t.target);
}
transitiveTargets(): Player[] {
@@ -809,7 +809,6 @@ export class PlayerImpl implements Player {
}
// only get missilesilos that are not on cooldown
const spawns = this.units(UnitType.MissileSilo)
.map((u) => u as Unit)
.filter((silo) => {
return !silo.isCooldown();
})