mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 08:11:54 +00:00
Optimize core simulation hot paths (no behavior change) (#4230)
## Summary Pure performance optimizations to the attack/conquer/cluster hot paths in `src/core`, driven by the full-game perf harness from #4228. **No behavior change**: the final game-state hash is identical before/after on every config tested — world quick run (2 different seeds), giantworldmap, and the default 1800-tick run. ### Changes - **Flat-arithmetic neighbor iteration**: `forEachNeighbor` / `forEachNeighborWithDiag` / `isBorder` / `isOceanShore` are now implemented inside `GameMapImpl` using raw `ref±1` / `ref±width` index math, skipping the per-neighbor `ref()` coordinate validation (`Number.isInteger` etc.). `GameImpl` and `GameView` delegate. - **New `neighbors4(ref, out)`**: zero-allocation, callback-free neighbor query for hot loops (W, E, N, S — same order as `forEachNeighbor`). - **`AttackExecution`**: the per-tile closures in `tick()` / `addNeighbors()` are replaced with reusable neighbor buffers, a cached `GameMap` reference, and integer `smallID()` owner comparisons instead of owner-object lookups. - **`GameImpl`**: the per-conquer `updateBorders` closure is hoisted to a method with a reusable buffer; `removeInactiveExecutions` compacts the executions array in place instead of allocating a new ~4200-element array every tick. - **`PlayerExecution`**: `surroundedBySamePlayer` / `isSurrounded` / `getCapturingPlayer` de-closured (`neighbors4` + integer compares; neighbor visit order preserved, so `getCapturingPlayer`'s Map-insertion-order tie-breaking is unchanged); flood-fill visit closure hoisted out of the while loop. - **`FlatBinaryHeap.dequeue`**: returns the tile directly instead of allocating a `[tile, priority]` tuple per dequeued tile (AttackExecution is the only caller). ### Performance (`npm run perf:game`, same machine, before → after) | run | mean tick | ticks/sec | max tick | |---|---|---|---| | default (world, 400 bots, 1800 ticks) | 9.04 → **7.98 ms** | 111 → **125** | 31.7 → 35.7 ms | | giantworldmap, 600 ticks | 22.5 → **17.4 ms** | 44 → **58** | 52.8 → **36.2 ms** | The giantworldmap tail improvement (max tick −31%) is the most relevant for the 100 ms tick budget. ### Determinism verification Identical `Final hash` before and after on all configs: | config | hash | |---|---| | `--map world --ticks 200 --bots 100` | `5455008589403520` | | same + `--seed second-seed-check` | `5580840142777488` | | `--map giantworldmap --ticks 600` | `37373734953428430` | | default run | `26773450321979388` | ### Tests - New `tests/NeighborIteration.test.ts` pins the exact neighbor iteration orders (W,E,N,S cardinal; dx-major diagonal — conquest order and RNG consumption depend on them) and conquer/border-tile invariants checked mid-battle. - New `tests/FlatBinaryHeap.test.ts` covers heap ordering, clear, and growth. - Full suite passes (122 files / 1386 tests + server tests); lint and prettier clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1065,6 +1065,18 @@ export class GameView implements GameMap {
|
|||||||
neighbors(ref: TileRef): TileRef[] {
|
neighbors(ref: TileRef): TileRef[] {
|
||||||
return this._map.neighbors(ref);
|
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 {
|
isWater(ref: TileRef): boolean {
|
||||||
return this._map.isWater(ref);
|
return this._map.isWater(ref);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
TerrainType,
|
TerrainType,
|
||||||
TerraNullius,
|
TerraNullius,
|
||||||
} from "../game/Game";
|
} from "../game/Game";
|
||||||
import { TileRef } from "../game/GameMap";
|
import { GameMap, TileRef } from "../game/GameMap";
|
||||||
import { PseudoRandom } from "../PseudoRandom";
|
import { PseudoRandom } from "../PseudoRandom";
|
||||||
import { assertNever } from "../Util";
|
import { assertNever } from "../Util";
|
||||||
import { FlatBinaryHeap } from "./utils/FlatBinaryHeap"; // adjust path if needed
|
import { FlatBinaryHeap } from "./utils/FlatBinaryHeap"; // adjust path if needed
|
||||||
@@ -26,9 +26,18 @@ export class AttackExecution implements Execution {
|
|||||||
private target: Player | TerraNullius;
|
private target: Player | TerraNullius;
|
||||||
|
|
||||||
private mg: Game;
|
private mg: Game;
|
||||||
|
// Direct GameMap reference to skip the Game delegation hop in hot loops.
|
||||||
|
private map: GameMap;
|
||||||
|
|
||||||
private attack: Attack | null = null;
|
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(
|
constructor(
|
||||||
private startTroops: number | null = null,
|
private startTroops: number | null = null,
|
||||||
private _owner: Player,
|
private _owner: Player,
|
||||||
@@ -50,6 +59,7 @@ export class AttackExecution implements Execution {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.mg = mg;
|
this.mg = mg;
|
||||||
|
this.map = mg.map();
|
||||||
|
|
||||||
if (this._targetID !== null && !mg.hasPlayer(this._targetID)) {
|
if (this._targetID !== null && !mg.hasPlayer(this._targetID)) {
|
||||||
console.warn(`target ${this._targetID} not found`);
|
console.warn(`target ${this._targetID} not found`);
|
||||||
@@ -61,6 +71,8 @@ export class AttackExecution implements Execution {
|
|||||||
this._targetID === this.mg.terraNullius().id()
|
this._targetID === this.mg.terraNullius().id()
|
||||||
? mg.terraNullius()
|
? mg.terraNullius()
|
||||||
: mg.player(this._targetID);
|
: mg.player(this._targetID);
|
||||||
|
this.ownerSmallID = this._owner.smallID();
|
||||||
|
this.targetSmallID = this.target.smallID();
|
||||||
|
|
||||||
if (this._owner === this.target) {
|
if (this._owner === this.target) {
|
||||||
console.error(`Player ${this._owner} cannot attack itself`);
|
console.error(`Player ${this._owner} cannot attack itself`);
|
||||||
@@ -270,19 +282,21 @@ export class AttackExecution implements Execution {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [tileToConquer] = this.toConquer.dequeue();
|
const tileToConquer = this.toConquer.dequeue();
|
||||||
this.attack.removeBorderTile(tileToConquer);
|
this.attack.removeBorderTile(tileToConquer);
|
||||||
|
|
||||||
let onBorder = false;
|
let onBorder = false;
|
||||||
this.mg.forEachNeighbor(tileToConquer, (n) => {
|
const numNeighbors = this.map.neighbors4(tileToConquer, this.nbuf);
|
||||||
if (!onBorder && this.mg.owner(n) === this._owner) {
|
for (let i = 0; i < numNeighbors; i++) {
|
||||||
|
if (this.map.ownerID(this.nbuf[i]) === this.ownerSmallID) {
|
||||||
onBorder = true;
|
onBorder = true;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
if (this.mg.owner(tileToConquer) !== this.target || !onBorder) {
|
if (this.map.ownerID(tileToConquer) !== this.targetSmallID || !onBorder) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!this.mg.isLand(tileToConquer)) {
|
if (!this.map.isLand(tileToConquer)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
this.addNeighbors(tileToConquer);
|
this.addNeighbors(tileToConquer);
|
||||||
@@ -322,23 +336,26 @@ export class AttackExecution implements Execution {
|
|||||||
|
|
||||||
const tickNow = this.mg.ticks(); // cache tick
|
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 (
|
if (
|
||||||
this.mg.isWater(neighbor) ||
|
this.map.isWater(neighbor) ||
|
||||||
this.mg.owner(neighbor) !== this.target
|
this.map.ownerID(neighbor) !== this.targetSmallID
|
||||||
) {
|
) {
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
this.attack!.addBorderTile(neighbor);
|
this.attack.addBorderTile(neighbor);
|
||||||
let numOwnedByMe = 0;
|
let numOwnedByMe = 0;
|
||||||
this.mg.forEachNeighbor(neighbor, (n) => {
|
const numInner = this.map.neighbors4(neighbor, this.nbuf2);
|
||||||
if (this.mg.owner(n) === this._owner) {
|
for (let j = 0; j < numInner; j++) {
|
||||||
|
if (this.map.ownerID(this.nbuf2[j]) === this.ownerSmallID) {
|
||||||
numOwnedByMe++;
|
numOwnedByMe++;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
let mag: number;
|
let mag: number;
|
||||||
switch (this.mg.terrainType(neighbor)) {
|
switch (this.map.terrainType(neighbor)) {
|
||||||
case TerrainType.Plains:
|
case TerrainType.Plains:
|
||||||
mag = 1;
|
mag = 1;
|
||||||
break;
|
break;
|
||||||
@@ -358,7 +375,7 @@ export class AttackExecution implements Execution {
|
|||||||
tickNow;
|
tickNow;
|
||||||
|
|
||||||
this.toConquer.enqueue(neighbor, priority);
|
this.toConquer.enqueue(neighbor, priority);
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleDeadDefender() {
|
private handleDeadDefender() {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
Structures,
|
Structures,
|
||||||
UnitType,
|
UnitType,
|
||||||
} from "../game/Game";
|
} from "../game/Game";
|
||||||
import { TileRef } from "../game/GameMap";
|
import { GameMap, TileRef } from "../game/GameMap";
|
||||||
import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util";
|
import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util";
|
||||||
|
|
||||||
interface ClusterTraversalState {
|
interface ClusterTraversalState {
|
||||||
@@ -24,7 +24,11 @@ export class PlayerExecution implements Execution {
|
|||||||
private config: Config;
|
private config: Config;
|
||||||
private lastCalc = 0;
|
private lastCalc = 0;
|
||||||
private mg: Game;
|
private mg: Game;
|
||||||
|
// Direct GameMap reference to skip the Game delegation hop in hot loops.
|
||||||
|
private map: GameMap;
|
||||||
private active = true;
|
private active = true;
|
||||||
|
// Reusable neighbor buffer to avoid closures/allocation in cluster checks.
|
||||||
|
private nbuf: TileRef[] = [0, 0, 0, 0];
|
||||||
|
|
||||||
constructor(private player: Player) {}
|
constructor(private player: Player) {}
|
||||||
|
|
||||||
@@ -34,6 +38,7 @@ export class PlayerExecution implements Execution {
|
|||||||
|
|
||||||
init(mg: Game, ticks: number) {
|
init(mg: Game, ticks: number) {
|
||||||
this.mg = mg;
|
this.mg = mg;
|
||||||
|
this.map = mg.map();
|
||||||
this.config = mg.config();
|
this.config = mg.config();
|
||||||
this.lastCalc =
|
this.lastCalc =
|
||||||
ticks + (simpleHash(this.player.name()) % this.ticksPerClusterCalc);
|
ticks + (simpleHash(this.player.name()) % this.ticksPerClusterCalc);
|
||||||
@@ -163,29 +168,29 @@ export class PlayerExecution implements Execution {
|
|||||||
maxX = -Infinity,
|
maxX = -Infinity,
|
||||||
maxY = -Infinity;
|
maxY = -Infinity;
|
||||||
|
|
||||||
|
const map = this.map;
|
||||||
|
const mySmallID = this.player.smallID();
|
||||||
for (const tile of cluster) {
|
for (const tile of cluster) {
|
||||||
let hasUnownedNeighbor = false;
|
if (map.isOceanShore(tile) || map.isOnEdgeOfMap(tile)) {
|
||||||
if (this.mg.isOceanShore(tile) || this.mg.isOnEdgeOfMap(tile)) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
this.mg.forEachNeighbor(tile, (n) => {
|
const numNeighbors = map.neighbors4(tile, this.nbuf);
|
||||||
if (!this.mg.hasOwner(n)) {
|
for (let i = 0; i < numNeighbors; i++) {
|
||||||
hasUnownedNeighbor = true;
|
const n = this.nbuf[i];
|
||||||
return;
|
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 !== mySmallID) {
|
||||||
if (ownerId !== this.player.smallID()) {
|
|
||||||
enemies.add(ownerId);
|
enemies.add(ownerId);
|
||||||
const px = this.mg.x(n);
|
const px = map.x(n);
|
||||||
const py = this.mg.y(n);
|
const py = map.y(n);
|
||||||
minX = Math.min(minX, px);
|
minX = Math.min(minX, px);
|
||||||
minY = Math.min(minY, py);
|
minY = Math.min(minY, py);
|
||||||
maxX = Math.max(maxX, px);
|
maxX = Math.max(maxX, px);
|
||||||
maxY = Math.max(maxY, py);
|
maxY = Math.max(maxY, py);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
if (hasUnownedNeighbor) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
if (enemies.size !== 1) {
|
if (enemies.size !== 1) {
|
||||||
return false;
|
return false;
|
||||||
@@ -212,22 +217,26 @@ export class PlayerExecution implements Execution {
|
|||||||
minY = Infinity,
|
minY = Infinity,
|
||||||
maxX = -Infinity,
|
maxX = -Infinity,
|
||||||
maxY = -Infinity;
|
maxY = -Infinity;
|
||||||
|
const map = this.map;
|
||||||
|
const mySmallID = this.player.smallID();
|
||||||
for (const tr of cluster) {
|
for (const tr of cluster) {
|
||||||
if (this.mg.isShore(tr) || this.mg.isOnEdgeOfMap(tr)) {
|
if (map.isShore(tr) || map.isOnEdgeOfMap(tr)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
this.mg.forEachNeighbor(tr, (n) => {
|
const numNeighbors = map.neighbors4(tr, this.nbuf);
|
||||||
const owner = this.mg.owner(n);
|
for (let i = 0; i < numNeighbors; i++) {
|
||||||
if (owner.isPlayer() && this.mg.ownerID(n) !== this.player.smallID()) {
|
const n = this.nbuf[i];
|
||||||
|
const ownerId = map.ownerID(n);
|
||||||
|
if (ownerId !== 0 && ownerId !== mySmallID) {
|
||||||
hasEnemy = true;
|
hasEnemy = true;
|
||||||
const x = this.mg.x(n);
|
const x = map.x(n);
|
||||||
const y = this.mg.y(n);
|
const y = map.y(n);
|
||||||
minX = Math.min(minX, x);
|
minX = Math.min(minX, x);
|
||||||
minY = Math.min(minY, y);
|
minY = Math.min(minY, y);
|
||||||
maxX = Math.max(maxX, x);
|
maxX = Math.max(maxX, x);
|
||||||
maxY = Math.max(maxY, y);
|
maxY = Math.max(maxY, y);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
if (!hasEnemy) {
|
if (!hasEnemy) {
|
||||||
return false;
|
return false;
|
||||||
@@ -275,17 +284,20 @@ export class PlayerExecution implements Execution {
|
|||||||
|
|
||||||
private getCapturingPlayer(cluster: Set<TileRef>): Player | null {
|
private getCapturingPlayer(cluster: Set<TileRef>): Player | null {
|
||||||
const neighbors = new Map<Player, number>();
|
const neighbors = new Map<Player, number>();
|
||||||
|
const map = this.map;
|
||||||
|
const mySmallID = this.player.smallID();
|
||||||
for (const t of cluster) {
|
for (const t of cluster) {
|
||||||
this.mg.forEachNeighbor(t, (neighbor) => {
|
const numNeighbors = map.neighbors4(t, this.nbuf);
|
||||||
const owner = this.mg.owner(neighbor);
|
for (let i = 0; i < numNeighbors; i++) {
|
||||||
if (
|
const ownerId = map.ownerID(this.nbuf[i]);
|
||||||
owner.isPlayer() &&
|
if (ownerId === 0 || ownerId === mySmallID) {
|
||||||
owner !== this.player &&
|
continue;
|
||||||
!owner.isFriendly(this.player)
|
}
|
||||||
) {
|
const owner = this.mg.playerBySmallID(ownerId) as Player;
|
||||||
|
if (!owner.isFriendly(this.player)) {
|
||||||
neighbors.set(owner, (neighbors.get(owner) ?? 0) + 1);
|
neighbors.set(owner, (neighbors.get(owner) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If there are no enemies, return null
|
// If there are no enemies, return null
|
||||||
@@ -392,19 +404,21 @@ export class PlayerExecution implements Execution {
|
|||||||
stack.push(start);
|
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) {
|
while (stack.length > 0) {
|
||||||
const tile = stack.pop()!;
|
const tile = stack.pop()!;
|
||||||
neighborFn(tile, (neighbor) => {
|
neighborFn(tile, visit);
|
||||||
if (visited[neighbor] === currentGen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!includeFn(neighbor)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
visited[neighbor] = currentGen;
|
|
||||||
result.add(neighbor);
|
|
||||||
stack.push(neighbor);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -43,12 +43,11 @@ export class FlatBinaryHeap {
|
|||||||
this.tiles[i] = tile;
|
this.tiles[i] = tile;
|
||||||
}
|
}
|
||||||
|
|
||||||
//remove tiles
|
/** remove and return the lowest-priority tile (no per-call allocation) */
|
||||||
dequeue(): [TileRef, number] {
|
dequeue(): TileRef {
|
||||||
if (this.len === 0) throw new Error("heap empty");
|
if (this.len === 0) throw new Error("heap empty");
|
||||||
|
|
||||||
const topTile = this.tiles[0];
|
const topTile = this.tiles[0];
|
||||||
const topPri = this.pri[0];
|
|
||||||
|
|
||||||
const lastPri = this.pri[--this.len];
|
const lastPri = this.pri[--this.len];
|
||||||
const lastTile = this.tiles[this.len];
|
const lastTile = this.tiles[this.len];
|
||||||
@@ -68,7 +67,7 @@ export class FlatBinaryHeap {
|
|||||||
}
|
}
|
||||||
this.pri[i] = lastPri;
|
this.pri[i] = lastPri;
|
||||||
this.tiles[i] = lastTile;
|
this.tiles[i] = lastTile;
|
||||||
return [topTile, topPri];
|
return topTile;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** double the underlying storage */
|
/** double the underlying storage */
|
||||||
|
|||||||
+34
-76
@@ -542,23 +542,21 @@ export class GameImpl implements Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeInactiveExecutions(): void {
|
removeInactiveExecutions(): void {
|
||||||
const activeExecs: Execution[] = [];
|
// Compact in place to avoid reallocating the (large) executions array
|
||||||
for (const exec of this.execs) {
|
// every tick.
|
||||||
if (this.inSpawnPhase()) {
|
const execs = this.execs;
|
||||||
if (exec.activeDuringSpawnPhase()) {
|
const inSpawnPhase = this.inSpawnPhase();
|
||||||
if (exec.isActive()) {
|
let w = 0;
|
||||||
activeExecs.push(exec);
|
for (let i = 0; i < execs.length; i++) {
|
||||||
}
|
const exec = execs[i];
|
||||||
} else {
|
const keep = inSpawnPhase
|
||||||
activeExecs.push(exec);
|
? !exec.activeDuringSpawnPhase() || exec.isActive()
|
||||||
}
|
: exec.isActive();
|
||||||
} else {
|
if (keep) {
|
||||||
if (exec.isActive()) {
|
execs[w++] = exec;
|
||||||
activeExecs.push(exec);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.execs = activeExecs;
|
execs.length = w;
|
||||||
}
|
}
|
||||||
|
|
||||||
players(): Player[] {
|
players(): Player[] {
|
||||||
@@ -666,23 +664,7 @@ export class GameImpl implements Game {
|
|||||||
tile: TileRef,
|
tile: TileRef,
|
||||||
callback: (neighbor: TileRef) => void,
|
callback: (neighbor: TileRef) => void,
|
||||||
): void {
|
): void {
|
||||||
const x = this.x(tile);
|
this._map.forEachNeighborWithDiag(tile, callback);
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
conquer(owner: PlayerImpl, tile: TileRef): void {
|
conquer(owner: PlayerImpl, tile: TileRef): void {
|
||||||
@@ -721,49 +703,27 @@ export class GameImpl implements Game {
|
|||||||
this.recordTileUpdate(tile);
|
this.recordTileUpdate(tile);
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateBorders(tile: TileRef) {
|
// Reusable neighbor buffer to avoid closures/allocation in updateBorders.
|
||||||
const updateBorderStatus = (t: TileRef) => {
|
private borderNbuf: TileRef[] = [0, 0, 0, 0];
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
updateBorderStatus(tile);
|
private updateBorders(tile: TileRef) {
|
||||||
this.forEachNeighbor(tile, updateBorderStatus);
|
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 {
|
private updateBorderStatus(t: TileRef): void {
|
||||||
if (!this.hasOwner(tile)) {
|
if (!this._map.hasOwner(t)) {
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
const ownerId = this.ownerID(tile);
|
const owner = this.owner(t) as PlayerImpl;
|
||||||
const x = this.x(tile);
|
if (this._map.isBorder(t)) {
|
||||||
const y = this.y(tile);
|
owner._borderTiles.add(t);
|
||||||
if (x > 0 && this.ownerID(this._map.ref(x - 1, y)) !== ownerId) {
|
} else {
|
||||||
return true;
|
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) {
|
target(targeter: Player, target: Player) {
|
||||||
@@ -1139,12 +1099,10 @@ export class GameImpl implements Game {
|
|||||||
}
|
}
|
||||||
// Zero-allocation neighbor iteration (cardinal only)
|
// Zero-allocation neighbor iteration (cardinal only)
|
||||||
forEachNeighbor(tile: TileRef, callback: (neighbor: TileRef) => void): void {
|
forEachNeighbor(tile: TileRef, callback: (neighbor: TileRef) => void): void {
|
||||||
const x = this.x(tile);
|
this._map.forEachNeighbor(tile, callback);
|
||||||
const y = this.y(tile);
|
}
|
||||||
if (x > 0) callback(this._map.ref(x - 1, y));
|
neighbors4(ref: TileRef, out: TileRef[]): number {
|
||||||
if (x + 1 < this._width) callback(this._map.ref(x + 1, y));
|
return this._map.neighbors4(ref, out);
|
||||||
if (y > 0) callback(this._map.ref(x, y - 1));
|
|
||||||
if (y + 1 < this._height) callback(this._map.ref(x, y + 1));
|
|
||||||
}
|
}
|
||||||
isWater(ref: TileRef): boolean {
|
isWater(ref: TileRef): boolean {
|
||||||
return this._map.isWater(ref);
|
return this._map.isWater(ref);
|
||||||
|
|||||||
@@ -36,6 +36,18 @@ export interface GameMap {
|
|||||||
isOnEdgeOfMap(ref: TileRef): boolean;
|
isOnEdgeOfMap(ref: TileRef): boolean;
|
||||||
isBorder(ref: TileRef): boolean;
|
isBorder(ref: TileRef): boolean;
|
||||||
neighbors(ref: TileRef): TileRef[];
|
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;
|
isWater(ref: TileRef): boolean;
|
||||||
isShore(ref: TileRef): boolean;
|
isShore(ref: TileRef): boolean;
|
||||||
cost(ref: TileRef): number;
|
cost(ref: TileRef): number;
|
||||||
@@ -196,9 +208,16 @@ export class GameMapImpl implements GameMap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isOceanShore(ref: TileRef): boolean {
|
isOceanShore(ref: TileRef): boolean {
|
||||||
return (
|
if (!this.isLand(ref)) {
|
||||||
this.isLand(ref) && this.neighbors(ref).some((tr) => this.isOcean(tr))
|
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 {
|
isOcean(ref: TileRef): boolean {
|
||||||
@@ -288,9 +307,16 @@ export class GameMapImpl implements GameMap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isBorder(ref: TileRef): boolean {
|
isBorder(ref: TileRef): boolean {
|
||||||
return this.neighbors(ref).some(
|
const w = this.width_;
|
||||||
(tr) => this.ownerID(tr) !== this.ownerID(ref),
|
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 {
|
hasDefenseBonus(ref: TileRef): boolean {
|
||||||
@@ -343,6 +369,51 @@ export class GameMapImpl implements GameMap {
|
|||||||
return neighbors;
|
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 {
|
forEachTile(fn: (tile: TileRef) => void): void {
|
||||||
for (let ref: TileRef = 0; ref < this.width_ * this.height_; ref++) {
|
for (let ref: TileRef = 0; ref < this.width_ * this.height_; ref++) {
|
||||||
fn(ref);
|
fn(ref);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user