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 2aea46ea4..44e73fd20 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -1,4 +1,3 @@ -import { DefaultConfig } from "../configuration/DefaultConfig"; import { consolex } from "../Consolex"; import { Cell, @@ -36,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(); @@ -47,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) { @@ -97,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) { @@ -122,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) { @@ -131,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 @@ -147,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( @@ -175,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)) { @@ -195,43 +209,24 @@ export class FakeHumanExecution implements Execution { } } - private chanceScaled(n: number): boolean { - const gameConfig = this.mg.config() as DefaultConfig; - const maxPop = gameConfig.maxPopulation(this.player); - const threshold = (this.player.targetTroopRatio() * maxPop) / 2; - const troops = this.player.troops(); - - let scaledN = n; - - if (troops < 0.25 * threshold) { - return false; // no chance - } else if (troops < 0.5 * threshold) { - // scale smoothly from 0 to 1 as ratio goes from 0.25 to 0.5 - const ratio = (troops - 0.25 * threshold) / (0.25 * threshold); // in [0, 1] - scaledN = Math.max(1, Math.round(n / ratio)); - } - - return this.random.chance(scaledN); - } - private shouldAttack(other: Player): boolean { if (this.player.isOnSameTeam(other)) { return false; } if (this.player.isFriendly(other)) { if (this.shouldDiscourageAttack(other)) { - return this.chanceScaled(200); + return this.random.chance(200); } - return this.chanceScaled(50); + return this.random.chance(50); } else { if (this.shouldDiscourageAttack(other)) { - return this.chanceScaled(4); + return this.random.chance(4); } return true; } } - shouldDiscourageAttack(other: Player) { + private shouldDiscourageAttack(other: Player) { if (other.isTraitor()) { return false; } @@ -247,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; @@ -277,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) ) { @@ -417,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); @@ -457,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(); })