diff --git a/docs/CoarseToFine.md b/docs/CoarseToFine.md new file mode 100644 index 000000000..463a50556 --- /dev/null +++ b/docs/CoarseToFine.md @@ -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 (don’t 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.). diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 56e5533d1..c3c3f015a 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -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(); + 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; diff --git a/src/core/game/TransportShipUtils.ts b/src/core/game/TransportShipUtils.ts index 815544d5c..804577764 100644 --- a/src/core/game/TransportShipUtils.ts +++ b/src/core/game/TransportShipUtils.ts @@ -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(); - 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(); + 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; diff --git a/src/core/pathfinding/CoarseToFineWaterPath.ts b/src/core/pathfinding/CoarseToFineWaterPath.ts new file mode 100644 index 000000000..a1437d7b9 --- /dev/null +++ b/src/core/pathfinding/CoarseToFineWaterPath.ts @@ -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(); +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(); + +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(); +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, + ); +} diff --git a/src/core/pathfinding/MultiSourceAnyTargetBFS.ts b/src/core/pathfinding/MultiSourceAnyTargetBFS.ts index 8843a3baf..4fb33876b 100644 --- a/src/core/pathfinding/MultiSourceAnyTargetBFS.ts +++ b/src/core/pathfinding/MultiSourceAnyTargetBFS.ts @@ -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; + } } } } diff --git a/tests/core/pathfinding/CoarseToFineWaterPath.test.ts b/tests/core/pathfinding/CoarseToFineWaterPath.test.ts new file mode 100644 index 000000000..0a1e72719 --- /dev/null +++ b/tests/core/pathfinding/CoarseToFineWaterPath.test.ts @@ -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); + }); +}); +