mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-24 00:23:38 +00:00
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:
@@ -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<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; 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user