feedback1d WO: the preference route is 0.1.0's again; equal floor keeps the official route

Review follow-up to the D5 fix. Two changes in grid.js _buildField:

- The road-over-floor preference smoothing is 0.1.0's again: its line of sight
  may cover non-blockable tiles (Bresenham footprint, diagonal-step corners
  included) only on the tile's own raw chain, and the jump stops at the first
  ancestor it cannot see so. Exact crossings (a corner touch is not a crossing)
  are used only to choose between the official and the preference pointer.
  The previous version let the preference brush the corner of off-chain floor,
  so on 战场#02's Final Assault the right-half exit route (2,10) -> (3,11)
  slipped past an operator on (2,11) while the left half was still blocked on
  (2,9). Both halves now mirror each other again ((2,10) -> (2,11) -> (4,11)).
- On an equal floor count the official pointer stays; the preference pointer
  wins only when it merely skips a collinear official waypoint (same walk).
  The old "farther waypoint" tie-break could bend an official route without
  saving any floor, e.g. a crate on (10,6) of 战场#04 brought back the L via
  (9,8) for the lower gate.

Still 140 of 154 stage routes official; data/stages.json groundPaths
regenerated (战场#02 boss right exit back to 0.1.0's, device-less helper paths
of #03 / #04 / #06 follow the same rule). New tests: 战场#02 boss exits mirror
and are blocked beside the exit; a single crate on any tile of an active stage
never leaves a gate route non-official without crossing strictly less floor.
The cross-check reference port follows both rules.
This commit is contained in:
sganggs committed 2026-10-03 04:57:30 +08:00
1 parent d8c62a96b5
commit 7a3918eab9
8 files changed
+147 -74

No files matched your search

+44 -35
View File
@@ -18,19 +18,22 @@
// 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 — `blockable()` false). A segment CROSSES the tiles whose
// interior it passes through (`crossTiles`, exact geometry); one it only touches at a corner point is not crossed.
// 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) and the official line-of-sight walk:
// ground buildable for melee (floor / gate / goal / teleport — `blockable()` false). 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: 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 a smoothed
// segment that would cross a non-blockable tile off the tile's own raw chain backs off to the farthest earlier
// waypoint whose segment does not — it never cuts across 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), or as few with a farther
// waypoint. So the route from every tile crosses no more floor than either candidate and is the official one wherever
// that is not floor-heavier (test/sim/pathing-official.test.js lists the routes that still differ on every stage).
// * 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). 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.
//
@@ -252,16 +255,23 @@ export class Grid {
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 los = allowDiagonal ? (a, b) => bresenhamClear(a, b, walk) : (a, b) => segmentClear(a, b, walk);
const nextO = smoothChains(official.parent, dist, los, null);
// preference smoothing: a segment may cross a non-blockable tile only on the tile's own raw chain between its ends
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);
const nextP = smoothChains(pref.parent, dist, los, {
begin: (n) => { for (let x = n, g = N; x >= 0 && g-- > 0; x = pref.parent[x]) onChain[x] = n; },
ok: (n, b) => crossTiles(n, b, (r, c) => { const k = r * COLS + c; return !unb[k] || (onChain[k] === n && dist[k] >= dist[b]); }),
});
// per tile, in increasing distance: the official pointer unless the preference one crosses fewer non-blockable
// tiles on the way to the destination (or as few with a farther waypoint)
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)
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);
@@ -274,7 +284,7 @@ export class Grid {
let use = o, best = through(k, o) + cost[o];
if (p >= 0 && p !== o) {
const cp = through(k, p) + cost[p];
if (cp < best || (cp === best && dist[p] < dist[o])) { use = p; best = cp; }
if (cp < best || (cp === best && next[o] === p && onSegment(k, o, p))) { use = p; best = cp; }
}
next[k] = use;
cost[k] = best;
@@ -402,29 +412,28 @@ export class Grid {
/**
* 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. With `guard`
* ({ begin(n), ok(n, b) }) the jump then backs off to the farthest of the waypoints it passed that `ok` accepts (the
* raw parent always is). Returns the smoothed pointers.
* (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, guard) {
function smoothChains(parent, dist, los, begin) {
const N = parent.length;
const next = new Int32Array(parent);
const hops = new Int32Array(N);
for (let n = 0; n < N; n++) {
if (dist[n] < 0 || next[n] < 0) continue;
let h = 0, b = next[n];
hops[h++] = b;
while (next[b] >= 0 && los(n, next[b])) { b = next[b]; hops[h++] = b; }
if (guard && h > 1) {
guard.begin(n);
while (h > 1 && !guard.ok(n, hops[h - 1])) h--;
b = hops[h - 1];
}
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;