Improve "Better troop management for nations 🤖" (#4278)

## 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
This commit is contained in:
FloPinguin
2026-06-15 03:53:01 +02:00
committed by GitHub
parent 5161d78d84
commit 094aa766ce
2 changed files with 63 additions and 11 deletions
+29 -11
View File
@@ -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;
+34
View File
@@ -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);
});
});