From 8a8079b979cfcf2242216a5ce403a4cab130c9cb Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:22:06 +0200 Subject: [PATCH] =?UTF-8?q?Fix=20parallel=20bot=20boat=20attacks=20of=20na?= =?UTF-8?q?tions=20=F0=9F=90=9B=20(#4297)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: When a nation attacked multiple bots via boat attacks in parallel, each boat attack computed its troop allocation independently using `player.troops() / 5` without subtracting `botAttackTroopsSent`. The cumulative troop commitment could exceed the nation's actual troop count, and when the queued `AttackExecution`s ran `init()`, they drained the nation to zero. Planetary Realignment found this bug by accident, here Russia has only 39 troops: image The land attack path already handled this correctly. The bug in `sendBoatAttack` was introduced by #3786, which made nations see and attack enemies across rivers via boats, and changed `attackBots()` from `.neighbors()` to `.nearby()`. So the bug was on prod for the entirety of v31. This fix extracts the shared attack troop calculation (reserve, bot-aware allocation, troopSendCap, isAttackTooWeak, emoji) into a new `calculateAttackTroops` method, with a callback for the non-bot troop default (land: `player.troops() - targetTroops`, boat: `player.troops() / 5`). Bot targets in both paths now go through the same reserve-aware calculation. ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- src/core/execution/utils/AiAttackBehavior.ts | 49 ++++++++++---------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/src/core/execution/utils/AiAttackBehavior.ts b/src/core/execution/utils/AiAttackBehavior.ts index 79e0ea674..59efba946 100644 --- a/src/core/execution/utils/AiAttackBehavior.ts +++ b/src/core/execution/utils/AiAttackBehavior.ts @@ -927,7 +927,10 @@ export class AiAttackBehavior { return cap; } - private sendLandAttack(target: Player | TerraNullius): boolean { + private calculateAttackTroops( + target: Player | TerraNullius, + nonBotTroops: (targetTroops: number) => number, + ): number | null { const maxTroops = this.game.config().maxTroops(this.player); const botWithStructures = target.isPlayer() && @@ -950,7 +953,7 @@ export class AiAttackBehavior { this.player.troops() - targetTroops - this.botAttackTroopsSent, ); } else { - troops = this.player.troops() - targetTroops; + troops = nonBotTroops(targetTroops); } // Hard & Impossible: don't drop below neighbor troop threshold (players only) @@ -959,12 +962,12 @@ export class AiAttackBehavior { } if (troops < 1) { - return false; + return null; } // Hard & Impossible: don't attack if we'd send less than 20% of target's troops if (target.isPlayer() && this.isAttackTooWeak(troops, target)) { - return false; + return null; } if (target.isPlayer() && this.player.type() === PlayerType.Nation) { @@ -972,6 +975,18 @@ export class AiAttackBehavior { this.emojiBehavior.maybeSendAttackEmoji(target); } + return troops; + } + + private sendLandAttack(target: Player | TerraNullius): boolean { + const troops = this.calculateAttackTroops( + target, + (targetTroops) => this.player.troops() - targetTroops, + ); + if (troops === null) { + return false; + } + this.game.addExecution( new AttackExecution( troops, @@ -1000,30 +1015,14 @@ export class AiAttackBehavior { return false; } - let troops; - if (target.type() === PlayerType.Bot) { - troops = this.calculateBotAttackTroops(target, this.player.troops() / 5); - } else { - troops = this.player.troops() / 5; - } - - // Hard & Impossible: don't drop below neighbor troop threshold - troops = Math.min(troops, this.troopSendCap()); - - if (troops < 1) { + const troops = this.calculateAttackTroops( + target, + () => this.player.troops() / 5, + ); + if (troops === null) { return false; } - // Hard & Impossible: don't attack if we'd send less than 20% of target's troops - if (this.isAttackTooWeak(troops, target)) { - return false; - } - - if (target.isPlayer() && this.player.type() === PlayerType.Nation) { - if (this.emojiBehavior === undefined) throw new Error("not initialized"); - this.emojiBehavior.maybeSendAttackEmoji(target); - } - this.game.addExecution( new TransportShipExecution(this.player, closest.y, troops), );