Files
ark-stronghold-protocol/server/sim/grid.js
T
sganggs 8d896e40ea feedback7 R22-FIELDS: the path tie-break never wades into more 深水区 than the official route — on 战场#08(下半) the patrolling 铳 / 卢西恩 take the official upper road from (3,9) to (2,3) again (GitHub #375); golden: boss-boss_2-pair, coop2-ABYSS-10-boosted
GitHub #375 (FrogThai): 「水图boss会从下面这条路往蓝门走,实际应该从上路走」. The walking leaders of 战场#08(下半) 涨潮控制
(act2autochess_m04) are 铳 (level_act1autochess_h07_02 route 6; the Hidden Core 铳 on act1autochess_h08_02's patrol
routes) and 卢西恩 (act2autochess_h07_05 route 8): PATROL_MOVE (3,9) → (2,3) → (5,9). Measured (notes repro-375.mjs):
ours 「3,9 2,9 2,6 4,6 4,4 2,4 2,3」 — the lower road through the 深水区 (3,6) / (4,6) — against the official SPFA
「3,9 5,9 5,4 2,4 2,3」 along row 5 with one 深水区 (5,6); the mirrored (3,11) → (2,17) split the same way.
Cause: grid.js _buildField takes the blockable-ground preference pointer (user playtest #2) when its route crosses
strictly fewer non-blockable tiles; 深水区 is non-blockable itself (no deployment), so the lower road counted 2 against
the upper road's 3 (two floor tiles and one water tile) and won. Official pathfinding is the plain 4-neighbour SPFA
(research 08 §3.1). Now the preference pointer is taken only when its route also crosses no more 深水区 than the official
pointer's (a per-tile `deep` count beside `cost`, start excluded). Rule from the Grok triage (scratch-i1b/375.patch),
re-implemented with a precomputed Grid.deepWater mask (terrain 'deepsea': tile_deepsea and tile_deepwater).

Scope (notes dump-fields.mjs / check-official.mjs, every stage × normal / 联防 / boss rect × every walkable
destination): 48 fields change, only on the two water maps (act2 m04 boss field; the inactive act1 m05), 149 pointers,
every one back to the official pointer. The 21 accepted DEVIATIONS (战场#01 / #02 floor, #07, the inactive #05's 7
water-avoiding routes) are unchanged, as are all 154 audited gate routes and data/stages.json's groundPaths
(tools/build-data.mjs --offline into a scratch dir: every data file identical). Other boss-field preference routes of
m04 that cross fewer 深水区 than the official one stay.

Tests: test/sim/pathing-official.test.js — two new #375 tests (the four patrol legs equal the pure official waypoints;
in a real battle the 铳 climbs to row 5 and steps on the 深水区 (5,6) only), both failing on the parent;
test/sim/pathing-crosscheck.test.js — the independent reference port gets the same 深水区 rule (it flagged both the
patrol leg and a random-crate normal-field route of act2 m04 until then); test/sim/pathing-blockable.test.js — the WALK-leg
audit compares with the equal-length routes that cross no more 深水区 than ours (the 14 accepted arena exceptions
unchanged). Grid-related suites 777/777.

Golden:
- fields boss-boss_2-pair (铳 pair on act2autochess_m04, act1autochess_h07_02): the two 铳 patrol the upper roads;
  leader left 5311560 → 5301010, p1 damage up / p2 down, deaths 0 / 2 → 1 / 3; still 8 / 8 kills.
- matches coop2-ABYSS-10-boosted (act2 m04; Final Assault 卢西恩, Hidden Core 铳): still cleared, team LP 800 → 615. The
  bots' operators stand on the lower rows (the same tiles as before); with the leaders on the upper road the Final
  Assault lasts 335 game s instead of 247 and the Hidden Core 638 instead of 190, both past the 150 real-second mark:
  the overtime drain takes 17 + 168 LP (none before), no leak.
2026-10-08 20:14:37 +08:00

581 lines
30 KiB
JavaScript
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.
// server/sim/grid.js — field grid, tile queries, passability and the official ground pathfinding (DESIGN §3,
// research 08 §3).
//
// Coordinates: row 0 = bottom, col 0 = left. Tile (r,c) has world centre (x=c, y=r).
// Stage input: `{ id, rows: string[19] (bottom-first), legend?: {glyph: {...}}, devices?: [...] }`.
// Legend entries are normalised from either the research format (heightType/buildableType/passableMask/tileKey)
// or the build-data format (height/build/passable/terrain); missing glyphs fall back to DEFAULT_LEGEND.
// `build` is the EFFECTIVE deploy type: a tile whose mechanism refuses deployment (DEPLOY_REFUSED_TILES: 深水区
// tile_deepsea — PRTS 深水区 地形信息 "地形机制:拒绝部署(待补充)"; player report after 0.1.0: operators stood in
// 战场#08's pool and 突袭 members jumped into it) is NONE whatever its level buildableType (ALL), so `canStand` — every
// automatic placement: the 突袭 landing tile, tactical points, summon tiles — and the path tie-break below treat it as
// ground no operator can stand on. data/stages.json legends already carry the effective value (tools/build-data.mjs).
// Known limit [ASSUMED]: the 特制水上平台 that makes a 深水区 deployable (act1 m05, weight 0 this season) exists only in
// the match's deploy map; the sim does not model those canoes (content/devices.js), so here their tiles stay NONE.
//
// Pathfinding = the official client's (`Torappu.Battle.SPFA`, research 08 §3.1/§3.4): one FLOW FIELD per destination,
// a FIFO SPFA from the destination over the rect with the 4 neighbours UP (row+1), RIGHT, DOWN, LEFT (in that order,
// no diagonal edges), edge cost = the neighbour's move cost (1, or OBSTACLE_COST 1000 for an "obstacle-like" tile: a
// crate), strict-improvement relaxation (a tile keeps the first parent that reached it); then the parents are
// smoothed row-major in place — each tile's parent jumps to the farthest ancestor in Bresenham line of sight (every
// tile on the line walkable and not a crate; a diagonal step needs both orthogonal neighbours clear). A ground enemy
// walks straight toward `next[tile]` of its current tile (ai.js). Fields are cached per (destination, grid version).
//
// Blockable-ground preference (user playtest #2: "the lower-gate enemies of 战场#01 walk up the col-9 floor lane where
// no operator can block them"), kept to where the official route really walks over more floor (community report after
// 0.1.0: on 战场#04 活性源石 the lower-gate enemies must cross diagonally from row 9 into row 10 as officially, not
// walk two tiles left of the gate and then straight up). A tile is non-blockable when it is walkable but not LOW
// ground buildable for melee (floor / gate / goal / teleport / 深水区 tiles — `blockable()` false; no gate route of 战场#08
// changed when its 深水区 became non-buildable). Each field holds two candidate
// pointers per tile, both with the official `dist` (so the crate cost 1000 and every grid route length stay official):
// * official: the plain algorithm above;
// * preference (0.1.0's): the SPFA relaxes on (dist, pen) lexicographically, `pen` = non-blockable tiles on the chain
// (among equal-length chains the fewest wins; remaining ties go to the first parent in SPFA order), and the line of
// sight may cover a non-blockable tile — its Bresenham footprint, the corner tiles of a diagonal step included —
// only on the tile's own raw chain between its two ends, so it never slips past a road tile beside floor its grid
// route does not walk.
// `next[tile]` is the official pointer unless the preference pointer leads to a route CROSSING strictly fewer
// non-blockable tiles (`cost`, counted to the destination in increasing `dist` order) and no more 深水区 (`deep`): 深水区
// is non-blockable itself, so without that test the preference waded into the pool to dodge floor (GitHub #375: on
// 战场#08(下半) the patrolling 铳 / 卢西恩 took the lower road (3,9) → (2,3) through two water tiles, one floor tile fewer
// than the official upper road with one). A segment crosses the tiles
// whose interior it passes through (`crossTiles`, exact geometry); one it only touches at a corner point is not crossed
// — the first version counted the corner tiles there too and so bent official diagonals into L shapes (D5). On equal
// counts the official pointer stays, except where the preference one merely skips it (the official waypoint lies on
// the straight line to it and leads there: the same walk, one waypoint fewer). So the route from every tile crosses
// no more floor than the official one and leaves it only for a step that crosses less (test/sim/pathing-official.test.js
// lists the routes that still differ on every stage).
// Unavoidable non-blockable tiles (gates, goals, teleports and single-exit floor, e.g. (12,9) next to the upper
// gate) stay on the route; test/sim/pathing-blockable.test.js lists them per stage, gate and field.
//
// Obstacle layer (bit flags per tile): BLOCK (射击台 / mounds / `setObstacle(r, c, on)` — impassable [ASSUMED for
// platforms, research 08 §8 #1]) and CRATE (阻隔工事 devices — `setObstacle(r, c, on, 'crate')`: walkable at cost 1000,
// blocks line of sight; an enemy that must cross one is blocked by the device and breaks it). Any change bumps
// `version`, which invalidates the cached fields (enemies re-read their next step). `groundPassable` (placement,
// displacement, content) still treats both kinds as occupied unless `ignoreObstacles`. `straightClear` tells whether an
// off-centre unit may walk straight to a tile centre (ai.js planLeg, fear.js: else it steps back to its tile centre).
import { ROWS, COLS } from './constants.js';
import { hypot } from './detmath.js';
/** Move cost of an obstacle-like tile (client `Tile.get_moveCost`: obstacle-like ? 1000 : 1). */
export const OBSTACLE_COST = 1000;
/** Obstacle-layer bits. */
export const OB_BLOCK = 1;
export const OB_CRATE = 2;
/** Official neighbour order `GridPosition.GRID_FOUR_WAYS`: UP (row+1), RIGHT, DOWN, LEFT — do not reorder. */
const FOUR_WAYS = [[1, 0], [0, 1], [-1, 0], [0, -1]];
/** Cached flow fields per grid version (destinations: route ends, checkpoints, attract points). */
const FIELD_CACHE_MAX = 64;
/** Built-in legend (research 05 §2.3) used when a stage has no legend entry for a glyph. */
export const DEFAULT_LEGEND = Object.freeze({
'#': { height: 'HIGH', build: 'NONE', pass: 'FLY', key: 'tile_forbidden' },
X: { height: 'HIGH', build: 'NONE', pass: 'NONE', key: 'tile_forbidden' },
r: { height: 'LOW', build: 'ALL', pass: 'ALL', key: 'tile_road' },
R: { height: 'LOW', build: 'NONE', pass: 'ALL', key: 'tile_road' },
f: { height: 'LOW', build: 'NONE', pass: 'ALL', key: 'tile_floor' },
p: { height: 'LOW', build: 'NONE', pass: 'ALL', key: 'tile_floor' },
h: { height: 'HIGH', build: 'RANGED', pass: 'FLY', key: 'tile_wall' },
b: { height: 'LOW', build: 'ALL', pass: 'FLY', key: 'tile_fence_bound' },
a: { height: 'HIGH', build: 'NONE', pass: 'FLY', key: 'tile_achand' },
A: { height: 'HIGH', build: 'NONE', pass: 'FLY', key: 'tile_achand' },
S: { height: 'LOW', build: 'NONE', pass: 'ALL', key: 'tile_start', special: 'start' },
E: { height: 'LOW', build: 'NONE', pass: 'ALL', key: 'tile_end', special: 'end' },
I: { height: 'LOW', build: 'NONE', pass: 'ALL', key: 'tile_telin', special: 'telin' },
O: { height: 'LOW', build: 'NONE', pass: 'ALL', key: 'tile_telout', special: 'telout' },
m: { height: 'LOW', build: 'ALL', pass: 'ALL', key: 'tile_mire', terrain: 'mire' },
g: { height: 'LOW', build: 'ALL', pass: 'ALL', key: 'tile_smog', terrain: 'smog' },
d: { height: 'LOW', build: 'NONE', pass: 'ALL', key: 'tile_deepsea', terrain: 'deepsea' },
i: { height: 'LOW', build: 'ALL', pass: 'ALL', key: 'tile_infection', terrain: 'infection' },
});
/**
* Tile keys whose mechanism refuses deployment although the level's buildableType allows it: 深水区 tile_deepsea (PRTS
* 深水区 地形信息 "部署类型 全部位 … 地形机制 拒绝部署(待补充)"; PRTS 作战机制: the 地形标记 AdvancedBuildableMask —
* 深水 among them — "限制玩家仅能部署匹配的单位于其上"; the season-1 战场#05 puts a 特制水上平台 — "在水上建立可以部署
* 任意单位的平台" — on every one of its 深水区 tiles). Shared with tools/build-data.mjs (the stages.json legend's
* `buildable`).
*/
export const DEPLOY_REFUSED_TILES = Object.freeze(new Set(['tile_deepsea']));
const TERRAIN_BY_KEY = { tile_mire: 'mire', tile_smog: 'smog', tile_deepsea: 'deepsea', tile_infection: 'infection', tile_deepwater: 'deepsea' };
const SPECIAL_BY_KEY = { tile_start: 'start', tile_end: 'end', tile_telin: 'telin', tile_telout: 'telout' };
function normPass(v) {
if (v === true) return 'ALL';
if (v === false) return 'NONE';
const s = String(v ?? '').toUpperCase();
if (s === 'ALL' || s === 'GROUND' || s === 'WALK') return 'ALL';
if (s.includes('FLY')) return 'FLY';
if (s === 'NONE' || s === '') return 'NONE';
return 'ALL';
}
/** Normalise one legend entry (any known format) into `{ height, build, pass, key, terrain, special }`. */
export function normalizeLegendEntry(glyph, e) {
const base = DEFAULT_LEGEND[glyph] || { height: 'LOW', build: 'NONE', pass: 'ALL', key: 'tile_floor' };
if (!e || typeof e !== 'object') return { ...base, glyph };
const key = e.tileKey ?? e.key ?? e.tile ?? base.key;
const heightRaw = e.height ?? e.heightType;
const height = heightRaw == null ? base.height : (/HIGH/i.test(String(heightRaw)) ? 'HIGH' : 'LOW');
const buildRaw = e.build ?? e.buildable ?? e.buildableType;
const build = DEPLOY_REFUSED_TILES.has(key) ? 'NONE'
: buildRaw == null ? base.build : (buildRaw === true ? 'ALL' : buildRaw === false ? 'NONE' : String(buildRaw).toUpperCase());
let pass;
if (e.pass != null) pass = normPass(e.pass);
else if (e.passable != null || e.passableMask != null) pass = normPass(e.passable ?? e.passableMask);
else if (e.groundPassable != null) pass = e.groundPassable ? 'ALL' : (e.flyPassable === false ? 'NONE' : 'FLY');
else pass = base.pass;
const terrain = e.terrain ?? TERRAIN_BY_KEY[key] ?? base.terrain ?? null;
const special = e.special ?? SPECIAL_BY_KEY[key] ?? base.special ?? null;
return { glyph, key, height, build, pass, terrain, special };
}
const EMPTY_TILE = Object.freeze({ glyph: 'X', key: 'tile_forbidden', height: 'HIGH', build: 'NONE', pass: 'NONE', terrain: null, special: null });
/**
* A melee operator may stand on this terrain: LOW ground buildable for ALL or MELEE. It blocks ground enemies there only
* where ground units can pass — never on a fenced 围墙 / 围栏 tile (Battle._blockerFor, DESIGN §21.23).
*/
function isBlockableTile(t) { return t.height === 'LOW' && (t.build === 'ALL' || t.build === 'MELEE'); }
export class Grid {
/**
* @param {object} stage stage entry (rows bottom-first)
* @param {{r0:number,r1:number,c0:number,c1:number}} rect inclusive simulation bounds
*/
constructor(stage, rect) {
this.stage = stage || {};
this.rect = rect;
this.rows = ROWS;
this.cols = COLS;
const rows = Array.isArray(this.stage.rows) ? this.stage.rows : [];
const legend = this.stage.legend || {};
const cache = new Map();
const info = (g) => {
if (!cache.has(g)) cache.set(g, Object.freeze(normalizeLegendEntry(g, legend[g])));
return cache.get(g);
};
/** @type {object[]} */
this.tiles = new Array(ROWS * COLS);
for (let r = 0; r < ROWS; r++) {
const line = typeof rows[r] === 'string' ? rows[r] : (Array.isArray(rows[r]) ? rows[r].join('') : '');
for (let c = 0; c < COLS; c++) {
const g = line[c];
this.tiles[r * COLS + c] = g ? info(g) : EMPTY_TILE;
}
}
/** 1 = walkable terrain an operator cannot block on (floor, gate, goal, teleport…): the path tie-break penalty */
this.unblockable = new Uint8Array(ROWS * COLS);
/** 1 = walkable 深水区 (terrain 'deepsea': tile_deepsea / tile_deepwater): the tie-break never walks into more of it */
this.deepWater = new Uint8Array(ROWS * COLS);
for (let k = 0; k < ROWS * COLS; k++) {
const t = this.tiles[k];
this.unblockable[k] = t.pass === 'ALL' && !isBlockableTile(t) ? 1 : 0;
this.deepWater[k] = t.pass === 'ALL' && t.terrain === 'deepsea' ? 1 : 0;
}
/** obstacle bits per tile (OB_BLOCK | OB_CRATE) */
this.obstacle = new Uint8Array(ROWS * COLS);
this.version = 0;
/** @type {Map<string, FlowField>} */
this._fields = new Map();
}
key(r, c) { return r * COLS + c; }
inBounds(r, c) { return r >= 0 && r < ROWS && c >= 0 && c < COLS; }
inRect(r, c) { const R = this.rect; return r >= R.r0 && r <= R.r1 && c >= R.c0 && c <= R.c1; }
/** Tile info (frozen) or an impassable placeholder outside the stage. */
tile(r, c) { return this.inBounds(r, c) ? this.tiles[r * COLS + c] : EMPTY_TILE; }
/** Ground units may stand / be placed here (inside the rect, terrain passable, no obstacle of any kind). */
groundPassable(r, c, ignoreObstacles = false) {
if (!this.inRect(r, c)) return false;
const t = this.tiles[r * COLS + c];
if (t.pass !== 'ALL') return false;
return ignoreObstacles || !this.obstacle[r * COLS + c];
}
/** Walkable for the flow field: in the rect, terrain passable for WALK, not hard-blocked (crates ARE walkable). */
walkable(r, c, ignoreObstacles = false) {
if (!this.inRect(r, c)) return false;
const k = r * COLS + c;
if (this.tiles[k].pass !== 'ALL') return false;
return ignoreObstacles || !(this.obstacle[k] & OB_BLOCK);
}
flyPassable(r, c) { return this.inRect(r, c) && this.tile(r, c).pass !== 'NONE'; }
/**
* Whether a unit may be placed on this tile (every automatic placement: the 突袭 landing tile, tactical points, summon
* tiles). `ranged` units may also use LOW ALL/MELEE tiles (DESIGN §3). The effective `build` refuses the 深水区
* (DEPLOY_REFUSED_TILES); a hard-blocked tile (OB_BLOCK: a 射击台 switched on by a map card, a mound) takes no melee
* unit — the match's deploy map makes a 射击台 ranged-only (server/match/board.js).
*/
canStand(r, c, { ranged = false } = {}) {
const t = this.tile(r, c);
if (t.build === 'NONE') return false;
if (ranged) return t.build === 'ALL' || t.build === 'RANGED' || (t.height === 'LOW' && t.build === 'MELEE');
if (this.inBounds(r, c) && (this.obstacle[r * COLS + c] & OB_BLOCK)) return false;
return t.height === 'LOW' && (t.build === 'ALL' || t.build === 'MELEE');
}
isLow(r, c) { return this.tile(r, c).height === 'LOW'; }
/** Ground a melee operator can be deployed on (LOW, buildable ALL / MELEE) — terrain only; blocking: isBlockableTile. */
blockable(r, c) { return isBlockableTile(this.tile(r, c)); }
/**
* Toggle an obstacle. `kind` 'block' (default: impassable — platforms, mounds, content) or 'crate' (obstacle-like:
* walkable at OBSTACLE_COST, blocks line of sight). Each kind is its own bit, so a crate turned into a platform on
* the same tile (map cards) stays consistent whatever the order of the two calls.
*/
setObstacle(r, c, on, kind = 'block') {
if (!this.inBounds(r, c)) return;
const k = r * COLS + c;
const bit = kind === 'crate' ? OB_CRATE : OB_BLOCK;
const v = on ? (this.obstacle[k] | bit) : (this.obstacle[k] & ~bit);
if (this.obstacle[k] === v) return;
this.obstacle[k] = v;
this.version++;
this._fields.clear();
}
/** Any obstacle (crate or block) on the tile. */
isObstacle(r, c) { return this.inBounds(r, c) && this.obstacle[r * COLS + c] !== 0; }
/** An obstacle-like (cost-1000) crate on the tile. */
isCrate(r, c) { return this.inBounds(r, c) && (this.obstacle[r * COLS + c] & OB_CRATE) !== 0; }
/** A hard block (platform / mound / content) on the tile. */
isBlocked(r, c) { return this.inBounds(r, c) && (this.obstacle[r * COLS + c] & OB_BLOCK) !== 0; }
/** List tiles of a given special kind ('start'|'end'|'telin'|'telout') inside the rect. */
specialTiles(kind) {
const out = [];
const R = this.rect;
for (let r = R.r0; r <= R.r1; r++) for (let c = R.c0; c <= R.c1; c++) if (this.tile(r, c).special === kind) out.push([r, c]);
return out;
}
// ---------------------------------------------------------------------------------------------------------------
// official flow field (research 08 §3.4)
/**
* Flow field toward (er, ec). `allowDiagonal` false ⇒ line of sight only along a row or a column (every autochess
* route allows diagonals). `ignoreObstacles` ⇒ blocks and crates are ignored (fallback when the goal is walled off).
* @returns {FlowField}
*/
flowField(er, ec, { allowDiagonal = true, ignoreObstacles = false } = {}) {
er |= 0; ec |= 0;
const ck = `${er},${ec},${allowDiagonal ? 1 : 0},${ignoreObstacles ? 1 : 0}`;
const hit = this._fields.get(ck);
if (hit) return hit;
const f = this._buildField(er, ec, allowDiagonal, ignoreObstacles);
if (this._fields.size >= FIELD_CACHE_MAX) this._fields.delete(this._fields.keys().next().value);
this._fields.set(ck, f);
return f;
}
_buildField(er, ec, allowDiagonal, ignore) {
const N = ROWS * COLS;
const f = { dest: -1, dist: null, parent: null, pen: null, next: null, official: null, cost: null, deep: null, len: null, version: this.version, allowDiagonal, ignore };
if (!this.inBounds(er, ec)) {
const none = new Int32Array(N).fill(-1);
return Object.assign(f, { dist: none, parent: none, next: none, official: none, pen: new Int32Array(N), cost: new Int32Array(N), deep: new Int32Array(N) });
}
const dest = er * COLS + ec;
f.dest = dest;
const unb = this.unblockable;
const official = this._spfa(dest, ignore, null);
const pref = this._spfa(dest, ignore, unb);
const dist = pref.dist; // the lexicographic relaxation leaves every distance the official one
const walk = (r, c) => this.walkable(r, c, ignore) && (ignore || !(this.obstacle[r * COLS + c] & OB_CRATE));
const ray = allowDiagonal ? bresenhamClear : segmentClear;
const nextO = smoothChains(official.parent, dist, (a, b) => ray(a, b, walk), null);
// preference smoothing (0.1.0's, playtest #2): the line of sight from n toward ancestor `to` may also cover a
// non-blockable tile — the Bresenham footprint, corner tiles of a diagonal step included — only when that tile is on
// n's own raw chain between n and `to`; the jump stops at the first ancestor not visible under that rule
const onChain = new Int32Array(N).fill(-1);
let from = -1, to = -1;
const walkP = (r, c) => {
if (!walk(r, c)) return false;
const k = r * COLS + c;
return !unb[k] || (onChain[k] === from && dist[k] >= dist[to]);
};
const nextP = smoothChains(pref.parent, dist, (a, b) => { from = a; to = b; return ray(a, b, walkP); },
(n) => { for (let x = n, g = N; x >= 0 && g-- > 0; x = pref.parent[x]) onChain[x] = n; });
// per tile, in increasing distance: the official pointer unless the preference one crosses strictly fewer
// non-blockable tiles on the way to the destination — or as few while only skipping the official waypoint o, which
// then lies on the straight line to it and leads there (the same walk, one waypoint fewer) — and no more 深水区
// than the official one (GitHub #375)
const order = [];
for (let k = 0; k < N; k++) if (dist[k] >= 0) order.push(k);
order.sort((a, b) => dist[a] - dist[b] || a - b);
const through = (a, b) => { let n = -unb[a]; crossTiles(a, b, (r, c) => { n += unb[r * COLS + c]; }); return n; };
const deepW = this.deepWater;
const wet = (a, b) => { let n = -deepW[a]; crossTiles(a, b, (r, c) => { n += deepW[r * COLS + c]; }); return n; };
const next = new Int32Array(N).fill(-1);
const cost = new Int32Array(N);
const deep = new Int32Array(N);
for (const k of order) {
const o = nextO[k], p = nextP[k];
if (o < 0) continue; // the destination
let use = o, best = through(k, o) + cost[o], bestWet = wet(k, o) + deep[o];
if (p >= 0 && p !== o) {
const cp = through(k, p) + cost[p], wp = wet(k, p) + deep[p];
if (wp <= bestWet && (cp < best || (cp === best && next[o] === p && onSegment(k, o, p)))) { use = p; best = cp; bestWet = wp; }
}
next[k] = use;
cost[k] = best;
deep[k] = bestWet;
}
return Object.assign(f, { dist, parent: pref.parent, pen: pref.pen, next, official: nextO, cost, deep });
}
/**
* FIFO SPFA from `dest` (walkable or not) with the official neighbour order and move costs. With `penalty` (per-tile
* 0/1) the relaxation is lexicographic on (dist, pen): equal-length chains keep the fewest penalised tiles.
*/
_spfa(dest, ignore, penalty) {
const N = ROWS * COLS;
const dist = new Int32Array(N).fill(-1);
const parent = new Int32Array(N).fill(-1);
/** penalised tiles on the raw chain (tile included, destination excluded) */
const pen = new Int32Array(N);
const inQ = new Uint8Array(N);
const q = new Int32Array(N * 4 + 8);
let qh = 0, qt = 0;
const push = (k) => {
if (qt >= q.length) { q.copyWithin(0, qh, qt); qt -= qh; qh = 0; }
q[qt++] = k;
};
dist[dest] = 0;
push(dest);
inQ[dest] = 1;
while (qh < qt) {
const cur = q[qh++];
inQ[cur] = 0;
const r = (cur / COLS) | 0, c = cur - r * COLS;
for (let i = 0; i < 4; i++) {
const nr = r + FOUR_WAYS[i][0], nc = c + FOUR_WAYS[i][1];
if (!this.walkable(nr, nc, ignore)) continue;
const nb = nr * COLS + nc;
const nd = dist[cur] + (!ignore && (this.obstacle[nb] & OB_CRATE) ? OBSTACLE_COST : 1);
const np = penalty ? pen[cur] + penalty[nb] : 0;
if (dist[nb] < 0 || nd < dist[nb] || (nd === dist[nb] && np < pen[nb])) {
dist[nb] = nd;
pen[nb] = np;
parent[nb] = cur;
if (!inQ[nb]) { push(nb); inQ[nb] = 1; }
}
}
}
return { dist, parent, pen };
}
/** Geometric length (tiles) of the smoothed route from tile key k to the field's destination (memoised). */
fieldLength(f, k) {
if (!f || k < 0 || f.dist[k] < 0) return Infinity;
if (!f.len) f.len = new Float64Array(ROWS * COLS).fill(-1);
const stack = [];
let x = k;
while (x >= 0 && f.len[x] < 0) {
if (x === f.dest || f.next[x] < 0) { f.len[x] = 0; break; }
stack.push(x);
x = f.next[x];
if (stack.length > ROWS * COLS) return Infinity; // cannot happen (dist strictly decreases); defensive
}
for (let i = stack.length - 1; i >= 0; i--) {
const a = stack[i], b = f.next[a];
const ar = (a / COLS) | 0, ac = a - ar * COLS, br = (b / COLS) | 0, bc = b - br * COLS;
f.len[a] = f.len[b] + hypot(br - ar, bc - ac);
}
return f.len[k];
}
/**
* Smoothed waypoints [[r,c]…] from (sr,sc) to (er,ec) along the flow field (start and goal included), or null when
* the start cannot reach the goal. With `ignoreObstacles` the field ignores blocks and crates.
*/
waypoints(sr, sc, er, ec, { ignoreObstacles = false, allowDiagonal = true } = {}) {
if (!this.inBounds(sr, sc) || !this.inBounds(er, ec)) return null;
const start = sr * COLS + sc;
const goal = er * COLS + ec;
if (start === goal) return [[sr, sc]];
const f = this.flowField(er, ec, { ignoreObstacles, allowDiagonal });
if (f.dist[start] < 0) return null;
const out = [[sr, sc]];
let k = start, guard = ROWS * COLS;
while (k !== goal && guard-- > 0) {
k = f.next[k];
if (k < 0) return null;
out.push([(k / COLS) | 0, k % COLS]);
}
return out;
}
/**
* Tiles crossed by the enemy route from (sr,sc) to (er,ec): the smoothed waypoints joined by their Bresenham lines
* (start and goal included; consecutive tiles are 8-neighbours). null when unreachable (unless ignoreObstacles).
*/
findPath(sr, sc, er, ec, { ignoreObstacles = false } = {}) {
const wp = this.waypoints(sr, sc, er, ec, { ignoreObstacles });
if (!wp) return null;
const out = [wp[0]];
for (let i = 1; i < wp.length; i++) {
const seg = bresenhamTiles(wp[i - 1], wp[i]);
for (let j = 1; j < seg.length; j++) out.push(seg[j]);
}
return out;
}
/** Length (tiles) of a polyline of [r,c] points. */
static pathLength(pts) {
let len = 0;
for (let i = 1; i < pts.length; i++) len += hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
return len;
}
}
/**
* @typedef {object} FlowField
* @property {number} dest destination tile key (-1 outside the grid)
* @property {Int32Array} dist SPFA distance to the destination (-1 = unreachable; the official one)
* @property {Int32Array} parent raw SPFA parent of the preference chain (fewest non-blockable tiles among equal `dist`)
* @property {Int32Array} pen non-blockable tiles on that raw chain (tile included, destination excluded)
* @property {Int32Array} official smoothed parent of the pure official algorithm
* @property {Int32Array} next the tile to walk straight toward (official, or the preference pointer when it crosses fewer
* non-blockable tiles and no more 深水区 — see the module header)
* @property {Int32Array} cost non-blockable tiles the route of `next` crosses from the tile (excluded) to the destination
* @property {Int32Array} deep 深水区 tiles the route of `next` crosses from the tile (excluded) to the destination
* @property {Float64Array|null} len memoised smoothed length (fieldLength)
*/
/**
* Row-major, in-place smoothing (client `_PostprocessAndMakeNextMapSmoothly`): each tile's pointer jumps along its chain
* (already smoothed pointers for tiles processed earlier) while `los(tile, ancestor)` holds; `begin(tile)`, when given,
* runs before the tile's jump. Returns the smoothed pointers.
*/
function smoothChains(parent, dist, los, begin) {
const N = parent.length;
const next = new Int32Array(parent);
for (let n = 0; n < N; n++) {
if (dist[n] < 0 || next[n] < 0) continue;
if (begin) begin(n);
let b = next[n];
while (next[b] >= 0 && los(n, next[b])) b = next[b];
next[n] = b;
}
return next;
}
/** Whether tile key b lies strictly inside the straight segment between tile keys a and c. */
function onSegment(a, b, c) {
const ar = (a / COLS) | 0, ac = a - ar * COLS, br = (b / COLS) | 0, bc = b - br * COLS, cr = (c / COLS) | 0, cc = c - cr * COLS;
return (br - ar) * (cc - ac) === (bc - ac) * (cr - ar) && (br - ar) * (cr - br) + (bc - ac) * (cc - bc) > 0;
}
/** Bresenham line of sight between tile keys a → b (client `_RaycastBresenhamLine`). */
function bresenhamClear(a, b, clear) {
let r = (a / COLS) | 0, c = a - r * COLS;
const r1 = (b / COLS) | 0, c1 = b - r1 * COLS;
const dr = Math.abs(r1 - r), dc = Math.abs(c1 - c);
const sr = r1 > r ? 1 : -1, sc = c1 > c ? 1 : -1;
let err = dc - dr;
if (!clear(r, c)) return false;
while (r !== r1 || c !== c1) {
const e2 = 2 * err;
let nr = r, nc = c;
if (e2 > -dr) { err -= dr; nc += sc; }
if (e2 < dc) { err += dc; nr += sr; }
if (nr !== r && nc !== c && !(clear(nr, c) && clear(r, nc))) return false;
r = nr; c = nc;
if (!clear(r, c)) return false;
}
return true;
}
/** Row / column line of sight (client `_RaycastSegmentLine`, routes without diagonal moves). */
function segmentClear(a, b, clear) {
let r = (a / COLS) | 0, c = a - r * COLS;
const r1 = (b / COLS) | 0, c1 = b - r1 * COLS;
if (r !== r1 && c !== c1) return false;
const sr = Math.sign(r1 - r), sc = Math.sign(c1 - c);
if (!clear(r, c)) return false;
while (r !== r1 || c !== c1) {
r += sr; c += sc;
if (!clear(r, c)) return false;
}
return true;
}
/** Tiles of the Bresenham line [r0,c0] → [r1,c1] (same stepping as bresenhamClear). */
export function bresenhamTiles(a, b) {
let [r, c] = a;
const [r1, c1] = b;
const dr = Math.abs(r1 - r), dc = Math.abs(c1 - c);
const sr = r1 > r ? 1 : -1, sc = c1 > c ? 1 : -1;
let err = dc - dr;
const out = [[r, c]];
let guard = 256;
while ((r !== r1 || c !== c1) && guard-- > 0) {
const e2 = 2 * err;
if (e2 > -dr) { err -= dr; c += sc; }
if (e2 < dc) { err += dc; r += sr; }
out.push([r, c]);
}
return out;
}
/**
* Exact traversal of the straight segment between the centres of tile keys a → b: calls `fn(r, c)` for every tile whose
* interior the segment crosses, a and b included, in order — a tile it only touches at a corner point (the segment
* passes exactly through a lattice corner) is skipped. Stops early and returns false when `fn` returns false.
* Integer arithmetic: the k-th column boundary is crossed at t = (2k − 1) / 2dx, the m-th row boundary at
* (2m − 1) / 2dy.
*/
function crossTiles(a, b, fn) {
let r = (a / COLS) | 0, c = a - r * COLS;
const r1 = (b / COLS) | 0, c1 = b - r1 * COLS;
const dy = Math.abs(r1 - r), dx = Math.abs(c1 - c);
const sr = r1 > r ? 1 : -1, sc = c1 > c ? 1 : -1;
if (fn(r, c) === false) return false;
for (let kx = 1, ky = 1; r !== r1 || c !== c1;) {
const tx = kx <= dx ? (2 * kx - 1) * dy : Infinity;
const ty = ky <= dy ? (2 * ky - 1) * dx : Infinity;
if (tx === ty) { c += sc; r += sr; kx++; ky++; } else if (tx < ty) { c += sc; kx++; } else { r += sr; ky++; }
if (fn(r, c) === false) return false;
}
return true;
}
/** Tiles [[r,c]…] whose interior the segment between the centres of tiles a = [r,c] and b crosses (crossTiles). */
export function segmentTiles(a, b) {
const out = [];
crossTiles(a[0] * COLS + a[1], b[0] * COLS + b[1], (r, c) => { out.push([r, c]); });
return out;
}
/**
* Whether a straight move from (x, y) to the centre of tile p = {x: col, y: row} only crosses ground-walkable,
* crate-free tiles (the start and end tiles excepted). Exact grid traversal (tile (r,c) spans c ± 0.5, r ± 0.5); a
* line through a tile corner needs both side tiles clear (as the grid's Bresenham smoothing).
*/
export function straightClear(g, x, y, p) {
let c = Math.round(x), r = Math.round(y);
const dx = p.x - x, dy = p.y - y;
const sc = dx > 0 ? 1 : -1, sr = dy > 0 ? 1 : -1;
const ddx = dx !== 0 ? Math.abs(1 / dx) : Infinity, ddy = dy !== 0 ? Math.abs(1 / dy) : Infinity;
let tx = dx !== 0 ? (c + 0.5 * sc - x) / dx : Infinity;
let ty = dy !== 0 ? (r + 0.5 * sr - y) / dy : Infinity;
const clear = (rr, cc) => (rr === p.y && cc === p.x) || (g.walkable(rr, cc) && !(g.obstacle[rr * COLS + cc] & OB_CRATE));
for (let guard = 4 * COLS; guard > 0 && (r !== p.y || c !== p.x); guard--) {
if (tx >= 1 && ty >= 1) break;
if (Math.abs(tx - ty) < 1e-9) {
if (!clear(r, c + sc) || !clear(r + sr, c)) return false;
c += sc; r += sr; tx += ddx; ty += ddy;
} else if (tx < ty) { c += sc; tx += ddx; } else { r += sr; ty += ddy; }
if (!clear(r, c)) return false;
}
return true;
}