Better troop management for nations 🤖 (#4239)

## Description:

When human pro players have non-allied players with similar troops next
to them, they wouldn't send out a big attack.

But nations are doing exactly that.

With this PR, they no longer do. On hard and impossible.
On easy and medium they are stupid 😀

```
1. Troop send cap: the nation must retain a minimum fraction of its
   strongest non-allied neighbor's troop count (Hard: 75%, Impossible:
   90%). Attacks that would drop below this floor are scaled down or
   skipped entirely. Allied and same-team neighbors are ignored since
   they pose no threat. The cap applies to land attacks, boat attacks,
   and random boat attacks.

2. Minimum attack strength: if the capped troop count is less than 20%
   of the target's troop count, the attack is skipped as too weak to be
   worthwhile. Only applies on Hard and Impossible.
```

_Coded by MiMo 2.5 Pro, reviewed by MiniMax M3_

## 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-12 18:35:43 +02:00
committed by GitHub
parent 71af72606a
commit d96c055df1
2 changed files with 330 additions and 4 deletions
+83 -3
View File
@@ -137,10 +137,19 @@ export class AiAttackBehavior {
}
}
// Hard & Impossible: don't drop below neighbor troop threshold
const troops = Math.min(this.player.troops() / 5, this.troopSendCap());
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;
}
this.game.addExecution(
new TransportShipExecution(this.player, dst, this.player.troops() / 5),
new TransportShipExecution(this.player, dst, troops),
);
return;
}
private findRandomBoatTarget(
@@ -804,7 +813,8 @@ export class AiAttackBehavior {
if (this.game.hasFallout(tile)) continue;
if (!canBuildTransportShip(this.game, this.player, tile)) continue;
const troops = this.player.troops() / 5;
// Hard & Impossible: don't drop below neighbor troop threshold
const troops = Math.min(this.player.troops() / 5, this.troopSendCap());
if (troops < 1) return false;
this.game.addExecution(
@@ -840,6 +850,60 @@ export class AiAttackBehavior {
return true;
}
/**
* For Hard & Impossible nations: returns true if `troops` is less than 20%
* of the target's troop count, meaning the attack is too weak to be
* worthwhile. Bots are exempt.
*/
private isAttackTooWeak(troops: number, target: Player): boolean {
if (this.player.type() === PlayerType.Bot) return false;
const { difficulty } = this.game.config().gameConfig();
return (
(difficulty === Difficulty.Hard ||
difficulty === Difficulty.Impossible) &&
troops < target.troops() * 0.2
);
}
/**
* For Hard & Impossible nations: computes the max troops this nation can send
* in an attack without letting its troop count drop below a fraction of its
* strongest non-allied neighbor's troop count (Hard: 75%, Impossible: 90%).
* Allied players and bot neighbors are not considered threats.
* Bots are entirely exempt. Returns Infinity when no cap applies.
*/
private troopSendCap(): number {
if (this.player.type() === PlayerType.Bot) return Infinity;
const { difficulty } = this.game.config().gameConfig();
let retainFraction: number;
switch (difficulty) {
case Difficulty.Hard:
retainFraction = 0.75;
break;
case Difficulty.Impossible:
retainFraction = 0.9;
break;
default:
return Infinity;
}
let maxNeighborTroops = 0;
for (const n of this.player.nearby()) {
if (
n.isPlayer() &&
!this.player.isFriendly(n) &&
n.type() !== PlayerType.Bot &&
n.troops() > maxNeighborTroops
) {
maxNeighborTroops = n.troops();
}
}
if (maxNeighborTroops === 0) return Infinity;
const minRetained = Math.ceil(maxNeighborTroops * retainFraction);
return Math.max(0, this.player.troops() - minRetained);
}
private sendLandAttack(target: Player | TerraNullius): boolean {
const maxTroops = this.game.config().maxTroops(this.player);
const botWithStructures =
@@ -866,10 +930,18 @@ export class AiAttackBehavior {
troops = this.player.troops() - targetTroops;
}
// Hard & Impossible: don't drop below neighbor troop threshold
troops = Math.min(troops, this.troopSendCap());
if (troops < 1) {
return false;
}
// 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;
}
if (target.isPlayer() && this.player.type() === PlayerType.Nation) {
if (this.emojiBehavior === undefined) throw new Error("not initialized");
this.emojiBehavior.maybeSendAttackEmoji(target);
@@ -910,10 +982,18 @@ export class AiAttackBehavior {
troops = this.player.troops() / 5;
}
// Hard & Impossible: don't drop below neighbor troop threshold
troops = Math.min(troops, this.troopSendCap());
if (troops < 1) {
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);