diff --git a/src/core/pathfinding/PathFinder.ts b/src/core/pathfinding/PathFinder.ts index 51ecf4e5c..f77776c36 100644 --- a/src/core/pathfinding/PathFinder.ts +++ b/src/core/pathfinding/PathFinder.ts @@ -11,10 +11,10 @@ import { import { StationPathFinder } from "./PathFinder.Station"; import { PathFinderBuilder } from "./PathFinderBuilder"; import { StepperConfig } from "./PathFinderStepper"; -import { BresenhamSmoothingTransformer } from "./smoothing/BresenhamPathSmoother"; import { ComponentCheckTransformer } from "./transformers/ComponentCheckTransformer"; import { MiniMapTransformer } from "./transformers/MiniMapTransformer"; import { ShoreCoercingTransformer } from "./transformers/ShoreCoercingTransformer"; +import { SmoothingWaterTransformer } from "./transformers/SmoothingWaterTransformer"; import { PathStatus, SteppingPathFinder } from "./types"; /** @@ -46,7 +46,7 @@ export class PathFinding { return PathFinderBuilder.create(pf) .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 MiniMapTransformer(pf, game.map(), miniMap)) .buildWithStepper(tileStepperConfig(game)); diff --git a/src/core/pathfinding/PathFinderBuilder.ts b/src/core/pathfinding/PathFinderBuilder.ts index 51d7b6460..d4eff7633 100644 --- a/src/core/pathfinding/PathFinderBuilder.ts +++ b/src/core/pathfinding/PathFinderBuilder.ts @@ -1,3 +1,4 @@ +import { DebugSpan } from "../utilities/DebugSpan"; import { PathFinderStepper, StepperConfig } from "./PathFinderStepper"; import { PathFinder, SteppingPathFinder } from "./types"; @@ -27,10 +28,17 @@ export class PathFinderBuilder { } build(): PathFinder { - return this.wrappers.reduce( + const pathFinder = this.wrappers.reduce( (pf, wrapper) => wrapper(pf), this.core as PathFinder, ); + + 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; } /** diff --git a/src/core/pathfinding/algorithms/AStar.Rail.ts b/src/core/pathfinding/algorithms/AStar.Rail.ts index 0457115fb..095115b96 100644 --- a/src/core/pathfinding/algorithms/AStar.Rail.ts +++ b/src/core/pathfinding/algorithms/AStar.Rail.ts @@ -1,4 +1,5 @@ import { GameMap } from "../../game/GameMap"; +import { DebugSpan } from "../../utilities/DebugSpan"; import { PathFinder } from "../types"; import { AStar, AStarAdapter } from "./AStar"; @@ -11,7 +12,9 @@ export class AStarRail implements PathFinder { } 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), + ); } } diff --git a/src/core/pathfinding/algorithms/AStar.Water.ts b/src/core/pathfinding/algorithms/AStar.Water.ts index 920f1a475..6452114a5 100644 --- a/src/core/pathfinding/algorithms/AStar.Water.ts +++ b/src/core/pathfinding/algorithms/AStar.Water.ts @@ -3,6 +3,16 @@ import { PathFinder } from "../types"; import { BucketQueue, PriorityQueue } from "./PriorityQueue"; 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 { heuristicWeight?: number; @@ -27,7 +37,7 @@ export class AStarWater implements PathFinder { this.terrain = (map as any).terrain as Uint8Array; this.width = map.width(); this.numNodes = map.width() * map.height(); - this.heuristicWeight = config?.heuristicWeight ?? 15; + this.heuristicWeight = config?.heuristicWeight ?? 5; this.maxIterations = config?.maxIterations ?? 1_000_000; this.closedStamp = new Uint32Array(this.numNodes); @@ -35,7 +45,10 @@ export class AStarWater implements PathFinder { this.gScore = new Uint32Array(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); } @@ -64,13 +77,32 @@ export class AStarWater implements PathFinder { queue.clear(); 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) { gScore[s] = 0; gScoreStamp[s] = stamp; cameFrom[s] = -1; const sx = s % width; 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); } @@ -91,15 +123,19 @@ export class AStarWater implements PathFinder { } const currentG = gScore[current]; - const tentativeG = currentG + 1; const currentX = current % width; + const currentY = (current / width) | 0; if (current >= width) { const neighbor = current - width; + const neighborTerrain = terrain[neighbor]; if ( 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 ( gScoreStamp[neighbor] !== stamp || tentativeG < gScore[neighbor] @@ -107,11 +143,12 @@ export class AStarWater implements PathFinder { cameFrom[neighbor] = current; gScore[neighbor] = tentativeG; gScoreStamp[neighbor] = stamp; - const nx = neighbor % width; - const ny = (neighbor / width) | 0; - const f = - tentativeG + - weight * (Math.abs(nx - goalX) + Math.abs(ny - goalY)); + const ny = currentY - 1; + const h = + weight * + BASE_COST * + (Math.abs(currentX - goalX) + Math.abs(ny - goalY)); + const f = tentativeG + h + crossTieBreaker(currentX, ny); queue.push(neighbor, f); } } @@ -119,10 +156,14 @@ export class AStarWater implements PathFinder { if (current < numNodes - width) { const neighbor = current + width; + const neighborTerrain = terrain[neighbor]; if ( 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 ( gScoreStamp[neighbor] !== stamp || tentativeG < gScore[neighbor] @@ -130,11 +171,12 @@ export class AStarWater implements PathFinder { cameFrom[neighbor] = current; gScore[neighbor] = tentativeG; gScoreStamp[neighbor] = stamp; - const nx = neighbor % width; - const ny = (neighbor / width) | 0; - const f = - tentativeG + - weight * (Math.abs(nx - goalX) + Math.abs(ny - goalY)); + const ny = currentY + 1; + const h = + weight * + BASE_COST * + (Math.abs(currentX - goalX) + Math.abs(ny - goalY)); + const f = tentativeG + h + crossTieBreaker(currentX, ny); queue.push(neighbor, f); } } @@ -142,10 +184,14 @@ export class AStarWater implements PathFinder { if (currentX !== 0) { const neighbor = current - 1; + const neighborTerrain = terrain[neighbor]; if ( 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 ( gScoreStamp[neighbor] !== stamp || tentativeG < gScore[neighbor] @@ -153,10 +199,12 @@ export class AStarWater implements PathFinder { cameFrom[neighbor] = current; gScore[neighbor] = tentativeG; gScoreStamp[neighbor] = stamp; - const ny = (neighbor / width) | 0; - const f = - tentativeG + - weight * (Math.abs(currentX - 1 - goalX) + Math.abs(ny - goalY)); + const nx = currentX - 1; + const h = + weight * + BASE_COST * + (Math.abs(nx - goalX) + Math.abs(currentY - goalY)); + const f = tentativeG + h + crossTieBreaker(nx, currentY); queue.push(neighbor, f); } } @@ -164,10 +212,14 @@ export class AStarWater implements PathFinder { if (currentX !== width - 1) { const neighbor = current + 1; + const neighborTerrain = terrain[neighbor]; if ( 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 ( gScoreStamp[neighbor] !== stamp || tentativeG < gScore[neighbor] @@ -175,10 +227,12 @@ export class AStarWater implements PathFinder { cameFrom[neighbor] = current; gScore[neighbor] = tentativeG; gScoreStamp[neighbor] = stamp; - const ny = (neighbor / width) | 0; - const f = - tentativeG + - weight * (Math.abs(currentX + 1 - goalX) + Math.abs(ny - goalY)); + const nx = currentX + 1; + const h = + weight * + BASE_COST * + (Math.abs(nx - goalX) + Math.abs(currentY - goalY)); + const f = tentativeG + h + crossTieBreaker(nx, currentY); queue.push(neighbor, f); } } diff --git a/src/core/pathfinding/algorithms/AStar.Bounded.ts b/src/core/pathfinding/algorithms/AStar.WaterBounded.ts similarity index 68% rename from src/core/pathfinding/algorithms/AStar.Bounded.ts rename to src/core/pathfinding/algorithms/AStar.WaterBounded.ts index 923f06083..aa8bdd693 100644 --- a/src/core/pathfinding/algorithms/AStar.Bounded.ts +++ b/src/core/pathfinding/algorithms/AStar.WaterBounded.ts @@ -3,6 +3,16 @@ import { PathFinder } from "../types"; import { BucketQueue } from "./PriorityQueue"; 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 { heuristicWeight?: number; @@ -16,7 +26,7 @@ export interface SearchBounds { maxY: number; } -export class AStarBounded implements PathFinder { +export class AStarWaterBounded implements PathFinder { private stamp = 1; private readonly closedStamp: Uint32Array; @@ -36,7 +46,7 @@ export class AStarBounded implements PathFinder { ) { this.terrain = (map as any).terrain as Uint8Array; this.mapWidth = map.width(); - this.heuristicWeight = config?.heuristicWeight ?? 1; + this.heuristicWeight = config?.heuristicWeight ?? 3; this.maxIterations = config?.maxIterations ?? 100_000; this.closedStamp = new Uint32Array(maxSearchArea); @@ -45,7 +55,9 @@ export class AStarBounded implements PathFinder { this.cameFrom = new Int32Array(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); } @@ -133,6 +145,24 @@ export class AStarBounded implements PathFinder { queue.clear(); 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) { const startLocal = toLocal(s, true); if (startLocal < 0 || startLocal >= numLocalNodes) { @@ -143,7 +173,8 @@ export class AStarBounded implements PathFinder { cameFrom[startLocal] = -1; const sx = s % mapWidth; 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); } @@ -164,7 +195,6 @@ export class AStarBounded implements PathFinder { } const currentG = gScore[currentLocal]; - const tentativeG = currentG + 1; // Convert to global coords for neighbor calculation const current = toGlobal(currentLocal); @@ -174,10 +204,14 @@ export class AStarBounded implements PathFinder { if (currentY > minY) { const neighbor = current - mapWidth; const neighborLocal = currentLocal - boundsWidth; + const neighborTerrain = terrain[neighbor]; if ( 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 ( gScoreStamp[neighborLocal] !== stamp || tentativeG < gScore[neighborLocal] @@ -185,10 +219,12 @@ export class AStarBounded implements PathFinder { cameFrom[neighborLocal] = currentLocal; gScore[neighborLocal] = tentativeG; gScoreStamp[neighborLocal] = stamp; - const f = - tentativeG + + const ny = currentY - 1; + const h = 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); } } @@ -197,10 +233,14 @@ export class AStarBounded implements PathFinder { if (currentY < maxY) { const neighbor = current + mapWidth; const neighborLocal = currentLocal + boundsWidth; + const neighborTerrain = terrain[neighbor]; if ( 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 ( gScoreStamp[neighborLocal] !== stamp || tentativeG < gScore[neighborLocal] @@ -208,10 +248,12 @@ export class AStarBounded implements PathFinder { cameFrom[neighborLocal] = currentLocal; gScore[neighborLocal] = tentativeG; gScoreStamp[neighborLocal] = stamp; - const f = - tentativeG + + const ny = currentY + 1; + const h = 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); } } @@ -220,10 +262,14 @@ export class AStarBounded implements PathFinder { if (currentX > minX) { const neighbor = current - 1; const neighborLocal = currentLocal - 1; + const neighborTerrain = terrain[neighbor]; if ( 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 ( gScoreStamp[neighborLocal] !== stamp || tentativeG < gScore[neighborLocal] @@ -231,10 +277,12 @@ export class AStarBounded implements PathFinder { cameFrom[neighborLocal] = currentLocal; gScore[neighborLocal] = tentativeG; gScoreStamp[neighborLocal] = stamp; - const f = - tentativeG + + const nx = currentX - 1; + const h = 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); } } @@ -243,10 +291,14 @@ export class AStarBounded implements PathFinder { if (currentX < maxX) { const neighbor = current + 1; const neighborLocal = currentLocal + 1; + const neighborTerrain = terrain[neighbor]; if ( 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 ( gScoreStamp[neighborLocal] !== stamp || tentativeG < gScore[neighborLocal] @@ -254,10 +306,12 @@ export class AStarBounded implements PathFinder { cameFrom[neighborLocal] = currentLocal; gScore[neighborLocal] = tentativeG; gScoreStamp[neighborLocal] = stamp; - const f = - tentativeG + + const nx = currentX + 1; + const h = 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); } } diff --git a/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts b/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts index 019893d6a..ce8ceb2a7 100644 --- a/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts +++ b/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts @@ -1,39 +1,19 @@ import { GameMap, TileRef } from "../../game/GameMap"; +import { DebugSpan } from "../../utilities/DebugSpan"; import { PathFinder } from "../types"; import { AbstractGraphAStar } from "./AStar.AbstractGraph"; -import { AStarBounded } from "./AStar.Bounded"; +import { AStarWaterBounded } from "./AStar.WaterBounded"; import { AbstractGraph, AbstractNode } from "./AbstractGraph"; import { BFSGrid } from "./BFS.Grid"; 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 { private tileBFS: BFSGrid; private abstractAStar: AbstractGraphAStar; - private localAStar: AStarBounded; - private localAStarMultiCluster: AStarBounded; + private localAStar: AStarWaterBounded; + private localAStarMultiCluster: AStarWaterBounded; private sourceResolver: SourceResolver; - public debugInfo: PathDebugInfo | null = null; - public debugMode: boolean = false; - constructor( private map: GameMap, private graph: AbstractGraph, @@ -51,23 +31,31 @@ export class AStarWaterHierarchical implements PathFinder { // BoundedAStar for cluster-bounded local pathfinding const maxLocalNodes = clusterSize * clusterSize; - this.localAStar = new AStarBounded(map, maxLocalNodes); + this.localAStar = new AStarWaterBounded(map, maxLocalNodes); // BoundedAStar for multi-cluster (3x3) local pathfinding const multiClusterSize = clusterSize * 3; const maxMultiClusterNodes = multiClusterSize * multiClusterSize; - this.localAStarMultiCluster = new AStarBounded(map, maxMultiClusterNodes); + this.localAStarMultiCluster = new AStarWaterBounded( + map, + maxMultiClusterNodes, + ); // SourceResolver for multi-source search this.sourceResolver = new SourceResolver(this.map, this.graph); } findPath(from: number | number[], to: number): number[] | null { - if (Array.isArray(from)) { - return this.findPathMultiSource(from as TileRef[], to as TileRef); - } + return DebugSpan.wrap("AStar.WaterHierarchical:findPath", () => { + 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( @@ -94,192 +82,66 @@ export class AStarWaterHierarchical implements PathFinder { return this.findPathSingle(winningSource, target); } - findPathSingle( - 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, - }, - }; - } - + findPathSingle(from: TileRef, to: TileRef): TileRef[] | null { const dist = this.map.manhattanDist(from, to); // Early exit for very short distances if (dist <= this.graph.clusterSize) { - performance.mark("hpa:findPath:earlyExitLocalPath:start"); + DebugSpan.start("earlyExit"); const startX = this.map.x(from); const startY = this.map.y(from); const clusterX = Math.floor(startX / this.graph.clusterSize); const clusterY = Math.floor(startY / this.graph.clusterSize); const localPath = this.findLocalPath(from, to, clusterX, clusterY, true); - performance.mark("hpa:findPath:earlyExitLocalPath: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; - } + DebugSpan.end(); if (localPath) { - if (debug) { - console.log( - `[DEBUG] Direct local path found for dist=${dist}, length=${localPath.length}`, - ); - } 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 endNode = this.findNearestNode(to); - performance.mark("hpa:findPath:findNodes: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; - } + DebugSpan.end(); if (!startNode) { - if (debug) { - console.log( - `[DEBUG] Cannot find start node for (${this.map.x(from)}, ${this.map.y(from)})`, - ); - } return null; } if (!endNode) { - if (debug) { - console.log( - `[DEBUG] Cannot find end node for (${this.map.x(to)}, ${this.map.y(to)})`, - ); - } return null; } if (startNode.id === endNode.id) { - if (debug) { - 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"); + DebugSpan.start("sameNodeLocalPath"); const clusterX = Math.floor(startNode.x / this.graph.clusterSize); const clusterY = Math.floor(startNode.y / this.graph.clusterSize); const path = this.findLocalPath(from, to, clusterX, clusterY, true); - performance.mark("hpa:findPath:sameNodeLocalPath: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; - } - + DebugSpan.end(); return path; } - performance.mark("hpa:findPath:findAbstractPath:start"); + DebugSpan.start("abstractPath"); const nodePath = this.findAbstractPath(startNode.id, endNode.id); - performance.mark("hpa:findPath:findAbstractPath: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; - } + DebugSpan.end(); if (!nodePath) { - if (debug) { - console.log( - `[DEBUG] No abstract path between nodes ${startNode.id} and ${endNode.id}`, - ); - } return null; } - if (debug) { - console.log(`[DEBUG] Abstract path found: ${nodePath.length} waypoints`); - } + DebugSpan.set("nodePath", () => + nodePath + .map((nodeId) => { + const node = this.graph.getNode(nodeId); + return node ? node.tile : -1; + }) + .filter((tile) => tile !== -1), + ); const initialPath: TileRef[] = []; - performance.mark("hpa:findPath:buildInitialPath:start"); + DebugSpan.start("initialPath"); // 1. Find path from start to first node const firstNode = this.graph.getNode(nodePath[0])!; @@ -324,6 +186,10 @@ export class AStarWaterHierarchical implements PathFinder { if (cachedPath && cachedPath.length > 0) { // Path is cached for this exact direction, use as-is initialPath.push(...cachedPath.slice(1)); + DebugSpan.set( + "$cachedSegmentsUsed", + (prev) => ((prev as number) ?? 0) + 1, + ); continue; } } @@ -368,20 +234,7 @@ export class AStarWaterHierarchical implements PathFinder { initialPath.push(...endSegment.slice(1)); - performance.mark("hpa:findPath:buildInitialPath:end"); - 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`); - } + DebugSpan.set("initialPath", () => initialPath); // Smoothing moved to SmoothingTransformer - return raw path return initialPath; diff --git a/src/core/pathfinding/algorithms/AbstractGraph.ts b/src/core/pathfinding/algorithms/AbstractGraph.ts index d7be8faeb..f22b30c65 100644 --- a/src/core/pathfinding/algorithms/AbstractGraph.ts +++ b/src/core/pathfinding/algorithms/AbstractGraph.ts @@ -1,4 +1,5 @@ import { GameMap, TileRef } from "../../game/GameMap"; +import { DebugSpan } from "../../utilities/DebugSpan"; import { BFSGrid } from "./BFS.Grid"; import { ConnectedComponents } from "./ConnectedComponents"; @@ -25,16 +26,6 @@ export interface Cluster { 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 { // Nodes (array indexed by id) private readonly _nodes: AbstractNode[] = []; @@ -97,6 +88,10 @@ export class AbstractGraph { return edge.nodeA === nodeId ? edge.nodeB : edge.nodeA; } + getAllEdges(): readonly AbstractEdge[] { + return this._edges; + } + get edgeCount(): number { return this._edges.length; } @@ -217,8 +212,6 @@ export class AbstractGraphBuilder { private nextEdgeId = 0; private edgeBetween = new Map>(); - public debugInfo: BuildDebugInfo | null = null; - constructor( private readonly map: GameMap, private readonly clusterSize: number = AbstractGraphBuilder.CLUSTER_SIZE, @@ -231,8 +224,8 @@ export class AbstractGraphBuilder { this.waterComponents = new ConnectedComponents(map); } - build(debug: boolean = false): AbstractGraph { - performance.mark("abstractgraph:build:start"); + build(): AbstractGraph { + DebugSpan.start("AbstractGraphBuilder:build"); this.graph = new AbstractGraph( this.clusterSize, @@ -240,37 +233,8 @@ export class AbstractGraphBuilder { 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 - performance.mark("abstractgraph:build:water-component:start"); 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 for (let cy = 0; cy < this.clustersY; cy++) { @@ -281,98 +245,30 @@ export class AbstractGraphBuilder { } // 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 cx = 0; cx < this.clustersX; cx++) { this.processCluster(cx, cy); } } - performance.mark("abstractgraph:build:nodes: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; - } + DebugSpan.end(); // 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 cx = 0; cx < this.clustersX; cx++) { const cluster = this.graph.getCluster(cx, cy); 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); } } - performance.mark("abstractgraph:build:edges:end"); - const edgesMeasure = performance.measure( - "abstractgraph:build:edges", - "abstractgraph:build:edges:start", - "abstractgraph:build:edges:end", - ); + DebugSpan.end(); - if (debug) { - this.debugInfo!.actualBFSCalls = - this.debugInfo!.potentialBFSCalls! - - this.debugInfo!.skippedByComponentFilter!; - - 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; - } + DebugSpan.set("nodes", () => this.graph.getAllNodes()); + DebugSpan.set("edges", () => this.graph.getAllEdges()); + DebugSpan.set("nodesCount", () => this.graph.nodeCount); + DebugSpan.set("edgesCount", () => this.graph.edgeCount); + DebugSpan.set("clustersCount", () => this.clustersX * this.clustersY); // Initialize path cache after all edges are built this.graph._initPathCache(); @@ -380,6 +276,8 @@ export class AbstractGraphBuilder { // Store water components for componentId lookups this.graph.setWaterComponents(this.waterComponents); + DebugSpan.end(); // AbstractGraphBuilder:build + return this.graph; } diff --git a/src/core/pathfinding/algorithms/ConnectedComponents.ts b/src/core/pathfinding/algorithms/ConnectedComponents.ts index 93813b341..0d379d3c1 100644 --- a/src/core/pathfinding/algorithms/ConnectedComponents.ts +++ b/src/core/pathfinding/algorithms/ConnectedComponents.ts @@ -1,6 +1,7 @@ // Connected Component Labeling using flood-fill import { GameMap, TileRef } from "../../game/GameMap"; +import { DebugSpan } from "../../utilities/DebugSpan"; export const LAND_MARKER = 0xff; // Must fit in Uint8Array @@ -28,6 +29,7 @@ export class ConnectedComponents { } initialize(): void { + DebugSpan.start("ConnectedComponents:initialize"); let ids: Uint8Array | Uint16Array = this.createPrefilledIds(); let nextId = 0; @@ -52,6 +54,7 @@ export class ConnectedComponents { } this.componentIds = ids; + DebugSpan.end(); } /** diff --git a/src/core/pathfinding/smoothing/BresenhamPathSmoother.ts b/src/core/pathfinding/smoothing/BresenhamPathSmoother.ts deleted file mode 100644 index d4cafdba9..000000000 --- a/src/core/pathfinding/smoothing/BresenhamPathSmoother.ts +++ /dev/null @@ -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 { - 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 { - private smoother: BresenhamPathSmoother; - - constructor( - private inner: PathFinder, - 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; - } -} diff --git a/src/core/pathfinding/smoothing/PathSmoother.ts b/src/core/pathfinding/smoothing/PathSmoother.ts deleted file mode 100644 index fc4188afc..000000000 --- a/src/core/pathfinding/smoothing/PathSmoother.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * PathSmoother - interface for path smoothing algorithms. - * Takes a path and returns a smoothed version. - */ -export interface PathSmoother { - smooth(path: T[]): T[]; -} diff --git a/src/core/pathfinding/smoothing/SmoothingTransformer.ts b/src/core/pathfinding/smoothing/SmoothingTransformer.ts deleted file mode 100644 index ed1c60bff..000000000 --- a/src/core/pathfinding/smoothing/SmoothingTransformer.ts +++ /dev/null @@ -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 implements PathFinder { - constructor( - private inner: PathFinder, - private smoother: PathSmoother, - ) {} - - findPath(from: T | T[], to: T): T[] | null { - const path = this.inner.findPath(from, to); - return path ? this.smoother.smooth(path) : null; - } -} diff --git a/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts b/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts new file mode 100644 index 000000000..63b30c97f --- /dev/null +++ b/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts @@ -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 { + private readonly mapWidth: number; + private readonly localAStar: AStarWaterBounded; + private readonly terrain: Uint8Array; + private readonly isTraversable: (tile: TileRef) => boolean; + + constructor( + private inner: PathFinder, + 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); + } +} diff --git a/src/core/utilities/DebugSpan.ts b/src/core/utilities/DebugSpan.ts new file mode 100644 index 000000000..b33b96acf --- /dev/null +++ b/src/core/utilities/DebugSpan.ts @@ -0,0 +1,159 @@ +type Span = { + name: string; + timeStart: number; + timeEnd?: number; + duration?: number; + data: Record; + 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 => { + 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(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]; + }, +}; diff --git a/tests/pathfinding/playground/api/maps.ts b/tests/pathfinding/playground/api/maps.ts index 4bafc6fed..c1a7b084f 100644 --- a/tests/pathfinding/playground/api/maps.ts +++ b/tests/pathfinding/playground/api/maps.ts @@ -2,39 +2,40 @@ import { readdirSync, readFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; 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"; // 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"]; +// Note: "hpa.cached" runs same algorithm without debug overhead for fair timing comparison +export const COMPARISON_ADAPTERS = [ + "hpa.cached", + "hpa", + "a.baseline", + "a.generic", + "a.full", +]; export interface MapInfo { name: string; displayName: string; } +export interface GraphBuildData { + nodes: any[]; + edges: any[]; + nodesCount: number; + edgesCount: number; + clustersCount: number; + buildTime: number; +} + export interface MapCache { game: Game; - hpaStar: AStarWaterHierarchical; + graphBuildData: GraphBuildData | null; } const cache = new Map(); -/** - * 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 */ @@ -105,6 +106,25 @@ export function listMaps(): MapInfo[] { 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 */ @@ -116,21 +136,17 @@ export async function loadMap(mapName: string): Promise { const mapsDir = getMapsDirectory(); + // Enable DebugSpan to capture graph build data + DebugSpan.enable(); + // Use the existing setupFromPath utility to load the map const game = await setupFromPath(mapsDir, mapName, { disableNavMesh: false }); - // Get pre-built graph from game - const graph = game.miniWaterGraph(); - if (!graph) { - throw new Error(`No water graph available for map: ${mapName}`); - } + // Capture graph build data from DebugSpan + const graphBuildData = extractGraphBuildData(); + DebugSpan.disable(); - // Initialize AStarWaterHierarchical with minimap and graph - const hpaStar = new AStarWaterHierarchical(game.miniMap(), graph, { - cachePaths: config.cachePaths, - }); - - const cacheEntry: MapCache = { game, hpaStar }; + const cacheEntry: MapCache = { game, graphBuildData }; // Store in cache cache.set(mapName, cacheEntry); @@ -142,7 +158,7 @@ export async function loadMap(mapName: string): Promise { * Get map metadata for client */ export async function getMapMetadata(mapName: string) { - const { game, hpaStar } = await loadMap(mapName); + const { game, graphBuildData } = await loadMap(mapName); // Extract map data const mapData: number[] = []; @@ -153,49 +169,84 @@ export async function getMapMetadata(mapName: string) { } } - // Extract static graph data from GameMapHPAStar - // Access internal graph via type casting (test code only) - const graph = (hpaStar as any).graph; + const graph = game.miniWaterGraph(); const miniMap = game.miniMap(); + const clusterSize = graph?.clusterSize ?? 0; - // 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), - })); - - // Convert edges to client format - const edges: Array<{ + // Use graphBuildData from DebugSpan if available, otherwise fall back to direct access + let allNodes: Array<{ id: number; x: number; y: number }>; + let 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; + }>; - const nodeA = graph.getNode(edge.nodeA); - const nodeB = graph.getNode(edge.nodeB); - if (!nodeA || !nodeB) continue; + if (graphBuildData) { + // Convert nodes from DebugSpan data (AbstractNode format) + allNodes = graphBuildData.nodes.map((node: any) => ({ + id: node.id, + x: miniMap.x(node.tile), + y: miniMap.y(node.tile), + })); - 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, + // Convert edges from DebugSpan data (AbstractEdge format) + edges = graphBuildData.edges.map((edge: any) => { + const nodeA = graphBuildData.nodes.find((n: any) => n.id === edge.nodeA); + const nodeB = graphBuildData.nodes.find((n: any) => n.id === edge.nodeB); + return { + 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 { name: mapName, width: game.width(), @@ -205,6 +256,7 @@ export async function getMapMetadata(mapName: string) { allNodes, edges, clusterSize, + buildTime: graphBuildData?.buildTime, }, adapters: COMPARISON_ADAPTERS, }; diff --git a/tests/pathfinding/playground/api/pathfinding.ts b/tests/pathfinding/playground/api/pathfinding.ts index 9cc69aa53..e1a8fd32f 100644 --- a/tests/pathfinding/playground/api/pathfinding.ts +++ b/tests/pathfinding/playground/api/pathfinding.ts @@ -1,13 +1,7 @@ import { TileRef } from "../../../../src/core/game/GameMap.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 { PathFinding } from "../../../../src/core/pathfinding/PathFinder.js"; +import { SteppingPathFinder } from "../../../../src/core/pathfinding/types.js"; +import { DebugSpan } from "../../../../src/core/utilities/DebugSpan.js"; import { getAdapter } from "../../utils.js"; import { COMPARISON_ADAPTERS, loadMap } from "./maps.js"; @@ -19,6 +13,7 @@ interface PrimaryResult { debug: { nodePath: Array<[number, number]> | null; initialPath: Array<[number, number]> | null; + cachedSegmentsUsed: number | null; timings: Record; }; } @@ -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( - hpaStar: AStarWaterHierarchical, - game: any, - graph: any, -): PathFinder { - const miniMap = game.miniMap(); - const componentCheckFn = (t: TileRef) => graph.getComponentId(t); +function extractTimings(span: { + name: string; + duration?: number; + children: any[]; +}): Record { + const timings: Record = {}; - // 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); + if (span.duration !== undefined) { + timings[span.name] = span.duration; + } - return withMiniMap; + for (const child of span.children) { + Object.assign(timings, extractTimings(child)); + } + + return timings; } /** - * Compute primary path using AStarWaterHierarchical with debug info - * Uses the same transformer chain as PathFinding.Water() + * Compute primary path using PathFinding.Water with debug info */ function computePrimaryPath( - hpaStar: AStarWaterHierarchical, game: any, - graph: any, fromRef: TileRef, toRef: TileRef, ): PrimaryResult { const miniMap = game.miniMap(); - // Build wrapped pathfinder with all transformers - const wrappedPf = buildWrappedPathFinder(hpaStar, game, graph); + // Use standard PathFinding.Water + const pf = PathFinding.Water(game); - // Enable debug mode to capture internal state - hpaStar.debugMode = true; + // Enable DebugSpan to capture internal state + DebugSpan.enable(); - const start = performance.now(); - const path = wrappedPf.findPath(fromRef, toRef); - const time = performance.now() - start; + const path = pf.findPath(fromRef, toRef); - const debugInfo = hpaStar.debugInfo; + // Get span data and disable + const span = DebugSpan.getLastSpan(); + DebugSpan.disable(); // 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 spanNodePath = span?.data?.nodePath as TileRef[] | undefined; + if (spanNodePath) { + nodePath = spanNodePath.map((tile: TileRef) => { const x = miniMap.x(tile) * 2; const y = miniMap.y(tile) * 2; return [x, y] as [number, number]; @@ -138,22 +124,32 @@ function computePrimaryPath( // Convert initialPath (miniMap TileRefs) to full map coords let initialPath: Array<[number, number]> | null = null; - if (debugInfo?.initialPath) { - initialPath = debugInfo.initialPath.map((tile: TileRef) => { + const spanInitialPath = span?.data?.initialPath as TileRef[] | undefined; + if (spanInitialPath) { + initialPath = spanInitialPath.map((tile: TileRef) => { const x = miniMap.x(tile) * 2; const y = miniMap.y(tile) * 2; 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 { path: pathToCoords(path, game), length: path ? path.length : 0, - time, + time: timings["hpa:findPath"] || 0, debug: { nodePath, initialPath, - timings: debugInfo?.timings ?? {}, + cachedSegmentsUsed, + timings, }, }; } @@ -189,8 +185,7 @@ export async function computePath( to: [number, number], options: { adapters?: string[] } = {}, ): Promise { - const { game, hpaStar } = await loadMap(mapName); - const graph = game.miniWaterGraph(); + const { game } = await loadMap(mapName); // Convert coordinates to TileRefs 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`); } - // Compute primary path (HPA* with debug) - const primary = computePrimaryPath(hpaStar, game, graph, fromRef, toRef); + // Compute primary path (PathFinding.Water with debug) + const primary = computePrimaryPath(game, fromRef, toRef); // Compute comparison paths const selectedAdapters = options.adapters ?? COMPARISON_ADAPTERS; diff --git a/tests/pathfinding/playground/public/client.js b/tests/pathfinding/playground/public/client.js index 8c9d68d8e..0016ccdb7 100644 --- a/tests/pathfinding/playground/public/client.js +++ b/tests/pathfinding/playground/public/client.js @@ -20,6 +20,7 @@ const state = { // Colors for comparison paths const COMPARISON_COLORS = { + "hpa.cached": "#00ffff", // cyan hpa: "#ff8800", // orange "a.baseline": "#ff00ff", // magenta "a.generic": "#88ff00", // lime @@ -814,20 +815,6 @@ function updatePathInfo(result) { function updateTimingsPanel(result) { const primary = result.primary; 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"); if (primary && primary.length > 0) { hpaTilesEl.textContent = `- ${primary.length} tiles`; @@ -840,8 +827,9 @@ function updateTimingsPanel(result) { const earlyExitEl = document.getElementById("timingEarlyExit"); const earlyExitValueEl = document.getElementById("timingEarlyExitValue"); earlyExitEl.style.display = "flex"; - if (timings.earlyExitLocalPath !== undefined) { - earlyExitValueEl.textContent = `${timings.earlyExitLocalPath.toFixed(2)}ms`; + const earlyExitTime = timings["earlyExit"]; + if (earlyExitTime !== undefined) { + earlyExitValueEl.textContent = `${earlyExitTime.toFixed(2)}ms`; earlyExitValueEl.style.color = "#f5f5f5"; } else { earlyExitValueEl.textContent = "—"; @@ -852,8 +840,9 @@ function updateTimingsPanel(result) { const findNodesEl = document.getElementById("timingFindNodes"); const findNodesValueEl = document.getElementById("timingFindNodesValue"); findNodesEl.style.display = "flex"; - if (timings.findNodes !== undefined) { - findNodesValueEl.textContent = `${timings.findNodes.toFixed(2)}ms`; + const nodeLookupTime = timings["nodeLookup"]; + if (nodeLookupTime !== undefined) { + findNodesValueEl.textContent = `${nodeLookupTime.toFixed(2)}ms`; findNodesValueEl.style.color = "#f5f5f5"; } else { findNodesValueEl.textContent = "—"; @@ -866,8 +855,9 @@ function updateTimingsPanel(result) { "timingAbstractPathValue", ); abstractPathEl.style.display = "flex"; - if (timings.findAbstractPath !== undefined) { - abstractPathValueEl.textContent = `${timings.findAbstractPath.toFixed(2)}ms`; + const abstractPathTime = timings["abstractPath"]; + if (abstractPathTime !== undefined) { + abstractPathValueEl.textContent = `${abstractPathTime.toFixed(2)}ms`; abstractPathValueEl.style.color = "#f5f5f5"; } else { abstractPathValueEl.textContent = "—"; @@ -878,14 +868,28 @@ function updateTimingsPanel(result) { const initialPathEl = document.getElementById("timingInitialPath"); const initialPathValueEl = document.getElementById("timingInitialPathValue"); initialPathEl.style.display = "flex"; - if (timings.buildInitialPath !== undefined) { - initialPathValueEl.textContent = `${timings.buildInitialPath.toFixed(2)}ms`; + const initialPathTime = timings["initialPath"]; + if (initialPathTime !== undefined) { + initialPathValueEl.textContent = `${initialPathTime.toFixed(2)}ms`; initialPathValueEl.style.color = "#f5f5f5"; } else { initialPathValueEl.textContent = "—"; 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 const comparisonsSection = document.getElementById("comparisonsSection"); 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 const compTimes = result.comparisons ? result.comparisons.map((c) => c.time).filter((t) => t > 0) diff --git a/tests/pathfinding/playground/public/index.html b/tests/pathfinding/playground/public/index.html index 6dbe07701..f03d041d3 100644 --- a/tests/pathfinding/playground/public/index.html +++ b/tests/pathfinding/playground/public/index.html @@ -196,6 +196,10 @@ Initial Path: + diff --git a/tests/pathfinding/playground/public/styles.css b/tests/pathfinding/playground/public/styles.css index 8587fc022..0ecf5fecf 100644 --- a/tests/pathfinding/playground/public/styles.css +++ b/tests/pathfinding/playground/public/styles.css @@ -522,6 +522,10 @@ canvas { background: rgba(255, 255, 255, 0.15); } +.comparison-row.active .comp-name { + color: #fff; +} + .comparison-row:last-child { border-bottom: none; } diff --git a/tests/pathfinding/playground/server.ts b/tests/pathfinding/playground/server.ts index 9beed0456..3bedee84f 100644 --- a/tests/pathfinding/playground/server.ts +++ b/tests/pathfinding/playground/server.ts @@ -6,20 +6,9 @@ import { clearCache as clearMapCache, getMapMetadata, listMaps, - setConfig, } from "./api/maps.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 PORT = process.env.PORT ?? 5555; @@ -203,9 +192,6 @@ app.listen(PORT, () => { Server running at: http://localhost:${PORT} -Configuration: - - Path caching: ${noCache ? "disabled" : "enabled"} - Press Ctrl+C to stop `); }); diff --git a/tests/pathfinding/utils.ts b/tests/pathfinding/utils.ts index 33c157b39..46b7fd38f 100644 --- a/tests/pathfinding/utils.ts +++ b/tests/pathfinding/utils.ts @@ -10,7 +10,7 @@ import { GameType, PlayerInfo, } 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 { genTerrainFromBin, @@ -90,16 +90,24 @@ export function getAdapter( // 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 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": return PathFinding.Water(game);