From aa09240d408a051fbaed8d4acb6a7525b0b7cc6f Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Sat, 27 Dec 2025 19:49:34 +0100 Subject: [PATCH] Add local corridor widening for adaptive pathfinding - Introduced a new approach to local corridor widening in the `CoarseToFineWaterPath` module, allowing for more efficient pathfinding by expanding the search corridor based on visited coarse regions. - Implemented a mechanism to avoid global radius increases, enhancing performance and reducing unnecessary searches. - Updated `MultiSourceAnyTargetBFS` to support marking visited coarse regions during BFS, facilitating the local widening process. - Adjusted parameters for maximum attempts and corridor radius to optimize pathfinding behavior. --- docs/LocalCorridorWidening.md | 107 ++++++++++++++++++ src/core/pathfinding/CoarseToFineWaterPath.ts | 94 ++++++++++++--- .../pathfinding/MultiSourceAnyTargetBFS.ts | 57 +++++++--- 3 files changed, 225 insertions(+), 33 deletions(-) create mode 100644 docs/LocalCorridorWidening.md diff --git a/docs/LocalCorridorWidening.md b/docs/LocalCorridorWidening.md new file mode 100644 index 000000000..59e45f6ba --- /dev/null +++ b/docs/LocalCorridorWidening.md @@ -0,0 +1,107 @@ +# Local corridor widening (adaptive coarse-to-fine water pathfinding, adaptive constraint relaxation) + +Goal: keep the coarse corridor win, but avoid the current “corridor fails → global full-res BFS” cliff. + +Local widening behaves like a cheap BSP refinement: + +- start with a narrow corridor (fast) +- if it fails, expand *only where it matters* (still fast) +- only as a last resort, drop the mask entirely + +This is intended to be a generic wrapper around `MultiSourceAnyTargetBFS` (used by transport/trade/warship). + +## Inputs / outputs + +Inputs: +- `fineMap: GameMap` +- `coarseMap: GameMap` (typically `map16x`) +- `seedNodes[]`, `seedOrigins[]` (multi-source) +- `targets[]` (any-target) +- `bfsOpts` (king moves, no-corner-cutting, etc.) +- initial corridor radius `r0`, max attempts `k` + +Output: +- `{ source, target, path }` like `MultiSourceAnyTargetBFSResult`, or `null` + +## Baseline (what we have today) + +1) Coarse BFS to get `coarsePath` +2) Corridor = inflate `coarsePath` by radius `r` +3) Fine BFS restricted by corridor mask +4) If fail: widen radius globally or fall back to unrestricted fine BFS + +Problem: a tiny lie in the coarse map (optimistic water) can cause step (4) to explode to “search the whole ocean”. + +## Local widening: two practical variants + +### Variant A (chosen): widen around visited coarse regions + +If fine BFS fails inside the corridor, we already know *where it was trying*. + +Implementation sketch: + +1) Build initial corridor mask: `allowedCoarse[coarseCell] = true`. +2) Run fine BFS with `allowedMask` = coarse corridor. +3) If it succeeds: done. +4) If it fails: + - compute `visitedCoarse`: all coarse cells that were actually visited by fine BFS + - expand corridor by 1 ring around `visitedCoarse` (Chebyshev ring, since king moves) + - retry fine BFS +5) Repeat up to `k` times. +6) If still no path: unrestricted fine BFS fallback (correctness guardrail). + +Clarification: widening is cumulative. Each failed attempt expands around that attempt’s `visitedCoarse`, and newly allowed coarse cells stay allowed across subsequent attempts (via the same `allowedCoarseStamp`). + +Why it works: +- you only “pay more” near the constriction you hit +- open-ocean cells that were never approached don’t get unlocked + +What “visitedCoarse” means (cheaply): +- while expanding fine BFS, map `fineTile -> coarseCell` (precomputed `fineToCoarse[]`) +- stamp `visitedCoarseStamp[coarseCell] = stamp` when the BFS pops/visits a tile + +How to expand by one ring: +- for each coarse cell in `visitedCoarse`, mark its 8 neighbors as allowed +- use stamps, not `Set`, to avoid allocations + +### Variant B (not chosen): widen only along the coarse path segment you reached + +Similar, but tighter: +- intersect `visitedCoarse` with the original `coarsePath` (or the prefix that’s reachable) +- widen only around that subset + +This can be even cheaper on huge corridors, but is easier to get wrong (requires careful “prefix” reasoning). + +## Hot-path constraints (don’t regress perf) + +- No per-call allocations in the inner BFS loop. +- Use stamp arrays: + - `allowedCoarseStamp[coarseCell]` + - `visitedCoarseStamp[coarseCell]` +- Reuse `MultiSourceAnyTargetBFS` instances via `WeakMap`. +- Keep attempt count small (`k = 2..4`). + +## Correctness guardrails + +- Coarse map is approximate: coarse success never guarantees fine success. +- Local widening can still miss a path if the corridor is too wrong; that’s fine: + - always end with an unrestricted fine BFS fallback +- Preserve current move rules: + - king moves (8-neighbor) + - no-corner-cutting + +## Suggested defaults + +- `r0 = 1..2` coarse cells (start tight) +- `k = 3` (initial + 2 widen steps) +- widen step = +1 ring around `visitedCoarse` +- final fallback = unrestricted fine BFS + +## Where this plugs in + +Replace the current “attempt loop that only increases radius globally” inside coarse-to-fine helper with: + +- attempt loop driven by `visitedCoarse` +- optional “global radius bump” as a last attempt before full fallback + +This keeps the interface identical for all callsites (transport/trade/warship), but makes “tight corridor” failures cheap. diff --git a/src/core/pathfinding/CoarseToFineWaterPath.ts b/src/core/pathfinding/CoarseToFineWaterPath.ts index a1437d7b9..1e3ca6afc 100644 --- a/src/core/pathfinding/CoarseToFineWaterPath.ts +++ b/src/core/pathfinding/CoarseToFineWaterPath.ts @@ -91,6 +91,16 @@ function getStampSet(gm: GameMap): StampSet { stampSetCache.set(gm, set); return set; } + +// Separate stamp array for "visited coarse regions" marking to avoid clobbering the allowed corridor stamp. +const visitedStampSetCache = new WeakMap(); +function getVisitedStampSet(gm: GameMap): StampSet { + const cached = visitedStampSetCache.get(gm); + if (cached) return cached; + const set: StampSet = { stamp: 1, data: new Uint32Array(gm.width() * gm.height()) }; + visitedStampSetCache.set(gm, set); + return set; +} function nextStamp(set: StampSet): number { const next = (set.stamp + 1) >>> 0; set.stamp = next === 0 ? 1 : next; @@ -137,6 +147,38 @@ function markCoarseCorridor( } } +function widenAllowedByVisitedRing( + coarseWidth: number, + coarseHeight: number, + allowedStamp: Uint32Array, + allowed: number, + visitedStamp: Uint32Array, + visited: number, +): boolean { + let widened = false; + for (let y = 0; y < coarseHeight; y++) { + const row = y * coarseWidth; + for (let x = 0; x < coarseWidth; x++) { + const idx = row + x; + if (visitedStamp[idx] !== visited) continue; + const y0 = Math.max(0, y - 1); + const y1 = Math.min(coarseHeight - 1, y + 1); + const x0 = Math.max(0, x - 1); + const x1 = Math.min(coarseWidth - 1, x + 1); + for (let yy = y0; yy <= y1; yy++) { + const nRow = yy * coarseWidth; + for (let xx = x0; xx <= x1; xx++) { + const n = nRow + xx; + if (allowedStamp[n] === allowed) continue; + allowedStamp[n] = allowed; + widened = true; + } + } + } + } + return widened; +} + export function findWaterPathFromSeedsCoarseToFine( fineMap: GameMap, seedNodes: readonly TileRef[], @@ -228,20 +270,23 @@ export function findWaterPathFromSeedsCoarseToFine( } const corridorRadius0 = Math.max(0, coarseToFine.corridorRadius ?? 2); - const maxAttempts = Math.max(1, coarseToFine.maxAttempts ?? 2); - const radiusMultiplier = Math.max(1, coarseToFine.radiusMultiplier ?? 2); + const maxAttempts = Math.max(1, coarseToFine.maxAttempts ?? 3); - const corridorSet = getStampSet(coarseMap); - for (let attempt = 0, radius = corridorRadius0; attempt < maxAttempts; attempt++) { - const corridorStamp = nextStamp(corridorSet); - markCoarseCorridor( - coarseWidth, - coarseHeight, - corridorSet.data, - corridorStamp, - coarseResult.path, - radius, - ); + // Allowed corridor stamp is stable across attempts (widening is cumulative). + const allowedSet = getStampSet(coarseMap); + const allowed = nextStamp(allowedSet); + markCoarseCorridor( + coarseWidth, + coarseHeight, + allowedSet.data, + allowed, + coarseResult.path, + corridorRadius0, + ); + + const visitedSet = getVisitedStampSet(coarseMap); + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const visited = nextStamp(visitedSet); const refined = fineBfs.findWaterPathFromSeeds( fineMap, @@ -252,14 +297,31 @@ export function findWaterPathFromSeedsCoarseToFine( ...bfsOpts, allowedMask: { tileToRegion: mapping.fineToCoarse, - regionStamp: corridorSet.data, - stamp: corridorStamp, + regionStamp: allowedSet.data, + stamp: allowed, + }, + visitedMaskOut: { + tileToRegion: mapping.fineToCoarse, + regionStamp: visitedSet.data, + stamp: visited, }, }, ); if (refined !== null) return refined; - radius *= radiusMultiplier; + if (attempt === maxAttempts - 1) break; + + // 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; } // Final fallback: unrestricted fine BFS. diff --git a/src/core/pathfinding/MultiSourceAnyTargetBFS.ts b/src/core/pathfinding/MultiSourceAnyTargetBFS.ts index 4fb33876b..69646b2dd 100644 --- a/src/core/pathfinding/MultiSourceAnyTargetBFS.ts +++ b/src/core/pathfinding/MultiSourceAnyTargetBFS.ts @@ -20,6 +20,17 @@ export type MultiSourceAnyTargetBFSOptions = { regionStamp: Uint32Array; stamp: number; }; + /** + * Optional region marking output. + * + * Intended for local corridor widening: during BFS, mark which coarse regions were + * actually visited (cheap stamp write, allocation-free). + */ + visitedMaskOut?: { + tileToRegion: Uint32Array; + regionStamp: Uint32Array; + stamp: number; + }; }; /** @@ -79,6 +90,7 @@ export class MultiSourceAnyTargetBFS { let tail = 0; const allowed = opts.allowedMask; + const visitedOut = opts.visitedMaskOut; const count = Math.min(seedNodes.length, seedOrigins.length); for (let i = 0; i < count; i++) { @@ -96,6 +108,9 @@ export class MultiSourceAnyTargetBFS { 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; } @@ -127,8 +142,8 @@ export class MultiSourceAnyTargetBFS { ) { // skip } else { - this.visit(n, node, stamp); - this.queue[tail++] = n; + this.visit(n, node, stamp, visitedOut); + this.queue[tail++] = n; } } } @@ -141,8 +156,8 @@ export class MultiSourceAnyTargetBFS { ) { // skip } else { - this.visit(s, node, stamp); - this.queue[tail++] = s; + this.visit(s, node, stamp, visitedOut); + this.queue[tail++] = s; } } } @@ -155,8 +170,8 @@ export class MultiSourceAnyTargetBFS { ) { // skip } else { - this.visit(wv, node, stamp); - this.queue[tail++] = wv; + this.visit(wv, node, stamp, visitedOut); + this.queue[tail++] = wv; } } } @@ -169,8 +184,8 @@ export class MultiSourceAnyTargetBFS { ) { // skip } else { - this.visit(ev, node, stamp); - this.queue[tail++] = ev; + this.visit(ev, node, stamp, visitedOut); + this.queue[tail++] = ev; } } } @@ -191,8 +206,8 @@ export class MultiSourceAnyTargetBFS { ) { // skip } else { - this.visit(nw, node, stamp); - this.queue[tail++] = nw; + this.visit(nw, node, stamp, visitedOut); + this.queue[tail++] = nw; } } } @@ -209,8 +224,8 @@ export class MultiSourceAnyTargetBFS { ) { // skip } else { - this.visit(ne, node, stamp); - this.queue[tail++] = ne; + this.visit(ne, node, stamp, visitedOut); + this.queue[tail++] = ne; } } } @@ -227,8 +242,8 @@ export class MultiSourceAnyTargetBFS { ) { // skip } else { - this.visit(sw, node, stamp); - this.queue[tail++] = sw; + this.visit(sw, node, stamp, visitedOut); + this.queue[tail++] = sw; } } } @@ -245,8 +260,8 @@ export class MultiSourceAnyTargetBFS { ) { // skip } else { - this.visit(se, node, stamp); - this.queue[tail++] = se; + this.visit(se, node, stamp, visitedOut); + this.queue[tail++] = se; } } } @@ -255,10 +270,18 @@ export class MultiSourceAnyTargetBFS { return null; } - private visit(node: TileRef, from: TileRef, stamp: number) { + private visit( + node: TileRef, + from: TileRef, + stamp: number, + visitedOut: MultiSourceAnyTargetBFSOptions["visitedMaskOut"], + ) { this.visitedStamp[node] = stamp; this.prev[node] = from; this.startOf[node] = this.startOf[from]; + if (visitedOut) { + visitedOut.regionStamp[visitedOut.tileToRegion[node]!] = visitedOut.stamp; + } } private reconstructPath(target: TileRef): TileRef[] {