From 65ca00d54f399227c1fb6f2c2045f8e77f66a766 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Fri, 26 Dec 2025 22:32:16 +0100 Subject: [PATCH] Implement MultiSourceAnyTargetBFS for efficient boat routing - Add MultiSourceAnyTargetBFS algorithm for water-based pathfinding - Replace "guess landing tile, then pathfind" with single multi-source search --- docs/MultiSourceAnyTargetBFS.md | 137 ++++++++++++ .../pathfinding/MultiSourceAnyTargetBFS.ts | 204 ++++++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 docs/MultiSourceAnyTargetBFS.md create mode 100644 src/core/pathfinding/MultiSourceAnyTargetBFS.ts diff --git a/docs/MultiSourceAnyTargetBFS.md b/docs/MultiSourceAnyTargetBFS.md new file mode 100644 index 000000000..822a69a51 --- /dev/null +++ b/docs/MultiSourceAnyTargetBFS.md @@ -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 boat’s 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). diff --git a/src/core/pathfinding/MultiSourceAnyTargetBFS.ts b/src/core/pathfinding/MultiSourceAnyTargetBFS.ts new file mode 100644 index 000000000..70f1778e4 --- /dev/null +++ b/src/core/pathfinding/MultiSourceAnyTargetBFS.ts @@ -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; + } +} +