From d96c055df17c74cbd863d50c03bcd84a5a264e1a Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:35:43 +0200 Subject: [PATCH] =?UTF-8?q?Better=20troop=20management=20for=20nations=20?= =?UTF-8?q?=F0=9F=A4=96=20(#4239)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- src/core/execution/utils/AiAttackBehavior.ts | 86 ++++++- tests/AiAttackBehavior.test.ts | 248 ++++++++++++++++++- 2 files changed, 330 insertions(+), 4 deletions(-) diff --git a/src/core/execution/utils/AiAttackBehavior.ts b/src/core/execution/utils/AiAttackBehavior.ts index 111585693..a52b56449 100644 --- a/src/core/execution/utils/AiAttackBehavior.ts +++ b/src/core/execution/utils/AiAttackBehavior.ts @@ -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); diff --git a/tests/AiAttackBehavior.test.ts b/tests/AiAttackBehavior.test.ts index fe20fbfbc..109f4add4 100644 --- a/tests/AiAttackBehavior.test.ts +++ b/tests/AiAttackBehavior.test.ts @@ -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); + }); +});