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 -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;
}
}
}
}