Fix parallel bot boat attacks of nations 🐛 (#4297)

## 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:

<img width="1189" height="654" alt="image"
src="https://github.com/user-attachments/assets/07b85e00-6734-4ddd-a16e-fe53309e0ef8"
/>

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
This commit is contained in:
FloPinguin
2026-06-16 19:22:06 +02:00
committed by GitHub
parent 6833cef7bc
commit 8a8079b979
+24 -25
View File
@@ -927,7 +927,10 @@ export class AiAttackBehavior {
return cap; 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 maxTroops = this.game.config().maxTroops(this.player);
const botWithStructures = const botWithStructures =
target.isPlayer() && target.isPlayer() &&
@@ -950,7 +953,7 @@ export class AiAttackBehavior {
this.player.troops() - targetTroops - this.botAttackTroopsSent, this.player.troops() - targetTroops - this.botAttackTroopsSent,
); );
} else { } else {
troops = this.player.troops() - targetTroops; troops = nonBotTroops(targetTroops);
} }
// Hard & Impossible: don't drop below neighbor troop threshold (players only) // Hard & Impossible: don't drop below neighbor troop threshold (players only)
@@ -959,12 +962,12 @@ export class AiAttackBehavior {
} }
if (troops < 1) { if (troops < 1) {
return false; return null;
} }
// Hard & Impossible: don't attack if we'd send less than 20% of target's troops // Hard & Impossible: don't attack if we'd send less than 20% of target's troops
if (target.isPlayer() && this.isAttackTooWeak(troops, target)) { if (target.isPlayer() && this.isAttackTooWeak(troops, target)) {
return false; return null;
} }
if (target.isPlayer() && this.player.type() === PlayerType.Nation) { if (target.isPlayer() && this.player.type() === PlayerType.Nation) {
@@ -972,6 +975,18 @@ export class AiAttackBehavior {
this.emojiBehavior.maybeSendAttackEmoji(target); 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( this.game.addExecution(
new AttackExecution( new AttackExecution(
troops, troops,
@@ -1000,30 +1015,14 @@ export class AiAttackBehavior {
return false; return false;
} }
let troops; const troops = this.calculateAttackTroops(
if (target.type() === PlayerType.Bot) { target,
troops = this.calculateBotAttackTroops(target, this.player.troops() / 5); () => this.player.troops() / 5,
} else { );
troops = this.player.troops() / 5; if (troops === null) {
}
// Hard & Impossible: don't drop below neighbor troop threshold
troops = Math.min(troops, this.troopSendCap());
if (troops < 1) {
return false; 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( this.game.addExecution(
new TransportShipExecution(this.player, closest.y, troops), new TransportShipExecution(this.player, closest.y, troops),
); );