Files
OpenFrontIO/src/core/game/GameMap.ts
T
7fa81c6bb9 perf: reduce core live-memory footprint by 45% on large maps (#4507)
## Summary

Reduces the simulation's steady-state memory footprint. On Giant World
Map at 20 game-minutes (12 000 ticks, 400 bots, seed `perf-default`),
live memory after a full GC drops **293 MB → 161 MB (−45%)**; unforced
peak heap drops **326 MB → 165 MB**. The simulation also runs ~10%
faster (85 → 94 ticks/s). The final game-state hash is **bit-identical**
(`57830793797434300`) — no behavior change.

## Measurement (first commit)

The full-game perf harness gains a footprint mode:

- `--footprint` — forces a full GC at every `--window` boundary and
records the live heap / ArrayBuffer / RSS curve across the game
(requires `NODE_OPTIONS=--expose-gc`).
- `--snapshot-at 0,2000,12000` — writes V8 `.heapsnapshot` files at
chosen ticks.
- `HeapSnapshotRetainers.ts` — attributes every heap node to its nearest
meaningfully-named retainer (e.g. `PlayerImpl._tiles`), plus prints
retainer chains for all nodes ≥128 KB. `HeapSnapshotSummary.ts` is a
streaming fallback for snapshots too large to `JSON.parse`.

Baseline attribution at tick 12 000: player `_tiles`/`_borderTiles` Sets
**83 MB**, GameMap `refToX`/`refToY` lookup tables **38 MB**, two
duplicate 30.5 MB visited-scratch arrays, trade-ship stepper paths **15
MB**, a construction-only flood-fill queue **9.5 MB**.

## Optimizations

**Map-sized buffers (second commit):**
- `GameMap.x()/y()` compute `ref % width` / `(ref / width) | 0` instead
of reading two per-tile Uint16 tables (−38 MB). The arithmetic is
cheaper than the tables' random-access cache misses — this is where the
speedup comes from.
- `PlayerExecution` and `SpatialQuery` each kept their own per-game
generation-stamped visited `Uint32Array`; both now share one via
`TileTraversalScratch` (−30 MB).
- `PathFinderStepper` stores numeric paths as `Uint32Array` (half the
bytes; steppers hold their full path for a unit's whole journey).
- `ConnectedComponents` frees its flood-fill queue after `initialize()`.

**Player tile sets (third commit):**
- New `TileSet`: insertion-ordered set of tile refs backed by a dense
`Uint32Array` plus an open-addressing hash index — ~12 bytes/element vs
~34 for a native `Set<number>`. Deletes tombstone; compaction is
deferred while iteration is in progress so positions never shift under
an iterator.
- Iteration semantics match `Set` exactly (insertion order, entries
added mid-iteration visited, deleted ones skipped, delete+re-add moves
to end) — the simulation relies on this order for determinism, and the
unchanged hash confirms it.
- `Player.borderTiles()` now returns `ReadonlyTileSet` (a native `Set`
still satisfies it structurally); `GameRunner.playerBorderTiles` copies
into a real `Set` since that result crosses the worker boundary via
structured clone.

## Footprint curve (giant world map, live MB after forced GC)

| checkpoint | before | after |
|---|---|---|
| spawn end | 20 + 100 buf | 20 + 55 buf |
| tick 6301 | 119 + 161 buf | 29 + 127 buf |
| tick 12301 | 130 + 161 buf | 32 + 129 buf |

## Validation

- Final hash `57830793797434300` identical across baseline / round 1 /
round 2 runs (12 000 ticks).
- Full suite passes (1798 + 126 tests), including new `TileSet` tests:
order semantics, mutation-during-iteration parity with `Set`, tombstone
compaction, and a 20 000-op randomized differential test against native
`Set`.
- Runs recorded in
`tests/perf/output/footprint-{baseline,round1,round2}-giant.txt`.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 15:25:29 -07:00

664 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Cell, TerrainType } from "./Game";
export type TileRef = number;
export interface GameMap {
ref(x: number, y: number): TileRef;
isValidRef(ref: TileRef): boolean;
x(ref: TileRef): number;
y(ref: TileRef): number;
cell(ref: TileRef): Cell;
width(): number;
height(): number;
numLandTiles(): number;
isValidCoord(x: number, y: number): boolean;
// Terrain getters
isLand(ref: TileRef): boolean;
isImpassable(ref: TileRef): boolean;
isOceanShore(ref: TileRef): boolean;
isOcean(ref: TileRef): boolean;
isShoreline(ref: TileRef): boolean;
magnitude(ref: TileRef): number;
terrainByte(ref: TileRef): number;
// Terrain setters
setWater(ref: TileRef): void;
setShorelineBit(ref: TileRef): void;
clearShorelineBit(ref: TileRef): void;
setOcean(ref: TileRef): void;
setMagnitude(ref: TileRef, value: number): void;
// State getters and setters (mutable)
ownerID(ref: TileRef): number;
hasOwner(ref: TileRef): boolean;
setOwnerID(ref: TileRef, playerId: number): void;
hasFallout(ref: TileRef): boolean;
setFallout(ref: TileRef, value: boolean): void;
isOnEdgeOfMap(ref: TileRef): boolean;
isBorder(ref: TileRef): boolean;
neighbors(ref: TileRef): TileRef[];
// Zero-allocation neighbor iteration (cardinal only), in the same N, S, W, E
// order as neighbors(). All cardinal-neighbor helpers share this order so
// they are interchangeable even in order-sensitive simulation code.
forEachNeighbor(ref: TileRef, callback: (neighbor: TileRef) => void): void;
// Writes the cardinal neighbors of ref into out (same N, S, W, E order as
// neighbors()) 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;
terrainType(ref: TileRef): TerrainType;
forEachTile(fn: (tile: TileRef) => void): void;
manhattanDist(c1: TileRef, c2: TileRef): number;
euclideanDistSquared(c1: TileRef, c2: TileRef): number;
circleSearch(
tile: TileRef,
radius: number,
filter?: (tile: TileRef, d2: number) => boolean,
): Set<TileRef>;
bfs(
tile: TileRef,
filter: (gm: GameMap, tile: TileRef) => boolean,
): Set<TileRef>;
/**
* Returns the packed per-tile state as an unsigned 16-bit value (`0..65535`).
*
* Backed by a `Uint16Array` in `GameMapImpl`, so callers must treat this as `uint16`.
*/
tileState(tile: TileRef): number;
/**
* Applies a packed per-tile state value.
*
* `state` must be an unsigned 16-bit value (`0..65535`). Implementations may
* store this in a `Uint16Array` and will truncate higher bits if provided.
*
* Returns `true` when the terrain byte changed (land/water/shoreline/magnitude).
*/
updateTile(tile: TileRef, state: number): boolean;
/**
* Direct access to the per-tile state buffer for zero-copy consumers
* (e.g. WebGL renderer uploading to a R16UI texture).
*
* The returned array is a live reference — it is mutated by `updateTile()`
* each tick. Callers must not write to it.
*
* The bit layout of each `uint16` matches the renderer's tile state:
* bits 0-11: ownerID
* bit 13: fallout
* bit 14: defense bonus
*/
tileStateBuffer(): Uint16Array;
numTilesWithFallout(): number;
}
export class GameMapImpl implements GameMap {
private _numTilesWithFallout = 0;
private readonly terrain: Uint8Array; // Immutable terrain data
private readonly state: Uint16Array; // Mutable game state
private readonly width_: number;
private readonly height_: number;
// Row-start ref per y, so ref(x, y) avoids a multiply. x/y are derived from
// a ref arithmetically (ref % width, ref / width) rather than via per-tile
// lookup tables — two Uint16 tables cost 4 bytes per tile (~32 MB on the
// largest maps) and their random-access reads miss cache more often than
// the division costs.
private readonly yToRef: Int32Array;
// Terrain bits (Uint8Array)
private static readonly IS_LAND_BIT = 7;
private static readonly SHORELINE_BIT = 6;
private static readonly OCEAN_BIT = 5;
private static readonly MAGNITUDE_MASK = 0x1f; // 11111 in binary
// Land tiles with magnitude == IMPASSABLE_MAGNITUDE are impassable terrain:
// solid ground that cannot be owned, attacked, or nuked, and that nuke
// trajectories cannot cross. Rendered as the map background colour so the
// map appears non-rectangular.
private static readonly IMPASSABLE_MAGNITUDE = 31;
// State bits (Uint16Array)
private static readonly PLAYER_ID_MASK = 0xfff;
private static readonly FALLOUT_BIT = 13;
private static readonly DEFENSE_BONUS_BIT = 14;
// Bit 15 still reserved
constructor(
width: number,
height: number,
terrainData: Uint8Array,
private numLandTiles_: number,
) {
if (terrainData.length !== width * height) {
throw new Error(
`Terrain data length ${terrainData.length} doesn't match dimensions ${width}x${height}`,
);
}
this.width_ = width;
this.height_ = height;
this.terrain = terrainData;
this.state = new Uint16Array(width * height);
this.yToRef = new Int32Array(height);
for (let y = 0; y < height; y++) {
this.yToRef[y] = y * width;
}
}
numTilesWithFallout(): number {
return this._numTilesWithFallout;
}
ref(x: number, y: number): TileRef {
if (!this.isValidCoord(x, y)) {
throw new Error(`Invalid coordinates: ${x},${y}`);
}
return this.yToRef[y] + x;
}
isValidRef(ref: TileRef): boolean {
return ref >= 0 && ref < this.width_ * this.height_;
}
x(ref: TileRef): number {
return ref % this.width_;
}
y(ref: TileRef): number {
return (ref / this.width_) | 0;
}
cell(ref: TileRef): Cell {
return new Cell(this.x(ref), this.y(ref));
}
width(): number {
return this.width_;
}
height(): number {
return this.height_;
}
numLandTiles(): number {
return this.numLandTiles_;
}
isValidCoord(x: number, y: number): boolean {
return (
Number.isInteger(x) &&
Number.isInteger(y) &&
x >= 0 &&
x < this.width_ &&
y >= 0 &&
y < this.height_
);
}
// Terrain getters (immutable)
isLand(ref: TileRef): boolean {
return Boolean(this.terrain[ref] & (1 << GameMapImpl.IS_LAND_BIT));
}
isImpassable(ref: TileRef): boolean {
return (
this.isLand(ref) &&
(this.terrain[ref] & GameMapImpl.MAGNITUDE_MASK) ===
GameMapImpl.IMPASSABLE_MAGNITUDE
);
}
isOceanShore(ref: TileRef): boolean {
if (!this.isLand(ref)) {
return false;
}
const w = this.width_;
const x = ref % w;
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 {
return Boolean(this.terrain[ref] & (1 << GameMapImpl.OCEAN_BIT));
}
isShoreline(ref: TileRef): boolean {
return Boolean(this.terrain[ref] & (1 << GameMapImpl.SHORELINE_BIT));
}
magnitude(ref: TileRef): number {
return this.terrain[ref] & GameMapImpl.MAGNITUDE_MASK;
}
terrainByte(ref: TileRef): number {
return this.terrain[ref];
}
setWater(ref: TileRef): void {
if (!this.isLand(ref) || this.isImpassable(ref)) return;
this.terrain[ref] = 0; // Lake water: no land, no ocean, no shoreline, magnitude 0
this.numLandTiles_--;
}
setShorelineBit(ref: TileRef): void {
this.terrain[ref] |= 1 << GameMapImpl.SHORELINE_BIT;
}
clearShorelineBit(ref: TileRef): void {
this.terrain[ref] &= ~(1 << GameMapImpl.SHORELINE_BIT);
}
setOcean(ref: TileRef): void {
this.terrain[ref] |= 1 << GameMapImpl.OCEAN_BIT;
}
setMagnitude(ref: TileRef, value: number): void {
this.terrain[ref] =
(this.terrain[ref] & ~GameMapImpl.MAGNITUDE_MASK) |
(value & GameMapImpl.MAGNITUDE_MASK);
}
// State getters and setters (mutable)
ownerID(ref: TileRef): number {
return this.state[ref] & GameMapImpl.PLAYER_ID_MASK;
}
hasOwner(ref: TileRef): boolean {
return this.ownerID(ref) !== 0;
}
setOwnerID(ref: TileRef, playerId: number): void {
if (playerId > GameMapImpl.PLAYER_ID_MASK) {
throw new Error(
`Player ID ${playerId} exceeds maximum value ${GameMapImpl.PLAYER_ID_MASK}`,
);
}
this.state[ref] =
(this.state[ref] & ~GameMapImpl.PLAYER_ID_MASK) | playerId;
}
hasFallout(ref: TileRef): boolean {
return Boolean(this.state[ref] & (1 << GameMapImpl.FALLOUT_BIT));
}
setFallout(ref: TileRef, value: boolean): void {
const existingFallout = this.hasFallout(ref);
if (value) {
if (!existingFallout) {
this._numTilesWithFallout++;
this.state[ref] |= 1 << GameMapImpl.FALLOUT_BIT;
}
} else {
if (existingFallout) {
this._numTilesWithFallout--;
this.state[ref] &= ~(1 << GameMapImpl.FALLOUT_BIT);
}
}
}
isOnEdgeOfMap(ref: TileRef): boolean {
const x = this.x(ref);
const y = this.y(ref);
return (
x === 0 || x === this.width() - 1 || y === 0 || y === this.height() - 1
);
}
isBorder(ref: TileRef): boolean {
const w = this.width_;
const x = ref % w;
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 {
return Boolean(this.state[ref] & (1 << GameMapImpl.DEFENSE_BONUS_BIT));
}
setDefenseBonus(ref: TileRef, value: boolean): void {
if (value) {
this.state[ref] |= 1 << GameMapImpl.DEFENSE_BONUS_BIT;
} else {
this.state[ref] &= ~(1 << GameMapImpl.DEFENSE_BONUS_BIT);
}
}
// Helper methods
isWater(ref: TileRef): boolean {
return !this.isLand(ref);
}
isShore(ref: TileRef): boolean {
return this.isLand(ref) && this.isShoreline(ref);
}
cost(ref: TileRef): number {
return this.magnitude(ref) < 10 ? 2 : 1;
}
// if updating these magnitude values, also update
// `../../../map-generator/map_generator.go` `getThumbnailColor`
terrainType(ref: TileRef): TerrainType {
if (this.isLand(ref)) {
const magnitude = this.magnitude(ref);
if (magnitude >= GameMapImpl.IMPASSABLE_MAGNITUDE)
return TerrainType.Impassable;
if (magnitude < 10) return TerrainType.Plains;
if (magnitude < 20) return TerrainType.Highland;
return TerrainType.Mountain;
}
return TerrainType.Ocean;
}
neighbors(ref: TileRef): TileRef[] {
const neighbors: TileRef[] = [];
const w = this.width_;
const x = ref % w;
if (ref >= w) neighbors.push(ref - w);
if (ref < (this.height_ - 1) * w) neighbors.push(ref + w);
if (x !== 0) neighbors.push(ref - 1);
if (x !== w - 1) neighbors.push(ref + 1);
return neighbors;
}
forEachNeighbor(ref: TileRef, callback: (neighbor: TileRef) => void): void {
const w = this.width_;
const x = ref % w;
if (ref >= w) callback(ref - w);
if (ref < (this.height_ - 1) * w) callback(ref + w);
if (x !== 0) callback(ref - 1);
if (x !== w - 1) callback(ref + 1);
}
neighbors4(ref: TileRef, out: TileRef[]): number {
const w = this.width_;
const x = ref % w;
let n = 0;
if (ref >= w) out[n++] = ref - w;
if (ref < (this.height_ - 1) * w) out[n++] = ref + w;
if (x !== 0) out[n++] = ref - 1;
if (x !== w - 1) out[n++] = ref + 1;
return n;
}
forEachNeighborWithDiag(
ref: TileRef,
callback: (neighbor: TileRef) => void,
): void {
const w = this.width_;
const x = ref % w;
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);
}
}
manhattanDist(c1: TileRef, c2: TileRef): number {
return (
Math.abs(this.x(c1) - this.x(c2)) + Math.abs(this.y(c1) - this.y(c2))
);
}
euclideanDistSquared(c1: TileRef, c2: TileRef): number {
const x = this.x(c1) - this.x(c2);
const y = this.y(c1) - this.y(c2);
return x * x + y * y;
}
circleSearch(
tile: TileRef,
radius: number,
filter?: (tile: TileRef, d2: number) => boolean,
): Set<TileRef> {
const center = { x: this.x(tile), y: this.y(tile) };
const tiles: Set<TileRef> = new Set<TileRef>();
const minX = Math.max(0, center.x - radius);
const maxX = Math.min(this.width_ - 1, center.x + radius);
const minY = Math.max(0, center.y - radius);
const maxY = Math.min(this.height_ - 1, center.y + radius);
for (let i = minX; i <= maxX; ++i) {
for (let j = minY; j <= maxY; j++) {
const t = this.yToRef[j] + i;
const d2 = this.euclideanDistSquared(tile, t);
if (d2 > radius * radius) continue;
if (!filter || filter(t, d2)) {
tiles.add(t);
}
}
}
return tiles;
}
bfs(
tile: TileRef,
filter: (gm: GameMap, tile: TileRef) => boolean,
): Set<TileRef> {
const seen = new Set<TileRef>();
const q: TileRef[] = [];
if (filter(this, tile)) {
seen.add(tile);
q.push(tile);
}
// Neighbors are enumerated inline in the same order as neighbors() to
// avoid allocating an array per visited tile.
const w = this.width_;
const southLimit = (this.height_ - 1) * w;
const visit = (n: TileRef) => {
if (!seen.has(n) && filter(this, n)) {
seen.add(n);
q.push(n);
}
};
while (q.length > 0) {
const curr = q.pop();
if (curr === undefined) continue;
const x = curr % w;
if (curr >= w) visit(curr - w);
if (curr < southLimit) visit(curr + w);
if (x !== 0) visit(curr - 1);
if (x !== w - 1) visit(curr + 1);
}
return seen;
}
tileState(tile: TileRef): number {
return this.state[tile];
}
tileStateBuffer(): Uint16Array {
return this.state;
}
/**
* Update a tile from a packed uint32:
* bits 0-15: tile state (owner, fallout, etc.)
* bits 16-23: terrain byte (land, ocean, shoreline, magnitude)
*/
updateTile(tile: TileRef, packed: number): boolean {
const state = packed & 0xffff;
const terrainByte = (packed >>> 16) & 0xff;
const existingFallout = this.hasFallout(tile);
this.state[tile] = state;
const newFallout = this.hasFallout(tile);
if (existingFallout && !newFallout) {
this._numTilesWithFallout--;
}
if (!existingFallout && newFallout) {
this._numTilesWithFallout++;
}
// Update terrain if the packed value includes a terrain byte that differs
const terrainChanged = this.terrain[tile] !== terrainByte;
if (terrainChanged) {
const wasLand = this.isLand(tile);
this.terrain[tile] = terrainByte;
const isNowLand = Boolean(terrainByte & (1 << GameMapImpl.IS_LAND_BIT));
if (wasLand && !isNowLand) this.numLandTiles_--;
else if (!wasLand && isNowLand) this.numLandTiles_++;
}
return terrainChanged;
}
}
export function euclDistFN(
root: TileRef,
dist: number,
center: boolean = false,
): (gm: GameMap, tile: TileRef) => boolean {
const dist2 = dist * dist;
if (!center) {
return (gm: GameMap, n: TileRef) =>
gm.euclideanDistSquared(root, n) <= dist2;
} else {
return (gm: GameMap, n: TileRef) => {
// shifts the root tiles coordinates by -0.5 so that its “center”
// center becomes the corner of four pixels rather than the middle of one pixel.
// just makes things based off even pixels instead of odd. Used to use 9x9 icons now 10x10 icons etc...
const rootX = gm.x(root) - 0.5;
const rootY = gm.y(root) - 0.5;
const dx = gm.x(n) - rootX;
const dy = gm.y(n) - rootY;
return dx * dx + dy * dy <= dist2;
};
}
}
export function manhattanDistFN(
root: TileRef,
dist: number,
center: boolean = false,
): (gm: GameMap, tile: TileRef) => boolean {
if (!center) {
return (gm: GameMap, n: TileRef) => gm.manhattanDist(root, n) <= dist;
} else {
return (gm: GameMap, n: TileRef) => {
const rootX = gm.x(root) - 0.5;
const rootY = gm.y(root) - 0.5;
const dx = Math.abs(gm.x(n) - rootX);
const dy = Math.abs(gm.y(n) - rootY);
return dx + dy <= dist;
};
}
}
export function rectDistFN(
root: TileRef,
dist: number,
center: boolean = false,
): (gm: GameMap, tile: TileRef) => boolean {
if (!center) {
return (gm: GameMap, n: TileRef) => {
const dx = Math.abs(gm.x(n) - gm.x(root));
const dy = Math.abs(gm.y(n) - gm.y(root));
return dx <= dist && dy <= dist;
};
} else {
return (gm: GameMap, n: TileRef) => {
const rootX = gm.x(root) - 0.5;
const rootY = gm.y(root) - 0.5;
const dx = Math.abs(gm.x(n) - rootX);
const dy = Math.abs(gm.y(n) - rootY);
return dx <= dist && dy <= dist;
};
}
}
function isInIsometricTile(
center: { x: number; y: number },
tile: { x: number; y: number },
yOffset: number,
distance: number,
): boolean {
const dx = Math.abs(tile.x - center.x);
const dy = Math.abs(tile.y - (center.y + yOffset));
return dx + dy * 2 <= distance + 1;
}
export function isometricDistFN(
root: TileRef,
dist: number,
center: boolean = false,
): (gm: GameMap, tile: TileRef) => boolean {
if (!center) {
return (gm: GameMap, n: TileRef) => gm.manhattanDist(root, n) <= dist;
} else {
return (gm: GameMap, n: TileRef) => {
const rootX = gm.x(root) - 0.5;
const rootY = gm.y(root) - 0.5;
return isInIsometricTile(
{ x: rootX, y: rootY },
{ x: gm.x(n), y: gm.y(n) },
0,
dist,
);
};
}
}
export function hexDistFN(
root: TileRef,
dist: number,
center: boolean = false,
): (gm: GameMap, tile: TileRef) => boolean {
if (!center) {
return (gm: GameMap, n: TileRef) => {
const dx = Math.abs(gm.x(n) - gm.x(root));
const dy = Math.abs(gm.y(n) - gm.y(root));
return dx <= dist && dy <= dist && dx + dy <= dist * 1.5;
};
} else {
return (gm: GameMap, n: TileRef) => {
const rootX = gm.x(root) - 0.5;
const rootY = gm.y(root) - 0.5;
const dx = Math.abs(gm.x(n) - rootX);
const dy = Math.abs(gm.y(n) - rootY);
return dx <= dist && dy <= dist && dx + dy <= dist * 1.5;
};
}
}
export function andFN(
x: (gm: GameMap, tile: TileRef) => boolean,
y: (gm: GameMap, tile: TileRef) => boolean,
): (gm: GameMap, tile: TileRef) => boolean {
return (gm: GameMap, tile: TileRef) => x(gm, tile) && y(gm, tile);
}