Implement MultiSourceAnyTargetBFS for efficient boat routing

- Add MultiSourceAnyTargetBFS algorithm for water-based pathfinding
- Replace "guess landing tile, then pathfind" with single multi-source search
This commit is contained in:
scamiv
2025-12-26 22:32:16 +01:00
parent 02a6ac58ea
commit 65ca00d54f
2 changed files with 341 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
# MultiSourceAnyTargetBFS (boats) — design notes
## Goal
Replace the current “guess a landing tile, then pathfind” approach with a single **multi-source, any-target** search that:
- Prefers landings **near the user click** (destination selection stays “near click”).
- Finds the **best source+target pair** in one run (no retries / staged hacks).
- Is fast enough for a hot path by doing **one search per boat launch**, not per tick.
This is the standard “virtual super-source + virtual super-target” idea:
- Imagine a `START` node connected to every source `S` with cost 0.
- Imagine every target `D` connected to an `END` node with cost 0.
- Run shortest-path from `START` to `END`.
We do not build those nodes; we seed the queue with all sources and stop when we pop a target.
## Scope / assumptions
- Boat routing runs on a **water-only graph**.
- For now, all edges are **equal cost**, so we use **BFS**.
- Optional “pretty” modes (diagonals or smoothing) must be behind a toggle so the default stays cheap.
If we later add non-uniform costs (e.g., currents, danger zones, traffic), the same API can switch to
Dijkstra/A* by supplying a cost function; BFS remains the fast-path when cost is constant.
## Inputs and outputs
### Inputs
- `sources`: candidate attacker spawn shores (or their adjacent water tiles).
- `targets`: candidate defender landing shores near click (or their adjacent water tiles).
- `neighbors(node)`: 4-neighbor (or optional 8-neighbor) adjacency function.
- `passable(node)` or `isTraversable(from,to)`: boat constraint (water-only).
- `maxNodes` / `maxRadius` / `maxSteps` (optional): guardrails for worst-case expansions.
### Output
`{ source, target, path } | PathNotFound`
- `source`: which source won.
- `target`: which target was reached first (with correct BFS semantics).
- `path`: full route (list of tiles) to persist on the unit/execution.
## Target selection (“near click”, but bounded)
Keep the “landing near click” behavior by constructing a **small target set**:
- Collect defender shore tiles around the clicked tile and/or on the defender border.
- Sort by Manhattan distance to click.
- Keep the first `K` (cap, e.g. `K=50..200`) or `min(K, floor(shoreCount * 0.05))` with a hard max.
- Preferably filter to the same connected water component as the attacker (ocean vs a specific lake),
otherwise BFS will waste time exploring an impossible component.
This turns “any destination” into a precise, controllable set.
## Source selection (bounded)
Sources can be large (player border can be huge). For performance:
- Prefer “spawnable shore tiles” (owned + shore + valid spawn rules).
- Cap/summarize: best-by-distance-to-click, extremal tiles, plus uniform sampling.
- If we already know the boats actual spawn (e.g. UI precomputes), sources can be a singleton.
## Core algorithm (unweighted)
### Correct early-exit rule
When using BFS:
- Early-exit only when a target node is **dequeued** (popped), not when first discovered.
Dequeue guarantees minimal distance.
### Multi-source seeding
Initialize BFS frontier with all sources:
- `dist[source] = 0`
- `prev[source] = -1`
- `startOf[source] = source`
- enqueue all sources
When expanding neighbors:
- if unseen, set `prev[neighbor] = node`, set `startOf[neighbor] = startOf[node]`, enqueue.
When dequeuing:
- if `node` is in `targets`, stop and reconstruct by walking `prev[]`.
### Data structures (hot-path friendly)
Avoid maps/sets in the inner loop:
- `visitedStamp: Uint32Array(numTiles)` + `stampCounter` (no clearing per query).
- `prev: Int32Array(numTiles)` (or `Int32Array` only for seen nodes using a compact list).
- `queue: Int32Array(numTiles)` with head/tail indices (ring buffer).
- `targetsStamp: Uint32Array(numTiles)` (mark target membership once per query via stamping).
- `startOf: Int32Array(numTiles)` if we need “which source won” without reconstructing first step.
Allocate these once and reuse.
## Integration points (boat launch only)
- On boat launch (intent/execution init), compute:
1) `targets` near click
2) `sources` (spawn candidates)
3) `path = MultiSourceAnyTargetBFS(sources, targets)`
- Persist `path` and only advance an index in `tick()`.
- Recompute only on meaningful topology changes (rare) or if the current path is invalidated.
Default: do not “find path… then find path…”.
## Diagonals / smoothing (optional)
Two mutually exclusive options:
1) **8-neighbor BFS** (“king moves”, Chebyshev) with **no corner cutting**:
- allow diagonal move only if both adjacent orthogonal tiles are water.
- still unweighted BFS (diagonal cost == orthogonal cost), so the metric becomes Chebyshev.
2) **4-neighbor BFS + smoothing pass**:
- keep BFS cheap/correct,
- do a short post-process that removes zigzags if there is line-of-sight over water.
Default recommendation for boats: option (1) enabled, with no-corner-cutting enabled.
## Failure modes and guardrails
- If `targets` is empty → return `PathNotFound` early.
- If `sources` is empty → return `PathNotFound` early.
- If BFS exceeds a configured budget (`maxNodes`) → return `PathNotFound` (and optionally fall back).
## What this intentionally does NOT do
- No staged “find path… then adjust/retry” heuristics.
- No per-tick pathfinding.
- No bidirectional multi-target tricks (too easy to get subtly wrong).
@@ -0,0 +1,204 @@
import { GameMap, TileRef } from "../game/GameMap";
export type MultiSourceAnyTargetBFSResult = {
source: TileRef;
target: TileRef;
path: TileRef[];
};
export type MultiSourceAnyTargetBFSOptions = {
kingMoves?: boolean;
noCornerCutting?: boolean;
maxVisited?: number;
};
/**
* Multi-source, any-target BFS for TileRef graphs.
*
* - Unweighted (edge cost == 1).
* - Early-exit is correct when terminating on target *dequeue* (pop), not discovery.
* - Designed for reuse: allocates typed arrays once.
*/
export class MultiSourceAnyTargetBFS {
private stamp = 1;
private readonly visitedStamp: Uint32Array;
private readonly targetStamp: Uint32Array;
private readonly prev: Int32Array;
private readonly startOf: Int32Array;
private readonly queue: Int32Array;
constructor(numTiles: number) {
this.visitedStamp = new Uint32Array(numTiles);
this.targetStamp = new Uint32Array(numTiles);
this.prev = new Int32Array(numTiles);
this.startOf = new Int32Array(numTiles);
this.queue = new Int32Array(numTiles);
}
findWaterPath(
gm: GameMap,
sources: readonly TileRef[],
targets: readonly TileRef[],
opts: MultiSourceAnyTargetBFSOptions = {},
): MultiSourceAnyTargetBFSResult | null {
if (sources.length === 0 || targets.length === 0) return null;
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;
for (const s of sources) {
if (s < 0 || s >= this.visitedStamp.length) continue;
if (!gm.isWater(s)) continue;
if (this.visitedStamp[s] === stamp) continue;
this.visitedStamp[s] = stamp;
this.prev[s] = -1;
this.startOf[s] = s;
this.queue[tail++] = s;
}
if (tail === 0) return null;
const kingMoves = opts.kingMoves ?? true;
const noCornerCutting = opts.noCornerCutting ?? true;
const maxVisited = opts.maxVisited ?? this.visitedStamp.length;
let visitedCount = tail;
while (head < tail) {
const node = this.queue[head++] as TileRef;
if (this.targetStamp[node] === stamp) {
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) {
this.visit(n, node, stamp);
this.queue[tail++] = n;
if (++visitedCount >= maxVisited) return null;
}
}
if (node < lastRowStart) {
const s = node + w;
if (gm.isWater(s) && this.visitedStamp[s] !== stamp) {
this.visit(s, node, stamp);
this.queue[tail++] = s;
if (++visitedCount >= maxVisited) return null;
}
}
if (x !== 0) {
const wv = node - 1;
if (gm.isWater(wv) && this.visitedStamp[wv] !== stamp) {
this.visit(wv, node, stamp);
this.queue[tail++] = wv;
if (++visitedCount >= maxVisited) return null;
}
}
if (x !== w - 1) {
const ev = node + 1;
if (gm.isWater(ev) && this.visitedStamp[ev] !== stamp) {
this.visit(ev, node, stamp);
this.queue[tail++] = ev;
if (++visitedCount >= maxVisited) return null;
}
}
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
) {
this.visit(nw, node, stamp);
this.queue[tail++] = nw;
if (++visitedCount >= maxVisited) return null;
}
}
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
) {
this.visit(ne, node, stamp);
this.queue[tail++] = ne;
if (++visitedCount >= maxVisited) return null;
}
}
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
) {
this.visit(sw, node, stamp);
this.queue[tail++] = sw;
if (++visitedCount >= maxVisited) return null;
}
}
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
) {
this.visit(se, node, stamp);
this.queue[tail++] = se;
if (++visitedCount >= maxVisited) return null;
}
}
}
return null;
}
private visit(node: TileRef, from: TileRef, stamp: number) {
this.visitedStamp[node] = stamp;
this.prev[node] = from;
this.startOf[node] = this.startOf[from];
}
private reconstructPath(target: TileRef): TileRef[] {
const out: TileRef[] = [];
let curr: number = target;
while (curr !== -1) {
out.push(curr);
curr = this.prev[curr];
}
out.reverse();
return out;
}
private nextStamp(): number {
const next = (this.stamp + 1) >>> 0;
this.stamp = next === 0 ? 1 : next;
return this.stamp;
}
}