Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 29 additions & 11 deletions src/core/execution/utils/AiAttackBehavior.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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;
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 ||
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
34 changes: 34 additions & 0 deletions tests/AiAttackBehavior.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
Loading