mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 06:10:42 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user