From 722c02353567fd5d258a395070681c96655ca7d9 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Sat, 27 Dec 2025 03:56:14 +0100 Subject: [PATCH] Add water component ID caching for improved pathfinding - Introduced `getWaterComponentIds` function to cache water component IDs for game maps, enhancing pathfinding efficiency. - Updated `boatPathFromTileToShore`, `boatPathFromTileToWater`, and `bestTransportShipRoute` functions to utilize water component IDs for more accurate routing. - Removed unused `maxVisited` parameter from `MultiSourceAnyTargetBFS` options to streamline pathfinding logic. --- src/core/game/GameImpl.ts | 5 + src/core/game/TransportShipUtils.ts | 98 ++++++++++++++++--- .../pathfinding/MultiSourceAnyTargetBFS.ts | 11 --- src/core/pathfinding/WaterComponents.ts | 74 ++++++++++++++ 4 files changed, 162 insertions(+), 26 deletions(-) create mode 100644 src/core/pathfinding/WaterComponents.ts diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 61a6e38d7..e76d9fdaf 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -42,6 +42,7 @@ import { StatsImpl } from "./StatsImpl"; import { assignTeams } from "./TeamAssignment"; import { TerraNulliusImpl } from "./TerraNulliusImpl"; import { UnitGrid, UnitPredicate } from "./UnitGrid"; +import { getWaterComponentIds } from "../pathfinding/WaterComponents"; export function createGame( humans: PlayerInfo[], @@ -50,6 +51,10 @@ export function createGame( miniGameMap: GameMap, config: Config, ): Game { + // Precompute and cache water-component IDs once per map instance. + getWaterComponentIds(gameMap); + getWaterComponentIds(miniGameMap); + const stats = new StatsImpl(); return new GameImpl(humans, nations, gameMap, miniGameMap, config, stats); } diff --git a/src/core/game/TransportShipUtils.ts b/src/core/game/TransportShipUtils.ts index 62ede882f..65930fb27 100644 --- a/src/core/game/TransportShipUtils.ts +++ b/src/core/game/TransportShipUtils.ts @@ -1,4 +1,5 @@ import { MultiSourceAnyTargetBFS } from "../pathfinding/MultiSourceAnyTargetBFS"; +import { getWaterComponentIds } from "../pathfinding/WaterComponents"; import { Game, Player, UnitType } from "./Game"; import { andFN, GameMap, manhattanDistFN, TileRef } from "./GameMap"; @@ -153,11 +154,6 @@ export function boatPathFromTileToShore( if (!gm.isValidRef(startTile) || !gm.isValidRef(dstShore)) return null; if (!gm.isShore(dstShore)) return null; - const targetWater = adjacentWaterTiles(gm, dstShore); - if (targetWater.length === 0) return null; - - const bfs = getBoatBfs(gm); - let seedNodes: TileRef[] = []; let seedOrigins: TileRef[] = []; if (gm.isWater(startTile)) { @@ -172,11 +168,46 @@ export function boatPathFromTileToShore( return null; } - const result = bfs.findWaterPathFromSeeds(gm, seedNodes, seedOrigins, targetWater, { + const targetWaterAll = adjacentWaterTiles(gm, dstShore); + if (targetWaterAll.length === 0) return null; + + // Avoid impossible searches: restrict to the same connected water component. + const ids = getWaterComponentIds(gm); + const targetComps = new Set(); + for (const t of targetWaterAll) { + const id = ids[t] ?? 0; + if (id !== 0) targetComps.add(id); + } + if (targetComps.size === 0) return null; + + const seedNodesFiltered: TileRef[] = []; + const seedOriginsFiltered: TileRef[] = []; + const seedComps = new Set(); + for (let i = 0; i < seedNodes.length; i++) { + const n = seedNodes[i]!; + const id = ids[n] ?? 0; + if (id === 0) continue; + if (!targetComps.has(id)) continue; + seedNodesFiltered.push(n); + seedOriginsFiltered.push(seedOrigins[i]!); + seedComps.add(id); + } + if (seedNodesFiltered.length === 0) return null; + + const targetWater = targetWaterAll.filter((t) => seedComps.has(ids[t] ?? 0)); + if (targetWater.length === 0) return null; + + const bfs = getBoatBfs(gm); + const result = bfs.findWaterPathFromSeeds( + gm, + seedNodesFiltered, + seedOriginsFiltered, + targetWater, + { kingMoves: true, noCornerCutting: true, - maxVisited: 300_000, //todo: replace with a proper limit based on the map size - }); + }, + ); if (result === null) return null; if (gm.isWater(startTile)) { @@ -193,8 +224,6 @@ export function boatPathFromTileToWater( if (!gm.isValidRef(startTile) || !gm.isValidRef(dstWater)) return null; if (!gm.isWater(dstWater)) return null; - const bfs = getBoatBfs(gm); - let seedNodes: TileRef[] = []; let seedOrigins: TileRef[] = []; if (gm.isWater(startTile)) { @@ -209,10 +238,25 @@ export function boatPathFromTileToWater( return null; } - const result = bfs.findWaterPathFromSeeds(gm, seedNodes, seedOrigins, [dstWater], { + // Avoid impossible searches: restrict seeds to the same connected water component. + const ids = getWaterComponentIds(gm); + const dstComp = ids[dstWater] ?? 0; + if (dstComp === 0) return null; + + const seedNodesFiltered: TileRef[] = []; + const seedOriginsFiltered: TileRef[] = []; + for (let i = 0; i < seedNodes.length; i++) { + const n = seedNodes[i]!; + if ((ids[n] ?? 0) !== dstComp) continue; + seedNodesFiltered.push(n); + seedOriginsFiltered.push(seedOrigins[i]!); + } + if (seedNodesFiltered.length === 0) return null; + + const bfs = getBoatBfs(gm); + const result = bfs.findWaterPathFromSeeds(gm, seedNodesFiltered, seedOriginsFiltered, [dstWater], { kingMoves: true, noCornerCutting: true, - maxVisited: 300_000, }); if (result === null) return null; @@ -272,12 +316,36 @@ export function bestTransportShipRoute( seedOrigins.push(origin); } + // Avoid impossible searches: restrict seeds/targets to overlapping water components. + const ids = getWaterComponentIds(gm); + const targetComps = new Set(); + for (const tw of targetWater) { + const id = ids[tw] ?? 0; + if (id !== 0) targetComps.add(id); + } + if (targetComps.size === 0) return false; + + const seedNodesFiltered: TileRef[] = []; + const seedOriginsFiltered: TileRef[] = []; + const seedComps = new Set(); + for (let i = 0; i < seedNodes.length; i++) { + const n = seedNodes[i]!; + const id = ids[n] ?? 0; + if (id === 0) continue; + if (!targetComps.has(id)) continue; + seedNodesFiltered.push(n); + seedOriginsFiltered.push(seedOrigins[i]!); + seedComps.add(id); + } + if (seedNodesFiltered.length === 0) return false; + + const targetWaterFiltered = targetWater.filter((t) => seedComps.has(ids[t] ?? 0)); + if (targetWaterFiltered.length === 0) return false; + const bfs = getBoatBfs(gm); - const result = bfs.findWaterPathFromSeeds(gm, seedNodes, seedOrigins, targetWater, { + const result = bfs.findWaterPathFromSeeds(gm, seedNodesFiltered, seedOriginsFiltered, targetWaterFiltered, { kingMoves: true, noCornerCutting: true, - // Hard budget to avoid pathological cases; tweak as needed. - maxVisited: 300_000, }); if (result === null) return false; diff --git a/src/core/pathfinding/MultiSourceAnyTargetBFS.ts b/src/core/pathfinding/MultiSourceAnyTargetBFS.ts index 144381972..8843a3baf 100644 --- a/src/core/pathfinding/MultiSourceAnyTargetBFS.ts +++ b/src/core/pathfinding/MultiSourceAnyTargetBFS.ts @@ -9,7 +9,6 @@ export type MultiSourceAnyTargetBFSResult = { export type MultiSourceAnyTargetBFSOptions = { kingMoves?: boolean; noCornerCutting?: boolean; - maxVisited?: number; }; /** @@ -85,8 +84,6 @@ export class MultiSourceAnyTargetBFS { 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; @@ -107,7 +104,6 @@ export class MultiSourceAnyTargetBFS { 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) { @@ -115,7 +111,6 @@ export class MultiSourceAnyTargetBFS { 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) { @@ -123,7 +118,6 @@ export class MultiSourceAnyTargetBFS { 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) { @@ -131,7 +125,6 @@ export class MultiSourceAnyTargetBFS { if (gm.isWater(ev) && this.visitedStamp[ev] !== stamp) { this.visit(ev, node, stamp); this.queue[tail++] = ev; - if (++visitedCount > maxVisited) return null; } } @@ -147,7 +140,6 @@ export class MultiSourceAnyTargetBFS { ) { this.visit(nw, node, stamp); this.queue[tail++] = nw; - if (++visitedCount > maxVisited) return null; } } if (node >= w && x !== w - 1) { @@ -159,7 +151,6 @@ export class MultiSourceAnyTargetBFS { ) { this.visit(ne, node, stamp); this.queue[tail++] = ne; - if (++visitedCount > maxVisited) return null; } } if (node < lastRowStart && x !== 0) { @@ -171,7 +162,6 @@ export class MultiSourceAnyTargetBFS { ) { this.visit(sw, node, stamp); this.queue[tail++] = sw; - if (++visitedCount > maxVisited) return null; } } if (node < lastRowStart && x !== w - 1) { @@ -183,7 +173,6 @@ export class MultiSourceAnyTargetBFS { ) { this.visit(se, node, stamp); this.queue[tail++] = se; - if (++visitedCount > maxVisited) return null; } } } diff --git a/src/core/pathfinding/WaterComponents.ts b/src/core/pathfinding/WaterComponents.ts new file mode 100644 index 000000000..3e1451036 --- /dev/null +++ b/src/core/pathfinding/WaterComponents.ts @@ -0,0 +1,74 @@ +import { GameMap, TileRef } from "../game/GameMap"; + +// Terrain (water/land) is immutable for a game map, so this can be cached forever per instance. +const cache = new WeakMap(); + +export function getWaterComponentIds(gm: GameMap): Uint32Array { + const cached = cache.get(gm); + if (cached) return cached; + + const w = gm.width(); + const h = gm.height(); + const numTiles = w * h; + const ids = new Uint32Array(numTiles); // 0 = not-water/unassigned, 1..N = component id + + let nextId = 0; + const queue = new Int32Array(numTiles); + const lastRowStart = (h - 1) * w; + + for (let start = 0; start < numTiles; start++) { + if (ids[start] !== 0) continue; + if (!gm.isWater(start)) continue; + + nextId++; + ids[start] = nextId; + + let head = 0; + let tail = 0; + queue[tail++] = start; + + while (head < tail) { + const node = queue[head++]!; + const x = node % w; + + if (node >= w) { + const n = node - w; + if (ids[n] === 0 && gm.isWater(n)) { + ids[n] = nextId; + queue[tail++] = n; + } + } + if (node < lastRowStart) { + const s = node + w; + if (ids[s] === 0 && gm.isWater(s)) { + ids[s] = nextId; + queue[tail++] = s; + } + } + if (x !== 0) { + const wv = node - 1; + if (ids[wv] === 0 && gm.isWater(wv)) { + ids[wv] = nextId; + queue[tail++] = wv; + } + } + if (x !== w - 1) { + const ev = node + 1; + if (ids[ev] === 0 && gm.isWater(ev)) { + ids[ev] = nextId; + queue[tail++] = ev; + } + } + } + } + + cache.set(gm, ids); + return ids; +} + +export function getWaterComponentId(gm: GameMap, tile: TileRef): number { + if (!gm.isWater(tile)) return 0; + const ids = getWaterComponentIds(gm); + return ids[tile] ?? 0; +} +