From 7bd7d35d925863bf18f563861b8d88c43b1326f7 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Sun, 28 Dec 2025 15:34:19 +0100 Subject: [PATCH] Add mask-expanding for adaptive corridor refinement - Introduced a new `MaskExpanding.md` documentation detailing the mask-expanding BFS approach, which enhances pathfinding by allowing corridor expansion without restarting the search. - Updated `CoarseToFineWaterPath` to utilize the new mask-expanding strategy, improving efficiency by resuming searches instead of clearing state upon corridor tightness. - Enhanced `MultiSourceAnyTargetBFS` with a new method to support mask expansion, allowing for dynamic adjustment of allowed regions during BFS execution. - Implemented data structures and core loop adjustments to facilitate both fast and optimal variants of the mask-expanding approach, ensuring soundness and performance improvements in pathfinding. - Suggested milestones for future enhancements and optimizations in corridor repair and pathfinding strategies. --- docs/MaskExpanding.md | 145 ++++++++ src/core/pathfinding/CoarseToFineWaterPath.ts | 86 ++--- .../pathfinding/MultiSourceAnyTargetBFS.ts | 310 ++++++++++++++++++ 3 files changed, 502 insertions(+), 39 deletions(-) create mode 100644 docs/MaskExpanding.md diff --git a/docs/MaskExpanding.md b/docs/MaskExpanding.md new file mode 100644 index 000000000..cfa024f2f --- /dev/null +++ b/docs/MaskExpanding.md @@ -0,0 +1,145 @@ +# Mask-expanding BFS (adaptive corridor refinement, no restart) + +Purpose: keep the “coarse corridor” win, but avoid repeated **restart + re-walk** churn when the corridor is too tight. + +This is the “performance-first” sibling of: +- `docs/CoarseToFine.md` (coarse corridor + safe fallback) +- `docs/LocalCorridorWidening.md` (visited-driven local relaxation) + +Key idea: run one fine-res search; if the queue exhausts because the corridor is too restrictive, **expand the mask and keep going** without clearing the fine BFS state. + +## What changes (vs restart-based local widening) + +Today (A2-style): +- attempt = run fine BFS inside current mask +- on failure: widen mask, **restart** fine BFS (visited/prev cleared via new stamp) + +No-restart: +- run fine BFS inside current mask +- on queue empty: widen mask, **resume** fine BFS (keep visited/prev/queue state) + +This avoids re-enqueueing and re-walking large already-explored areas on “almost works” corridors. + +## Correctness note (don’t hand-wave) + +Naively resuming a FIFO BFS after expanding the allowed set can change shortest-path guarantees, because newly-allowed tiles might introduce shorter routes to areas you already visited. + +Invariant: once a fine tile is marked visited, it is never “unvisited” again — mask expansion only enables additional neighbors/regions and never invalidates already-visited tiles. This is why the fast variant remains sound (valid path) and why we can justify not clearing `visitedStamp` when expanding the mask. + +Two viable interpretations: + +1) **Fast variant (good enough for corridor repair):** + - accept that expanding the mask mid-run can produce a path that is not strictly shortest in the *final* expanded region + - still produces a valid path and is often much faster + +2) **Optimal variant (paper-grade):** + - track `dist[tile]` (or level) and allow “relaxation” when new tiles become allowed + - process newly enabled nodes in non-decreasing distance (Dial/bucket queue or heap) + - guarantees shortest path in the final allowed region + +For OpenFront boats, the fast variant may already be acceptable because the corridor is a heuristic bound anyway; if we care about strict optimality, use the optimal variant. + +This doc describes the implementation in a way that supports either, with minimal extra plumbing. + +## Groundwork we already have + +From existing coarse-to-fine: +- `fineToCoarse: Uint32Array` mapping +- `allowedMask` as `(tileToRegion, regionStamp, stamp)` using stamps + +From `LocalCorridorWidening` implementation: +- `visitedMaskOut` to collect “which coarse regions were actually explored” in a failed attempt +- widening by 1-ring around visited coarse regions, cumulative + +We reuse that exact widening rule; the only change is: don’t restart the fine search. + +## Data structures (recommended) + +Inside `MultiSourceAnyTargetBFS` (or a sibling specialized class): +- `visitedStamp[tile]` (already exists) +- `prev[tile]` (already exists) +- `startOf[tile]` (already exists) +- `queue[]`, `head`, `tail` (already exists) + +Additionally for the optimal variant: +- `dist[tile]: Int32Array` (init -1; set on visit) +- `deferred[]` or a bucket/heap for nodes that become enabled later + +Mask tracking: +- `allowedCoarseStamp[coarseCell]` (cumulative allowed regions) +- `visitedCoarseStamp[coarseCell]` (per-expansion snapshot; used to widen) + +## Core loop (fast variant) + +Pseudocode: + +1) Build initial allowed mask from coarse spine corridor (`r0`). +2) Seed BFS queue from fine seedNodes filtered by allowed mask. +3) Run BFS: + - when exploring neighbors: + - if neighbor is blocked by allowed mask: **skip** (but see below) + - otherwise visit/enqueue as usual +4) When `head == tail` (queue empty): + - widen `allowedCoarseStamp` by 1 ring around `visitedCoarseStamp` from the last phase + - **activate newly enabled frontier nodes**: + - for each visited fine tile, re-check its neighbors that were previously mask-blocked + - enqueue any newly-allowed, unvisited neighbors + - continue BFS +5) Stop when a target is dequeued. + +The only missing piece is “activate newly enabled frontier nodes” efficiently. + +### Frontier activation strategies + +**Strategy F1 (simple, may be OK):** +- keep an `Int32Array deferredTiles` of “neighbor candidates that were blocked by mask” +- when mask widens, scan deferredTiles and enqueue those that are now allowed +- keep deferredTiles deduped via a stamp array to avoid blowup + +**Strategy F2 (faster, more code):** +- maintain a per-coarse-region list of deferred fine tiles +- when a coarse region becomes allowed, enqueue only tiles in that region’s list + +F2 is the “hot path” answer; F1 is the “get it working + measure” answer. + +## Core loop (optimal variant) + +If we want shortest paths in the final expanded region, treat mask-expansion as adding nodes that can introduce shorter routes. + +Minimal way: +- maintain `dist[tile]` +- when a neighbor becomes newly allowed: + - `nd = dist[cur] + 1` + - if `dist[neighbor] == -1 || nd < dist[neighbor]`: + - update `dist`, `prev`, `startOf` + - push neighbor into a structure processed in increasing `dist` + +Implementation options: +- bucket queue (Dial) since edge weights are 1 +- binary heap (slower constants, simpler reasoning) + +## Where this plugs in + +Implement as a variant of the current coarse-to-fine helper: + +- `findWaterPathFromSeedsMaskExpanding(...)` (fineMap + coarseMap + opts) +- reuse the same `allowedMask` and the same visited-driven widening rule +- keep the same guardrail: if expansions exceed `k`, fall back to unrestricted fine BFS + +This is a stepping stone to Spine & Portals: +- This improves “corridor repair” without restarts +- Spine & Portals changes the big-O by avoiding fine search over open water entirely + +## Suggested milestones + +1) Implement the fast variant with deferred frontier list (F1) and measure. +2) If it helps but still shows spikes, upgrade frontier activation to F2. +3) If strict optimality is needed, implement the optimal variant (dist + buckets/heap). + +## Note: depth-gated BFS (future) + +We can reuse the same “monotonic relaxation” idea for “prefer deep water” by adding a second passability mask like `gm.magnitude(tile) >= minDepth` and relaxing `minDepth` only if needed. This stays BFS-friendly (still unweighted), but changes the objective to “deepest-possible path, then shortest within that depth”; if we need strict shortest for the relaxed threshold, restart per-threshold or use the optimal semantics. + +## BSP-ish note + +Both mask expansion and depth-gating are “BSP-ish” in the same sense: they incrementally relax constraints (expand the allowed subset) without invalidating already explored space. This makes the search behave more like progressive partition refinement than a single global floodfill, even though we are not literally constructing a BSP tree. diff --git a/src/core/pathfinding/CoarseToFineWaterPath.ts b/src/core/pathfinding/CoarseToFineWaterPath.ts index 1e3ca6afc..3d9a7121a 100644 --- a/src/core/pathfinding/CoarseToFineWaterPath.ts +++ b/src/core/pathfinding/CoarseToFineWaterPath.ts @@ -154,8 +154,9 @@ function widenAllowedByVisitedRing( allowed: number, visitedStamp: Uint32Array, visited: number, -): boolean { - let widened = false; + outNewlyAllowed: Int32Array, +): number { + let count = 0; for (let y = 0; y < coarseHeight; y++) { const row = y * coarseWidth; for (let x = 0; x < coarseWidth; x++) { @@ -171,12 +172,12 @@ function widenAllowedByVisitedRing( const n = nRow + xx; if (allowedStamp[n] === allowed) continue; allowedStamp[n] = allowed; - widened = true; + outNewlyAllowed[count++] = n; } } } } - return widened; + return count; } export function findWaterPathFromSeedsCoarseToFine( @@ -269,7 +270,8 @@ export function findWaterPathFromSeedsCoarseToFine( ); } - const corridorRadius0 = Math.max(0, coarseToFine.corridorRadius ?? 2); + // Start tight (radius 0) and rely on local widening + final fallback for robustness. + const corridorRadius0 = Math.max(0, coarseToFine.corridorRadius ?? 0); const maxAttempts = Math.max(1, coarseToFine.maxAttempts ?? 3); // Allowed corridor stamp is stable across attempts (widening is cumulative). @@ -285,44 +287,50 @@ export function findWaterPathFromSeedsCoarseToFine( ); const visitedSet = getVisitedStampSet(coarseMap); - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const visited = nextStamp(visitedSet); + let expansionsLeft = maxAttempts - 1; + const visitedMask = { + tileToRegion: mapping.fineToCoarse, + regionStamp: visitedSet.data, + stamp: nextStamp(visitedSet), + }; - const refined = fineBfs.findWaterPathFromSeeds( - fineMap, - seedNodes, - seedOrigins, - targets, - { - ...bfsOpts, - allowedMask: { - tileToRegion: mapping.fineToCoarse, - regionStamp: allowedSet.data, - stamp: allowed, - }, - visitedMaskOut: { - tileToRegion: mapping.fineToCoarse, - regionStamp: visitedSet.data, - stamp: visited, - }, + const refined = fineBfs.findWaterPathFromSeedsMaskExpanding( + fineMap, + seedNodes, + seedOrigins, + targets, + { + ...bfsOpts, + allowedMask: { + tileToRegion: mapping.fineToCoarse, + regionStamp: allowedSet.data, + stamp: allowed, }, - ); - if (refined !== null) return refined; + visitedMaskOut: visitedMask, + }, + (outNewlyAllowed) => { + if (expansionsLeft <= 0) return 0; - if (attempt === maxAttempts - 1) break; + // Expand by 1 ring around the coarse regions actually visited in the most recent phase. + // Widening is cumulative (newly allowed regions stay allowed). + const newCount = widenAllowedByVisitedRing( + coarseWidth, + coarseHeight, + allowedSet.data, + allowed, + visitedSet.data, + visitedMask.stamp, + outNewlyAllowed, + ); + expansionsLeft--; + if (newCount <= 0) return 0; - // Local corridor widening: expand by 1 ring around the coarse regions actually visited - // in this failed attempt. Widening is cumulative (newly allowed regions stay allowed). - const widened = widenAllowedByVisitedRing( - coarseWidth, - coarseHeight, - allowedSet.data, - allowed, - visitedSet.data, - visited, - ); - if (!widened) break; - } + // Reset visited coarse tracking for the next phase. + visitedMask.stamp = nextStamp(visitedSet); + return newCount; + }, + ); + if (refined !== null) return refined; // Final fallback: unrestricted fine BFS. return fineBfs.findWaterPathFromSeeds( diff --git a/src/core/pathfinding/MultiSourceAnyTargetBFS.ts b/src/core/pathfinding/MultiSourceAnyTargetBFS.ts index 69646b2dd..c7c904e75 100644 --- a/src/core/pathfinding/MultiSourceAnyTargetBFS.ts +++ b/src/core/pathfinding/MultiSourceAnyTargetBFS.ts @@ -48,6 +48,16 @@ export class MultiSourceAnyTargetBFS { private readonly startOf: Int32Array; private readonly queue: Int32Array; + // Scratch for mask-expanding searches (allocated lazily). + private deferredStamp?: Uint32Array; + private deferredPrev?: Int32Array; + private deferredNext?: Int32Array; + private deferredRegionHead?: Int32Array; + private deferredRegionTouched?: Int32Array; + private deferredRegionTouchedCount = 0; + private deferredRegionsSize = 0; + private newlyAllowedRegions?: Int32Array; + constructor(numTiles: number) { this.visitedStamp = new Uint32Array(numTiles); this.targetStamp = new Uint32Array(numTiles); @@ -270,6 +280,282 @@ export class MultiSourceAnyTargetBFS { return null; } + /** + * Like `findWaterPathFromSeeds`, but supports expanding `opts.allowedMask` without restarting. + * + * When the queue exhausts, calls `onQueueEmpty(outNewlyAllowedRegions)`: + * - the callback should widen the allowed mask in-place and return how many coarse regions were newly allowed + * - if it returns 0, the search stops and returns null + * + * This is the "mask-expanding BFS" fast variant: it is sound (finds a valid path if one exists + * under the eventually allowed regions), but it is not guaranteed to be shortest under the final + * expanded region set. + */ + findWaterPathFromSeedsMaskExpanding( + gm: GameMap, + seedNodes: readonly TileRef[], + seedOrigins: readonly TileRef[], + targets: readonly TileRef[], + opts: MultiSourceAnyTargetBFSOptions, + onQueueEmpty: (outNewlyAllowedRegions: Int32Array) => number, + ): MultiSourceAnyTargetBFSResult | null { + if (seedNodes.length === 0 || targets.length === 0) return null; + + const allowed = opts.allowedMask; + if (!allowed) { + return this.findWaterPathFromSeeds( + gm, + seedNodes, + seedOrigins, + targets, + opts, + ); + } + + this.ensureMaskExpandingScratch(allowed.regionStamp.length); + + const deferredStamp = this.deferredStamp!; + const deferredPrev = this.deferredPrev!; + const deferredNext = this.deferredNext!; + const regionHead = this.deferredRegionHead!; + const touched = this.deferredRegionTouched!; + const outNewRegions = this.newlyAllowedRegions!; + + const stamp = this.nextStamp(); + + for (const t of targets) { + if (t >= 0 && t < this.targetStamp.length) { + this.targetStamp[t] = stamp; + } + } + + const w = gm.width(); + const h = gm.height(); + const lastRowStart = (h - 1) * w; + + let head = 0; + let tail = 0; + + const visitedOut = opts.visitedMaskOut; + + const count = Math.min(seedNodes.length, seedOrigins.length); + for (let i = 0; i < count; i++) { + const node = seedNodes[i]!; + const origin = seedOrigins[i]!; + if (node < 0 || node >= this.visitedStamp.length) continue; + if ( + allowed.regionStamp[allowed.tileToRegion[node]!] !== allowed.stamp + ) { + continue; + } + if (!gm.isWater(node)) continue; + if (this.visitedStamp[node] === stamp) continue; + this.visitedStamp[node] = stamp; + this.prev[node] = -1; + this.startOf[node] = origin; + if (visitedOut) { + visitedOut.regionStamp[visitedOut.tileToRegion[node]!] = + visitedOut.stamp; + } + this.queue[tail++] = node; + } + + if (tail === 0) return null; + + const kingMoves = opts.kingMoves ?? true; + const noCornerCutting = opts.noCornerCutting ?? true; + + const defer = (tile: TileRef, from: TileRef) => { + if (tile < 0 || tile >= deferredStamp.length) return; + if (deferredStamp[tile] === stamp) return; + const region = allowed.tileToRegion[tile]!; + deferredStamp[tile] = stamp; + deferredPrev[tile] = from; + deferredNext[tile] = regionHead[region]!; + if (regionHead[region] === -1) { + touched[this.deferredRegionTouchedCount++] = region; + } + regionHead[region] = tile; + }; + + const activateNewRegions = (newCount: number) => { + for (let i = 0; i < newCount; i++) { + const region = outNewRegions[i]!; + let tile = regionHead[region]!; + regionHead[region] = -1; + while (tile !== -1) { + const next = deferredNext[tile]!; + if ( + this.visitedStamp[tile] !== stamp && + allowed.regionStamp[allowed.tileToRegion[tile]!] === allowed.stamp + ) { + // Deferred tiles are always water (we only defer after gm.isWater check), + // so we can skip re-checking gm.isWater here. + this.visit(tile, deferredPrev[tile]! as TileRef, stamp, visitedOut); + this.queue[tail++] = tile; + } + tile = next; + } + } + }; + + for (;;) { + while (head < tail) { + const node = this.queue[head++] as TileRef; + + if (this.targetStamp[node] === stamp) { + this.resetTouchedRegions(regionHead, touched); + return { + source: this.startOf[node] as TileRef, + target: node, + path: this.reconstructPath(node), + }; + } + + const x = gm.x(node); + + // Orthogonal neighbors + if (node >= w) { + const n = node - w; + if (gm.isWater(n) && this.visitedStamp[n] !== stamp) { + if ( + allowed.regionStamp[allowed.tileToRegion[n]!] !== allowed.stamp + ) { + defer(n, node); + } else { + this.visit(n, node, stamp, visitedOut); + this.queue[tail++] = n; + } + } + } + if (node < lastRowStart) { + const s = node + w; + if (gm.isWater(s) && this.visitedStamp[s] !== stamp) { + if ( + allowed.regionStamp[allowed.tileToRegion[s]!] !== allowed.stamp + ) { + defer(s, node); + } else { + this.visit(s, node, stamp, visitedOut); + this.queue[tail++] = s; + } + } + } + if (x !== 0) { + const wv = node - 1; + if (gm.isWater(wv) && this.visitedStamp[wv] !== stamp) { + if ( + allowed.regionStamp[allowed.tileToRegion[wv]!] !== allowed.stamp + ) { + defer(wv, node); + } else { + this.visit(wv, node, stamp, visitedOut); + this.queue[tail++] = wv; + } + } + } + if (x !== w - 1) { + const ev = node + 1; + if (gm.isWater(ev) && this.visitedStamp[ev] !== stamp) { + if ( + allowed.regionStamp[allowed.tileToRegion[ev]!] !== allowed.stamp + ) { + defer(ev, node); + } else { + this.visit(ev, node, stamp, visitedOut); + this.queue[tail++] = ev; + } + } + } + + if (!kingMoves) continue; + + // Diagonals (king moves). With noCornerCutting, forbid squeezing past land corners. + if (node >= w && x !== 0) { + const nw = node - w - 1; + if ( + gm.isWater(nw) && + (!noCornerCutting || + (gm.isWater(node - w) && gm.isWater(node - 1))) && + this.visitedStamp[nw] !== stamp + ) { + if ( + allowed.regionStamp[allowed.tileToRegion[nw]!] !== allowed.stamp + ) { + defer(nw, node); + } else { + this.visit(nw, node, stamp, visitedOut); + this.queue[tail++] = nw; + } + } + } + if (node >= w && x !== w - 1) { + const ne = node - w + 1; + if ( + gm.isWater(ne) && + (!noCornerCutting || + (gm.isWater(node - w) && gm.isWater(node + 1))) && + this.visitedStamp[ne] !== stamp + ) { + if ( + allowed.regionStamp[allowed.tileToRegion[ne]!] !== allowed.stamp + ) { + defer(ne, node); + } else { + this.visit(ne, node, stamp, visitedOut); + this.queue[tail++] = ne; + } + } + } + if (node < lastRowStart && x !== 0) { + const sw = node + w - 1; + if ( + gm.isWater(sw) && + (!noCornerCutting || + (gm.isWater(node + w) && gm.isWater(node - 1))) && + this.visitedStamp[sw] !== stamp + ) { + if ( + allowed.regionStamp[allowed.tileToRegion[sw]!] !== allowed.stamp + ) { + defer(sw, node); + } else { + this.visit(sw, node, stamp, visitedOut); + this.queue[tail++] = sw; + } + } + } + if (node < lastRowStart && x !== w - 1) { + const se = node + w + 1; + if ( + gm.isWater(se) && + (!noCornerCutting || + (gm.isWater(node + w) && gm.isWater(node + 1))) && + this.visitedStamp[se] !== stamp + ) { + if ( + allowed.regionStamp[allowed.tileToRegion[se]!] !== allowed.stamp + ) { + defer(se, node); + } else { + this.visit(se, node, stamp, visitedOut); + this.queue[tail++] = se; + } + } + } + } + + // Queue exhausted under current mask. + const newCount = onQueueEmpty(outNewRegions); + if (newCount <= 0) break; + activateNewRegions(newCount); + // If expansion didn't actually yield any new reachable nodes, we'll loop back and exhaust again. + } + + this.resetTouchedRegions(regionHead, touched); + return null; + } + private visit( node: TileRef, from: TileRef, @@ -300,4 +586,28 @@ export class MultiSourceAnyTargetBFS { this.stamp = next === 0 ? 1 : next; return this.stamp; } + + private ensureMaskExpandingScratch(regionCount: number) { + if (!this.deferredStamp) { + const n = this.visitedStamp.length; + this.deferredStamp = new Uint32Array(n); + this.deferredPrev = new Int32Array(n); + this.deferredNext = new Int32Array(n); + } + if (!this.deferredRegionHead || this.deferredRegionsSize !== regionCount) { + this.deferredRegionsSize = regionCount; + this.deferredRegionHead = new Int32Array(regionCount); + this.deferredRegionHead.fill(-1); + this.deferredRegionTouched = new Int32Array(regionCount); + this.newlyAllowedRegions = new Int32Array(regionCount); + } + this.deferredRegionTouchedCount = 0; + } + + private resetTouchedRegions(regionHead: Int32Array, touched: Int32Array) { + for (let i = 0; i < this.deferredRegionTouchedCount; i++) { + regionHead[touched[i]!] = -1; + } + this.deferredRegionTouchedCount = 0; + } }