mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:10:42 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { NationEmojiBehavior } from "../src/core/execution/nation/NationEmojiBehavior";
|
||||
import { AiAttackBehavior } from "../src/core/execution/utils/AiAttackBehavior";
|
||||
import { Game, Player, PlayerInfo, PlayerType } from "../src/core/game/Game";
|
||||
import {
|
||||
Difficulty,
|
||||
Game,
|
||||
Player,
|
||||
PlayerInfo,
|
||||
PlayerType,
|
||||
} from "../src/core/game/Game";
|
||||
import { PseudoRandom } from "../src/core/PseudoRandom";
|
||||
import { setup } from "./util/Setup";
|
||||
|
||||
@@ -161,3 +167,243 @@ describe("Ai Attack Behavior", () => {
|
||||
expect(nation.outgoingAttacks()).toHaveLength(attacksBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hard/Impossible troop floor", () => {
|
||||
/**
|
||||
* Sets up a game where a nation attacker borders a neighbor and a bot target.
|
||||
* All players get alternating land tiles so they share borders.
|
||||
*/
|
||||
async function setupTroopFloorTest(difficulty: Difficulty) {
|
||||
const testGame = await setup("big_plains", {
|
||||
difficulty,
|
||||
});
|
||||
|
||||
const attackerInfo = new PlayerInfo(
|
||||
"attacker",
|
||||
PlayerType.Nation,
|
||||
null,
|
||||
"attacker_id",
|
||||
);
|
||||
const neighborInfo = new PlayerInfo(
|
||||
"neighbor",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"neighbor_id",
|
||||
);
|
||||
const botInfo = new PlayerInfo(
|
||||
"target_bot",
|
||||
PlayerType.Bot,
|
||||
null,
|
||||
"bot_id",
|
||||
);
|
||||
testGame.addPlayer(attackerInfo);
|
||||
testGame.addPlayer(neighborInfo);
|
||||
testGame.addPlayer(botInfo);
|
||||
|
||||
const attacker = testGame.player("attacker_id");
|
||||
const neighbor = testGame.player("neighbor_id");
|
||||
const bot = testGame.player("bot_id");
|
||||
|
||||
// Assign alternating tiles so all three share borders
|
||||
let assigned = 0;
|
||||
testGame.map().forEachTile((tile) => {
|
||||
if (assigned >= 90) return;
|
||||
if (!testGame.map().isLand(tile)) return;
|
||||
const players = [attacker, neighbor, bot];
|
||||
players[assigned % 3].conquer(tile);
|
||||
assigned++;
|
||||
});
|
||||
|
||||
// Give bot target a tiny amount of troops so it's a valid target
|
||||
bot.addTroops(100);
|
||||
|
||||
// Nation type requires alliance and emoji behaviors
|
||||
const mockEmoji = {
|
||||
maybeSendAttackEmoji: vi.fn(),
|
||||
sendEmoji: vi.fn(),
|
||||
} as any;
|
||||
const mockAlliance = { maybeBetray: vi.fn() } as any;
|
||||
|
||||
const behavior = new AiAttackBehavior(
|
||||
new PseudoRandom(42),
|
||||
testGame,
|
||||
attacker,
|
||||
0.5, // triggerRatio
|
||||
0.3, // reserveRatio
|
||||
0.2, // expandRatio
|
||||
mockAlliance,
|
||||
mockEmoji,
|
||||
);
|
||||
|
||||
return { testGame, attacker, neighbor, bot, behavior };
|
||||
}
|
||||
|
||||
it("Hard: caps attack troops so nation retains 75% of strongest neighbor's troops", async () => {
|
||||
const { testGame, attacker, neighbor, behavior } =
|
||||
await setupTroopFloorTest(Difficulty.Hard);
|
||||
|
||||
attacker.addTroops(100_000);
|
||||
neighbor.addTroops(90_000);
|
||||
|
||||
const addExecSpy = vi.spyOn(testGame, "addExecution");
|
||||
// Attack the neighbor directly (already shares border, is Human type)
|
||||
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();
|
||||
// Nation must retain at least 75% of strongest non-allied neighbor's troops
|
||||
const minRetained = Math.ceil(neighbor.troops() * 0.75);
|
||||
const expectedCap = Math.max(0, attacker.troops() - minRetained);
|
||||
expect(exec.startTroops).toBeLessThanOrEqual(expectedCap);
|
||||
});
|
||||
|
||||
it("Hard: prevents attack when nation troops < 75% of strongest neighbor", async () => {
|
||||
const { testGame, attacker, neighbor, bot, behavior } =
|
||||
await setupTroopFloorTest(Difficulty.Hard);
|
||||
|
||||
// Attacker has fewer troops than 75% of neighbor
|
||||
attacker.addTroops(3_000);
|
||||
neighbor.addTroops(5_000);
|
||||
// minRetained = ceil(5_000 * 0.75) = 3_750
|
||||
// troopSendCap = max(0, 3_000 - 3_750) = 0
|
||||
// Attack should be blocked entirely
|
||||
|
||||
const addExecSpy = vi.spyOn(testGame, "addExecution");
|
||||
const result = behavior.sendAttack(bot);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(addExecSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Hard: skips attack when capped troops are < 20% of target's troops", async () => {
|
||||
const { testGame, attacker, neighbor, behavior } =
|
||||
await setupTroopFloorTest(Difficulty.Hard);
|
||||
|
||||
// Add a strong human target sharing borders
|
||||
const targetInfo = new PlayerInfo(
|
||||
"strong_target",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"target_id",
|
||||
);
|
||||
testGame.addPlayer(targetInfo);
|
||||
const target = testGame.player("target_id");
|
||||
|
||||
// Give target some tiles from the attacker's pool
|
||||
let stolen = 0;
|
||||
for (const tile of Array.from(attacker.tiles())) {
|
||||
if (stolen >= 20) break;
|
||||
target.conquer(tile);
|
||||
stolen++;
|
||||
}
|
||||
|
||||
attacker.addTroops(100_000);
|
||||
neighbor.addTroops(100_000);
|
||||
target.addTroops(300_000);
|
||||
// troopSendCap = 100_000 - ceil(100_000 * 0.75) = 25_000
|
||||
// 20% of target = 300_000 * 0.2 = 60_000
|
||||
// 25_000 < 60_000 → attack should be blocked
|
||||
|
||||
const addExecSpy = vi.spyOn(testGame, "addExecution");
|
||||
const result = behavior.sendAttack(target);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(addExecSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Impossible: caps attack troops so nation retains 90% of strongest neighbor's troops", async () => {
|
||||
const { testGame, attacker, neighbor, behavior } =
|
||||
await setupTroopFloorTest(Difficulty.Impossible);
|
||||
|
||||
attacker.addTroops(100_000);
|
||||
neighbor.addTroops(90_000);
|
||||
|
||||
const addExecSpy = vi.spyOn(testGame, "addExecution");
|
||||
// Attack the neighbor directly (already shares border, is Human type)
|
||||
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();
|
||||
// Nation must retain at least 90% of strongest non-allied neighbor's troops
|
||||
const minRetained = Math.ceil(neighbor.troops() * 0.9);
|
||||
const expectedCap = Math.max(0, attacker.troops() - minRetained);
|
||||
expect(exec.startTroops).toBeLessThanOrEqual(expectedCap);
|
||||
});
|
||||
|
||||
it("Easy: no troop floor — sends based on reserve only", async () => {
|
||||
const { testGame, attacker, neighbor, bot, behavior } =
|
||||
await setupTroopFloorTest(Difficulty.Easy);
|
||||
|
||||
attacker.addTroops(100_000);
|
||||
neighbor.addTroops(90_000);
|
||||
// No cap on Easy — sends full reserve amount
|
||||
|
||||
const addExecSpy = vi.spyOn(testGame, "addExecution");
|
||||
const result = behavior.sendAttack(bot);
|
||||
|
||||
expect(result).toBe(true);
|
||||
const exec = addExecSpy.mock.calls.find(
|
||||
(c) => c[0].constructor.name === "AttackExecution",
|
||||
)?.[0] as any;
|
||||
expect(exec).toBeDefined();
|
||||
// On Easy, no troop floor applies — troops are only limited by the reserve ratio
|
||||
expect(exec.startTroops).toBeGreaterThan(0);
|
||||
// Verify the troops exceed what the Hard cap would have been
|
||||
const hardCap = Math.max(
|
||||
0,
|
||||
attacker.troops() - Math.ceil(neighbor.troops() * 0.75),
|
||||
);
|
||||
expect(exec.startTroops).toBeGreaterThan(hardCap);
|
||||
});
|
||||
|
||||
it("Hard: sendAttack uncapped when nation has no player neighbors", async () => {
|
||||
const testGame = await setup("big_plains", {
|
||||
infiniteGold: true,
|
||||
instantBuild: true,
|
||||
difficulty: Difficulty.Hard,
|
||||
});
|
||||
|
||||
// Give bot only half the land so there's unowned land to attack via sendAttack
|
||||
const botInfo = new PlayerInfo("lone_bot", PlayerType.Bot, null, "lone_id");
|
||||
testGame.addPlayer(botInfo);
|
||||
const bot = testGame.player("lone_id");
|
||||
let assigned = 0;
|
||||
testGame.map().forEachTile((tile) => {
|
||||
if (!testGame.map().isLand(tile)) return;
|
||||
if (assigned % 2 === 0) bot.conquer(tile);
|
||||
assigned++;
|
||||
});
|
||||
bot.addTroops(100_000);
|
||||
|
||||
// No player neighbors — troopSendCap should return Infinity
|
||||
expect(bot.nearby().filter((n) => n.isPlayer()).length).toBe(0);
|
||||
|
||||
const behavior = new AiAttackBehavior(
|
||||
new PseudoRandom(42),
|
||||
testGame,
|
||||
bot,
|
||||
0.5,
|
||||
0.3,
|
||||
0.2,
|
||||
);
|
||||
|
||||
const addExecSpy = vi.spyOn(testGame, "addExecution");
|
||||
// sendAttack goes through sendLandAttack which applies troopSendCap.
|
||||
// With no player neighbors, troopSendCap returns Infinity (no cap).
|
||||
const result = behavior.sendAttack(testGame.terraNullius());
|
||||
|
||||
expect(result).toBe(true);
|
||||
const exec = addExecSpy.mock.calls.find(
|
||||
(c) => c[0].constructor.name === "AttackExecution",
|
||||
)?.[0] as any;
|
||||
expect(exec).toBeDefined();
|
||||
// No cap applies, so troops should be the full reserve amount
|
||||
expect(exec.startTroops).toBeGreaterThan(40_000);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user