Files
OpenFrontIO/tests/NeighborIteration.test.ts
T
Evan 22d5aba5ae refactor: standardize cardinal-neighbor iteration on neighbors() N,S,W,E order (#4495)
## Summary

Follow-up to #4494. That PR added `forEachNeighborNSWE` as a third
neighbor iterator because the existing allocation-free helpers
(`forEachNeighbor`, `neighbors4`) visit in W,E,N,S order while
`neighbors()` visits N,S,W,E — and substituting one for the other
changes simulation behavior at order-sensitive call sites.

This PR removes that duplication by standardizing on **one order
everywhere**: `forEachNeighbor` and `neighbors4` now visit in the same
N,S,W,E order as `neighbors()`, and `forEachNeighborNSWE` is deleted.

## ⚠️ Intentional behavior change

Callers of the flipped helpers that are order-sensitive now make
different (equally valid) decisions:

- `AttackExecution.addNeighbors` — PRNG values are drawn per neighbor
while building the conquest frontier, so attack expansion patterns
differ
- `AttackExecution.handleDeadDefender` — a dead defender's tiles go to
the *first-visited* adjacent player
- `WarshipExecution.bestNeighborToward` — distance ties break by visit
order
- `PlayerExecution` surrounded-cluster flood fill — set insertion order
propagates to conquer order

Game outcomes for a given seed differ from previous builds (verified:
the 12k-tick reference run ends with 31 players alive vs 24 before).
Determinism across clients *within* a build is unaffected — all clients
run the same code, so there is no desync risk. Replays/verification
pinned to old hashes will not match this build.

New reference hashes for the headless perf harness (seed
`perf-default`):

| Run | Final hash |
|---|---|
| giantworldmap, 12,000 ticks | `57830793797434300` |
| giantworldmap, 2,000 ticks | `55125379638382860` |
| world, 1,800 ticks | `32337437717390864` |

## Verification

- [x] Full suite green (1,901 tests), including new exact-order contract
tests: `forEachNeighbor` and `neighbors4` must match `neighbors()`
contents **and order** for every tile
- [x] 20-game-minute Giant World Map benchmark: no perf regression (73
ticks/sec, GC 1.2% of wall, allocation profile unchanged)
- [x] Order-sensitivity audit of every `forEachNeighbor`/`neighbors4`
call site (sensitive ones listed above; the rest are booleans, counts,
or min/max accumulations)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:42:22 -07:00

223 lines
6.6 KiB
TypeScript

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";
let game: Game;
const gameID: GameID = "game_id";
function collectNeighbors(tile: TileRef): TileRef[] {
const out: TileRef[] = [];
game.forEachNeighbor(tile, (n) => out.push(n));
return out;
}
function collectNeighborsWithDiag(tile: TileRef): TileRef[] {
const out: TileRef[] = [];
game.forEachNeighborWithDiag(tile, (n) => out.push(n));
return out;
}
describe("Neighbor iteration", () => {
beforeEach(async () => {
game = await setup("ocean_and_land"); // 16x16
});
test("forEachNeighbor visits N, S, W, E in that exact order for interior tiles", () => {
const tile = game.ref(5, 7);
expect(collectNeighbors(tile)).toEqual([
game.ref(5, 6),
game.ref(5, 8),
game.ref(4, 7),
game.ref(6, 7),
]);
});
test("forEachNeighbor clips at corners and edges", () => {
const w = game.width();
const h = game.height();
// top-left corner: S, E only
expect(collectNeighbors(game.ref(0, 0))).toEqual([
game.ref(0, 1),
game.ref(1, 0),
]);
// bottom-right corner: N, W only
expect(collectNeighbors(game.ref(w - 1, h - 1))).toEqual([
game.ref(w - 1, h - 2),
game.ref(w - 2, h - 1),
]);
// left edge: N, S, E
expect(collectNeighbors(game.ref(0, 5))).toEqual([
game.ref(0, 4),
game.ref(0, 6),
game.ref(1, 5),
]);
// bottom edge: N, W, E
expect(collectNeighbors(game.ref(5, h - 1))).toEqual([
game.ref(5, h - 2),
game.ref(4, h - 1),
game.ref(6, h - 1),
]);
});
// All cardinal-neighbor helpers share neighbors()'s exact N, S, W, E order,
// including at edges and corners, so they are interchangeable even in
// order-sensitive simulation code.
test("forEachNeighbor matches map.neighbors() exactly (contents and order) for every tile", () => {
game.forEachTile((tile) => {
expect(collectNeighbors(tile)).toEqual(game.map().neighbors(tile));
});
});
test("neighbors4 matches map.neighbors() exactly (contents and order) for every tile", () => {
const nbuf: TileRef[] = [0, 0, 0, 0];
game.forEachTile((tile) => {
const n = game.map().neighbors4(tile, nbuf);
expect(nbuf.slice(0, n)).toEqual(game.map().neighbors(tile));
});
});
test("forEachNeighborWithDiag visits all 8 neighbors in dx-major order", () => {
const tile = game.ref(5, 7);
expect(collectNeighborsWithDiag(tile)).toEqual([
game.ref(4, 6),
game.ref(4, 7),
game.ref(4, 8),
game.ref(5, 6),
game.ref(5, 8),
game.ref(6, 6),
game.ref(6, 7),
game.ref(6, 8),
]);
});
test("forEachNeighborWithDiag clips at corners and edges", () => {
const w = game.width();
const h = game.height();
expect(collectNeighborsWithDiag(game.ref(0, 0))).toEqual([
game.ref(0, 1),
game.ref(1, 0),
game.ref(1, 1),
]);
expect(collectNeighborsWithDiag(game.ref(w - 1, h - 1))).toEqual([
game.ref(w - 2, h - 2),
game.ref(w - 2, h - 1),
game.ref(w - 1, h - 2),
]);
expect(collectNeighborsWithDiag(game.ref(5, 0))).toEqual([
game.ref(4, 0),
game.ref(4, 1),
game.ref(5, 1),
game.ref(6, 0),
game.ref(6, 1),
]);
});
});
describe("Conquer border invariants", () => {
let attacker: Player;
let defender: Player;
// For every player: borderTiles ⊆ tiles, and a tile is a border tile iff
// some in-bounds cardinal neighbor has a different owner.
function checkBorderInvariant() {
for (const player of game.players()) {
const tiles = player.tiles();
const borderTiles = player.borderTiles();
for (const tile of borderTiles) {
expect(tiles.has(tile)).toBe(true);
}
const mismatches: TileRef[] = [];
for (const tile of tiles) {
let isBorder = false;
game.forEachNeighbor(tile, (n) => {
if (game.owner(n) !== player) {
isBorder = true;
}
});
if (borderTiles.has(tile) !== isBorder) {
mismatches.push(tile);
}
}
expect(mismatches).toEqual([]);
}
}
beforeEach(async () => {
game = await setup("plains", { infiniteTroops: true }); // 100x100, all land
const attackerInfo = new PlayerInfo(
"attacker dude",
PlayerType.Human,
null,
"attacker_id",
);
game.addPlayer(attackerInfo);
const defenderInfo = new PlayerInfo(
"defender dude",
PlayerType.Human,
null,
"defender_id",
);
game.addPlayer(defenderInfo);
game.addExecution(
new SpawnExecution(gameID, attackerInfo, game.ref(0, 0)),
new SpawnExecution(gameID, defenderInfo, game.ref(5, 5)),
);
game.executeNextTick();
game.executeNextTick();
attacker = game.player(attackerInfo.id);
defender = game.player(defenderInfo.id);
});
test("border invariant holds after expanding into terra nullius", () => {
game.addExecution(
new AttackExecution(1000, attacker, game.terraNullius().id()),
);
for (let i = 0; i < 30; i++) {
game.executeNextTick();
}
expect(attacker.numTilesOwned()).toBeGreaterThan(10);
checkBorderInvariant();
});
test("border invariant holds while two players fight over territory", () => {
game.addExecution(
new AttackExecution(1000, attacker, game.terraNullius().id()),
new AttackExecution(1000, defender, game.terraNullius().id()),
);
for (let i = 0; i < 40; i++) {
game.executeNextTick();
}
game.addExecution(new AttackExecution(5000, attacker, defender.id()));
// Check the invariant repeatedly while the fight is in progress, not
// just at the end.
for (let i = 0; i < 40; i++) {
game.executeNextTick();
if (i % 10 === 0) {
checkBorderInvariant();
}
}
expect(attacker.numTilesOwned()).toBeGreaterThan(10);
checkBorderInvariant();
});
test("conquering a specific tile updates owner and neighbors' border status", () => {
game.addExecution(
new AttackExecution(1000, attacker, game.terraNullius().id()),
);
for (let i = 0; i < 30; i++) {
game.executeNextTick();
}
// Pick a border tile of the attacker and verify its interior neighbors
// are not border tiles.
for (const tile of attacker.tiles()) {
expect(game.owner(tile)).toBe(attacker);
}
checkBorderInvariant();
});
});