Add coarse-to-fine pathfinding for water navigation

- Introduced a new `CoarseToFineWaterPath` module to enhance pathfinding efficiency for boats by utilizing a coarse map to guide fine path searches.
- Implemented a two-stage pathfinding approach: a coarse search on a low-resolution map followed by a refined search on the full-resolution map.
- Updated `TransportShipExecution` and `TransportShipUtils` to leverage the new coarse-to-fine pathfinding method, improving routing decisions and handling of retreat paths.
- Added tests to validate the new pathfinding functionality and ensure robustness in various scenarios.
This commit is contained in:
scamiv
2025-12-27 16:23:14 +01:00
parent 69e422d35e
commit e08acdf09c
6 changed files with 634 additions and 49 deletions
+73
View File
@@ -0,0 +1,73 @@
# Coarse-to-fine pathfinding (boats) — notes
## Why
Full-res water BFS is optimal and simple, but the “ocean case” can still expand a lot of tiles.
Coarse-to-fine is the next lever: do a cheap solve on a low-res map to guide / bound the expensive solve.
## Do we already have low-res maps?
Yes. The terrain loader already ships multiple resolutions per map:
- `manifest.map` + `map.bin` (full res)
- `manifest.map4x` + `map4x.bin` (coarser)
- `manifest.map16x` + `map16x.bin` (even coarser)
At runtime we already load both:
- `gameMap`: full res for normal games (or `map4x` for compact games)
- `miniGameMap`: lower res (`map4x` for normal games, or `map16x` for compact games)
So we can prototype coarse-to-fine without extending mapgen first.
## Core idea (dont overthink it)
Stage 1 (coarse):
- Run the same multi-source/any-target search on `miniGameMap` (BFS, water-only, king-moves if desired).
- Result is a coarse path (or just a coarse distance field).
Stage 2 (refine):
- Run full-res BFS on `gameMap`, but **restricted** by what stage 1 learned (a “corridor”) *or* guided by a coarse heuristic.
Important: the coarse map is an approximation. It must never be allowed to make the final path invalid.
If the refine stage fails inside the corridor, fall back to full-res BFS.
## Option A: Coarse corridor (usually the biggest win)
1) Map fine tiles → coarse cells by integer scaling:
- `scaleX = gameMap.width / miniGameMap.width`
- `scaleY = gameMap.height / miniGameMap.height`
2) Solve on coarse, get a coarse cell path.
3) Inflate that path into a corridor:
- include all coarse cells within radius `r` of the coarse path (e.g. `r = 1..3` )(Manhattan or Chebyshev radius depending on move rules)
4) Refine on full-res with a fast mask:
- `passableFine(tile) = gm.isWater(tile) && corridorMask[coarseOf(tile)]`
5) If no path found, retry without the corridor (or inflate `r` and retry once).
Notes:
- If the low-res generation is “optimistic” (water if any child tile is water), the coarse path can cut across land.
Inflation + fallback is what keeps this safe.
## Option B: Coarse heuristic for A*
If we ever move from BFS → A* on full-res, a cheap heuristic is:
- Precompute `coarseDist[coarseCell]` by BFS on `miniGameMap` seeded from coarse targets.
- Use `h(tile) = coarseDist[coarseOf(tile)] * min(scaleX, scaleY)`
If the coarse map is “more passable” than the fine map (typical for minimaps), `coarseDist` tends to **underestimate**,
which is admissible (safe) but not always very tight.
## Where component IDs fit
Water-component IDs are still a free early reject:
- `WaterComponents.ts` already precomputes IDs per `GameMap` instance.
- Do the same check on `miniGameMap` if useful, but full-res component filtering already prevents the worst “wrong ocean” searches.
## Practical next steps (incremental)
1) Add a coarse route helper that mirrors the existing API but runs on `miniGameMap`.
2) Implement corridor masking + refine fallback as a generic helper (so transport/trade/warship can all share it).
3) Measure: expansions + ms, before/after, on worst-case oceans.
4) Only then decide if mapgen needs a better “navmap” (e.g. conservative water, coastline preservation, etc.).
+73 -15
View File
@@ -13,7 +13,9 @@ import { TileRef } from "../game/GameMap";
import {
bestTransportShipRoute,
boatPathFromTileToShore,
candidateShoreTiles,
} from "../game/TransportShipUtils";
import { getWaterComponentIds } from "../pathfinding/WaterComponents";
import { AttackExecution } from "./AttackExecution";
const malusForRetreat = 25;
@@ -168,6 +170,14 @@ export class TransportShipExecution implements Execution {
this.active = false;
return;
}
// In team games, disconnected players can have their ships captured by teammates on conquest.
// Until ownership transfers, avoid progressing the ship (prevents it from completing an attack
// while the original owner is already disconnected).
if (this.originalOwner.isDisconnected() && this.boat.owner() === this.originalOwner) {
return;
}
if (ticks - this.lastMove < this.ticksPerMove) {
return;
}
@@ -186,15 +196,67 @@ export class TransportShipExecution implements Execution {
}
if (this.boat.retreating()) {
const curr = this.boat.tile();
if (curr === null) {
this.active = false;
return;
}
// Ensure retreat source is still valid for (new) owner
if (this.mg.owner(this.src!) !== this.attacker) {
// Use bestTransportShipSpawn, not canBuild because of its max boats check etc
const newSrc = this.attacker.bestTransportShipSpawn(this.dst);
if (newSrc === false) {
this.src = null;
} else {
this.src = newSrc;
if (
this.src === null ||
!this.mg.isValidRef(this.src) ||
this.mg.owner(this.src) !== this.attacker ||
!this.mg.isShore(this.src)
) {
// Pick a reachable shore tile owned by the new owner.
const ids = getWaterComponentIds(this.mg);
const currComps = new Set<number>();
if (this.mg.isWater(curr)) {
const id = ids[curr] ?? 0;
if (id !== 0) currComps.add(id);
} else if (this.mg.isShore(curr)) {
for (const n of this.mg.neighbors(curr)) {
if (!this.mg.isWater(n)) continue;
const id = ids[n] ?? 0;
if (id !== 0) currComps.add(id);
}
}
let newSrc: TileRef | null = null;
if (currComps.size !== 0) {
const candidates = candidateShoreTiles(this.mg, this.attacker, curr);
for (const shore of candidates) {
if (shore === curr || shore === this.ref) continue;
for (const n of this.mg.neighbors(shore)) {
if (!this.mg.isWater(n)) continue;
const id = ids[n] ?? 0;
if (id !== 0 && currComps.has(id)) {
newSrc = shore;
break;
}
}
if (newSrc !== null) break;
}
if (newSrc === null) {
for (const t of this.attacker.borderTiles()) {
if (!this.mg.isShore(t)) continue;
if (t === curr || t === this.ref) continue;
for (const n of this.mg.neighbors(t)) {
if (!this.mg.isWater(n)) continue;
const id = ids[n] ?? 0;
if (id !== 0 && currComps.has(id)) {
newSrc = t;
break;
}
}
if (newSrc !== null) break;
}
}
}
this.src = newSrc;
}
if (this.src === null) {
@@ -215,18 +277,14 @@ export class TransportShipExecution implements Execution {
// Retreat is just the existing forward path in reverse (hot-path friendly).
// Fallback to a recompute only if we can't safely reverse (e.g. path invalidated).
if (!this.usingReverseRetreatPath) {
const curr = this.boat.tile();
const idx = curr === null ? -1 : this.forwardPath.indexOf(curr);
if (idx >= 0) {
const originalSrc = this.forwardPath[0] ?? null;
const idx = this.forwardPath.indexOf(curr);
if (originalSrc !== null && this.dst === originalSrc && idx >= 0) {
this.path = this.forwardPath.slice(0, idx + 1).reverse();
this.pathIndex = 0;
this.usingReverseRetreatPath = true;
} else {
const retreatPath = boatPathFromTileToShore(
this.mg,
curr!,
this.dst,
);
const retreatPath = boatPathFromTileToShore(this.mg, curr, this.dst);
if (retreatPath !== null) {
this.path = retreatPath;
this.pathIndex = 0;
+59 -34
View File
@@ -1,4 +1,4 @@
import { MultiSourceAnyTargetBFS } from "../pathfinding/MultiSourceAnyTargetBFS";
import { findWaterPathFromSeedsCoarseToFine } from "../pathfinding/CoarseToFineWaterPath";
import { getWaterComponentIds } from "../pathfinding/WaterComponents";
import { Game, Player, UnitType } from "./Game";
import { andFN, GameMap, manhattanDistFN, TileRef } from "./GameMap";
@@ -9,15 +9,10 @@ type BoatRoute = {
path: TileRef[];
};
let boatBfs: MultiSourceAnyTargetBFS | null = null;
let boatBfsNumTiles = 0;
function getBoatBfs(gm: GameMap): MultiSourceAnyTargetBFS {
const numTiles = gm.width() * gm.height();
if (boatBfs === null || boatBfsNumTiles !== numTiles) {
boatBfs = new MultiSourceAnyTargetBFS(numTiles);
boatBfsNumTiles = numTiles;
}
return boatBfs;
function miniMapOrNull(gm: GameMap): GameMap | null {
const mm = (gm as any).miniMap;
if (typeof mm === "function") return mm.call(gm) as GameMap;
return null;
}
function insertTopK(
@@ -88,6 +83,13 @@ function shoreTargetsNearClick(
// Default behavior: scan a bounding box near the click for candidate shore tiles.
// (Previously, player targets used all border tiles, which could pick very distant shores.)
// TerraNullius targets keep the classic "find closest unowned shore within a radius" behavior
// so inland clicks still resolve to a nearby shoreline.
if (!targetOwner.isPlayer()) {
const tn = closestShoreTN(gm, click, 50);
return tn === null ? [] : [tn];
}
const top: { tile: TileRef; dist: number }[] = [];
const cx = gm.x(click);
const cy = gm.y(click);
@@ -197,9 +199,8 @@ export function boatPathFromTileToShore(
const targetWater = targetWaterAll.filter((t) => seedComps.has(ids[t] ?? 0));
if (targetWater.length === 0) return null;
const bfs = getBoatBfs(gm);
const startTime = performance.now();
const result = bfs.findWaterPathFromSeeds(
const result = findWaterPathFromSeedsCoarseToFine(
gm,
seedNodesFiltered,
seedOriginsFiltered,
@@ -208,6 +209,7 @@ export function boatPathFromTileToShore(
kingMoves: true,
noCornerCutting: true,
},
miniMapOrNull(gm),
);
const duration = performance.now() - startTime;
if (result === null) return null;
@@ -269,12 +271,18 @@ export function boatPathFromTileToWater(
}
if (seedNodesFiltered.length === 0) return null;
const bfs = getBoatBfs(gm);
const startTime = performance.now();
const result = bfs.findWaterPathFromSeeds(gm, seedNodesFiltered, seedOriginsFiltered, [dstWater], {
kingMoves: true,
noCornerCutting: true,
});
const result = findWaterPathFromSeedsCoarseToFine(
gm,
seedNodesFiltered,
seedOriginsFiltered,
[dstWater],
{
kingMoves: true,
noCornerCutting: true,
},
miniMapOrNull(gm),
);
const duration = performance.now() - startTime;
if (result === null) return null;
@@ -324,21 +332,32 @@ export function bestTransportShipRoute(
}
if (targetWater.length === 0) return false;
const sourceShores: TileRef[] =
preferredSrc !== null && gm.isValidRef(preferredSrc)
? [preferredSrc]
: candidateShoreTiles(gm, attacker, clickTile);
const preferredRequested =
preferredSrc !== null && gm.isValidRef(preferredSrc);
const seedNodeToOrigin = new Map<TileRef, TileRef>();
for (const shore of sourceShores) {
if (!gm.isValidRef(shore)) continue;
if (gm.owner(shore) !== attacker) continue;
if (!gm.isShore(shore)) continue;
for (const w of adjacentWaterTiles(gm, shore)) {
if (!seedNodeToOrigin.has(w)) {
seedNodeToOrigin.set(w, shore);
const buildSeedNodeToOrigin = (sourceShores: readonly TileRef[]) => {
const out = new Map<TileRef, TileRef>();
for (const shore of sourceShores) {
if (!gm.isValidRef(shore)) continue;
if (gm.owner(shore) !== attacker) continue;
if (!gm.isShore(shore)) continue;
for (const w of adjacentWaterTiles(gm, shore)) {
if (!out.has(w)) {
out.set(w, shore);
}
}
}
return out;
};
let seedNodeToOrigin = buildSeedNodeToOrigin(
preferredRequested ? [preferredSrc!] : candidateShoreTiles(gm, attacker, clickTile),
);
// Preferred src is a hint; if it doesn't yield a valid spawn shore, fall back to normal candidates.
if (seedNodeToOrigin.size === 0 && preferredRequested) {
seedNodeToOrigin = buildSeedNodeToOrigin(
candidateShoreTiles(gm, attacker, clickTile),
);
}
if (seedNodeToOrigin.size === 0) return false;
@@ -375,12 +394,18 @@ export function bestTransportShipRoute(
const targetWaterFiltered = targetWater.filter((t) => seedComps.has(ids[t] ?? 0));
if (targetWaterFiltered.length === 0) return false;
const bfs = getBoatBfs(gm);
const startTime = performance.now();
const result = bfs.findWaterPathFromSeeds(gm, seedNodesFiltered, seedOriginsFiltered, targetWaterFiltered, {
kingMoves: true,
noCornerCutting: true,
});
const result = findWaterPathFromSeedsCoarseToFine(
gm,
seedNodesFiltered,
seedOriginsFiltered,
targetWaterFiltered,
{
kingMoves: true,
noCornerCutting: true,
},
gm.miniMap(),
);
const duration = performance.now() - startTime;
if (result === null) return false;
@@ -0,0 +1,273 @@
import { GameMap, TileRef } from "../game/GameMap";
import {
MultiSourceAnyTargetBFS,
MultiSourceAnyTargetBFSOptions,
MultiSourceAnyTargetBFSResult,
} from "./MultiSourceAnyTargetBFS";
export type CoarseToFineWaterPathOptions = {
/**
* Corridor radius in coarse cells (Chebyshev) around the coarse path.
* Larger = safer (less likely to miss due to minimap tearing), smaller = faster.
*/
corridorRadius?: number;
/**
* How many corridor attempts to try before falling back to unrestricted fine BFS.
*/
maxAttempts?: number;
/**
* Multiply radius each attempt (e.g. 2 turns 2 -> 4 -> 8 ...).
*/
radiusMultiplier?: number;
};
const bfsCache = new WeakMap<GameMap, MultiSourceAnyTargetBFS>();
function getBfs(gm: GameMap): MultiSourceAnyTargetBFS {
const cached = bfsCache.get(gm);
if (cached) return cached;
const bfs = new MultiSourceAnyTargetBFS(gm.width() * gm.height());
bfsCache.set(gm, bfs);
return bfs;
}
type FineToCoarseMapping = {
coarse: GameMap;
fineToCoarse: Uint32Array;
scaleX: number;
scaleY: number;
};
const fineToCoarseCache = new WeakMap<GameMap, FineToCoarseMapping>();
function getFineToCoarseMapping(
fine: GameMap,
coarse: GameMap,
): FineToCoarseMapping | null {
const cached = fineToCoarseCache.get(fine);
if (cached && cached.coarse === coarse) return cached;
const fw = fine.width();
const fh = fine.height();
const cw = coarse.width();
const ch = coarse.height();
if (cw <= 0 || ch <= 0) return null;
if (fw % cw !== 0 || fh % ch !== 0) return null;
const scaleX = fw / cw;
const scaleY = fh / ch;
if (!Number.isInteger(scaleX) || !Number.isInteger(scaleY)) return null;
if (scaleX <= 0 || scaleY <= 0) return null;
const fineToCoarse = new Uint32Array(fw * fh);
// Fill by coarse cell rectangles to avoid division in the inner loop.
for (let cy = 0; cy < ch; cy++) {
const fineYStart = cy * scaleY;
const fineYEnd = fineYStart + scaleY;
for (let cx = 0; cx < cw; cx++) {
const coarseRef = cy * cw + cx;
const fineXStart = cx * scaleX;
const fineXEnd = fineXStart + scaleX;
for (let y = fineYStart; y < fineYEnd; y++) {
let fineRef = y * fw + fineXStart;
for (let x = fineXStart; x < fineXEnd; x++) {
fineToCoarse[fineRef++] = coarseRef;
}
}
}
}
const entry: FineToCoarseMapping = { coarse, fineToCoarse, scaleX, scaleY };
fineToCoarseCache.set(fine, entry);
return entry;
}
type StampSet = { stamp: number; data: Uint32Array };
const stampSetCache = new WeakMap<GameMap, StampSet>();
function getStampSet(gm: GameMap): StampSet {
const cached = stampSetCache.get(gm);
if (cached) return cached;
const set: StampSet = { stamp: 1, data: new Uint32Array(gm.width() * gm.height()) };
stampSetCache.set(gm, set);
return set;
}
function nextStamp(set: StampSet): number {
const next = (set.stamp + 1) >>> 0;
set.stamp = next === 0 ? 1 : next;
return set.stamp;
}
function dedupeByStamp(
tiles: readonly TileRef[],
stampSet: StampSet,
stamp: number,
): TileRef[] {
const out: TileRef[] = [];
for (const t of tiles) {
if (t < 0 || t >= stampSet.data.length) continue;
if (stampSet.data[t] === stamp) continue;
stampSet.data[t] = stamp;
out.push(t);
}
return out;
}
function markCoarseCorridor(
coarseWidth: number,
coarseHeight: number,
corridorStamp: Uint32Array,
stamp: number,
coarsePath: readonly TileRef[],
radius: number,
) {
for (const ref of coarsePath) {
const x = ref % coarseWidth;
const y = Math.floor(ref / coarseWidth);
const y0 = Math.max(0, y - radius);
const y1 = Math.min(coarseHeight - 1, y + radius);
const x0 = Math.max(0, x - radius);
const x1 = Math.min(coarseWidth - 1, x + radius);
for (let yy = y0; yy <= y1; yy++) {
const row = yy * coarseWidth;
for (let xx = x0; xx <= x1; xx++) {
corridorStamp[row + xx] = stamp;
}
}
}
}
export function findWaterPathFromSeedsCoarseToFine(
fineMap: GameMap,
seedNodes: readonly TileRef[],
seedOrigins: readonly TileRef[],
targets: readonly TileRef[],
bfsOpts: MultiSourceAnyTargetBFSOptions = {},
coarseMap: GameMap | null = null,
coarseToFine: CoarseToFineWaterPathOptions = {},
): MultiSourceAnyTargetBFSResult | null {
const fineBfs = getBfs(fineMap);
if (!coarseMap) {
return fineBfs.findWaterPathFromSeeds(
fineMap,
seedNodes,
seedOrigins,
targets,
bfsOpts,
);
}
const mapping = getFineToCoarseMapping(fineMap, coarseMap);
if (mapping === null) {
return fineBfs.findWaterPathFromSeeds(
fineMap,
seedNodes,
seedOrigins,
targets,
bfsOpts,
);
}
const coarseWidth = coarseMap.width();
const coarseHeight = coarseMap.height();
const coarseStampSet = getStampSet(coarseMap);
const coarseSeedStamp = nextStamp(coarseStampSet);
const coarseTargetStamp = nextStamp(coarseStampSet);
const coarseSeedsRaw: TileRef[] = [];
for (const s of seedNodes) {
if (s < 0 || s >= mapping.fineToCoarse.length) continue;
coarseSeedsRaw.push(mapping.fineToCoarse[s] as TileRef);
}
const coarseTargetsRaw: TileRef[] = [];
for (const t of targets) {
if (t < 0 || t >= mapping.fineToCoarse.length) continue;
coarseTargetsRaw.push(mapping.fineToCoarse[t] as TileRef);
}
const coarseSeeds = dedupeByStamp(
coarseSeedsRaw,
coarseStampSet,
coarseSeedStamp,
);
const coarseTargets = dedupeByStamp(
coarseTargetsRaw,
coarseStampSet,
coarseTargetStamp,
);
if (coarseSeeds.length === 0 || coarseTargets.length === 0) {
return fineBfs.findWaterPathFromSeeds(
fineMap,
seedNodes,
seedOrigins,
targets,
bfsOpts,
);
}
// Coarse solve (cheap) to define a corridor.
const coarseBfs = getBfs(coarseMap);
const coarseResult = coarseBfs.findWaterPath(
coarseMap,
coarseSeeds,
coarseTargets,
bfsOpts,
);
if (coarseResult === null) {
// Safe fallback: if the coarse map is conservative, we might still have a fine path.
return fineBfs.findWaterPathFromSeeds(
fineMap,
seedNodes,
seedOrigins,
targets,
bfsOpts,
);
}
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 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,
);
const refined = fineBfs.findWaterPathFromSeeds(
fineMap,
seedNodes,
seedOrigins,
targets,
{
...bfsOpts,
allowedMask: {
tileToRegion: mapping.fineToCoarse,
regionStamp: corridorSet.data,
stamp: corridorStamp,
},
},
);
if (refined !== null) return refined;
radius *= radiusMultiplier;
}
// Final fallback: unrestricted fine BFS.
return fineBfs.findWaterPathFromSeeds(
fineMap,
seedNodes,
seedOrigins,
targets,
bfsOpts,
);
}
@@ -9,6 +9,17 @@ export type MultiSourceAnyTargetBFSResult = {
export type MultiSourceAnyTargetBFSOptions = {
kingMoves?: boolean;
noCornerCutting?: boolean;
/**
* Optional region mask to restrict traversal.
*
* Intended for coarse-to-fine corridors: map each fine tile to a coarse region and
* allow only regions whose stamp matches.
*/
allowedMask?: {
tileToRegion: Uint32Array;
regionStamp: Uint32Array;
stamp: number;
};
};
/**
@@ -67,11 +78,19 @@ export class MultiSourceAnyTargetBFS {
let head = 0;
let tail = 0;
const allowed = opts.allowedMask;
const count = Math.min(seedNodes.length, seedOrigins.length);
for (let i = 0; i < count; i++) {
const node = seedNodes[i]!;
const origin = seedOrigins[i]!;
if (node < 0 || node >= this.visitedStamp.length) continue;
if (
allowed &&
allowed.regionStamp[allowed.tileToRegion[node]!] !== allowed.stamp
) {
continue;
}
if (!gm.isWater(node)) continue;
if (this.visitedStamp[node] === stamp) continue;
this.visitedStamp[node] = stamp;
@@ -102,29 +121,57 @@ export class MultiSourceAnyTargetBFS {
if (node >= w) {
const n = node - w;
if (gm.isWater(n) && this.visitedStamp[n] !== stamp) {
if (
allowed &&
allowed.regionStamp[allowed.tileToRegion[n]!] !== allowed.stamp
) {
// skip
} else {
this.visit(n, node, stamp);
this.queue[tail++] = n;
}
}
}
if (node < lastRowStart) {
const s = node + w;
if (gm.isWater(s) && this.visitedStamp[s] !== stamp) {
if (
allowed &&
allowed.regionStamp[allowed.tileToRegion[s]!] !== allowed.stamp
) {
// skip
} else {
this.visit(s, node, stamp);
this.queue[tail++] = s;
}
}
}
if (x !== 0) {
const wv = node - 1;
if (gm.isWater(wv) && this.visitedStamp[wv] !== stamp) {
if (
allowed &&
allowed.regionStamp[allowed.tileToRegion[wv]!] !== allowed.stamp
) {
// skip
} else {
this.visit(wv, node, stamp);
this.queue[tail++] = wv;
}
}
}
if (x !== w - 1) {
const ev = node + 1;
if (gm.isWater(ev) && this.visitedStamp[ev] !== stamp) {
if (
allowed &&
allowed.regionStamp[allowed.tileToRegion[ev]!] !== allowed.stamp
) {
// skip
} else {
this.visit(ev, node, stamp);
this.queue[tail++] = ev;
}
}
}
@@ -138,8 +185,15 @@ export class MultiSourceAnyTargetBFS {
(!noCornerCutting || (gm.isWater(node - w) && gm.isWater(node - 1))) &&
this.visitedStamp[nw] !== stamp
) {
if (
allowed &&
allowed.regionStamp[allowed.tileToRegion[nw]!] !== allowed.stamp
) {
// skip
} else {
this.visit(nw, node, stamp);
this.queue[tail++] = nw;
}
}
}
if (node >= w && x !== w - 1) {
@@ -149,8 +203,15 @@ export class MultiSourceAnyTargetBFS {
(!noCornerCutting || (gm.isWater(node - w) && gm.isWater(node + 1))) &&
this.visitedStamp[ne] !== stamp
) {
if (
allowed &&
allowed.regionStamp[allowed.tileToRegion[ne]!] !== allowed.stamp
) {
// skip
} else {
this.visit(ne, node, stamp);
this.queue[tail++] = ne;
}
}
}
if (node < lastRowStart && x !== 0) {
@@ -160,8 +221,15 @@ export class MultiSourceAnyTargetBFS {
(!noCornerCutting || (gm.isWater(node + w) && gm.isWater(node - 1))) &&
this.visitedStamp[sw] !== stamp
) {
if (
allowed &&
allowed.regionStamp[allowed.tileToRegion[sw]!] !== allowed.stamp
) {
// skip
} else {
this.visit(sw, node, stamp);
this.queue[tail++] = sw;
}
}
}
if (node < lastRowStart && x !== w - 1) {
@@ -171,8 +239,15 @@ export class MultiSourceAnyTargetBFS {
(!noCornerCutting || (gm.isWater(node + w) && gm.isWater(node + 1))) &&
this.visitedStamp[se] !== stamp
) {
if (
allowed &&
allowed.regionStamp[allowed.tileToRegion[se]!] !== allowed.stamp
) {
// skip
} else {
this.visit(se, node, stamp);
this.queue[tail++] = se;
}
}
}
}
@@ -0,0 +1,81 @@
import { findWaterPathFromSeedsCoarseToFine } from "../../../src/core/pathfinding/CoarseToFineWaterPath";
type TileRef = number;
function makeGridWaterMap(w: number, h: number, water: boolean[]) {
const num = w * h;
if (water.length !== num) throw new Error("bad water array");
return {
width: () => w,
height: () => h,
x: (ref: TileRef) => ref % w,
y: (ref: TileRef) => Math.floor(ref / w),
isWater: (ref: TileRef) => water[ref] === true,
} as any;
}
function coarseCellOfFine(ref: TileRef, fineW: number): number {
const x = ref % fineW;
const y = Math.floor(ref / fineW);
const cx = x >= 4 ? 1 : 0;
const cy = y >= 4 ? 1 : 0;
return cy * 2 + cx;
}
describe("findWaterPathFromSeedsCoarseToFine (Option A)", () => {
it("finds a route inside a tight corridor on all-water maps", () => {
const fineW = 8;
const fineH = 8;
const fine = makeGridWaterMap(fineW, fineH, new Array(64).fill(true));
const coarse = makeGridWaterMap(2, 2, new Array(4).fill(true));
const res = findWaterPathFromSeedsCoarseToFine(
fine,
[0],
[0],
[63],
{ kingMoves: true, noCornerCutting: true },
coarse,
{ corridorRadius: 0, maxAttempts: 1 },
);
expect(res).not.toBeNull();
// Corridor from coarse path [0,3] at radius 0 only allows coarse cells {0,3}.
const usedCells = new Set(
res!.path.map((t) => coarseCellOfFine(t, fineW)),
);
expect(Array.from(usedCells).sort()).toEqual([0, 3]);
});
it("falls back to unrestricted fine BFS when the corridor is too tight", () => {
const fineW = 8;
const fineH = 8;
const water = new Array(64).fill(true) as boolean[];
// Block the central no-corner-cutting diagonal from (3,3)->(4,4):
// it requires both orthogonals (4,3) and (3,4) to be water.
water[4 + 3 * fineW] = false; // (4,3)
water[3 + 4 * fineW] = false; // (3,4)
const fine = makeGridWaterMap(fineW, fineH, water);
const coarse = makeGridWaterMap(2, 2, new Array(4).fill(true));
const res = findWaterPathFromSeedsCoarseToFine(
fine,
[0],
[0],
[63],
{ kingMoves: true, noCornerCutting: true },
coarse,
{ corridorRadius: 0, maxAttempts: 1 },
);
expect(res).not.toBeNull();
// With the diagonal blocked, any fine path from cell 0 to cell 3 must pass through cell 1 or 2.
const usedCells = new Set(
res!.path.map((t) => coarseCellOfFine(t, fineW)),
);
expect(usedCells.has(1) || usedCells.has(2)).toBe(true);
});
});