mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-28 15:34:20 +00:00
Refactor MultiSourceAnyTargetBFS documentation for clarity and performance
- Updated the design notes to clarify the multi-source, any-target BFS approach for boat routing on a water-only grid. - Simplified the explanation of the algorithm, focusing on the use of a virtual super-source and super-target. - Enhanced the API description, detailing the return value structure and movement model. - Added performance optimizations, including the use of typed arrays and precomputation of water component IDs to improve routing efficiency.
This commit is contained in:
+31
-119
@@ -1,137 +1,49 @@
|
||||
# MultiSourceAnyTargetBFS (boats) — design notes
|
||||
|
||||
## Goal
|
||||
## What we’re doing
|
||||
|
||||
Replace the current “guess a landing tile, then pathfind” approach with a single **multi-source, any-target** search that:
|
||||
Boat routing is now a single, sound **multi-source / any-target** shortest-path solve on a **water-only** grid.
|
||||
For equal cost edges, that’s just **BFS**.
|
||||
|
||||
- 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.
|
||||
The mental model is the classic “virtual super-source / super-target” trick:
|
||||
|
||||
This is the standard “virtual super-source + virtual super-target” idea:
|
||||
- Pretend there is a `START` node with 0-cost edges to every source `S`.
|
||||
- Pretend every target `D` has a 0-cost edge to an `END` node.
|
||||
- Run shortest path `START → END`.
|
||||
|
||||
- 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 don’t build those nodes: we seed the queue with all sources, and we stop when we **dequeue** any target.
|
||||
|
||||
We do not build those nodes; we seed the queue with all sources and stop when we pop a target.
|
||||
## API shape
|
||||
|
||||
## Scope / assumptions
|
||||
Return value: `{ source, target, path } | null`
|
||||
|
||||
- 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.
|
||||
- `source`: which seed-origin won (useful when seeds are water-adjacent tiles but the origin is a shore tile).
|
||||
- `target`: the water tile we reached (usually adjacent to the chosen landing shore).
|
||||
- `path`: water path as a list of `TileRef` to cache and replay (no per-tick pathfinding).
|
||||
|
||||
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.
|
||||
## Movement model (boats)
|
||||
|
||||
## Inputs and outputs
|
||||
- Traversal: `gm.isWater(tile)` only.
|
||||
- Neighbors: **king moves** (8-neighbor / Chebyshev) by default.
|
||||
- No-corner-cutting: diagonal is only allowed if both touching orthogonals are water.
|
||||
- All moves cost 1, so BFS is optimal.
|
||||
|
||||
### Inputs
|
||||
## Performance wins (the non-negotiables)
|
||||
|
||||
- `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.
|
||||
- Use typed arrays + stamps (no `Set`/`Map` in the inner loop).
|
||||
- **Precompute water-body component IDs** once per map instance (`WaterComponents.ts`) and filter sources/targets:
|
||||
- If source component ≠ target component, skip it (ocean vs lake becomes O(1) reject).
|
||||
- This makes “impossible” routes cheap and lets us delete hacky visited-count early exits.
|
||||
|
||||
### Output
|
||||
## How this integrates (transport/trade/warships)
|
||||
|
||||
`{ source, target, path } | PathNotFound`
|
||||
- Destination selection stays “near click”: pick a bounded set of landing shore candidates, then convert to adjacent water targets.
|
||||
- Source selection stays bounded (sampling/extrema/etc), then convert to adjacent water seeds.
|
||||
- Compute the route once on launch/init, cache `path`, and only advance an index during ticks.
|
||||
- Retreat follows the existing cached path backwards (no recompute).
|
||||
|
||||
- `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.
|
||||
## Current known waste
|
||||
|
||||
## Target selection (“near click”, but bounded)
|
||||
If the client pre-queries and the server recomputes, you will see two searches for a single action.
|
||||
We should ensure we only do one solve per user action (exact mechanism TBD: remove the pre-query, or reuse/publish the computed route).
|
||||
|
||||
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).
|
||||
|
||||
Reference in New Issue
Block a user