diff --git a/src/core/Util.ts b/src/core/Util.ts index 6524e300c..d90516af6 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -148,9 +148,13 @@ export function calculateBoundingBoxCenter( borderTiles: ReadonlySet, ): Cell { const { min, max } = calculateBoundingBox(gm, borderTiles); + return boundingBoxCenter({ min, max }); +} + +export function boundingBoxCenter(box: { min: Cell; max: Cell }): Cell { return new Cell( - min.x + Math.floor((max.x - min.x) / 2), - min.y + Math.floor((max.y - min.y) / 2), + box.min.x + Math.floor((box.max.x - box.min.x) / 2), + box.min.y + Math.floor((box.max.y - box.min.y) / 2), ); } diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index 2178af231..b43114062 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -197,43 +197,44 @@ export class FakeHumanExecution implements Execution { this.mg.isLand(t) && this.mg.ownerID(t) !== this.player?.smallID(), ); + let borderingEnemies: Player[] = []; if (enemyborder.length === 0) { - if (this.random.chance(10)) { + if (this.random.chance(5)) { this.sendBoatRandomly(); } - return; - } - if (this.random.chance(20)) { - this.sendBoatRandomly(); - return; - } + } else { + if (this.random.chance(10)) { + this.sendBoatRandomly(); + return; + } - const borderPlayers = enemyborder.map((t) => - this.mg.playerBySmallID(this.mg.ownerID(t)), - ); - if (borderPlayers.some((o) => !o.isPlayer())) { - this.behavior.sendAttack(this.mg.terraNullius()); - return; - } + const borderPlayers = enemyborder.map((t) => + this.mg.playerBySmallID(this.mg.ownerID(t)), + ); + if (borderPlayers.some((o) => !o.isPlayer())) { + this.behavior.sendAttack(this.mg.terraNullius()); + return; + } - const enemies = borderPlayers - .filter((o) => o.isPlayer()) - .sort((a, b) => a.troops() - b.troops()); + borderingEnemies = borderPlayers + .filter((o) => o.isPlayer()) + .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)) { - this.mg.addExecution( - new AllianceRequestExecution(this.player, toAlly.id()), - ); + // 5% chance to send a random alliance request + if (this.random.chance(20)) { + const toAlly = this.random.randElement(borderingEnemies); + if (this.player.canSendAllianceRequest(toAlly)) { + this.mg.addExecution( + new AllianceRequestExecution(this.player, toAlly.id()), + ); + } } } this.behavior.forgetOldEnemies(); this.behavior.assistAllies(); - const enemy = this.behavior.selectEnemy(enemies); + const enemy = this.behavior.selectEnemy(borderingEnemies); if (!enemy) return; this.maybeSendEmoji(enemy); this.maybeSendNuke(enemy); @@ -592,9 +593,14 @@ export class FakeHumanExecution implements Execution { const src = this.random.randElement(oceanShore); - const dst = this.randomBoatTarget(src, 150); + // First look for high-interest targets (unowned or bot-owned). Mainly relevant for earlygame + let dst = this.randomBoatTarget(src, 150, true); if (dst === null) { - return; + // None found? Then look for players + dst = this.randomBoatTarget(src, 150, false); + if (dst === null) { + return; + } } this.mg.addExecution( @@ -634,7 +640,11 @@ export class FakeHumanExecution implements Execution { return null; } - private randomBoatTarget(tile: TileRef, dist: number): TileRef | null { + private randomBoatTarget( + tile: TileRef, + dist: number, + highInterestOnly: boolean = false, + ): TileRef | null { if (this.player === null) throw new Error("not initialized"); const x = this.mg.x(tile); const y = this.mg.y(tile); @@ -649,11 +659,23 @@ export class FakeHumanExecution implements Execution { continue; } const owner = this.mg.owner(randTile); - if (!owner.isPlayer()) { - return randTile; + if (owner === this.player) { + continue; } - if (!owner.isFriendly(this.player)) { - return randTile; + // Don't spam boats into players that are more than twice as large as us + if (owner.isPlayer() && owner.troops() > this.player.troops() * 2) { + continue; + } + // High-interest targeting: prioritize unowned tiles or tiles owned by bots + if (highInterestOnly) { + if (!owner.isPlayer() || owner.type() === PlayerType.Bot) { + return randTile; + } + } else { + // Normal targeting: return unowned tiles or tiles owned by non-friendly players + if (!owner.isPlayer() || !owner.isFriendly(this.player)) { + return randTile; + } } } return null; diff --git a/src/core/execution/utils/BotBehavior.ts b/src/core/execution/utils/BotBehavior.ts index 2b914db6a..e748b9f4a 100644 --- a/src/core/execution/utils/BotBehavior.ts +++ b/src/core/execution/utils/BotBehavior.ts @@ -9,7 +9,11 @@ import { Tick, } from "../../game/Game"; import { PseudoRandom } from "../../PseudoRandom"; -import { flattenedEmojiTable } from "../../Util"; +import { + boundingBoxCenter, + calculateBoundingBoxCenter, + flattenedEmojiTable, +} from "../../Util"; import { AllianceExtensionExecution } from "../alliance/AllianceExtensionExecution"; import { AttackExecution } from "../AttackExecution"; import { EmojiExecution } from "../EmojiExecution"; @@ -198,7 +202,7 @@ export class BotBehavior { } } - selectEnemy(enemies: Player[]): Player | null { + selectEnemy(borderingEnemies: Player[]): Player | null { if (this.enemy === null) { // Save up troops until we reach the reserve ratio if (!this.hasReserveRatioTroops()) return null; @@ -249,13 +253,18 @@ export class BotBehavior { } // Select the weakest player - if (this.enemy === null && enemies.length > 0) { - this.setNewEnemy(enemies[0]); + if (this.enemy === null && borderingEnemies.length > 0) { + this.setNewEnemy(borderingEnemies[0]); } // Select a random player - if (this.enemy === null && enemies.length > 0) { - this.setNewEnemy(this.random.randElement(enemies)); + if (this.enemy === null && borderingEnemies.length > 0) { + this.setNewEnemy(this.random.randElement(borderingEnemies)); + } + + // 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(); } } @@ -263,6 +272,64 @@ export class BotBehavior { return this.enemySanityCheck(); } + getPlayerCenter(player: Player) { + if (player.largestClusterBoundingBox) { + return boundingBoxCenter(player.largestClusterBoundingBox); + } + return calculateBoundingBoxCenter(this.game, player.borderTiles()); + } + + selectNearestIslandEnemy() { + const myBorder = this.player.borderTiles(); + if (myBorder.size === 0) return; + + const filteredPlayers = this.game.players().filter((p) => { + if (p === this.player) return false; + if (!p.isAlive()) return false; + if (p.borderTiles().size === 0) return false; + if (this.player.isFriendly(p)) return false; + // Don't spam boats into players more than 2x our troops + return p.troops() <= this.player.troops() * 2; + }); + + if (filteredPlayers.length > 0) { + const playerCenter = this.getPlayerCenter(this.player); + + const sortedPlayers = filteredPlayers + .map((filteredPlayer) => { + const filteredPlayerCenter = this.getPlayerCenter(filteredPlayer); + + const playerCenterTile = this.game.ref( + playerCenter.x, + playerCenter.y, + ); + const filteredPlayerCenterTile = this.game.ref( + filteredPlayerCenter.x, + filteredPlayerCenter.y, + ); + + const distance = this.game.manhattanDist( + playerCenterTile, + filteredPlayerCenterTile, + ); + return { player: filteredPlayer, distance }; + }) + .sort((a, b) => a.distance - b.distance); // Sort by distance (ascending) + + // Select the nearest or second-nearest enemy (So our boat doesn't always run into the same warship, if there is one) + let selectedEnemy: Player | null; + if (sortedPlayers.length > 1 && this.random.chance(2)) { + selectedEnemy = sortedPlayers[1].player; + } else { + selectedEnemy = sortedPlayers[0].player; + } + + if (selectedEnemy !== null) { + this.setNewEnemy(selectedEnemy); + } + } + } + selectRandomEnemy(): Player | TerraNullius | null { if (this.enemy === null) { // Save up troops until we reach the trigger ratio