Pathfinding Refinement (#2878)

# Pathfinding pt. 3

## Description:

This PR introduces final change to the pathfinding - path refinement. It
optimizes Line of Sight refinement by searching with for the best tile
with a binary search instead of linearly. And then spends the recovered
budget on better refinement of the first and last 50 tiles of the
journey - the place where user is most likely to look at. Additionally
this PR re-introduces magnitude check and makes the ships prefer sailing
close to the coast, but not too close.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## What?

| Before | After |
| :--- | :--- |
| <img width="1097" height="1117" alt="image"
src="https://github.com/user-attachments/assets/4a0b300d-10ef-4151-b6dc-33acfb49f992"
/> | <img width="1093" height="1119" alt="image"
src="https://github.com/user-attachments/assets/cf81c515-c145-40f4-91e5-a4353986907b"
/> |
| <img width="1096" height="1129" alt="image"
src="https://github.com/user-attachments/assets/21b46bce-f961-4259-88f6-fe4a66180270"
/> | <img width="1098" height="1126" alt="image"
src="https://github.com/user-attachments/assets/d92587d1-e6b6-4353-b4a4-1efe71bca43d"
/> |

## Performance

There is actually a severe performance impact of these changes. The path
initial path takes almost 2x as long to generate - this is because pre
processing can only do so much if the initial path is ugly. Luckily in
real gameplay we only need to do this calculation once per edge, so the
actual observed performance impact should be much smaller. Cache FTW.

| | No Cache | Cache |
| :--- | :--- | :--- |
| Before | 277.04ms | 208.58ms |
| After | 498.34ms | 264.27ms |

## DebugSpan

Small utility, it allows any code to be easily instrumented for
performance. The idea is the same as with [OTEL
Spans](https://opentelemetry.io/docs/concepts/signals/traces/). Produce
a span, create sub-spans, measure whatever you need. Works only when
`globalThis.__DEBUG_SPAN_ENABLED__ === true`, otherwise no-op.

Cool stuff, try it out:
```ts
// Convenient wrapper, small performance impact
return DebugSpan.wrap('add', () => a + b)

// Synchronous API, basically free
DebugSpan.start('work')
work()
DebugSpan.end()

// Create sub spans
DebugSpan.wrap('complex', () => {
  const aPlusB = DebugSpan.wrap('add', () => a + b)
  DebugSpan.set('additionResult', () => aPlusB)  // Store data
  return aPlusB * c
})

// Access spans, data and timing
const span = DebugSpan.getLast()
const compelxSpan = DebugSpan.getLast('complex')

console.log(complexSpan.duration, complexSpan.data['additionResult'])
```

These are virtually free and can be enabled on-demand **in production**
and available in the devtools. Under the hood devtools integration is
just a wrapper around [Performance
API](https://developer.mozilla.org/en-US/docs/Web/API/Performance_API).
For clarity data keys not prefixed by `$` are omitted from the
integration. Every key prefixed with `$` must be fully JSON
serializable.

<img width="977" height="799" alt="image"
src="https://github.com/user-attachments/assets/b4d43506-1639-4f78-a611-30e61de12a07"
/>
This commit is contained in:
Arkadiusz Sygulski
2026-01-13 12:39:54 -08:00
committed by GitHub
parent 35b7213c5c
commit 85def73bd9
20 changed files with 963 additions and 713 deletions
+2 -2
View File
@@ -11,10 +11,10 @@ import {
import { StationPathFinder } from "./PathFinder.Station"; import { StationPathFinder } from "./PathFinder.Station";
import { PathFinderBuilder } from "./PathFinderBuilder"; import { PathFinderBuilder } from "./PathFinderBuilder";
import { StepperConfig } from "./PathFinderStepper"; import { StepperConfig } from "./PathFinderStepper";
import { BresenhamSmoothingTransformer } from "./smoothing/BresenhamPathSmoother";
import { ComponentCheckTransformer } from "./transformers/ComponentCheckTransformer"; import { ComponentCheckTransformer } from "./transformers/ComponentCheckTransformer";
import { MiniMapTransformer } from "./transformers/MiniMapTransformer"; import { MiniMapTransformer } from "./transformers/MiniMapTransformer";
import { ShoreCoercingTransformer } from "./transformers/ShoreCoercingTransformer"; import { ShoreCoercingTransformer } from "./transformers/ShoreCoercingTransformer";
import { SmoothingWaterTransformer } from "./transformers/SmoothingWaterTransformer";
import { PathStatus, SteppingPathFinder } from "./types"; import { PathStatus, SteppingPathFinder } from "./types";
/** /**
@@ -46,7 +46,7 @@ export class PathFinding {
return PathFinderBuilder.create(pf) return PathFinderBuilder.create(pf)
.wrap((pf) => new ComponentCheckTransformer(pf, componentCheckFn)) .wrap((pf) => new ComponentCheckTransformer(pf, componentCheckFn))
.wrap((pf) => new BresenhamSmoothingTransformer(pf, miniMap)) .wrap((pf) => new SmoothingWaterTransformer(pf, miniMap))
.wrap((pf) => new ShoreCoercingTransformer(pf, miniMap)) .wrap((pf) => new ShoreCoercingTransformer(pf, miniMap))
.wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap)) .wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap))
.buildWithStepper(tileStepperConfig(game)); .buildWithStepper(tileStepperConfig(game));
+9 -1
View File
@@ -1,3 +1,4 @@
import { DebugSpan } from "../utilities/DebugSpan";
import { PathFinderStepper, StepperConfig } from "./PathFinderStepper"; import { PathFinderStepper, StepperConfig } from "./PathFinderStepper";
import { PathFinder, SteppingPathFinder } from "./types"; import { PathFinder, SteppingPathFinder } from "./types";
@@ -27,10 +28,17 @@ export class PathFinderBuilder<T> {
} }
build(): PathFinder<T> { build(): PathFinder<T> {
return this.wrappers.reduce( const pathFinder = this.wrappers.reduce(
(pf, wrapper) => wrapper(pf), (pf, wrapper) => wrapper(pf),
this.core as PathFinder<T>, this.core as PathFinder<T>,
); );
const _findPath = pathFinder.findPath;
pathFinder.findPath = function (from: T | T[], to: T): T[] | null {
return DebugSpan.wrap("findPath", () => _findPath.call(this, from, to));
};
return pathFinder;
} }
/** /**
@@ -1,4 +1,5 @@
import { GameMap } from "../../game/GameMap"; import { GameMap } from "../../game/GameMap";
import { DebugSpan } from "../../utilities/DebugSpan";
import { PathFinder } from "../types"; import { PathFinder } from "../types";
import { AStar, AStarAdapter } from "./AStar"; import { AStar, AStarAdapter } from "./AStar";
@@ -11,7 +12,9 @@ export class AStarRail implements PathFinder<number> {
} }
findPath(from: number | number[], to: number): number[] | null { findPath(from: number | number[], to: number): number[] | null {
return this.aStar.findPath(from, to); return DebugSpan.wrap("AStar.Rail:findPath", () =>
this.aStar.findPath(from, to),
);
} }
} }
+80 -26
View File
@@ -3,6 +3,16 @@ import { PathFinder } from "../types";
import { BucketQueue, PriorityQueue } from "./PriorityQueue"; import { BucketQueue, PriorityQueue } from "./PriorityQueue";
const LAND_BIT = 7; // Bit 7 in terrain indicates land const LAND_BIT = 7; // Bit 7 in terrain indicates land
const MAGNITUDE_MASK = 0x1f;
const COST_SCALE = 100;
const BASE_COST = 1 * COST_SCALE;
// Prefer magnitude 3-10 (3-10 tiles from shore)
function getMagnitudePenalty(magnitude: number): number {
if (magnitude < 3) return 10 * COST_SCALE; // too close to shore
if (magnitude <= 10) return 0; // sweet spot
return 1 * COST_SCALE; // deep water, slight penalty
}
export interface AStarWaterConfig { export interface AStarWaterConfig {
heuristicWeight?: number; heuristicWeight?: number;
@@ -27,7 +37,7 @@ export class AStarWater implements PathFinder<number> {
this.terrain = (map as any).terrain as Uint8Array; this.terrain = (map as any).terrain as Uint8Array;
this.width = map.width(); this.width = map.width();
this.numNodes = map.width() * map.height(); this.numNodes = map.width() * map.height();
this.heuristicWeight = config?.heuristicWeight ?? 15; this.heuristicWeight = config?.heuristicWeight ?? 5;
this.maxIterations = config?.maxIterations ?? 1_000_000; this.maxIterations = config?.maxIterations ?? 1_000_000;
this.closedStamp = new Uint32Array(this.numNodes); this.closedStamp = new Uint32Array(this.numNodes);
@@ -35,7 +45,10 @@ export class AStarWater implements PathFinder<number> {
this.gScore = new Uint32Array(this.numNodes); this.gScore = new Uint32Array(this.numNodes);
this.cameFrom = new Int32Array(this.numNodes); this.cameFrom = new Int32Array(this.numNodes);
const maxF = this.heuristicWeight * (map.width() + map.height()); // Account for scaled costs + tie-breaker headroom
const maxDim = map.width() + map.height();
const maxF =
(this.heuristicWeight + 1) * BASE_COST * maxDim + COST_SCALE * maxDim;
this.queue = new BucketQueue(maxF); this.queue = new BucketQueue(maxF);
} }
@@ -64,13 +77,32 @@ export class AStarWater implements PathFinder<number> {
queue.clear(); queue.clear();
const starts = Array.isArray(start) ? start : [start]; const starts = Array.isArray(start) ? start : [start];
// For cross-product tie-breaker (prefer diagonal paths)
const s0 = starts[0];
const startX = s0 % width;
const startY = (s0 / width) | 0;
const dxGoal = goalX - startX;
const dyGoal = goalY - startY;
// Normalization factor to keep tie-breaker small (< COST_SCALE)
const crossNorm = Math.max(1, Math.abs(dxGoal) + Math.abs(dyGoal));
// Cross-product tie-breaker: measures deviation from start-goal line
const crossTieBreaker = (nx: number, ny: number): number => {
const dxN = nx - goalX;
const dyN = ny - goalY;
const cross = Math.abs(dxGoal * dyN - dyGoal * dxN);
return Math.floor((cross * (COST_SCALE - 1)) / crossNorm / crossNorm);
};
for (const s of starts) { for (const s of starts) {
gScore[s] = 0; gScore[s] = 0;
gScoreStamp[s] = stamp; gScoreStamp[s] = stamp;
cameFrom[s] = -1; cameFrom[s] = -1;
const sx = s % width; const sx = s % width;
const sy = (s / width) | 0; const sy = (s / width) | 0;
const h = weight * (Math.abs(sx - goalX) + Math.abs(sy - goalY)); const h =
weight * BASE_COST * (Math.abs(sx - goalX) + Math.abs(sy - goalY));
queue.push(s, h); queue.push(s, h);
} }
@@ -91,15 +123,19 @@ export class AStarWater implements PathFinder<number> {
} }
const currentG = gScore[current]; const currentG = gScore[current];
const tentativeG = currentG + 1;
const currentX = current % width; const currentX = current % width;
const currentY = (current / width) | 0;
if (current >= width) { if (current >= width) {
const neighbor = current - width; const neighbor = current - width;
const neighborTerrain = terrain[neighbor];
if ( if (
closedStamp[neighbor] !== stamp && closedStamp[neighbor] !== stamp &&
(neighbor === goal || (terrain[neighbor] & landMask) === 0) (neighbor === goal || (neighborTerrain & landMask) === 0)
) { ) {
const magnitude = neighborTerrain & MAGNITUDE_MASK;
const cost = BASE_COST + getMagnitudePenalty(magnitude);
const tentativeG = currentG + cost;
if ( if (
gScoreStamp[neighbor] !== stamp || gScoreStamp[neighbor] !== stamp ||
tentativeG < gScore[neighbor] tentativeG < gScore[neighbor]
@@ -107,11 +143,12 @@ export class AStarWater implements PathFinder<number> {
cameFrom[neighbor] = current; cameFrom[neighbor] = current;
gScore[neighbor] = tentativeG; gScore[neighbor] = tentativeG;
gScoreStamp[neighbor] = stamp; gScoreStamp[neighbor] = stamp;
const nx = neighbor % width; const ny = currentY - 1;
const ny = (neighbor / width) | 0; const h =
const f = weight *
tentativeG + BASE_COST *
weight * (Math.abs(nx - goalX) + Math.abs(ny - goalY)); (Math.abs(currentX - goalX) + Math.abs(ny - goalY));
const f = tentativeG + h + crossTieBreaker(currentX, ny);
queue.push(neighbor, f); queue.push(neighbor, f);
} }
} }
@@ -119,10 +156,14 @@ export class AStarWater implements PathFinder<number> {
if (current < numNodes - width) { if (current < numNodes - width) {
const neighbor = current + width; const neighbor = current + width;
const neighborTerrain = terrain[neighbor];
if ( if (
closedStamp[neighbor] !== stamp && closedStamp[neighbor] !== stamp &&
(neighbor === goal || (terrain[neighbor] & landMask) === 0) (neighbor === goal || (neighborTerrain & landMask) === 0)
) { ) {
const magnitude = neighborTerrain & MAGNITUDE_MASK;
const cost = BASE_COST + getMagnitudePenalty(magnitude);
const tentativeG = currentG + cost;
if ( if (
gScoreStamp[neighbor] !== stamp || gScoreStamp[neighbor] !== stamp ||
tentativeG < gScore[neighbor] tentativeG < gScore[neighbor]
@@ -130,11 +171,12 @@ export class AStarWater implements PathFinder<number> {
cameFrom[neighbor] = current; cameFrom[neighbor] = current;
gScore[neighbor] = tentativeG; gScore[neighbor] = tentativeG;
gScoreStamp[neighbor] = stamp; gScoreStamp[neighbor] = stamp;
const nx = neighbor % width; const ny = currentY + 1;
const ny = (neighbor / width) | 0; const h =
const f = weight *
tentativeG + BASE_COST *
weight * (Math.abs(nx - goalX) + Math.abs(ny - goalY)); (Math.abs(currentX - goalX) + Math.abs(ny - goalY));
const f = tentativeG + h + crossTieBreaker(currentX, ny);
queue.push(neighbor, f); queue.push(neighbor, f);
} }
} }
@@ -142,10 +184,14 @@ export class AStarWater implements PathFinder<number> {
if (currentX !== 0) { if (currentX !== 0) {
const neighbor = current - 1; const neighbor = current - 1;
const neighborTerrain = terrain[neighbor];
if ( if (
closedStamp[neighbor] !== stamp && closedStamp[neighbor] !== stamp &&
(neighbor === goal || (terrain[neighbor] & landMask) === 0) (neighbor === goal || (neighborTerrain & landMask) === 0)
) { ) {
const magnitude = neighborTerrain & MAGNITUDE_MASK;
const cost = BASE_COST + getMagnitudePenalty(magnitude);
const tentativeG = currentG + cost;
if ( if (
gScoreStamp[neighbor] !== stamp || gScoreStamp[neighbor] !== stamp ||
tentativeG < gScore[neighbor] tentativeG < gScore[neighbor]
@@ -153,10 +199,12 @@ export class AStarWater implements PathFinder<number> {
cameFrom[neighbor] = current; cameFrom[neighbor] = current;
gScore[neighbor] = tentativeG; gScore[neighbor] = tentativeG;
gScoreStamp[neighbor] = stamp; gScoreStamp[neighbor] = stamp;
const ny = (neighbor / width) | 0; const nx = currentX - 1;
const f = const h =
tentativeG + weight *
weight * (Math.abs(currentX - 1 - goalX) + Math.abs(ny - goalY)); BASE_COST *
(Math.abs(nx - goalX) + Math.abs(currentY - goalY));
const f = tentativeG + h + crossTieBreaker(nx, currentY);
queue.push(neighbor, f); queue.push(neighbor, f);
} }
} }
@@ -164,10 +212,14 @@ export class AStarWater implements PathFinder<number> {
if (currentX !== width - 1) { if (currentX !== width - 1) {
const neighbor = current + 1; const neighbor = current + 1;
const neighborTerrain = terrain[neighbor];
if ( if (
closedStamp[neighbor] !== stamp && closedStamp[neighbor] !== stamp &&
(neighbor === goal || (terrain[neighbor] & landMask) === 0) (neighbor === goal || (neighborTerrain & landMask) === 0)
) { ) {
const magnitude = neighborTerrain & MAGNITUDE_MASK;
const cost = BASE_COST + getMagnitudePenalty(magnitude);
const tentativeG = currentG + cost;
if ( if (
gScoreStamp[neighbor] !== stamp || gScoreStamp[neighbor] !== stamp ||
tentativeG < gScore[neighbor] tentativeG < gScore[neighbor]
@@ -175,10 +227,12 @@ export class AStarWater implements PathFinder<number> {
cameFrom[neighbor] = current; cameFrom[neighbor] = current;
gScore[neighbor] = tentativeG; gScore[neighbor] = tentativeG;
gScoreStamp[neighbor] = stamp; gScoreStamp[neighbor] = stamp;
const ny = (neighbor / width) | 0; const nx = currentX + 1;
const f = const h =
tentativeG + weight *
weight * (Math.abs(currentX + 1 - goalX) + Math.abs(ny - goalY)); BASE_COST *
(Math.abs(nx - goalX) + Math.abs(currentY - goalY));
const f = tentativeG + h + crossTieBreaker(nx, currentY);
queue.push(neighbor, f); queue.push(neighbor, f);
} }
} }
@@ -3,6 +3,16 @@ import { PathFinder } from "../types";
import { BucketQueue } from "./PriorityQueue"; import { BucketQueue } from "./PriorityQueue";
const LAND_BIT = 7; const LAND_BIT = 7;
const MAGNITUDE_MASK = 0x1f;
const COST_SCALE = 100;
const BASE_COST = 1 * COST_SCALE;
// Prefer magnitude 3-10 (3-10 tiles from shore)
function getMagnitudePenalty(magnitude: number): number {
if (magnitude < 3) return 3 * COST_SCALE; // too close to shore
if (magnitude <= 10) return 0; // sweet spot
return 1 * COST_SCALE; // deep water, slight penalty
}
export interface BoundedAStarConfig { export interface BoundedAStarConfig {
heuristicWeight?: number; heuristicWeight?: number;
@@ -16,7 +26,7 @@ export interface SearchBounds {
maxY: number; maxY: number;
} }
export class AStarBounded implements PathFinder<number> { export class AStarWaterBounded implements PathFinder<number> {
private stamp = 1; private stamp = 1;
private readonly closedStamp: Uint32Array; private readonly closedStamp: Uint32Array;
@@ -36,7 +46,7 @@ export class AStarBounded implements PathFinder<number> {
) { ) {
this.terrain = (map as any).terrain as Uint8Array; this.terrain = (map as any).terrain as Uint8Array;
this.mapWidth = map.width(); this.mapWidth = map.width();
this.heuristicWeight = config?.heuristicWeight ?? 1; this.heuristicWeight = config?.heuristicWeight ?? 3;
this.maxIterations = config?.maxIterations ?? 100_000; this.maxIterations = config?.maxIterations ?? 100_000;
this.closedStamp = new Uint32Array(maxSearchArea); this.closedStamp = new Uint32Array(maxSearchArea);
@@ -45,7 +55,9 @@ export class AStarBounded implements PathFinder<number> {
this.cameFrom = new Int32Array(maxSearchArea); this.cameFrom = new Int32Array(maxSearchArea);
const maxDim = Math.ceil(Math.sqrt(maxSearchArea)); const maxDim = Math.ceil(Math.sqrt(maxSearchArea));
const maxF = this.heuristicWeight * maxDim * 2; // Account for scaled costs + tie-breaker headroom
const maxF =
(this.heuristicWeight + 1) * BASE_COST * maxDim * 2 + COST_SCALE * maxDim;
this.queue = new BucketQueue(maxF); this.queue = new BucketQueue(maxF);
} }
@@ -133,6 +145,24 @@ export class AStarBounded implements PathFinder<number> {
queue.clear(); queue.clear();
const starts = Array.isArray(start) ? start : [start]; const starts = Array.isArray(start) ? start : [start];
// For cross-product tie-breaker (prefer diagonal paths)
const s0 = starts[0];
const startX = s0 % mapWidth;
const startY = (s0 / mapWidth) | 0;
const dxGoal = goalX - startX;
const dyGoal = goalY - startY;
// Normalization factor to keep tie-breaker small (< COST_SCALE)
const crossNorm = Math.max(1, Math.abs(dxGoal) + Math.abs(dyGoal));
// Cross-product tie-breaker: measures deviation from start-goal line
const crossTieBreaker = (nx: number, ny: number): number => {
const dxN = nx - goalX;
const dyN = ny - goalY;
const cross = Math.abs(dxGoal * dyN - dyGoal * dxN);
return Math.floor((cross * (COST_SCALE - 1)) / crossNorm / crossNorm);
};
for (const s of starts) { for (const s of starts) {
const startLocal = toLocal(s, true); const startLocal = toLocal(s, true);
if (startLocal < 0 || startLocal >= numLocalNodes) { if (startLocal < 0 || startLocal >= numLocalNodes) {
@@ -143,7 +173,8 @@ export class AStarBounded implements PathFinder<number> {
cameFrom[startLocal] = -1; cameFrom[startLocal] = -1;
const sx = s % mapWidth; const sx = s % mapWidth;
const sy = (s / mapWidth) | 0; const sy = (s / mapWidth) | 0;
const h = weight * (Math.abs(sx - goalX) + Math.abs(sy - goalY)); const h =
weight * BASE_COST * (Math.abs(sx - goalX) + Math.abs(sy - goalY));
queue.push(startLocal, h); queue.push(startLocal, h);
} }
@@ -164,7 +195,6 @@ export class AStarBounded implements PathFinder<number> {
} }
const currentG = gScore[currentLocal]; const currentG = gScore[currentLocal];
const tentativeG = currentG + 1;
// Convert to global coords for neighbor calculation // Convert to global coords for neighbor calculation
const current = toGlobal(currentLocal); const current = toGlobal(currentLocal);
@@ -174,10 +204,14 @@ export class AStarBounded implements PathFinder<number> {
if (currentY > minY) { if (currentY > minY) {
const neighbor = current - mapWidth; const neighbor = current - mapWidth;
const neighborLocal = currentLocal - boundsWidth; const neighborLocal = currentLocal - boundsWidth;
const neighborTerrain = terrain[neighbor];
if ( if (
closedStamp[neighborLocal] !== stamp && closedStamp[neighborLocal] !== stamp &&
(neighbor === goal || (terrain[neighbor] & landMask) === 0) (neighbor === goal || (neighborTerrain & landMask) === 0)
) { ) {
const magnitude = neighborTerrain & MAGNITUDE_MASK;
const cost = BASE_COST + getMagnitudePenalty(magnitude);
const tentativeG = currentG + cost;
if ( if (
gScoreStamp[neighborLocal] !== stamp || gScoreStamp[neighborLocal] !== stamp ||
tentativeG < gScore[neighborLocal] tentativeG < gScore[neighborLocal]
@@ -185,10 +219,12 @@ export class AStarBounded implements PathFinder<number> {
cameFrom[neighborLocal] = currentLocal; cameFrom[neighborLocal] = currentLocal;
gScore[neighborLocal] = tentativeG; gScore[neighborLocal] = tentativeG;
gScoreStamp[neighborLocal] = stamp; gScoreStamp[neighborLocal] = stamp;
const f = const ny = currentY - 1;
tentativeG + const h =
weight * weight *
(Math.abs(currentX - goalX) + Math.abs(currentY - 1 - goalY)); BASE_COST *
(Math.abs(currentX - goalX) + Math.abs(ny - goalY));
const f = tentativeG + h + crossTieBreaker(currentX, ny);
queue.push(neighborLocal, f); queue.push(neighborLocal, f);
} }
} }
@@ -197,10 +233,14 @@ export class AStarBounded implements PathFinder<number> {
if (currentY < maxY) { if (currentY < maxY) {
const neighbor = current + mapWidth; const neighbor = current + mapWidth;
const neighborLocal = currentLocal + boundsWidth; const neighborLocal = currentLocal + boundsWidth;
const neighborTerrain = terrain[neighbor];
if ( if (
closedStamp[neighborLocal] !== stamp && closedStamp[neighborLocal] !== stamp &&
(neighbor === goal || (terrain[neighbor] & landMask) === 0) (neighbor === goal || (neighborTerrain & landMask) === 0)
) { ) {
const magnitude = neighborTerrain & MAGNITUDE_MASK;
const cost = BASE_COST + getMagnitudePenalty(magnitude);
const tentativeG = currentG + cost;
if ( if (
gScoreStamp[neighborLocal] !== stamp || gScoreStamp[neighborLocal] !== stamp ||
tentativeG < gScore[neighborLocal] tentativeG < gScore[neighborLocal]
@@ -208,10 +248,12 @@ export class AStarBounded implements PathFinder<number> {
cameFrom[neighborLocal] = currentLocal; cameFrom[neighborLocal] = currentLocal;
gScore[neighborLocal] = tentativeG; gScore[neighborLocal] = tentativeG;
gScoreStamp[neighborLocal] = stamp; gScoreStamp[neighborLocal] = stamp;
const f = const ny = currentY + 1;
tentativeG + const h =
weight * weight *
(Math.abs(currentX - goalX) + Math.abs(currentY + 1 - goalY)); BASE_COST *
(Math.abs(currentX - goalX) + Math.abs(ny - goalY));
const f = tentativeG + h + crossTieBreaker(currentX, ny);
queue.push(neighborLocal, f); queue.push(neighborLocal, f);
} }
} }
@@ -220,10 +262,14 @@ export class AStarBounded implements PathFinder<number> {
if (currentX > minX) { if (currentX > minX) {
const neighbor = current - 1; const neighbor = current - 1;
const neighborLocal = currentLocal - 1; const neighborLocal = currentLocal - 1;
const neighborTerrain = terrain[neighbor];
if ( if (
closedStamp[neighborLocal] !== stamp && closedStamp[neighborLocal] !== stamp &&
(neighbor === goal || (terrain[neighbor] & landMask) === 0) (neighbor === goal || (neighborTerrain & landMask) === 0)
) { ) {
const magnitude = neighborTerrain & MAGNITUDE_MASK;
const cost = BASE_COST + getMagnitudePenalty(magnitude);
const tentativeG = currentG + cost;
if ( if (
gScoreStamp[neighborLocal] !== stamp || gScoreStamp[neighborLocal] !== stamp ||
tentativeG < gScore[neighborLocal] tentativeG < gScore[neighborLocal]
@@ -231,10 +277,12 @@ export class AStarBounded implements PathFinder<number> {
cameFrom[neighborLocal] = currentLocal; cameFrom[neighborLocal] = currentLocal;
gScore[neighborLocal] = tentativeG; gScore[neighborLocal] = tentativeG;
gScoreStamp[neighborLocal] = stamp; gScoreStamp[neighborLocal] = stamp;
const f = const nx = currentX - 1;
tentativeG + const h =
weight * weight *
(Math.abs(currentX - 1 - goalX) + Math.abs(currentY - goalY)); BASE_COST *
(Math.abs(nx - goalX) + Math.abs(currentY - goalY));
const f = tentativeG + h + crossTieBreaker(nx, currentY);
queue.push(neighborLocal, f); queue.push(neighborLocal, f);
} }
} }
@@ -243,10 +291,14 @@ export class AStarBounded implements PathFinder<number> {
if (currentX < maxX) { if (currentX < maxX) {
const neighbor = current + 1; const neighbor = current + 1;
const neighborLocal = currentLocal + 1; const neighborLocal = currentLocal + 1;
const neighborTerrain = terrain[neighbor];
if ( if (
closedStamp[neighborLocal] !== stamp && closedStamp[neighborLocal] !== stamp &&
(neighbor === goal || (terrain[neighbor] & landMask) === 0) (neighbor === goal || (neighborTerrain & landMask) === 0)
) { ) {
const magnitude = neighborTerrain & MAGNITUDE_MASK;
const cost = BASE_COST + getMagnitudePenalty(magnitude);
const tentativeG = currentG + cost;
if ( if (
gScoreStamp[neighborLocal] !== stamp || gScoreStamp[neighborLocal] !== stamp ||
tentativeG < gScore[neighborLocal] tentativeG < gScore[neighborLocal]
@@ -254,10 +306,12 @@ export class AStarBounded implements PathFinder<number> {
cameFrom[neighborLocal] = currentLocal; cameFrom[neighborLocal] = currentLocal;
gScore[neighborLocal] = tentativeG; gScore[neighborLocal] = tentativeG;
gScoreStamp[neighborLocal] = stamp; gScoreStamp[neighborLocal] = stamp;
const f = const nx = currentX + 1;
tentativeG + const h =
weight * weight *
(Math.abs(currentX + 1 - goalX) + Math.abs(currentY - goalY)); BASE_COST *
(Math.abs(nx - goalX) + Math.abs(currentY - goalY));
const f = tentativeG + h + crossTieBreaker(nx, currentY);
queue.push(neighborLocal, f); queue.push(neighborLocal, f);
} }
} }
@@ -1,39 +1,19 @@
import { GameMap, TileRef } from "../../game/GameMap"; import { GameMap, TileRef } from "../../game/GameMap";
import { DebugSpan } from "../../utilities/DebugSpan";
import { PathFinder } from "../types"; import { PathFinder } from "../types";
import { AbstractGraphAStar } from "./AStar.AbstractGraph"; import { AbstractGraphAStar } from "./AStar.AbstractGraph";
import { AStarBounded } from "./AStar.Bounded"; import { AStarWaterBounded } from "./AStar.WaterBounded";
import { AbstractGraph, AbstractNode } from "./AbstractGraph"; import { AbstractGraph, AbstractNode } from "./AbstractGraph";
import { BFSGrid } from "./BFS.Grid"; import { BFSGrid } from "./BFS.Grid";
import { LAND_MARKER } from "./ConnectedComponents"; import { LAND_MARKER } from "./ConnectedComponents";
type PathDebugInfo = {
nodePath: TileRef[] | null;
initialPath: TileRef[] | null;
graph: {
clusterSize: number;
nodes: Array<{ id: number; tile: TileRef }>;
edges: Array<{
id: number;
nodeA: number;
nodeB: number;
from: TileRef;
to: TileRef;
cost: number;
}>;
};
timings: { [key: string]: number };
};
export class AStarWaterHierarchical implements PathFinder<number> { export class AStarWaterHierarchical implements PathFinder<number> {
private tileBFS: BFSGrid; private tileBFS: BFSGrid;
private abstractAStar: AbstractGraphAStar; private abstractAStar: AbstractGraphAStar;
private localAStar: AStarBounded; private localAStar: AStarWaterBounded;
private localAStarMultiCluster: AStarBounded; private localAStarMultiCluster: AStarWaterBounded;
private sourceResolver: SourceResolver; private sourceResolver: SourceResolver;
public debugInfo: PathDebugInfo | null = null;
public debugMode: boolean = false;
constructor( constructor(
private map: GameMap, private map: GameMap,
private graph: AbstractGraph, private graph: AbstractGraph,
@@ -51,23 +31,31 @@ export class AStarWaterHierarchical implements PathFinder<number> {
// BoundedAStar for cluster-bounded local pathfinding // BoundedAStar for cluster-bounded local pathfinding
const maxLocalNodes = clusterSize * clusterSize; const maxLocalNodes = clusterSize * clusterSize;
this.localAStar = new AStarBounded(map, maxLocalNodes); this.localAStar = new AStarWaterBounded(map, maxLocalNodes);
// BoundedAStar for multi-cluster (3x3) local pathfinding // BoundedAStar for multi-cluster (3x3) local pathfinding
const multiClusterSize = clusterSize * 3; const multiClusterSize = clusterSize * 3;
const maxMultiClusterNodes = multiClusterSize * multiClusterSize; const maxMultiClusterNodes = multiClusterSize * multiClusterSize;
this.localAStarMultiCluster = new AStarBounded(map, maxMultiClusterNodes); this.localAStarMultiCluster = new AStarWaterBounded(
map,
maxMultiClusterNodes,
);
// SourceResolver for multi-source search // SourceResolver for multi-source search
this.sourceResolver = new SourceResolver(this.map, this.graph); this.sourceResolver = new SourceResolver(this.map, this.graph);
} }
findPath(from: number | number[], to: number): number[] | null { findPath(from: number | number[], to: number): number[] | null {
if (Array.isArray(from)) { return DebugSpan.wrap("AStar.WaterHierarchical:findPath", () => {
return this.findPathMultiSource(from as TileRef[], to as TileRef); DebugSpan.set("$to", () => to);
} DebugSpan.set("$from", () => from);
return this.findPathSingle(from as TileRef, to as TileRef, this.debugMode); if (Array.isArray(from)) {
return this.findPathMultiSource(from as TileRef[], to as TileRef);
}
return this.findPathSingle(from as TileRef, to as TileRef);
});
} }
private findPathMultiSource( private findPathMultiSource(
@@ -94,192 +82,66 @@ export class AStarWaterHierarchical implements PathFinder<number> {
return this.findPathSingle(winningSource, target); return this.findPathSingle(winningSource, target);
} }
findPathSingle( findPathSingle(from: TileRef, to: TileRef): TileRef[] | null {
from: TileRef,
to: TileRef,
debug: boolean = false,
): TileRef[] | null {
if (debug) {
const allEdges: Array<{
id: number;
nodeA: number;
nodeB: number;
from: TileRef;
to: TileRef;
cost: number;
}> = [];
for (let edgeId = 0; edgeId < this.graph.edgeCount; edgeId++) {
const edge = this.graph.getEdge(edgeId);
if (!edge) continue;
const nodeA = this.graph.getNode(edge.nodeA);
const nodeB = this.graph.getNode(edge.nodeB);
if (!nodeA || !nodeB) continue;
allEdges.push({
id: edge.id,
nodeA: edge.nodeA,
nodeB: edge.nodeB,
from: nodeA.tile,
to: nodeB.tile,
cost: edge.cost,
});
}
this.debugInfo = {
nodePath: null,
initialPath: null,
graph: {
clusterSize: this.graph.clusterSize,
nodes: this.graph
.getAllNodes()
.map((node) => ({ id: node.id, tile: node.tile })),
edges: allEdges,
},
timings: {
total: 0,
},
};
}
const dist = this.map.manhattanDist(from, to); const dist = this.map.manhattanDist(from, to);
// Early exit for very short distances // Early exit for very short distances
if (dist <= this.graph.clusterSize) { if (dist <= this.graph.clusterSize) {
performance.mark("hpa:findPath:earlyExitLocalPath:start"); DebugSpan.start("earlyExit");
const startX = this.map.x(from); const startX = this.map.x(from);
const startY = this.map.y(from); const startY = this.map.y(from);
const clusterX = Math.floor(startX / this.graph.clusterSize); const clusterX = Math.floor(startX / this.graph.clusterSize);
const clusterY = Math.floor(startY / this.graph.clusterSize); const clusterY = Math.floor(startY / this.graph.clusterSize);
const localPath = this.findLocalPath(from, to, clusterX, clusterY, true); const localPath = this.findLocalPath(from, to, clusterX, clusterY, true);
performance.mark("hpa:findPath:earlyExitLocalPath:end"); DebugSpan.end();
const measure = performance.measure(
"hpa:findPath:earlyExitLocalPath",
"hpa:findPath:earlyExitLocalPath:start",
"hpa:findPath:earlyExitLocalPath:end",
);
if (debug) {
this.debugInfo!.timings.earlyExitLocalPath = measure.duration;
this.debugInfo!.timings.total += measure.duration;
}
if (localPath) { if (localPath) {
if (debug) {
console.log(
`[DEBUG] Direct local path found for dist=${dist}, length=${localPath.length}`,
);
}
return localPath; return localPath;
} }
if (debug) {
console.log(
`[DEBUG] Direct path failed for dist=${dist}, falling back to abstract graph`,
);
}
} }
performance.mark("hpa:findPath:findNodes:start"); DebugSpan.start("nodeLookup");
const startNode = this.findNearestNode(from); const startNode = this.findNearestNode(from);
const endNode = this.findNearestNode(to); const endNode = this.findNearestNode(to);
performance.mark("hpa:findPath:findNodes:end"); DebugSpan.end();
const findNodesMeasure = performance.measure(
"hpa:findPath:findNodes",
"hpa:findPath:findNodes:start",
"hpa:findPath:findNodes:end",
);
if (debug) {
this.debugInfo!.timings.findNodes = findNodesMeasure.duration;
this.debugInfo!.timings.total += findNodesMeasure.duration;
}
if (!startNode) { if (!startNode) {
if (debug) {
console.log(
`[DEBUG] Cannot find start node for (${this.map.x(from)}, ${this.map.y(from)})`,
);
}
return null; return null;
} }
if (!endNode) { if (!endNode) {
if (debug) {
console.log(
`[DEBUG] Cannot find end node for (${this.map.x(to)}, ${this.map.y(to)})`,
);
}
return null; return null;
} }
if (startNode.id === endNode.id) { if (startNode.id === endNode.id) {
if (debug) { DebugSpan.start("sameNodeLocalPath");
console.log(
`[DEBUG] Start and end nodes are the same (ID=${startNode.id}), finding local path with multi-cluster search`,
);
}
performance.mark("hpa:findPath:sameNodeLocalPath:start");
const clusterX = Math.floor(startNode.x / this.graph.clusterSize); const clusterX = Math.floor(startNode.x / this.graph.clusterSize);
const clusterY = Math.floor(startNode.y / this.graph.clusterSize); const clusterY = Math.floor(startNode.y / this.graph.clusterSize);
const path = this.findLocalPath(from, to, clusterX, clusterY, true); const path = this.findLocalPath(from, to, clusterX, clusterY, true);
performance.mark("hpa:findPath:sameNodeLocalPath:end"); DebugSpan.end();
const sameNodeMeasure = performance.measure(
"hpa:findPath:sameNodeLocalPath",
"hpa:findPath:sameNodeLocalPath:start",
"hpa:findPath:sameNodeLocalPath:end",
);
if (debug) {
this.debugInfo!.timings.sameNodeLocalPath = sameNodeMeasure.duration;
this.debugInfo!.timings.total += sameNodeMeasure.duration;
}
return path; return path;
} }
performance.mark("hpa:findPath:findAbstractPath:start"); DebugSpan.start("abstractPath");
const nodePath = this.findAbstractPath(startNode.id, endNode.id); const nodePath = this.findAbstractPath(startNode.id, endNode.id);
performance.mark("hpa:findPath:findAbstractPath:end"); DebugSpan.end();
const findAbstractPathMeasure = performance.measure(
"hpa:findPath:findAbstractPath",
"hpa:findPath:findAbstractPath:start",
"hpa:findPath:findAbstractPath:end",
);
if (debug) {
this.debugInfo!.timings.findAbstractPath =
findAbstractPathMeasure.duration;
this.debugInfo!.timings.total += findAbstractPathMeasure.duration;
this.debugInfo!.nodePath = nodePath
? nodePath
.map((nodeId) => {
const node = this.graph.getNode(nodeId);
return node ? node.tile : -1;
})
.filter((tile) => tile !== -1)
: null;
}
if (!nodePath) { if (!nodePath) {
if (debug) {
console.log(
`[DEBUG] No abstract path between nodes ${startNode.id} and ${endNode.id}`,
);
}
return null; return null;
} }
if (debug) { DebugSpan.set("nodePath", () =>
console.log(`[DEBUG] Abstract path found: ${nodePath.length} waypoints`); nodePath
} .map((nodeId) => {
const node = this.graph.getNode(nodeId);
return node ? node.tile : -1;
})
.filter((tile) => tile !== -1),
);
const initialPath: TileRef[] = []; const initialPath: TileRef[] = [];
performance.mark("hpa:findPath:buildInitialPath:start"); DebugSpan.start("initialPath");
// 1. Find path from start to first node // 1. Find path from start to first node
const firstNode = this.graph.getNode(nodePath[0])!; const firstNode = this.graph.getNode(nodePath[0])!;
@@ -324,6 +186,10 @@ export class AStarWaterHierarchical implements PathFinder<number> {
if (cachedPath && cachedPath.length > 0) { if (cachedPath && cachedPath.length > 0) {
// Path is cached for this exact direction, use as-is // Path is cached for this exact direction, use as-is
initialPath.push(...cachedPath.slice(1)); initialPath.push(...cachedPath.slice(1));
DebugSpan.set(
"$cachedSegmentsUsed",
(prev) => ((prev as number) ?? 0) + 1,
);
continue; continue;
} }
} }
@@ -368,20 +234,7 @@ export class AStarWaterHierarchical implements PathFinder<number> {
initialPath.push(...endSegment.slice(1)); initialPath.push(...endSegment.slice(1));
performance.mark("hpa:findPath:buildInitialPath:end"); DebugSpan.set("initialPath", () => initialPath);
const buildInitialPathMeasure = performance.measure(
"hpa:findPath:buildInitialPath",
"hpa:findPath:buildInitialPath:start",
"hpa:findPath:buildInitialPath:end",
);
if (debug) {
this.debugInfo!.timings.buildInitialPath =
buildInitialPathMeasure.duration;
this.debugInfo!.timings.total += buildInitialPathMeasure.duration;
this.debugInfo!.initialPath = initialPath;
console.log(`[DEBUG] Initial path: ${initialPath.length} tiles`);
}
// Smoothing moved to SmoothingTransformer - return raw path // Smoothing moved to SmoothingTransformer - return raw path
return initialPath; return initialPath;
+18 -120
View File
@@ -1,4 +1,5 @@
import { GameMap, TileRef } from "../../game/GameMap"; import { GameMap, TileRef } from "../../game/GameMap";
import { DebugSpan } from "../../utilities/DebugSpan";
import { BFSGrid } from "./BFS.Grid"; import { BFSGrid } from "./BFS.Grid";
import { ConnectedComponents } from "./ConnectedComponents"; import { ConnectedComponents } from "./ConnectedComponents";
@@ -25,16 +26,6 @@ export interface Cluster {
nodeIds: number[]; nodeIds: number[];
} }
export type BuildDebugInfo = {
clusters: number | null;
nodes: number | null;
edges: number | null;
actualBFSCalls: number | null;
potentialBFSCalls: number | null;
skippedByComponentFilter: number | null;
timings: { [key: string]: number };
};
export class AbstractGraph { export class AbstractGraph {
// Nodes (array indexed by id) // Nodes (array indexed by id)
private readonly _nodes: AbstractNode[] = []; private readonly _nodes: AbstractNode[] = [];
@@ -97,6 +88,10 @@ export class AbstractGraph {
return edge.nodeA === nodeId ? edge.nodeB : edge.nodeA; return edge.nodeA === nodeId ? edge.nodeB : edge.nodeA;
} }
getAllEdges(): readonly AbstractEdge[] {
return this._edges;
}
get edgeCount(): number { get edgeCount(): number {
return this._edges.length; return this._edges.length;
} }
@@ -217,8 +212,6 @@ export class AbstractGraphBuilder {
private nextEdgeId = 0; private nextEdgeId = 0;
private edgeBetween = new Map<number, Map<number, AbstractEdge>>(); private edgeBetween = new Map<number, Map<number, AbstractEdge>>();
public debugInfo: BuildDebugInfo | null = null;
constructor( constructor(
private readonly map: GameMap, private readonly map: GameMap,
private readonly clusterSize: number = AbstractGraphBuilder.CLUSTER_SIZE, private readonly clusterSize: number = AbstractGraphBuilder.CLUSTER_SIZE,
@@ -231,8 +224,8 @@ export class AbstractGraphBuilder {
this.waterComponents = new ConnectedComponents(map); this.waterComponents = new ConnectedComponents(map);
} }
build(debug: boolean = false): AbstractGraph { build(): AbstractGraph {
performance.mark("abstractgraph:build:start"); DebugSpan.start("AbstractGraphBuilder:build");
this.graph = new AbstractGraph( this.graph = new AbstractGraph(
this.clusterSize, this.clusterSize,
@@ -240,37 +233,8 @@ export class AbstractGraphBuilder {
this.clustersY, this.clustersY,
); );
if (debug) {
console.log(
`[DEBUG] Building abstract graph with cluster size ${this.clusterSize} (${this.clustersX}x${this.clustersY} clusters)`,
);
this.debugInfo = {
clusters: null,
nodes: null,
edges: null,
actualBFSCalls: null,
potentialBFSCalls: null,
skippedByComponentFilter: null,
timings: {},
};
}
// Initialize water components // Initialize water components
performance.mark("abstractgraph:build:water-component:start");
this.waterComponents.initialize(); this.waterComponents.initialize();
performance.mark("abstractgraph:build:water-component:end");
const wcMeasure = performance.measure(
"abstractgraph:build:water-component",
"abstractgraph:build:water-component:start",
"abstractgraph:build:water-component:end",
);
if (debug) {
console.log(
`[DEBUG] Water Component Identification: ${wcMeasure.duration.toFixed(2)}ms`,
);
}
// Pre-create all clusters // Pre-create all clusters
for (let cy = 0; cy < this.clustersY; cy++) { for (let cy = 0; cy < this.clustersY; cy++) {
@@ -281,98 +245,30 @@ export class AbstractGraphBuilder {
} }
// Find nodes (gateways) at cluster boundaries // Find nodes (gateways) at cluster boundaries
performance.mark("abstractgraph:build:nodes:start"); DebugSpan.start("nodes");
for (let cy = 0; cy < this.clustersY; cy++) { for (let cy = 0; cy < this.clustersY; cy++) {
for (let cx = 0; cx < this.clustersX; cx++) { for (let cx = 0; cx < this.clustersX; cx++) {
this.processCluster(cx, cy); this.processCluster(cx, cy);
} }
} }
performance.mark("abstractgraph:build:nodes:end"); DebugSpan.end();
const nodesMeasure = performance.measure(
"abstractgraph:build:nodes",
"abstractgraph:build:nodes:start",
"abstractgraph:build:nodes:end",
);
if (debug) {
console.log(
`[DEBUG] Node identification: ${nodesMeasure.duration.toFixed(2)}ms`,
);
this.debugInfo!.potentialBFSCalls = 0;
this.debugInfo!.skippedByComponentFilter = 0;
}
// Build edges between nodes in same cluster // Build edges between nodes in same cluster
performance.mark("abstractgraph:build:edges:start"); DebugSpan.start("edges");
for (let cy = 0; cy < this.clustersY; cy++) { for (let cy = 0; cy < this.clustersY; cy++) {
for (let cx = 0; cx < this.clustersX; cx++) { for (let cx = 0; cx < this.clustersX; cx++) {
const cluster = this.graph.getCluster(cx, cy); const cluster = this.graph.getCluster(cx, cy);
if (!cluster || cluster.nodeIds.length === 0) continue; if (!cluster || cluster.nodeIds.length === 0) continue;
if (debug) {
const n = cluster.nodeIds.length;
this.debugInfo!.potentialBFSCalls! += (n * (n - 1)) / 2;
// Count skipped by component filter
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const nodeI = this.graph.getNode(cluster.nodeIds[i])!;
const nodeJ = this.graph.getNode(cluster.nodeIds[j])!;
if (nodeI.componentId !== nodeJ.componentId) {
this.debugInfo!.skippedByComponentFilter!++;
}
}
}
}
this.buildClusterConnections(cx, cy); this.buildClusterConnections(cx, cy);
} }
} }
performance.mark("abstractgraph:build:edges:end"); DebugSpan.end();
const edgesMeasure = performance.measure(
"abstractgraph:build:edges",
"abstractgraph:build:edges:start",
"abstractgraph:build:edges:end",
);
if (debug) { DebugSpan.set("nodes", () => this.graph.getAllNodes());
this.debugInfo!.actualBFSCalls = DebugSpan.set("edges", () => this.graph.getAllEdges());
this.debugInfo!.potentialBFSCalls! - DebugSpan.set("nodesCount", () => this.graph.nodeCount);
this.debugInfo!.skippedByComponentFilter!; DebugSpan.set("edgesCount", () => this.graph.edgeCount);
DebugSpan.set("clustersCount", () => this.clustersX * this.clustersY);
console.log(
`[DEBUG] Edge identification: ${edgesMeasure.duration.toFixed(2)}ms`,
);
console.log(
`[DEBUG] Potential BFS calls: ${this.debugInfo!.potentialBFSCalls}`,
);
console.log(
`[DEBUG] Skipped by component filter: ${this.debugInfo!.skippedByComponentFilter} (${((this.debugInfo!.skippedByComponentFilter! / this.debugInfo!.potentialBFSCalls!) * 100).toFixed(1)}%)`,
);
console.log(
`[DEBUG] Actual BFS calls: ${this.debugInfo!.actualBFSCalls}`,
);
}
performance.mark("abstractgraph:build:end");
const totalMeasure = performance.measure(
"abstractgraph:build:total",
"abstractgraph:build:start",
"abstractgraph:build:end",
);
if (debug) {
console.log(
`[DEBUG] Abstract graph built in ${totalMeasure.duration.toFixed(2)}ms`,
);
console.log(`[DEBUG] Nodes: ${this.graph.nodeCount}`);
console.log(`[DEBUG] Edges: ${this.graph.edgeCount}`);
console.log(`[DEBUG] Clusters: ${this.clustersX * this.clustersY}`);
this.debugInfo!.clusters = this.clustersX * this.clustersY;
this.debugInfo!.nodes = this.graph.nodeCount;
this.debugInfo!.edges = this.graph.edgeCount;
}
// Initialize path cache after all edges are built // Initialize path cache after all edges are built
this.graph._initPathCache(); this.graph._initPathCache();
@@ -380,6 +276,8 @@ export class AbstractGraphBuilder {
// Store water components for componentId lookups // Store water components for componentId lookups
this.graph.setWaterComponents(this.waterComponents); this.graph.setWaterComponents(this.waterComponents);
DebugSpan.end(); // AbstractGraphBuilder:build
return this.graph; return this.graph;
} }
@@ -1,6 +1,7 @@
// Connected Component Labeling using flood-fill // Connected Component Labeling using flood-fill
import { GameMap, TileRef } from "../../game/GameMap"; import { GameMap, TileRef } from "../../game/GameMap";
import { DebugSpan } from "../../utilities/DebugSpan";
export const LAND_MARKER = 0xff; // Must fit in Uint8Array export const LAND_MARKER = 0xff; // Must fit in Uint8Array
@@ -28,6 +29,7 @@ export class ConnectedComponents {
} }
initialize(): void { initialize(): void {
DebugSpan.start("ConnectedComponents:initialize");
let ids: Uint8Array | Uint16Array = this.createPrefilledIds(); let ids: Uint8Array | Uint16Array = this.createPrefilledIds();
let nextId = 0; let nextId = 0;
@@ -52,6 +54,7 @@ export class ConnectedComponents {
} }
this.componentIds = ids; this.componentIds = ids;
DebugSpan.end();
} }
/** /**
@@ -1,168 +0,0 @@
import { GameMap, TileRef } from "../../game/GameMap";
import { PathFinder } from "../types";
import { PathSmoother } from "./PathSmoother";
/**
* Path smoother using Bresenham line-of-sight algorithm.
* Greedily skips waypoints when direct traversal is possible.
*/
export class BresenhamPathSmoother implements PathSmoother<TileRef> {
constructor(
private map: GameMap,
private isTraversable: (tile: TileRef) => boolean,
) {}
smooth(path: TileRef[]): TileRef[] {
if (path.length <= 2) {
return path;
}
const smoothed: TileRef[] = [];
let current = 0;
while (current < path.length - 1) {
let farthest = current + 1;
let bestTrace: TileRef[] | null = null;
for (
let i = current + 2;
i < path.length;
i += Math.max(1, Math.floor(path.length / 20))
) {
const trace = this.tracePath(path[current], path[i]);
if (trace !== null) {
farthest = i;
bestTrace = trace;
} else {
break;
}
}
if (
farthest < path.length - 1 &&
(path.length - 1 - current) % 10 !== 0
) {
const trace = this.tracePath(path[current], path[path.length - 1]);
if (trace !== null) {
farthest = path.length - 1;
bestTrace = trace;
}
}
if (bestTrace !== null && farthest > current + 1) {
smoothed.push(...bestTrace.slice(0, -1));
} else {
smoothed.push(path[current]);
}
current = farthest;
}
smoothed.push(path[path.length - 1]);
return smoothed;
}
private tracePath(from: TileRef, to: TileRef): TileRef[] | null {
const x0 = this.map.x(from);
const y0 = this.map.y(from);
const x1 = this.map.x(to);
const y1 = this.map.y(to);
const tiles: TileRef[] = [];
const dx = Math.abs(x1 - x0);
const dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
let err = dx - dy;
let x = x0;
let y = y0;
const maxTiles = 100000;
let iterations = 0;
while (true) {
if (iterations++ > maxTiles) {
return null;
}
const tile = this.map.ref(x, y);
if (!this.isTraversable(tile)) {
return null;
}
tiles.push(tile);
if (x === x1 && y === y1) {
break;
}
const e2 = 2 * err;
const shouldMoveX = e2 > -dy;
const shouldMoveY = e2 < dx;
if (shouldMoveX && shouldMoveY) {
x += sx;
err -= dy;
const intermediateTile = this.map.ref(x, y);
if (!this.isTraversable(intermediateTile)) {
x -= sx;
err += dy;
y += sy;
err += dx;
const altTile = this.map.ref(x, y);
if (!this.isTraversable(altTile)) {
return null;
}
tiles.push(altTile);
x += sx;
err -= dy;
} else {
tiles.push(intermediateTile);
y += sy;
err += dx;
}
} else {
if (shouldMoveX) {
x += sx;
err -= dy;
}
if (shouldMoveY) {
y += sy;
err += dx;
}
}
}
return tiles;
}
}
/**
* Ready-to-use transformer that applies Bresenham smoothing.
* Defaults to water traversability.
*/
export class BresenhamSmoothingTransformer implements PathFinder<TileRef> {
private smoother: BresenhamPathSmoother;
constructor(
private inner: PathFinder<TileRef>,
map: GameMap,
isTraversable: (tile: TileRef) => boolean = (t) => map.isWater(t),
) {
this.smoother = new BresenhamPathSmoother(map, isTraversable);
}
findPath(from: TileRef | TileRef[], to: TileRef): TileRef[] | null {
const path = this.inner.findPath(from, to);
return path ? this.smoother.smooth(path) : null;
}
}
@@ -1,7 +0,0 @@
/**
* PathSmoother - interface for path smoothing algorithms.
* Takes a path and returns a smoothed version.
*/
export interface PathSmoother<T> {
smooth(path: T[]): T[];
}
@@ -1,18 +0,0 @@
import { PathFinder } from "../types";
import { PathSmoother } from "./PathSmoother";
/**
* Transformer that applies path smoothing to any PathFinder.
* Wraps an inner PathFinder and smooths its output.
*/
export class SmoothingTransformer<T> implements PathFinder<T> {
constructor(
private inner: PathFinder<T>,
private smoother: PathSmoother<T>,
) {}
findPath(from: T | T[], to: T): T[] | null {
const path = this.inner.findPath(from, to);
return path ? this.smoother.smooth(path) : null;
}
}
@@ -0,0 +1,341 @@
import { GameMap, TileRef } from "../../game/GameMap";
import { DebugSpan } from "../../utilities/DebugSpan";
import {
AStarWaterBounded,
SearchBounds,
} from "../algorithms/AStar.WaterBounded";
import { PathFinder } from "../types";
const ENDPOINT_REFINEMENT_TILES = 50;
const LOCAL_ASTAR_MAX_AREA = 100 * 100;
const LOS_MIN_MAGNITUDE = 3;
const MAGNITUDE_MASK = 0x1f;
/**
* Water path smoother transformer with two passes:
* 1. Binary search LOS smoothing (avoids shallow water)
* 2. Local A* refinement on endpoints (first/last N tiles)
*/
export class SmoothingWaterTransformer implements PathFinder<TileRef> {
private readonly mapWidth: number;
private readonly localAStar: AStarWaterBounded;
private readonly terrain: Uint8Array;
private readonly isTraversable: (tile: TileRef) => boolean;
constructor(
private inner: PathFinder<TileRef>,
private map: GameMap,
isTraversable: (tile: TileRef) => boolean = (t) => map.isWater(t),
) {
this.mapWidth = map.width();
this.localAStar = new AStarWaterBounded(map, LOCAL_ASTAR_MAX_AREA);
this.terrain = (map as any).terrain as Uint8Array;
this.isTraversable = isTraversable;
}
findPath(from: TileRef | TileRef[], to: TileRef): TileRef[] | null {
const path = this.inner.findPath(from, to);
return DebugSpan.wrap("smoothingTransformer", () =>
path ? this.smooth(path) : null,
);
}
private smooth(path: TileRef[]): TileRef[] {
if (path.length <= 2) {
return path;
}
// Pass 1: LOS smoothing with binary search
let smoothed = DebugSpan.wrap("smoother:los", () => this.losSmooth(path));
// Pass 2: Local A* refinement on endpoints
smoothed = DebugSpan.wrap("smoother:refine", () =>
this.refineEndpoints(smoothed),
);
return smoothed;
}
private losSmooth(path: TileRef[]): TileRef[] {
const result: TileRef[] = [path[0]];
let current = 0;
while (current < path.length - 1) {
// Binary search for farthest visible waypoint
let lo = current + 1;
let hi = path.length - 1;
let farthest = lo;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
if (this.canSee(path[current], path[mid])) {
farthest = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
// Trace the path to farthest visible point
if (farthest > current + 1) {
const trace = this.tracePath(path[current], path[farthest]);
if (trace) {
// Add all intermediate tiles except the last (will be added in next iteration or at end)
for (let i = 1; i < trace.length - 1; i++) {
result.push(trace[i]);
}
}
}
current = farthest;
if (current < path.length - 1) {
result.push(path[current]);
}
}
result.push(path[path.length - 1]);
return result;
}
private refineEndpoints(path: TileRef[]): TileRef[] {
if (path.length <= 2) {
return path;
}
const refineDist = ENDPOINT_REFINEMENT_TILES;
let result = path;
// Find the index where cumulative distance reaches refineDist from start
const startEndIdx = this.findTileAtDistance(path, 0, refineDist, true);
// Refine start segment if it's more than 2 tiles and not already optimal
if (startEndIdx > 1) {
const startSegment = this.refineSegment(path[0], path[startEndIdx]);
if (startSegment && startSegment.length > 0) {
result = [...startSegment.slice(0, -1), ...result.slice(startEndIdx)];
}
}
// Find the index where cumulative distance reaches refineDist from end
const endStartIdx = this.findTileAtDistance(
result,
result.length - 1,
refineDist,
false,
);
// Refine end segment if it's more than 2 tiles and not already optimal
// Search in reverse (from destination backwards) so path approaches target naturally
if (endStartIdx < result.length - 2) {
const endSegment = this.refineSegment(
result[result.length - 1],
result[endStartIdx],
);
if (endSegment && endSegment.length > 0) {
endSegment.reverse();
result = [...result.slice(0, endStartIdx), ...endSegment];
}
}
return result;
}
private findTileAtDistance(
path: TileRef[],
startIdx: number,
distance: number,
forward: boolean,
): number {
let cumDist = 0;
let idx = startIdx;
if (forward) {
while (idx < path.length - 1 && cumDist < distance) {
cumDist += this.manhattanDist(path[idx], path[idx + 1]);
idx++;
}
} else {
while (idx > 0 && cumDist < distance) {
cumDist += this.manhattanDist(path[idx], path[idx - 1]);
idx--;
}
}
return idx;
}
private refineSegment(from: TileRef, to: TileRef): TileRef[] | null {
const x0 = this.map.x(from);
const y0 = this.map.y(from);
const x1 = this.map.x(to);
const y1 = this.map.y(to);
// Calculate bounds with padding
const padding = 10;
const bounds: SearchBounds = {
minX: Math.max(0, Math.min(x0, x1) - padding),
maxX: Math.min(this.map.width() - 1, Math.max(x0, x1) + padding),
minY: Math.max(0, Math.min(y0, y1) - padding),
maxY: Math.min(this.map.height() - 1, Math.max(y0, y1) + padding),
};
return this.localAStar.searchBounded(from, to, bounds);
}
private canSee(from: TileRef, to: TileRef): boolean {
const x0 = from % this.mapWidth;
const y0 = (from / this.mapWidth) | 0;
const x1 = to % this.mapWidth;
const y1 = (to / this.mapWidth) | 0;
const dx = Math.abs(x1 - x0);
const dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
let err = dx - dy;
let x = x0;
let y = y0;
const maxTiles = 100000;
let iterations = 0;
while (true) {
if (iterations++ > maxTiles) return false;
const tile = (y * this.mapWidth + x) as TileRef;
if (!this.isTraversable(tile)) return false;
// Check magnitude - avoid shallow water
const magnitude = this.terrain[tile] & MAGNITUDE_MASK;
if (magnitude < LOS_MIN_MAGNITUDE) return false;
if (x === x1 && y === y1) return true;
const e2 = 2 * err;
const shouldMoveX = e2 > -dy;
const shouldMoveY = e2 < dx;
if (shouldMoveX && shouldMoveY) {
// Diagonal move - check intermediate tile
x += sx;
err -= dy;
const intermediateTile = (y * this.mapWidth + x) as TileRef;
const intMag = this.terrain[intermediateTile] & MAGNITUDE_MASK;
if (
!this.isTraversable(intermediateTile) ||
intMag < LOS_MIN_MAGNITUDE
) {
// Try alternative path
x -= sx;
err += dy;
y += sy;
err += dx;
const altTile = (y * this.mapWidth + x) as TileRef;
const altMag = this.terrain[altTile] & MAGNITUDE_MASK;
if (!this.isTraversable(altTile) || altMag < LOS_MIN_MAGNITUDE)
return false;
x += sx;
err -= dy;
} else {
y += sy;
err += dx;
}
} else {
if (shouldMoveX) {
x += sx;
err -= dy;
}
if (shouldMoveY) {
y += sy;
err += dx;
}
}
}
}
private tracePath(from: TileRef, to: TileRef): TileRef[] | null {
const x0 = from % this.mapWidth;
const y0 = (from / this.mapWidth) | 0;
const x1 = to % this.mapWidth;
const y1 = (to / this.mapWidth) | 0;
const tiles: TileRef[] = [];
const dx = Math.abs(x1 - x0);
const dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
let err = dx - dy;
let x = x0;
let y = y0;
const maxTiles = 100000;
let iterations = 0;
while (true) {
if (iterations++ > maxTiles) return null;
const tile = (y * this.mapWidth + x) as TileRef;
if (!this.isTraversable(tile)) return null;
tiles.push(tile);
if (x === x1 && y === y1) break;
const e2 = 2 * err;
const shouldMoveX = e2 > -dy;
const shouldMoveY = e2 < dx;
if (shouldMoveX && shouldMoveY) {
x += sx;
err -= dy;
const intermediateTile = (y * this.mapWidth + x) as TileRef;
if (!this.isTraversable(intermediateTile)) {
x -= sx;
err += dy;
y += sy;
err += dx;
const altTile = (y * this.mapWidth + x) as TileRef;
if (!this.isTraversable(altTile)) return null;
tiles.push(altTile);
x += sx;
err -= dy;
} else {
tiles.push(intermediateTile);
y += sy;
err += dx;
}
} else {
if (shouldMoveX) {
x += sx;
err -= dy;
}
if (shouldMoveY) {
y += sy;
err += dx;
}
}
}
return tiles;
}
private manhattanDist(a: TileRef, b: TileRef): number {
const ax = a % this.mapWidth;
const ay = (a / this.mapWidth) | 0;
const bx = b % this.mapWidth;
const by = (b / this.mapWidth) | 0;
return Math.abs(ax - bx) + Math.abs(ay - by);
}
}
+159
View File
@@ -0,0 +1,159 @@
type Span = {
name: string;
timeStart: number;
timeEnd?: number;
duration?: number;
data: Record<string, unknown>;
children: Span[];
};
const stack: Span[] = [];
function isEnabled(): boolean {
return globalThis.__DEBUG_SPAN_ENABLED__ === true;
}
export const DebugSpan = {
isEnabled,
enable(): void {
globalThis.__DEBUG_SPAN_ENABLED__ = true;
},
disable(): void {
globalThis.__DEBUG_SPAN_ENABLED__ = false;
},
start(name: string): void {
if (!isEnabled()) return;
const span: Span = {
name,
timeStart: performance.now(),
data: {},
children: [],
};
const parent = stack[stack.length - 1];
parent?.children.push(span);
stack.push(span);
},
end(name?: string): void {
if (!isEnabled()) return;
if (stack.length === 0) {
const payload = name ? `"${name}"` : "";
throw new Error(`DebugSpan.end(${payload}): no open span`);
}
// If name provided, close all spans up to and including the named one
if (name) {
while (stack.length > 0) {
const span = stack.pop()!;
span.timeEnd = performance.now();
span.duration = span.timeEnd - span.timeStart;
if (stack.length === 0) {
DebugSpan.storeSpan(span);
}
if (span.name === name) break;
}
return;
}
// Default: close just the current span
const span = stack.pop()!;
span.timeEnd = performance.now();
span.duration = span.timeEnd - span.timeStart;
if (stack.length === 0) {
DebugSpan.storeSpan(span);
}
},
storeSpan(span: Span): void {
if (!isEnabled()) return;
globalThis.__DEBUG_SPANS__ = globalThis.__DEBUG_SPANS__ ?? [];
globalThis.__DEBUG_SPANS__.push(span);
const extractData = (span: Span): Record<string, unknown> => {
return Object.fromEntries(
Object.entries(span.data).filter(
([key]) => typeof key === "string" && key.startsWith("$"),
),
);
};
const properties = {
timings: { total: span.duration },
data: extractData(span),
};
if (span.children.length > 0) {
const getChildren = (span: Span): Span[] =>
span.children.flatMap((child) => [child, ...getChildren(child)]);
const children = getChildren(span);
for (const childSpan of children) {
properties.timings[childSpan.name] = childSpan.duration;
const childData = extractData(childSpan);
for (const key of Object.keys(childData)) {
properties.data[key] = childData[key];
}
}
}
try {
performance.measure(span.name, {
start: span.timeStart,
end: span.timeEnd,
detail: properties,
});
} catch (err) {
console.error("DebugSpan.storeSpan: performance.measure failed", err);
console.error("Span:", span);
}
while (globalThis.__DEBUG_SPANS__.length > 100) {
globalThis.__DEBUG_SPANS__.shift();
}
},
wrap<T>(name: string, fn: () => T): T {
this.start(name);
try {
return fn();
} finally {
this.end(name);
}
},
set(
key: string,
valueFn: (previous: unknown) => unknown,
root: boolean = true,
): void {
if (!isEnabled()) return;
if (stack.length === 0) {
throw new Error(`DebugSpan.set("${key}"): no open span`);
}
const span = root ? stack[0] : stack[stack.length - 1];
span.data[key] = valueFn(span.data[key]);
},
getLastSpan(name?: string): Span | undefined {
if (!isEnabled()) return;
globalThis.__DEBUG_SPANS__ = globalThis.__DEBUG_SPANS__ ?? [];
if (name) {
for (let i = globalThis.__DEBUG_SPANS__.length - 1 || 0; i >= 0; i--) {
const span = globalThis.__DEBUG_SPANS__[i];
if (span.name === name) {
return span;
}
}
return undefined;
}
return globalThis.__DEBUG_SPANS__[globalThis.__DEBUG_SPANS__.length - 1];
},
};
+113 -61
View File
@@ -2,39 +2,40 @@ import { readdirSync, readFileSync } from "fs";
import { dirname, join } from "path"; import { dirname, join } from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { Game } from "../../../../src/core/game/Game.js"; import { Game } from "../../../../src/core/game/Game.js";
import { AStarWaterHierarchical } from "../../../../src/core/pathfinding/algorithms/AStar.WaterHierarchical.js"; import { DebugSpan } from "../../../../src/core/utilities/DebugSpan.js";
import { setupFromPath } from "../../utils.js"; import { setupFromPath } from "../../utils.js";
// Available comparison adapters // Available comparison adapters
// Note: "hpa" runs same algorithm without debug overhead for fair timing comparison // Note: "hpa.cached" runs same algorithm without debug overhead for fair timing comparison
export const COMPARISON_ADAPTERS = ["hpa", "a.baseline", "a.generic", "a.full"]; export const COMPARISON_ADAPTERS = [
"hpa.cached",
"hpa",
"a.baseline",
"a.generic",
"a.full",
];
export interface MapInfo { export interface MapInfo {
name: string; name: string;
displayName: string; displayName: string;
} }
export interface GraphBuildData {
nodes: any[];
edges: any[];
nodesCount: number;
edgesCount: number;
clustersCount: number;
buildTime: number;
}
export interface MapCache { export interface MapCache {
game: Game; game: Game;
hpaStar: AStarWaterHierarchical; graphBuildData: GraphBuildData | null;
} }
const cache = new Map<string, MapCache>(); const cache = new Map<string, MapCache>();
/**
* Global configuration for map loading
*/
let config = {
cachePaths: true,
};
/**
* Set configuration options
*/
export function setConfig(options: { cachePaths?: boolean }) {
config = { ...config, ...options };
}
/** /**
* Get the resources/maps directory path * Get the resources/maps directory path
*/ */
@@ -105,6 +106,25 @@ export function listMaps(): MapInfo[] {
return maps.sort((a, b) => a.displayName.localeCompare(b.displayName)); return maps.sort((a, b) => a.displayName.localeCompare(b.displayName));
} }
/**
* Extract graph build data from DebugSpan
*/
function extractGraphBuildData(): GraphBuildData | null {
const span = DebugSpan.getLastSpan();
if (!span || span.name !== "AbstractGraphBuilder:build") {
return null;
}
return {
nodes: (span.data.nodes as any[]) || [],
edges: (span.data.edges as any[]) || [],
nodesCount: (span.data.nodesCount as number) || 0,
edgesCount: (span.data.edgesCount as number) || 0,
clustersCount: (span.data.clustersCount as number) || 0,
buildTime: span.duration || 0,
};
}
/** /**
* Load a map from cache or disk * Load a map from cache or disk
*/ */
@@ -116,21 +136,17 @@ export async function loadMap(mapName: string): Promise<MapCache> {
const mapsDir = getMapsDirectory(); const mapsDir = getMapsDirectory();
// Enable DebugSpan to capture graph build data
DebugSpan.enable();
// Use the existing setupFromPath utility to load the map // Use the existing setupFromPath utility to load the map
const game = await setupFromPath(mapsDir, mapName, { disableNavMesh: false }); const game = await setupFromPath(mapsDir, mapName, { disableNavMesh: false });
// Get pre-built graph from game // Capture graph build data from DebugSpan
const graph = game.miniWaterGraph(); const graphBuildData = extractGraphBuildData();
if (!graph) { DebugSpan.disable();
throw new Error(`No water graph available for map: ${mapName}`);
}
// Initialize AStarWaterHierarchical with minimap and graph const cacheEntry: MapCache = { game, graphBuildData };
const hpaStar = new AStarWaterHierarchical(game.miniMap(), graph, {
cachePaths: config.cachePaths,
});
const cacheEntry: MapCache = { game, hpaStar };
// Store in cache // Store in cache
cache.set(mapName, cacheEntry); cache.set(mapName, cacheEntry);
@@ -142,7 +158,7 @@ export async function loadMap(mapName: string): Promise<MapCache> {
* Get map metadata for client * Get map metadata for client
*/ */
export async function getMapMetadata(mapName: string) { export async function getMapMetadata(mapName: string) {
const { game, hpaStar } = await loadMap(mapName); const { game, graphBuildData } = await loadMap(mapName);
// Extract map data // Extract map data
const mapData: number[] = []; const mapData: number[] = [];
@@ -153,49 +169,84 @@ export async function getMapMetadata(mapName: string) {
} }
} }
// Extract static graph data from GameMapHPAStar const graph = game.miniWaterGraph();
// Access internal graph via type casting (test code only)
const graph = (hpaStar as any).graph;
const miniMap = game.miniMap(); const miniMap = game.miniMap();
const clusterSize = graph?.clusterSize ?? 0;
// Convert nodes to client format // Use graphBuildData from DebugSpan if available, otherwise fall back to direct access
const allNodes = graph.getAllNodes().map((node: any) => ({ let allNodes: Array<{ id: number; x: number; y: number }>;
id: node.id, let edges: Array<{
x: miniMap.x(node.tile),
y: miniMap.y(node.tile),
}));
// Convert edges to client format
const edges: Array<{
fromId: number; fromId: number;
toId: number; toId: number;
from: number[]; from: number[];
to: number[]; to: number[];
cost: number; cost: number;
}> = []; }>;
for (let i = 0; i < graph.edgeCount; i++) {
const edge = graph.getEdge(i);
if (!edge) continue;
const nodeA = graph.getNode(edge.nodeA); if (graphBuildData) {
const nodeB = graph.getNode(edge.nodeB); // Convert nodes from DebugSpan data (AbstractNode format)
if (!nodeA || !nodeB) continue; allNodes = graphBuildData.nodes.map((node: any) => ({
id: node.id,
x: miniMap.x(node.tile),
y: miniMap.y(node.tile),
}));
edges.push({ // Convert edges from DebugSpan data (AbstractEdge format)
fromId: edge.nodeA, edges = graphBuildData.edges.map((edge: any) => {
toId: edge.nodeB, const nodeA = graphBuildData.nodes.find((n: any) => n.id === edge.nodeA);
from: [miniMap.x(nodeA.tile) * 2, miniMap.y(nodeA.tile) * 2], const nodeB = graphBuildData.nodes.find((n: any) => n.id === edge.nodeB);
to: [miniMap.x(nodeB.tile) * 2, miniMap.y(nodeB.tile) * 2], return {
cost: edge.cost, fromId: edge.nodeA,
toId: edge.nodeB,
from: nodeA
? [miniMap.x(nodeA.tile) * 2, miniMap.y(nodeA.tile) * 2]
: [0, 0],
to: nodeB
? [miniMap.x(nodeB.tile) * 2, miniMap.y(nodeB.tile) * 2]
: [0, 0],
cost: edge.cost,
};
}); });
console.log(
`Map ${mapName}: ${allNodes.length} nodes, ${edges.length} edges (from DebugSpan, built in ${graphBuildData.buildTime.toFixed(2)}ms)`,
);
} else if (graph) {
// Fallback: extract directly from graph
allNodes = graph.getAllNodes().map((node: any) => ({
id: node.id,
x: miniMap.x(node.tile),
y: miniMap.y(node.tile),
}));
edges = [];
for (let i = 0; i < graph.edgeCount; i++) {
const edge = graph.getEdge(i);
if (!edge) continue;
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,
});
}
console.log(
`Map ${mapName}: ${allNodes.length} nodes, ${edges.length} edges (fallback)`,
);
} else {
// No graph available
allNodes = [];
edges = [];
console.log(`Map ${mapName}: no graph available`);
} }
console.log(
`Map ${mapName}: ${allNodes.length} nodes, ${edges.length} edges`,
);
const clusterSize = graph.clusterSize;
return { return {
name: mapName, name: mapName,
width: game.width(), width: game.width(),
@@ -205,6 +256,7 @@ export async function getMapMetadata(mapName: string) {
allNodes, allNodes,
edges, edges,
clusterSize, clusterSize,
buildTime: graphBuildData?.buildTime,
}, },
adapters: COMPARISON_ADAPTERS, adapters: COMPARISON_ADAPTERS,
}; };
+49 -54
View File
@@ -1,13 +1,7 @@
import { TileRef } from "../../../../src/core/game/GameMap.js"; import { TileRef } from "../../../../src/core/game/GameMap.js";
import { AStarWaterHierarchical } from "../../../../src/core/pathfinding/algorithms/AStar.WaterHierarchical.js"; import { PathFinding } from "../../../../src/core/pathfinding/PathFinder.js";
import { BresenhamSmoothingTransformer } from "../../../../src/core/pathfinding/smoothing/BresenhamPathSmoother.js"; import { SteppingPathFinder } from "../../../../src/core/pathfinding/types.js";
import { ComponentCheckTransformer } from "../../../../src/core/pathfinding/transformers/ComponentCheckTransformer.js"; import { DebugSpan } from "../../../../src/core/utilities/DebugSpan.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 { getAdapter } from "../../utils.js";
import { COMPARISON_ADAPTERS, loadMap } from "./maps.js"; import { COMPARISON_ADAPTERS, loadMap } from "./maps.js";
@@ -19,6 +13,7 @@ interface PrimaryResult {
debug: { debug: {
nodePath: Array<[number, number]> | null; nodePath: Array<[number, number]> | null;
initialPath: Array<[number, number]> | null; initialPath: Array<[number, number]> | null;
cachedSegmentsUsed: number | null;
timings: Record<string, number>; timings: Record<string, number>;
}; };
} }
@@ -73,63 +68,54 @@ function pathToCoords(
} }
/** /**
* Build the full transformer chain like PathFinding.Water() does * Extract timings from DebugSpan hierarchy
* Flattens nested spans into { spanName: duration } format
*/ */
function buildWrappedPathFinder( function extractTimings(span: {
hpaStar: AStarWaterHierarchical, name: string;
game: any, duration?: number;
graph: any, children: any[];
): PathFinder<TileRef> { }): Record<string, number> {
const miniMap = game.miniMap(); const timings: Record<string, number> = {};
const componentCheckFn = (t: TileRef) => graph.getComponentId(t);
// Chain: hpaStar -> ComponentCheck -> Bresenham -> ShoreCoercing -> MiniMap if (span.duration !== undefined) {
const withComponentCheck = new ComponentCheckTransformer( timings[span.name] = span.duration;
hpaStar, }
componentCheckFn,
);
const withSmoothing = new BresenhamSmoothingTransformer(
withComponentCheck,
miniMap,
);
const withShoreCoercing = new ShoreCoercingTransformer(
withSmoothing,
miniMap,
);
const withMiniMap = new MiniMapTransformer(withShoreCoercing, game, miniMap);
return withMiniMap; for (const child of span.children) {
Object.assign(timings, extractTimings(child));
}
return timings;
} }
/** /**
* Compute primary path using AStarWaterHierarchical with debug info * Compute primary path using PathFinding.Water with debug info
* Uses the same transformer chain as PathFinding.Water()
*/ */
function computePrimaryPath( function computePrimaryPath(
hpaStar: AStarWaterHierarchical,
game: any, game: any,
graph: any,
fromRef: TileRef, fromRef: TileRef,
toRef: TileRef, toRef: TileRef,
): PrimaryResult { ): PrimaryResult {
const miniMap = game.miniMap(); const miniMap = game.miniMap();
// Build wrapped pathfinder with all transformers // Use standard PathFinding.Water
const wrappedPf = buildWrappedPathFinder(hpaStar, game, graph); const pf = PathFinding.Water(game);
// Enable debug mode to capture internal state // Enable DebugSpan to capture internal state
hpaStar.debugMode = true; DebugSpan.enable();
const start = performance.now(); const path = pf.findPath(fromRef, toRef);
const path = wrappedPf.findPath(fromRef, toRef);
const time = performance.now() - start;
const debugInfo = hpaStar.debugInfo; // Get span data and disable
const span = DebugSpan.getLastSpan();
DebugSpan.disable();
// Convert node path (miniMap coords) to full map coords // Convert node path (miniMap coords) to full map coords
let nodePath: Array<[number, number]> | null = null; let nodePath: Array<[number, number]> | null = null;
if (debugInfo?.nodePath) { const spanNodePath = span?.data?.nodePath as TileRef[] | undefined;
nodePath = debugInfo.nodePath.map((tile: TileRef) => { if (spanNodePath) {
nodePath = spanNodePath.map((tile: TileRef) => {
const x = miniMap.x(tile) * 2; const x = miniMap.x(tile) * 2;
const y = miniMap.y(tile) * 2; const y = miniMap.y(tile) * 2;
return [x, y] as [number, number]; return [x, y] as [number, number];
@@ -138,22 +124,32 @@ function computePrimaryPath(
// Convert initialPath (miniMap TileRefs) to full map coords // Convert initialPath (miniMap TileRefs) to full map coords
let initialPath: Array<[number, number]> | null = null; let initialPath: Array<[number, number]> | null = null;
if (debugInfo?.initialPath) { const spanInitialPath = span?.data?.initialPath as TileRef[] | undefined;
initialPath = debugInfo.initialPath.map((tile: TileRef) => { if (spanInitialPath) {
initialPath = spanInitialPath.map((tile: TileRef) => {
const x = miniMap.x(tile) * 2; const x = miniMap.x(tile) * 2;
const y = miniMap.y(tile) * 2; const y = miniMap.y(tile) * 2;
return [x, y] as [number, number]; return [x, y] as [number, number];
}); });
} }
let cachedSegmentsUsed: number | null = null;
if (span?.data?.cachedSegmentsUsed !== undefined) {
cachedSegmentsUsed = span.data.cachedSegmentsUsed as number;
}
// Extract timings from span hierarchy
const timings = span ? extractTimings(span) : {};
return { return {
path: pathToCoords(path, game), path: pathToCoords(path, game),
length: path ? path.length : 0, length: path ? path.length : 0,
time, time: timings["hpa:findPath"] || 0,
debug: { debug: {
nodePath, nodePath,
initialPath, initialPath,
timings: debugInfo?.timings ?? {}, cachedSegmentsUsed,
timings,
}, },
}; };
} }
@@ -189,8 +185,7 @@ export async function computePath(
to: [number, number], to: [number, number],
options: { adapters?: string[] } = {}, options: { adapters?: string[] } = {},
): Promise<PathfindResult> { ): Promise<PathfindResult> {
const { game, hpaStar } = await loadMap(mapName); const { game } = await loadMap(mapName);
const graph = game.miniWaterGraph();
// Convert coordinates to TileRefs // Convert coordinates to TileRefs
const fromRef = game.ref(from[0], from[1]); const fromRef = game.ref(from[0], from[1]);
@@ -204,8 +199,8 @@ export async function computePath(
throw new Error(`End point (${to[0]}, ${to[1]}) is not water`); throw new Error(`End point (${to[0]}, ${to[1]}) is not water`);
} }
// Compute primary path (HPA* with debug) // Compute primary path (PathFinding.Water with debug)
const primary = computePrimaryPath(hpaStar, game, graph, fromRef, toRef); const primary = computePrimaryPath(game, fromRef, toRef);
// Compute comparison paths // Compute comparison paths
const selectedAdapters = options.adapters ?? COMPARISON_ADAPTERS; const selectedAdapters = options.adapters ?? COMPARISON_ADAPTERS;
+43 -22
View File
@@ -20,6 +20,7 @@ const state = {
// Colors for comparison paths // Colors for comparison paths
const COMPARISON_COLORS = { const COMPARISON_COLORS = {
"hpa.cached": "#00ffff", // cyan
hpa: "#ff8800", // orange hpa: "#ff8800", // orange
"a.baseline": "#ff00ff", // magenta "a.baseline": "#ff00ff", // magenta
"a.generic": "#88ff00", // lime "a.generic": "#88ff00", // lime
@@ -814,20 +815,6 @@ function updatePathInfo(result) {
function updateTimingsPanel(result) { function updateTimingsPanel(result) {
const primary = result.primary; const primary = result.primary;
const timings = primary && primary.debug ? primary.debug.timings : {}; const timings = primary && primary.debug ? primary.debug.timings : {};
// Use timings.total (excludes debug overhead) instead of raw time
const hpaTime = timings.total || 0;
// Show HPA* time and path length (or 0.00 in light gray if no data)
const hpaTimeEl = document.getElementById("hpaTime");
if (hpaTime > 0) {
hpaTimeEl.textContent = `${hpaTime.toFixed(2)}ms`;
hpaTimeEl.classList.remove("faded");
} else {
hpaTimeEl.textContent = "0.00ms";
hpaTimeEl.classList.add("faded");
}
const hpaTilesEl = document.getElementById("hpaTiles"); const hpaTilesEl = document.getElementById("hpaTiles");
if (primary && primary.length > 0) { if (primary && primary.length > 0) {
hpaTilesEl.textContent = `- ${primary.length} tiles`; hpaTilesEl.textContent = `- ${primary.length} tiles`;
@@ -840,8 +827,9 @@ function updateTimingsPanel(result) {
const earlyExitEl = document.getElementById("timingEarlyExit"); const earlyExitEl = document.getElementById("timingEarlyExit");
const earlyExitValueEl = document.getElementById("timingEarlyExitValue"); const earlyExitValueEl = document.getElementById("timingEarlyExitValue");
earlyExitEl.style.display = "flex"; earlyExitEl.style.display = "flex";
if (timings.earlyExitLocalPath !== undefined) { const earlyExitTime = timings["earlyExit"];
earlyExitValueEl.textContent = `${timings.earlyExitLocalPath.toFixed(2)}ms`; if (earlyExitTime !== undefined) {
earlyExitValueEl.textContent = `${earlyExitTime.toFixed(2)}ms`;
earlyExitValueEl.style.color = "#f5f5f5"; earlyExitValueEl.style.color = "#f5f5f5";
} else { } else {
earlyExitValueEl.textContent = "—"; earlyExitValueEl.textContent = "—";
@@ -852,8 +840,9 @@ function updateTimingsPanel(result) {
const findNodesEl = document.getElementById("timingFindNodes"); const findNodesEl = document.getElementById("timingFindNodes");
const findNodesValueEl = document.getElementById("timingFindNodesValue"); const findNodesValueEl = document.getElementById("timingFindNodesValue");
findNodesEl.style.display = "flex"; findNodesEl.style.display = "flex";
if (timings.findNodes !== undefined) { const nodeLookupTime = timings["nodeLookup"];
findNodesValueEl.textContent = `${timings.findNodes.toFixed(2)}ms`; if (nodeLookupTime !== undefined) {
findNodesValueEl.textContent = `${nodeLookupTime.toFixed(2)}ms`;
findNodesValueEl.style.color = "#f5f5f5"; findNodesValueEl.style.color = "#f5f5f5";
} else { } else {
findNodesValueEl.textContent = "—"; findNodesValueEl.textContent = "—";
@@ -866,8 +855,9 @@ function updateTimingsPanel(result) {
"timingAbstractPathValue", "timingAbstractPathValue",
); );
abstractPathEl.style.display = "flex"; abstractPathEl.style.display = "flex";
if (timings.findAbstractPath !== undefined) { const abstractPathTime = timings["abstractPath"];
abstractPathValueEl.textContent = `${timings.findAbstractPath.toFixed(2)}ms`; if (abstractPathTime !== undefined) {
abstractPathValueEl.textContent = `${abstractPathTime.toFixed(2)}ms`;
abstractPathValueEl.style.color = "#f5f5f5"; abstractPathValueEl.style.color = "#f5f5f5";
} else { } else {
abstractPathValueEl.textContent = "—"; abstractPathValueEl.textContent = "—";
@@ -878,14 +868,28 @@ function updateTimingsPanel(result) {
const initialPathEl = document.getElementById("timingInitialPath"); const initialPathEl = document.getElementById("timingInitialPath");
const initialPathValueEl = document.getElementById("timingInitialPathValue"); const initialPathValueEl = document.getElementById("timingInitialPathValue");
initialPathEl.style.display = "flex"; initialPathEl.style.display = "flex";
if (timings.buildInitialPath !== undefined) { const initialPathTime = timings["initialPath"];
initialPathValueEl.textContent = `${timings.buildInitialPath.toFixed(2)}ms`; if (initialPathTime !== undefined) {
initialPathValueEl.textContent = `${initialPathTime.toFixed(2)}ms`;
initialPathValueEl.style.color = "#f5f5f5"; initialPathValueEl.style.color = "#f5f5f5";
} else { } else {
initialPathValueEl.textContent = "—"; initialPathValueEl.textContent = "—";
initialPathValueEl.style.color = "#666"; initialPathValueEl.style.color = "#666";
} }
// Smooth Path
const smoothPathEl = document.getElementById("timingSmoothPath");
const smoothPathValueEl = document.getElementById("timingSmoothPathValue");
smoothPathEl.style.display = "flex";
const smoothPathTime = timings["smoothingTransformer"];
if (smoothPathTime !== undefined) {
smoothPathValueEl.textContent = `${smoothPathTime.toFixed(2)}ms`;
smoothPathValueEl.style.color = "#f5f5f5";
} else {
smoothPathValueEl.textContent = "—";
smoothPathValueEl.style.color = "#666";
}
// Show comparisons section // Show comparisons section
const comparisonsSection = document.getElementById("comparisonsSection"); const comparisonsSection = document.getElementById("comparisonsSection");
const comparisonsContainer = document.getElementById("comparisonsContainer"); const comparisonsContainer = document.getElementById("comparisonsContainer");
@@ -905,6 +909,23 @@ function updateTimingsPanel(result) {
} }
} }
// Use total span time from DebugSpan
let hpaTime = timings["findPath"] || 0;
if (compMap["hpa.cached"]) {
hpaTime = compMap["hpa.cached"].time;
}
// Show HPA* time and path length (or 0.00 in light gray if no data)
const hpaTimeEl = document.getElementById("hpaTime");
if (hpaTime > 0) {
hpaTimeEl.textContent = `${hpaTime.toFixed(2)}ms`;
hpaTimeEl.classList.remove("faded");
} else {
hpaTimeEl.textContent = "0.00ms";
hpaTimeEl.classList.add("faded");
}
// Find fastest time overall (including HPA*) when we have data // Find fastest time overall (including HPA*) when we have data
const compTimes = result.comparisons const compTimes = result.comparisons
? result.comparisons.map((c) => c.time).filter((t) => t > 0) ? result.comparisons.map((c) => c.time).filter((t) => t > 0)
@@ -196,6 +196,10 @@
<span class="timing-name">Initial Path:</span> <span class="timing-name">Initial Path:</span>
<span class="timing-value" id="timingInitialPathValue"></span> <span class="timing-value" id="timingInitialPathValue"></span>
</div> </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> </div>
@@ -522,6 +522,10 @@ canvas {
background: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.15);
} }
.comparison-row.active .comp-name {
color: #fff;
}
.comparison-row:last-child { .comparison-row:last-child {
border-bottom: none; border-bottom: none;
} }
-14
View File
@@ -6,20 +6,9 @@ import {
clearCache as clearMapCache, clearCache as clearMapCache,
getMapMetadata, getMapMetadata,
listMaps, listMaps,
setConfig,
} from "./api/maps.js"; } from "./api/maps.js";
import { clearAdapterCaches, computePath } from "./api/pathfinding.js"; import { clearAdapterCaches, computePath } from "./api/pathfinding.js";
// Parse command-line arguments
const args = process.argv.slice(2);
const noCache = args.includes("--no-cache");
// Configure map loading
if (noCache) {
setConfig({ cachePaths: false });
console.log("Path caching disabled (--no-cache)");
}
const app = express(); const app = express();
const PORT = process.env.PORT ?? 5555; const PORT = process.env.PORT ?? 5555;
@@ -203,9 +192,6 @@ app.listen(PORT, () => {
Server running at: http://localhost:${PORT} Server running at: http://localhost:${PORT}
Configuration:
- Path caching: ${noCache ? "disabled" : "enabled"}
Press Ctrl+C to stop Press Ctrl+C to stop
`); `);
}); });
+18 -10
View File
@@ -10,7 +10,7 @@ import {
GameType, GameType,
PlayerInfo, PlayerInfo,
} from "../../src/core/game/Game"; } from "../../src/core/game/Game";
import { createGame } from "../../src/core/game/GameImpl"; import { createGame, GameImpl } from "../../src/core/game/GameImpl";
import { TileRef } from "../../src/core/game/GameMap"; import { TileRef } from "../../src/core/game/GameMap";
import { import {
genTerrainFromBin, genTerrainFromBin,
@@ -90,16 +90,24 @@ export function getAdapter(
// Recreate AStarWaterHierarchical without cache, this approach was chosen // Recreate AStarWaterHierarchical without cache, this approach was chosen
// over adding cache toggles to the existing game instance // over adding cache toggles to the existing game instance
// to avoid adding side effect from benchmark to the game // to avoid adding side effect from benchmark to the game
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 PathFinding.Water(game); const originalGame = game as any;
const clonedGame = new GameImpl(
originalGame._humans,
originalGame._nations,
originalGame._map,
originalGame.miniGameMap,
originalGame._config,
originalGame._stats,
);
(clonedGame as any)._miniWaterHPA = new AStarWaterHierarchical(
clonedGame.miniMap(),
(clonedGame as any)._miniWaterGraph!,
{ cachePaths: false },
);
return PathFinding.Water(clonedGame);
} }
case "hpa.cached": case "hpa.cached":
return PathFinding.Water(game); return PathFinding.Water(game);