mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-02 20:48:35 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user