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.
This commit is contained in:
scamiv
2025-12-27 19:49:34 +01:00
parent 368f5c5900
commit aa09240d40
3 changed files with 225 additions and 33 deletions
+107
View File
@@ -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 attempts `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 dont 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 thats 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 (dont regress perf)
- No per-call allocations in the inner BFS loop.
- Use stamp arrays:
- `allowedCoarseStamp[coarseCell]`
- `visitedCoarseStamp[coarseCell]`
- Reuse `MultiSourceAnyTargetBFS` instances via `WeakMap<GameMap, MultiSourceAnyTargetBFS>`.
- 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; thats 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.
+78 -16
View File
@@ -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<GameMap, StampSet>();
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.
+40 -17
View File
@@ -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[] {