From d9e8984df58095e701b913cf7feb1cca2c7d79a4 Mon Sep 17 00:00:00 2001 From: Scott Anderson Date: Sun, 27 Apr 2025 17:15:45 -0400 Subject: [PATCH] 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> --- src/core/execution/BotExecution.ts | 31 ++++++---- src/core/execution/FakeHumanExecution.ts | 75 +++++++++++----------- src/core/execution/utils/BotBehavior.ts | 79 +++++++++++++++++++----- src/core/game/PlayerImpl.ts | 5 +- 4 files changed, 126 insertions(+), 64 deletions(-) diff --git a/src/core/execution/BotExecution.ts b/src/core/execution/BotExecution.ts index f71bd6407..2d9075d38 100644 --- a/src/core/execution/BotExecution.ts +++ b/src/core/execution/BotExecution.ts @@ -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; } diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index bf311b8c3..44e73fd20 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -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(); private lastNukeSent: [Tick, TileRef][] = []; private embargoMalusApplied = new Set(); @@ -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 { diff --git a/src/core/execution/utils/BotBehavior.ts b/src/core/execution/utils/BotBehavior.ts index 3c6b6108f..b84d84859 100644 --- a/src/core/execution/utils/BotBehavior.ts +++ b/src/core/execution/utils/BotBehavior.ts @@ -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( diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index b0f889671..de8c2bc96 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -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(); })