Skip to content
Open
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
6 changes: 4 additions & 2 deletions src/core/execution/AttackExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,8 +367,10 @@ export class AttackExecution implements Execution {

this.mg.conquerPlayer(this._owner, target);

for (let i = 0; i < 10; i++) {
for (const tile of target.tiles()) {
for (let i = 0; i < 100; i++) {
const remainingTiles = Array.from(target.tiles());
if (remainingTiles.length === 0) break;
for (const tile of remainingTiles) {
Comment on lines +370 to +373
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Add regression tests for this core simulation change

This fixes a real runtime edge case, but the PR includes no tests for the new sweep behavior (snapshot iteration + 100-pass/early-break flow). Please add deterministic tests that cover at least: (1) live-collection mutation safety, (2) full absorption for larger dead-defender tile sets, and (3) stable termination when no tiles remain.

As per coding guidelines: “All changes to src/core/ must include tests”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/execution/AttackExecution.ts` around lines 370 - 373, Add
deterministic regression tests for the new sweep iteration logic in
AttackExecution (the loop that calls target.tiles() and does up to 100 passes).
Create tests that (1) mutate the live collection during iteration to verify safe
snapshot behavior by asserting no runtime errors and that mutated tiles are
processed as expected, (2) simulate a larger dead-defender tile set to confirm
full absorption (all defender tiles removed) after the sweep completes, and (3)
verify stable termination when no tiles remain by asserting the loop exits early
(fewer than 100 passes) and no infinite loop occurs; use a fixed board/state or
seeded RNG to ensure determinism and reference the AttackExecution class and its
sweep behavior (the for-loop using target.tiles()) in test names and assertions.

let borders = false;
this.mg.forEachNeighbor(tile, (t) => {
if (!borders && this.mg.owner(t) === this._owner) {
Expand Down
143 changes: 143 additions & 0 deletions tests/HandleDeadDefender.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { AttackExecution } from "../src/core/execution/AttackExecution";
import { SpawnExecution } from "../src/core/execution/SpawnExecution";
import { Game, Player, PlayerInfo, PlayerType } from "../src/core/game/Game";
import { TileRef } from "../src/core/game/GameMap";
import { GameID } from "../src/core/Schemas";
import { setup } from "./util/Setup";

/**
* Regression tests for BUG-02: handleDeadDefender sweep logic.
*
* These tests verify that:
* 1. Iterating over target.tiles() with Array.from() prevents iterator
* invalidation when conquer() modifies the live collection.
* 2. A defender with many tiles is fully absorbed (all tiles transferred).
* 3. The loop terminates early when no tiles remain (no infinite loop).
*/

let game: Game;
const gameID: GameID = "game_id";
let attacker: Player;
let defender: Player;

describe("handleDeadDefender sweep (BUG-02 regression)", () => {
beforeEach(async () => {
game = await setup("plains", {
infiniteGold: true,
instantBuild: true,
infiniteTroops: true,
});

const attackerInfo = new PlayerInfo(
"attacker",
PlayerType.Human,
null,
"attacker_id",
);
const defenderInfo = new PlayerInfo(
"defender",
PlayerType.Human,
null,
"defender_id",
);

game.addPlayer(attackerInfo);
game.addPlayer(defenderInfo);

// Spawn attacker at (0, 0) region
const attackerSpawn = game.ref(0, 0);
game.addExecution(new SpawnExecution(gameID, attackerInfo, attackerSpawn));
game.executeNextTick();

// Spawn defender adjacent at (0, 5)
const defenderSpawn = game.ref(0, 5);
game.addExecution(new SpawnExecution(gameID, defenderInfo, defenderSpawn));
game.executeNextTick();

attacker = game.player(attackerInfo.id);
defender = game.player(defenderInfo.id);
});

test("should fully absorb a dead defender without runtime errors (snapshot safety)", async () => {
// Give defender extra tiles to create a larger territory
let extraTiles = 0;
game.map().forEachTile((tile) => {
if (extraTiles >= 30) return;
if (
game.owner(tile) !== attacker &&
game.owner(tile) !== defender &&
game.map().isLand(tile)
) {
defender.conquer(tile);
extraTiles++;
}
});

const defenderTilesBefore = defender.numTilesOwned();
expect(defenderTilesBefore).toBeGreaterThan(10);

// Launch a massive attack to kill the defender
game.addExecution(new AttackExecution(null, attacker, defender.id(), null));

// Run enough ticks for the attack to finish and handleDeadDefender to fire
for (let i = 0; i < 500; i++) {
game.executeNextTick();
if (!defender.isAlive()) break;
}

// After handleDeadDefender, defender should have no tiles
expect(defender.isAlive()).toBe(false);
expect(defender.numTilesOwned()).toBe(0);
});

test("should complete sweep without infinite loop (early break when empty)", async () => {
// Attack and kill the defender
game.addExecution(new AttackExecution(null, attacker, defender.id(), null));

const startTime = Date.now();

for (let i = 0; i < 500; i++) {
game.executeNextTick();
if (!defender.isAlive()) break;
}

const elapsed = Date.now() - startTime;

// The sweep should complete quickly (no infinite loop)
// 500 ticks should not take more than 10 seconds even on slow machines
expect(elapsed).toBeLessThan(10000);
expect(defender.numTilesOwned()).toBe(0);
});

test("should transfer all defender tiles to attacker or neighbors after sweep", async () => {
// Give defender some extra tiles adjacent to attacker territory
let extraTiles = 0;
const defenderExtraTiles: TileRef[] = [];
game.map().forEachTile((tile) => {
if (extraTiles >= 20) return;
if (
game.owner(tile) !== attacker &&
game.owner(tile) !== defender &&
game.map().isLand(tile)
) {
defender.conquer(tile);
defenderExtraTiles.push(tile);
extraTiles++;
}
});

// Attack and kill the defender
game.addExecution(new AttackExecution(null, attacker, defender.id(), null));

for (let i = 0; i < 500; i++) {
game.executeNextTick();
if (!defender.isAlive()) break;
}

// All defender tiles should now belong to someone other than defender
expect(defender.numTilesOwned()).toBe(0);

// Attacker should have gained tiles
expect(attacker.numTilesOwned()).toBeGreaterThan(0);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Strengthen the assertion to verify tile transfer.

The assertion checks that the attacker has more than zero tiles, but the attacker already owns tiles from spawning (line 48-50). This doesn't prove that tiles were transferred from the defender during the sweep.

Consider capturing the attacker's tile count before the attack and asserting that it increased:

💪 Suggested improvement
+    const attackerTilesBefore = attacker.numTilesOwned();
+
     // Attack and kill the defender
     game.addExecution(new AttackExecution(null, attacker, defender.id(), null));

     for (let i = 0; i < 500; i++) {
       game.executeNextTick();
       if (!defender.isAlive()) break;
     }

     // All defender tiles should now belong to someone other than defender
     expect(defender.numTilesOwned()).toBe(0);

     // Attacker should have gained tiles
-    expect(attacker.numTilesOwned()).toBeGreaterThan(0);
+    expect(attacker.numTilesOwned()).toBeGreaterThan(attackerTilesBefore);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
expect(attacker.numTilesOwned()).toBeGreaterThan(0);
const attackerTilesBefore = attacker.numTilesOwned();
// Attack and kill the defender
game.addExecution(new AttackExecution(null, attacker, defender.id(), null));
for (let i = 0; i < 500; i++) {
game.executeNextTick();
if (!defender.isAlive()) break;
}
// All defender tiles should now belong to someone other than defender
expect(defender.numTilesOwned()).toBe(0);
// Attacker should have gained tiles
expect(attacker.numTilesOwned()).toBeGreaterThan(attackerTilesBefore);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/HandleDeadDefender.test.ts` at line 141, Capture the attacker's tile
count before performing the sweep/attack by storing attacker.numTilesOwned() in
a variable (e.g., preTiles), then run the existing sweep/attack call in the
test, then get a new count (postTiles = attacker.numTilesOwned()) and replace
the current loose assertion expect(attacker.numTilesOwned()).toBeGreaterThan(0)
with expect(postTiles).toBeGreaterThan(preTiles) to assert tiles were
transferred from the defender; use variable names like preTiles/postTiles to
make intent clear.

});
});
Loading