diff --git a/src/client/view/GameView.ts b/src/client/view/GameView.ts index 04e84a254..67ed744e1 100644 --- a/src/client/view/GameView.ts +++ b/src/client/view/GameView.ts @@ -1065,6 +1065,18 @@ export class GameView implements GameMap { neighbors(ref: TileRef): TileRef[] { return this._map.neighbors(ref); } + forEachNeighbor(ref: TileRef, callback: (neighbor: TileRef) => void): void { + this._map.forEachNeighbor(ref, callback); + } + neighbors4(ref: TileRef, out: TileRef[]): number { + return this._map.neighbors4(ref, out); + } + forEachNeighborWithDiag( + ref: TileRef, + callback: (neighbor: TileRef) => void, + ): void { + this._map.forEachNeighborWithDiag(ref, callback); + } isWater(ref: TileRef): boolean { return this._map.isWater(ref); } diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index b820b132f..ea41ab03c 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -11,7 +11,7 @@ import { TerrainType, TerraNullius, } from "../game/Game"; -import { TileRef } from "../game/GameMap"; +import { GameMap, TileRef } from "../game/GameMap"; import { PseudoRandom } from "../PseudoRandom"; import { assertNever } from "../Util"; import { FlatBinaryHeap } from "./utils/FlatBinaryHeap"; // adjust path if needed @@ -26,9 +26,18 @@ export class AttackExecution implements Execution { private target: Player | TerraNullius; private mg: Game; + // Direct GameMap reference to skip the Game delegation hop in hot loops. + private map: GameMap; private attack: Attack | null = null; + // Cached smallIDs for integer owner comparisons in hot loops. + private ownerSmallID: number; + private targetSmallID: number; + // Reusable neighbor buffers to avoid closures/allocation in hot loops. + private nbuf: TileRef[] = [0, 0, 0, 0]; + private nbuf2: TileRef[] = [0, 0, 0, 0]; + constructor( private startTroops: number | null = null, private _owner: Player, @@ -50,6 +59,7 @@ export class AttackExecution implements Execution { return; } this.mg = mg; + this.map = mg.map(); if (this._targetID !== null && !mg.hasPlayer(this._targetID)) { console.warn(`target ${this._targetID} not found`); @@ -61,6 +71,8 @@ export class AttackExecution implements Execution { this._targetID === this.mg.terraNullius().id() ? mg.terraNullius() : mg.player(this._targetID); + this.ownerSmallID = this._owner.smallID(); + this.targetSmallID = this.target.smallID(); if (this._owner === this.target) { console.error(`Player ${this._owner} cannot attack itself`); @@ -270,19 +282,21 @@ export class AttackExecution implements Execution { return; } - const [tileToConquer] = this.toConquer.dequeue(); + const tileToConquer = this.toConquer.dequeue(); this.attack.removeBorderTile(tileToConquer); let onBorder = false; - this.mg.forEachNeighbor(tileToConquer, (n) => { - if (!onBorder && this.mg.owner(n) === this._owner) { + const numNeighbors = this.map.neighbors4(tileToConquer, this.nbuf); + for (let i = 0; i < numNeighbors; i++) { + if (this.map.ownerID(this.nbuf[i]) === this.ownerSmallID) { onBorder = true; + break; } - }); - if (this.mg.owner(tileToConquer) !== this.target || !onBorder) { + } + if (this.map.ownerID(tileToConquer) !== this.targetSmallID || !onBorder) { continue; } - if (!this.mg.isLand(tileToConquer)) { + if (!this.map.isLand(tileToConquer)) { continue; } this.addNeighbors(tileToConquer); @@ -322,23 +336,26 @@ export class AttackExecution implements Execution { const tickNow = this.mg.ticks(); // cache tick - this.mg.forEachNeighbor(tile, (neighbor) => { + const numNeighbors = this.map.neighbors4(tile, this.nbuf); + for (let i = 0; i < numNeighbors; i++) { + const neighbor = this.nbuf[i]; if ( - this.mg.isWater(neighbor) || - this.mg.owner(neighbor) !== this.target + this.map.isWater(neighbor) || + this.map.ownerID(neighbor) !== this.targetSmallID ) { - return; + continue; } - this.attack!.addBorderTile(neighbor); + this.attack.addBorderTile(neighbor); let numOwnedByMe = 0; - this.mg.forEachNeighbor(neighbor, (n) => { - if (this.mg.owner(n) === this._owner) { + const numInner = this.map.neighbors4(neighbor, this.nbuf2); + for (let j = 0; j < numInner; j++) { + if (this.map.ownerID(this.nbuf2[j]) === this.ownerSmallID) { numOwnedByMe++; } - }); + } let mag: number; - switch (this.mg.terrainType(neighbor)) { + switch (this.map.terrainType(neighbor)) { case TerrainType.Plains: mag = 1; break; @@ -358,7 +375,7 @@ export class AttackExecution implements Execution { tickNow; this.toConquer.enqueue(neighbor, priority); - }); + } } private handleDeadDefender() { diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index 4aaf0dded..5742f2d9d 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -7,7 +7,7 @@ import { Structures, UnitType, } from "../game/Game"; -import { TileRef } from "../game/GameMap"; +import { GameMap, TileRef } from "../game/GameMap"; import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util"; interface ClusterTraversalState { @@ -24,7 +24,11 @@ export class PlayerExecution implements Execution { private config: Config; private lastCalc = 0; private mg: Game; + // Direct GameMap reference to skip the Game delegation hop in hot loops. + private map: GameMap; private active = true; + // Reusable neighbor buffer to avoid closures/allocation in cluster checks. + private nbuf: TileRef[] = [0, 0, 0, 0]; constructor(private player: Player) {} @@ -34,6 +38,7 @@ export class PlayerExecution implements Execution { init(mg: Game, ticks: number) { this.mg = mg; + this.map = mg.map(); this.config = mg.config(); this.lastCalc = ticks + (simpleHash(this.player.name()) % this.ticksPerClusterCalc); @@ -163,29 +168,29 @@ export class PlayerExecution implements Execution { maxX = -Infinity, maxY = -Infinity; + const map = this.map; + const mySmallID = this.player.smallID(); for (const tile of cluster) { - let hasUnownedNeighbor = false; - if (this.mg.isOceanShore(tile) || this.mg.isOnEdgeOfMap(tile)) { + if (map.isOceanShore(tile) || map.isOnEdgeOfMap(tile)) { return false; } - this.mg.forEachNeighbor(tile, (n) => { - if (!this.mg.hasOwner(n)) { - hasUnownedNeighbor = true; - return; + const numNeighbors = map.neighbors4(tile, this.nbuf); + for (let i = 0; i < numNeighbors; i++) { + const n = this.nbuf[i]; + const ownerId = map.ownerID(n); + if (ownerId === 0) { + // Unowned neighbor: the cluster is not fully surrounded. + return false; } - const ownerId = this.mg.ownerID(n); - if (ownerId !== this.player.smallID()) { + if (ownerId !== mySmallID) { enemies.add(ownerId); - const px = this.mg.x(n); - const py = this.mg.y(n); + const px = map.x(n); + const py = map.y(n); minX = Math.min(minX, px); minY = Math.min(minY, py); maxX = Math.max(maxX, px); maxY = Math.max(maxY, py); } - }); - if (hasUnownedNeighbor) { - return false; } if (enemies.size !== 1) { return false; @@ -212,22 +217,26 @@ export class PlayerExecution implements Execution { minY = Infinity, maxX = -Infinity, maxY = -Infinity; + const map = this.map; + const mySmallID = this.player.smallID(); for (const tr of cluster) { - if (this.mg.isShore(tr) || this.mg.isOnEdgeOfMap(tr)) { + if (map.isShore(tr) || map.isOnEdgeOfMap(tr)) { return false; } - this.mg.forEachNeighbor(tr, (n) => { - const owner = this.mg.owner(n); - if (owner.isPlayer() && this.mg.ownerID(n) !== this.player.smallID()) { + const numNeighbors = map.neighbors4(tr, this.nbuf); + for (let i = 0; i < numNeighbors; i++) { + const n = this.nbuf[i]; + const ownerId = map.ownerID(n); + if (ownerId !== 0 && ownerId !== mySmallID) { hasEnemy = true; - const x = this.mg.x(n); - const y = this.mg.y(n); + const x = map.x(n); + const y = map.y(n); minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); } - }); + } } if (!hasEnemy) { return false; @@ -275,17 +284,20 @@ export class PlayerExecution implements Execution { private getCapturingPlayer(cluster: Set): Player | null { const neighbors = new Map(); + const map = this.map; + const mySmallID = this.player.smallID(); for (const t of cluster) { - this.mg.forEachNeighbor(t, (neighbor) => { - const owner = this.mg.owner(neighbor); - if ( - owner.isPlayer() && - owner !== this.player && - !owner.isFriendly(this.player) - ) { + const numNeighbors = map.neighbors4(t, this.nbuf); + for (let i = 0; i < numNeighbors; i++) { + const ownerId = map.ownerID(this.nbuf[i]); + if (ownerId === 0 || ownerId === mySmallID) { + continue; + } + const owner = this.mg.playerBySmallID(ownerId) as Player; + if (!owner.isFriendly(this.player)) { neighbors.set(owner, (neighbors.get(owner) ?? 0) + 1); } - }); + } } // If there are no enemies, return null @@ -392,19 +404,21 @@ export class PlayerExecution implements Execution { stack.push(start); } + const visit = (neighbor: TileRef) => { + if (visited[neighbor] === currentGen) { + return; + } + if (!includeFn(neighbor)) { + return; + } + visited[neighbor] = currentGen; + result.add(neighbor); + stack.push(neighbor); + }; + while (stack.length > 0) { const tile = stack.pop()!; - neighborFn(tile, (neighbor) => { - if (visited[neighbor] === currentGen) { - return; - } - if (!includeFn(neighbor)) { - return; - } - visited[neighbor] = currentGen; - result.add(neighbor); - stack.push(neighbor); - }); + neighborFn(tile, visit); } return result; diff --git a/src/core/execution/utils/FlatBinaryHeap.ts b/src/core/execution/utils/FlatBinaryHeap.ts index 627195613..b0199d43b 100644 --- a/src/core/execution/utils/FlatBinaryHeap.ts +++ b/src/core/execution/utils/FlatBinaryHeap.ts @@ -43,12 +43,11 @@ export class FlatBinaryHeap { this.tiles[i] = tile; } - //remove tiles - dequeue(): [TileRef, number] { + /** remove and return the lowest-priority tile (no per-call allocation) */ + dequeue(): TileRef { if (this.len === 0) throw new Error("heap empty"); const topTile = this.tiles[0]; - const topPri = this.pri[0]; const lastPri = this.pri[--this.len]; const lastTile = this.tiles[this.len]; @@ -68,7 +67,7 @@ export class FlatBinaryHeap { } this.pri[i] = lastPri; this.tiles[i] = lastTile; - return [topTile, topPri]; + return topTile; } /** double the underlying storage */ diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 65a250b28..84e893671 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -542,23 +542,21 @@ export class GameImpl implements Game { } removeInactiveExecutions(): void { - const activeExecs: Execution[] = []; - for (const exec of this.execs) { - if (this.inSpawnPhase()) { - if (exec.activeDuringSpawnPhase()) { - if (exec.isActive()) { - activeExecs.push(exec); - } - } else { - activeExecs.push(exec); - } - } else { - if (exec.isActive()) { - activeExecs.push(exec); - } + // Compact in place to avoid reallocating the (large) executions array + // every tick. + const execs = this.execs; + const inSpawnPhase = this.inSpawnPhase(); + let w = 0; + for (let i = 0; i < execs.length; i++) { + const exec = execs[i]; + const keep = inSpawnPhase + ? !exec.activeDuringSpawnPhase() || exec.isActive() + : exec.isActive(); + if (keep) { + execs[w++] = exec; } } - this.execs = activeExecs; + execs.length = w; } players(): Player[] { @@ -666,23 +664,7 @@ export class GameImpl implements Game { tile: TileRef, callback: (neighbor: TileRef) => void, ): void { - const x = this.x(tile); - const y = this.y(tile); - for (let dx = -1; dx <= 1; dx++) { - for (let dy = -1; dy <= 1; dy++) { - if (dx === 0 && dy === 0) continue; // Skip the center tile - const newX = x + dx; - const newY = y + dy; - if ( - newX >= 0 && - newX < this._width && - newY >= 0 && - newY < this._height - ) { - callback(this._map.ref(newX, newY)); - } - } - } + this._map.forEachNeighborWithDiag(tile, callback); } conquer(owner: PlayerImpl, tile: TileRef): void { @@ -721,49 +703,27 @@ export class GameImpl implements Game { this.recordTileUpdate(tile); } - private updateBorders(tile: TileRef) { - const updateBorderStatus = (t: TileRef) => { - if (!this.hasOwner(t)) { - return; - } - const owner = this.owner(t) as PlayerImpl; - if (this.calcIsBorder(t)) { - owner._borderTiles.add(t); - } else { - owner._borderTiles.delete(t); - } - }; + // Reusable neighbor buffer to avoid closures/allocation in updateBorders. + private borderNbuf: TileRef[] = [0, 0, 0, 0]; - updateBorderStatus(tile); - this.forEachNeighbor(tile, updateBorderStatus); + private updateBorders(tile: TileRef) { + this.updateBorderStatus(tile); + const numNeighbors = this._map.neighbors4(tile, this.borderNbuf); + for (let i = 0; i < numNeighbors; i++) { + this.updateBorderStatus(this.borderNbuf[i]); + } } - private calcIsBorder(tile: TileRef): boolean { - if (!this.hasOwner(tile)) { - return false; + private updateBorderStatus(t: TileRef): void { + if (!this._map.hasOwner(t)) { + return; } - const ownerId = this.ownerID(tile); - const x = this.x(tile); - const y = this.y(tile); - if (x > 0 && this.ownerID(this._map.ref(x - 1, y)) !== ownerId) { - return true; + const owner = this.owner(t) as PlayerImpl; + if (this._map.isBorder(t)) { + owner._borderTiles.add(t); + } else { + owner._borderTiles.delete(t); } - if ( - x + 1 < this._width && - this.ownerID(this._map.ref(x + 1, y)) !== ownerId - ) { - return true; - } - if (y > 0 && this.ownerID(this._map.ref(x, y - 1)) !== ownerId) { - return true; - } - if ( - y + 1 < this._height && - this.ownerID(this._map.ref(x, y + 1)) !== ownerId - ) { - return true; - } - return false; } target(targeter: Player, target: Player) { @@ -1139,12 +1099,10 @@ export class GameImpl implements Game { } // Zero-allocation neighbor iteration (cardinal only) forEachNeighbor(tile: TileRef, callback: (neighbor: TileRef) => void): void { - const x = this.x(tile); - const y = this.y(tile); - if (x > 0) callback(this._map.ref(x - 1, y)); - if (x + 1 < this._width) callback(this._map.ref(x + 1, y)); - if (y > 0) callback(this._map.ref(x, y - 1)); - if (y + 1 < this._height) callback(this._map.ref(x, y + 1)); + this._map.forEachNeighbor(tile, callback); + } + neighbors4(ref: TileRef, out: TileRef[]): number { + return this._map.neighbors4(ref, out); } isWater(ref: TileRef): boolean { return this._map.isWater(ref); diff --git a/src/core/game/GameMap.ts b/src/core/game/GameMap.ts index b872c3eaf..39b87f3fd 100644 --- a/src/core/game/GameMap.ts +++ b/src/core/game/GameMap.ts @@ -36,6 +36,18 @@ export interface GameMap { isOnEdgeOfMap(ref: TileRef): boolean; isBorder(ref: TileRef): boolean; neighbors(ref: TileRef): TileRef[]; + // Zero-allocation neighbor iteration (cardinal only), in W, E, N, S order. + forEachNeighbor(ref: TileRef, callback: (neighbor: TileRef) => void): void; + // Writes the cardinal neighbors of ref into out (W, E, N, S order) and + // returns the count. out must have length >= 4; reuse it across calls to + // avoid allocation in hot loops. + neighbors4(ref: TileRef, out: TileRef[]): number; + // Zero-allocation neighbor iteration including diagonals, in dx-major + // order: (-1,-1),(-1,0),(-1,1),(0,-1),(0,1),(1,-1),(1,0),(1,1). + forEachNeighborWithDiag( + ref: TileRef, + callback: (neighbor: TileRef) => void, + ): void; isWater(ref: TileRef): boolean; isShore(ref: TileRef): boolean; cost(ref: TileRef): number; @@ -196,9 +208,16 @@ export class GameMapImpl implements GameMap { } isOceanShore(ref: TileRef): boolean { - return ( - this.isLand(ref) && this.neighbors(ref).some((tr) => this.isOcean(tr)) - ); + if (!this.isLand(ref)) { + return false; + } + const w = this.width_; + const x = this.refToX[ref]; + if (x !== 0 && this.isOcean(ref - 1)) return true; + if (x !== w - 1 && this.isOcean(ref + 1)) return true; + if (ref >= w && this.isOcean(ref - w)) return true; + if (ref < (this.height_ - 1) * w && this.isOcean(ref + w)) return true; + return false; } isOcean(ref: TileRef): boolean { @@ -288,9 +307,16 @@ export class GameMapImpl implements GameMap { } isBorder(ref: TileRef): boolean { - return this.neighbors(ref).some( - (tr) => this.ownerID(tr) !== this.ownerID(ref), - ); + const w = this.width_; + const x = this.refToX[ref]; + const owner = this.ownerID(ref); + if (x !== 0 && this.ownerID(ref - 1) !== owner) return true; + if (x !== w - 1 && this.ownerID(ref + 1) !== owner) return true; + if (ref >= w && this.ownerID(ref - w) !== owner) return true; + if (ref < (this.height_ - 1) * w && this.ownerID(ref + w) !== owner) { + return true; + } + return false; } hasDefenseBonus(ref: TileRef): boolean { @@ -343,6 +369,51 @@ export class GameMapImpl implements GameMap { return neighbors; } + forEachNeighbor(ref: TileRef, callback: (neighbor: TileRef) => void): void { + const w = this.width_; + const x = this.refToX[ref]; + + if (x !== 0) callback(ref - 1); + if (x !== w - 1) callback(ref + 1); + if (ref >= w) callback(ref - w); + if (ref < (this.height_ - 1) * w) callback(ref + w); + } + + neighbors4(ref: TileRef, out: TileRef[]): number { + const w = this.width_; + const x = this.refToX[ref]; + let n = 0; + + if (x !== 0) out[n++] = ref - 1; + if (x !== w - 1) out[n++] = ref + 1; + if (ref >= w) out[n++] = ref - w; + if (ref < (this.height_ - 1) * w) out[n++] = ref + w; + return n; + } + + forEachNeighborWithDiag( + ref: TileRef, + callback: (neighbor: TileRef) => void, + ): void { + const w = this.width_; + const x = this.refToX[ref]; + const hasN = ref >= w; + const hasS = ref < (this.height_ - 1) * w; + + if (x !== 0) { + if (hasN) callback(ref - 1 - w); + callback(ref - 1); + if (hasS) callback(ref - 1 + w); + } + if (hasN) callback(ref - w); + if (hasS) callback(ref + w); + if (x !== w - 1) { + if (hasN) callback(ref + 1 - w); + callback(ref + 1); + if (hasS) callback(ref + 1 + w); + } + } + forEachTile(fn: (tile: TileRef) => void): void { for (let ref: TileRef = 0; ref < this.width_ * this.height_; ref++) { fn(ref); diff --git a/tests/FlatBinaryHeap.test.ts b/tests/FlatBinaryHeap.test.ts new file mode 100644 index 000000000..27c62f5d9 --- /dev/null +++ b/tests/FlatBinaryHeap.test.ts @@ -0,0 +1,52 @@ +import { FlatBinaryHeap } from "../src/core/execution/utils/FlatBinaryHeap"; + +describe("FlatBinaryHeap", () => { + test("dequeues tiles in ascending priority order", () => { + const heap = new FlatBinaryHeap(); + const entries: [number, number][] = [ + [100, 5.0], + [200, 1.0], + [300, 3.0], + [400, 2.0], + [500, 4.0], + ]; + for (const [tile, pri] of entries) { + heap.enqueue(tile, pri); + } + expect(heap.size()).toBe(5); + expect(heap.dequeue()).toBe(200); + expect(heap.dequeue()).toBe(400); + expect(heap.dequeue()).toBe(300); + expect(heap.dequeue()).toBe(500); + expect(heap.dequeue()).toBe(100); + expect(heap.size()).toBe(0); + }); + + test("throws when dequeuing an empty heap", () => { + const heap = new FlatBinaryHeap(); + expect(() => heap.dequeue()).toThrow("heap empty"); + }); + + test("clear empties the heap without breaking subsequent use", () => { + const heap = new FlatBinaryHeap(); + heap.enqueue(1, 1); + heap.enqueue(2, 2); + heap.clear(); + expect(heap.size()).toBe(0); + heap.enqueue(3, 3); + expect(heap.dequeue()).toBe(3); + }); + + test("grows past its initial capacity and stays ordered", () => { + const heap = new FlatBinaryHeap(4); + // Insert in descending priority so every enqueue sifts up. + const n = 1000; + for (let i = 0; i < n; i++) { + heap.enqueue(i, n - i); + } + expect(heap.size()).toBe(n); + for (let i = 0; i < n; i++) { + expect(heap.dequeue()).toBe(n - 1 - i); + } + }); +}); diff --git a/tests/NeighborIteration.test.ts b/tests/NeighborIteration.test.ts new file mode 100644 index 000000000..754e66baf --- /dev/null +++ b/tests/NeighborIteration.test.ts @@ -0,0 +1,213 @@ +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 W, E, N, S in that exact order for interior tiles", () => { + const tile = game.ref(5, 7); + expect(collectNeighbors(tile)).toEqual([ + game.ref(4, 7), + game.ref(6, 7), + game.ref(5, 6), + game.ref(5, 8), + ]); + }); + + test("forEachNeighbor clips at corners and edges", () => { + const w = game.width(); + const h = game.height(); + // top-left corner: E, S only + expect(collectNeighbors(game.ref(0, 0))).toEqual([ + game.ref(1, 0), + game.ref(0, 1), + ]); + // bottom-right corner: W, N only + expect(collectNeighbors(game.ref(w - 1, h - 1))).toEqual([ + game.ref(w - 2, h - 1), + game.ref(w - 1, h - 2), + ]); + // left edge: E, N, S + expect(collectNeighbors(game.ref(0, 5))).toEqual([ + game.ref(1, 5), + game.ref(0, 4), + game.ref(0, 6), + ]); + // bottom edge: W, E, N + expect(collectNeighbors(game.ref(5, h - 1))).toEqual([ + game.ref(4, h - 1), + game.ref(6, h - 1), + game.ref(5, h - 2), + ]); + }); + + test("forEachNeighbor matches map.neighbors() as a set for every tile", () => { + game.forEachTile((tile) => { + const a = [...collectNeighbors(tile)].sort((x, y) => x - y); + const b = [...game.map().neighbors(tile)].sort((x, y) => x - y); + expect(a).toEqual(b); + }); + }); + + 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(); + }); +});