Pathfinding Refactor pt. 2 (#2866)

## Playtest

https://pf-pt-2.openfront.dev/

## Pathfinding Refactor pt. 2

<img width="1536" height="1024" alt="image"
src="https://github.com/user-attachments/assets/9477958e-54b7-4c83-b317-ba789e809e9e"
/>


This is a follow-up to a previous PR introducing pathfinding changes.
This time, it introduces a complete refactor of `pathfinding` directory
and breakdown into composable pieces.

### Unified PathFinder interface

`PathFinder<T>` and `SteppingPathFinder<T>` are introduced to unify
**all** pathfinding across the application. First one exposes complete
path, while stepping variant allows the callee to iterate over the path
by calling `.next`. All pathfinders share this one common interface,
which makes them easy to use in any scenario -
`PathFinding.Water(game).search(from, to)`.

`SteppingPathFinder<T>` extends `PathFinder<T>` with an ability to
iterate over the path. It handles caching, storing current index and
invalidation. This allows the units to not care about the inner workings
of the pathfinder and just call `pf.next(current, target)` and receive
instructions on what to do next.

### Common entry point

All pathfinders are now exposed from common `PathFinding` entrypoint:

- `PathFinding.Water`
- `PathFinding.Rail`
- `PathFinding.Stations`
- `PathFinding.Rail`

Additional entry point is introduced for pathfinders which need to work
both in the worker, but also on the frontend, which lacks `Game`
interface. Currently only `UniversalPathFinding.Parabola` is available.

### Spatial Query

New module has been introduced close to `pathfinding` - `SpatialQuery`.
It aims to resolve any questions game may have about finding tiles
meeting criteria. Currently `SpatialQuery.closestShore(player, target)`
and `SpatialQuery.closestShoreByWater(player, target)` are available -
they help answering questions about naval invasion: "What is the best
landing location from user's click?" and "Which our tile should be used
to launch the transport ship?". Under the hood they use very similar
mechanics to pathfinding, so it felt right to put them close by.

### Modular architecture

Pathfinders now support transformers: `MiniMapTransformer`,
`ShoreCoercingTransformer`, `ComponentCheckTransformer`,
`SmoothingTransformer`. Transformers functions like a middleware in the
pathfinding chain. They wrap around the pathfinder and provide
additional functionality. This allows the pathfinder to focus on
actually finding the path instead of doing unrelated things.

Example chain for simple (A*) water pathfinding:
```ts
static WaterSimple(game: Game): SteppingPathFinder<TileRef> {
  const miniMap = game.miniMap();
  const pf = new AStarWater(miniMap);

  return PathFinderBuilder.create(pf)
    .wrap((pf) => new ShoreCoercingTransformer(pf, miniMap))
    .wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap))
    .buildWithStepper(tileStepperConfig(game));
}
```

The Pathfinder - here `AStarWater` - does not care about the conversion
between minimap and main map tiles. It also does not care if the source
or destination is a land tile. The transformers take care of that. The
pathfinder gets a set of valid coordinates and produces the path -
that's it.

Modular approach makes working on a particular set of utilities much
easier - for example map upscaling is handled consistently across all
pathfinders. Additionally, the pathfinders are not tied to the
particular map resolution used. Pass them a different map and they will
work the same.

### Algorithms

Algorithms used are neatly organized inside
`src/core/pathfinding/algorithms`. They are prefixed with the algorithm
name and suffixed with the use case. File without suffix exposes generic
version ready to traverse any graph with adapters. Specialized versions
either use an adapter or inline logic when performance is critical -
using adapters leads to 20-30% performance loss.

The directory includes `A*` and `BFS` but also other useful utils, such
as `AbstractGraph` used to generate... an abstract graph on top of the
tile map and `ConnectedComponents` helping to identify whether two tiles
are connected by a path without actually computing the path.

### Playground

The playground have been updated with new algorithms, including tweaked
very greedy `A*`.

<img width="2175" height="1424" alt="image"
src="https://github.com/user-attachments/assets/1f833651-0024-4299-bf86-882f5368358c"
/>

### Tests

Yeah, there are some, a little too many if I say so myself. But there
are no useless tests. I had to ensure refactored code works somehow
reliably. This PR comes with trust me bro guarantee, but I would
appreciate someone confirming **naval invasions, nukes (esp. MIRV) and
warships**.

### Discord
`moleole`

GL & HF
This commit is contained in:
Arkadiusz Sygulski
2026-01-12 05:11:14 +01:00
committed by GitHub
parent bcec4ad758
commit 0e3ced3bfa
75 changed files with 6800 additions and 4200 deletions
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env node
/**
* Compare pathfinding adapters side-by-side
*
* Usage:
* npx tsx tests/pathfinding/benchmark/compare.ts <scenario> <adapters>
* npx tsx tests/pathfinding/benchmark/compare.ts --synthetic <map-name> <adapters>
*
* Examples:
* npx tsx tests/pathfinding/benchmark/compare.ts default hpa,a.baseline
* npx tsx tests/pathfinding/benchmark/compare.ts --synthetic giantworldmap hpa,hpa.cached,a.full
*/
import {
type BenchmarkResult,
calculateStats,
getAdapter,
getScenario,
measureExecutionTime,
measurePathLength,
} from "../utils";
interface AdapterResults {
adapter: string;
initTime: number;
totalTime: number;
totalDistance: number;
successfulRoutes: number;
totalRoutes: number;
}
const DEFAULT_ITERATIONS = 1;
async function runBenchmark(
scenarioName: string,
adapterName: string,
): Promise<AdapterResults> {
const { game, routes, initTime } = await getScenario(
scenarioName,
adapterName,
);
const adapter = getAdapter(game, adapterName);
const results: BenchmarkResult[] = [];
// Measure path lengths
for (const route of routes) {
const pathLength = measurePathLength(adapter, route);
results.push({ route: route.name, pathLength, executionTime: null });
}
// Measure execution times
for (const route of routes) {
const result = results.find((r) => r.route === route.name);
if (result && result.pathLength !== null) {
const execTime = measureExecutionTime(adapter, route, DEFAULT_ITERATIONS);
result.executionTime = execTime;
}
}
const stats = calculateStats(results);
return {
adapter: adapterName,
initTime,
totalTime: stats.totalTime,
totalDistance: stats.totalDistance,
successfulRoutes: stats.successfulRoutes,
totalRoutes: stats.totalRoutes,
};
}
const TABLE_HEADERS = [
"Adapter",
"Init (ms)",
"Path (ms)",
"Distance",
"Routes",
];
const TABLE_WIDTHS = [20, 12, 12, 12, 10];
function printTableHeader(scenarioName: string) {
console.log(`\nResults: ${scenarioName}`);
console.log("=".repeat(70));
console.log(TABLE_HEADERS.map((h, i) => h.padEnd(TABLE_WIDTHS[i])).join(" "));
console.log("-".repeat(70));
}
function printTableRow(r: AdapterResults) {
const row = [
r.adapter,
r.initTime.toFixed(2),
r.totalTime.toFixed(2),
r.totalDistance.toString(),
`${r.successfulRoutes}/${r.totalRoutes}`,
];
console.log(row.map((c, i) => c.padEnd(TABLE_WIDTHS[i])).join(" "));
}
function printTableFooter() {
console.log("-".repeat(70));
}
function printUsage() {
console.log(`
Usage:
npx tsx tests/pathfinding/benchmark/compare.ts <scenario> <adapters>
npx tsx tests/pathfinding/benchmark/compare.ts --synthetic <map-name> <adapters>
Arguments:
<scenario> Name of the scenario (default: "default")
<adapters> Comma-separated list of adapters to compare (e.g., "hpa,a.baseline")
Examples:
npx tsx tests/pathfinding/benchmark/compare.ts default hpa,a.baseline
npx tsx tests/pathfinding/benchmark/compare.ts --synthetic giantworldmap hpa,hpa.cached,a.full
Available adapters:
a.baseline - A* on minimap (inlined)
a.generic - A* on minimap (adapter)
a.full - A* on full map
hpa - Hierarchical pathfinding (no cache)
hpa.cached - Hierarchical pathfinding (with cache)
`);
}
async function main() {
const args = process.argv.slice(2);
if (args.includes("--help") || args.includes("-h")) {
printUsage();
process.exit(0);
}
const isSynthetic = args.includes("--synthetic");
const nonFlagArgs = args.filter((arg) => !arg.startsWith("--"));
if (nonFlagArgs.length < 2) {
console.error("Error: requires <scenario> and <adapters> arguments");
printUsage();
process.exit(1);
}
const scenarioArg = nonFlagArgs[0];
const adaptersArg = nonFlagArgs[1];
const adapters = adaptersArg.split(",").map((a) => a.trim());
if (adapters.length < 1) {
console.error("Error: at least one adapter required");
process.exit(1);
}
const scenarioName = isSynthetic ? `synthetic/${scenarioArg}` : scenarioArg;
console.log(
`Comparing ${adapters.length} adapters on scenario: ${scenarioName}`,
);
console.log(`Adapters: ${adapters.join(", ")}`);
console.log("");
printTableHeader(scenarioName);
for (const adapter of adapters) {
try {
const result = await runBenchmark(scenarioName, adapter);
printTableRow(result);
} catch (error) {
console.log(`${adapter.padEnd(TABLE_WIDTHS[0])} FAILED: ${error}`);
}
}
printTableFooter();
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
+54 -60
View File
@@ -2,10 +2,13 @@ import { readdirSync, readFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { Game } from "../../../../src/core/game/Game.js";
import { TileRef } from "../../../../src/core/game/GameMap.js";
import { NavMesh } from "../../../../src/core/pathfinding/navmesh/NavMesh.js";
import { AStarWaterHierarchical } from "../../../../src/core/pathfinding/algorithms/AStar.WaterHierarchical.js";
import { setupFromPath } from "../../utils.js";
// Available comparison adapters
// Note: "hpa" runs same algorithm without debug overhead for fair timing comparison
export const COMPARISON_ADAPTERS = ["hpa", "a.baseline", "a.generic", "a.full"];
export interface MapInfo {
name: string;
displayName: string;
@@ -13,7 +16,7 @@ export interface MapInfo {
export interface MapCache {
game: Game;
navMesh: NavMesh;
hpaStar: AStarWaterHierarchical;
}
const cache = new Map<string, MapCache>();
@@ -114,13 +117,20 @@ export async function loadMap(mapName: string): Promise<MapCache> {
const mapsDir = getMapsDirectory();
// Use the existing setupFromPath utility to load the map
const game = await setupFromPath(mapsDir, mapName);
const game = await setupFromPath(mapsDir, mapName, { disableNavMesh: false });
// Initialize NavMesh
const navMesh = new NavMesh(game, { cachePaths: config.cachePaths });
navMesh.initialize();
// Get pre-built graph from game
const graph = game.miniWaterGraph();
if (!graph) {
throw new Error(`No water graph available for map: ${mapName}`);
}
const cacheEntry: MapCache = { game, navMesh };
// Initialize AStarWaterHierarchical with minimap and graph
const hpaStar = new AStarWaterHierarchical(game.miniMap(), graph, {
cachePaths: config.cachePaths,
});
const cacheEntry: MapCache = { game, hpaStar };
// Store in cache
cache.set(mapName, cacheEntry);
@@ -132,7 +142,7 @@ export async function loadMap(mapName: string): Promise<MapCache> {
* Get map metadata for client
*/
export async function getMapMetadata(mapName: string) {
const { game, navMesh } = await loadMap(mapName);
const { game, hpaStar } = await loadMap(mapName);
// Extract map data
const mapData: number[] = [];
@@ -143,65 +153,48 @@ export async function getMapMetadata(mapName: string) {
}
}
// Extract static graph data from NavMesh
// Extract static graph data from GameMapHPAStar
// Access internal graph via type casting (test code only)
const graph = (hpaStar as any).graph;
const miniMap = game.miniMap();
const navMeshGraph = (navMesh as any).graph;
// Convert gateways from Map to array
const gatewaysArray = Array.from(navMeshGraph.gateways.values());
const allGateways = gatewaysArray.map((gw: any) => ({
id: gw.id,
x: miniMap.x(gw.tile),
y: miniMap.y(gw.tile),
// Convert nodes to client format
const allNodes = graph.getAllNodes().map((node: any) => ({
id: node.id,
x: miniMap.x(node.tile),
y: miniMap.y(node.tile),
}));
// Create a lookup map from gateway ID to gateway for edge conversion
const gatewayById = new Map(gatewaysArray.map((gw: any) => [gw.id, gw]));
// Convert edges to client format
const edges: Array<{
fromId: number;
toId: number;
from: number[];
to: number[];
cost: number;
}> = [];
for (let i = 0; i < graph.edgeCount; i++) {
const edge = graph.getEdge(i);
if (!edge) continue;
// Convert edges from Map<gatewayId, Edge[]> to flat array
// The edges Map has gateway IDs as keys, and arrays of edges as values
const allEdges: any[] = [];
for (const edgeArray of navMeshGraph.edges.values()) {
allEdges.push(...edgeArray);
const nodeA = graph.getNode(edge.nodeA);
const nodeB = graph.getNode(edge.nodeB);
if (!nodeA || !nodeB) continue;
edges.push({
fromId: edge.nodeA,
toId: edge.nodeB,
from: [miniMap.x(nodeA.tile) * 2, miniMap.y(nodeA.tile) * 2],
to: [miniMap.x(nodeB.tile) * 2, miniMap.y(nodeB.tile) * 2],
cost: edge.cost,
});
}
// Deduplicate edges (they're bidirectional, so each edge appears twice)
const seenEdges = new Set<string>();
const edges = allEdges
.filter((edge: any) => {
const edgeKey =
edge.from < edge.to
? `${edge.from}-${edge.to}`
: `${edge.to}-${edge.from}`;
if (seenEdges.has(edgeKey)) return false;
seenEdges.add(edgeKey);
return true;
})
.map((edge: any) => {
const fromGateway = gatewayById.get(edge.from);
const toGateway = gatewayById.get(edge.to);
return {
fromId: edge.from,
toId: edge.to,
from: fromGateway
? [miniMap.x(fromGateway.tile) * 2, miniMap.y(fromGateway.tile) * 2]
: [0, 0],
to: toGateway
? [miniMap.x(toGateway.tile) * 2, miniMap.y(toGateway.tile) * 2]
: [0, 0],
cost: edge.cost,
path: edge.path
? edge.path.map((tile: TileRef) => [game.x(tile), game.y(tile)])
: null,
};
});
console.log(
`Map ${mapName}: ${allGateways.length} gateways, ${edges.length} edges`,
`Map ${mapName}: ${allNodes.length} nodes, ${edges.length} edges`,
);
const sectorSize = navMeshGraph.sectorSize;
const clusterSize = graph.clusterSize;
return {
name: mapName,
@@ -209,10 +202,11 @@ export async function getMapMetadata(mapName: string) {
height: game.height(),
mapData,
graphDebug: {
allGateways,
allNodes,
edges,
sectorSize,
clusterSize,
},
adapters: COMPARISON_ADAPTERS,
};
}
+191 -99
View File
@@ -1,39 +1,64 @@
import { TileRef } from "../../../../src/core/game/GameMap.js";
import { MiniAStarAdapter } from "../../../../src/core/pathfinding/adapters/MiniAStarAdapter.js";
import { loadMap } from "./maps.js";
import { AStarWaterHierarchical } from "../../../../src/core/pathfinding/algorithms/AStar.WaterHierarchical.js";
import { BresenhamSmoothingTransformer } from "../../../../src/core/pathfinding/smoothing/BresenhamPathSmoother.js";
import { ComponentCheckTransformer } from "../../../../src/core/pathfinding/transformers/ComponentCheckTransformer.js";
import { MiniMapTransformer } from "../../../../src/core/pathfinding/transformers/MiniMapTransformer.js";
import { ShoreCoercingTransformer } from "../../../../src/core/pathfinding/transformers/ShoreCoercingTransformer.js";
import {
PathFinder,
SteppingPathFinder,
} from "../../../../src/core/pathfinding/types.js";
import { getAdapter } from "../../utils.js";
import { COMPARISON_ADAPTERS, loadMap } from "./maps.js";
interface PathfindingOptions {
includePfMini?: boolean;
includeNavMesh?: boolean;
}
interface NavMeshResult {
// Primary result with debug info
interface PrimaryResult {
path: Array<[number, number]> | null;
initialPath: Array<[number, number]> | null;
gateways: Array<[number, number]> | null;
timings: any;
length: number;
time: number;
debug: {
nodePath: Array<[number, number]> | null;
initialPath: Array<[number, number]> | null;
timings: Record<string, number>;
};
}
interface PfMiniResult {
// Comparison result (path + timing only)
interface ComparisonResult {
adapter: string;
path: Array<[number, number]> | null;
length: number;
time: number;
}
// Cache pathfinding adapters per map
const pfMiniCache = new Map<string, MiniAStarAdapter>();
export interface PathfindResult {
primary: PrimaryResult;
comparisons: ComparisonResult[];
}
// Cache adapters per map
const adapterCache = new Map<
string,
Map<string, SteppingPathFinder<TileRef>>
>();
/**
* Get or create MiniAStar adapter for a map
* Get or create an adapter for a map
*/
function getPfMiniAdapter(mapName: string, game: any): MiniAStarAdapter {
if (!pfMiniCache.has(mapName)) {
const adapter = new MiniAStarAdapter(game, { waterPath: true });
pfMiniCache.set(mapName, adapter);
function getOrCreateAdapter(
mapName: string,
adapterName: string,
game: any,
): SteppingPathFinder<TileRef> {
if (!adapterCache.has(mapName)) {
adapterCache.set(mapName, new Map());
}
return pfMiniCache.get(mapName)!;
const mapAdapters = adapterCache.get(mapName)!;
if (!mapAdapters.has(adapterName)) {
mapAdapters.set(adapterName, getAdapter(game, adapterName));
}
return mapAdapters.get(adapterName)!;
}
/**
@@ -48,110 +73,177 @@ function pathToCoords(
}
/**
* Compute pathfinding between two points
* Build the full transformer chain like PathFinding.Water() does
*/
export async function computePath(
mapName: string,
from: [number, number],
to: [number, number],
options: PathfindingOptions = {},
): Promise<NavMeshResult> {
const { game, navMesh: navMeshAdapter } = await loadMap(mapName);
function buildWrappedPathFinder(
hpaStar: AStarWaterHierarchical,
game: any,
graph: any,
): PathFinder<TileRef> {
const miniMap = game.miniMap();
const componentCheckFn = (t: TileRef) => graph.getComponentId(t);
// Convert coordinates to TileRefs
const fromRef = game.ref(from[0], from[1]);
const toRef = game.ref(to[0], to[1]);
// Chain: hpaStar -> ComponentCheck -> Bresenham -> ShoreCoercing -> MiniMap
const withComponentCheck = new ComponentCheckTransformer(
hpaStar,
componentCheckFn,
);
const withSmoothing = new BresenhamSmoothingTransformer(
withComponentCheck,
miniMap,
);
const withShoreCoercing = new ShoreCoercingTransformer(
withSmoothing,
miniMap,
);
const withMiniMap = new MiniMapTransformer(withShoreCoercing, game, miniMap);
// Validate that both points are water tiles
if (!game.isWater(fromRef)) {
throw new Error(`Start point (${from[0]}, ${from[1]}) is not water`);
}
if (!game.isWater(toRef)) {
throw new Error(`End point (${to[0]}, ${to[1]}) is not water`);
}
// Compute NavMesh path
const navMeshPath = navMeshAdapter.findPath(fromRef, toRef, true);
const path = pathToCoords(navMeshPath, game);
return withMiniMap;
}
/**
* Compute primary path using AStarWaterHierarchical with debug info
* Uses the same transformer chain as PathFinding.Water()
*/
function computePrimaryPath(
hpaStar: AStarWaterHierarchical,
game: any,
graph: any,
fromRef: TileRef,
toRef: TileRef,
): PrimaryResult {
const miniMap = game.miniMap();
// Extract debug info
let gateways: Array<[number, number]> | null = null;
// Build wrapped pathfinder with all transformers
const wrappedPf = buildWrappedPathFinder(hpaStar, game, graph);
// Enable debug mode to capture internal state
hpaStar.debugMode = true;
const start = performance.now();
const path = wrappedPf.findPath(fromRef, toRef);
const time = performance.now() - start;
const debugInfo = hpaStar.debugInfo;
// Convert node path (miniMap coords) to full map coords
let nodePath: Array<[number, number]> | null = null;
if (debugInfo?.nodePath) {
nodePath = debugInfo.nodePath.map((tile: TileRef) => {
const x = miniMap.x(tile) * 2;
const y = miniMap.y(tile) * 2;
return [x, y] as [number, number];
});
}
// Convert initialPath (miniMap TileRefs) to full map coords
let initialPath: Array<[number, number]> | null = null;
let timings: any = {};
if (navMeshAdapter.debugInfo) {
// Convert gatewayPath (TileRefs on miniMap) to full map coordinates
if (navMeshAdapter.debugInfo.gatewayPath) {
gateways = navMeshAdapter.debugInfo.gatewayPath.map((tile: TileRef) => {
const x = miniMap.x(tile) * 2;
const y = miniMap.y(tile) * 2;
return [x, y] as [number, number];
});
}
// Convert initial path
if (navMeshAdapter.debugInfo.initialPath) {
initialPath = navMeshAdapter.debugInfo.initialPath.map(
(tile: TileRef) => [game.x(tile), game.y(tile)] as [number, number],
);
}
timings = navMeshAdapter.debugInfo.timings || {};
if (debugInfo?.initialPath) {
initialPath = debugInfo.initialPath.map((tile: TileRef) => {
const x = miniMap.x(tile) * 2;
const y = miniMap.y(tile) * 2;
return [x, y] as [number, number];
});
}
return {
path,
initialPath,
gateways,
timings,
path: pathToCoords(path, game),
length: path ? path.length : 0,
time: timings.total ?? 0,
time,
debug: {
nodePath,
initialPath,
timings: debugInfo?.timings ?? {},
},
};
}
/**
* Compute only PathFinder.Mini path
* Compute comparison path using adapter
*/
export async function computePfMiniPath(
mapName: string,
from: [number, number],
to: [number, number],
): Promise<PfMiniResult> {
const { game } = await loadMap(mapName);
// Convert coordinates to TileRefs
const fromRef = game.ref(from[0], from[1]);
const toRef = game.ref(to[0], to[1]);
// Validate that both points are water tiles
if (!game.isWater(fromRef)) {
throw new Error(`Start point (${from[0]}, ${from[1]}) is not water`);
}
if (!game.isWater(toRef)) {
throw new Error(`End point (${to[0]}, ${to[1]}) is not water`);
}
// Compute PathFinder.Mini path
const pfMiniAdapter = getPfMiniAdapter(mapName, game);
const pfMiniStart = performance.now();
const pfMiniPath = pfMiniAdapter.findPath(fromRef, toRef);
const pfMiniEnd = performance.now();
const path = pathToCoords(pfMiniPath, game);
const time = pfMiniEnd - pfMiniStart;
function computeComparisonPath(
adapter: SteppingPathFinder<TileRef>,
game: any,
fromRef: TileRef,
toRef: TileRef,
adapterName: string,
): ComparisonResult {
const start = performance.now();
const path = adapter.findPath(fromRef, toRef);
const time = performance.now() - start;
return {
path,
adapter: adapterName,
path: pathToCoords(path, game),
length: path ? path.length : 0,
time,
};
}
/**
* Compute pathfinding between two points
*/
export async function computePath(
mapName: string,
from: [number, number],
to: [number, number],
options: { adapters?: string[] } = {},
): Promise<PathfindResult> {
const { game, hpaStar } = await loadMap(mapName);
const graph = game.miniWaterGraph();
// Convert coordinates to TileRefs
const fromRef = game.ref(from[0], from[1]);
const toRef = game.ref(to[0], to[1]);
// Validate that both points are water tiles
if (!game.isWater(fromRef)) {
throw new Error(`Start point (${from[0]}, ${from[1]}) is not water`);
}
if (!game.isWater(toRef)) {
throw new Error(`End point (${to[0]}, ${to[1]}) is not water`);
}
// Compute primary path (HPA* with debug)
const primary = computePrimaryPath(hpaStar, game, graph, fromRef, toRef);
// Compute comparison paths
const selectedAdapters = options.adapters ?? COMPARISON_ADAPTERS;
const comparisons: ComparisonResult[] = [];
for (const adapterName of selectedAdapters) {
if (!COMPARISON_ADAPTERS.includes(adapterName)) {
console.warn(`Unknown adapter: ${adapterName}, skipping`);
continue;
}
try {
const adapter = getOrCreateAdapter(mapName, adapterName, game);
const result = computeComparisonPath(
adapter,
game,
fromRef,
toRef,
adapterName,
);
comparisons.push(result);
} catch (error) {
console.error(`Error with adapter ${adapterName}:`, error);
comparisons.push({
adapter: adapterName,
path: null,
length: 0,
time: 0,
});
}
}
return { primary, comparisons };
}
/**
* Clear pathfinding adapter caches
*/
export function clearAdapterCaches() {
pfMiniCache.clear();
adapterCache.clear();
}
File diff suppressed because it is too large Load Diff
+21 -58
View File
@@ -129,13 +129,13 @@
<button class="toggle-button" id="showInitialPath" data-active="false">
Initial Path
</button>
<button class="toggle-button" id="showUsedGateways" data-active="false">
Used Gateways
<button class="toggle-button" id="showUsedNodes" data-active="false">
Used Nodes
</button>
</div>
<div class="debug-panel-row">
<button class="toggle-button" id="showGateways" data-active="false">
Gateways
<button class="toggle-button" id="showNodes" data-active="false">
Nodes
</button>
<button class="toggle-button" id="showSectorGrid" data-active="false">
Sectors
@@ -166,75 +166,42 @@
<div class="timing-label">
<button
class="refresh-icon"
id="refreshNavMesh"
title="Recompute NavMesh path"
id="refreshHpa"
title="Recompute HPA* path"
>
<span></span>
</button>
NavMesh <span class="timing-label-detail" id="navMeshTiles"></span>
HPA* <span class="timing-label-detail" id="hpaTiles"></span>
</div>
<div class="timing-value-large" id="navMeshTime"></div>
<div class="timing-value-large" id="hpaTime"></div>
<div class="timing-breakdown" id="timingBreakdown">
<div class="timing-item" id="timingEarlyExit" style="display: none">
<span class="timing-name">Early Exit:</span>
<span class="timing-value" id="timingEarlyExitValue"></span>
</div>
<div class="timing-item" id="timingFindNodes" style="display: none">
<span class="timing-name">Find Nodes:</span>
<span class="timing-value" id="timingFindNodesValue"></span>
</div>
<div
class="timing-item"
id="timingFindGateways"
id="timingAbstractPath"
style="display: none"
>
<span class="timing-name">Find Gateways:</span>
<span class="timing-value" id="timingFindGatewaysValue"></span>
</div>
<div class="timing-item" id="timingGatewayPath" style="display: none">
<span class="timing-name">Gateway Path:</span>
<span class="timing-value" id="timingGatewayPathValue"></span>
<span class="timing-name">Abstract Path:</span>
<span class="timing-value" id="timingAbstractPathValue"></span>
</div>
<div class="timing-item" id="timingInitialPath" style="display: none">
<span class="timing-name">Initial Path:</span>
<span class="timing-value" id="timingInitialPathValue"></span>
</div>
<div class="timing-item" id="timingSmoothPath" style="display: none">
<span class="timing-name">Smooth Path:</span>
<span class="timing-value" id="timingSmoothPathValue"></span>
</div>
</div>
</div>
<div class="timing-section" id="pfMiniRequestSection">
<button
id="requestPfMini"
class="timing-button"
title="PathFinder.Mini is slow (50-1800ms per path). Click to compare."
disabled
>
Request PathFinder.Mini
</button>
</div>
<div
class="timing-section"
id="pfMiniTimingSection"
style="display: none"
>
<div class="timing-label">
<button
class="refresh-icon"
id="refreshPfMini"
title="Recompute PF.Mini path"
>
<span></span>
</button>
PF.Mini <span class="timing-label-detail" id="pfMiniTiles"></span>
</div>
<div class="timing-value-large" id="pfMiniTime"></div>
</div>
<div class="timing-section" id="speedupSection" style="display: none">
<div class="timing-label">Speedup</div>
<div class="timing-value-speedup" id="speedupValue"></div>
<div class="timing-section" id="comparisonsSection" style="display: none">
<div class="timing-label">Comparisons</div>
<div id="comparisonsContainer"></div>
</div>
</div>
@@ -256,13 +223,9 @@
></div>
<span>End Point</span>
</div>
<div class="legend-item" id="pfMiniLegend" style="display: none">
<div class="legend-color" style="background: #ffaa00"></div>
<span>PathFinder.Mini</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #00ffff"></div>
<span>NavMesh</span>
<span>HPA*</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #ff00ff"></div>
@@ -273,7 +236,7 @@
class="legend-color"
style="background: #ffff00; height: 8px"
></div>
<span>Used Gateways</span>
<span>Used Nodes</span>
</div>
<div class="legend-item">
<div
@@ -285,7 +248,7 @@
border-radius: 50%;
"
></div>
<span>Gateways</span>
<span>Nodes</span>
</div>
<div class="legend-item">
<div
@@ -500,6 +500,67 @@ canvas {
font-size: 20px;
}
/* Comparison rows */
.comparison-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px;
margin: 0 -8px;
font-size: 14px;
border-bottom: 1px solid #333;
cursor: pointer;
border-radius: 4px;
transition: background 0.15s;
}
.comparison-row:hover {
background: rgba(255, 255, 255, 0.1);
}
.comparison-row.active {
background: rgba(255, 255, 255, 0.15);
}
.comparison-row:last-child {
border-bottom: none;
}
.comp-color {
width: 12px;
height: 12px;
border-radius: 2px;
margin-right: 8px;
flex-shrink: 0;
opacity: 0.4;
transition: opacity 0.15s;
}
.comparison-row.active .comp-color {
opacity: 1;
}
.comp-name {
color: #aaa;
flex: 1;
font-family: monospace;
}
.comp-tiles {
font-family: monospace;
color: #888;
width: 50px;
text-align: right;
margin-right: 10px;
}
.comp-time {
font-family: monospace;
color: #f5f5f5;
width: 60px;
text-align: right;
}
/* Legend panel (right side) */
.legend-panel {
position: fixed;
+10 -68
View File
@@ -8,11 +8,7 @@ import {
listMaps,
setConfig,
} from "./api/maps.js";
import {
clearAdapterCaches,
computePath,
computePfMiniPath,
} from "./api/pathfinding.js";
import { clearAdapterCaches, computePath } from "./api/pathfinding.js";
// Parse command-line arguments
const args = process.argv.slice(2);
@@ -112,12 +108,18 @@ app.get("/api/maps/:name/thumbnail", (req: Request, res: Response) => {
* map: string,
* from: [x, y],
* to: [x, y],
* includePfMini?: boolean
* adapters?: string[] // Optional: which comparison adapters to run
* }
*
* Response:
* {
* primary: { path, length, time, debug: { nodePath, initialPath, timings } },
* comparisons: [{ adapter, path, length, time }, ...]
* }
*/
app.post("/api/pathfind", async (req: Request, res: Response) => {
try {
const { map, from, to, includePfMini } = req.body;
const { map, from, to, adapters } = req.body;
// Validate request
if (!map || !from || !to) {
@@ -144,7 +146,7 @@ app.post("/api/pathfind", async (req: Request, res: Response) => {
map,
from as [number, number],
to as [number, number],
{ includePfMini: !!includePfMini },
{ adapters },
);
res.json(result);
@@ -165,66 +167,6 @@ app.post("/api/pathfind", async (req: Request, res: Response) => {
}
});
/**
* POST /api/pathfind-pfmini
* Compute only PathFinder.Mini path
*
* Request body:
* {
* map: string,
* from: [x, y],
* to: [x, y]
* }
*/
app.post("/api/pathfind-pfmini", async (req: Request, res: Response) => {
try {
const { map, from, to } = req.body;
// Validate request
if (!map || !from || !to) {
return res.status(400).json({
error: "Invalid request",
message: "Missing required fields: map, from, to",
});
}
if (
!Array.isArray(from) ||
from.length !== 2 ||
!Array.isArray(to) ||
to.length !== 2
) {
return res.status(400).json({
error: "Invalid coordinates",
message: "from and to must be [x, y] coordinate arrays",
});
}
// Compute PF.Mini path only
const result = await computePfMiniPath(
map,
from as [number, number],
to as [number, number],
);
res.json(result);
} catch (error) {
console.error("Error computing PF.Mini path:", error);
if (error instanceof Error && error.message.includes("is not water")) {
res.status(400).json({
error: "Invalid coordinates",
message: error.message,
});
} else {
res.status(500).json({
error: "Failed to compute PF.Mini path",
message: error instanceof Error ? error.message : String(error),
});
}
}
});
/**
* POST /api/cache/clear
* Clear all caches (useful for development)
+58 -16
View File
@@ -17,10 +17,19 @@ import {
MapManifest,
} from "../../src/core/game/TerrainMapLoader";
import { UserSettings } from "../../src/core/game/UserSettings";
import { NavMesh } from "../../src/core/pathfinding/navmesh/NavMesh";
import { PathFinder, PathFinders } from "../../src/core/pathfinding/PathFinder";
import { AStarWater } from "../../src/core/pathfinding/algorithms/AStar.Water";
import { AStarWaterHierarchical } from "../../src/core/pathfinding/algorithms/AStar.WaterHierarchical";
import { PathFinding } from "../../src/core/pathfinding/PathFinder";
import { PathFinderBuilder } from "../../src/core/pathfinding/PathFinderBuilder";
import { StepperConfig } from "../../src/core/pathfinding/PathFinderStepper";
import { MiniMapTransformer } from "../../src/core/pathfinding/transformers/MiniMapTransformer";
import {
PathStatus,
SteppingPathFinder,
} from "../../src/core/pathfinding/types";
import { GameConfig } from "../../src/core/Schemas";
import { TestConfig } from "../util/TestConfig";
export type BenchmarkRoute = {
name: string;
from: TileRef;
@@ -42,25 +51,58 @@ export type BenchmarkSummary = {
avgTime: number;
};
export function getAdapter(game: Game, name: string): PathFinder {
function tileStepperConfig(game: Game): StepperConfig<TileRef> {
return {
equals: (a, b) => a === b,
distance: (a, b) => game.manhattanDist(a, b),
preCheck: (from, to) =>
typeof from !== "number" ||
typeof to !== "number" ||
!game.isValidRef(from) ||
!game.isValidRef(to)
? { status: PathStatus.NOT_FOUND }
: null,
};
}
export function getAdapter(
game: Game,
name: string,
): SteppingPathFinder<TileRef> {
switch (name) {
case "legacy":
return PathFinders.WaterLegacy(game, {
iterations: 500_000,
maxTries: 50,
});
case "a.baseline": {
return PathFinderBuilder.create(new AStarWater(game.miniMap()))
.wrap((pf) => new MiniMapTransformer(pf, game, game.miniMap()))
.buildWithStepper(tileStepperConfig(game));
}
case "a.generic": {
// Same as baseline - uses AStarWater on minimap
return PathFinderBuilder.create(new AStarWater(game.miniMap()))
.wrap((pf) => new MiniMapTransformer(pf, game, game.miniMap()))
.buildWithStepper(tileStepperConfig(game));
}
case "a.full": {
return PathFinderBuilder.create(
new AStarWater(game.map()),
).buildWithStepper(tileStepperConfig(game));
}
case "hpa": {
// Recreate NavMesh without cache, this approach was chosen
// Recreate AStarWaterHierarchical without cache, this approach was chosen
// over adding cache toggles to the existing game instance
// to avoid adding side effect from benchmark to the game
const navMesh = new NavMesh(game, { cachePaths: false });
navMesh.initialize();
(game as any)._navMesh = navMesh;
const graph = game.miniWaterGraph();
if (!graph) {
throw new Error("miniWaterGraph not available");
}
const hpa = new AStarWaterHierarchical(game.miniMap(), graph, {
cachePaths: false,
});
(game as any)._miniWaterHPA = hpa;
return PathFinders.Water(game);
return PathFinding.Water(game);
}
case "hpa.cached":
return PathFinders.Water(game);
return PathFinding.Water(game);
default:
throw new Error(`Unknown pathfinding adapter: ${name}`);
}
@@ -102,7 +144,7 @@ export async function getScenario(
}
export function measurePathLength(
adapter: PathFinder,
adapter: SteppingPathFinder<TileRef>,
route: BenchmarkRoute,
): number | null {
const path = adapter.findPath(route.from, route.to);
@@ -117,7 +159,7 @@ export function measureTime<T>(fn: () => T): { result: T; time: number } {
}
export function measureExecutionTime(
adapter: PathFinder,
adapter: SteppingPathFinder<TileRef>,
route: BenchmarkRoute,
executions: number = 1,
): number | null {