diff --git a/src/core/execution/utils/AiAttackBehavior.ts b/src/core/execution/utils/AiAttackBehavior.ts index a52b56449..140fa6648 100644 --- a/src/core/execution/utils/AiAttackBehavior.ts +++ b/src/core/execution/utils/AiAttackBehavior.ts @@ -851,12 +851,15 @@ export class AiAttackBehavior { } /** - * 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. + * For Hard & Impossible nations in FFA: returns true if `troops` is less + * than 20% of the target's troop count, meaning the attack is too weak to + * be worthwhile. Bots and team games are exempt. */ private isAttackTooWeak(troops: number, target: Player): boolean { if (this.player.type() === PlayerType.Bot) return false; + if (this.game.config().gameConfig().gameMode === GameMode.Team) + return false; + const { difficulty } = this.game.config().gameConfig(); return ( (difficulty === Difficulty.Hard || @@ -866,14 +869,18 @@ export class AiAttackBehavior { } /** - * 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. + * For Hard & Impossible nations in FFA: 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 and team games are entirely exempt. Returns Infinity when + * no cap applies. */ private troopSendCap(): number { if (this.player.type() === PlayerType.Bot) return Infinity; + if (this.game.config().gameConfig().gameMode === GameMode.Team) + return Infinity; + const { difficulty } = this.game.config().gameConfig(); let retainFraction: number; switch (difficulty) { diff --git a/tests/AiAttackBehavior.test.ts b/tests/AiAttackBehavior.test.ts index 109f4add4..2a94853cb 100644 --- a/tests/AiAttackBehavior.test.ts +++ b/tests/AiAttackBehavior.test.ts @@ -3,6 +3,7 @@ import { AiAttackBehavior } from "../src/core/execution/utils/AiAttackBehavior"; import { Difficulty, Game, + GameMode, Player, PlayerInfo, PlayerType, @@ -406,4 +407,164 @@ describe("Hard/Impossible troop floor", () => { // No cap applies, so troops should be the full reserve amount expect(exec.startTroops).toBeGreaterThan(40_000); }); + + it("Team: troopSendCap returns Infinity — no cap in team games", async () => { + // Same setup as Hard cap test but with GameMode.Team + const testGame = await setup("big_plains", { + difficulty: Difficulty.Hard, + gameMode: GameMode.Team, + playerTeams: 2, + }); + + 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"); + + 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++; + }); + bot.addTroops(100); + + 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, + 0.3, + 0.2, + mockAlliance, + mockEmoji, + ); + + // In FFA Hard, attacker with 100k and neighbor with 90k would cap + // attack troops to 32.5k. In Team mode, troopSendCap returns Infinity + // so the attack is not capped by neighbor strength. + attacker.addTroops(100_000); + neighbor.addTroops(90_000); + + 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(); + // In FFA Hard, troops would be capped to 32.5k. In Team mode, no cap. + expect(exec.startTroops).toBeGreaterThan(32_500); + }); + + it("Team: isAttackTooWeak returns false — weak attacks allowed in team games", async () => { + // Same setup as the FFA "skips attack when capped troops are < 20%" test + // but with GameMode.Team. In FFA Hard, the attack would be blocked. + const testGame = await setup("big_plains", { + difficulty: Difficulty.Hard, + gameMode: GameMode.Team, + playerTeams: 2, + }); + + const attackerInfo = new PlayerInfo( + "attacker", + PlayerType.Nation, + null, + "attacker_id", + ); + const neighborInfo = new PlayerInfo( + "neighbor", + PlayerType.Human, + null, + "neighbor_id", + ); + testGame.addPlayer(attackerInfo); + testGame.addPlayer(neighborInfo); + + const attacker = testGame.player("attacker_id"); + const neighbor = testGame.player("neighbor_id"); + + // 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"); + + let assigned = 0; + testGame.map().forEachTile((tile) => { + if (assigned >= 90) return; + if (!testGame.map().isLand(tile)) return; + const players = [attacker, neighbor, target]; + players[assigned % 3].conquer(tile); + assigned++; + }); + + 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, + 0.3, + 0.2, + mockAlliance, + mockEmoji, + ); + + attacker.addTroops(100_000); + neighbor.addTroops(100_000); + target.addTroops(300_000); + // In FFA Hard: troopSendCap = 25k, 20% of target = 60k → blocked. + // In Team mode: isAttackTooWeak returns false, so the attack proceeds + // even though troops would be below 20% of the target. + + const addExecSpy = vi.spyOn(testGame, "addExecution"); + const result = behavior.sendAttack(target); + + expect(result).toBe(true); + const exec = addExecSpy.mock.calls.find( + (c) => c[0].constructor.name === "AttackExecution", + )?.[0] as any; + expect(exec).toBeDefined(); + expect(exec.startTroops).toBeGreaterThan(0); + }); });