diff --git a/src/core/execution/BotExecution.ts b/src/core/execution/BotExecution.ts index 69f1f4f29..7bb9bcd51 100644 --- a/src/core/execution/BotExecution.ts +++ b/src/core/execution/BotExecution.ts @@ -89,11 +89,7 @@ export class BotExecution implements Execution { this.neighborsTerraNullius = false; } - this.behavior.forgetOldEnemies(); - const enemy = this.behavior.selectRandomEnemy(); - if (!enemy) return; - if (!this.bot.sharesBorderWith(enemy)) return; - this.behavior.sendAttack(enemy); + this.behavior.attackRandomTarget(); } isActive(): boolean { diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index 10805d4b1..88552570b 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -20,14 +20,13 @@ import { GameID } from "../Schemas"; import { boundingBoxTiles, calculateBoundingBox, simpleHash } from "../Util"; import { AllianceRequestExecution } from "./alliance/AllianceRequestExecution"; import { ConstructionExecution } from "./ConstructionExecution"; -import { EmojiExecution } from "./EmojiExecution"; import { MirvExecution } from "./MIRVExecution"; import { structureSpawnTileValue } from "./nation/structureSpawnTileValue"; import { NukeExecution } from "./NukeExecution"; import { SpawnExecution } from "./SpawnExecution"; import { TransportShipExecution } from "./TransportShipExecution"; import { calculateTerritoryCenter, closestTwoTiles } from "./Util"; -import { BotBehavior, EMOJI_HECKLE } from "./utils/BotBehavior"; +import { BotBehavior } from "./utils/BotBehavior"; export class FakeHumanExecution implements Execution { private active = true; @@ -42,7 +41,6 @@ export class FakeHumanExecution implements Execution { private reserveRatio: number; private expandRatio: number; - private readonly lastEmojiSent = new Map(); private readonly lastNukeSent: [Tick, TileRef][] = []; private readonly lastMIRVSent: [Tick, TileRef][] = []; private readonly embargoMalusApplied = new Set(); @@ -207,22 +205,35 @@ export class FakeHumanExecution implements Execution { throw new Error("not initialized"); } - const enemyborder = Array.from(this.player.borderTiles()) + const border = Array.from(this.player.borderTiles()) .flatMap((t) => this.mg.neighbors(t)) .filter( (t) => this.mg.isLand(t) && this.mg.ownerID(t) !== this.player?.smallID(), ); - const borderPlayers = enemyborder.map((t) => - this.mg.playerBySmallID(this.mg.ownerID(t)), - ); - const borderingEnemies = borderPlayers + const borderingPlayers = border + .map((t) => this.mg.playerBySmallID(this.mg.ownerID(t))) .filter((o) => o.isPlayer()) .sort((a, b) => a.troops() - b.troops()); + const borderingFriends = borderingPlayers.filter( + (o) => this.player?.isFriendly(o) === true, + ); + const borderingEnemies = borderingPlayers.filter( + (o) => this.player?.isFriendly(o) === false, + ); - if (enemyborder.length === 0) { + // Attack TerraNullius but not nuked territory + const hasNonNukedTerraNullius = border.some( + (t) => !this.mg.hasOwner(t) && !this.mg.hasFallout(t), + ); + if (hasNonNukedTerraNullius) { + this.behavior.sendAttack(this.mg.terraNullius()); + return; + } + + if (borderingEnemies.length === 0) { if (this.random.chance(5)) { - this.sendBoatRandomly(borderingEnemies); + this.sendBoatRandomly(); } } else { if (this.random.chance(10)) { @@ -230,11 +241,6 @@ export class FakeHumanExecution implements Execution { return; } - if (borderPlayers.some((o) => !o.isPlayer())) { - this.behavior.sendAttack(this.mg.terraNullius()); - return; - } - // 5% chance to send a random alliance request if (this.random.chance(20)) { const toAlly = this.random.randElement(borderingEnemies); @@ -246,41 +252,20 @@ export class FakeHumanExecution implements Execution { } } - this.behavior.forgetOldEnemies(); this.behavior.assistAllies(); - const enemy = this.behavior.selectEnemy(borderingEnemies); - if (!enemy) return; - this.maybeSendEmoji(enemy); - this.maybeSendNuke(enemy); - if (this.player.sharesBorderWith(enemy)) { - this.behavior.sendAttack(enemy); - } else { - this.maybeSendBoatAttack(enemy); - } + this.behavior.attackBestTarget(borderingFriends, borderingEnemies); + + this.maybeSendNuke(this.behavior.findBestNukeTarget(borderingEnemies)); } - 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, - enemy.id(), - this.random.randElement(EMOJI_HECKLE), - ), - ); - } - - private maybeSendNuke(other: Player) { + private maybeSendNuke(other: Player | null) { 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 === null || other.type() === PlayerType.Bot || // Don't nuke bots (as opposed to fakehumans and humans) this.player.isOnSameTeam(other) ) { @@ -326,7 +311,7 @@ export class FakeHumanExecution implements Execution { } } if (bestTile !== null) { - this.sendNuke(bestTile, nukeType); + this.sendNuke(bestTile, nukeType, other); } } @@ -344,11 +329,13 @@ export class FakeHumanExecution implements Execution { private sendNuke( tile: TileRef, nukeType: UnitType.AtomBomb | UnitType.HydrogenBomb, + targetPlayer: Player, ) { if (this.player === null) throw new Error("not initialized"); const tick = this.mg.ticks(); this.lastNukeSent.push([tick, tile]); this.mg.addExecution(new NukeExecution(nukeType, this.player, tile)); + this.behavior?.maybeSendEmoji(targetPlayer); } private nukeTileScore(tile: TileRef, silos: Unit[], targets: Unit[]): number { @@ -399,30 +386,6 @@ export class FakeHumanExecution implements Execution { return tileValue; } - private maybeSendBoatAttack(other: Player) { - if (this.player === null) throw new Error("not initialized"); - if (this.player.isFriendly(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, - other.id(), - closest.y, - this.player.troops() / 5, - null, - ), - ); - } - private handleUnits() { return ( this.maybeSpawnStructure(UnitType.City, (num) => num) || @@ -597,7 +560,7 @@ export class FakeHumanExecution implements Execution { return this.mg.unitInfo(type).cost(this.player); } - sendBoatRandomly(borderingEnemies: Player[]) { + sendBoatRandomly(borderingEnemies: Player[] = []) { if (this.player === null) throw new Error("not initialized"); const oceanShore = Array.from(this.player.borderTiles()).filter((t) => this.mg.isOceanShore(t), @@ -884,7 +847,7 @@ export class FakeHumanExecution implements Execution { private maybeSendMIRV(enemy: Player): void { if (this.player === null) throw new Error("not initialized"); - this.maybeSendEmoji(enemy); + this.behavior?.maybeSendEmoji(enemy); const centerTile = this.calculateTerritoryCenter(enemy); if (centerTile && this.player.canBuild(UnitType.MIRV, centerTile)) { diff --git a/src/core/execution/utils/BotBehavior.ts b/src/core/execution/utils/BotBehavior.ts index d05912501..1f9843832 100644 --- a/src/core/execution/utils/BotBehavior.ts +++ b/src/core/execution/utils/BotBehavior.ts @@ -17,6 +17,8 @@ import { import { AllianceExtensionExecution } from "../alliance/AllianceExtensionExecution"; import { AttackExecution } from "../AttackExecution"; import { EmojiExecution } from "../EmojiExecution"; +import { TransportShipExecution } from "../TransportShipExecution"; +import { closestTwoTiles } from "../Util"; const emojiId = (e: (typeof flattenedEmojiTable)[number]) => flattenedEmojiTable.indexOf(e); @@ -24,11 +26,11 @@ const EMOJI_ASSIST_ACCEPT = (["👍", "⛵", "🤝", "🎯"] as const).map(emoji const EMOJI_RELATION_TOO_LOW = (["🥱", "🤦‍♂️"] as const).map(emojiId); const EMOJI_TARGET_ME = (["🥺", "💀"] as const).map(emojiId); const EMOJI_TARGET_ALLY = (["🕊️", "👎"] as const).map(emojiId); -export const EMOJI_HECKLE = (["🤡", "😡"] as const).map(emojiId); +const EMOJI_HECKLE = (["🤡", "😡"] as const).map(emojiId); export class BotBehavior { - private enemy: Player | null = null; - private enemyUpdated: Tick | undefined; + private botAttackTroopsSent: number = 0; + private readonly lastEmojiSent = new Map(); constructor( private random: PseudoRandom, @@ -76,71 +78,31 @@ export class BotBehavior { this.game.addExecution(new EmojiExecution(this.player, player.id(), emoji)); } - private setNewEnemy(newEnemy: Player | null, force = false) { - if (newEnemy !== null && !force && !this.shouldAttack(newEnemy)) return; - this.enemy = newEnemy; - this.enemyUpdated = this.game.ticks(); - } - - private shouldAttack(other: Player): boolean { - if (this.player === null) throw new Error("not initialized"); - if (this.player.isOnSameTeam(other)) { - return false; - } - const shouldAttack = this.attackChance(other); - if (shouldAttack && this.player.isAlliedWith(other)) { - this.betray(other); + // Prevent attacking of humans on lower difficulties + private shouldAttack(other: Player | TerraNullius): boolean { + // Always attack Terra Nullius, non-humans and traitors + if ( + other.isPlayer() === false || + other.type() !== PlayerType.Human || + other.isTraitor() + ) { return true; } - return shouldAttack; - } - private betray(target: Player): void { - if (this.player === null) throw new Error("not initialized"); - const alliance = this.player.allianceWith(target); - if (!alliance) return; - this.player.breakAlliance(alliance); - } - - private attackChance(other: Player): boolean { - if (this.player === null) throw new Error("not initialized"); - - if (this.player.isAlliedWith(other)) { - return this.shouldDiscourageAttack(other) - ? this.random.chance(200) - : this.random.chance(50); - } else { - return this.shouldDiscourageAttack(other) ? this.random.chance(4) : true; - } - } - - private shouldDiscourageAttack(other: Player) { - if (other.isTraitor()) { - return false; - } const { difficulty } = this.game.config().gameConfig(); - if ( - difficulty === Difficulty.Hard || - difficulty === Difficulty.Impossible - ) { + if (difficulty === Difficulty.Easy && this.random.chance(4)) { return false; } - if (other.type() !== PlayerType.Human) { + if (difficulty === Difficulty.Medium && this.random.chance(2)) { return false; } - // Only discourage attacks on Humans who are not traitors on easy or medium difficulty. return true; } - private clearEnemy() { - this.enemy = null; - } - - forgetOldEnemies() { - // Forget old enemies - if (this.game.ticks() - (this.enemyUpdated ?? 0) > 100) { - this.clearEnemy(); - } + private betray(target: Player): void { + const alliance = this.player.allianceWith(target); + if (!alliance) return; + this.player.breakAlliance(alliance); } private hasReserveRatioTroops(): boolean { @@ -155,9 +117,14 @@ export class BotBehavior { return ratio >= this.triggerRatio; } - private checkIncomingAttacks() { - // Switch enemies if we're under attack - const incomingAttacks = this.player.incomingAttacks(); + private findIncomingAttackPlayer(): Player | null { + // Ignore bot attacks if we are not a bot. + let incomingAttacks = this.player.incomingAttacks(); + if (this.player.type() !== PlayerType.Bot) { + incomingAttacks = incomingAttacks.filter( + (attack) => attack.attacker().type() !== PlayerType.Bot, + ); + } let largestAttack = 0; let largestAttacker: Player | undefined; for (const attack of incomingAttacks) { @@ -166,14 +133,18 @@ export class BotBehavior { largestAttacker = attack.attacker(); } if (largestAttacker !== undefined) { - this.setNewEnemy(largestAttacker, true); + return largestAttacker; } + return null; } getNeighborTraitorToAttack(): Player | null { const traitors = this.player .neighbors() - .filter((n): n is Player => n.isPlayer() && n.isTraitor()); + .filter( + (n): n is Player => + n.isPlayer() && this.player.isFriendly(n) === false && n.isTraitor(), + ); return traitors.length > 0 ? this.random.randElement(traitors) : null; } @@ -189,87 +160,172 @@ export class BotBehavior { this.emoji(ally, this.random.randElement(EMOJI_TARGET_ME)); continue; } - if (this.player.isAlliedWith(target)) { + if (this.player.isFriendly(target)) { this.emoji(ally, this.random.randElement(EMOJI_TARGET_ALLY)); continue; } // All checks passed, assist them this.player.updateRelation(ally, -20); - this.setNewEnemy(target); + this.sendAttack(target); this.emoji(ally, this.random.randElement(EMOJI_ASSIST_ACCEPT)); return; } } } - selectEnemy(borderingEnemies: Player[]): Player | null { - if (this.enemy === null) { - // Save up troops until we reach the reserve ratio - if (!this.hasReserveRatioTroops()) return null; + attackBestTarget(borderingFriends: Player[], borderingEnemies: Player[]) { + // Save up troops until we reach the reserve ratio + if (!this.hasReserveRatioTroops()) return; - // Maybe save up troops until we reach the trigger ratio - if (!this.hasTriggerRatioTroops() && !this.random.chance(10)) return null; + // Maybe save up troops until we reach the trigger ratio + if (!this.hasTriggerRatioTroops() && !this.random.chance(10)) return; - // Prefer neighboring bots - const bots = this.player - .neighbors() - .filter( - (n): n is Player => n.isPlayer() && n.type() === PlayerType.Bot, - ); - if (bots.length > 0) { - const density = (p: Player) => p.troops() / p.numTilesOwned(); - let lowestDensityBot: Player | undefined; - let lowestDensity = Infinity; + // Retaliate against incoming attacks (Most important!) + const incomingAttackPlayer = this.findIncomingAttackPlayer(); + if (incomingAttackPlayer) { + this.sendAttack(incomingAttackPlayer, true); + return; + } - for (const bot of bots) { - const currentDensity = density(bot); - if (currentDensity < lowestDensity) { - lowestDensity = currentDensity; - lowestDensityBot = bot; - } - } + // Attack bots + if (this.attackBots()) return; - if (lowestDensityBot !== undefined) { - this.setNewEnemy(lowestDensityBot); - } + // Maybe betray and attack + if (this.maybeBetrayAndAttack(borderingFriends)) return; + + // Attack nuked territory + if (this.isBorderingNukedTerritory()) { + this.sendAttack(this.game.terraNullius()); + return; + } + + // Attack the most hated player with hostile relation + const mostHated = this.player.allRelationsSorted()[0]; + if ( + mostHated !== undefined && + mostHated.relation === Relation.Hostile && + this.player.isFriendly(mostHated.player) === false + ) { + this.sendAttack(mostHated.player); + return; + } + + // Attack the weakest player + if (borderingEnemies.length > 0) { + this.sendAttack(borderingEnemies[0]); + return; + } + + // If we don't have bordering enemies, attack someone on an island next to us + if (borderingEnemies.length === 0) { + const nearestIslandEnemy = this.findNearestIslandEnemy(); + if (nearestIslandEnemy) { + this.sendAttack(nearestIslandEnemy); + return; } + } + } - // Retaliate against incoming attacks - if (this.enemy === null) { - // Only after clearing bots - this.checkIncomingAttacks(); - } + // Sort neighboring bots by density (troops / tiles) and attempt to attack many of them (Parallel attacks) + // sendAttack will do nothing if we don't have enough reserve troops left + attackBots(): boolean { + const bots = this.player + .neighbors() + .filter( + (n): n is Player => + n.isPlayer() && + this.player.isFriendly(n) === false && + n.type() === PlayerType.Bot, + ); - // Select the most hated player - if (this.enemy === null && this.random.chance(2)) { - // 50% chance - const mostHated = this.player.allRelationsSorted()[0]; + if (bots.length === 0) { + return false; + } + + this.botAttackTroopsSent = 0; + + const density = (p: Player) => p.troops() / p.numTilesOwned(); + const sortedBots = bots.slice().sort((a, b) => density(a) - density(b)); + const reducedBots = sortedBots.slice(0, this.getBotAttackMaxParallelism()); + + for (const bot of reducedBots) { + this.sendAttack(bot); + } + + // Only short-circuit the rest of the targeting pipeline if we actually + // allocated some troops to bot attacks. + return this.botAttackTroopsSent > 0; + } + + getBotAttackMaxParallelism(): number { + const { difficulty } = this.game.config().gameConfig(); + switch (difficulty) { + case Difficulty.Easy: + return 1; + case Difficulty.Medium: + return 2; + case Difficulty.Hard: + return 4; + // On impossible difficulty, attack as much bots as possible in parallel + default: + return 100; + } + } + + // Betray friends if we have 10 times more troops than them + // TODO: Implement better and deeper strategies, for example: + // Check impact on relations with other players + // Check value of targets territory + // Check if target is distracted + // Check the targets territory size + maybeBetrayAndAttack(borderingFriends: Player[]): boolean { + if (borderingFriends.length > 0) { + for (const friend of borderingFriends) { if ( - mostHated !== undefined && - mostHated.relation === Relation.Hostile + this.player.isAlliedWith(friend) && + this.player.troops() >= friend.troops() * 10 ) { - this.setNewEnemy(mostHated.player); + this.betray(friend); + this.sendAttack(friend, true); + return true; } } + } + return false; + } - // Select the weakest player - if (this.enemy === null && borderingEnemies.length > 0) { - this.setNewEnemy(borderingEnemies[0]); - } + // TODO: Nuke the crown if it's far enough ahead of everybody else (based on difficulty) + findBestNukeTarget(borderingEnemies: Player[]): Player | null { + // Retaliate against incoming attacks (Most important!) + const incomingAttackPlayer = this.findIncomingAttackPlayer(); + if (incomingAttackPlayer) { + return incomingAttackPlayer; + } - // Select a random player - if (this.enemy === null && borderingEnemies.length > 0) { - this.setNewEnemy(this.random.randElement(borderingEnemies)); - } + // Find the most hated player with hostile relation + const mostHated = this.player.allRelationsSorted()[0]; + if ( + mostHated !== undefined && + mostHated.relation === Relation.Hostile && + this.player.isFriendly(mostHated.player) === false + ) { + return mostHated.player; + } - // If we don't have bordering enemies, we are on an island. Attack someone on an island next to us - if (this.enemy === null && borderingEnemies.length === 0) { - this.selectNearestIslandEnemy(); + // Find the weakest player + if (borderingEnemies.length > 0) { + return borderingEnemies[0]; + } + + // If we don't have bordering enemies, find someone on an island next to us + if (borderingEnemies.length === 0) { + const nearestIslandEnemy = this.findNearestIslandEnemy(); + if (nearestIslandEnemy) { + return nearestIslandEnemy; } } - // Sanity check, don't attack our allies or teammates - return this.enemySanityCheck(); + return null; } getPlayerCenter(player: Player) { @@ -279,9 +335,9 @@ export class BotBehavior { return calculateBoundingBoxCenter(this.game, player.borderTiles()); } - selectNearestIslandEnemy() { + findNearestIslandEnemy(): Player | null { const myBorder = this.player.borderTiles(); - if (myBorder.size === 0) return; + if (myBorder.size === 0) return null; const filteredPlayers = this.game.players().filter((p) => { if (p === this.player) return false; @@ -325,54 +381,64 @@ export class BotBehavior { } if (selectedEnemy !== null) { - this.setNewEnemy(selectedEnemy); + return selectedEnemy; } } + return null; + } + + attackRandomTarget() { + // Save up troops until we reach the trigger ratio + if (!this.hasTriggerRatioTroops()) return; + + // Retaliate against incoming attacks + const incomingAttackPlayer = this.findIncomingAttackPlayer(); + if (incomingAttackPlayer) { + this.sendAttack(incomingAttackPlayer, true); + return; + } + + // Select a traitor as an enemy + const toAttack = this.getNeighborTraitorToAttack(); + if (toAttack !== null) { + if (this.random.chance(3)) { + this.sendAttack(toAttack); + return; + } + } + + // Choose a new enemy randomly + const { difficulty } = this.game.config().gameConfig(); + const neighbors = this.player.neighbors(); + for (const neighbor of this.random.shuffleArray(neighbors)) { + if (!neighbor.isPlayer()) continue; + if (this.player.isFriendly(neighbor)) continue; + if ( + neighbor.type() === PlayerType.FakeHuman || + neighbor.type() === PlayerType.Human + ) { + if (this.random.chance(2) || difficulty === Difficulty.Easy) { + continue; + } + } + this.sendAttack(neighbor); + return; + } } - selectRandomEnemy(): Player | TerraNullius | null { - if (this.enemy === null) { - // Save up troops until we reach the trigger ratio - if (!this.hasTriggerRatioTroops()) return null; - - // Choose a new enemy randomly - const neighbors = this.player.neighbors(); - for (const neighbor of this.random.shuffleArray(neighbors)) { - if (!neighbor.isPlayer()) continue; - if (this.player.isFriendly(neighbor)) continue; - if (neighbor.type() === PlayerType.FakeHuman) { - if (this.random.chance(2)) { - continue; - } - } - this.setNewEnemy(neighbor); - } - - // Retaliate against incoming attacks - if (this.enemy === null) { - this.checkIncomingAttacks(); - } - - // Select a traitor as an enemy - if (this.enemy === null) { - const toAttack = this.getNeighborTraitorToAttack(); - if (toAttack !== null) { - if (!this.player.isFriendly(toAttack) && this.random.chance(3)) { - this.setNewEnemy(toAttack); - } + isBorderingNukedTerritory(): boolean { + for (const tile of this.player.borderTiles()) { + for (const neighbor of this.game.neighbors(tile)) { + if ( + this.game.isLand(neighbor) && + !this.game.hasOwner(neighbor) && + this.game.hasFallout(neighbor) + ) { + return true; } } } - - // Sanity check, don't attack our allies or teammates - return this.enemySanityCheck(); - } - - private enemySanityCheck(): Player | null { - if (this.enemy && this.player.isFriendly(this.enemy)) { - this.clearEnemy(); - } - return this.enemy; + return false; } forceSendAttack(target: Player | TerraNullius) { @@ -385,17 +451,41 @@ export class BotBehavior { ); } - sendAttack(target: Player | TerraNullius) { - // Skip attacking friendly targets (allies or teammates) - decision to break alliances should be made by caller - if (target.isPlayer() && this.player.isFriendly(target)) return; + sendAttack(target: Player | TerraNullius, force = false) { + if (!force && !this.shouldAttack(target)) return; + if (this.player.sharesBorderWith(target)) { + this.sendLandAttack(target); + } else if (target.isPlayer()) { + this.sendBoatAttack(target); + } + } + + sendLandAttack(target: Player | TerraNullius) { const maxTroops = this.game.config().maxTroops(this.player); const reserveRatio = target.isPlayer() ? this.reserveRatio : this.expandRatio; const targetTroops = maxTroops * reserveRatio; - const troops = this.player.troops() - targetTroops; - if (troops < 1) return; + + let troops; + if ( + target.isPlayer() && + target.type() === PlayerType.Bot && + this.player.type() !== PlayerType.Bot + ) { + troops = this.calculateBotAttackTroops( + target, + this.player.troops() - targetTroops - this.botAttackTroopsSent, + ); + } else { + troops = this.player.troops() - targetTroops; + } + + if (troops < 1) { + return; + } + this.game.addExecution( new AttackExecution( troops, @@ -403,6 +493,82 @@ export class BotBehavior { target.isPlayer() ? target.id() : this.game.terraNullius().id(), ), ); + + if (target.isPlayer()) { + this.maybeSendEmoji(target); + } + } + + sendBoatAttack(other: Player) { + const closest = closestTwoTiles( + this.game, + Array.from(this.player.borderTiles()).filter((t) => + this.game.isOceanShore(t), + ), + Array.from(other.borderTiles()).filter((t) => this.game.isOceanShore(t)), + ); + if (closest === null) { + return; + } + + let troops; + if (other.type() === PlayerType.Bot) { + troops = this.calculateBotAttackTroops(other, this.player.troops() / 5); + } else { + troops = this.player.troops() / 5; + } + + if (troops < 1) { + return; + } + + this.game.addExecution( + new TransportShipExecution( + this.player, + other.id(), + closest.y, + troops, + null, + ), + ); + + this.maybeSendEmoji(other); + } + + calculateBotAttackTroops(target: Player, maxTroops: number): number { + const { difficulty } = this.game.config().gameConfig(); + if (difficulty === Difficulty.Easy) { + this.botAttackTroopsSent += maxTroops; + return maxTroops; + } + let troops = target.troops() * 4; + + // Don't send more troops than maxTroops (Keep reserve) + if (troops > maxTroops) { + // If we haven't enough troops left to do a big enough bot attack, skip it + if (maxTroops < target.troops() * 2) { + troops = 0; + } else { + troops = maxTroops; + } + } + this.botAttackTroopsSent += troops; + return troops; + } + + maybeSendEmoji(enemy: Player) { + if (this.player.type() === PlayerType.Bot) return; + if (enemy.type() !== PlayerType.Human) return; + const lastSent = this.lastEmojiSent.get(enemy) ?? -300; + if (this.game.ticks() - lastSent <= 300) return; + this.lastEmojiSent.set(enemy, this.game.ticks()); + this.game.addExecution( + new EmojiExecution( + this.player, + enemy.id(), + this.random.randElement(EMOJI_HECKLE), + ), + ); } }