From 094aa766ced86c5c0aea46af4a4172b691ea92b7 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Mon, 15 Jun 2026 03:53:01 +0200 Subject: [PATCH] =?UTF-8?q?Improve=20"Better=20troop=20management=20for=20?= =?UTF-8?q?nations=20=F0=9F=A4=96"=20(#4278)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: **Allow Hard/Impossible nations to retaliate and expand freely** Previously, nations on Hard/Impossible difficulty could be stuck unable to fight back if their `troopSendCap` or `isAttackTooWeak` checks blocked them from sending enough troops. **@legan320** on the main discord noticed it. Now: - `troopSendCap` raises the cap to at least the total incoming attack troops, so nations can match the force being used against them - `isAttackTooWeak` bypasses the 20% minimum check entirely when under attack - `troopSendCap` no longer applies when attacking Terra Nullius, so nations can always expand into unowned land with full troops All checks still apply normally for unprovoked attacks against other players. ## 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 | 40 ++++++++++++++------ tests/AiAttackBehavior.test.ts | 34 +++++++++++++++++ 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/core/execution/utils/AiAttackBehavior.ts b/src/core/execution/utils/AiAttackBehavior.ts index 140fa6648..79e0ea674 100644 --- a/src/core/execution/utils/AiAttackBehavior.ts +++ b/src/core/execution/utils/AiAttackBehavior.ts @@ -137,12 +137,12 @@ export class AiAttackBehavior { } } - // Hard & Impossible: don't drop below neighbor troop threshold - const troops = Math.min(this.player.troops() / 5, this.troopSendCap()); + const owner = this.game.owner(dst); + const cap = owner.isPlayer() ? this.troopSendCap() : Infinity; + const troops = Math.min(this.player.troops() / 5, cap); if (troops < 1) return; // Hard & Impossible: don't attack if we'd send less than 20% of target's troops - const owner = this.game.owner(dst); if (owner.isPlayer() && this.isAttackTooWeak(troops, owner)) { return; } @@ -813,8 +813,7 @@ export class AiAttackBehavior { if (this.game.hasFallout(tile)) continue; if (!canBuildTransportShip(this.game, this.player, tile)) continue; - // Hard & Impossible: don't drop below neighbor troop threshold - const troops = Math.min(this.player.troops() / 5, this.troopSendCap()); + const troops = this.player.troops() / 5; if (troops < 1) return false; this.game.addExecution( @@ -859,7 +858,8 @@ export class AiAttackBehavior { if (this.player.type() === PlayerType.Bot) return false; if (this.game.config().gameConfig().gameMode === GameMode.Team) return false; - + // Nations under attack may retaliate freely + if (this.player.incomingAttacks().length > 0) return false; const { difficulty } = this.game.config().gameConfig(); return ( (difficulty === Difficulty.Hard || @@ -875,6 +875,9 @@ export class AiAttackBehavior { * Impossible: 90%). Allied players and bot neighbors are not considered * threats. Bots and team games are entirely exempt. Returns Infinity when * no cap applies. + * + * Nations under attack may retaliate with at least the total incoming + * attack troops, even if that exceeds the neighbor-based cap. */ private troopSendCap(): number { if (this.player.type() === PlayerType.Bot) return Infinity; @@ -905,10 +908,23 @@ export class AiAttackBehavior { maxNeighborTroops = n.troops(); } } - if (maxNeighborTroops === 0) return Infinity; - const minRetained = Math.ceil(maxNeighborTroops * retainFraction); - return Math.max(0, this.player.troops() - minRetained); + let cap: number; + if (maxNeighborTroops === 0) { + cap = Infinity; + } else { + const minRetained = Math.ceil(maxNeighborTroops * retainFraction); + cap = Math.max(0, this.player.troops() - minRetained); + } + + // Nations under attack may retaliate with at least the incoming troops + const incoming = this.player.incomingAttacks(); + if (incoming.length > 0) { + const totalIncoming = incoming.reduce((sum, a) => sum + a.troops(), 0); + cap = Math.max(cap, totalIncoming); + } + + return cap; } private sendLandAttack(target: Player | TerraNullius): boolean { @@ -937,8 +953,10 @@ export class AiAttackBehavior { troops = this.player.troops() - targetTroops; } - // Hard & Impossible: don't drop below neighbor troop threshold - troops = Math.min(troops, this.troopSendCap()); + // Hard & Impossible: don't drop below neighbor troop threshold (players only) + if (target.isPlayer()) { + troops = Math.min(troops, this.troopSendCap()); + } if (troops < 1) { return false; diff --git a/tests/AiAttackBehavior.test.ts b/tests/AiAttackBehavior.test.ts index 2a94853cb..6c5669ac7 100644 --- a/tests/AiAttackBehavior.test.ts +++ b/tests/AiAttackBehavior.test.ts @@ -1,3 +1,4 @@ +import { AttackExecution } from "../src/core/execution/AttackExecution"; import { NationEmojiBehavior } from "../src/core/execution/nation/NationEmojiBehavior"; import { AiAttackBehavior } from "../src/core/execution/utils/AiAttackBehavior"; import { @@ -567,4 +568,37 @@ describe("Hard/Impossible troop floor", () => { expect(exec).toBeDefined(); expect(exec.startTroops).toBeGreaterThan(0); }); + + it("Hard: nation under attack bypasses troopSendCap and isAttackTooWeak", async () => { + const { testGame, attacker, neighbor, behavior } = + await setupTroopFloorTest(Difficulty.Hard); + + // Neighbor has far more troops, so the normal cap would be 0 + attacker.addTroops(100_000); + neighbor.addTroops(200_000); + // Normal cap = max(0, 100k - ceil(200k * 0.75)) = max(0, 100k - 150k) = 0 + // Without the bypass, the nation couldn't attack at all. + const normalCap = Math.max( + 0, + attacker.troops() - Math.ceil(neighbor.troops() * 0.75), + ); + expect(normalCap).toBe(0); + + // Simulate the neighbor attacking with 50k troops + testGame.addExecution(new AttackExecution(50_000, neighbor, attacker.id())); + testGame.executeNextTick(); + expect(attacker.incomingAttacks().length).toBeGreaterThan(0); + + // With incoming attacks, troopSendCap raises to at least totalIncoming + const addExecSpy = vi.spyOn(testGame, "addExecution"); + const result = behavior.sendAttack(neighbor); + + expect(result).toBe(true); + const exec = addExecSpy.mock.calls.find( + (c) => c[0].constructor.name === "AttackExecution", + )?.[0] as any; + expect(exec).toBeDefined(); + // The bypass allows retaliation with at least the incoming 50k + expect(exec.startTroops).toBeGreaterThanOrEqual(50_000); + }); });