-
Notifications
You must be signed in to change notification settings - Fork 1k
fix: prevent iterator invalidation in handleDeadDefender (BUG-02) #3943
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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