From 0e3ced3bfab9a3798a718e997ed7041eb77c3e8b Mon Sep 17 00:00:00 2001 From: Arkadiusz Sygulski Date: Mon, 12 Jan 2026 05:11:14 +0100 Subject: [PATCH] Pathfinding Refactor pt. 2 (#2866) ## Playtest https://pf-pt-2.openfront.dev/ ## Pathfinding Refactor pt. 2 image This is a follow-up to a previous PR introducing pathfinding changes. This time, it introduces a complete refactor of `pathfinding` directory and breakdown into composable pieces. ### Unified PathFinder interface `PathFinder` and `SteppingPathFinder` are introduced to unify **all** pathfinding across the application. First one exposes complete path, while stepping variant allows the callee to iterate over the path by calling `.next`. All pathfinders share this one common interface, which makes them easy to use in any scenario - `PathFinding.Water(game).search(from, to)`. `SteppingPathFinder` extends `PathFinder` with an ability to iterate over the path. It handles caching, storing current index and invalidation. This allows the units to not care about the inner workings of the pathfinder and just call `pf.next(current, target)` and receive instructions on what to do next. ### Common entry point All pathfinders are now exposed from common `PathFinding` entrypoint: - `PathFinding.Water` - `PathFinding.Rail` - `PathFinding.Stations` - `PathFinding.Rail` Additional entry point is introduced for pathfinders which need to work both in the worker, but also on the frontend, which lacks `Game` interface. Currently only `UniversalPathFinding.Parabola` is available. ### Spatial Query New module has been introduced close to `pathfinding` - `SpatialQuery`. It aims to resolve any questions game may have about finding tiles meeting criteria. Currently `SpatialQuery.closestShore(player, target)` and `SpatialQuery.closestShoreByWater(player, target)` are available - they help answering questions about naval invasion: "What is the best landing location from user's click?" and "Which our tile should be used to launch the transport ship?". Under the hood they use very similar mechanics to pathfinding, so it felt right to put them close by. ### Modular architecture Pathfinders now support transformers: `MiniMapTransformer`, `ShoreCoercingTransformer`, `ComponentCheckTransformer`, `SmoothingTransformer`. Transformers functions like a middleware in the pathfinding chain. They wrap around the pathfinder and provide additional functionality. This allows the pathfinder to focus on actually finding the path instead of doing unrelated things. Example chain for simple (A*) water pathfinding: ```ts static WaterSimple(game: Game): SteppingPathFinder { const miniMap = game.miniMap(); const pf = new AStarWater(miniMap); return PathFinderBuilder.create(pf) .wrap((pf) => new ShoreCoercingTransformer(pf, miniMap)) .wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap)) .buildWithStepper(tileStepperConfig(game)); } ``` The Pathfinder - here `AStarWater` - does not care about the conversion between minimap and main map tiles. It also does not care if the source or destination is a land tile. The transformers take care of that. The pathfinder gets a set of valid coordinates and produces the path - that's it. Modular approach makes working on a particular set of utilities much easier - for example map upscaling is handled consistently across all pathfinders. Additionally, the pathfinders are not tied to the particular map resolution used. Pass them a different map and they will work the same. ### Algorithms Algorithms used are neatly organized inside `src/core/pathfinding/algorithms`. They are prefixed with the algorithm name and suffixed with the use case. File without suffix exposes generic version ready to traverse any graph with adapters. Specialized versions either use an adapter or inline logic when performance is critical - using adapters leads to 20-30% performance loss. The directory includes `A*` and `BFS` but also other useful utils, such as `AbstractGraph` used to generate... an abstract graph on top of the tile map and `ConnectedComponents` helping to identify whether two tiles are connected by a path without actually computing the path. ### Playground The playground have been updated with new algorithms, including tweaked very greedy `A*`. image ### Tests Yeah, there are some, a little too many if I say so myself. But there are no useless tests. I had to ensure refactored code works somehow reliably. This PR comes with trust me bro guarantee, but I would appreciate someone confirming **naval invasions, nukes (esp. MIRV) and warships**. ### Discord `moleole` GL & HF --- .../layers/NukeTrajectoryPreviewLayer.ts | 22 +- src/core/execution/MIRVExecution.ts | 25 +- src/core/execution/NukeExecution.ts | 29 +- src/core/execution/SAMMissileExecution.ts | 16 +- src/core/execution/ShellExecution.ts | 15 +- src/core/execution/TradeShipExecution.ts | 7 +- src/core/execution/TransportShipExecution.ts | 82 +- src/core/execution/WarshipExecution.ts | 34 +- .../execution/nation/NationNukeBehavior.ts | 20 +- src/core/game/Game.ts | 8 +- src/core/game/GameImpl.ts | 95 +- src/core/game/PlayerImpl.ts | 2 +- src/core/game/RailNetwork.ts | 2 + src/core/game/RailNetworkImpl.ts | 60 +- src/core/game/TrainStation.ts | 25 +- src/core/game/TransportShipUtils.ts | 235 +---- src/core/pathfinding/AStar.ts | 31 - src/core/pathfinding/MiniAStar.ts | 177 ---- src/core/pathfinding/PathFinder.Air.ts | 66 ++ src/core/pathfinding/PathFinder.Parabola.ts | 90 ++ src/core/pathfinding/PathFinder.Station.ts | 78 ++ src/core/pathfinding/PathFinder.ts | 125 ++- src/core/pathfinding/PathFinderBuilder.ts | 42 + src/core/pathfinding/PathFinderStepper.ts | 119 +++ src/core/pathfinding/PathFinding.ts | 217 ----- src/core/pathfinding/SerialAStar.ts | 189 ---- .../pathfinding/adapters/MiniAStarAdapter.ts | 66 -- .../pathfinding/adapters/NavMeshAdapter.ts | 99 --- .../algorithms/AStar.AbstractGraph.ts | 249 ++++++ .../pathfinding/algorithms/AStar.Bounded.ts | 289 ++++++ src/core/pathfinding/algorithms/AStar.Rail.ts | 101 +++ .../pathfinding/algorithms/AStar.Water.ts | 203 +++++ .../algorithms/AStar.WaterHierarchical.ts | 562 ++++++++++++ src/core/pathfinding/algorithms/AStar.ts | 127 +++ .../pathfinding/algorithms/AbstractGraph.ts | 682 +++++++++++++++ .../FastBFS.ts => algorithms/BFS.Grid.ts} | 62 +- src/core/pathfinding/algorithms/BFS.ts | 64 ++ .../ConnectedComponents.ts} | 10 +- .../pathfinding/algorithms/PriorityQueue.ts | 154 ++++ src/core/pathfinding/navmesh/FastAStar.ts | 202 ----- .../pathfinding/navmesh/FastAStarAdapter.ts | 120 --- src/core/pathfinding/navmesh/GatewayGraph.ts | 587 ------------- src/core/pathfinding/navmesh/NavMesh.ts | 819 ------------------ .../smoothing/BresenhamPathSmoother.ts | 168 ++++ .../pathfinding/smoothing/PathSmoother.ts | 7 + .../smoothing/SmoothingTransformer.ts | 18 + src/core/pathfinding/spatial/SpatialQuery.ts | 90 ++ .../transformers/ComponentCheckTransformer.ts | 35 + .../transformers/MiniMapTransformer.ts | 128 +++ .../transformers/ShoreCoercingTransformer.ts | 91 ++ src/core/pathfinding/types.ts | 34 + tests/Disconnected.test.ts | 1 + .../executions/TradeShipExecution.test.ts | 2 +- tests/core/pathfinding/PathFinder.test.ts | 333 ------- .../pathfinding/PathFinderStepper.test.ts | 179 ++++ .../core/pathfinding/PathFinding.Air.test.ts | 184 ++++ .../core/pathfinding/PathFinding.Rail.test.ts | 36 + .../pathfinding/PathFinding.Water.test.ts | 277 ++++++ tests/core/pathfinding/SpatialQuery.test.ts | 230 +++++ .../UniversalPathFinding.Parabola.test.ts | 320 +++++++ .../core/pathfinding/WaterComponents.test.ts | 145 ++++ tests/core/pathfinding/_fixtures.ts | 179 ++++ .../ComponentCheckTransformer.test.ts | 169 ++++ .../transformers/MiniMapTransformer.test.ts | 179 ++++ .../ShoreCoercingTransformer.test.ts | 245 ++++++ tests/core/pathfinding/utils.ts | 135 --- tests/pathfinding/benchmark/compare.ts | 180 ++++ tests/pathfinding/playground/api/maps.ts | 114 ++- .../pathfinding/playground/api/pathfinding.ts | 290 ++++--- tests/pathfinding/playground/public/client.js | 696 +++++++-------- .../pathfinding/playground/public/index.html | 79 +- .../pathfinding/playground/public/styles.css | 61 ++ tests/pathfinding/playground/server.ts | 78 +- tests/pathfinding/utils.ts | 74 +- tests/perf/AstarPerf.ts | 36 - 75 files changed, 6800 insertions(+), 4200 deletions(-) delete mode 100644 src/core/pathfinding/AStar.ts delete mode 100644 src/core/pathfinding/MiniAStar.ts create mode 100644 src/core/pathfinding/PathFinder.Air.ts create mode 100644 src/core/pathfinding/PathFinder.Parabola.ts create mode 100644 src/core/pathfinding/PathFinder.Station.ts create mode 100644 src/core/pathfinding/PathFinderBuilder.ts create mode 100644 src/core/pathfinding/PathFinderStepper.ts delete mode 100644 src/core/pathfinding/PathFinding.ts delete mode 100644 src/core/pathfinding/SerialAStar.ts delete mode 100644 src/core/pathfinding/adapters/MiniAStarAdapter.ts delete mode 100644 src/core/pathfinding/adapters/NavMeshAdapter.ts create mode 100644 src/core/pathfinding/algorithms/AStar.AbstractGraph.ts create mode 100644 src/core/pathfinding/algorithms/AStar.Bounded.ts create mode 100644 src/core/pathfinding/algorithms/AStar.Rail.ts create mode 100644 src/core/pathfinding/algorithms/AStar.Water.ts create mode 100644 src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts create mode 100644 src/core/pathfinding/algorithms/AStar.ts create mode 100644 src/core/pathfinding/algorithms/AbstractGraph.ts rename src/core/pathfinding/{navmesh/FastBFS.ts => algorithms/BFS.Grid.ts} (66%) create mode 100644 src/core/pathfinding/algorithms/BFS.ts rename src/core/pathfinding/{navmesh/WaterComponents.ts => algorithms/ConnectedComponents.ts} (95%) create mode 100644 src/core/pathfinding/algorithms/PriorityQueue.ts delete mode 100644 src/core/pathfinding/navmesh/FastAStar.ts delete mode 100644 src/core/pathfinding/navmesh/FastAStarAdapter.ts delete mode 100644 src/core/pathfinding/navmesh/GatewayGraph.ts delete mode 100644 src/core/pathfinding/navmesh/NavMesh.ts create mode 100644 src/core/pathfinding/smoothing/BresenhamPathSmoother.ts create mode 100644 src/core/pathfinding/smoothing/PathSmoother.ts create mode 100644 src/core/pathfinding/smoothing/SmoothingTransformer.ts create mode 100644 src/core/pathfinding/spatial/SpatialQuery.ts create mode 100644 src/core/pathfinding/transformers/ComponentCheckTransformer.ts create mode 100644 src/core/pathfinding/transformers/MiniMapTransformer.ts create mode 100644 src/core/pathfinding/transformers/ShoreCoercingTransformer.ts create mode 100644 src/core/pathfinding/types.ts delete mode 100644 tests/core/pathfinding/PathFinder.test.ts create mode 100644 tests/core/pathfinding/PathFinderStepper.test.ts create mode 100644 tests/core/pathfinding/PathFinding.Air.test.ts create mode 100644 tests/core/pathfinding/PathFinding.Rail.test.ts create mode 100644 tests/core/pathfinding/PathFinding.Water.test.ts create mode 100644 tests/core/pathfinding/SpatialQuery.test.ts create mode 100644 tests/core/pathfinding/UniversalPathFinding.Parabola.test.ts create mode 100644 tests/core/pathfinding/WaterComponents.test.ts create mode 100644 tests/core/pathfinding/_fixtures.ts create mode 100644 tests/core/pathfinding/transformers/ComponentCheckTransformer.test.ts create mode 100644 tests/core/pathfinding/transformers/MiniMapTransformer.test.ts create mode 100644 tests/core/pathfinding/transformers/ShoreCoercingTransformer.test.ts delete mode 100644 tests/core/pathfinding/utils.ts create mode 100644 tests/pathfinding/benchmark/compare.ts delete mode 100644 tests/perf/AstarPerf.ts diff --git a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts index 32bd471f0..36bf818c7 100644 --- a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts +++ b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts @@ -2,7 +2,7 @@ import { EventBus } from "../../../core/EventBus"; import { UnitType } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView } from "../../../core/game/GameView"; -import { ParabolaPathFinder } from "../../../core/pathfinding/PathFinding"; +import { UniversalPathFinding } from "../../../core/pathfinding/PathFinder"; import { GhostStructureChangedEvent, MouseMoveEvent, @@ -211,20 +211,16 @@ export class NukeTrajectoryPreviewLayer implements Layer { const targetTile = this.game.ref(worldCoords.x, worldCoords.y); - // Calculate trajectory using ParabolaPathFinder with cached spawn tile - const pathFinder = new ParabolaPathFinder(this.game); + // Calculate trajectory using ParabolaUniversalPathFinder with cached spawn tile const speed = this.game.config().defaultNukeSpeed(); - const distanceBasedHeight = true; // AtomBomb/HydrogenBomb use distance-based height + const pathFinder = UniversalPathFinding.Parabola(this.game, { + increment: speed, + distanceBasedHeight: true, // AtomBomb/HydrogenBomb use distance-based height + directionUp: this.uiState.rocketDirectionUp, + }); - pathFinder.computeControlPoints( - this.cachedSpawnTile, - targetTile, - speed, - distanceBasedHeight, - this.uiState.rocketDirectionUp, - ); - - this.trajectoryPoints = pathFinder.allTiles(); + this.trajectoryPoints = + pathFinder.findPath(this.cachedSpawnTile, targetTile) ?? []; // NOTE: This is a lot to do in the rendering method, naive // But trajectory is already calculated here and needed for prediction. diff --git a/src/core/execution/MIRVExecution.ts b/src/core/execution/MIRVExecution.ts index e17b3ceeb..ebdba59e5 100644 --- a/src/core/execution/MIRVExecution.ts +++ b/src/core/execution/MIRVExecution.ts @@ -8,7 +8,9 @@ import { UnitType, } from "../game/Game"; import { TileRef } from "../game/GameMap"; -import { ParabolaPathFinder } from "../pathfinding/PathFinding"; +import { UniversalPathFinding } from "../pathfinding/PathFinder"; +import { ParabolaUniversalPathFinder } from "../pathfinding/PathFinder.Parabola"; +import { PathStatus } from "../pathfinding/types"; import { PseudoRandom } from "../PseudoRandom"; import { simpleHash } from "../Util"; import { NukeExecution } from "./NukeExecution"; @@ -30,11 +32,12 @@ export class MirvExecution implements Execution { private random: PseudoRandom; - private pathFinder: ParabolaPathFinder; + private pathFinder: ParabolaUniversalPathFinder; private targetPlayer: Player | TerraNullius; private separateDst: TileRef; + private spawnTile: TileRef; private speed: number = -1; @@ -46,9 +49,11 @@ export class MirvExecution implements Execution { init(mg: Game, ticks: number): void { this.random = new PseudoRandom(mg.ticks() + simpleHash(this.player.id())); this.mg = mg; - this.pathFinder = new ParabolaPathFinder(mg); this.targetPlayer = this.mg.owner(this.dst); this.speed = this.mg.config().defaultNukeSpeed(); + this.pathFinder = UniversalPathFinding.Parabola(mg, { + increment: this.speed, + }); // Betrayal on launch if (this.targetPlayer.isPlayer()) { @@ -70,6 +75,7 @@ export class MirvExecution implements Execution { this.active = false; return; } + this.spawnTile = spawn; this.nuke = this.player.buildUnit(UnitType.MIRV, spawn, { targetTile: this.dst, }); @@ -79,7 +85,6 @@ export class MirvExecution implements Execution { ); const y = Math.max(0, this.mg.y(this.dst) - 500) + 50; this.separateDst = this.mg.ref(x, y); - this.pathFinder.computeControlPoints(spawn, this.separateDst); this.mg.displayIncomingUnit( this.nuke.id(), @@ -90,15 +95,19 @@ export class MirvExecution implements Execution { ); } - const result = this.pathFinder.nextTile(this.speed); - if (result === true) { + const result = this.pathFinder.next( + this.spawnTile, + this.separateDst, + this.speed, + ); + if (result.status === PathStatus.COMPLETE) { this.separate(); this.active = false; // Record stats this.mg.stats().bombLand(this.player, this.targetPlayer, UnitType.MIRV); return; - } else { - this.nuke.move(result); + } else if (result.status === PathStatus.NEXT) { + this.nuke.move(result.node); } } diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 17cf878ea..6cc3965ad 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -10,7 +10,9 @@ import { UnitType, } from "../game/Game"; import { TileRef } from "../game/GameMap"; -import { ParabolaPathFinder } from "../pathfinding/PathFinding"; +import { UniversalPathFinding } from "../pathfinding/PathFinder"; +import { ParabolaUniversalPathFinder } from "../pathfinding/PathFinder.Parabola"; +import { PathStatus } from "../pathfinding/types"; import { PseudoRandom } from "../PseudoRandom"; import { NukeType } from "../StatsSchemas"; import { computeNukeBlastCounts } from "./Util"; @@ -22,7 +24,7 @@ export class NukeExecution implements Execution { private mg: Game; private nuke: Unit | null = null; private tilesToDestroyCache: Set | undefined; - private pathFinder: ParabolaPathFinder; + private pathFinder: ParabolaUniversalPathFinder; constructor( private nukeType: NukeType, @@ -39,7 +41,11 @@ export class NukeExecution implements Execution { if (this.speed === -1) { this.speed = this.mg.config().defaultNukeSpeed(); } - this.pathFinder = new ParabolaPathFinder(mg); + this.pathFinder = UniversalPathFinding.Parabola(mg, { + increment: this.speed, + distanceBasedHeight: this.nukeType !== UnitType.MIRVWarhead, + directionUp: this.rocketDirectionUp, + }); } public target(): Player | TerraNullius { @@ -123,13 +129,6 @@ export class NukeExecution implements Execution { return; } this.src = spawn; - this.pathFinder.computeControlPoints( - spawn, - this.dst, - this.speed, - this.nukeType !== UnitType.MIRVWarhead, - this.rocketDirectionUp, - ); this.nuke = this.player.buildUnit(this.nukeType, spawn, { targetTile: this.dst, trajectory: this.getTrajectory(this.dst), @@ -186,13 +185,13 @@ export class NukeExecution implements Execution { } // Move to next tile - const nextTile = this.pathFinder.nextTile(this.speed); - if (nextTile === true) { + const result = this.pathFinder.next(this.src!, this.dst, this.speed); + if (result.status === PathStatus.COMPLETE) { this.detonate(); return; - } else { + } else if (result.status === PathStatus.NEXT) { this.updateNukeTargetable(); - this.nuke.move(nextTile); + this.nuke.move(result.node); // Update index so SAM can interpolate future position this.nuke.setTrajectoryIndex(this.pathFinder.currentIndex()); } @@ -206,7 +205,7 @@ export class NukeExecution implements Execution { const trajectoryTiles: TrajectoryTile[] = []; const targetRangeSquared = this.mg.config().defaultNukeTargetableRange() ** 2; - const allTiles: TileRef[] = this.pathFinder.allTiles(); + const allTiles = this.pathFinder.findPath(this.src!, target) ?? []; for (const tile of allTiles) { trajectoryTiles.push({ tile, diff --git a/src/core/execution/SAMMissileExecution.ts b/src/core/execution/SAMMissileExecution.ts index a8d0862f6..7fb4346ce 100644 --- a/src/core/execution/SAMMissileExecution.ts +++ b/src/core/execution/SAMMissileExecution.ts @@ -7,13 +7,13 @@ import { UnitType, } from "../game/Game"; import { TileRef } from "../game/GameMap"; -import { AirPathFinder } from "../pathfinding/PathFinding"; -import { PseudoRandom } from "../PseudoRandom"; +import { PathFinding } from "../pathfinding/PathFinder"; +import { PathStatus, SteppingPathFinder } from "../pathfinding/types"; import { NukeType } from "../StatsSchemas"; export class SAMMissileExecution implements Execution { private active = true; - private pathFinder: AirPathFinder; + private pathFinder: SteppingPathFinder; private SAMMissile: Unit | undefined; private mg: Game; private speed: number = 0; @@ -27,7 +27,7 @@ export class SAMMissileExecution implements Execution { ) {} init(mg: Game, ticks: number): void { - this.pathFinder = new AirPathFinder(mg, new PseudoRandom(mg.ticks())); + this.pathFinder = PathFinding.Air(mg); this.mg = mg; this.speed = this.mg.config().defaultSamMissileSpeed(); } @@ -55,11 +55,11 @@ export class SAMMissileExecution implements Execution { return; } for (let i = 0; i < this.speed; i++) { - const result = this.pathFinder.nextTile( + const result = this.pathFinder.next( this.SAMMissile.tile(), this.targetTile, ); - if (result === true) { + if (result.status === PathStatus.COMPLETE) { this.mg.displayMessage( "events_display.missile_intercepted", MessageType.SAM_HIT, @@ -76,8 +76,8 @@ export class SAMMissileExecution implements Execution { .stats() .bombIntercept(this._owner, this.target.type() as NukeType, 1); return; - } else { - this.SAMMissile.move(result); + } else if (result.status === PathStatus.NEXT) { + this.SAMMissile.move(result.node); } } } diff --git a/src/core/execution/ShellExecution.ts b/src/core/execution/ShellExecution.ts index 4bf1103ec..b28101e16 100644 --- a/src/core/execution/ShellExecution.ts +++ b/src/core/execution/ShellExecution.ts @@ -1,11 +1,12 @@ import { Execution, Game, Player, Unit, UnitType } from "../game/Game"; import { TileRef } from "../game/GameMap"; -import { AirPathFinder } from "../pathfinding/PathFinding"; +import { PathFinding } from "../pathfinding/PathFinder"; +import { PathStatus, SteppingPathFinder } from "../pathfinding/types"; import { PseudoRandom } from "../PseudoRandom"; export class ShellExecution implements Execution { private active = true; - private pathFinder: AirPathFinder; + private pathFinder: SteppingPathFinder; private shell: Unit | undefined; private mg: Game; private destroyAtTick: number = -1; @@ -19,7 +20,7 @@ export class ShellExecution implements Execution { ) {} init(mg: Game, ticks: number): void { - this.pathFinder = new AirPathFinder(mg, new PseudoRandom(mg.ticks())); + this.pathFinder = PathFinding.Air(mg); this.mg = mg; this.random = new PseudoRandom(mg.ticks()); } @@ -45,18 +46,18 @@ export class ShellExecution implements Execution { } for (let i = 0; i < 3; i++) { - const result = this.pathFinder.nextTile( + const result = this.pathFinder.next( this.shell.tile(), this.target.tile(), ); - if (result === true) { + if (result.status === PathStatus.COMPLETE) { this.active = false; this.target.modifyHealth(-this.effectOnTarget(), this._owner); this.shell.setReachedTarget(); this.shell.delete(false); return; - } else { - this.shell.move(result); + } else if (result.status === PathStatus.NEXT) { + this.shell.move(result.node); } } } diff --git a/src/core/execution/TradeShipExecution.ts b/src/core/execution/TradeShipExecution.ts index c421e17aa..2de607b69 100644 --- a/src/core/execution/TradeShipExecution.ts +++ b/src/core/execution/TradeShipExecution.ts @@ -8,7 +8,8 @@ import { UnitType, } from "../game/Game"; import { TileRef } from "../game/GameMap"; -import { PathFinder, PathFinders, PathStatus } from "../pathfinding/PathFinder"; +import { PathFinding } from "../pathfinding/PathFinder"; +import { PathStatus, SteppingPathFinder } from "../pathfinding/types"; import { distSortUnit } from "../Util"; export class TradeShipExecution implements Execution { @@ -16,7 +17,7 @@ export class TradeShipExecution implements Execution { private mg: Game; private tradeShip: Unit | undefined; private wasCaptured = false; - private pathFinder: PathFinder; + private pathFinder: SteppingPathFinder; private tilesTraveled = 0; constructor( @@ -27,7 +28,7 @@ export class TradeShipExecution implements Execution { init(mg: Game, ticks: number): void { this.mg = mg; - this.pathFinder = PathFinders.Water(mg); + this.pathFinder = PathFinding.Water(mg); } tick(ticks: number): void { diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index b5a8c2d2f..73c3b1028 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -11,7 +11,8 @@ import { } from "../game/Game"; import { TileRef } from "../game/GameMap"; import { targetTransportTile } from "../game/TransportShipUtils"; -import { PathFinder, PathFinders, PathStatus } from "../pathfinding/PathFinder"; +import { PathFinding } from "../pathfinding/PathFinder"; +import { PathStatus, SteppingPathFinder } from "../pathfinding/types"; import { AttackExecution } from "./AttackExecution"; const malusForRetreat = 25; @@ -29,11 +30,10 @@ export class TransportShipExecution implements Execution { // TODO make private public path: TileRef[]; private dst: TileRef | null; - private dstShore: TileRef | null; private boat: Unit; - private pathFinder: PathFinder; + private pathFinder: SteppingPathFinder; private originalOwner: Player; @@ -70,7 +70,7 @@ export class TransportShipExecution implements Execution { this.lastMove = ticks; this.mg = mg; - this.pathFinder = PathFinders.Water(mg); + this.pathFinder = PathFinding.Water(mg); if ( this.attacker.unitCount(UnitType.TransportShip) >= @@ -106,8 +106,8 @@ export class TransportShipExecution implements Execution { this.startTroops = Math.min(this.startTroops, this.attacker.troops()); - this.dstShore = targetTransportTile(this.mg, this.ref); - if (this.dstShore === null) { + this.dst = targetTransportTile(this.mg, this.ref); + if (this.dst === null) { console.warn( `${this.attacker} cannot send ship to ${this.target}, cannot find attack tile`, ); @@ -115,18 +115,9 @@ export class TransportShipExecution implements Execution { return; } - this.dst = this.adjacentWater(this.dstShore); - if (this.dst === null) { - console.warn( - `${this.attacker} cannot find water tile adjacent to destination`, - ); - this.active = false; - return; - } - const closestTileSrc = this.attacker.canBuild( UnitType.TransportShip, - this.dstShore, + this.dst, ); if (closestTileSrc === false) { console.warn(`can't build transport ship`); @@ -152,21 +143,10 @@ export class TransportShipExecution implements Execution { this.boat = this.attacker.buildUnit(UnitType.TransportShip, this.src, { troops: this.startTroops, - targetTile: this.dst ?? undefined, }); - // Move boat from shore to adjacent water for pathfinding - const spawnWater = this.adjacentWater(this.src); - if (spawnWater === null) { - console.warn(`No adjacent water for transport ship spawn`); - this.boat.delete(false); - this.active = false; - return; - } - this.boat.move(spawnWater); - - if (this.dstShore !== null) { - this.boat.setTargetTile(this.dstShore); + if (this.dst !== null) { + this.boat.setTargetTile(this.dst); } else { this.boat.setTargetTile(undefined); } @@ -222,7 +202,6 @@ export class TransportShipExecution implements Execution { if (this.mg.owner(this.src!) !== this.attacker) { // Use bestTransportShipSpawn, not canBuild because of its max boats check etc const newSrc = this.attacker.bestTransportShipSpawn(this.dst); - if (newSrc === false) { this.src = null; } else { @@ -239,19 +218,10 @@ export class TransportShipExecution implements Execution { this.active = false; return; } else { - this.dstShore = this.src; - const retreatWater = this.adjacentWater(this.src); - if (retreatWater === null) { - console.warn(`No adjacent water for retreat destination`); - this.attacker.addTroops(this.boat.troops()); - this.boat.delete(false); - this.active = false; - return; - } - this.dst = retreatWater; + this.dst = this.src; - if (this.boat.targetTile() !== this.dstShore) { - this.boat.setTargetTile(this.dstShore!); + if (this.boat.targetTile() !== this.dst) { + this.boat.setTargetTile(this.dst); } } } @@ -259,7 +229,7 @@ export class TransportShipExecution implements Execution { const result = this.pathFinder.next(this.boat.tile(), this.dst); switch (result.status) { case PathStatus.COMPLETE: - if (this.mg.owner(this.dstShore!) === this.attacker) { + if (this.mg.owner(this.dst) === this.attacker) { const deaths = this.boat.troops() * (malusForRetreat / 100); const survivors = this.boat.troops() - deaths; this.attacker.addTroops(survivors); @@ -281,7 +251,7 @@ export class TransportShipExecution implements Execution { } return; } - this.attacker.conquer(this.dstShore!); + this.attacker.conquer(this.dst); if (this.target.isPlayer() && this.attacker.isFriendly(this.target)) { this.attacker.addTroops(this.boat.troops()); } else { @@ -290,7 +260,7 @@ export class TransportShipExecution implements Execution { this.boat.troops(), this.attacker, this.targetID, - this.dstShore!, + this.dst, false, ), ); @@ -308,13 +278,18 @@ export class TransportShipExecution implements Execution { break; case PathStatus.PENDING: break; - case PathStatus.NOT_FOUND: + case PathStatus.NOT_FOUND: { // TODO: add to poisoned port list - console.warn(`path not found to dst`); + const map = this.mg.map(); + const boatTile = this.boat.tile(); + console.warn( + `TransportShip path not found: boat@(${map.x(boatTile)},${map.y(boatTile)}) -> dst@(${map.x(this.dst)},${map.y(this.dst)}), attacker=${this.attacker.id()}, target=${this.targetID}`, + ); this.attacker.addTroops(this.boat.troops()); this.boat.delete(false); this.active = false; return; + } } } @@ -325,17 +300,4 @@ export class TransportShipExecution implements Execution { isActive(): boolean { return this.active; } - - private adjacentWater(tile: TileRef): TileRef | null { - if (this.mg.isWater(tile)) { - return tile; - } - - for (const neighbor of this.mg.neighbors(tile)) { - if (this.mg.isWater(neighbor)) { - return neighbor; - } - } - return null; - } } diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts index 01ca55126..6a0845d64 100644 --- a/src/core/execution/WarshipExecution.ts +++ b/src/core/execution/WarshipExecution.ts @@ -8,7 +8,8 @@ import { UnitType, } from "../game/Game"; import { TileRef } from "../game/GameMap"; -import { PathFinder, PathFinders, PathStatus } from "../pathfinding/PathFinder"; +import { PathFinding } from "../pathfinding/PathFinder"; +import { PathStatus, SteppingPathFinder } from "../pathfinding/types"; import { PseudoRandom } from "../PseudoRandom"; import { ShellExecution } from "./ShellExecution"; @@ -16,7 +17,7 @@ export class WarshipExecution implements Execution { private random: PseudoRandom; private warship: Unit; private mg: Game; - private pathfinder: PathFinder; + private pathfinder: SteppingPathFinder; private lastShellAttack = 0; private alreadySentShell = new Set(); @@ -26,7 +27,7 @@ export class WarshipExecution implements Execution { init(mg: Game, ticks: number): void { this.mg = mg; - this.pathfinder = PathFinders.Water(mg); + this.pathfinder = PathFinding.Water(mg); this.random = new PseudoRandom(mg.ticks()); if (isUnit(this.input)) { this.warship = this.input; @@ -193,9 +194,10 @@ export class WarshipExecution implements Execution { case PathStatus.PENDING: this.warship.touch(); break; - case PathStatus.NOT_FOUND: + case PathStatus.NOT_FOUND: { console.log(`path not found to target`); break; + } } } } @@ -223,10 +225,10 @@ export class WarshipExecution implements Execution { case PathStatus.PENDING: this.warship.touch(); return; - case PathStatus.NOT_FOUND: - console.warn(`path not found to target tile`); - this.warship.setTargetTile(undefined); + case PathStatus.NOT_FOUND: { + console.log(`path not found to target`); break; + } } } @@ -243,6 +245,10 @@ export class WarshipExecution implements Execution { const maxAttemptBeforeExpand: number = 500; let attempts: number = 0; let expandCount: number = 0; + + // Get warship's water component for connectivity check + const warshipComponent = this.mg.getWaterComponent(this.warship.tile()); + while (expandCount < 3) { const x = this.mg.x(this.warship.patrolTile()!) + @@ -267,6 +273,20 @@ export class WarshipExecution implements Execution { } continue; } + // Check water component connectivity + if ( + warshipComponent !== null && + !this.mg.hasWaterComponent(tile, warshipComponent) + ) { + attempts++; + if (attempts === maxAttemptBeforeExpand) { + expandCount++; + attempts = 0; + warshipPatrolRange = + warshipPatrolRange + Math.floor(warshipPatrolRange / 2); + } + continue; + } return tile; } console.warn( diff --git a/src/core/execution/nation/NationNukeBehavior.ts b/src/core/execution/nation/NationNukeBehavior.ts index ef0ebea24..cddd8b88e 100644 --- a/src/core/execution/nation/NationNukeBehavior.ts +++ b/src/core/execution/nation/NationNukeBehavior.ts @@ -11,7 +11,7 @@ import { UnitType, } from "../../game/Game"; import { TileRef, euclDistFN } from "../../game/GameMap"; -import { ParabolaPathFinder } from "../../pathfinding/PathFinding"; +import { UniversalPathFinding } from "../../pathfinding/PathFinder"; import { PseudoRandom } from "../../PseudoRandom"; import { assertNever, boundingBoxTiles } from "../../Util"; import { NukeExecution } from "../NukeExecution"; @@ -456,20 +456,14 @@ export class NationNukeBehavior { spawnTile: TileRef, targetTile: TileRef, ): boolean { - const pathFinder = new ParabolaPathFinder(this.game); const speed = this.game.config().defaultNukeSpeed(); - const distanceBasedHeight = true; // Atom/Hydrogen bombs use distance-based height - const rocketDirectionUp = true; // AI nukes always go "up" for now + const pathFinder = UniversalPathFinding.Parabola(this.game, { + increment: speed, + distanceBasedHeight: true, // Atom/Hydrogen bombs use distance-based height + directionUp: true, // AI nukes always go "up" for now + }); - pathFinder.computeControlPoints( - spawnTile, - targetTile, - speed, - distanceBasedHeight, - rocketDirectionUp, - ); - - const trajectory = pathFinder.allTiles(); + const trajectory = pathFinder.findPath(spawnTile, targetTile) ?? []; if (trajectory.length === 0) { return false; } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 8d732c271..53de9657c 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -1,5 +1,6 @@ import { Config } from "../configuration/Config"; -import { NavMesh } from "../pathfinding/navmesh/NavMesh"; +import { AbstractGraph } from "../pathfinding/algorithms/AbstractGraph"; +import { PathFinder } from "../pathfinding/types"; import { AllPlayersStats, ClientID } from "../Schemas"; import { getClanTag } from "../Util"; import { GameMap, TileRef } from "./GameMap"; @@ -802,7 +803,10 @@ export interface Game extends GameMap { addUpdate(update: GameUpdate): void; railNetwork(): RailNetwork; conquerPlayer(conqueror: Player, conquered: Player): void; - navMesh(): NavMesh | null; + miniWaterHPA(): PathFinder | null; + miniWaterGraph(): AbstractGraph | null; + getWaterComponent(tile: TileRef): number | null; + hasWaterComponent(tile: TileRef, component: number): boolean; } export interface PlayerActions { diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 47c0cc46c..926466208 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -1,6 +1,11 @@ import { renderNumber } from "../../client/Utils"; import { Config } from "../configuration/Config"; -import { NavMesh } from "../pathfinding/navmesh/NavMesh"; +import { + AbstractGraph, + AbstractGraphBuilder, +} from "../pathfinding/algorithms/AbstractGraph"; +import { AStarWaterHierarchical } from "../pathfinding/algorithms/AStar.WaterHierarchical"; +import { PathFinder } from "../pathfinding/types"; import { AllPlayersStats, ClientID, Winner } from "../Schemas"; import { simpleHash } from "../Util"; import { AllianceImpl } from "./AllianceImpl"; @@ -87,7 +92,8 @@ export class GameImpl implements Game { private nextAllianceID: number = 0; private _isPaused: boolean = false; - private _navMesh: NavMesh | null = null; + private _miniWaterGraph: AbstractGraph | null = null; + private _miniWaterHPA: AStarWaterHierarchical | null = null; constructor( private _humans: PlayerInfo[], @@ -108,8 +114,14 @@ export class GameImpl implements Game { this.addPlayers(); if (!_config.disableNavMesh()) { - this._navMesh = new NavMesh(this, { cachePaths: true }); - this._navMesh.initialize(); + const graphBuilder = new AbstractGraphBuilder(this.miniGameMap); + this._miniWaterGraph = graphBuilder.build(); + + this._miniWaterHPA = new AStarWaterHierarchical( + this.miniGameMap, + this._miniWaterGraph, + { cachePaths: true }, + ); } } @@ -966,8 +978,79 @@ export class GameImpl implements Game { railNetwork(): RailNetwork { return this._railNetwork; } - navMesh(): NavMesh | null { - return this._navMesh; + miniWaterHPA(): PathFinder | null { + return this._miniWaterHPA; + } + miniWaterGraph(): AbstractGraph | null { + return this._miniWaterGraph; + } + getWaterComponent(tile: TileRef): number | null { + // Permissive fallback for tests with disableNavMesh + if (!this._miniWaterGraph) return 0; + + const miniX = Math.floor(this._map.x(tile) / 2); + const miniY = Math.floor(this._map.y(tile) / 2); + const miniTile = this.miniGameMap.ref(miniX, miniY); + + if (this.miniGameMap.isWater(miniTile)) { + return this._miniWaterGraph.getComponentId(miniTile); + } + + // Shore tile: find water neighbor (expand search for minimap resolution loss) + for (const n of this.miniGameMap.neighbors(miniTile)) { + if (this.miniGameMap.isWater(n)) { + return this._miniWaterGraph.getComponentId(n); + } + } + + // Extended search: check 2-hop neighbors for narrow straits + for (const n of this.miniGameMap.neighbors(miniTile)) { + for (const n2 of this.miniGameMap.neighbors(n)) { + if (this.miniGameMap.isWater(n2)) { + return this._miniWaterGraph.getComponentId(n2); + } + } + } + return null; + } + hasWaterComponent(tile: TileRef, component: number): boolean { + // Permissive fallback for tests with disableNavMesh + if (!this._miniWaterGraph) return true; + + const miniX = Math.floor(this._map.x(tile) / 2); + const miniY = Math.floor(this._map.y(tile) / 2); + const miniTile = this.miniGameMap.ref(miniX, miniY); + + // Check miniTile itself (shore in full map may be water in minimap) + if ( + this.miniGameMap.isWater(miniTile) && + this._miniWaterGraph.getComponentId(miniTile) === component + ) { + return true; + } + + // Check neighbors + for (const n of this.miniGameMap.neighbors(miniTile)) { + if ( + this.miniGameMap.isWater(n) && + this._miniWaterGraph.getComponentId(n) === component + ) { + return true; + } + } + + // Extended search: check 2-hop neighbors for narrow straits + for (const n of this.miniGameMap.neighbors(miniTile)) { + for (const n2 of this.miniGameMap.neighbors(n)) { + if ( + this.miniGameMap.isWater(n2) && + this._miniWaterGraph.getComponentId(n2) === component + ) { + return true; + } + } + } + return false; } conquerPlayer(conqueror: Player, conquered: Player) { if (conquered.isDisconnected() && conqueror.isOnSameTeam(conquered)) { diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index b11c09354..3b773576e 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -1259,6 +1259,6 @@ export class PlayerImpl implements Player { } bestTransportShipSpawn(targetTile: TileRef): TileRef | false { - return bestShoreDeploymentSource(this.mg, this, targetTile); + return bestShoreDeploymentSource(this.mg, this, targetTile) ?? false; } } diff --git a/src/core/game/RailNetwork.ts b/src/core/game/RailNetwork.ts index 404c062be..64d55c187 100644 --- a/src/core/game/RailNetwork.ts +++ b/src/core/game/RailNetwork.ts @@ -1,8 +1,10 @@ import { Unit } from "./Game"; +import { StationManager } from "./RailNetworkImpl"; import { TrainStation } from "./TrainStation"; export interface RailNetwork { connectStation(station: TrainStation): void; removeStation(unit: Unit): void; findStationsPath(from: TrainStation, to: TrainStation): TrainStation[]; + stationManager(): StationManager; } diff --git a/src/core/game/RailNetworkImpl.ts b/src/core/game/RailNetworkImpl.ts index ca1a3319e..711221120 100644 --- a/src/core/game/RailNetworkImpl.ts +++ b/src/core/game/RailNetworkImpl.ts @@ -1,12 +1,10 @@ import { RailroadExecution } from "../execution/RailroadExecution"; -import { PathFindResultType } from "../pathfinding/AStar"; -import { MiniAStar } from "../pathfinding/MiniAStar"; -import { SerialAStar } from "../pathfinding/SerialAStar"; +import { PathFinding } from "../pathfinding/PathFinder"; import { Game, Unit, UnitType } from "./Game"; import { TileRef } from "./GameMap"; import { RailNetwork } from "./RailNetwork"; import { Railroad } from "./Railroad"; -import { Cluster, TrainStation, TrainStationMapAdapter } from "./TrainStation"; +import { Cluster, TrainStation } from "./TrainStation"; /** * The Stations handle their own neighbors so the graph is naturally traversable, @@ -18,16 +16,23 @@ export interface StationManager { removeStation(station: TrainStation): void; findStation(unit: Unit): TrainStation | null; getAll(): Set; + getById(id: number): TrainStation | undefined; + count(): number; } export class StationManagerImpl implements StationManager { private stations: Set = new Set(); + private stationsById: (TrainStation | undefined)[] = []; + private nextId = 0; addStation(station: TrainStation) { + station.id = this.nextId++; + this.stationsById[station.id] = station; this.stations.add(station); } removeStation(station: TrainStation) { + this.stationsById[station.id] = undefined; this.stations.delete(station); } @@ -41,6 +46,14 @@ export class StationManagerImpl implements StationManager { getAll(): Set { return this.stations; } + + getById(id: number): TrainStation | undefined { + return this.stationsById[id]; + } + + count(): number { + return this.nextId; + } } export interface RailPathFinderService { @@ -52,32 +65,11 @@ class RailPathFinderServiceImpl implements RailPathFinderService { constructor(private game: Game) {} findTilePath(from: TileRef, to: TileRef): TileRef[] { - const astar = new MiniAStar( - this.game.map(), - this.game.miniMap(), - from, - to, - 5000, - 20, - false, - 3, - ); - return astar.compute() === PathFindResultType.Completed - ? astar.reconstructPath() - : []; + return PathFinding.Rail(this.game).findPath(from, to) ?? []; } findStationsPath(from: TrainStation, to: TrainStation): TrainStation[] { - const stationAStar = new SerialAStar( - from, - to, - 5000, - 20, - new TrainStationMapAdapter(this.game), - ); - return stationAStar.compute() === PathFindResultType.Completed - ? stationAStar.reconstructPath() - : []; + return PathFinding.Stations(this.game).findPath(from, to) ?? []; } } @@ -92,22 +84,26 @@ export class RailNetworkImpl implements RailNetwork { constructor( private game: Game, - private stationManager: StationManager, + private _stationManager: StationManager, private pathService: RailPathFinderService, ) {} + stationManager(): StationManager { + return this._stationManager; + } + connectStation(station: TrainStation) { - this.stationManager.addStation(station); + this._stationManager.addStation(station); this.connectToNearbyStations(station); } removeStation(unit: Unit): void { - const station = this.stationManager.findStation(unit); + const station = this._stationManager.findStation(unit); if (!station) return; const neighbors = station.neighbors(); this.disconnectFromNetwork(station); - this.stationManager.removeStation(station); + this._stationManager.removeStation(station); const cluster = station.getCluster(); if (!cluster) return; @@ -142,7 +138,7 @@ export class RailNetworkImpl implements RailNetwork { for (const neighbor of neighbors) { if (neighbor.unit === station.unit) continue; - const neighborStation = this.stationManager.findStation(neighbor.unit); + const neighborStation = this._stationManager.findStation(neighbor.unit); if (!neighborStation) continue; const distanceToStation = this.distanceFrom( diff --git a/src/core/game/TrainStation.ts b/src/core/game/TrainStation.ts index 9318fab25..170cfab7a 100644 --- a/src/core/game/TrainStation.ts +++ b/src/core/game/TrainStation.ts @@ -1,5 +1,4 @@ import { TrainExecution } from "../execution/TrainExecution"; -import { GraphAdapter } from "../pathfinding/SerialAStar"; import { PseudoRandom } from "../PseudoRandom"; import { Game, Player, Unit, UnitType } from "./Game"; import { TileRef } from "./GameMap"; @@ -72,6 +71,7 @@ export function createTrainStopHandlers( } export class TrainStation { + id: number = -1; // assigned by StationManager private readonly stopHandlers: Partial> = {}; private cluster: Cluster | null; @@ -171,29 +171,6 @@ export class TrainStation { } } -/** - * Make the trainstation usable with A* - */ -export class TrainStationMapAdapter implements GraphAdapter { - constructor(private game: Game) {} - - neighbors(node: TrainStation): TrainStation[] { - return node.neighbors(); - } - - cost(node: TrainStation): number { - return 1; - } - - position(node: TrainStation): { x: number; y: number } { - return { x: this.game.x(node.tile()), y: this.game.y(node.tile()) }; - } - - isTraversable(from: TrainStation, to: TrainStation): boolean { - return true; - } -} - /** * Cluster of connected stations */ diff --git a/src/core/game/TransportShipUtils.ts b/src/core/game/TransportShipUtils.ts index 4d60f6bed..b53a11688 100644 --- a/src/core/game/TransportShipUtils.ts +++ b/src/core/game/TransportShipUtils.ts @@ -1,7 +1,6 @@ -import { PathFindResultType } from "../pathfinding/AStar"; -import { MiniAStar } from "../pathfinding/MiniAStar"; +import { SpatialQuery } from "../pathfinding/spatial/SpatialQuery"; import { Game, Player, UnitType } from "./Game"; -import { andFN, GameMap, manhattanDistFN, TileRef } from "./GameMap"; +import { TileRef } from "./GameMap"; export function canBuildTransportShip( game: Game, @@ -27,236 +26,20 @@ export function canBuildTransportShip( return false; } - if (game.isOceanShore(dst)) { - let myPlayerBordersOcean = false; - for (const bt of player.borderTiles()) { - if (game.isOceanShore(bt)) { - myPlayerBordersOcean = true; - break; - } - } - - let otherPlayerBordersOcean = false; - if (!game.hasOwner(tile)) { - otherPlayerBordersOcean = true; - } else { - for (const bt of (other as Player).borderTiles()) { - if (game.isOceanShore(bt)) { - otherPlayerBordersOcean = true; - break; - } - } - } - - if (myPlayerBordersOcean && otherPlayerBordersOcean) { - return transportShipSpawn(game, player, dst); - } else { - return false; - } - } - - // Now we are boating in a lake, so do a bfs from target until we find - // a border tile owned by the player - - const tiles = game.bfs( - dst, - andFN( - manhattanDistFN(dst, 300), - (_, t: TileRef) => game.isLake(t) || game.isShore(t), - ), - ); - - const sorted = Array.from(tiles).sort( - (a, b) => game.manhattanDist(dst, a) - game.manhattanDist(dst, b), - ); - - for (const t of sorted) { - if (game.owner(t) === player) { - return transportShipSpawn(game, player, t); - } - } - return false; -} - -function transportShipSpawn( - game: Game, - player: Player, - targetTile: TileRef, -): TileRef | false { - if (!game.isShore(targetTile)) { - return false; - } - const spawn = closestShoreFromPlayer(game, player, targetTile); - if (spawn === null) { - return false; - } - return spawn; -} - -export function sourceDstOceanShore( - gm: Game, - src: Player, - tile: TileRef, -): [TileRef | null, TileRef | null] { - const dst = gm.owner(tile); - const srcTile = closestShoreFromPlayer(gm, src, tile); - let dstTile: TileRef | null = null; - if (dst.isPlayer()) { - dstTile = closestShoreFromPlayer(gm, dst as Player, tile); - } else { - dstTile = closestShoreTN(gm, tile, 50); - } - return [srcTile, dstTile]; + const spatial = new SpatialQuery(game); + return spatial.closestShoreByWater(player, dst) ?? false; } export function targetTransportTile(gm: Game, tile: TileRef): TileRef | null { - const dst = gm.playerBySmallID(gm.ownerID(tile)); - let dstTile: TileRef | null = null; - if (dst.isPlayer()) { - dstTile = closestShoreFromPlayer(gm, dst as Player, tile); - } else { - dstTile = closestShoreTN(gm, tile, 50); - } - return dstTile; -} - -export function closestShoreFromPlayer( - gm: GameMap, - player: Player, - target: TileRef, -): TileRef | null { - const shoreTiles = Array.from(player.borderTiles()).filter((t) => - gm.isShore(t), - ); - if (shoreTiles.length === 0) { - return null; - } - - return shoreTiles.reduce((closest, current) => { - const closestDistance = gm.manhattanDist(target, closest); - const currentDistance = gm.manhattanDist(target, current); - return currentDistance < closestDistance ? current : closest; - }); + const spatial = new SpatialQuery(gm); + return spatial.closestShore(gm.owner(tile), tile); } export function bestShoreDeploymentSource( gm: Game, player: Player, - target: TileRef, -): TileRef | false { - const t = targetTransportTile(gm, target); - if (t === null) return false; - - const candidates = candidateShoreTiles(gm, player, t); - if (candidates.length === 0) return false; - - const aStar = new MiniAStar(gm, gm.miniMap(), candidates, t, 1_000_000, 1); - const result = aStar.compute(); - if (result !== PathFindResultType.Completed) { - console.warn(`bestShoreDeploymentSource: path not found: ${result}`); - return false; - } - const path = aStar.reconstructPath(); - if (path.length === 0) { - return false; - } - const potential = path[0]; - // Since mini a* downscales the map, we need to check the neighbors - // of the potential tile to find a valid deployment point - const neighbors = gm - .neighbors(potential) - .filter((n) => gm.isShore(n) && gm.owner(n) === player); - if (neighbors.length === 0) { - return false; - } - return neighbors[0]; -} - -export function candidateShoreTiles( - gm: Game, - player: Player, - target: TileRef, -): TileRef[] { - let closestManhattanDistance = Infinity; - let minX = Infinity, - minY = Infinity, - maxX = -Infinity, - maxY = -Infinity; - - let bestByManhattan: TileRef | null = null; - const extremumTiles: Record = { - minX: null, - minY: null, - maxX: null, - maxY: null, - }; - - const borderShoreTiles = Array.from(player.borderTiles()).filter((t) => - gm.isShore(t), - ); - - for (const tile of borderShoreTiles) { - const distance = gm.manhattanDist(tile, target); - const cell = gm.cell(tile); - - // Manhattan-closest tile - if (distance < closestManhattanDistance) { - closestManhattanDistance = distance; - bestByManhattan = tile; - } - - // Extremum tiles - if (cell.x < minX) { - minX = cell.x; - extremumTiles.minX = tile; - } else if (cell.y < minY) { - minY = cell.y; - extremumTiles.minY = tile; - } else if (cell.x > maxX) { - maxX = cell.x; - extremumTiles.maxX = tile; - } else if (cell.y > maxY) { - maxY = cell.y; - extremumTiles.maxY = tile; - } - } - - // Calculate sampling interval to ensure we get at most 50 tiles - const samplingInterval = Math.max( - 10, - Math.ceil(borderShoreTiles.length / 50), - ); - const sampledTiles = borderShoreTiles.filter( - (_, index) => index % samplingInterval === 0, - ); - - const candidates = [ - bestByManhattan, - extremumTiles.minX, - extremumTiles.minY, - extremumTiles.maxX, - extremumTiles.maxY, - ...sampledTiles, - ].filter(Boolean) as number[]; - - return candidates; -} - -function closestShoreTN( - gm: GameMap, - tile: TileRef, - searchDist: number, + dst: TileRef, ): TileRef | null { - const tn = Array.from( - gm.bfs( - tile, - andFN((_, t) => !gm.hasOwner(t), manhattanDistFN(tile, searchDist)), - ), - ) - .filter((t) => gm.isShore(t)) - .sort((a, b) => gm.manhattanDist(tile, a) - gm.manhattanDist(tile, b)); - if (tn.length === 0) { - return null; - } - return tn[0]; + const spatial = new SpatialQuery(gm); + return spatial.closestShoreByWater(player, dst); } diff --git a/src/core/pathfinding/AStar.ts b/src/core/pathfinding/AStar.ts deleted file mode 100644 index f16accf4e..000000000 --- a/src/core/pathfinding/AStar.ts +++ /dev/null @@ -1,31 +0,0 @@ -export interface AStar { - compute(): PathFindResultType; - reconstructPath(): NodeType[]; -} - -export enum PathFindResultType { - NextTile, - Pending, - Completed, - PathNotFound, -} -export type AStarResult = - | { - type: PathFindResultType.NextTile; - node: NodeType; - } - | { - type: PathFindResultType.Pending; - } - | { - type: PathFindResultType.Completed; - node: NodeType; - } - | { - type: PathFindResultType.PathNotFound; - }; - -export interface Point { - x: number; - y: number; -} diff --git a/src/core/pathfinding/MiniAStar.ts b/src/core/pathfinding/MiniAStar.ts deleted file mode 100644 index 216a91edd..000000000 --- a/src/core/pathfinding/MiniAStar.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { Cell } from "../game/Game"; -import { GameMap, TileRef } from "../game/GameMap"; -import { AStar, PathFindResultType } from "./AStar"; -import { GraphAdapter, SerialAStar } from "./SerialAStar"; - -export class GameMapAdapter implements GraphAdapter { - private readonly waterPenalty = 3; - constructor( - private gameMap: GameMap, - private waterPath: boolean, - ) {} - - neighbors(node: TileRef): TileRef[] { - return this.gameMap.neighbors(node); - } - - cost(node: TileRef): number { - let base = this.gameMap.cost(node); - // Avoid crossing water when possible - if (!this.waterPath && this.gameMap.isWater(node)) { - base += this.waterPenalty; - } - return base; - } - - position(node: TileRef): { x: number; y: number } { - return { x: this.gameMap.x(node), y: this.gameMap.y(node) }; - } - - isTraversable(from: TileRef, to: TileRef): boolean { - const toWater = this.gameMap.isWater(to); - if (this.waterPath) { - return toWater; - } - // Allow water access from/to shore - const fromShore = this.gameMap.isShoreline(from); - const toShore = this.gameMap.isShoreline(to); - return !toWater || fromShore || toShore; - } -} -export class MiniAStar implements AStar { - private aStar: AStar; - - constructor( - private gameMap: GameMap, - private miniMap: GameMap, - private src: TileRef | TileRef[], - private dst: TileRef, - iterations: number, - maxTries: number, - waterPath: boolean = true, - directionChangePenalty: number = 0, - ) { - const srcArray: TileRef[] = Array.isArray(src) ? src : [src]; - const miniSrc = srcArray.map((srcPoint) => - this.miniMap.ref( - Math.floor(gameMap.x(srcPoint) / 2), - Math.floor(gameMap.y(srcPoint) / 2), - ), - ); - - const miniDst = this.miniMap.ref( - Math.floor(gameMap.x(dst) / 2), - Math.floor(gameMap.y(dst) / 2), - ); - - this.aStar = new SerialAStar( - miniSrc, - miniDst, - iterations, - maxTries, - new GameMapAdapter(miniMap, waterPath), - directionChangePenalty, - ); - } - - compute(): PathFindResultType { - return this.aStar.compute(); - } - - reconstructPath(): TileRef[] { - let cellSrc: Cell | undefined; - if (!Array.isArray(this.src)) { - cellSrc = new Cell(this.gameMap.x(this.src), this.gameMap.y(this.src)); - } - const cellDst = new Cell( - this.gameMap.x(this.dst), - this.gameMap.y(this.dst), - ); - const upscaled = fixExtremes( - upscalePath( - this.aStar - .reconstructPath() - .map((tr) => new Cell(this.miniMap.x(tr), this.miniMap.y(tr))), - ), - cellDst, - cellSrc, - ); - return upscaled.map((c) => this.gameMap.ref(c.x, c.y)); - } -} - -function fixExtremes(upscaled: Cell[], cellDst: Cell, cellSrc?: Cell): Cell[] { - if (cellSrc !== undefined) { - const srcIndex = findCell(upscaled, cellSrc); - if (srcIndex === -1) { - // didn't find the start tile in the path - upscaled.unshift(cellSrc); - } else if (srcIndex !== 0) { - // found start tile but not at the start - // remove all tiles before the start tile - upscaled = upscaled.slice(srcIndex); - } - } - - const dstIndex = findCell(upscaled, cellDst); - if (dstIndex === -1) { - // didn't find the dst tile in the path - upscaled.push(cellDst); - } else if (dstIndex !== upscaled.length - 1) { - // found dst tile but not at the end - // remove all tiles after the dst tile - upscaled = upscaled.slice(0, dstIndex + 1); - } - return upscaled; -} - -function upscalePath(path: Cell[], scaleFactor: number = 2): Cell[] { - // Scale up each point - const scaledPath = path.map( - (point) => new Cell(point.x * scaleFactor, point.y * scaleFactor), - ); - - const smoothPath: Cell[] = []; - - for (let i = 0; i < scaledPath.length - 1; i++) { - const current = scaledPath[i]; - const next = scaledPath[i + 1]; - - // Add the current point - smoothPath.push(current); - - // Always interpolate between scaled points - const dx = next.x - current.x; - const dy = next.y - current.y; - - // Calculate number of steps needed - const distance = Math.max(Math.abs(dx), Math.abs(dy)); - const steps = distance; - - // Add intermediate points - for (let step = 1; step < steps; step++) { - smoothPath.push( - new Cell( - Math.round(current.x + (dx * step) / steps), - Math.round(current.y + (dy * step) / steps), - ), - ); - } - } - - // Add the last point - if (scaledPath.length > 0) { - smoothPath.push(scaledPath[scaledPath.length - 1]); - } - - return smoothPath; -} - -function findCell(upscaled: Cell[], cellDst: Cell): number { - for (let i = 0; i < upscaled.length; i++) { - if (upscaled[i].x === cellDst.x && upscaled[i].y === cellDst.y) { - return i; - } - } - return -1; -} diff --git a/src/core/pathfinding/PathFinder.Air.ts b/src/core/pathfinding/PathFinder.Air.ts new file mode 100644 index 000000000..504427643 --- /dev/null +++ b/src/core/pathfinding/PathFinder.Air.ts @@ -0,0 +1,66 @@ +import { Game } from "../game/Game"; +import { TileRef } from "../game/GameMap"; +import { PseudoRandom } from "../PseudoRandom"; +import { PathFinder } from "./types"; + +export class AirPathFinder implements PathFinder { + private seed: number; + + constructor(private game: Game) { + this.seed = game.ticks(); + } + + findPath(from: TileRef | TileRef[], to: TileRef): TileRef[] | null { + if (Array.isArray(from)) { + throw new Error("AirPathFinder does not support multiple start points"); + } + + const random = new PseudoRandom(this.seed); + const path: TileRef[] = [from]; + let current = from; + + while (current !== to) { + const next = this.computeNext(current, to, random); + if (next === current) break; // Prevent infinite loop if something breaks + current = next; + path.push(current); + } + + return path; + } + + private computeNext( + from: TileRef, + to: TileRef, + random: PseudoRandom, + ): TileRef { + const x = this.game.x(from); + const y = this.game.y(from); + const dstX = this.game.x(to); + const dstY = this.game.y(to); + + if (x === dstX && y === dstY) { + return to; + } + + let nextX = x; + let nextY = y; + const ratio = Math.floor(1 + Math.abs(dstY - y) / (Math.abs(dstX - x) + 1)); + + if (x === dstX) { + // Can only move in Y + nextY += y < dstY ? 1 : -1; + } else if (y === dstY) { + // Can only move in X + nextX += x < dstX ? 1 : -1; + } else { + if (random.chance(ratio)) { + nextX += x < dstX ? 1 : -1; + } else { + nextY += y < dstY ? 1 : -1; + } + } + + return this.game.ref(nextX, nextY); + } +} diff --git a/src/core/pathfinding/PathFinder.Parabola.ts b/src/core/pathfinding/PathFinder.Parabola.ts new file mode 100644 index 000000000..c58f49fbc --- /dev/null +++ b/src/core/pathfinding/PathFinder.Parabola.ts @@ -0,0 +1,90 @@ +import { GameMap, TileRef } from "../game/GameMap"; +import { within } from "../Util"; +import { DistanceBasedBezierCurve } from "../utilities/Line"; +import { PathResult, PathStatus, SteppingPathFinder } from "./types"; + +export interface ParabolaOptions { + increment?: number; + distanceBasedHeight?: boolean; + directionUp?: boolean; +} + +const PARABOLA_MIN_HEIGHT = 50; + +export class ParabolaUniversalPathFinder + implements SteppingPathFinder +{ + private curve: DistanceBasedBezierCurve | null = null; + private lastTo: TileRef | null = null; + + constructor( + private gameMap: GameMap, + private options?: ParabolaOptions, + ) {} + + private createCurve(from: TileRef, to: TileRef): DistanceBasedBezierCurve { + const increment = this.options?.increment ?? 3; + const distanceBasedHeight = this.options?.distanceBasedHeight ?? true; + const directionUp = this.options?.directionUp ?? true; + + const p0 = { x: this.gameMap.x(from), y: this.gameMap.y(from) }; + const p3 = { x: this.gameMap.x(to), y: this.gameMap.y(to) }; + const dx = p3.x - p0.x; + const dy = p3.y - p0.y; + const distance = Math.sqrt(dx * dx + dy * dy); + const maxHeight = distanceBasedHeight + ? Math.max(distance / 3, PARABOLA_MIN_HEIGHT) + : 0; + const heightMult = directionUp ? -1 : 1; + const mapHeight = this.gameMap.height(); + + const p1 = { + x: p0.x + dx / 4, + y: within(p0.y + dy / 4 + heightMult * maxHeight, 0, mapHeight - 1), + }; + const p2 = { + x: p0.x + (dx * 3) / 4, + y: within(p0.y + (dy * 3) / 4 + heightMult * maxHeight, 0, mapHeight - 1), + }; + + return new DistanceBasedBezierCurve(p0, p1, p2, p3, increment); + } + + findPath(from: TileRef | TileRef[], to: TileRef): TileRef[] | null { + if (Array.isArray(from)) { + throw new Error( + "ParabolaUniversalPathFinder does not support multiple start points", + ); + } + const curve = this.createCurve(from, to); + return curve + .getAllPoints() + .map((p) => this.gameMap.ref(Math.floor(p.x), Math.floor(p.y))); + } + + next(from: TileRef, to: TileRef, speed?: number): PathResult { + if (this.lastTo !== to) { + this.curve = this.createCurve(from, to); + this.lastTo = to; + } + + const nextPoint = this.curve!.increment(speed ?? 1); + if (!nextPoint) { + return { status: PathStatus.COMPLETE, node: to }; + } + const tile = this.gameMap.ref( + Math.floor(nextPoint.x), + Math.floor(nextPoint.y), + ); + return { status: PathStatus.NEXT, node: tile }; + } + + invalidate(): void { + this.curve = null; + this.lastTo = null; + } + + currentIndex(): number { + return this.curve?.getCurrentIndex() ?? 0; + } +} diff --git a/src/core/pathfinding/PathFinder.Station.ts b/src/core/pathfinding/PathFinder.Station.ts new file mode 100644 index 000000000..8510fb048 --- /dev/null +++ b/src/core/pathfinding/PathFinder.Station.ts @@ -0,0 +1,78 @@ +import { Game } from "../game/Game"; +import { StationManager } from "../game/RailNetworkImpl"; +import { TrainStation } from "../game/TrainStation"; +import { AStar, AStarAdapter } from "./algorithms/AStar"; +import { PathFinder } from "./types"; + +export class StationPathFinder implements PathFinder { + private manager: StationManager; + private aStar: AStar; + + constructor(game: Game) { + this.manager = game.railNetwork().stationManager(); + const adapter = new StationGraphAdapter(game, this.manager); + this.aStar = new AStar({ adapter }); + } + + findPath( + from: TrainStation | TrainStation[], + to: TrainStation, + ): TrainStation[] | null { + const toCluster = to.getCluster(); + const fromArray = Array.isArray(from) ? from : [from]; + const sameCluster = fromArray.filter((s) => s.getCluster() === toCluster); + if (sameCluster.length === 0) return null; + + const fromIds = sameCluster.map((s) => s.id); + const path = this.aStar.findPath(fromIds, to.id); + + if (!path) return null; + return path.map((id) => this.manager.getById(id)!); + } +} + +class StationGraphAdapter implements AStarAdapter { + constructor( + private game: Game, + private manager: StationManager, + ) {} + + numNodes(): number { + return this.manager.count(); + } + + maxNeighbors(): number { + return 8; + } + + maxPriority(): number { + return this.game.map().width() + this.game.map().height(); + } + + neighbors(node: number, buffer: Int32Array): number { + const station = this.manager.getById(node); + if (!station) return 0; + + let count = 0; + for (const n of station.neighbors()) { + buffer[count++] = n.id; + } + return count; + } + + cost(): number { + return 1; + } + + heuristic(node: number, goal: number): number { + const a = this.manager.getById(node); + const b = this.manager.getById(goal); + if (!a || !b) return 0; + + const ax = this.game.x(a.tile()); + const ay = this.game.y(a.tile()); + const bx = this.game.x(b.tile()); + const by = this.game.y(b.tile()); + return Math.abs(ax - bx) + Math.abs(ay - by); + } +} diff --git a/src/core/pathfinding/PathFinder.ts b/src/core/pathfinding/PathFinder.ts index 7422e836b..51ecf4e5c 100644 --- a/src/core/pathfinding/PathFinder.ts +++ b/src/core/pathfinding/PathFinder.ts @@ -1,43 +1,104 @@ import { Game } from "../game/Game"; -import { TileRef } from "../game/GameMap"; -import { MiniAStarAdapter } from "./adapters/MiniAStarAdapter"; -import { NavMeshAdapter } from "./adapters/NavMeshAdapter"; +import { GameMap, TileRef } from "../game/GameMap"; +import { TrainStation } from "../game/TrainStation"; +import { AStarRail } from "./algorithms/AStar.Rail"; +import { AStarWater } from "./algorithms/AStar.Water"; +import { AirPathFinder } from "./PathFinder.Air"; +import { + ParabolaOptions, + ParabolaUniversalPathFinder, +} from "./PathFinder.Parabola"; +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 { PathStatus, SteppingPathFinder } from "./types"; -export enum PathStatus { - NEXT, - PENDING, - COMPLETE, - NOT_FOUND, +/** + * Pathfinders that work with GameMap - usable in both simulation and UI layers + */ +export class UniversalPathFinding { + static Parabola( + gameMap: GameMap, + options?: ParabolaOptions, + ): ParabolaUniversalPathFinder { + return new ParabolaUniversalPathFinder(gameMap, options); + } } -export type PathResult = - | { status: PathStatus.PENDING } - | { status: PathStatus.NEXT; node: TileRef } - | { status: PathStatus.COMPLETE; node: TileRef } - | { status: PathStatus.NOT_FOUND }; +/** + * Pathfinders that require Game - simulation layer only + */ +export class PathFinding { + static Water(game: Game): SteppingPathFinder { + const pf = game.miniWaterHPA(); + const graph = game.miniWaterGraph(); -export interface PathFinder { - next(from: TileRef, to: TileRef, dist?: number): PathResult; - findPath(from: TileRef, to: TileRef): TileRef[] | null; -} - -export interface MiniAStarOptions { - waterPath?: boolean; - iterations?: number; - maxTries?: number; -} - -export class PathFinders { - static Water(game: Game): PathFinder { - if (!game.navMesh()) { - // Fall back to old water pathfinder if navmesh is not available - return PathFinders.WaterLegacy(game); + if (!pf || !graph || graph.nodeCount < 100) { + return PathFinding.WaterSimple(game); } - return new NavMeshAdapter(game); + const miniMap = game.miniMap(); + const componentCheckFn = (t: TileRef) => graph.getComponentId(t); + + return PathFinderBuilder.create(pf) + .wrap((pf) => new ComponentCheckTransformer(pf, componentCheckFn)) + .wrap((pf) => new BresenhamSmoothingTransformer(pf, miniMap)) + .wrap((pf) => new ShoreCoercingTransformer(pf, miniMap)) + .wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap)) + .buildWithStepper(tileStepperConfig(game)); } - static WaterLegacy(game: Game, options?: MiniAStarOptions): PathFinder { - return new MiniAStarAdapter(game, options); + static WaterSimple(game: Game): SteppingPathFinder { + const miniMap = game.miniMap(); + const pf = new AStarWater(miniMap); + + return PathFinderBuilder.create(pf) + .wrap((pf) => new ShoreCoercingTransformer(pf, miniMap)) + .wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap)) + .buildWithStepper(tileStepperConfig(game)); + } + + static Rail(game: Game): SteppingPathFinder { + const miniMap = game.miniMap(); + const pf = new AStarRail(miniMap); + + return PathFinderBuilder.create(pf) + .wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap)) + .buildWithStepper(tileStepperConfig(game)); + } + + static Stations(game: Game): SteppingPathFinder { + const pf = new StationPathFinder(game); + + return PathFinderBuilder.create(pf).buildWithStepper({ + equals: (a, b) => a.id === b.id, + distance: (a, b) => game.manhattanDist(a.tile(), b.tile()), + }); + } + + static Air(game: Game): SteppingPathFinder { + const pf = new AirPathFinder(game); + + return PathFinderBuilder.create(pf).buildWithStepper({ + equals: (a, b) => a === b, + }); } } + +function tileStepperConfig(game: Game): StepperConfig { + return { + equals: (a, b) => a === b, + distance: (a, b) => game.manhattanDist(a, b), + preCheck: (from, to) => + typeof from !== "number" || + typeof to !== "number" || + !game.isValidRef(from) || + !game.isValidRef(to) + ? { status: PathStatus.NOT_FOUND } + : null, + }; +} diff --git a/src/core/pathfinding/PathFinderBuilder.ts b/src/core/pathfinding/PathFinderBuilder.ts new file mode 100644 index 000000000..51d7b6460 --- /dev/null +++ b/src/core/pathfinding/PathFinderBuilder.ts @@ -0,0 +1,42 @@ +import { PathFinderStepper, StepperConfig } from "./PathFinderStepper"; +import { PathFinder, SteppingPathFinder } from "./types"; + +type WrapFactory = (pf: PathFinder) => PathFinder; + +/** + * PathFinderBuilder - fluent builder for composing PathFinder transformers. + * + * Usage: + * const finder = PathFinderBuilder.create(corePathFinder) + * .wrap((pf) => new SomeTransformer(pf, deps)) + * .wrap((pf) => new AnotherTransformer(pf, deps)) + * .build(); + */ +export class PathFinderBuilder { + private wrappers: WrapFactory[] = []; + + private constructor(private core: PathFinder) {} + + static create(core: PathFinder): PathFinderBuilder { + return new PathFinderBuilder(core); + } + + wrap(factory: WrapFactory): this { + this.wrappers.push(factory); + return this; + } + + build(): PathFinder { + return this.wrappers.reduce( + (pf, wrapper) => wrapper(pf), + this.core as PathFinder, + ); + } + + /** + * Build and wrap with PathFinderStepper for step-by-step traversal. + */ + buildWithStepper(config: StepperConfig): SteppingPathFinder { + return new PathFinderStepper(this.build(), config); + } +} diff --git a/src/core/pathfinding/PathFinderStepper.ts b/src/core/pathfinding/PathFinderStepper.ts new file mode 100644 index 000000000..4b8081fdc --- /dev/null +++ b/src/core/pathfinding/PathFinderStepper.ts @@ -0,0 +1,119 @@ +import { + PathFinder, + PathResult, + PathStatus, + SteppingPathFinder, +} from "./types"; + +export interface StepperConfig { + equals: (a: T, b: T) => boolean; + distance?: (a: T, b: T) => number; + preCheck?: (from: T, to: T) => PathResult | null; +} + +/** + * PathFinderStepper - wraps a PathFinder and provides step-by-step traversal + * + * Handles path caching, invalidation, and incremental movement. + * Generic over any PathFinder implementation. + */ +export class PathFinderStepper implements SteppingPathFinder { + private path: T[] | null = null; + private pathIndex = 0; + private lastTo: T | null = null; + + constructor( + private finder: PathFinder, + private config: StepperConfig = { equals: (a, b) => a === b }, + ) {} + + /** + * Get the next step on the path from `from` to `to`. + * Returns PathResult with status and optional next node. + */ + next(from: T, to: T, dist?: number): PathResult { + // Domain-specific pre-check (validation, cluster, etc.) + if (this.config.preCheck) { + const result = this.config.preCheck(from, to); + if (result) return result; + } + + if (this.config.equals(from, to)) { + return { status: PathStatus.COMPLETE, node: to }; + } + + // Distance-based early exit + if (dist !== undefined && dist > 0 && this.config.distance) { + if (this.config.distance(from, to) <= dist) { + return { status: PathStatus.COMPLETE, node: from }; + } + } + + // Invalidate cache if destination changed + if (this.lastTo === null || !this.config.equals(this.lastTo, to)) { + this.path = null; + this.pathIndex = 0; + this.lastTo = to; + } + + // Compute path if not cached + if (this.path === null) { + try { + this.path = this.finder.findPath(from, to); + } catch (err) { + console.error("PathFinder threw an error during findPath", err); + return { status: PathStatus.NOT_FOUND }; + } + + if (this.path === null) { + return { status: PathStatus.NOT_FOUND }; + } + + this.pathIndex = 0; + if (this.path.length > 0 && this.config.equals(this.path[0], from)) { + this.pathIndex = 1; + } + } + + const expectedPos = this.path[this.pathIndex - 1]; + if (this.pathIndex > 0 && !this.config.equals(from, expectedPos)) { + this.invalidate(); + this.lastTo = to; + return this.next(from, to, dist); + } + + // Check if we've reached the end + if (this.pathIndex >= this.path.length) { + return { status: PathStatus.COMPLETE, node: to }; + } + + // Return next step + const nextNode = this.path[this.pathIndex]; + this.pathIndex++; + + return { status: PathStatus.NEXT, node: nextNode }; + } + + invalidate(): void { + this.path = null; + this.pathIndex = 0; + this.lastTo = null; + } + + findPath(from: T | T[], to: T): T[] | null { + if (this.config.preCheck) { + const fromArray = Array.isArray(from) ? from : [from]; + + const allFailed = fromArray.every((f) => { + const result = this.config.preCheck!(f, to); + return result?.status === PathStatus.NOT_FOUND; + }); + + if (allFailed) { + return null; + } + } + + return this.finder.findPath(from, to); + } +} diff --git a/src/core/pathfinding/PathFinding.ts b/src/core/pathfinding/PathFinding.ts deleted file mode 100644 index a8cb06f61..000000000 --- a/src/core/pathfinding/PathFinding.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { Game } from "../game/Game"; -import { GameMap, TileRef } from "../game/GameMap"; -import { PseudoRandom } from "../PseudoRandom"; -import { within } from "../Util"; -import { DistanceBasedBezierCurve } from "../utilities/Line"; -import { AStar, AStarResult, PathFindResultType } from "./AStar"; -import { MiniAStar } from "./MiniAStar"; - -const parabolaMinHeight = 50; - -export class ParabolaPathFinder { - constructor(private mg: GameMap) {} - private curve: DistanceBasedBezierCurve | undefined; - - computeControlPoints( - orig: TileRef, - dst: TileRef, - increment: number = 3, - distanceBasedHeight = true, - directionUp = true, - ) { - const p0 = { x: this.mg.x(orig), y: this.mg.y(orig) }; - const p3 = { x: this.mg.x(dst), y: this.mg.y(dst) }; - const dx = p3.x - p0.x; - const dy = p3.y - p0.y; - const distance = Math.sqrt(dx * dx + dy * dy); - const maxHeight = distanceBasedHeight - ? Math.max(distance / 3, parabolaMinHeight) - : 0; - // Use a bezier curve pointing up or down based on directionUp parameter - const heightMultiplier = directionUp ? -1 : 1; - const mapHeight = this.mg.height(); - const p1 = { - x: p0.x + (p3.x - p0.x) / 4, - y: within( - p0.y + (p3.y - p0.y) / 4 + heightMultiplier * maxHeight, - 0, - mapHeight - 1, - ), - }; - const p2 = { - x: p0.x + ((p3.x - p0.x) * 3) / 4, - y: within( - p0.y + ((p3.y - p0.y) * 3) / 4 + heightMultiplier * maxHeight, - 0, - mapHeight - 1, - ), - }; - - this.curve = new DistanceBasedBezierCurve(p0, p1, p2, p3, increment); - } - - nextTile(speed: number): TileRef | true { - if (!this.curve) { - throw new Error("ParabolaPathFinder not initialized"); - } - const nextPoint = this.curve.increment(speed); - if (!nextPoint) { - return true; - } - return this.mg.ref(Math.floor(nextPoint.x), Math.floor(nextPoint.y)); - } - - currentIndex(): number { - if (!this.curve) { - return 0; - } - return this.curve.getCurrentIndex(); - } - - allTiles(): TileRef[] { - if (!this.curve) { - return []; - } - return this.curve - .getAllPoints() - .map((point) => this.mg.ref(Math.floor(point.x), Math.floor(point.y))); - } -} - -export class AirPathFinder { - constructor( - private mg: GameMap, - private random: PseudoRandom, - ) {} - - nextTile(tile: TileRef, dst: TileRef): TileRef | true { - const x = this.mg.x(tile); - const y = this.mg.y(tile); - const dstX = this.mg.x(dst); - const dstY = this.mg.y(dst); - - if (x === dstX && y === dstY) { - return true; - } - - // Calculate next position - let nextX = x; - let nextY = y; - - const ratio = Math.floor(1 + Math.abs(dstY - y) / (Math.abs(dstX - x) + 1)); - - if (this.random.chance(ratio) && x !== dstX) { - if (x < dstX) nextX++; - else if (x > dstX) nextX--; - } else { - if (y < dstY) nextY++; - else if (y > dstY) nextY--; - } - if (nextX === x && nextY === y) { - return true; - } - return this.mg.ref(nextX, nextY); - } -} - -export class MiniPathFinder { - private curr: TileRef | null = null; - private dst: TileRef | null = null; - private path: TileRef[] | null = null; - private path_idx: number = 0; - private aStar: AStar; - private computeFinished = true; - - constructor( - private game: Game, - private iterations: number, - private waterPath: boolean, - private maxTries: number, - ) {} - - private createAStar(curr: TileRef, dst: TileRef): AStar { - return new MiniAStar( - this.game.map(), - this.game.miniMap(), - curr, - dst, - this.iterations, - this.maxTries, - this.waterPath, - ); - } - - nextTile( - curr: TileRef | null, - dst: TileRef | null, - dist: number = 1, - ): AStarResult { - if (curr === null) { - console.error("curr is null"); - return { type: PathFindResultType.PathNotFound }; - } - if (dst === null) { - console.error("dst is null"); - return { type: PathFindResultType.PathNotFound }; - } - - if (this.game.manhattanDist(curr, dst) < dist) { - this.path = null; - return { type: PathFindResultType.Completed, node: curr }; - } - - if (this.computeFinished) { - if (this.shouldRecompute(curr, dst)) { - this.curr = curr; - this.dst = dst; - this.path = null; - this.path_idx = 0; - this.aStar = this.createAStar(curr, dst); - this.computeFinished = false; - return this.nextTile(curr, dst); - } else { - const tile = this.path?.[this.path_idx++]; - if (tile === undefined) { - throw new Error("missing tile"); - } - return { type: PathFindResultType.NextTile, node: tile }; - } - } - - switch (this.aStar.compute()) { - case PathFindResultType.Completed: - this.computeFinished = true; - this.path = this.aStar.reconstructPath(); - - // exclude first tile - this.path_idx = 1; - - return this.nextTile(curr, dst); - case PathFindResultType.Pending: - return { type: PathFindResultType.Pending }; - case PathFindResultType.PathNotFound: - return { type: PathFindResultType.PathNotFound }; - default: - throw new Error("unexpected compute result"); - } - } - - private shouldRecompute(curr: TileRef, dst: TileRef) { - if (this.path === null || this.curr === null || this.dst === null) { - return true; - } - const dist = this.game.manhattanDist(curr, dst); - let tolerance = 10; - if (dist > 50) { - tolerance = 10; - } else if (dist > 25) { - tolerance = 5; - } else { - tolerance = 0; - } - if (this.game.manhattanDist(this.dst, dst) > tolerance) { - return true; - } - return false; - } -} diff --git a/src/core/pathfinding/SerialAStar.ts b/src/core/pathfinding/SerialAStar.ts deleted file mode 100644 index 5e3e2c61f..000000000 --- a/src/core/pathfinding/SerialAStar.ts +++ /dev/null @@ -1,189 +0,0 @@ -import FastPriorityQueue from "fastpriorityqueue"; -import { AStar, PathFindResultType } from "./AStar"; - -/** - * Implement this interface with your graph to find paths with A* - */ -export interface GraphAdapter { - neighbors(node: NodeType): NodeType[]; - cost(node: NodeType): number; - position(node: NodeType): { x: number; y: number }; - isTraversable(from: NodeType, to: NodeType): boolean; -} - -export class SerialAStar implements AStar { - private fwdOpenSet: FastPriorityQueue<{ - tile: NodeType; - fScore: number; - }>; - private bwdOpenSet: FastPriorityQueue<{ - tile: NodeType; - fScore: number; - }>; - - private fwdCameFrom = new Map(); - private bwdCameFrom = new Map(); - private fwdGScore = new Map(); - private bwdGScore = new Map(); - - private meetingPoint: NodeType | null = null; - public completed = false; - private sources: NodeType[]; - private closestSource: NodeType; - - constructor( - src: NodeType | NodeType[], - private dst: NodeType, - private iterations: number, - private maxTries: number, - private graph: GraphAdapter, - private directionChangePenalty: number = 0, - ) { - this.fwdOpenSet = new FastPriorityQueue((a, b) => a.fScore < b.fScore); - this.bwdOpenSet = new FastPriorityQueue((a, b) => a.fScore < b.fScore); - this.sources = Array.isArray(src) ? src : [src]; - this.closestSource = this.findClosestSource(dst); - - // Initialize forward search with source point(s) - this.sources.forEach((startPoint) => { - this.fwdGScore.set(startPoint, 0); - this.fwdOpenSet.add({ - tile: startPoint, - fScore: this.heuristic(startPoint, dst), - }); - }); - - // Initialize backward search from destination - this.bwdGScore.set(dst, 0); - this.bwdOpenSet.add({ - tile: dst, - fScore: this.heuristic(dst, this.findClosestSource(dst)), - }); - } - - private findClosestSource(tile: NodeType): NodeType { - return this.sources.reduce((closest, source) => - this.heuristic(tile, source) < this.heuristic(tile, closest) - ? source - : closest, - ); - } - - compute(): PathFindResultType { - if (this.completed) return PathFindResultType.Completed; - - this.maxTries -= 1; - let iterations = this.iterations; - - while (!this.fwdOpenSet.isEmpty() && !this.bwdOpenSet.isEmpty()) { - iterations--; - if (iterations <= 0) { - if (this.maxTries <= 0) { - return PathFindResultType.PathNotFound; - } - return PathFindResultType.Pending; - } - - // Process forward search - const fwdCurrent = this.fwdOpenSet.poll()!.tile; - - // Check if we've found a meeting point - if (this.bwdGScore.has(fwdCurrent)) { - this.meetingPoint = fwdCurrent; - this.completed = true; - return PathFindResultType.Completed; - } - this.expandNode(fwdCurrent, true); - - // Process backward search - const bwdCurrent = this.bwdOpenSet.poll()!.tile; - - // Check if we've found a meeting point - if (this.fwdGScore.has(bwdCurrent)) { - this.meetingPoint = bwdCurrent; - this.completed = true; - return PathFindResultType.Completed; - } - this.expandNode(bwdCurrent, false); - } - - return this.completed - ? PathFindResultType.Completed - : PathFindResultType.PathNotFound; - } - - private expandNode(current: NodeType, isForward: boolean) { - for (const neighbor of this.graph.neighbors(current)) { - if ( - neighbor !== (isForward ? this.dst : this.closestSource) && - !this.graph.isTraversable(current, neighbor) - ) - continue; - - const gScore = isForward ? this.fwdGScore : this.bwdGScore; - const openSet = isForward ? this.fwdOpenSet : this.bwdOpenSet; - const cameFrom = isForward ? this.fwdCameFrom : this.bwdCameFrom; - - const tentativeGScore = gScore.get(current)! + this.graph.cost(neighbor); - let penalty = 0; - // With a direction change penalty, the path will get as straight as possible - if (this.directionChangePenalty > 0) { - const prev = cameFrom.get(current); - if (prev) { - const prevDir = this.getDirection(prev, current); - const newDir = this.getDirection(current, neighbor); - if (prevDir !== newDir) { - penalty = this.directionChangePenalty; - } - } - } - - const totalG = tentativeGScore + penalty; - if (!gScore.has(neighbor) || totalG < gScore.get(neighbor)!) { - cameFrom.set(neighbor, current); - gScore.set(neighbor, totalG); - const fScore = - totalG + - this.heuristic(neighbor, isForward ? this.dst : this.closestSource); - openSet.add({ tile: neighbor, fScore: fScore }); - } - } - } - - private heuristic(a: NodeType, b: NodeType): number { - const posA = this.graph.position(a); - const posB = this.graph.position(b); - return 2 * (Math.abs(posA.x - posB.x) + Math.abs(posA.y - posB.y)); - } - - private getDirection(from: NodeType, to: NodeType): string { - const fromPos = this.graph.position(from); - const toPos = this.graph.position(to); - const dx = toPos.x - fromPos.x; - const dy = toPos.y - fromPos.y; - return `${Math.sign(dx)},${Math.sign(dy)}`; - } - - public reconstructPath(): NodeType[] { - if (!this.meetingPoint) return []; - - // Reconstruct path from start to meeting point - const fwdPath: NodeType[] = [this.meetingPoint]; - let current = this.meetingPoint; - - while (this.fwdCameFrom.has(current)) { - current = this.fwdCameFrom.get(current)!; - fwdPath.unshift(current); - } - - // Reconstruct path from meeting point to goal - current = this.meetingPoint; - - while (this.bwdCameFrom.has(current)) { - current = this.bwdCameFrom.get(current)!; - fwdPath.push(current); - } - - return fwdPath; - } -} diff --git a/src/core/pathfinding/adapters/MiniAStarAdapter.ts b/src/core/pathfinding/adapters/MiniAStarAdapter.ts deleted file mode 100644 index b1c1841d6..000000000 --- a/src/core/pathfinding/adapters/MiniAStarAdapter.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { Game } from "../../game/Game"; -import { TileRef } from "../../game/GameMap"; -import { PathFindResultType } from "../AStar"; -import { - MiniAStarOptions, - PathFinder, - PathResult, - PathStatus, -} from "../PathFinder"; -import { MiniPathFinder } from "../PathFinding"; - -const DEFAULT_ITERATIONS = 10_000; -const DEFAULT_MAX_TRIES = 100; - -export class MiniAStarAdapter implements PathFinder { - private miniPathFinder: MiniPathFinder; - - constructor(game: Game, options?: MiniAStarOptions) { - this.miniPathFinder = new MiniPathFinder( - game, - options?.iterations ?? DEFAULT_ITERATIONS, - options?.waterPath ?? true, - options?.maxTries ?? DEFAULT_MAX_TRIES, - ); - } - - next(from: TileRef, to: TileRef, dist?: number): PathResult { - const result = this.miniPathFinder.nextTile(from, to, dist); - - switch (result.type) { - case PathFindResultType.Pending: - return { status: PathStatus.PENDING }; - case PathFindResultType.NextTile: - return { status: PathStatus.NEXT, node: result.node }; - case PathFindResultType.Completed: - return { status: PathStatus.COMPLETE, node: result.node }; - case PathFindResultType.PathNotFound: - return { status: PathStatus.NOT_FOUND }; - } - } - - findPath(from: TileRef, to: TileRef): TileRef[] | null { - const path: TileRef[] = [from]; - let current = from; - const maxSteps = 100_000; - - for (let i = 0; i < maxSteps; i++) { - const result = this.next(current, to); - - if (result.status === PathStatus.COMPLETE) { - return path; - } - - if (result.status === PathStatus.NOT_FOUND) { - return null; - } - - if (result.status === PathStatus.NEXT) { - current = result.node; - path.push(current); - } - } - - return null; - } -} diff --git a/src/core/pathfinding/adapters/NavMeshAdapter.ts b/src/core/pathfinding/adapters/NavMeshAdapter.ts deleted file mode 100644 index 4e081eb4a..000000000 --- a/src/core/pathfinding/adapters/NavMeshAdapter.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { Game } from "../../game/Game"; -import { TileRef } from "../../game/GameMap"; -import { NavMesh } from "../navmesh/NavMesh"; -import { PathFinder, PathResult, PathStatus } from "../PathFinder"; - -export class NavMeshAdapter implements PathFinder { - private navMesh: NavMesh; - private pathIndex = 0; - private path: TileRef[] | null = null; - private lastTo: TileRef | null = null; - - constructor(private game: Game) { - const navMesh = game.navMesh(); - if (!navMesh) { - throw new Error("NavMeshAdapter requires game.navMesh() to be available"); - } - this.navMesh = navMesh; - } - - next(from: TileRef, to: TileRef, dist?: number): PathResult { - if (typeof from !== "number" || typeof to !== "number") { - return { status: PathStatus.NOT_FOUND }; - } - - if (!this.game.isValidRef(from) || !this.game.isValidRef(to)) { - return { status: PathStatus.NOT_FOUND }; - } - - if (from === to) { - return { status: PathStatus.COMPLETE, node: to }; - } - - if (dist !== undefined && dist > 0) { - const distance = this.game.manhattanDist(from, to); - - if (distance <= dist) { - return { status: PathStatus.COMPLETE, node: from }; - } - } - - if (this.lastTo !== to) { - this.path = null; - this.pathIndex = 0; - this.lastTo = to; - } - - if (this.path === null) { - this.cachePath(from, to); - - if (this.path === null) { - return { status: PathStatus.NOT_FOUND }; - } - } - - // Recompute if deviated from planned path - const expectedPos = this.path[this.pathIndex - 1]; - if (this.pathIndex > 0 && from !== expectedPos) { - this.cachePath(from, to); - - if (this.path === null) { - return { status: PathStatus.NOT_FOUND }; - } - } - - if (this.pathIndex >= this.path.length) { - return { status: PathStatus.COMPLETE, node: to }; - } - - const nextNode = this.path[this.pathIndex]; - this.pathIndex++; - - return { status: PathStatus.NEXT, node: nextNode }; - } - - findPath(from: TileRef, to: TileRef): TileRef[] | null { - return this.navMesh.findPath(from, to); - } - - private cachePath(from: TileRef, to: TileRef): boolean { - try { - this.path = this.navMesh.findPath(from, to); - } catch { - return false; - } - - if (this.path === null) { - return false; - } - - this.pathIndex = 0; - - // Path starts with 'from', skip to next tile - if (this.path.length > 0 && this.path[0] === from) { - this.pathIndex = 1; - } - - return true; - } -} diff --git a/src/core/pathfinding/algorithms/AStar.AbstractGraph.ts b/src/core/pathfinding/algorithms/AStar.AbstractGraph.ts new file mode 100644 index 000000000..82da9f604 --- /dev/null +++ b/src/core/pathfinding/algorithms/AStar.AbstractGraph.ts @@ -0,0 +1,249 @@ +import { PathFinder } from "../types"; +import { AbstractGraph } from "./AbstractGraph"; +import { BucketQueue, MinHeap, PriorityQueue } from "./PriorityQueue"; + +export interface AbstractGraphAStarConfig { + heuristicWeight?: number; + maxIterations?: number; + useMinHeap?: boolean; // Use MinHeap instead of BucketQueue (better for variable costs) +} + +export class AbstractGraphAStar implements PathFinder { + private stamp = 1; + + private readonly closedStamp: Uint32Array; + private readonly gScoreStamp: Uint32Array; + private readonly gScore: Float32Array; + private readonly cameFrom: Int32Array; + private readonly startNode: Int32Array; // tracks which start each node came from + private readonly queue: PriorityQueue; + private readonly graph: AbstractGraph; + private readonly heuristicWeight: number; + private readonly maxIterations: number; + + constructor(graph: AbstractGraph, config?: AbstractGraphAStarConfig) { + this.graph = graph; + this.heuristicWeight = config?.heuristicWeight ?? 1; + this.maxIterations = config?.maxIterations ?? 100_000; + + const numNodes = graph.nodeCount; + + this.closedStamp = new Uint32Array(numNodes); + this.gScoreStamp = new Uint32Array(numNodes); + this.gScore = new Float32Array(numNodes); + this.cameFrom = new Int32Array(numNodes); + this.startNode = new Int32Array(numNodes); + + // For abstract graphs with variable costs, MinHeap may be better + // BucketQueue is O(1) but requires integer priorities + if (config?.useMinHeap) { + this.queue = new MinHeap(numNodes); + } else { + // Estimate max priority: weight * (mapWidth + mapHeight) + // Use cluster size * clusters as approximation + const maxDist = graph.clusterSize * Math.max(graph.clustersX, 10) * 2; + const maxF = this.heuristicWeight * maxDist; + this.queue = new BucketQueue(maxF); + } + } + + findPath(start: number | number[], goal: number): number[] | null { + if (Array.isArray(start)) { + return this.findPathMultiSource(start, goal); + } + return this.findPathSingle(start, goal); + } + + private findPathSingle(startId: number, goalId: number): number[] | null { + this.stamp++; + if (this.stamp > 0xffffffff) { + this.closedStamp.fill(0); + this.gScoreStamp.fill(0); + this.stamp = 1; + } + + const stamp = this.stamp; + const graph = this.graph; + const closedStamp = this.closedStamp; + const gScoreStamp = this.gScoreStamp; + const gScore = this.gScore; + const cameFrom = this.cameFrom; + const queue = this.queue; + const weight = this.heuristicWeight; + + // Get goal node for heuristic + const goalNode = graph.getNode(goalId); + if (!goalNode) return null; + const goalX = goalNode.x; + const goalY = goalNode.y; + + // Get start node for initial heuristic + const startNode = graph.getNode(startId); + if (!startNode) return null; + + // Initialize + queue.clear(); + gScore[startId] = 0; + gScoreStamp[startId] = stamp; + cameFrom[startId] = -1; + + const startH = + weight * (Math.abs(startNode.x - goalX) + Math.abs(startNode.y - goalY)); + queue.push(startId, startH); + + let iterations = this.maxIterations; + + while (!queue.isEmpty()) { + if (--iterations <= 0) { + return null; + } + + const current = queue.pop(); + + if (closedStamp[current] === stamp) continue; + closedStamp[current] = stamp; + + if (current === goalId) { + return this.buildPathFromGoal(goalId); + } + + const currentG = gScore[current]; + const edges = graph.getNodeEdges(current); + + // Inline neighbor iteration + for (let i = 0; i < edges.length; i++) { + const edge = edges[i]; + const neighbor = graph.getOtherNode(edge, current); + + if (closedStamp[neighbor] === stamp) continue; + + const tentativeG = currentG + edge.cost; + + if (gScoreStamp[neighbor] !== stamp || tentativeG < gScore[neighbor]) { + cameFrom[neighbor] = current; + gScore[neighbor] = tentativeG; + gScoreStamp[neighbor] = stamp; + + // Inline heuristic calculation + const neighborNode = graph.getNode(neighbor); + if (neighborNode) { + const h = + weight * + (Math.abs(neighborNode.x - goalX) + + Math.abs(neighborNode.y - goalY)); + queue.push(neighbor, tentativeG + h); + } + } + } + } + + return null; + } + + private findPathMultiSource( + startIds: number[], + goalId: number, + ): number[] | null { + if (startIds.length === 0) return null; + if (startIds.length === 1) return this.findPathSingle(startIds[0], goalId); + + this.stamp++; + if (this.stamp > 0xffffffff) { + this.closedStamp.fill(0); + this.gScoreStamp.fill(0); + this.stamp = 1; + } + + const stamp = this.stamp; + const graph = this.graph; + const closedStamp = this.closedStamp; + const gScoreStamp = this.gScoreStamp; + const gScore = this.gScore; + const cameFrom = this.cameFrom; + const startNode = this.startNode; + const queue = this.queue; + const weight = this.heuristicWeight; + + // Get goal node for heuristic + const goalNode = graph.getNode(goalId); + if (!goalNode) return null; + const goalX = goalNode.x; + const goalY = goalNode.y; + + // Initialize all start nodes + queue.clear(); + for (const startId of startIds) { + const node = graph.getNode(startId); + if (!node) continue; + + gScore[startId] = 0; + gScoreStamp[startId] = stamp; + cameFrom[startId] = -1; + startNode[startId] = startId; // each start is its own origin + + const h = weight * (Math.abs(node.x - goalX) + Math.abs(node.y - goalY)); + queue.push(startId, h); + } + + let iterations = this.maxIterations; + + while (!queue.isEmpty()) { + if (--iterations <= 0) { + return null; + } + + const current = queue.pop(); + + if (closedStamp[current] === stamp) continue; + closedStamp[current] = stamp; + + if (current === goalId) { + return this.buildPathFromGoal(goalId); + } + + const currentG = gScore[current]; + const currentStart = startNode[current]; + const edges = graph.getNodeEdges(current); + + for (let i = 0; i < edges.length; i++) { + const edge = edges[i]; + const neighbor = graph.getOtherNode(edge, current); + + if (closedStamp[neighbor] === stamp) continue; + + const tentativeG = currentG + edge.cost; + + if (gScoreStamp[neighbor] !== stamp || tentativeG < gScore[neighbor]) { + cameFrom[neighbor] = current; + gScore[neighbor] = tentativeG; + gScoreStamp[neighbor] = stamp; + startNode[neighbor] = currentStart; // propagate origin + + const neighborNode = graph.getNode(neighbor); + if (neighborNode) { + const h = + weight * + (Math.abs(neighborNode.x - goalX) + + Math.abs(neighborNode.y - goalY)); + queue.push(neighbor, tentativeG + h); + } + } + } + } + + return null; + } + + private buildPathFromGoal(goalId: number): number[] { + const path: number[] = []; + let current = goalId; + + while (current !== -1) { + path.push(current); + current = this.cameFrom[current]; + } + + path.reverse(); + return path; + } +} diff --git a/src/core/pathfinding/algorithms/AStar.Bounded.ts b/src/core/pathfinding/algorithms/AStar.Bounded.ts new file mode 100644 index 000000000..923f06083 --- /dev/null +++ b/src/core/pathfinding/algorithms/AStar.Bounded.ts @@ -0,0 +1,289 @@ +import { GameMap, TileRef } from "../../game/GameMap"; +import { PathFinder } from "../types"; +import { BucketQueue } from "./PriorityQueue"; + +const LAND_BIT = 7; + +export interface BoundedAStarConfig { + heuristicWeight?: number; + maxIterations?: number; +} + +export interface SearchBounds { + minX: number; + maxX: number; + minY: number; + maxY: number; +} + +export class AStarBounded implements PathFinder { + private stamp = 1; + + private readonly closedStamp: Uint32Array; + private readonly gScoreStamp: Uint32Array; + private readonly gScore: Uint32Array; + private readonly cameFrom: Int32Array; + private readonly queue: BucketQueue; + private readonly terrain: Uint8Array; + private readonly mapWidth: number; + private readonly heuristicWeight: number; + private readonly maxIterations: number; + + constructor( + map: GameMap, + maxSearchArea: number, + config?: BoundedAStarConfig, + ) { + this.terrain = (map as any).terrain as Uint8Array; + this.mapWidth = map.width(); + this.heuristicWeight = config?.heuristicWeight ?? 1; + this.maxIterations = config?.maxIterations ?? 100_000; + + this.closedStamp = new Uint32Array(maxSearchArea); + this.gScoreStamp = new Uint32Array(maxSearchArea); + this.gScore = new Uint32Array(maxSearchArea); + this.cameFrom = new Int32Array(maxSearchArea); + + const maxDim = Math.ceil(Math.sqrt(maxSearchArea)); + const maxF = this.heuristicWeight * maxDim * 2; + this.queue = new BucketQueue(maxF); + } + + findPath(start: number | number[], goal: number): number[] | null { + const starts = Array.isArray(start) ? start : [start]; + const goalX = goal % this.mapWidth; + const goalY = (goal / this.mapWidth) | 0; + + let minX = goalX; + let maxX = goalX; + let minY = goalY; + let maxY = goalY; + + for (const s of starts) { + const sx = s % this.mapWidth; + const sy = (s / this.mapWidth) | 0; + minX = Math.min(minX, sx); + maxX = Math.max(maxX, sx); + minY = Math.min(minY, sy); + maxY = Math.max(maxY, sy); + } + + return this.searchBounded(starts as TileRef[], goal as TileRef, { + minX, + maxX, + minY, + maxY, + }); + } + + searchBounded( + start: TileRef | TileRef[], + goal: TileRef, + bounds: SearchBounds, + ): TileRef[] | null { + this.stamp++; + if (this.stamp > 0xffffffff) { + this.closedStamp.fill(0); + this.gScoreStamp.fill(0); + this.stamp = 1; + } + + const stamp = this.stamp; + const mapWidth = this.mapWidth; + const terrain = this.terrain; + const closedStamp = this.closedStamp; + const gScoreStamp = this.gScoreStamp; + const gScore = this.gScore; + const cameFrom = this.cameFrom; + const queue = this.queue; + const weight = this.heuristicWeight; + const landMask = 1 << LAND_BIT; + + const { minX, maxX, minY, maxY } = bounds; + const boundsWidth = maxX - minX + 1; + const goalX = goal % mapWidth; + const goalY = (goal / mapWidth) | 0; + const boundsHeight = maxY - minY + 1; + const numLocalNodes = boundsWidth * boundsHeight; + + if (numLocalNodes > this.closedStamp.length) { + return null; + } + + const toLocal = (tile: TileRef, clamp: boolean = false): number => { + let x = tile % mapWidth; + let y = (tile / mapWidth) | 0; + if (clamp) { + x = Math.max(minX, Math.min(maxX, x)); + y = Math.max(minY, Math.min(maxY, y)); + } + return (y - minY) * boundsWidth + (x - minX); + }; + + const toGlobal = (local: number): TileRef => { + const localX = local % boundsWidth; + const localY = (local / boundsWidth) | 0; + return ((localY + minY) * mapWidth + (localX + minX)) as TileRef; + }; + + const goalLocal = toLocal(goal, true); + if (goalLocal < 0 || goalLocal >= numLocalNodes) { + return null; + } + + queue.clear(); + const starts = Array.isArray(start) ? start : [start]; + for (const s of starts) { + const startLocal = toLocal(s, true); + if (startLocal < 0 || startLocal >= numLocalNodes) { + continue; + } + gScore[startLocal] = 0; + gScoreStamp[startLocal] = stamp; + cameFrom[startLocal] = -1; + const sx = s % mapWidth; + const sy = (s / mapWidth) | 0; + const h = weight * (Math.abs(sx - goalX) + Math.abs(sy - goalY)); + queue.push(startLocal, h); + } + + let iterations = this.maxIterations; + + while (!queue.isEmpty()) { + if (--iterations <= 0) { + return null; + } + + const currentLocal = queue.pop(); + + if (closedStamp[currentLocal] === stamp) continue; + closedStamp[currentLocal] = stamp; + + if (currentLocal === goalLocal) { + return this.buildPath(goalLocal, toGlobal, numLocalNodes); + } + + const currentG = gScore[currentLocal]; + const tentativeG = currentG + 1; + + // Convert to global coords for neighbor calculation + const current = toGlobal(currentLocal); + const currentX = current % mapWidth; + const currentY = (current / mapWidth) | 0; + + if (currentY > minY) { + const neighbor = current - mapWidth; + const neighborLocal = currentLocal - boundsWidth; + if ( + closedStamp[neighborLocal] !== stamp && + (neighbor === goal || (terrain[neighbor] & landMask) === 0) + ) { + if ( + gScoreStamp[neighborLocal] !== stamp || + tentativeG < gScore[neighborLocal] + ) { + cameFrom[neighborLocal] = currentLocal; + gScore[neighborLocal] = tentativeG; + gScoreStamp[neighborLocal] = stamp; + const f = + tentativeG + + weight * + (Math.abs(currentX - goalX) + Math.abs(currentY - 1 - goalY)); + queue.push(neighborLocal, f); + } + } + } + + if (currentY < maxY) { + const neighbor = current + mapWidth; + const neighborLocal = currentLocal + boundsWidth; + if ( + closedStamp[neighborLocal] !== stamp && + (neighbor === goal || (terrain[neighbor] & landMask) === 0) + ) { + if ( + gScoreStamp[neighborLocal] !== stamp || + tentativeG < gScore[neighborLocal] + ) { + cameFrom[neighborLocal] = currentLocal; + gScore[neighborLocal] = tentativeG; + gScoreStamp[neighborLocal] = stamp; + const f = + tentativeG + + weight * + (Math.abs(currentX - goalX) + Math.abs(currentY + 1 - goalY)); + queue.push(neighborLocal, f); + } + } + } + + if (currentX > minX) { + const neighbor = current - 1; + const neighborLocal = currentLocal - 1; + if ( + closedStamp[neighborLocal] !== stamp && + (neighbor === goal || (terrain[neighbor] & landMask) === 0) + ) { + if ( + gScoreStamp[neighborLocal] !== stamp || + tentativeG < gScore[neighborLocal] + ) { + cameFrom[neighborLocal] = currentLocal; + gScore[neighborLocal] = tentativeG; + gScoreStamp[neighborLocal] = stamp; + const f = + tentativeG + + weight * + (Math.abs(currentX - 1 - goalX) + Math.abs(currentY - goalY)); + queue.push(neighborLocal, f); + } + } + } + + if (currentX < maxX) { + const neighbor = current + 1; + const neighborLocal = currentLocal + 1; + if ( + closedStamp[neighborLocal] !== stamp && + (neighbor === goal || (terrain[neighbor] & landMask) === 0) + ) { + if ( + gScoreStamp[neighborLocal] !== stamp || + tentativeG < gScore[neighborLocal] + ) { + cameFrom[neighborLocal] = currentLocal; + gScore[neighborLocal] = tentativeG; + gScoreStamp[neighborLocal] = stamp; + const f = + tentativeG + + weight * + (Math.abs(currentX + 1 - goalX) + Math.abs(currentY - goalY)); + queue.push(neighborLocal, f); + } + } + } + } + + return null; + } + + private buildPath( + goalLocal: number, + toGlobal: (local: number) => TileRef, + maxPathLength: number, + ): TileRef[] { + const path: TileRef[] = []; + let current = goalLocal; + + // Safety check to prevent infinite loops + let iterations = 0; + while (current !== -1 && iterations < maxPathLength) { + path.push(toGlobal(current)); + current = this.cameFrom[current]; + iterations++; + } + + path.reverse(); + return path; + } +} diff --git a/src/core/pathfinding/algorithms/AStar.Rail.ts b/src/core/pathfinding/algorithms/AStar.Rail.ts new file mode 100644 index 000000000..0457115fb --- /dev/null +++ b/src/core/pathfinding/algorithms/AStar.Rail.ts @@ -0,0 +1,101 @@ +import { GameMap } from "../../game/GameMap"; +import { PathFinder } from "../types"; +import { AStar, AStarAdapter } from "./AStar"; + +export class AStarRail implements PathFinder { + private readonly aStar: AStar; + + constructor(gameMap: GameMap) { + const adapter = new RailAdapter(gameMap); + this.aStar = new AStar({ adapter }); + } + + findPath(from: number | number[], to: number): number[] | null { + return this.aStar.findPath(from, to); + } +} + +// Internal adapter +class RailAdapter implements AStarAdapter { + private readonly gameMap: GameMap; + private readonly width: number; + private readonly height: number; + private readonly _numNodes: number; + private readonly waterPenalty = 5; + private readonly heuristicWeight = 2; + private readonly directionChangePenalty = 3; + + constructor(gameMap: GameMap) { + this.gameMap = gameMap; + this.width = gameMap.width(); + this.height = gameMap.height(); + this._numNodes = this.width * this.height; + } + + numNodes(): number { + return this._numNodes; + } + + maxNeighbors(): number { + return 4; + } + + maxPriority(): number { + const maxCost = 1 + this.waterPenalty + this.directionChangePenalty; + return this.heuristicWeight * (this.width + this.height) * maxCost; + } + + neighbors(node: number, buffer: Int32Array): number { + let count = 0; + const x = node % this.width; + const fromShoreline = this.gameMap.isShoreline(node); + + if (node >= this.width) { + const n = node - this.width; + if (this.isTraversable(n, fromShoreline)) buffer[count++] = n; + } + if (node < this._numNodes - this.width) { + const n = node + this.width; + if (this.isTraversable(n, fromShoreline)) buffer[count++] = n; + } + if (x !== 0) { + const n = node - 1; + if (this.isTraversable(n, fromShoreline)) buffer[count++] = n; + } + if (x !== this.width - 1) { + const n = node + 1; + if (this.isTraversable(n, fromShoreline)) buffer[count++] = n; + } + + return count; + } + + private isTraversable(to: number, fromShoreline: boolean): boolean { + const toWater = this.gameMap.isWater(to); + if (!toWater) return true; + return fromShoreline || this.gameMap.isShoreline(to); + } + + cost(from: number, to: number, prev?: number): number { + const penalized = this.gameMap.isWater(to) || this.gameMap.isShoreline(to); + let c = penalized ? 1 + this.waterPenalty : 1; + + if (prev !== undefined) { + const d1 = from - prev; + const d2 = to - from; + if (d1 !== d2) { + c += this.directionChangePenalty; + } + } + + return c; + } + + heuristic(node: number, goal: number): number { + const nx = node % this.width; + const ny = (node / this.width) | 0; + const gx = goal % this.width; + const gy = (goal / this.width) | 0; + return this.heuristicWeight * (Math.abs(nx - gx) + Math.abs(ny - gy)); + } +} diff --git a/src/core/pathfinding/algorithms/AStar.Water.ts b/src/core/pathfinding/algorithms/AStar.Water.ts new file mode 100644 index 000000000..920f1a475 --- /dev/null +++ b/src/core/pathfinding/algorithms/AStar.Water.ts @@ -0,0 +1,203 @@ +import { GameMap, TileRef } from "../../game/GameMap"; +import { PathFinder } from "../types"; +import { BucketQueue, PriorityQueue } from "./PriorityQueue"; + +const LAND_BIT = 7; // Bit 7 in terrain indicates land + +export interface AStarWaterConfig { + heuristicWeight?: number; + maxIterations?: number; +} + +export class AStarWater implements PathFinder { + private stamp = 1; + + private readonly closedStamp: Uint32Array; + private readonly gScoreStamp: Uint32Array; + private readonly gScore: Uint32Array; + private readonly cameFrom: Int32Array; + private readonly queue: PriorityQueue; + private readonly terrain: Uint8Array; + private readonly width: number; + private readonly numNodes: number; + private readonly heuristicWeight: number; + private readonly maxIterations: number; + + constructor(map: GameMap, config?: AStarWaterConfig) { + this.terrain = (map as any).terrain as Uint8Array; + this.width = map.width(); + this.numNodes = map.width() * map.height(); + this.heuristicWeight = config?.heuristicWeight ?? 15; + this.maxIterations = config?.maxIterations ?? 1_000_000; + + this.closedStamp = new Uint32Array(this.numNodes); + this.gScoreStamp = new Uint32Array(this.numNodes); + this.gScore = new Uint32Array(this.numNodes); + this.cameFrom = new Int32Array(this.numNodes); + + const maxF = this.heuristicWeight * (map.width() + map.height()); + this.queue = new BucketQueue(maxF); + } + + findPath(start: number | number[], goal: number): number[] | null { + this.stamp++; + if (this.stamp > 0xffffffff) { + this.closedStamp.fill(0); + this.gScoreStamp.fill(0); + this.stamp = 1; + } + + const stamp = this.stamp; + const width = this.width; + const numNodes = this.numNodes; + const terrain = this.terrain; + const closedStamp = this.closedStamp; + const gScoreStamp = this.gScoreStamp; + const gScore = this.gScore; + const cameFrom = this.cameFrom; + const queue = this.queue; + const weight = this.heuristicWeight; + const landMask = 1 << LAND_BIT; + + const goalX = goal % width; + const goalY = (goal / width) | 0; + + queue.clear(); + const starts = Array.isArray(start) ? start : [start]; + 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)); + queue.push(s, h); + } + + let iterations = this.maxIterations; + + while (!queue.isEmpty()) { + if (--iterations <= 0) { + return null; + } + + const current = queue.pop(); + + if (closedStamp[current] === stamp) continue; + closedStamp[current] = stamp; + + if (current === goal) { + return this.buildPath(goal); + } + + const currentG = gScore[current]; + const tentativeG = currentG + 1; + const currentX = current % width; + + if (current >= width) { + const neighbor = current - width; + if ( + closedStamp[neighbor] !== stamp && + (neighbor === goal || (terrain[neighbor] & landMask) === 0) + ) { + if ( + gScoreStamp[neighbor] !== stamp || + tentativeG < gScore[neighbor] + ) { + 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)); + queue.push(neighbor, f); + } + } + } + + if (current < numNodes - width) { + const neighbor = current + width; + if ( + closedStamp[neighbor] !== stamp && + (neighbor === goal || (terrain[neighbor] & landMask) === 0) + ) { + if ( + gScoreStamp[neighbor] !== stamp || + tentativeG < gScore[neighbor] + ) { + 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)); + queue.push(neighbor, f); + } + } + } + + if (currentX !== 0) { + const neighbor = current - 1; + if ( + closedStamp[neighbor] !== stamp && + (neighbor === goal || (terrain[neighbor] & landMask) === 0) + ) { + if ( + gScoreStamp[neighbor] !== stamp || + tentativeG < gScore[neighbor] + ) { + 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)); + queue.push(neighbor, f); + } + } + } + + if (currentX !== width - 1) { + const neighbor = current + 1; + if ( + closedStamp[neighbor] !== stamp && + (neighbor === goal || (terrain[neighbor] & landMask) === 0) + ) { + if ( + gScoreStamp[neighbor] !== stamp || + tentativeG < gScore[neighbor] + ) { + 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)); + queue.push(neighbor, f); + } + } + } + } + + return null; + } + + private buildPath(goal: number): TileRef[] { + const path: TileRef[] = []; + let current = goal; + + while (current !== -1) { + path.push(current as TileRef); + current = this.cameFrom[current]; + } + + path.reverse(); + return path; + } +} diff --git a/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts b/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts new file mode 100644 index 000000000..019893d6a --- /dev/null +++ b/src/core/pathfinding/algorithms/AStar.WaterHierarchical.ts @@ -0,0 +1,562 @@ +import { GameMap, TileRef } from "../../game/GameMap"; +import { PathFinder } from "../types"; +import { AbstractGraphAStar } from "./AStar.AbstractGraph"; +import { AStarBounded } from "./AStar.Bounded"; +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 sourceResolver: SourceResolver; + + public debugInfo: PathDebugInfo | null = null; + public debugMode: boolean = false; + + constructor( + private map: GameMap, + private graph: AbstractGraph, + private options: { + cachePaths?: boolean; + } = {}, + ) { + // BFS for nearest node search + this.tileBFS = new BFSGrid(map.width() * map.height()); + + const clusterSize = graph.clusterSize; + + // AbstractGraphAStar for abstract graph routing + this.abstractAStar = new AbstractGraphAStar(this.graph); + + // BoundedAStar for cluster-bounded local pathfinding + const maxLocalNodes = clusterSize * clusterSize; + this.localAStar = new AStarBounded(map, maxLocalNodes); + + // BoundedAStar for multi-cluster (3x3) local pathfinding + const multiClusterSize = clusterSize * 3; + const maxMultiClusterNodes = multiClusterSize * multiClusterSize; + this.localAStarMultiCluster = new AStarBounded(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 this.findPathSingle(from as TileRef, to as TileRef, this.debugMode); + } + + private findPathMultiSource( + sources: TileRef[], + target: TileRef, + ): TileRef[] | null { + // 1. Resolve target to abstract node + const targetNode = this.sourceResolver.resolveTarget(target); + if (!targetNode) return null; + + // 2. Map sources → abstract nodes (cheap O(1) cluster lookup per source) + const nodeToSource = this.sourceResolver.resolveSourcesToNodes(sources); + if (nodeToSource.size === 0) return null; + + // 3. Run multi-source A* on abstract graph + const nodeIds = [...nodeToSource.keys()]; + const nodePath = this.abstractAStar.findPath(nodeIds, targetNode.id); + if (!nodePath) return null; + + // 4. Get winning source tile (nodePath[0] is winning start node) + const winningSource = nodeToSource.get(nodePath[0])!; + + // 5. Run full single-source from winner + 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, + }, + }; + } + + const dist = this.map.manhattanDist(from, to); + + // Early exit for very short distances + if (dist <= this.graph.clusterSize) { + performance.mark("hpa:findPath:earlyExitLocalPath:start"); + 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; + } + + 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"); + 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; + } + + 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"); + 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; + } + + return path; + } + + performance.mark("hpa:findPath:findAbstractPath:start"); + 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; + } + + 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`); + } + + const initialPath: TileRef[] = []; + + performance.mark("hpa:findPath:buildInitialPath:start"); + + // 1. Find path from start to first node + const firstNode = this.graph.getNode(nodePath[0])!; + const firstNodeTile = firstNode.tile; + + const startX = this.map.x(from); + const startY = this.map.y(from); + const startClusterX = Math.floor(startX / this.graph.clusterSize); + const startClusterY = Math.floor(startY / this.graph.clusterSize); + const startSegment = this.findLocalPath( + from, + firstNodeTile, + startClusterX, + startClusterY, + ); + + if (!startSegment) { + return null; + } + + initialPath.push(...startSegment); + + // 2. Build path through abstract nodes + for (let i = 0; i < nodePath.length - 1; i++) { + const fromNodeId = nodePath[i]; + const toNodeId = nodePath[i + 1]; + + const edge = this.graph.getEdgeBetween(fromNodeId, toNodeId); + if (!edge) { + return null; + } + + const fromNode = this.graph.getNode(fromNodeId)!; + const toNode = this.graph.getNode(toNodeId)!; + const fromTile = fromNode.tile; + const toTile = toNode.tile; + + // Check path cache (stored on graph, shared across all instances) + // Cache is direction-aware: A→B and B→A are cached separately + if (this.options.cachePaths) { + const cachedPath = this.graph.getCachedPath(edge.id, fromNodeId); + if (cachedPath && cachedPath.length > 0) { + // Path is cached for this exact direction, use as-is + initialPath.push(...cachedPath.slice(1)); + continue; + } + } + + const segmentPath = this.findLocalPath( + fromTile, + toTile, + edge.clusterX, + edge.clusterY, + ); + + if (!segmentPath) { + return null; + } + + initialPath.push(...segmentPath.slice(1)); + + // Cache the path for this direction + if (this.options.cachePaths) { + this.graph.setCachedPath(edge.id, fromNodeId, segmentPath); + } + } + + // 3. Find path from last node to end + const lastNode = this.graph.getNode(nodePath[nodePath.length - 1])!; + const lastNodeTile = lastNode.tile; + + const endX = this.map.x(to); + const endY = this.map.y(to); + const endClusterX = Math.floor(endX / this.graph.clusterSize); + const endClusterY = Math.floor(endY / this.graph.clusterSize); + const endSegment = this.findLocalPath( + lastNodeTile, + to, + endClusterX, + endClusterY, + ); + + if (!endSegment) { + return null; + } + + 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`); + } + + // Smoothing moved to SmoothingTransformer - return raw path + return initialPath; + } + + private findNearestNode(tile: TileRef): AbstractNode | null { + const x = this.map.x(tile); + const y = this.map.y(tile); + + const clusterX = Math.floor(x / this.graph.clusterSize); + const clusterY = Math.floor(y / this.graph.clusterSize); + + const clusterSize = this.graph.clusterSize; + const minX = clusterX * clusterSize; + const minY = clusterY * clusterSize; + const maxX = Math.min(this.map.width() - 1, minX + clusterSize - 1); + const maxY = Math.min(this.map.height() - 1, minY + clusterSize - 1); + + const cluster = this.graph.getCluster(clusterX, clusterY); + if (!cluster || cluster.nodeIds.length === 0) { + return null; + } + + const candidateNodes = cluster.nodeIds.map((id) => this.graph.getNode(id)!); + const maxDistance = clusterSize * clusterSize; + + return this.tileBFS.search( + this.map.width(), + this.map.height(), + tile, + maxDistance, + (t: TileRef) => this.graph.getComponentId(t) !== LAND_MARKER, + (t: TileRef, _dist: number) => { + const tileX = this.map.x(t); + const tileY = this.map.y(t); + + for (const node of candidateNodes) { + if (node.x === tileX && node.y === tileY) { + return node; + } + } + + if (tileX < minX || tileX > maxX || tileY < minY || tileY > maxY) { + return null; + } + }, + ); + } + + private findAbstractPath( + fromNodeId: number, + toNodeId: number, + ): number[] | null { + return this.abstractAStar.findPath(fromNodeId, toNodeId); + } + + private findLocalPath( + from: TileRef, + to: TileRef, + clusterX: number, + clusterY: number, + multiCluster: boolean = false, + ): TileRef[] | null { + // Calculate cluster bounds + const clusterSize = this.graph.clusterSize; + + let minX: number; + let minY: number; + let maxX: number; + let maxY: number; + + if (multiCluster) { + // 3×3 clusters centered on the starting cluster + minX = Math.max(0, (clusterX - 1) * clusterSize); + minY = Math.max(0, (clusterY - 1) * clusterSize); + maxX = Math.min(this.map.width() - 1, (clusterX + 2) * clusterSize - 1); + maxY = Math.min(this.map.height() - 1, (clusterY + 2) * clusterSize - 1); + } else { + minX = clusterX * clusterSize; + minY = clusterY * clusterSize; + maxX = Math.min(this.map.width() - 1, minX + clusterSize - 1); + maxY = Math.min(this.map.height() - 1, minY + clusterSize - 1); + } + + // Choose the appropriate BoundedAStar based on search area + const selectedAStar = multiCluster + ? this.localAStarMultiCluster + : this.localAStar; + + // Run BoundedAStar on bounded region - works directly on map coords + const path = selectedAStar.searchBounded(from, to, { + minX, + maxX, + minY, + maxY, + }); + + if (!path || path.length === 0) { + return null; + } + + // Fix endpoints: BoundedAStar clamps tiles to bounds, but node tiles may be + // just outside cluster bounds. Ensure path starts/ends at exact requested tiles. + if (path[0] !== from) { + path.unshift(from); + } + if (path[path.length - 1] !== to) { + path.push(to); + } + + return path; + } +} + +// Helper class for resolving tiles to abstract nodes +// Assumes tiles are already water and component-filtered (by transformer pipeline) +class SourceResolver { + constructor( + private map: GameMap, + private graph: AbstractGraph, + ) {} + + // Resolves target to its abstract node + resolveTarget(target: TileRef): AbstractNode | null { + return this.getClusterNode(target); + } + + // Maps sources → abstract nodes, returns Map + resolveSourcesToNodes(sources: TileRef[]): Map { + const nodeToSource = new Map(); + const nodeToDist = new Map(); + + for (const source of sources) { + const node = this.getClusterNode(source); + if (node === null) continue; + + const x = this.map.x(source); + const y = this.map.y(source); + const dist = Math.abs(node.x - x) + Math.abs(node.y - y); + + // Keep closest source per node + const prevDist = nodeToDist.get(node.id); + if (prevDist === undefined || dist < prevDist) { + nodeToSource.set(node.id, source); + nodeToDist.set(node.id, dist); + } + } + + return nodeToSource; + } + + private getClusterNode(tile: TileRef): AbstractNode | null { + const x = this.map.x(tile); + const y = this.map.y(tile); + const clusterX = Math.floor(x / this.graph.clusterSize); + const clusterY = Math.floor(y / this.graph.clusterSize); + + const cluster = this.graph.getCluster(clusterX, clusterY); + if (!cluster || cluster.nodeIds.length === 0) return null; + + // Return closest node to tile + let bestNode: AbstractNode | null = null; + let bestDist = Infinity; + + for (const nodeId of cluster.nodeIds) { + const node = this.graph.getNode(nodeId); + if (!node) continue; + + const dist = Math.abs(node.x - x) + Math.abs(node.y - y); + if (dist < bestDist) { + bestDist = dist; + bestNode = node; + } + } + + return bestNode; + } +} diff --git a/src/core/pathfinding/algorithms/AStar.ts b/src/core/pathfinding/algorithms/AStar.ts new file mode 100644 index 000000000..bb8fe405d --- /dev/null +++ b/src/core/pathfinding/algorithms/AStar.ts @@ -0,0 +1,127 @@ +// Generic A* implementation with adapter interface +// See AStar.Rail.ts for adapter version where performance is not critical +// See AStar.Water.ts for inlined version for performance-critical use + +import { PathFinder } from "../types"; +import { BucketQueue, PriorityQueue } from "./PriorityQueue"; + +export interface AStarAdapter { + // Important optimization: write to the buffer and return the count + // You can do this and it will be much faster :) + neighbors(node: number, buffer: Int32Array): number; + + cost(from: number, to: number, prev?: number): number; + heuristic(node: number, goal: number): number; + numNodes(): number; + maxPriority(): number; + maxNeighbors(): number; +} + +export interface AStarConfig { + adapter: AStarAdapter; + maxIterations?: number; +} + +export class AStar implements PathFinder { + private stamp = 1; + + private readonly closedStamp: Uint32Array; + private readonly gScoreStamp: Uint32Array; + private readonly gScore: Uint32Array; + private readonly cameFrom: Int32Array; + private readonly queue: PriorityQueue; + private readonly adapter: AStarAdapter; + private readonly neighborBuffer: Int32Array; + private readonly maxIterations: number; + + constructor(config: AStarConfig) { + this.adapter = config.adapter; + this.maxIterations = config.maxIterations ?? 500_000; + this.neighborBuffer = new Int32Array(this.adapter.maxNeighbors()); + this.closedStamp = new Uint32Array(this.adapter.numNodes()); + this.gScoreStamp = new Uint32Array(this.adapter.numNodes()); + this.gScore = new Uint32Array(this.adapter.numNodes()); + this.cameFrom = new Int32Array(this.adapter.numNodes()); + this.queue = new BucketQueue(this.adapter.maxPriority()); + } + + findPath(start: number | number[], goal: number): number[] | null { + this.stamp++; + if (this.stamp > 0xffffffff) { + this.closedStamp.fill(0); + this.gScoreStamp.fill(0); + this.stamp = 1; + } + + const stamp = this.stamp; + const adapter = this.adapter; + const closedStamp = this.closedStamp; + const gScoreStamp = this.gScoreStamp; + const gScore = this.gScore; + const cameFrom = this.cameFrom; + const queue = this.queue; + const buffer = this.neighborBuffer; + + queue.clear(); + const starts = Array.isArray(start) ? start : [start]; + for (const s of starts) { + gScore[s] = 0; + gScoreStamp[s] = stamp; + cameFrom[s] = -1; + queue.push(s, adapter.heuristic(s, goal)); + } + + let iterations = this.maxIterations; + + while (!queue.isEmpty()) { + if (--iterations <= 0) { + return null; + } + + const current = queue.pop(); + + if (closedStamp[current] === stamp) continue; + closedStamp[current] = stamp; + + if (current === goal) { + return this.buildPath(goal); + } + + const currentG = gScore[current]; + const prev = cameFrom[current]; + const count = adapter.neighbors(current, buffer); + + for (let i = 0; i < count; i++) { + const neighbor = buffer[i]; + + if (closedStamp[neighbor] === stamp) continue; + + const tentativeG = + currentG + + adapter.cost(current, neighbor, prev === -1 ? undefined : prev); + + if (gScoreStamp[neighbor] !== stamp || tentativeG < gScore[neighbor]) { + cameFrom[neighbor] = current; + gScore[neighbor] = tentativeG; + gScoreStamp[neighbor] = stamp; + queue.push(neighbor, tentativeG + adapter.heuristic(neighbor, goal)); + } + } + } + + return null; + } + + private buildPath(goal: number): number[] { + const path: number[] = []; + let current = goal; + + while (current !== -1) { + path.push(current); + current = this.cameFrom[current]; + } + + path.reverse(); + return path; + } +} diff --git a/src/core/pathfinding/algorithms/AbstractGraph.ts b/src/core/pathfinding/algorithms/AbstractGraph.ts new file mode 100644 index 000000000..d7be8faeb --- /dev/null +++ b/src/core/pathfinding/algorithms/AbstractGraph.ts @@ -0,0 +1,682 @@ +import { GameMap, TileRef } from "../../game/GameMap"; +import { BFSGrid } from "./BFS.Grid"; +import { ConnectedComponents } from "./ConnectedComponents"; + +export interface AbstractNode { + id: number; + x: number; + y: number; + tile: TileRef; + componentId: number; +} + +export interface AbstractEdge { + id: number; + nodeA: number; // Lower node ID (canonical order: nodeA < nodeB) + nodeB: number; // Higher node ID + cost: number; + clusterX: number; + clusterY: number; +} + +export interface Cluster { + x: number; + y: 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 { + // Nodes (array indexed by id) + private readonly _nodes: AbstractNode[] = []; + + // Edges (bidirectional, stored once) + private readonly _edges: AbstractEdge[] = []; + private readonly _nodeEdgeIds: number[][] = []; // nodeId → edge IDs + + // Clusters (array indexed by clusterKey) + private readonly _clusters: Cluster[] = []; + + // Path cache indexed by edge.id (shared across all users) + private _pathCache: (TileRef[] | null)[] = []; + + // Water components for componentId lookup + private _waterComponents: ConnectedComponents | null = null; + + constructor( + readonly clusterSize: number, + readonly clustersX: number, + readonly clustersY: number, + ) {} + + getNode(id: number): AbstractNode | undefined { + return this._nodes[id]; + } + + getAllNodes(): readonly AbstractNode[] { + return this._nodes; + } + + get nodeCount(): number { + return this._nodes.length; + } + + getEdge(id: number): AbstractEdge | undefined { + return this._edges[id]; + } + + getNodeEdges(nodeId: number): AbstractEdge[] { + const edgeIds = this._nodeEdgeIds[nodeId]; + if (!edgeIds) return []; + return edgeIds.map((id) => this._edges[id]); + } + + getEdgeBetween(nodeA: number, nodeB: number): AbstractEdge | undefined { + const edgeIds = this._nodeEdgeIds[nodeA]; + if (!edgeIds) return undefined; + + for (const edgeId of edgeIds) { + const edge = this._edges[edgeId]; + if (edge.nodeA === nodeB || edge.nodeB === nodeB) { + return edge; + } + } + return undefined; + } + + getOtherNode(edge: AbstractEdge, nodeId: number): number { + return edge.nodeA === nodeId ? edge.nodeB : edge.nodeA; + } + + get edgeCount(): number { + return this._edges.length; + } + + /** + * Get cached path for edge in specific direction + * @param edgeId Edge ID + * @param fromNodeId The starting node of the traversal (determines direction) + */ + getCachedPath(edgeId: number, fromNodeId: number): TileRef[] | null { + const edge = this._edges[edgeId]; + if (!edge) return null; + // Direction: 0 if traversing A→B, 1 if traversing B→A + const direction = fromNodeId === edge.nodeA ? 0 : 1; + const cacheIndex = edgeId * 2 + direction; + return this._pathCache[cacheIndex] ?? null; + } + + /** + * Cache path for edge in specific direction + * @param edgeId Edge ID + * @param fromNodeId The starting node of the traversal (determines direction) + * @param path The path tiles + */ + setCachedPath(edgeId: number, fromNodeId: number, path: TileRef[]): void { + const edge = this._edges[edgeId]; + if (!edge) return; + // Direction: 0 if traversing A→B, 1 if traversing B→A + const direction = fromNodeId === edge.nodeA ? 0 : 1; + const cacheIndex = edgeId * 2 + direction; + this._pathCache[cacheIndex] = path; + } + + _initPathCache(): void { + // Double the cache size to store both directions + this._pathCache = new Array(this._edges.length * 2).fill(null); + } + + setWaterComponents(wc: ConnectedComponents): void { + this._waterComponents = wc; + } + + getComponentId(tile: TileRef): number { + return this._waterComponents?.getComponentId(tile) ?? 0; + } + + getClusterKey(clusterX: number, clusterY: number): number { + return clusterY * this.clustersX + clusterX; + } + + getCluster(clusterX: number, clusterY: number): Cluster | undefined { + return this._clusters[this.getClusterKey(clusterX, clusterY)]; + } + + getClusterNodes(clusterX: number, clusterY: number): AbstractNode[] { + const cluster = this.getCluster(clusterX, clusterY); + if (!cluster) return []; + return cluster.nodeIds.map((id) => this._nodes[id]); + } + + getNearbyClusterNodes(clusterX: number, clusterY: number): AbstractNode[] { + const nodes: AbstractNode[] = []; + + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + const cluster = this.getCluster(clusterX + dx, clusterY + dy); + if (cluster) { + for (const nodeId of cluster.nodeIds) { + nodes.push(this._nodes[nodeId]); + } + } + } + } + + return nodes; + } + + _addNode(node: AbstractNode): void { + this._nodes[node.id] = node; + this._nodeEdgeIds[node.id] = []; + } + + _addEdge(edge: AbstractEdge): void { + this._edges[edge.id] = edge; + this._nodeEdgeIds[edge.nodeA].push(edge.id); + this._nodeEdgeIds[edge.nodeB].push(edge.id); + } + + _setCluster(key: number, cluster: Cluster): void { + this._clusters[key] = cluster; + } + + _addNodeToCluster(clusterKey: number, nodeId: number): void { + if (!this._clusters[clusterKey]) { + // This shouldn't happen if clusters are pre-created + return; + } + + this._clusters[clusterKey].nodeIds.push(nodeId); + } +} + +export class AbstractGraphBuilder { + static readonly CLUSTER_SIZE = 32; + + // Derived immutable state + private readonly width: number; + private readonly height: number; + private readonly clustersX: number; + private readonly clustersY: number; + private readonly tileBFS: BFSGrid; + private readonly waterComponents: ConnectedComponents; + + // Build state + private graph!: AbstractGraph; + private tileToNode = new Map(); + private nextNodeId = 0; + private nextEdgeId = 0; + private edgeBetween = new Map>(); + + public debugInfo: BuildDebugInfo | null = null; + + constructor( + private readonly map: GameMap, + private readonly clusterSize: number = AbstractGraphBuilder.CLUSTER_SIZE, + ) { + this.width = map.width(); + this.height = map.height(); + this.clustersX = Math.ceil(this.width / clusterSize); + this.clustersY = Math.ceil(this.height / clusterSize); + this.tileBFS = new BFSGrid(this.width * this.height); + this.waterComponents = new ConnectedComponents(map); + } + + build(debug: boolean = false): AbstractGraph { + performance.mark("abstractgraph:build:start"); + + this.graph = new AbstractGraph( + this.clusterSize, + this.clustersX, + 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++) { + for (let cx = 0; cx < this.clustersX; cx++) { + const key = this.graph.getClusterKey(cx, cy); + this.graph._setCluster(key, { x: cx, y: cy, nodeIds: [] }); + } + } + + // Find nodes (gateways) at cluster boundaries + performance.mark("abstractgraph:build:nodes:start"); + 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; + } + + // Build edges between nodes in same cluster + performance.mark("abstractgraph:build:edges:start"); + 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", + ); + + 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; + } + + // Initialize path cache after all edges are built + this.graph._initPathCache(); + + // Store water components for componentId lookups + this.graph.setWaterComponents(this.waterComponents); + + return this.graph; + } + + private getOrCreateNode(x: number, y: number): AbstractNode { + const tile = this.map.ref(x, y); + + const existing = this.tileToNode.get(tile); + if (existing) { + return existing; + } + + const node: AbstractNode = { + id: this.nextNodeId++, + x, + y, + tile, + componentId: this.waterComponents.getComponentId(tile), + }; + + this.graph._addNode(node); + this.tileToNode.set(tile, node); + return node; + } + + private addNodeToCluster( + clusterX: number, + clusterY: number, + node: AbstractNode, + ): void { + const cluster = this.graph.getCluster(clusterX, clusterY); + if (!cluster) return; + + // Check for duplicates (node at cluster corner can be found by both edge scans) + if (!cluster.nodeIds.includes(node.id)) { + cluster.nodeIds.push(node.id); + } + } + + private processCluster(cx: number, cy: number): void { + const baseX = cx * this.clusterSize; + const baseY = cy * this.clusterSize; + + // Right edge (vertical boundary to next cluster) + if (cx < this.clustersX - 1) { + const edgeX = Math.min(baseX + this.clusterSize - 1, this.width - 1); + const nodes = this.findNodesOnVerticalEdge(edgeX, baseY); + + for (const node of nodes) { + this.addNodeToCluster(cx, cy, node); + this.addNodeToCluster(cx + 1, cy, node); + } + } + + // Bottom edge (horizontal boundary to next cluster) + if (cy < this.clustersY - 1) { + const edgeY = Math.min(baseY + this.clusterSize - 1, this.height - 1); + const nodes = this.findNodesOnHorizontalEdge(edgeY, baseX); + + for (const node of nodes) { + this.addNodeToCluster(cx, cy, node); + this.addNodeToCluster(cx, cy + 1, node); + } + } + } + + private findNodesOnVerticalEdge(x: number, baseY: number): AbstractNode[] { + const nodes: AbstractNode[] = []; + const maxY = Math.min(baseY + this.clusterSize, this.height); + + let spanStart = -1; + + const tryAddNode = (y: number) => { + if (spanStart === -1) return; + + const spanLength = y - spanStart; + const midY = spanStart + Math.floor(spanLength / 2); + spanStart = -1; + + const node = this.getOrCreateNode(x, midY); + nodes.push(node); + }; + + for (let y = baseY; y < maxY; y++) { + const tile = this.map.ref(x, y); + const nextTile = x + 1 < this.map.width() ? this.map.ref(x + 1, y) : -1; + const isEntrance = + this.map.isWater(tile) && nextTile !== -1 && this.map.isWater(nextTile); + + if (isEntrance) { + if (spanStart === -1) { + spanStart = y; + } + } else { + tryAddNode(y); + } + } + + tryAddNode(maxY); + return nodes; + } + + private findNodesOnHorizontalEdge(y: number, baseX: number): AbstractNode[] { + const nodes: AbstractNode[] = []; + const maxX = Math.min(baseX + this.clusterSize, this.width); + + let spanStart = -1; + + const tryAddNode = (x: number) => { + if (spanStart === -1) return; + + const spanLength = x - spanStart; + const midX = spanStart + Math.floor(spanLength / 2); + spanStart = -1; + + const node = this.getOrCreateNode(midX, y); + nodes.push(node); + }; + + for (let x = baseX; x < maxX; x++) { + const tile = this.map.ref(x, y); + const nextTile = y + 1 < this.map.height() ? this.map.ref(x, y + 1) : -1; + const isEntrance = + this.map.isWater(tile) && nextTile !== -1 && this.map.isWater(nextTile); + + if (isEntrance) { + if (spanStart === -1) { + spanStart = x; + } + } else { + tryAddNode(x); + } + } + + tryAddNode(maxX); + return nodes; + } + + private buildClusterConnections(cx: number, cy: number): void { + const cluster = this.graph.getCluster(cx, cy); + if (!cluster) return; + + const nodeIds = cluster.nodeIds; + const nodes = nodeIds.map((id) => this.graph.getNode(id)!); + + // Calculate cluster bounds + const clusterMinX = cx * this.clusterSize; + const clusterMinY = cy * this.clusterSize; + const clusterMaxX = Math.min( + this.width - 1, + clusterMinX + this.clusterSize - 1, + ); + const clusterMaxY = Math.min( + this.height - 1, + clusterMinY + this.clusterSize - 1, + ); + + for (let i = 0; i < nodes.length; i++) { + const fromNode = nodes[i]; + + // Build list of target nodes (only those we haven't processed with this node) + const targetNodes: AbstractNode[] = []; + for (let j = i + 1; j < nodes.length; j++) { + // Skip if nodes are in different water components + if (nodes[i].componentId !== nodes[j].componentId) { + continue; + } + targetNodes.push(nodes[j]); + } + + if (targetNodes.length === 0) continue; + + // Single BFS to find all reachable target nodes + const reachable = this.findAllReachableNodesInBounds( + fromNode.tile, + targetNodes, + clusterMinX, + clusterMaxX, + clusterMinY, + clusterMaxY, + ); + + // Create edges for all reachable nodes + for (const [targetId, cost] of reachable.entries()) { + this.addOrUpdateEdge(fromNode.id, targetId, cost, cx, cy); + } + } + } + + /** + * Add or update edge between two nodes. + * Edges are bidirectional and stored once with canonical order (nodeA < nodeB). + * If edge exists with higher cost, update it. + */ + private addOrUpdateEdge( + nodeIdA: number, + nodeIdB: number, + cost: number, + clusterX: number, + clusterY: number, + ): void { + // Canonical order: lower ID first + const [lo, hi] = + nodeIdA < nodeIdB ? [nodeIdA, nodeIdB] : [nodeIdB, nodeIdA]; + + // Check for existing edge + let nodeMap = this.edgeBetween.get(lo); + if (!nodeMap) { + nodeMap = new Map(); + this.edgeBetween.set(lo, nodeMap); + } + + const existingEdge = nodeMap.get(hi); + + if (existingEdge) { + // Update if new cost is cheaper + if (cost < existingEdge.cost) { + existingEdge.cost = cost; + existingEdge.clusterX = clusterX; + existingEdge.clusterY = clusterY; + } + return; + } + + // Create new edge + const edge: AbstractEdge = { + id: this.nextEdgeId++, + nodeA: lo, + nodeB: hi, + cost, + clusterX, + clusterY, + }; + + nodeMap.set(hi, edge); + this.graph._addEdge(edge); + } + + private findAllReachableNodesInBounds( + from: TileRef, + targetNodes: AbstractNode[], + minX: number, + maxX: number, + minY: number, + maxY: number, + ): Map { + const fromX = this.map.x(from); + const fromY = this.map.y(from); + + // Create a map of tile positions to node IDs for fast lookup + const tileToNodeId = new Map(); + let maxManhattanDist = 0; + + for (const node of targetNodes) { + tileToNodeId.set(node.tile, node.id); + const dx = Math.abs(node.x - fromX); + const dy = Math.abs(node.y - fromY); + maxManhattanDist = Math.max(maxManhattanDist, dx + dy); + } + + const maxDistance = maxManhattanDist * 4; // Allow path deviation + const reachable = new Map(); + let foundCount = 0; + + this.tileBFS.search( + this.map.width(), + this.map.height(), + from, + maxDistance, + (tile: number) => this.map.isWater(tile), + (tile: number, dist: number) => { + const x = this.map.x(tile); + const y = this.map.y(tile); + + // Reject if outside of bounding box (except start/target) + const isStartOrTarget = tile === from || tileToNodeId.has(tile); + if ( + !isStartOrTarget && + (x < minX || x > maxX || y < minY || y > maxY) + ) { + return null; + } + + // Check if this tile is one of our target nodes + const nodeId = tileToNodeId.get(tile); + + if (nodeId !== undefined) { + reachable.set(nodeId, dist); + foundCount++; + + // Early exit if we've found all target nodes + if (foundCount === targetNodes.length) { + return dist; // Return to stop BFS + } + } + }, + ); + + return reachable; + } +} diff --git a/src/core/pathfinding/navmesh/FastBFS.ts b/src/core/pathfinding/algorithms/BFS.Grid.ts similarity index 66% rename from src/core/pathfinding/navmesh/FastBFS.ts rename to src/core/pathfinding/algorithms/BFS.Grid.ts index 11ff34725..d70803c71 100644 --- a/src/core/pathfinding/navmesh/FastBFS.ts +++ b/src/core/pathfinding/algorithms/BFS.Grid.ts @@ -1,11 +1,7 @@ -export interface FastBFSAdapter { - visitor(node: number, dist: number): T | null | undefined; - isValidNode(node: number): boolean; -} - -// Optimized BFS using stamp-based visited tracking and typed array queue -export class FastBFS { +// 4-direction grid BFS with stamp-based visited tracking +export class BFSGrid { private stamp = 1; + private readonly visitedStamp: Uint32Array; private readonly queue: Int32Array; private readonly dist: Uint16Array; @@ -16,48 +12,57 @@ export class FastBFS { this.dist = new Uint16Array(numNodes); } - search( + /** + * Grid BFS search with visitor pattern. + * @param start - Starting node(s) + * @param maxDistance - Maximum distance to search + * @param isValidNode - Filter for traversable nodes + * @param visitor - Called for each node: + * - Returns R: Found target, return immediately + * - Returns undefined: Valid node, explore neighbors + * - Returns null: Reject node, don't explore neighbors + */ + search( width: number, height: number, - start: number, + start: number | number[], maxDistance: number, - isValidNode: FastBFSAdapter["isValidNode"], - visitor: FastBFSAdapter["visitor"], - ): T | null { + isValidNode: (node: number) => boolean, + visitor: (node: number, dist: number) => R | null | undefined, + ): R | null { const stamp = this.nextStamp(); const lastRowStart = (height - 1) * width; + const starts = typeof start === "number" ? [start] : start; let head = 0; let tail = 0; - this.visitedStamp[start] = stamp; - this.dist[start] = 0; - this.queue[tail++] = start; + for (const s of starts) { + this.visitedStamp[s] = stamp; + this.dist[s] = 0; + this.queue[tail++] = s; + } while (head < tail) { const node = this.queue[head++]; - const currentDist = this.dist[node]; + const dist = this.dist[node]; - if (currentDist > maxDistance) { - continue; - } - - // Call visitor: - // - Returns T: Found target, return immediately - // - Returns null: Reject tile, don't explore neighbors - // - Returns undefined: Valid tile, explore neighbors - const result = visitor(node, currentDist); + const result = visitor(node, dist); if (result !== null && result !== undefined) { return result; } - // If visitor returned null, reject this tile and don't explore neighbors if (result === null) { continue; } - const nextDist = currentDist + 1; + const nextDist = dist + 1; + + if (nextDist > maxDistance) { + continue; + } + const x = node % width; // North @@ -107,8 +112,7 @@ export class FastBFS { private nextStamp(): number { const stamp = this.stamp++; - if (this.stamp === 0) { - // Overflow - reset (extremely rare) + if (this.stamp > 0xffffffff) { this.visitedStamp.fill(0); this.stamp = 1; } diff --git a/src/core/pathfinding/algorithms/BFS.ts b/src/core/pathfinding/algorithms/BFS.ts new file mode 100644 index 000000000..06a7acc80 --- /dev/null +++ b/src/core/pathfinding/algorithms/BFS.ts @@ -0,0 +1,64 @@ +// Generic BFS implementation with adapter interface + +export interface BFSAdapter { + neighbors(node: T): T[]; +} + +export class BFS { + constructor(private adapter: BFSAdapter) {} + + /** + * BFS search with visitor pattern. + * @param start - Starting node(s) + * @param maxDistance - Maximum distance to search (Infinity for unlimited) + * @param visitor - Called for each node: + * - Returns R: Found target, return immediately + * - Returns undefined: Valid node, explore neighbors + * - Returns null: Reject node, don't explore neighbors + */ + search( + start: T | T[], + maxDistance: number, + visitor: (node: T, dist: number) => R | null | undefined, + ): R | null { + const visited = new Set(); + const queue: { node: T; dist: number }[] = []; + const starts = Array.isArray(start) ? start : [start]; + + for (const s of starts) { + visited.add(s); + queue.push({ node: s, dist: 0 }); + } + + while (queue.length > 0) { + const { node, dist } = queue.shift()!; + + const result = visitor(node, dist); + + if (result !== null && result !== undefined) { + return result; + } + + if (result === null) { + continue; + } + + const nextDist = dist + 1; + + if (nextDist > maxDistance) { + continue; + } + + for (const neighbor of this.adapter.neighbors(node)) { + if (visited.has(neighbor)) { + continue; + } + + visited.add(neighbor); + queue.push({ node: neighbor, dist: nextDist }); + } + } + + return null; + } +} diff --git a/src/core/pathfinding/navmesh/WaterComponents.ts b/src/core/pathfinding/algorithms/ConnectedComponents.ts similarity index 95% rename from src/core/pathfinding/navmesh/WaterComponents.ts rename to src/core/pathfinding/algorithms/ConnectedComponents.ts index e58cdf96f..93813b341 100644 --- a/src/core/pathfinding/navmesh/WaterComponents.ts +++ b/src/core/pathfinding/algorithms/ConnectedComponents.ts @@ -1,12 +1,14 @@ +// Connected Component Labeling using flood-fill + import { GameMap, TileRef } from "../../game/GameMap"; -const LAND_MARKER = 0xff; // Must fit in Uint8Array +export const LAND_MARKER = 0xff; // Must fit in Uint8Array /** - * Manages water component identification using flood-fill. - * Pre-allocates buffers and provides explicit initialization. + * Connected component labeling for grid-based maps. + * Identifies isolated regions using scan-line flood-fill. */ -export class WaterComponents { +export class ConnectedComponents { private readonly width: number; private readonly height: number; private readonly numTiles: number; diff --git a/src/core/pathfinding/algorithms/PriorityQueue.ts b/src/core/pathfinding/algorithms/PriorityQueue.ts new file mode 100644 index 000000000..c8f525f0b --- /dev/null +++ b/src/core/pathfinding/algorithms/PriorityQueue.ts @@ -0,0 +1,154 @@ +export interface PriorityQueue { + push(node: number, priority: number): void; + pop(): number; + isEmpty(): boolean; + clear(): void; +} + +// Binary min-heap: O(log n) push/pop, works with any priority values +export class MinHeap implements PriorityQueue { + private heap: Int32Array; + private priorities: Float32Array; + private size = 0; + + constructor(private capacity: number) { + this.heap = new Int32Array(capacity); + this.priorities = new Float32Array(capacity); + } + + push(node: number, priority: number): void { + if (this.size >= this.capacity) { + throw new Error(`MinHeap capacity exceeded: ${this.capacity}`); + } + + let i = this.size++; + this.heap[i] = node; + this.priorities[i] = priority; + + // Bubble up + while (i > 0) { + const parent = (i - 1) >> 1; + if (this.priorities[parent] <= this.priorities[i]) break; + // Swap + const tmpNode = this.heap[parent]; + const tmpPri = this.priorities[parent]; + this.heap[parent] = this.heap[i]; + this.priorities[parent] = this.priorities[i]; + this.heap[i] = tmpNode; + this.priorities[i] = tmpPri; + i = parent; + } + } + + pop(): number { + const result = this.heap[0]; + this.size--; + if (this.size > 0) { + this.heap[0] = this.heap[this.size]; + this.priorities[0] = this.priorities[this.size]; + + // Bubble down + let i = 0; + while (true) { + const left = (i << 1) + 1; + const right = left + 1; + let smallest = i; + + if ( + left < this.size && + this.priorities[left] < this.priorities[smallest] + ) { + smallest = left; + } + if ( + right < this.size && + this.priorities[right] < this.priorities[smallest] + ) { + smallest = right; + } + if (smallest === i) break; + + // Swap + const tmpNode = this.heap[smallest]; + const tmpPri = this.priorities[smallest]; + this.heap[smallest] = this.heap[i]; + this.priorities[smallest] = this.priorities[i]; + this.heap[i] = tmpNode; + this.priorities[i] = tmpPri; + i = smallest; + } + } + return result; + } + + isEmpty(): boolean { + return this.size === 0; + } + + clear(): void { + this.size = 0; + } +} + +// Bucket queue: O(1) push/pop when priorities are integers +export class BucketQueue implements PriorityQueue { + private buckets: Int32Array[]; + private bucketSizes: Int32Array; + private minBucket: number; + private maxBucket: number; + private size: number; + + constructor(maxPriority: number) { + this.maxBucket = maxPriority + 1; + this.buckets = new Array(this.maxBucket); + this.bucketSizes = new Int32Array(this.maxBucket); + this.minBucket = this.maxBucket; + this.size = 0; + } + + push(node: number, priority: number): void { + const bucket = Math.min(priority | 0, this.maxBucket - 1); + + if (!this.buckets[bucket]) { + this.buckets[bucket] = new Int32Array(64); + } + + const size = this.bucketSizes[bucket]; + if (size >= this.buckets[bucket].length) { + const newBucket = new Int32Array(this.buckets[bucket].length * 2); + newBucket.set(this.buckets[bucket]); + this.buckets[bucket] = newBucket; + } + + this.buckets[bucket][size] = node; + this.bucketSizes[bucket]++; + this.size++; + + if (bucket < this.minBucket) { + this.minBucket = bucket; + } + } + + pop(): number { + while (this.minBucket < this.maxBucket) { + const size = this.bucketSizes[this.minBucket]; + if (size > 0) { + this.bucketSizes[this.minBucket]--; + this.size--; + return this.buckets[this.minBucket][size - 1]; + } + this.minBucket++; + } + return -1; + } + + isEmpty(): boolean { + return this.size === 0; + } + + clear(): void { + this.bucketSizes.fill(0); + this.minBucket = this.maxBucket; + this.size = 0; + } +} diff --git a/src/core/pathfinding/navmesh/FastAStar.ts b/src/core/pathfinding/navmesh/FastAStar.ts deleted file mode 100644 index 770248e79..000000000 --- a/src/core/pathfinding/navmesh/FastAStar.ts +++ /dev/null @@ -1,202 +0,0 @@ -// A* optimized for performance for small to medium graphs. -// Works with node IDs represented as integers (0 to numNodes-1) - -export interface FastAStarAdapter { - getNeighbors(node: number): number[]; - getCost(from: number, to: number): number; - heuristic(node: number, goal: number): number; -} - -// Simple binary min-heap for open set using typed arrays -class MinHeap { - private heap: Int32Array; - private scores: Float32Array; - private size = 0; - - constructor(capacity: number, scores: Float32Array) { - this.heap = new Int32Array(capacity); - this.scores = scores; - } - - push(node: number): void { - let i = this.size++; - this.heap[i] = node; - - // Bubble up - while (i > 0) { - const parent = (i - 1) >> 1; - if (this.scores[this.heap[parent]] <= this.scores[this.heap[i]]) { - break; - } - - // Swap - const tmp = this.heap[parent]; - this.heap[parent] = this.heap[i]; - this.heap[i] = tmp; - i = parent; - } - } - - pop(): number { - const result = this.heap[0]; - this.heap[0] = this.heap[--this.size]; - - // Bubble down - let i = 0; - while (true) { - const left = (i << 1) + 1; - const right = left + 1; - let smallest = i; - - if ( - left < this.size && - this.scores[this.heap[left]] < this.scores[this.heap[smallest]] - ) { - smallest = left; - } - - if ( - right < this.size && - this.scores[this.heap[right]] < this.scores[this.heap[smallest]] - ) { - smallest = right; - } - - if (smallest === i) { - break; - } - - // Swap - const tmp = this.heap[smallest]; - this.heap[smallest] = this.heap[i]; - this.heap[i] = tmp; - i = smallest; - } - - return result; - } - - isEmpty(): boolean { - return this.size === 0; - } - - clear(): void { - this.size = 0; - } -} - -export class FastAStar { - private stamp = 1; - private readonly closedStamp: Uint32Array; // Tracks fully processed nodes - private readonly gScoreStamp: Uint32Array; // Tracks valid gScores - private readonly gScore: Float32Array; - private readonly fScore: Float32Array; - private readonly cameFrom: Int32Array; - private readonly openHeap: MinHeap; - - constructor(numNodes: number) { - this.closedStamp = new Uint32Array(numNodes); - this.gScoreStamp = new Uint32Array(numNodes); - this.gScore = new Float32Array(numNodes); - this.fScore = new Float32Array(numNodes); - this.cameFrom = new Int32Array(numNodes); - this.openHeap = new MinHeap(numNodes, this.fScore); - } - - private nextStamp(): number { - const stamp = this.stamp++; - - if (this.stamp === 0) { - // Overflow - reset (extremely rare) - this.closedStamp.fill(0); - this.gScoreStamp.fill(0); - this.stamp = 1; - } - - return stamp; - } - - search( - start: number, - goal: number, - adapter: FastAStarAdapter, - maxIterations: number = 100000, - ): number[] | null { - const stamp = this.nextStamp(); - - this.openHeap.clear(); - this.gScore[start] = 0; - this.gScoreStamp[start] = stamp; - this.fScore[start] = adapter.heuristic(start, goal); - this.cameFrom[start] = -1; - this.openHeap.push(start); - - let iterations = 0; - - while (!this.openHeap.isEmpty() && iterations < maxIterations) { - iterations++; - - const current = this.openHeap.pop(); - - // Skip if already processed (duplicate from heap) - if (this.closedStamp[current] === stamp) { - continue; - } - - // Mark as processed - this.closedStamp[current] = stamp; - - // Found goal - if (current === goal) { - return this.reconstructPath(start, goal); - } - - const neighbors = adapter.getNeighbors(current); - const currentGScore = this.gScore[current]; - - for (const neighbor of neighbors) { - // Skip already processed neighbors - if (this.closedStamp[neighbor] === stamp) { - continue; - } - - const tentativeGScore = - currentGScore + adapter.getCost(current, neighbor); - - // If we haven't visited this neighbor yet, or found a better path - const hasValidGScore = this.gScoreStamp[neighbor] === stamp; - if (!hasValidGScore || tentativeGScore < this.gScore[neighbor]) { - this.cameFrom[neighbor] = current; - this.gScore[neighbor] = tentativeGScore; - this.gScoreStamp[neighbor] = stamp; - this.fScore[neighbor] = - tentativeGScore + adapter.heuristic(neighbor, goal); - - // Add to heap (allow duplicates for better paths) - this.openHeap.push(neighbor); - } - } - } - - return null; - } - - private reconstructPath(start: number, goal: number): number[] { - const path: number[] = []; - let current = goal; - - while (current !== start) { - path.push(current); - current = this.cameFrom[current]; - - // Safety check - if (current === -1) { - return []; - } - } - - path.push(start); - path.reverse(); - return path; - } -} diff --git a/src/core/pathfinding/navmesh/FastAStarAdapter.ts b/src/core/pathfinding/navmesh/FastAStarAdapter.ts deleted file mode 100644 index 95b962233..000000000 --- a/src/core/pathfinding/navmesh/FastAStarAdapter.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { GameMap, TileRef } from "../../game/GameMap"; -import { FastAStarAdapter } from "./FastAStar"; -import { GatewayGraph } from "./GatewayGraph"; - -export class GatewayGraphAdapter implements FastAStarAdapter { - constructor(private graph: GatewayGraph) {} - - getNeighbors(node: number): number[] { - const edges = this.graph.getEdges(node); - return edges.map((edge) => edge.to); - } - - getCost(from: number, to: number): number { - const edges = this.graph.getEdges(from); - const edge = edges.find((edge) => edge.to === to); - return edge?.cost ?? 1; - } - - heuristic(node: number, goal: number): number { - const nodeGw = this.graph.getGateway(node); - const goalGw = this.graph.getGateway(goal); - - if (!nodeGw || !goalGw) { - throw new Error( - `Invalid gateway ID in heuristic: node=${node} (${nodeGw ? "exists" : "missing"}), goal=${goal} (${goalGw ? "exists" : "missing"})`, - ); - } - - // Manhattan distance heuristic - const dx = Math.abs(nodeGw.x - goalGw.x); - const dy = Math.abs(nodeGw.y - goalGw.y); - return dx + dy; - } -} - -export class BoundedGameMapAdapter implements FastAStarAdapter { - private readonly minX: number; - private readonly minY: number; - private readonly width: number; - private readonly height: number; - private readonly startTile: TileRef; - private readonly goalTile: TileRef; - - readonly numNodes: number; - - constructor( - private map: GameMap, - startTile: TileRef, - goalTile: TileRef, - bounds: { minX: number; maxX: number; minY: number; maxY: number }, - ) { - this.startTile = startTile; - this.goalTile = goalTile; - - this.minX = bounds.minX; - this.minY = bounds.minY; - this.width = bounds.maxX - bounds.minX + 1; - this.height = bounds.maxY - bounds.minY + 1; - - this.numNodes = this.width * this.height; - } - - // Convert global TileRef to local node ID - tileToNode(tile: TileRef): number { - const x = this.map.x(tile) - this.minX; - const y = this.map.y(tile) - this.minY; - - // Allow start and goal tiles to be outside bounds (matching graph building behavior) - const isOutsideBounds = - x < 0 || x >= this.width || y < 0 || y >= this.height; - const isStartOrGoal = tile === this.startTile || tile === this.goalTile; - if (isOutsideBounds && !isStartOrGoal) { - return -1; // Outside bounds - } - - // Clamp coordinates for start/goal tiles that are outside bounds - const clampedX = Math.max(0, Math.min(this.width - 1, x)); - const clampedY = Math.max(0, Math.min(this.height - 1, y)); - - return clampedY * this.width + clampedX; - } - - // Convert local node ID to global TileRef - nodeToTile(node: number): TileRef { - const localX = node % this.width; - const localY = Math.floor(node / this.width); - return this.map.ref(localX + this.minX, localY + this.minY); - } - - getNeighbors(node: number): number[] { - const tile = this.nodeToTile(node); - const neighbors = this.map.neighbors(tile); - const result: number[] = []; - - for (const neighborTile of neighbors) { - if (!this.map.isWater(neighborTile)) continue; - - const neighborNode = this.tileToNode(neighborTile); - if (neighborNode !== -1) { - result.push(neighborNode); - } - } - - return result; - } - - getCost(_from: number, _to: number): number { - return 1; // Uniform cost for water tiles - } - - heuristic(node: number, goal: number): number { - const nodeTile = this.nodeToTile(node); - const goalTile = this.nodeToTile(goal); - - const dx = Math.abs(this.map.x(nodeTile) - this.map.x(goalTile)); - const dy = Math.abs(this.map.y(nodeTile) - this.map.y(goalTile)); - - return dx + dy; // Manhattan distance - } -} diff --git a/src/core/pathfinding/navmesh/GatewayGraph.ts b/src/core/pathfinding/navmesh/GatewayGraph.ts deleted file mode 100644 index c08b60a99..000000000 --- a/src/core/pathfinding/navmesh/GatewayGraph.ts +++ /dev/null @@ -1,587 +0,0 @@ -import { Game } from "../../game/Game"; -import { GameMap, TileRef } from "../../game/GameMap"; -import { FastBFS } from "./FastBFS"; -import { WaterComponents } from "./WaterComponents"; - -export interface Gateway { - id: number; - x: number; - y: number; - tile: TileRef; - componentId: number; -} - -export interface Edge { - from: number; - to: number; - cost: number; - path?: TileRef[]; - sectorX: number; - sectorY: number; -} - -export interface Sector { - x: number; - y: number; - gateways: Gateway[]; - edges: Edge[]; -} - -export type BuildDebugInfo = { - sectors: number | null; - gateways: number | null; - edges: number | null; - actualBFSCalls: number | null; - potentialBFSCalls: number | null; - skippedByComponentFilter: number | null; - timings: { [key: string]: number }; -}; - -export class GatewayGraph { - constructor( - readonly sectors: ReadonlyMap, - readonly gateways: ReadonlyMap, - readonly edges: ReadonlyMap, - readonly sectorSize: number, - readonly sectorsX: number, - ) {} - - getSectorKey(sectorX: number, sectorY: number): number { - return sectorY * this.sectorsX + sectorX; - } - - getSector(sectorX: number, sectorY: number): Sector | undefined { - return this.sectors.get(this.getSectorKey(sectorX, sectorY)); - } - - getGateway(id: number): Gateway | undefined { - return this.gateways.get(id); - } - - getEdges(gatewayId: number): Edge[] { - return this.edges.get(gatewayId) ?? []; - } - - getNearbySectorGateways(sectorX: number, sectorY: number): Gateway[] { - const nearby: Gateway[] = []; - for (let dy = -1; dy <= 1; dy++) { - for (let dx = -1; dx <= 1; dx++) { - const sector = this.getSector(sectorX + dx, sectorY + dy); - if (sector) { - nearby.push(...sector.gateways); - } - } - } - return nearby; - } - - getAllGateways(): Gateway[] { - return Array.from(this.gateways.values()); - } -} - -export class GatewayGraphBuilder { - static readonly SECTOR_SIZE = 32; - - // Derived immutable state - private readonly miniMap: GameMap; - private readonly width: number; - private readonly height: number; - private readonly sectorsX: number; - private readonly sectorsY: number; - private readonly fastBFS: FastBFS; - private readonly waterComponents: WaterComponents; - - // Mutable build state - private sectors = new Map(); - private gateways = new Map(); - private tileToGateway = new Map(); - private edges = new Map(); - private nextGatewayId = 0; - - // Programatically accessible debug info - public debugInfo: BuildDebugInfo | null = null; - - constructor( - private readonly game: Game, - private readonly sectorSize: number, - ) { - this.miniMap = game.miniMap(); - this.width = this.miniMap.width(); - this.height = this.miniMap.height(); - this.sectorsX = Math.ceil(this.width / sectorSize); - this.sectorsY = Math.ceil(this.height / sectorSize); - this.fastBFS = new FastBFS(this.width * this.height); - this.waterComponents = new WaterComponents(this.miniMap); - } - - build(debug: boolean): GatewayGraph { - performance.mark("navsat:build:start"); - - if (debug) { - console.log( - `[DEBUG] Building gateway graph with sector size ${this.sectorSize} (${this.sectorsX}x${this.sectorsY} sectors)`, - ); - - this.debugInfo = { - sectors: null, - gateways: null, - edges: null, - actualBFSCalls: null, - potentialBFSCalls: null, - skippedByComponentFilter: null, - timings: {}, - }; - } - - // Initialize water components before building gateway graph - performance.mark("navsat:build:water-component:start"); - this.waterComponents.initialize(); - performance.mark("navsat:build:water-component:end"); - const measure = performance.measure( - "navsat:build:water-component", - "navsat:build:water-component:start", - "navsat:build:water-component:end", - ); - - if (debug) { - console.log( - `[DEBUG] Water Component Identification: ${measure.duration.toFixed(2)}ms`, - ); - } - - performance.mark("navsat:build:gateways:start"); - for (let sy = 0; sy < this.sectorsY; sy++) { - for (let sx = 0; sx < this.sectorsX; sx++) { - this.processSector(sx, sy); - } - } - performance.mark("navsat:build:gateways:end"); - const gatewaysMeasure = performance.measure( - "navsat:build:gateways", - "navsat:build:gateways:start", - "navsat:build:gateways:end", - ); - - if (debug) { - console.log( - `[DEBUG] Gateway identification: ${gatewaysMeasure.duration.toFixed(2)}ms`, - ); - - this.debugInfo!.edges = 0; - this.debugInfo!.potentialBFSCalls = 0; - this.debugInfo!.skippedByComponentFilter = 0; - } - - performance.mark("navsat:build:edges:start"); - for (const sector of this.sectors.values()) { - const gws = sector.gateways; - const numGateways = gws.length; - - if (debug) { - this.debugInfo!.potentialBFSCalls! += - (numGateways * (numGateways - 1)) / 2; - - for (let i = 0; i < gws.length; i++) { - for (let j = i + 1; j < gws.length; j++) { - if (gws[i].componentId !== gws[j].componentId) { - this.debugInfo!.skippedByComponentFilter!++; - } - } - } - } - - this.buildSectorConnections(sector); - - if (debug) { - // Divide by 2 because bidirectional - this.debugInfo!.edges! += sector.edges.length / 2; - } - } - - if (debug) { - this.debugInfo!.actualBFSCalls = - this.debugInfo!.potentialBFSCalls! - - this.debugInfo!.skippedByComponentFilter!; - } - - performance.mark("navsat:build:edges:end"); - const edgesMeasure = performance.measure( - "navsat:build:edges", - "navsat:build:edges:start", - "navsat:build:edges:end", - ); - - if (debug) { - console.log( - `[DEBUG] Edges 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}`, - ); - console.log( - `[DEBUG] Edges Found: ${this.debugInfo!.edges} (${((this.debugInfo!.edges! / this.debugInfo!.actualBFSCalls!) * 100).toFixed(1)}% success rate)`, - ); - } - - performance.mark("navsat:build:end"); - const totalMeasure = performance.measure( - "navsat:build:total", - "navsat:build:start", - "navsat:build:end", - ); - - if (debug) { - console.log( - `[DEBUG] Gateway graph built in ${totalMeasure.duration.toFixed(2)}ms`, - ); - console.log(`[DEBUG] Gateways: ${this.gateways.size}`); - console.log(`[DEBUG] Sectors: ${this.sectors.size}`); - } - - return new GatewayGraph( - this.sectors, - this.gateways, - this.edges, - this.sectorSize, - this.sectorsX, - ); - } - - private getSectorKey(sectorX: number, sectorY: number): number { - return sectorY * this.sectorsX + sectorX; - } - - private getOrCreateGateway(x: number, y: number): Gateway { - const tile = this.miniMap.ref(x, y); - - // O(1) lookup using tile reference - const existing = this.tileToGateway.get(tile); - if (existing) { - return existing; - } - - const gateway: Gateway = { - id: this.nextGatewayId++, - x: x, - y: y, - tile: tile, - componentId: this.waterComponents.getComponentId(tile), - }; - - this.gateways.set(gateway.id, gateway); - this.tileToGateway.set(tile, gateway); - return gateway; - } - - private addGatewayToSector(sector: Sector, gateway: Gateway): void { - // Check for duplicates: a gateway at a sector corner can be - // detected by both horizontal and vertical edge scans - for (const existingGw of sector.gateways) { - if (existingGw.x === gateway.x && existingGw.y === gateway.y) { - return; - } - } - - // Gateway doesn't exist in sector yet, add it - sector.gateways.push(gateway); - } - - private processSector(sx: number, sy: number): void { - const sectorKey = this.getSectorKey(sx, sy); - let sector = this.sectors.get(sectorKey); - - if (!sector) { - sector = { x: sx, y: sy, gateways: [], edges: [] }; - this.sectors.set(sectorKey, sector); - } - - const baseX = sx * this.sectorSize; - const baseY = sy * this.sectorSize; - - if (sx < this.sectorsX - 1) { - const edgeX = Math.min(baseX + this.sectorSize - 1, this.width - 1); - const newGateways = this.findGatewaysOnVerticalEdge(edgeX, baseY); - - for (const gateway of newGateways) { - this.addGatewayToSector(sector, gateway); - - const rightSectorKey = this.getSectorKey(sx + 1, sy); - let rightSector = this.sectors.get(rightSectorKey); - - if (!rightSector) { - rightSector = { x: sx + 1, y: sy, gateways: [], edges: [] }; - this.sectors.set(rightSectorKey, rightSector); - } - - this.addGatewayToSector(rightSector, gateway); - } - } - - if (sy < this.sectorsY - 1) { - const edgeY = Math.min(baseY + this.sectorSize - 1, this.height - 1); - const newGateways = this.findGatewaysOnHorizontalEdge(edgeY, baseX); - - for (const gateway of newGateways) { - this.addGatewayToSector(sector, gateway); - - const bottomSectorKey = this.getSectorKey(sx, sy + 1); - let bottomSector = this.sectors.get(bottomSectorKey); - - if (!bottomSector) { - bottomSector = { x: sx, y: sy + 1, gateways: [], edges: [] }; - this.sectors.set(bottomSectorKey, bottomSector); - } - - this.addGatewayToSector(bottomSector, gateway); - } - } - } - - private findGatewaysOnVerticalEdge(x: number, baseY: number): Gateway[] { - const gateways: Gateway[] = []; - const maxY = Math.min(baseY + this.sectorSize, this.height); - - let gatewayStart = -1; - - const tryAddGateway = (y: number) => { - if (gatewayStart === -1) return; - - const gatewayLength = y - gatewayStart; - const midY = gatewayStart + Math.floor(gatewayLength / 2); - - gatewayStart = -1; - - const gateway = this.getOrCreateGateway(x, midY); - gateways.push(gateway); - }; - - for (let y = baseY; y < maxY; y++) { - const tile = this.miniMap.ref(x, y); - const nextTile = - x + 1 < this.miniMap.width() ? this.miniMap.ref(x + 1, y) : -1; - const isGateway = - this.miniMap.isWater(tile) && - nextTile !== -1 && - this.miniMap.isWater(nextTile); - - if (isGateway) { - if (gatewayStart === -1) { - gatewayStart = y; - } - } else { - tryAddGateway(y); - } - } - - tryAddGateway(maxY); - - return gateways; - } - - private findGatewaysOnHorizontalEdge(y: number, baseX: number): Gateway[] { - const gateways: Gateway[] = []; - const maxX = Math.min(baseX + this.sectorSize, this.width); - - let gatewayStart = -1; - - const tryAddGateway = (x: number) => { - if (gatewayStart === -1) return; - - const gatewayLength = x - gatewayStart; - const midX = gatewayStart + Math.floor(gatewayLength / 2); - - gatewayStart = -1; - - const gateway = this.getOrCreateGateway(midX, y); - gateways.push(gateway); - }; - - for (let x = baseX; x < maxX; x++) { - const tile = this.miniMap.ref(x, y); - const nextTile = - y + 1 < this.miniMap.height() ? this.miniMap.ref(x, y + 1) : -1; - const isGateway = - this.miniMap.isWater(tile) && - nextTile !== -1 && - this.miniMap.isWater(nextTile); - - if (isGateway) { - if (gatewayStart === -1) { - gatewayStart = x; - } - } else { - tryAddGateway(x); - } - } - - tryAddGateway(maxX); - - return gateways; - } - - private buildSectorConnections(sector: Sector): void { - const gateways = sector.gateways; - - // Calculate bounding box once for this sector - const sectorMinX = sector.x * this.sectorSize; - const sectorMinY = sector.y * this.sectorSize; - const sectorMaxX = Math.min( - this.width - 1, - sectorMinX + this.sectorSize - 1, - ); - const sectorMaxY = Math.min( - this.height - 1, - sectorMinY + this.sectorSize - 1, - ); - - for (let i = 0; i < gateways.length; i++) { - const fromGateway = gateways[i]; - - // Build list of target gateways (only those we haven't processed yet) - const targetGateways: Gateway[] = []; - for (let j = i + 1; j < gateways.length; j++) { - // Skip if gateways are in different water components - if (gateways[i].componentId !== gateways[j].componentId) { - continue; - } - - targetGateways.push(gateways[j]); - } - - if (targetGateways.length === 0) { - continue; - } - - // Single BFS to find all reachable target gateways - const reachableGateways = this.findAllReachableGatewaysInBounds( - fromGateway.tile, - targetGateways, - sectorMinX, - sectorMaxX, - sectorMinY, - sectorMaxY, - ); - - // Create edges for all reachable gateways - for (const [targetId, cost] of reachableGateways.entries()) { - if (!this.edges.has(fromGateway.id)) { - this.edges.set(fromGateway.id, []); - } - - if (!this.edges.has(targetId)) { - this.edges.set(targetId, []); - } - - // Check for existing edges - gateways may live in 2 sectors, keep only cheaper connection - const existingEdgeFromI = this.edges - .get(fromGateway.id)! - .find((e) => e.to === targetId); - const existingEdgeFromJ = this.edges - .get(targetId)! - .find((e) => e.to === fromGateway.id); - - // If edge doesn't exist or new cost is cheaper, update it - if (!existingEdgeFromI || cost < existingEdgeFromI.cost) { - const edge1: Edge = { - from: fromGateway.id, - to: targetId, - cost: cost, - sectorX: sector.x, - sectorY: sector.y, - }; - - const edge2: Edge = { - from: targetId, - to: fromGateway.id, - cost: cost, - sectorX: sector.x, - sectorY: sector.y, - }; - - // Add to sector edges for tracking - sector.edges.push(edge1, edge2); - - if (existingEdgeFromI) { - const idx1 = this.edges - .get(fromGateway.id)! - .indexOf(existingEdgeFromI); - this.edges.get(fromGateway.id)![idx1] = edge1; - - const idx2 = this.edges.get(targetId)!.indexOf(existingEdgeFromJ!); - this.edges.get(targetId)![idx2] = edge2; - } else { - this.edges.get(fromGateway.id)!.push(edge1); - this.edges.get(targetId)!.push(edge2); - } - } - } - } - } - - private findAllReachableGatewaysInBounds( - from: TileRef, - targetGateways: Gateway[], - minX: number, - maxX: number, - minY: number, - maxY: number, - ): Map { - const fromX = this.miniMap.x(from); - const fromY = this.miniMap.y(from); - - // Create a map of tile positions to gateway IDs for fast lookup - const tileToGateway = new Map(); - let maxManhattanDist = 0; - - for (const gateway of targetGateways) { - tileToGateway.set(gateway.tile, gateway.id); - const dx = Math.abs(gateway.x - fromX); - const dy = Math.abs(gateway.y - fromY); - maxManhattanDist = Math.max(maxManhattanDist, dx + dy); - } - - const maxDistance = maxManhattanDist * 4; // Allow path deviation - const reachable = new Map(); - let foundCount = 0; - - this.fastBFS.search( - this.miniMap.width(), - this.miniMap.height(), - from, - maxDistance, - (tile: number) => this.miniMap.isWater(tile), - (tile: number, dist: number) => { - const x = this.miniMap.x(tile); - const y = this.miniMap.y(tile); - - // Reject if outside of bounding box - const isStartOrEnd = tile === from || tileToGateway.has(tile); - if (!isStartOrEnd && (x < minX || x > maxX || y < minY || y > maxY)) { - return null; - } - - // Check if this tile is one of our target gateways - const gatewayId = tileToGateway.get(tile); - - if (gatewayId !== undefined) { - reachable.set(gatewayId, dist); - foundCount++; - - // Early exit if we've found all target gateways - if (foundCount === targetGateways.length) { - return dist; // Return to stop BFS - } - } - }, - ); - - return reachable; - } -} diff --git a/src/core/pathfinding/navmesh/NavMesh.ts b/src/core/pathfinding/navmesh/NavMesh.ts deleted file mode 100644 index 574dbfccc..000000000 --- a/src/core/pathfinding/navmesh/NavMesh.ts +++ /dev/null @@ -1,819 +0,0 @@ -import { Game } from "../../game/Game"; -import { TileRef } from "../../game/GameMap"; -import { FastAStar } from "./FastAStar"; -import { BoundedGameMapAdapter, GatewayGraphAdapter } from "./FastAStarAdapter"; -import { FastBFS } from "./FastBFS"; -import { Gateway, GatewayGraph, GatewayGraphBuilder } from "./GatewayGraph"; - -type PathDebugInfo = { - gatewayPath: TileRef[] | null; - initialPath: TileRef[] | null; - smoothPath: TileRef[] | null; - graph: { - sectorSize: number; - gateways: Array<{ id: number; tile: TileRef }>; - edges: Array<{ - fromId: number; - toId: number; - from: TileRef; - to: TileRef; - cost: number; - path: TileRef[] | null; - }>; - }; - timings: { [key: string]: number }; -}; - -export class NavMesh { - private graph!: GatewayGraph; - private initialized = false; - private fastBFS!: FastBFS; - private gatewayAStar!: FastAStar; - private localAStar!: FastAStar; - private localAStarMultiSector!: FastAStar; - - public debugInfo: PathDebugInfo | null = null; - - constructor( - private game: Game, - private options: { - cachePaths?: boolean; - } = {}, - ) {} - - initialize(debug: boolean = false) { - const gatewayGraphBuilder = new GatewayGraphBuilder( - this.game, - GatewayGraphBuilder.SECTOR_SIZE, - ); - this.graph = gatewayGraphBuilder.build(debug); - - const miniMap = this.game.miniMap(); - this.fastBFS = new FastBFS(miniMap.width() * miniMap.height()); - - const gatewayCount = this.graph.getAllGateways().length; - this.gatewayAStar = new FastAStar(gatewayCount); - - // Fixed-size FastAStar for sector-bounded local pathfinding - // Single sector: 32×32 = 1,024 nodes - const sectorSize = GatewayGraphBuilder.SECTOR_SIZE; - const maxLocalNodes = sectorSize * sectorSize; // 1,024 nodes - this.localAStar = new FastAStar(maxLocalNodes); - - // Multi-sector FastAStar for cross-sector pathfinding (same gateway, different sectors) - // 3×3 sectors: 96×96 = 9,216 nodes - const multiSectorSize = sectorSize * 3; - const maxMultiSectorNodes = multiSectorSize * multiSectorSize; - this.localAStarMultiSector = new FastAStar(maxMultiSectorNodes); - - this.initialized = true; - } - - findPath( - from: TileRef, - to: TileRef, - debug: boolean = false, - ): TileRef[] | null { - if (!this.initialized) { - throw new Error( - "NavMesh not initialized. Call initialize() before using findPath().", - ); - } - - if (debug) { - // Collect all edges with their paths for visualization - const allEdges: Array<{ - fromId: number; - toId: number; - from: TileRef; - to: TileRef; - cost: number; - path: TileRef[] | null; - }> = []; - - for (const [fromId, edges] of this.graph.edges.entries()) { - const fromGw = this.graph.getGateway(fromId); - if (!fromGw) continue; - - for (const edge of edges) { - const toGw = this.graph.getGateway(edge.to); - if (!toGw) continue; - - // Only add each edge once (not both directions) - // Include self-loops (fromId === edge.to) for debugging - if (fromId <= edge.to) { - allEdges.push({ - fromId: fromId, - toId: edge.to, - from: fromGw.tile, - to: toGw.tile, - cost: edge.cost, - path: edge.path ?? null, - }); - } - } - } - - this.debugInfo = { - gatewayPath: null, - initialPath: null, - smoothPath: null, - graph: { - sectorSize: this.graph.sectorSize, - gateways: this.graph - .getAllGateways() - .map((gw) => ({ id: gw.id, tile: gw.tile })), - edges: allEdges, - }, - timings: { - total: 0, - }, - }; - } - - const dist = this.game.manhattanDist(from, to); - - // Early exit for very short distances that fit within multi-sector range - if (dist <= this.graph.sectorSize) { - performance.mark("navsat:findPath:earlyExitLocalPath:start"); - const map = this.game.map(); - const startMiniX = Math.floor(map.x(from) / 2); - const startMiniY = Math.floor(map.y(from) / 2); - const sectorX = Math.floor(startMiniX / this.graph.sectorSize); - const sectorY = Math.floor(startMiniY / this.graph.sectorSize); - const localPath = this.findLocalPath( - from, - to, - sectorX, - sectorY, - 2000, - true, - ); - performance.mark("navsat:findPath:earlyExitLocalPath:end"); - const measure = performance.measure( - "navsat:findPath:earlyExitLocalPath", - "navsat:findPath:earlyExitLocalPath:start", - "navsat:findPath:earlyExitLocalPath:end", - ); - - if (debug) { - this.debugInfo!.timings.earlyExitLocalPath = measure.duration; - this.debugInfo!.timings.total += measure.duration; - } - - 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 gateway graph`, - ); - } - } - - performance.mark("navsat:findPath:findGateways:start"); - const startGateway = this.findNearestGateway(from); - const endGateway = this.findNearestGateway(to); - performance.mark("navsat:findPath:findGateways:end"); - const findGatewaysMeasure = performance.measure( - "navsat:findPath:findGateways", - "navsat:findPath:findGateways:start", - "navsat:findPath:findGateways:end", - ); - - if (debug) { - this.debugInfo!.timings.findGateways = findGatewaysMeasure.duration; - this.debugInfo!.timings.total += findGatewaysMeasure.duration; - } - - if (!startGateway) { - if (debug) { - console.log( - `[DEBUG] Cannot find start gateway for (${this.game.x(from)}, ${this.game.y(from)})`, - ); - } - - return null; - } - - if (!endGateway) { - if (debug) { - console.log( - `[DEBUG] Cannot find end gateway for (${this.game.x(to)}, ${this.game.y(to)})`, - ); - } - - return null; - } - - if (startGateway.id === endGateway.id) { - if (debug) { - console.log( - `[DEBUG] Start and end gateways are the same (ID=${startGateway.id}), finding local path with multi-sector search`, - ); - } - - performance.mark("navsat:findPath:sameGatewayLocalPath:start"); - const sectorX = Math.floor(startGateway.x / this.graph.sectorSize); - const sectorY = Math.floor(startGateway.y / this.graph.sectorSize); - const path = this.findLocalPath(from, to, sectorX, sectorY, 10000, true); - performance.mark("navsat:findPath:sameGatewayLocalPath:end"); - const sameGatewayMeasure = performance.measure( - "navsat:findPath:sameGatewayLocalPath", - "navsat:findPath:sameGatewayLocalPath:start", - "navsat:findPath:sameGatewayLocalPath:end", - ); - - if (debug) { - this.debugInfo!.timings.sameGatewayLocalPath = - sameGatewayMeasure.duration; - this.debugInfo!.timings.total += sameGatewayMeasure.duration; - } - - return path; - } - - performance.mark("navsat:findPath:findGatewayPath:start"); - const gatewayPath = this.findGatewayPath(startGateway.id, endGateway.id); - performance.mark("navsat:findPath:findGatewayPath:end"); - const findGatewayPathMeasure = performance.measure( - "navsat:findPath:findGatewayPath", - "navsat:findPath:findGatewayPath:start", - "navsat:findPath:findGatewayPath:end", - ); - - if (debug) { - this.debugInfo!.timings.findGatewayPath = findGatewayPathMeasure.duration; - this.debugInfo!.timings.total += findGatewayPathMeasure.duration; - - this.debugInfo!.gatewayPath = gatewayPath - ? gatewayPath - .map((gwId) => { - const gw = this.graph.getGateway(gwId); - return gw ? gw.tile : -1; - }) - .filter((tile) => tile !== -1) - : null; - } - - if (!gatewayPath) { - if (debug) { - console.log( - `[DEBUG] No gateway path between gateways ${startGateway.id} and ${endGateway.id}`, - ); - } - - return null; - } - - if (debug) { - console.log( - `[DEBUG] Gateway path found: ${gatewayPath.length} waypoints`, - ); - } - - const initialPath: TileRef[] = []; - const map = this.game.map(); - const miniMap = this.game.miniMap(); - - performance.mark("navsat:findPath:buildInitialPath:start"); - - // 1. Find path from start to first gateway - const firstGateway = this.graph.getGateway(gatewayPath[0])!; - const firstGatewayTile = map.ref( - miniMap.x(firstGateway.tile) * 2, - miniMap.y(firstGateway.tile) * 2, - ); - - // Use start position's sector with multi-sector search (gateway may be on border) - const startMiniX = Math.floor(map.x(from) / 2); - const startMiniY = Math.floor(map.y(from) / 2); - const startSectorX = Math.floor(startMiniX / this.graph.sectorSize); - const startSectorY = Math.floor(startMiniY / this.graph.sectorSize); - const startSegment = this.findLocalPath( - from, - firstGatewayTile, - startSectorX, - startSectorY, - ); - - if (!startSegment) { - return null; - } - - initialPath.push(...startSegment); - - // 2. Build path through gateways - for (let i = 0; i < gatewayPath.length - 1; i++) { - const fromGwId = gatewayPath[i]; - const toGwId = gatewayPath[i + 1]; - - const edges = this.graph.getEdges(fromGwId); - const edge = edges.find((edge) => edge.to === toGwId); - - if (!edge) { - return null; - } - - if (edge.path) { - // Use cached path if available - initialPath.push(...edge.path.slice(1)); - continue; - } - - const fromGw = this.graph.getGateway(fromGwId)!; - const toGw = this.graph.getGateway(toGwId)!; - const fromTile = map.ref( - miniMap.x(fromGw.tile) * 2, - miniMap.y(fromGw.tile) * 2, - ); - const toTile = map.ref( - miniMap.x(toGw.tile) * 2, - miniMap.y(toGw.tile) * 2, - ); - - const segmentPath = this.findLocalPath( - fromTile, - toTile, - edge.sectorX, - edge.sectorY, - ); - - if (!segmentPath) { - return null; - } - - // Skip first tile to avoid duplication - initialPath.push(...segmentPath.slice(1)); - - if (this.options.cachePaths) { - // Cache the path for future reuse on both directional edges - edge.path = segmentPath; - - // Also cache the reversed path on the opposite direction edge - const reverseEdges = this.graph.getEdges(toGwId); - const reverseEdge = reverseEdges.find((e) => e.to === fromGwId); - if (reverseEdge) { - reverseEdge.path = segmentPath.slice().reverse(); - } - } - } - - // 3. Find path from last gateway to end - const lastGateway = this.graph.getGateway( - gatewayPath[gatewayPath.length - 1], - )!; - const lastGatewayTile = map.ref( - miniMap.x(lastGateway.tile) * 2, - miniMap.y(lastGateway.tile) * 2, - ); - - // Use end position's sector with multi-sector search (gateway may be on border) - const endMiniX = Math.floor(map.x(to) / 2); - const endMiniY = Math.floor(map.y(to) / 2); - const endSectorX = Math.floor(endMiniX / this.graph.sectorSize); - const endSectorY = Math.floor(endMiniY / this.graph.sectorSize); - const endSegment = this.findLocalPath( - lastGatewayTile, - to, - endSectorX, - endSectorY, - ); - - if (!endSegment) { - return null; - } - - // Skip first tile to avoid duplication - initialPath.push(...endSegment.slice(1)); - - performance.mark("navsat:findPath:buildInitialPath:end"); - const buildInitialPathMeasure = performance.measure( - "navsat:findPath:buildInitialPath", - "navsat:findPath:buildInitialPath:start", - "navsat: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`); - } - - performance.mark("navsat:findPath:smoothPath:start"); - const smoothedPath = this.smoothPath(initialPath); - performance.mark("navsat:findPath:smoothPath:end"); - const smoothPathMeasure = performance.measure( - "navsat:findPath:smoothPath", - "navsat:findPath:smoothPath:start", - "navsat:findPath:smoothPath:end", - ); - - if (debug) { - this.debugInfo!.timings.buildSmoothPath = smoothPathMeasure.duration; - this.debugInfo!.timings.total += smoothPathMeasure.duration; - this.debugInfo!.smoothPath = smoothedPath; - console.log( - `[DEBUG] Smoothed path: ${initialPath.length} → ${smoothedPath.length} tiles`, - ); - } - - return smoothedPath; - } - - private findNearestGateway(tile: TileRef): Gateway | null { - const map = this.game.map(); - const x = map.x(tile); - const y = map.y(tile); - - // Convert to miniMap coordinates - const miniMap = this.game.miniMap(); - const miniX = Math.floor(x / 2); - const miniY = Math.floor(y / 2); - const miniFrom = miniMap.ref(miniX, miniY); - - // Check gateways in the tile's own sector (using miniMap coordinates) - const sectorX = Math.floor(miniX / this.graph.sectorSize); - const sectorY = Math.floor(miniY / this.graph.sectorSize); - - // Calculate single sector bounds - const sectorSize = this.graph.sectorSize; - const minX = sectorX * sectorSize; - const minY = sectorY * sectorSize; - const maxX = Math.min(miniMap.width() - 1, minX + sectorSize - 1); - const maxY = Math.min(miniMap.height() - 1, minY + sectorSize - 1); - - // Get gateways from the tile's own sector only (includes border gateways) - const sector = this.graph.getSector(sectorX, sectorY); - - if (!sector) { - return null; - } - - const candidateGateways = sector.gateways; - if (candidateGateways.length === 0) { - return null; - } - - // Use BFS to find the nearest reachable gateway (by water path distance) - // Search space is bounded by sector bounds, so maxDistance can be large - const maxDistance = sectorSize * sectorSize; - - return this.fastBFS.search( - miniMap.width(), - miniMap.height(), - miniFrom, - maxDistance, - (tile: TileRef) => miniMap.isWater(tile), - (tile: TileRef, _dist: number) => { - const tileX = miniMap.x(tile); - const tileY = miniMap.y(tile); - - // Check if any candidate gateway is at this position first - for (const gateway of candidateGateways) { - if (gateway.x === tileX && gateway.y === tileY) { - return gateway; - } - } - - // Reject non-gateway tiles outside the sector bounds - if (tileX < minX || tileX > maxX || tileY < minY || tileY > maxY) { - return null; - } - }, - ); - } - - private findGatewayPath( - fromGatewayId: number, - toGatewayId: number, - ): number[] | null { - const adapter = new GatewayGraphAdapter(this.graph); - return this.gatewayAStar.search( - fromGatewayId, - toGatewayId, - adapter, - 100000, - ); - } - - private findLocalPath( - from: TileRef, - to: TileRef, - sectorX: number, - sectorY: number, - maxIterations: number = 10000, - multiSector: boolean = false, - ): TileRef[] | null { - const map = this.game.map(); - const miniMap = this.game.miniMap(); - - // Convert full map coordinates to miniMap coordinates - const miniFrom = miniMap.ref( - Math.floor(map.x(from) / 2), - Math.floor(map.y(from) / 2), - ); - - const miniTo = miniMap.ref( - Math.floor(map.x(to) / 2), - Math.floor(map.y(to) / 2), - ); - - // Calculate sector bounds - const sectorSize = this.graph.sectorSize; - - let minX: number; - let minY: number; - let maxX: number; - let maxY: number; - - if (multiSector) { - // 3×3 sectors centered on the starting sector - minX = Math.max(0, (sectorX - 1) * sectorSize); - minY = Math.max(0, (sectorY - 1) * sectorSize); - maxX = Math.min(miniMap.width() - 1, (sectorX + 2) * sectorSize - 1); - maxY = Math.min(miniMap.height() - 1, (sectorY + 2) * sectorSize - 1); - } else { - // Single sector - minX = sectorX * sectorSize; - minY = sectorY * sectorSize; - maxX = Math.min(miniMap.width() - 1, minX + sectorSize - 1); - maxY = Math.min(miniMap.height() - 1, minY + sectorSize - 1); - } - - const adapter = new BoundedGameMapAdapter(miniMap, miniFrom, miniTo, { - minX, - maxX, - minY, - maxY, - }); - - // Convert to local node IDs - const startNode = adapter.tileToNode(miniFrom); - const goalNode = adapter.tileToNode(miniTo); - - if (startNode === -1 || goalNode === -1) { - return null; // Start or goal outside bounds - } - - // Choose the appropriate FastAStar buffer based on search area - const selectedAStar = multiSector - ? this.localAStarMultiSector - : this.localAStar; - - // Run FastAStar on bounded region - const path = selectedAStar.search( - startNode, - goalNode, - adapter, - maxIterations, - ); - - if (!path) { - return null; - } - - // Convert path from local node IDs back to miniMap TileRefs - const miniPath = path.map((node: number) => adapter.nodeToTile(node)); - - // Upscale from miniMap to full map (same logic as MiniAStar) - const result = this.upscalePathToFullMap(miniPath, from, to); - - return result; - } - - private upscalePathToFullMap( - miniPath: TileRef[], - from: TileRef, - to: TileRef, - ): TileRef[] { - const map = this.game.map(); - const miniMap = this.game.miniMap(); - - // Convert miniMap path to cells - const miniCells = miniPath.map((tile) => ({ - x: miniMap.x(tile), - y: miniMap.y(tile), - })); - - // FIRST: Scale all points (2x) - const scaledPath = miniCells.map((point) => ({ - x: point.x * 2, - y: point.y * 2, - })); - - // SECOND: Interpolate between scaled points - const smoothPath: Array<{ x: number; y: number }> = []; - for (let i = 0; i < scaledPath.length - 1; i++) { - const current = scaledPath[i]; - const next = scaledPath[i + 1]; - - // Add the current point - smoothPath.push(current); - - // Calculate dx/dy from SCALED coordinates - const dx = next.x - current.x; - const dy = next.y - current.y; - const distance = Math.max(Math.abs(dx), Math.abs(dy)); - const steps = distance; - - // Add intermediate points - for (let step = 1; step < steps; step++) { - smoothPath.push({ - x: Math.round(current.x + (dx * step) / steps), - y: Math.round(current.y + (dy * step) / steps), - }); - } - } - - // Add last point - if (scaledPath.length > 0) { - smoothPath.push(scaledPath[scaledPath.length - 1]); - } - - const scaledCells = smoothPath; - - // Fix extremes to ensure exact start/end - const fromCell = { x: map.x(from), y: map.y(from) }; - const toCell = { x: map.x(to), y: map.y(to) }; - - // Ensure start is correct - const startIdx = scaledCells.findIndex( - (c) => c.x === fromCell.x && c.y === fromCell.y, - ); - if (startIdx === -1) { - scaledCells.unshift(fromCell); - } else if (startIdx !== 0) { - scaledCells.splice(0, startIdx); - } - - // Ensure end is correct - const endIdx = scaledCells.findIndex( - (c) => c.x === toCell.x && c.y === toCell.y, - ); - if (endIdx === -1) { - scaledCells.push(toCell); - } else if (endIdx !== scaledCells.length - 1) { - scaledCells.splice(endIdx + 1); - } - - // Convert back to TileRefs - return scaledCells.map((cell) => map.ref(cell.x, cell.y)); - } - - private tracePath(from: TileRef, to: TileRef): TileRef[] | null { - const x0 = this.game.x(from); - const y0 = this.game.y(from); - const x1 = this.game.x(to); - const y1 = this.game.y(to); - - const tiles: TileRef[] = []; - - // Bresenham's line algorithm - trace and collect all tiles - 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; - - // Safety limit to prevent excessive memory allocation - const maxTiles = 100000; - let iterations = 0; - - while (true) { - if (iterations++ > maxTiles) { - return null; // Path too long - } - const tile = this.game.ref(x, y); - if (!this.game.isWater(tile)) { - return null; // Path blocked - } - - tiles.push(tile); - - if (x === x1 && y === y1) { - break; - } - - const e2 = 2 * err; - const shouldMoveX = e2 > -dy; - const shouldMoveY = e2 < dx; - - if (shouldMoveX && shouldMoveY) { - // Diagonal move - need to expand into two 4-directional moves - // Try moving X first, then Y - x += sx; - err -= dy; - - const intermediateTile = this.game.ref(x, y); - if (!this.game.isWater(intermediateTile)) { - // X first doesn't work, try Y first instead - x -= sx; // undo - err += dy; // undo - - y += sy; - err += dx; - - const altTile = this.game.ref(x, y); - if (!this.game.isWater(altTile)) { - return null; // Neither direction works - } - tiles.push(altTile); - - // Now move X - x += sx; - err -= dy; - } else { - tiles.push(intermediateTile); - - // Now move Y - y += sy; - err += dx; - } - } else { - // Single-axis move - if (shouldMoveX) { - x += sx; - err -= dy; - } - - if (shouldMoveY) { - y += sy; - err += dx; - } - } - } - - return tiles; - } - - private smoothPath(path: TileRef[]): TileRef[] { - if (path.length <= 2) { - return path; - } - - const smoothed: TileRef[] = []; - let current = 0; - - while (current < path.length - 1) { - // Look as far ahead as possible while maintaining line of sight - 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; - } - } - - // Also try the final tile if we haven't already - 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; - } - } - - // Add the traced path (or just current tile if no improvement) - if (bestTrace !== null && farthest > current + 1) { - // Add all tiles from the trace except the last one (to avoid duplication) - smoothed.push(...bestTrace.slice(0, -1)); - } else { - // No LOS improvement, just add current tile - smoothed.push(path[current]); - } - - current = farthest; - } - - // Add the final tile - smoothed.push(path[path.length - 1]); - - return smoothed; - } -} diff --git a/src/core/pathfinding/smoothing/BresenhamPathSmoother.ts b/src/core/pathfinding/smoothing/BresenhamPathSmoother.ts new file mode 100644 index 000000000..d4cafdba9 --- /dev/null +++ b/src/core/pathfinding/smoothing/BresenhamPathSmoother.ts @@ -0,0 +1,168 @@ +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 new file mode 100644 index 000000000..fc4188afc --- /dev/null +++ b/src/core/pathfinding/smoothing/PathSmoother.ts @@ -0,0 +1,7 @@ +/** + * 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 new file mode 100644 index 000000000..ed1c60bff --- /dev/null +++ b/src/core/pathfinding/smoothing/SmoothingTransformer.ts @@ -0,0 +1,18 @@ +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/spatial/SpatialQuery.ts b/src/core/pathfinding/spatial/SpatialQuery.ts new file mode 100644 index 000000000..1336a636f --- /dev/null +++ b/src/core/pathfinding/spatial/SpatialQuery.ts @@ -0,0 +1,90 @@ +import { Game, Player, TerraNullius } from "../../game/Game"; +import { TileRef } from "../../game/GameMap"; +import { PathFinding } from "../PathFinder"; + +type Owner = Player | TerraNullius; + +export class SpatialQuery { + constructor(private game: Game) {} + + /** + * Find nearest tile matching predicate using BFS traversal. + * Uses Manhattan distance filter, ignores terrain barriers. + */ + private bfsNearest( + from: TileRef, + maxDist: number, + predicate: (t: TileRef) => boolean, + ): TileRef | null { + const map = this.game.map(); + const candidates: TileRef[] = []; + + for (const tile of map.bfs( + from, + (_, t) => map.manhattanDist(from, t) <= maxDist, + )) { + if (predicate(tile)) { + candidates.push(tile); + } + } + + if (candidates.length === 0) return null; + + // Sort by Manhattan distance to find actual nearest + candidates.sort( + (a, b) => map.manhattanDist(from, a) - map.manhattanDist(from, b), + ); + + return candidates[0]; + } + + /** + * Find closest shore tile by land BFS. + * Works for both players and terra nullius. + */ + closestShore( + owner: Owner, + tile: TileRef, + maxDist: number = 50, + ): TileRef | null { + const gm = this.game; + const ownerId = owner.smallID(); + + const isValidTile = (t: TileRef) => { + if (!gm.isShore(t) || !gm.isLand(t)) return false; + const tOwner = gm.ownerID(t); + return tOwner === ownerId; + }; + + return this.bfsNearest(tile, maxDist, isValidTile); + } + + /** + * Find closest shore tile by water pathfinding. + * Returns null for terra nullius (no borderTiles). + */ + closestShoreByWater(owner: Owner, target: TileRef): TileRef | null { + if (!owner.isPlayer()) return null; + + const gm = this.game; + const player = owner as Player; + + // Target must be water or shore (land adjacent to water) + if (!gm.isWater(target) && !gm.isShore(target)) return null; + + const targetComponent = gm.getWaterComponent(target); + if (targetComponent === null) return null; + + const isValidTile = (t: TileRef) => { + if (!gm.isShore(t) || !gm.isLand(t)) return false; + const tComponent = gm.getWaterComponent(t); + return tComponent === targetComponent; + }; + + const shores = Array.from(player.borderTiles()).filter(isValidTile); + if (shores.length === 0) return null; + + const path = PathFinding.Water(gm).findPath(shores, target); + return path?.[0] ?? null; + } +} diff --git a/src/core/pathfinding/transformers/ComponentCheckTransformer.ts b/src/core/pathfinding/transformers/ComponentCheckTransformer.ts new file mode 100644 index 000000000..2d1d4d685 --- /dev/null +++ b/src/core/pathfinding/transformers/ComponentCheckTransformer.ts @@ -0,0 +1,35 @@ +// Component check transformer - fail fast if src/dst in different components + +import { PathFinder } from "../types"; + +/** + * Wraps a PathFinder to fail fast when source and destination + * are in different components (e.g., disconnected water bodies). + * + * Avoids running expensive pathfinding when no path exists. + */ +export class ComponentCheckTransformer implements PathFinder { + constructor( + private inner: PathFinder, + private getComponent: (t: T) => number, + ) {} + + findPath(from: T | T[], to: T): T[] | null { + const toComponent = this.getComponent(to); + + // Check all sources - at least one must match destination component + const fromArray = Array.isArray(from) ? from : [from]; + const validSources = fromArray.filter( + (f) => this.getComponent(f) === toComponent, + ); + + if (validSources.length === 0) { + return null; // No source in same component as destination + } + + // Delegate with only valid sources + const delegateFrom = + validSources.length === 1 ? validSources[0] : validSources; + return this.inner.findPath(delegateFrom, to); + } +} diff --git a/src/core/pathfinding/transformers/MiniMapTransformer.ts b/src/core/pathfinding/transformers/MiniMapTransformer.ts new file mode 100644 index 000000000..885368716 --- /dev/null +++ b/src/core/pathfinding/transformers/MiniMapTransformer.ts @@ -0,0 +1,128 @@ +import { Cell } from "../../game/Game"; +import { GameMap, TileRef } from "../../game/GameMap"; +import { PathFinder } from "../types"; + +export class MiniMapTransformer implements PathFinder { + constructor( + private inner: PathFinder, + private map: GameMap, + private miniMap: GameMap, + ) {} + + findPath(from: TileRef | TileRef[], to: TileRef): TileRef[] | null { + // Convert game coords → minimap coords (supports multi-source) + const fromArray = Array.isArray(from) ? from : [from]; + const miniFromArray = fromArray.map((f) => + this.miniMap.ref( + Math.floor(this.map.x(f) / 2), + Math.floor(this.map.y(f) / 2), + ), + ); + const miniFrom = + miniFromArray.length === 1 ? miniFromArray[0] : miniFromArray; + + const miniTo = this.miniMap.ref( + Math.floor(this.map.x(to) / 2), + Math.floor(this.map.y(to) / 2), + ); + + // Search on minimap + const path = this.inner.findPath(miniFrom, miniTo); + if (!path || path.length === 0) { + return null; + } + + // Convert minimap TileRefs → Cells + const cellPath = path.map( + (ref) => new Cell(this.miniMap.x(ref), this.miniMap.y(ref)), + ); + + // For multi-source, find closest source to path start + const upscaledPath = this.upscalePath(cellPath); + let cellFrom: Cell | undefined; + if (Array.isArray(from)) { + if (upscaledPath.length > 0) { + const pathStart = upscaledPath[0]; + let minDist = Infinity; + for (const f of from) { + const fx = this.map.x(f); + const fy = this.map.y(f); + const dist = Math.abs(fx - pathStart.x) + Math.abs(fy - pathStart.y); + if (dist < minDist) { + minDist = dist; + cellFrom = new Cell(fx, fy); + } + } + } + } else { + cellFrom = new Cell(this.map.x(from), this.map.y(from)); + } + const cellTo = new Cell(this.map.x(to), this.map.y(to)); + const upscaled = this.fixExtremes(upscaledPath, cellTo, cellFrom); + + return upscaled.map((c) => this.map.ref(c.x, c.y)); + } + + private upscalePath(path: Cell[], scaleFactor: number = 2): Cell[] { + const scaledPath = path.map( + (point) => new Cell(point.x * scaleFactor, point.y * scaleFactor), + ); + + const smoothPath: Cell[] = []; + + for (let i = 0; i < scaledPath.length - 1; i++) { + const current = scaledPath[i]; + const next = scaledPath[i + 1]; + + smoothPath.push(current); + + const dx = next.x - current.x; + const dy = next.y - current.y; + const distance = Math.max(Math.abs(dx), Math.abs(dy)); + const steps = distance; + + for (let step = 1; step < steps; step++) { + smoothPath.push( + new Cell( + Math.round(current.x + (dx * step) / steps), + Math.round(current.y + (dy * step) / steps), + ), + ); + } + } + + if (scaledPath.length > 0) { + smoothPath.push(scaledPath[scaledPath.length - 1]); + } + + return smoothPath; + } + + private fixExtremes(upscaled: Cell[], cellDst: Cell, cellSrc?: Cell): Cell[] { + if (cellSrc !== undefined) { + const srcIndex = this.findCell(upscaled, cellSrc); + if (srcIndex === -1) { + upscaled.unshift(cellSrc); + } else if (srcIndex !== 0) { + upscaled = upscaled.slice(srcIndex); + } + } + + const dstIndex = this.findCell(upscaled, cellDst); + if (dstIndex === -1) { + upscaled.push(cellDst); + } else if (dstIndex !== upscaled.length - 1) { + upscaled = upscaled.slice(0, dstIndex + 1); + } + return upscaled; + } + + private findCell(cells: Cell[], target: Cell): number { + for (let i = 0; i < cells.length; i++) { + if (cells[i].x === target.x && cells[i].y === target.y) { + return i; + } + } + return -1; + } +} diff --git a/src/core/pathfinding/transformers/ShoreCoercingTransformer.ts b/src/core/pathfinding/transformers/ShoreCoercingTransformer.ts new file mode 100644 index 000000000..523387127 --- /dev/null +++ b/src/core/pathfinding/transformers/ShoreCoercingTransformer.ts @@ -0,0 +1,91 @@ +// Shore-coercing transformer that converts shore tiles to water tiles for pathfinding + +import { GameMap, TileRef } from "../../game/GameMap"; +import { PathFinder } from "../types"; + +/** + * Wraps a PathFinder to handle shore tiles. + * Coerces shore tiles to nearby water tiles before pathfinding, + * then fixes the path extremes to include the original shore tiles. + * + * Works at whatever resolution the map provides - can be used with + * full map or minimap-based pathfinders. + */ +export class ShoreCoercingTransformer implements PathFinder { + constructor( + private inner: PathFinder, + private map: GameMap, + ) {} + + findPath(from: TileRef | TileRef[], to: TileRef): TileRef[] | null { + const fromArray = Array.isArray(from) ? from : [from]; + const waterToOriginal = new Map(); + const waterFrom: TileRef[] = []; + + for (const f of fromArray) { + const coerced = this.coerceToWater(f); + if (coerced.water !== null) { + waterFrom.push(coerced.water); + waterToOriginal.set(coerced.water, coerced.original); + } + } + + if (waterFrom.length === 0) { + return null; + } + + // Coerce to tile + const coercedTo = this.coerceToWater(to); + if (coercedTo.water === null) { + return null; + } + + // Search on water tiles + const fromTiles = waterFrom.length === 1 ? waterFrom[0] : waterFrom; + const path = this.inner.findPath(fromTiles, coercedTo.water); + if (!path || path.length === 0) { + return null; + } + + // Look up the actual path start in the map + const originalShore = waterToOriginal.get(path[0]); + if (originalShore !== undefined && originalShore !== null) { + path.unshift(originalShore); + } + + // Append original to if different + if ( + coercedTo.original !== null && + path[path.length - 1] !== coercedTo.original + ) { + path.push(coercedTo.original); + } + + return path; + } + + /** + * Coerce a tile to water for pathfinding. + * If tile is already water, returns it unchanged. + * If tile is shore (land with water neighbor), finds the nearest water neighbor. + */ + private coerceToWater(tile: TileRef): { + water: TileRef | null; + original: TileRef | null; + } { + // If already water, no coercion needed + if (this.map.isWater(tile)) { + return { water: tile, original: null }; + } + + // Find adjacent water neighbor + for (const n of this.map.neighbors(tile)) { + if (this.map.isWater(n)) { + return { water: n, original: tile }; + } + } + + // No water neighbor found - let HPA* handle at minimap level + return { water: null, original: tile }; + } +} diff --git a/src/core/pathfinding/types.ts b/src/core/pathfinding/types.ts new file mode 100644 index 000000000..89e746957 --- /dev/null +++ b/src/core/pathfinding/types.ts @@ -0,0 +1,34 @@ +/** + * Core pathfinding types and interfaces. + * No dependencies - safe to import from anywhere. + */ + +export enum PathStatus { + NEXT, + PENDING, + COMPLETE, + NOT_FOUND, +} + +export type PathResult = + | { status: PathStatus.PENDING } + | { status: PathStatus.NEXT; node: T } + | { status: PathStatus.COMPLETE; node: T } + | { status: PathStatus.NOT_FOUND }; + +/** + * PathFinder - core pathfinding interface. + * Implementations find paths between nodes. + */ +export interface PathFinder { + findPath(from: T | T[], to: T): T[] | null; +} + +/** + * SteppingPathFinder - PathFinder with stepping support. + * Used by execution classes that need incremental path traversal. + */ +export interface SteppingPathFinder extends PathFinder { + next(from: T, to: T, dist?: number): PathResult; + invalidate(): void; +} diff --git a/tests/Disconnected.test.ts b/tests/Disconnected.test.ts index 693cbdf86..3dcc7011f 100644 --- a/tests/Disconnected.test.ts +++ b/tests/Disconnected.test.ts @@ -383,6 +383,7 @@ describe("Disconnected", () => { player1.conquer(game.map().ref(coastX, 4)); player2.conquer(game.map().ref(coastX, 1)); + // Use a far destination so boat is still in transit after attack completes const enemyShoreTile = game.map().ref(coastX, 15); game.addExecution( diff --git a/tests/core/executions/TradeShipExecution.test.ts b/tests/core/executions/TradeShipExecution.test.ts index 0492b7555..3d3d5bb03 100644 --- a/tests/core/executions/TradeShipExecution.test.ts +++ b/tests/core/executions/TradeShipExecution.test.ts @@ -1,6 +1,6 @@ import { TradeShipExecution } from "../../../src/core/execution/TradeShipExecution"; import { Game, Player, Unit } from "../../../src/core/game/Game"; -import { PathStatus } from "../../../src/core/pathfinding/PathFinder"; +import { PathStatus } from "../../../src/core/pathfinding/types"; import { setup } from "../../util/Setup"; describe("TradeShipExecution", () => { diff --git a/tests/core/pathfinding/PathFinder.test.ts b/tests/core/pathfinding/PathFinder.test.ts deleted file mode 100644 index 5ea96d4dd..000000000 --- a/tests/core/pathfinding/PathFinder.test.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { beforeAll, describe, expect, test, vi } from "vitest"; -import { Game } from "../../../src/core/game/Game"; -import { TileRef } from "../../../src/core/game/GameMap"; -import { MiniAStarAdapter } from "../../../src/core/pathfinding/adapters/MiniAStarAdapter"; -import { NavMeshAdapter } from "../../../src/core/pathfinding/adapters/NavMeshAdapter"; -import { - PathFinder, - PathStatus, -} from "../../../src/core/pathfinding/PathFinder"; -import { setup } from "../../util/Setup"; -import { gameFromString } from "./utils"; - -type AdapterFactory = { - name: string; - create: (game: Game) => PathFinder; -}; - -const adapters: AdapterFactory[] = [ - { - name: "MiniAStarAdapter", - create: (game) => new MiniAStarAdapter(game, { waterPath: true }), - }, - { - name: "NavMeshAdapter", - create: (game) => new NavMeshAdapter(game), - }, -]; - -// Shared world game instance -let worldGame: Game; - -beforeAll(async () => { - worldGame = await setup("world", { disableNavMesh: false }); -}); - -describe.each(adapters)("$name", ({ create }) => { - describe("findPath()", () => { - test("finds path between adjacent tiles", async () => { - const game = await gameFromString(["WWWW"]); - const adapter = create(game); - const src = game.ref(0, 0); - const dst = game.ref(1, 0); - - const path = adapter.findPath(src, dst); - - expect(path).not.toBeNull(); - expect(path![0]).toBe(src); - expect(path![path!.length - 1]).toBe(dst); - }); - - test("finds path across multiple tiles", async () => { - const game = await gameFromString(["WWWWWW", "WWWWWW", "WWWWWW"]); - const adapter = create(game); - const src = game.ref(0, 0); - const dst = game.ref(5, 2); - - const path = adapter.findPath(src, dst); - - expect(path).not.toBeNull(); - expect(path![0]).toBe(src); - expect(path![path!.length - 1]).toBe(dst); - }); - - test("returns single-element path for same tile", async () => { - // Old quirk of MiniAStar, we return dst tile twice - // Should probably be fixed to return [] instead - - const game = await gameFromString(["WW"]); - const adapter = create(game); - const tile = game.ref(0, 0); - - const path = adapter.findPath(tile, tile); - - expect(path).not.toBeNull(); - expect(path!.length).toBe(1); - expect(path![0]).toBe(tile); - }); - - test("returns null for blocked path", async () => { - const game = await gameFromString(["WWLLWW"]); - const adapter = create(game); - const src = game.ref(0, 0); - const dst = game.ref(5, 0); - - const path = adapter.findPath(src, dst); - - expect(path).toBeNull(); - }); - - test("returns null for water to land", () => { - const adapter = create(worldGame); - const src = worldGame.ref(926, 283); // water - const dst = worldGame.ref(950, 230); // land - - const path = adapter.findPath(src, dst); - - expect(path).toBeNull(); - }); - - test("traverses 3-tile path in 3 tiles", async () => { - // Expected: [1, 2, 3] - const game = await gameFromString(["WWWW"]); - const adapter = create(game); - const src = game.ref(0, 0); - const dst = game.ref(3, 0); - - const path = adapter.findPath(src, dst); - - expect(path).not.toBeNull(); - expect(path).toEqual([ - game.ref(0, 0), - game.ref(1, 0), - game.ref(2, 0), - game.ref(3, 0), - ]); - }); - }); - - describe("next() state machine", () => { - test("returns NEXT on first call", async () => { - const game = await gameFromString(["WWWW"]); - const adapter = create(game); - const src = game.ref(0, 0); - const dst = game.ref(3, 0); - - const result = adapter.next(src, dst); - - expect(result.status).toBe(PathStatus.NEXT); - }); - - test("returns COMPLETE when at destination", async () => { - const game = await gameFromString(["WW"]); - const adapter = create(game); - const tile = game.ref(0, 0); - - const result = adapter.next(tile, tile); - - expect(result.status).toBe(PathStatus.COMPLETE); - }); - - test("returns NOT_FOUND for blocked path", async () => { - const game = await gameFromString(["WWLLWW"]); - const adapter = create(game); - const src = game.ref(0, 0); - const dst = game.ref(5, 0); - - const result = adapter.next(src, dst); - - expect(result.status).toBe(PathStatus.NOT_FOUND); - }); - - test("traverses 3-tile path in 4 calls", async () => { - // Expected: NEXT(1) -> NEXT(2) -> NEXT(3) -> COMPLETE(4) - const game = await gameFromString(["WWWW"]); - const adapter = create(game); - const src = game.ref(0, 0); - const dst = game.ref(3, 0); - - let current = src; - const steps: string[] = []; - - // 3 NEXT calls to reach destination - for (let i = 1; i <= 4; i++) { - const result = adapter.next(current, dst); - expect([PathStatus.NEXT, PathStatus.COMPLETE]).toContain(result.status); - - current = (result as { node: TileRef }).node; - steps.push(`${PathStatus[result.status]}(${current})`); - } - - expect(steps).toEqual(["NEXT(1)", "NEXT(2)", "NEXT(3)", "COMPLETE(3)"]); - }); - }); - - describe("Destination changes", () => { - test("reaches new destination when dest changes", async () => { - const game = await gameFromString(["WWWWWWWW"]); // 8 wide - const adapter = create(game); - const src = game.ref(0, 0); - const dst1 = game.ref(4, 0); - const dst2 = game.ref(7, 0); - - // First path exists - expect(adapter.findPath(src, dst1)).not.toBeNull(); - - // Can still find path to new destination - expect(adapter.findPath(dst1, dst2)).not.toBeNull(); - }); - - test("recomputes when destination changes mid-path", async () => { - const game = await gameFromString(["WWWWWWWWWWWWWWWWWWWW"]); // 20 wide - const adapter = create(game); - const src = game.ref(0, 0); - const dst1 = game.ref(10, 0); - const dst2 = game.ref(19, 0); - - // Start pathing to dst1, take one step - const result1 = adapter.next(src, dst1); - expect(result1.status).toBe(PathStatus.NEXT); - - // Change destination mid-path, continue from current position - let current = (result1 as { node: TileRef }).node; - let result = adapter.next(current, dst2); - for (let i = 0; i < 100 && result.status === PathStatus.NEXT; i++) { - current = (result as { node: TileRef }).node; - result = adapter.next(current, dst2); - } - - expect(result.status).toBe(PathStatus.COMPLETE); - expect(current).toBe(dst2); - }); - }); - - describe("Error handling", () => { - // MiniAStar logs console error when nulls passed, muted in test - - test("returns NOT_FOUND for null source", async () => { - const game = await gameFromString(["WWWW"]); - const adapter = create(game); - const dst = game.ref(0, 0); - - const consoleSpy = vi - .spyOn(console, "error") - .mockImplementation(() => {}); - const result = adapter.next(null as unknown as TileRef, dst); - expect(result.status).toBe(PathStatus.NOT_FOUND); - consoleSpy.mockRestore(); - }); - - test("returns NOT_FOUND for null destination", async () => { - const game = await gameFromString(["WWWW"]); - const adapter = create(game); - const src = game.ref(0, 0); - - const consoleSpy = vi - .spyOn(console, "error") - .mockImplementation(() => {}); - const result = adapter.next(src, null as unknown as TileRef); - expect(result.status).toBe(PathStatus.NOT_FOUND); - consoleSpy.mockRestore(); - }); - }); - - describe("dist parameter", () => { - test("returns COMPLETE when within dist", () => { - const adapter = create(worldGame); - const src = worldGame.ref(926, 283); - const dst = worldGame.ref(928, 283); // 2 tiles away - - const result = adapter.next(src, dst, 5); - - expect(result.status).toBe(PathStatus.COMPLETE); - }); - - test("returns NEXT when beyond dist", () => { - const adapter = create(worldGame); - const src = worldGame.ref(926, 283); - const dst = worldGame.ref(950, 257); - - // Adapter may need a few ticks to compute path - let result = adapter.next(src, dst, 5); - for (let i = 0; i < 100 && result.status === PathStatus.PENDING; i++) { - result = adapter.next(src, dst, 5); - } - - expect(result.status).toBe(PathStatus.NEXT); - }); - }); - - describe("World map routes", () => { - test("Spain to France (Mediterranean)", () => { - const adapter = create(worldGame); - const path = adapter.findPath( - worldGame.ref(926, 283), - worldGame.ref(950, 257), - ); - expect(path).not.toBeNull(); - }); - - test("Miami to Rio (Atlantic)", () => { - const adapter = create(worldGame); - const path = adapter.findPath( - worldGame.ref(488, 355), - worldGame.ref(680, 658), - ); - expect(path).not.toBeNull(); - expect(path!.length).toBeGreaterThan(100); - }); - - test("France to Poland (around Europe)", () => { - const adapter = create(worldGame); - const path = adapter.findPath( - worldGame.ref(950, 257), - worldGame.ref(1033, 175), - ); - expect(path).not.toBeNull(); - }); - - test("Miami to Spain (transatlantic)", () => { - const adapter = create(worldGame); - const path = adapter.findPath( - worldGame.ref(488, 355), - worldGame.ref(926, 283), - ); - expect(path).not.toBeNull(); - }); - - test("Rio to Poland (South Atlantic to Baltic)", () => { - const adapter = create(worldGame); - const path = adapter.findPath( - worldGame.ref(680, 658), - worldGame.ref(1033, 175), - ); - expect(path).not.toBeNull(); - }); - }); - - describe("Known bugs", () => { - test("path can cross 1-tile land barrier", async () => { - const game = await gameFromString(["WLLWLWWLLW"]); - const adapter = create(game); - const path = adapter.findPath(game.ref(0, 0), game.ref(9, 0)); - expect(path).not.toBeNull(); - }); - - test("path can cross diagonal land barrier", async () => { - const game = await gameFromString(["WL", "LW"]); - const adapter = create(game); - const path = adapter.findPath(game.ref(0, 0), game.ref(1, 1)); - expect(path).not.toBeNull(); - }); - }); -}); diff --git a/tests/core/pathfinding/PathFinderStepper.test.ts b/tests/core/pathfinding/PathFinderStepper.test.ts new file mode 100644 index 000000000..5cf0fbc53 --- /dev/null +++ b/tests/core/pathfinding/PathFinderStepper.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; +import { PathFinderStepper } from "../../../src/core/pathfinding/PathFinderStepper"; +import { PathFinder, PathStatus } from "../../../src/core/pathfinding/types"; + +describe("PathFinderStepper", () => { + function createMockFinder( + pathMap: Map, + ): PathFinder { + return { + findPath(from: number | number[], to: number): number[] | null { + const fromTile = Array.isArray(from) ? from[0] : from; + const key = `${fromTile}->${to}`; + return pathMap.get(key) ?? null; + }, + }; + } + + describe("next", () => { + it("returns COMPLETE when at destination", () => { + const pathMap = new Map(); + const stepper = new PathFinderStepper(createMockFinder(pathMap)); + + const result = stepper.next(5, 5); + + expect(result.status).toBe(PathStatus.COMPLETE); + expect((result as { node: number }).node).toBe(5); + }); + + it("returns NEXT with path nodes sequentially", () => { + const pathMap = new Map([["1->4", [1, 2, 3, 4]]]); + const stepper = new PathFinderStepper(createMockFinder(pathMap)); + + // First step: 1 -> 4, returns 2 + const result1 = stepper.next(1, 4); + expect(result1.status).toBe(PathStatus.NEXT); + expect((result1 as { node: number }).node).toBe(2); + + // Second step: from 2, returns 3 + const result2 = stepper.next(2, 4); + expect(result2.status).toBe(PathStatus.NEXT); + expect((result2 as { node: number }).node).toBe(3); + + // Third step: from 3, returns 4 + const result3 = stepper.next(3, 4); + expect(result3.status).toBe(PathStatus.NEXT); + expect((result3 as { node: number }).node).toBe(4); + + // Fourth step: at destination + const result4 = stepper.next(4, 4); + expect(result4.status).toBe(PathStatus.COMPLETE); + }); + + it("returns NOT_FOUND when no path exists", () => { + const pathMap = new Map(); + const stepper = new PathFinderStepper(createMockFinder(pathMap)); + + const result = stepper.next(1, 99); + + expect(result.status).toBe(PathStatus.NOT_FOUND); + }); + + it("recomputes path when moved off-path", () => { + // Path from 1->5 goes through 2,3,4 + // Path from 10->5 goes through 9,8,7,6 + const pathMap = new Map([ + ["1->5", [1, 2, 3, 4, 5]], + ["10->5", [10, 9, 8, 7, 6, 5]], + ]); + const stepper = new PathFinderStepper(createMockFinder(pathMap)); + + // Start on path 1->5 + const result1 = stepper.next(1, 5); + expect(result1.status).toBe(PathStatus.NEXT); + expect((result1 as { node: number }).node).toBe(2); + + // Move off-path to tile 10 (not on original path) + // Should recompute using path from 10->5 + const result2 = stepper.next(10, 5); + expect(result2.status).toBe(PathStatus.NEXT); + expect((result2 as { node: number }).node).toBe(9); + }); + + it("recomputes path when destination changes", () => { + const pathMap = new Map([ + ["1->5", [1, 2, 3, 4, 5]], + ["2->9", [2, 6, 7, 8, 9]], + ]); + const stepper = new PathFinderStepper(createMockFinder(pathMap)); + + // Start on path 1->5 + const result1 = stepper.next(1, 5); + expect(result1.status).toBe(PathStatus.NEXT); + expect((result1 as { node: number }).node).toBe(2); + + // Change destination to 9 (from current position 2) + const result2 = stepper.next(2, 9); + expect(result2.status).toBe(PathStatus.NEXT); + expect((result2 as { node: number }).node).toBe(6); + }); + }); + + describe("invalidate", () => { + it("clears cached path so next recomputes", () => { + let callCount = 0; + const finder: PathFinder = { + findPath(from, to): number[] | null { + callCount++; + const fromTile = Array.isArray(from) ? from[0] : from; + return [fromTile, to]; + }, + }; + const stepper = new PathFinderStepper(finder); + + stepper.next(1, 5); + stepper.next(5, 5); + + // Second call follows path without recomputing + expect(callCount).toBe(1); + + stepper.invalidate(); + stepper.next(1, 5); + + // Recomputed path after invalidation + expect(callCount).toBe(2); + }); + }); + + describe("findPath", () => { + it("delegates to inner finder", () => { + const pathMap = new Map([["1->5", [1, 2, 3, 4, 5]]]); + const stepper = new PathFinderStepper(createMockFinder(pathMap)); + + const path = stepper.findPath(1, 5); + + expect(path).toEqual([1, 2, 3, 4, 5]); + }); + + it("supports multi-source", () => { + const finder: PathFinder = { + findPath(from, to): number[] | null { + const firstFrom = Array.isArray(from) ? from[0] : from; + return [firstFrom, to]; + }, + }; + const stepper = new PathFinderStepper(finder); + + const path = stepper.findPath([1, 2, 3], 5); + + expect(path).toEqual([1, 5]); + }); + }); + + describe("custom equals", () => { + it("uses custom equals function for position comparison", () => { + type Pos = { x: number; y: number }; + const posEquals = (a: Pos, b: Pos) => a.x === b.x && a.y === b.y; + + const finder: PathFinder = { + findPath(from, to): Pos[] | null { + const f = Array.isArray(from) ? from[0] : from; + return [f, { x: 2, y: 0 }, to]; + }, + }; + + const stepper = new PathFinderStepper(finder, { equals: posEquals }); + + const from1 = { x: 1, y: 0 }; + const to = { x: 3, y: 0 }; + + const result1 = stepper.next(from1, to); + expect(result1.status).toBe(PathStatus.NEXT); + + // Use equivalent but different object (a !== b), still on track + const result2 = stepper.next({ x: 2, y: 0 }, to); + expect(result2.status).toBe(PathStatus.NEXT); + expect((result2 as { node: Pos }).node).toEqual({ x: 3, y: 0 }); + }); + }); +}); diff --git a/tests/core/pathfinding/PathFinding.Air.test.ts b/tests/core/pathfinding/PathFinding.Air.test.ts new file mode 100644 index 000000000..b14eb26c1 --- /dev/null +++ b/tests/core/pathfinding/PathFinding.Air.test.ts @@ -0,0 +1,184 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { Game } from "../../../src/core/game/Game"; +import { TileRef } from "../../../src/core/game/GameMap"; +import { PathFinding } from "../../../src/core/pathfinding/PathFinder"; +import { SteppingPathFinder } from "../../../src/core/pathfinding/types"; +import { setup } from "../../util/Setup"; + +describe("PathFinding.Air", () => { + let game: Game; + + function createPathFinder(): SteppingPathFinder { + return PathFinding.Air(game); + } + + beforeAll(async () => { + game = await setup("ocean_and_land"); + }); + + describe("findPath", () => { + it("returns path between any two points (ignores terrain)", () => { + const pathFinder = createPathFinder(); + const map = game.map(); + + // Air pathfinder ignores terrain, so can go anywhere + // (2,2) → (14,14): manhattan = 24, path length = 25 + const from = map.ref(2, 2); + const to = map.ref(14, 14); + + const path = pathFinder.findPath(from, to); + + expect(path).not.toBeNull(); + expect(path!.length).toBe(25); + expect(path![0]).toBe(from); + expect(path![path!.length - 1]).toBe(to); + }); + + it("throws error for multiple start points", () => { + const pathFinder = createPathFinder(); + const map = game.map(); + + const from = [map.ref(2, 2), map.ref(4, 4)]; + const to = map.ref(14, 14); + + expect(() => pathFinder.findPath(from, to)).toThrow( + "does not support multiple start points", + ); + }); + + it("returns single-tile path when from equals to", () => { + const pathFinder = createPathFinder(); + const map = game.map(); + const tile = map.ref(8, 8); + + const path = pathFinder.findPath(tile, tile); + + expect(path).not.toBeNull(); + expect(path![0]).toBe(tile); + }); + }); + + describe("path validity", () => { + it("all consecutive tiles in path are adjacent (Manhattan distance 1)", () => { + const pathFinder = createPathFinder(); + const map = game.map(); + + // (2,2) → (14,14): manhattan = 24, path length = 25 + const from = map.ref(2, 2); + const to = map.ref(14, 14); + + const path = pathFinder.findPath(from, to); + + expect(path).not.toBeNull(); + expect(path!.length).toBe(25); + + // Verify every consecutive pair is adjacent + for (let i = 1; i < path!.length; i++) { + const dist = map.manhattanDist(path![i - 1], path![i]); + expect(dist).toBe(1); + } + }); + + it("path ends at exact destination", () => { + const pathFinder = createPathFinder(); + const map = game.map(); + + const from = map.ref(5, 5); + const to = map.ref(10, 12); + + const path = pathFinder.findPath(from, to); + + expect(path).not.toBeNull(); + expect(path![path!.length - 1]).toBe(to); + }); + }); + + describe("path shapes", () => { + it("diagonal path has equal X and Y movement", () => { + const pathFinder = createPathFinder(); + const map = game.map(); + + // Equal X and Y offset: (0,0) → (10,10) + const from = map.ref(0, 0); + const to = map.ref(10, 10); + + const path = pathFinder.findPath(from, to); + expect(path).not.toBeNull(); + + let xMoves = 0; + let yMoves = 0; + for (let i = 1; i < path!.length; i++) { + const dx = map.x(path![i]) - map.x(path![i - 1]); + const dy = map.y(path![i]) - map.y(path![i - 1]); + if (dx !== 0) xMoves++; + if (dy !== 0) yMoves++; + } + + expect(xMoves).toBe(10); + expect(yMoves).toBe(10); + }); + + it("horizontal path has only X movement", () => { + const pathFinder = createPathFinder(); + const map = game.map(); + + // Pure horizontal: (0,5) → (15,5) + const from = map.ref(0, 5); + const to = map.ref(15, 5); + + const path = pathFinder.findPath(from, to); + expect(path).not.toBeNull(); + + let xMoves = 0; + let yMoves = 0; + for (let i = 1; i < path!.length; i++) { + const dx = map.x(path![i]) - map.x(path![i - 1]); + const dy = map.y(path![i]) - map.y(path![i - 1]); + if (dx !== 0) xMoves++; + if (dy !== 0) yMoves++; + } + + expect(xMoves).toBe(15); + expect(yMoves).toBe(0); + }); + + it("vertical path has only Y movement", () => { + const pathFinder = createPathFinder(); + const map = game.map(); + + // Pure vertical: (5,0) → (5,15) + const from = map.ref(5, 0); + const to = map.ref(5, 15); + + const path = pathFinder.findPath(from, to); + expect(path).not.toBeNull(); + + let xMoves = 0; + let yMoves = 0; + for (let i = 1; i < path!.length; i++) { + const dx = map.x(path![i]) - map.x(path![i - 1]); + const dy = map.y(path![i]) - map.y(path![i - 1]); + if (dx !== 0) xMoves++; + if (dy !== 0) yMoves++; + } + + expect(xMoves).toBe(0); + expect(yMoves).toBe(15); + }); + + it("adjacent tiles produce minimal path", () => { + const pathFinder = createPathFinder(); + const map = game.map(); + + const from = map.ref(5, 5); + const to = map.ref(6, 5); + + const path = pathFinder.findPath(from, to); + + expect(path).not.toBeNull(); + expect(path!.length).toBe(2); + expect(path![0]).toBe(from); + expect(path![1]).toBe(to); + }); + }); +}); diff --git a/tests/core/pathfinding/PathFinding.Rail.test.ts b/tests/core/pathfinding/PathFinding.Rail.test.ts new file mode 100644 index 000000000..3d254f9eb --- /dev/null +++ b/tests/core/pathfinding/PathFinding.Rail.test.ts @@ -0,0 +1,36 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { Game } from "../../../src/core/game/Game"; +import { TileRef } from "../../../src/core/game/GameMap"; +import { PathFinding } from "../../../src/core/pathfinding/PathFinder"; +import { SteppingPathFinder } from "../../../src/core/pathfinding/types"; +import { setup } from "../../util/Setup"; + +describe("PathFinding.Rail", () => { + let game: Game; + let pathFinder: SteppingPathFinder; + + beforeAll(async () => { + game = await setup("ocean_and_land"); + pathFinder = PathFinding.Rail(game); + }); + + describe("findPath", () => { + it("finds path on land tiles", () => { + const map = game.map(); + + // Adjacent land tiles: (0,0) and (1,0) + const from = map.ref(0, 0); + const to = map.ref(1, 0); + + expect(map.isLand(from)).toBe(true); + expect(map.isLand(to)).toBe(true); + + const path = pathFinder.findPath(from, to); + + expect(path).not.toBeNull(); + expect(path!.length).toBe(2); + expect(path![0]).toBe(from); + expect(path![1]).toBe(to); + }); + }); +}); diff --git a/tests/core/pathfinding/PathFinding.Water.test.ts b/tests/core/pathfinding/PathFinding.Water.test.ts new file mode 100644 index 000000000..48d06b20f --- /dev/null +++ b/tests/core/pathfinding/PathFinding.Water.test.ts @@ -0,0 +1,277 @@ +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { Game } from "../../../src/core/game/Game"; +import { TileRef } from "../../../src/core/game/GameMap"; +import { PathFinding } from "../../../src/core/pathfinding/PathFinder"; +import { + PathStatus, + SteppingPathFinder, +} from "../../../src/core/pathfinding/types"; +import { setup } from "../../util/Setup"; +import { createGame, L, W } from "./_fixtures"; + +describe("PathFinding.Water", () => { + let game: Game; + let worldGame: Game; + + function createPathFinder(g: Game = game): SteppingPathFinder { + return PathFinding.Water(g); + } + + beforeAll(async () => { + game = await setup("ocean_and_land"); + worldGame = await setup("world", { disableNavMesh: false }); + }); + + describe("findPath", () => { + it("finds path between adjacent water tiles", () => { + const pathFinder = createPathFinder(); + const map = game.map(); + + const from = map.ref(8, 0); + const to = map.ref(9, 0); + + expect(map.isWater(from)).toBe(true); + expect(map.isWater(to)).toBe(true); + + const path = pathFinder.findPath(from, to); + + expect(path).not.toBeNull(); + expect(path!.length).toBe(2); + expect(path![0]).toBe(from); + expect(path![1]).toBe(to); + }); + + it("returns null for land tiles", () => { + const pathFinder = createPathFinder(); + const map = game.map(); + + const landTile = map.ref(0, 0); + const waterTile = map.ref(8, 0); + + expect(map.isLand(landTile)).toBe(true); + expect(map.isShore(landTile)).toBe(false); + expect(map.isWater(waterTile)).toBe(true); + + const path = pathFinder.findPath(landTile, waterTile); + + expect(path).toBeNull(); + }); + + it("returns single-tile path when from equals to", () => { + const pathFinder = createPathFinder(); + const map = game.map(); + + const waterTile = map.ref(8, 0); + expect(map.isWater(waterTile)).toBe(true); + + const path = pathFinder.findPath(waterTile, waterTile); + + expect(path).not.toBeNull(); + expect(path!.length).toBe(1); + expect(path![0]).toBe(waterTile); + }); + + it("supports multiple start tiles", () => { + const pathFinder = createPathFinder(); + const map = game.map(); + + const dest = map.ref(8, 0); + const source1 = map.ref(9, 0); + const source2 = map.ref(8, 1); + + expect(map.isWater(dest)).toBe(true); + expect(map.isWater(source1)).toBe(true); + expect(map.isWater(source2)).toBe(true); + + const from = [source1, source2]; + const path = pathFinder.findPath(from, dest); + + expect(path).not.toBeNull(); + expect(path!.length).toBe(2); + expect(from).toContain(path![0]); + expect(path![1]).toBe(dest); + }); + }); + + describe("path validity", () => { + it("all consecutive tiles in path are connected", () => { + const pathFinder = createPathFinder(); + const map = game.map(); + + // Distant water tiles: (8,0) → (15,4), distance = 11 + const from = map.ref(8, 0); + const to = map.ref(15, 4); + + expect(map.isWater(from)).toBe(true); + expect(map.isWater(to)).toBe(true); + expect(map.manhattanDist(from, to)).toBe(11); + + const path = pathFinder.findPath(from, to); + + expect(path).not.toBeNull(); + + for (let i = 1; i < path!.length; i++) { + const dist = map.manhattanDist(path![i - 1], path![i]); + expect(dist).toEqual(1); + } + }); + }); + + describe("shore handling", () => { + it("path from shore to shore starts and ends on shore", () => { + const pathFinder = createPathFinder(); + const map = game.map(); + + // Shore tiles at (7,0) and (7,6), distance = 6 + // Both have water neighbors at (8,0) and (8,6) + const from = map.ref(7, 0); + const to = map.ref(7, 6); + + expect(map.isShore(from)).toBe(true); + expect(map.isShore(to)).toBe(true); + expect(map.manhattanDist(from, to)).toBe(6); + + const path = pathFinder.findPath(from, to); + + expect(path).not.toBeNull(); + expect(path![0]).toBe(from); + expect(path![path!.length - 1]).toBe(to); + }); + }); + + describe("determinism", () => { + it("same inputs produce identical paths", () => { + const pathFinder1 = createPathFinder(); + const pathFinder2 = createPathFinder(); + const map = game.map(); + + // Distant water tiles: (8,0) → (15,4) + const from = map.ref(8, 0); + const to = map.ref(15, 4); + + const path1 = pathFinder1.findPath(from, to); + const path2 = pathFinder2.findPath(from, to); + + expect(path1).not.toBeNull(); + expect(path2).not.toBeNull(); + expect(path1).toEqual(path2); + }); + }); + + describe("World map routes", () => { + it("Spain to France (Mediterranean)", () => { + const pathFinder = createPathFinder(worldGame); + const path = pathFinder.findPath( + worldGame.ref(926, 283), + worldGame.ref(950, 257), + ); + expect(path).not.toBeNull(); + }); + + it("Miami to Rio (Atlantic)", () => { + const pathFinder = createPathFinder(worldGame); + const path = pathFinder.findPath( + worldGame.ref(488, 355), + worldGame.ref(680, 658), + ); + expect(path).not.toBeNull(); + }); + + it("France to Poland (around Europe)", () => { + const pathFinder = createPathFinder(worldGame); + const path = pathFinder.findPath( + worldGame.ref(950, 257), + worldGame.ref(1033, 175), + ); + expect(path).not.toBeNull(); + }); + + it("Miami to Spain (transatlantic)", () => { + const pathFinder = createPathFinder(worldGame); + const path = pathFinder.findPath( + worldGame.ref(488, 355), + worldGame.ref(926, 283), + ); + expect(path).not.toBeNull(); + }); + + it("Rio to Poland (South Atlantic to Baltic)", () => { + const pathFinder = createPathFinder(worldGame); + const path = pathFinder.findPath( + worldGame.ref(680, 658), + worldGame.ref(1033, 175), + ); + expect(path).not.toBeNull(); + }); + }); + + describe("Error handling", () => { + it("returns NOT_FOUND for null source", () => { + const pathFinder = createPathFinder(); + + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + const result = pathFinder.next( + null as unknown as TileRef, + game.ref(8, 0), + ); + + expect(result.status).toBe(PathStatus.NOT_FOUND); + + consoleSpy.mockRestore(); + }); + + it("returns NOT_FOUND for null destination", () => { + const pathFinder = createPathFinder(); + + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + const result = pathFinder.next( + game.ref(8, 0), + null as unknown as TileRef, + ); + + expect(result.status).toBe(PathStatus.NOT_FOUND); + + consoleSpy.mockRestore(); + }); + }); + + describe("Known bugs", () => { + it("path can cross 1-tile land barrier", () => { + const syntheticGame = createGame({ + width: 10, + height: 1, + grid: [W, L, L, W, L, W, W, L, L, W], + }); + + const pathFinder = createPathFinder(syntheticGame); + const path = pathFinder.findPath( + syntheticGame.ref(0, 0), + syntheticGame.ref(9, 0), + ); + + expect(path).not.toBeNull(); + }); + + it("path can cross diagonal land barrier", () => { + const syntheticGame = createGame({ + width: 2, + height: 2, + grid: [W, L, L, W], + }); + + const pathFinder = createPathFinder(syntheticGame); + const path = pathFinder.findPath( + syntheticGame.ref(0, 0), + syntheticGame.ref(1, 1), + ); + + expect(path).not.toBeNull(); + }); + }); +}); diff --git a/tests/core/pathfinding/SpatialQuery.test.ts b/tests/core/pathfinding/SpatialQuery.test.ts new file mode 100644 index 000000000..45c1e7490 --- /dev/null +++ b/tests/core/pathfinding/SpatialQuery.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from "vitest"; +import { SpawnExecution } from "../../../src/core/execution/SpawnExecution"; +import { + Game, + Player, + PlayerInfo, + PlayerType, +} from "../../../src/core/game/Game"; +import { TileRef } from "../../../src/core/game/GameMap"; +import { SpatialQuery } from "../../../src/core/pathfinding/spatial/SpatialQuery"; +import { createGame, L, W } from "./_fixtures"; + +// Spawns player and **expands territory** via getSpawnTiles (euclidean dist 4) +// Ref: src/core/execution/Util.ts +function addPlayer(game: Game, tile: TileRef): Player { + const info = new PlayerInfo("test", PlayerType.Human, null, "test_id"); + game.addPlayer(info); + game.addExecution(new SpawnExecution("game_id", info, tile)); + while (game.inSpawnPhase()) game.executeNextTick(); + return game.player(info.id); +} + +describe("SpatialQuery", () => { + describe("closestShore", () => { + it("finds shore tile owned by player", () => { + // prettier-ignore + const game = createGame({ + width: 5, height: 5, grid: [ + W, W, W, W, W, + W, L, L, L, W, + W, L, L, L, W, + W, L, L, L, W, + W, W, W, W, W, + ], + }); + + const spatial = new SpatialQuery(game); + const player = addPlayer(game, game.ref(2, 2)); + + // All land tiles owned by player because of spawn expansion + const result = spatial.closestShore(player, game.ref(2, 2)); + + expect(result).not.toBeNull(); + expect(game.isShore(result!)).toBe(true); + expect(game.ownerID(result!)).toBe(player.smallID()); + }); + + it("returns null when no shore within maxDist", () => { + // prettier-ignore + const game = createGame({ + width: 7, height: 7, grid: [ + W, W, W, W, W, W, W, + W, L, L, L, L, L, W, + W, L, L, L, L, L, W, + W, L, L, L, L, L, W, + W, L, L, L, L, L, W, + W, L, L, L, L, L, W, + W, W, W, W, W, W, W, + ], + }); + + const spatial = new SpatialQuery(game); + const player = addPlayer(game, game.ref(3, 3)); + + // maxDist=1 from center (3,3) - shore is 2 tiles away + const result = spatial.closestShore(player, game.ref(3, 3), 1); + + expect(result).toBeNull(); + }); + + it("finds shore on player's island (two separate islands)", () => { + // prettier-ignore + const game = createGame({ + width: 8, height: 4, grid: [ + L, L, W, W, W, W, L, L, + L, L, W, W, W, W, L, L, + L, L, W, W, W, W, L, L, + L, L, W, W, W, W, L, L, + ], + }); + + const spatial = new SpatialQuery(game); + const player = addPlayer(game, game.ref(0, 0)); + + const result = spatial.closestShore(player, game.ref(0, 2)); + + expect(result).not.toBeNull(); + expect(game.isShore(result!)).toBe(true); + expect(game.ownerID(result!)).toBe(player.smallID()); + expect(game.x(result!)).toBeLessThanOrEqual(2); + }); + + it("finds shore even if no land path exists (two separate islands)", () => { + // prettier-ignore + const game = createGame({ + width: 8, height: 4, grid: [ + L, L, W, W, W, W, L, L, + L, L, W, W, W, W, L, L, + L, L, W, W, W, W, L, L, + L, L, W, W, W, W, L, L, + ], + }); + + const spatial = new SpatialQuery(game); + const player = addPlayer(game, game.ref(0, 0)); + + const result = spatial.closestShore(player, game.ref(7, 2)); + + expect(result).not.toBeNull(); + expect(game.isShore(result!)).toBe(true); + expect(game.ownerID(result!)).toBe(player.smallID()); + expect(game.x(result!)).toBeLessThanOrEqual(2); + }); + + it("finds shore for terra nullius when land is unclaimed", () => { + // prettier-ignore + const game = createGame({ + width: 5, height: 5, grid: [ + W, W, W, W, W, + W, L, L, L, W, + W, L, L, L, W, + W, L, L, L, W, + W, W, W, W, W, + ], + }); + + const spatial = new SpatialQuery(game); + const terraNullius = game.terraNullius(); + + const result = spatial.closestShore(terraNullius, game.ref(2, 2)); + + expect(result).not.toBeNull(); + expect(game.isShore(result!)).toBe(true); + }); + }); + + describe("closestShoreByWater", () => { + it("returns null for terra nullius", () => { + // prettier-ignore + const game = createGame({ + width: 5, height: 5, grid: [ + W, W, W, W, W, + W, L, L, L, W, + W, L, L, L, W, + W, L, L, L, W, + W, W, W, W, W, + ], + }); + + const spatial = new SpatialQuery(game); + const terraNullius = game.terraNullius(); + + const result = spatial.closestShoreByWater(terraNullius, game.ref(0, 0)); + + expect(result).toBeNull(); + }); + + it("returns null when target is on land", () => { + // prettier-ignore + const game = createGame({ + width: 5, height: 5, grid: [ + W, W, W, W, W, + W, L, L, L, W, + W, L, L, L, W, + W, L, L, L, W, + W, W, W, W, W, + ], + }); + + const spatial = new SpatialQuery(game); + const player = addPlayer(game, game.ref(2, 2)); + + const result = spatial.closestShoreByWater(player, game.ref(2, 2)); + + expect(result).toBeNull(); + }); + + it("returns null when target is in disconnected water body", () => { + // prettier-ignore + const game = createGame({ + width: 14, height: 6, grid: [ + W, W, L, L, L, L, L, L, L, L, L, L, W, W, + W, W, L, L, L, L, L, L, L, L, L, L, W, W, + W, W, L, L, L, L, L, L, L, L, L, L, W, W, + W, W, L, L, L, L, L, L, L, L, L, L, W, W, + W, W, L, L, L, L, L, L, L, L, L, L, W, W, + W, W, L, L, L, L, L, L, L, L, L, L, W, W, + ], + }); + + const spatial = new SpatialQuery(game); + const player = addPlayer(game, game.ref(3, 2)); + const result = spatial.closestShoreByWater(player, game.ref(13, 2)); + + expect(result).toBeNull(); + }); + + it("finds shore via long water path around island", () => { + // prettier-ignore + const game = createGame({ + width: 18, height: 14, grid: [ + W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, + W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, + W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, + W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, + W, W, W, W, L, L, L, L, L, L, L, L, L, L, W, W, W, W, + W, W, W, W, L, L, L, L, L, L, L, L, L, L, W, W, W, W, + W, W, W, W, L, L, L, L, L, L, L, L, L, L, W, W, W, W, + W, W, W, W, L, L, L, L, L, L, L, L, L, L, W, W, W, W, + W, W, W, W, L, L, L, L, L, L, L, L, L, L, W, W, W, W, + W, W, W, W, L, L, L, L, L, L, L, L, L, L, W, W, W, W, + W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, + W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, + W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, + W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, L, + ], + }); + + const spatial = new SpatialQuery(game); + const player = addPlayer(game, game.ref(4, 4)); + + const target = game.ref(17, 13); + const result = spatial.closestShoreByWater(player, target); + + expect(result).not.toBeNull(); + expect(game.isShore(result!)).toBe(true); + expect(game.ownerID(result!)).toBe(player.smallID()); + }); + }); +}); diff --git a/tests/core/pathfinding/UniversalPathFinding.Parabola.test.ts b/tests/core/pathfinding/UniversalPathFinding.Parabola.test.ts new file mode 100644 index 000000000..b00215da3 --- /dev/null +++ b/tests/core/pathfinding/UniversalPathFinding.Parabola.test.ts @@ -0,0 +1,320 @@ +import { describe, expect, it } from "vitest"; +import { GameMapImpl } from "../../../src/core/game/GameMap"; +import { UniversalPathFinding } from "../../../src/core/pathfinding/PathFinder"; +import { PathStatus } from "../../../src/core/pathfinding/types"; + +describe("UniversalPathFinding.Parabola", () => { + function createLargeMap() { + // Create a larger map for parabola tests (need space for arcs) + const W = 0x20; + const terrain = new Uint8Array(10000).fill(W); + return new GameMapImpl(100, 100, terrain, 0); + } + + describe("findPath", () => { + it("returns parabolic arc between two points", () => { + const map = createLargeMap(); + const finder = UniversalPathFinding.Parabola(map); + + const from = map.ref(10, 50); + const to = map.ref(90, 50); + + const path = finder.findPath(from, to); + + expect(path).not.toBeNull(); + expect(path!.length).toBe(39); + expect(path![0]).toBe(from); + expect(path![path!.length - 1]).toBe(to); + }); + + it("throws error for multiple start points", () => { + const map = createLargeMap(); + const finder = UniversalPathFinding.Parabola(map); + + const from = [map.ref(10, 50), map.ref(20, 50)]; + const to = map.ref(90, 50); + + expect(() => finder.findPath(from, to)).toThrow( + "does not support multiple start points", + ); + }); + + it("handles same start and end point", () => { + const map = createLargeMap(); + const finder = UniversalPathFinding.Parabola(map); + + const tile = map.ref(50, 50); + + const path = finder.findPath(tile, tile); + + expect(path).not.toBeNull(); + expect(path!.length).toBe(26); + }); + + it("creates arc across map", () => { + const map = createLargeMap(); + const finder = UniversalPathFinding.Parabola(map); + + const from = map.ref(0, 50); + const to = map.ref(99, 50); + + const path = finder.findPath(from, to); + + expect(path).not.toBeNull(); + expect(path!.length).toBe(43); + expect(path![0]).toBe(from); + expect(path![path!.length - 1]).toBe(to); + }); + }); + + describe("next (stepping)", () => { + it("returns NEXT with node when not at destination", () => { + const map = createLargeMap(); + const finder = UniversalPathFinding.Parabola(map); + + const from = map.ref(10, 50); + const to = map.ref(90, 50); + + const result = finder.next(from, to); + + expect(result.status).toBe(PathStatus.NEXT); + expect("node" in result).toBe(true); + }); + + it("respects speed parameter (higher speed = further movement)", () => { + const map = createLargeMap(); + const finder1 = UniversalPathFinding.Parabola(map); + const finder2 = UniversalPathFinding.Parabola(map); + + const from = map.ref(10, 50); + const to = map.ref(90, 50); + + // Step with speed 1 + const result1 = finder1.next(from, to, 1); + + // Step with speed 5 + const result2 = finder2.next(from, to, 5); + + // Both should be NEXT (not at destination yet) + expect(result1.status).toBe(PathStatus.NEXT); + expect(result2.status).toBe(PathStatus.NEXT); + + const node1 = ( + result1 as { status: typeof PathStatus.NEXT; node: number } + ).node; + const node2 = ( + result2 as { status: typeof PathStatus.NEXT; node: number } + ).node; + + // Speed 5 should move strictly further than speed 1 + const dist1 = map.manhattanDist(from, node1); + const dist2 = map.manhattanDist(from, node2); + expect(dist2).toBeGreaterThan(dist1); + + expect(finder2.currentIndex()).toBeGreaterThan(finder1.currentIndex()); + }); + }); + + describe("options", () => { + it("increment option affects path density", () => { + const map = createLargeMap(); + const finder1 = UniversalPathFinding.Parabola(map, { increment: 1 }); + const finder2 = UniversalPathFinding.Parabola(map, { increment: 10 }); + + const from = map.ref(10, 50); + const to = map.ref(90, 50); + + const path1 = finder1.findPath(from, to); + const path2 = finder2.findPath(from, to); + + expect(path1).not.toBeNull(); + expect(path2).not.toBeNull(); + + expect(path1!.length).toBeGreaterThan(path2!.length); + }); + + it("distanceBasedHeight option affects arc height", () => { + const map = createLargeMap(); + const finder1 = UniversalPathFinding.Parabola(map, { + distanceBasedHeight: true, + }); + const finder2 = UniversalPathFinding.Parabola(map, { + distanceBasedHeight: false, + }); + + const from = map.ref(10, 50); + const to = map.ref(90, 50); + + const path1 = finder1.findPath(from, to); + const path2 = finder2.findPath(from, to); + + expect(path1).not.toBeNull(); + expect(path2).not.toBeNull(); + + // With distanceBasedHeight=true, path should have Y deviation + // With distanceBasedHeight=false, path should be more direct + const getMaxYDeviation = (path: number[]) => { + const midY = map.y(from); + return Math.max(...path.map((t) => Math.abs(map.y(t) - midY))); + }; + + const dev1 = getMaxYDeviation(path1!); + const dev2 = getMaxYDeviation(path2!); + expect(dev1).toBeGreaterThan(dev2); + }); + + it("directionUp option affects arc direction", () => { + const map = createLargeMap(); + const finderUp = UniversalPathFinding.Parabola(map, { + directionUp: true, + }); + const finderDown = UniversalPathFinding.Parabola(map, { + directionUp: false, + }); + + const from = map.ref(10, 50); + const to = map.ref(90, 50); + + const pathUp = finderUp.findPath(from, to); + const pathDown = finderDown.findPath(from, to); + + expect(pathUp).not.toBeNull(); + expect(pathDown).not.toBeNull(); + + // Get midpoint Y values + const midIdx = Math.floor(pathUp!.length / 2); + const midY_Up = map.y(pathUp![midIdx]); + const midY_Down = map.y(pathDown![midIdx]); + const startY = map.y(from); + + // directionUp=true means Y decreases (goes "up" on screen) + // directionUp=false means Y increases (goes "down" on screen) + expect(midY_Up).toBeLessThan(startY); + expect(midY_Down).toBeGreaterThan(startY); + }); + }); + + describe("currentIndex", () => { + it("returns 0 when no curve", () => { + const map = createLargeMap(); + const finder = UniversalPathFinding.Parabola(map); + + expect(finder.currentIndex()).toBe(0); + }); + + it("increments as path is stepped", () => { + const map = createLargeMap(); + const finder = UniversalPathFinding.Parabola(map); + + const from = map.ref(10, 50); + const to = map.ref(90, 50); + + let current = from; + let previousIndex = 0; + + for (let i = 0; i < 50; i++) { + const result = finder.next(current, to); + expect(result.status).toBe(PathStatus.NEXT); + + const index = finder.currentIndex(); + expect(index).toBeGreaterThanOrEqual(previousIndex); + previousIndex = index; + + current = (result as { status: typeof PathStatus.NEXT; node: number }) + .node; + } + }); + }); + + describe("short distances", () => { + it("creates valid arc for distance < 50 (PARABOLA_MIN_HEIGHT)", () => { + const map = createLargeMap(); + const finder = UniversalPathFinding.Parabola(map, { + distanceBasedHeight: true, + }); + + // Distance of 30 is less than PARABOLA_MIN_HEIGHT (50) + const from = map.ref(50, 50); + const to = map.ref(80, 50); + + const path = finder.findPath(from, to); + + expect(path).not.toBeNull(); + expect(path!.length).toBe(28); + expect(path![0]).toBe(from); + expect(path![path!.length - 1]).toBe(to); + }); + + it("creates valid path for adjacent tiles (distance=1)", () => { + const map = createLargeMap(); + const finder = UniversalPathFinding.Parabola(map); + + const from = map.ref(50, 50); + const to = map.ref(51, 50); + + const path = finder.findPath(from, to); + + expect(path).not.toBeNull(); + expect(path!.length).toBe(26); + expect(path![0]).toBe(from); + expect(path![path!.length - 1]).toBe(to); + }); + + it("creates valid path for very short distance (distance=5)", () => { + const map = createLargeMap(); + const finder = UniversalPathFinding.Parabola(map, { + distanceBasedHeight: true, + }); + + const from = map.ref(50, 50); + const to = map.ref(55, 50); + + const path = finder.findPath(from, to); + + expect(path).not.toBeNull(); + expect(path![0]).toBe(from); + expect(path![path!.length - 1]).toBe(to); + }); + }); + + describe("map boundary clipping", () => { + it("arc clipped at map top boundary (directionUp near y=0)", () => { + const map = createLargeMap(); + const finder = UniversalPathFinding.Parabola(map, { + directionUp: true, + distanceBasedHeight: true, + }); + + // Start near top of map + const from = map.ref(10, 5); + const to = map.ref(90, 5); + + const path = finder.findPath(from, to); + + expect(path).not.toBeNull(); + + for (const t of path!) { + expect(map.y(t)).toBeGreaterThanOrEqual(0); + } + }); + + it("arc clipped at map bottom boundary (directionDown near y=max)", () => { + const map = createLargeMap(); + const finder = UniversalPathFinding.Parabola(map, { + directionUp: false, + distanceBasedHeight: true, + }); + + const from = map.ref(10, 95); + const to = map.ref(90, 95); + + const path = finder.findPath(from, to); + + expect(path).not.toBeNull(); + + for (const t of path!) { + expect(map.y(t)).toBeLessThan(100); + } + }); + }); +}); diff --git a/tests/core/pathfinding/WaterComponents.test.ts b/tests/core/pathfinding/WaterComponents.test.ts new file mode 100644 index 000000000..91faad9c6 --- /dev/null +++ b/tests/core/pathfinding/WaterComponents.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { + ConnectedComponents, + LAND_MARKER, +} from "../../../src/core/pathfinding/algorithms/ConnectedComponents"; +import { createGameMap, createIslandMap, L, W } from "./_fixtures"; + +// prettier-ignore +const twoComponentsMapData = { + width: 7, height: 5, grid: [ + W, W, L, L, L, W, W, + W, W, L, L, L, W, W, + W, W, L, L, L, W, W, + W, W, L, L, L, W, W, + W, W, L, L, L, W, W, + ], +}; + +describe("ConnectedComponents", () => { + describe("getComponentId", () => { + it("returns 0 before initialization", () => { + const map = createGameMap(createIslandMap()); + const wc = new ConnectedComponents(map); + + // Water tile at (0,0) - should return 0 (not initialized) + const waterTile = map.ref(0, 0); + expect(wc.getComponentId(waterTile)).toBe(0); + }); + + it("returns same component ID for all water tiles in single connected area", () => { + const map = createGameMap(createIslandMap()); + const wc = new ConnectedComponents(map); + wc.initialize(); + + const water1 = map.ref(0, 0); + const water2 = map.ref(4, 0); + const water3 = map.ref(0, 4); + const water4 = map.ref(4, 4); + + expect(map.isWater(water1)).toBe(true); + expect(map.isWater(water2)).toBe(true); + expect(map.isWater(water3)).toBe(true); + expect(map.isWater(water4)).toBe(true); + + const id1 = wc.getComponentId(water1); + const id2 = wc.getComponentId(water2); + const id3 = wc.getComponentId(water3); + const id4 = wc.getComponentId(water4); + + expect(id1).toBe(1); + expect(id2).toBe(id1); + expect(id3).toBe(id1); + expect(id4).toBe(id1); + }); + + it("returns different component IDs for disconnected water areas", () => { + const map = createGameMap(twoComponentsMapData); + const wc = new ConnectedComponents(map); + wc.initialize(); + + const leftWater1 = map.ref(0, 0); + const leftWater2 = map.ref(1, 2); + const rightWater1 = map.ref(5, 0); + const rightWater2 = map.ref(6, 4); + + expect(map.isWater(leftWater1)).toBe(true); + expect(map.isWater(leftWater2)).toBe(true); + expect(map.isWater(rightWater1)).toBe(true); + expect(map.isWater(rightWater2)).toBe(true); + + const leftId1 = wc.getComponentId(leftWater1); + const leftId2 = wc.getComponentId(leftWater2); + const rightId1 = wc.getComponentId(rightWater1); + const rightId2 = wc.getComponentId(rightWater2); + + expect(leftId1).not.toBe(rightId1); + + expect(leftId1).toBe(leftId2); + expect(leftId1).toBeGreaterThan(0); + expect(leftId1).not.toBe(LAND_MARKER); + + expect(rightId1).toBe(rightId2); + expect(rightId1).toBeGreaterThan(0); + expect(rightId1).not.toBe(LAND_MARKER); + }); + + it("returns LAND_MARKER for land tiles", () => { + const map = createGameMap(twoComponentsMapData); + const wc = new ConnectedComponents(map); + wc.initialize(); + + const landTile1 = map.ref(2, 0); + const landTile2 = map.ref(3, 2); + const landTile3 = map.ref(4, 4); + + expect(map.isLand(landTile1)).toBe(true); + expect(map.isLand(landTile2)).toBe(true); + expect(map.isLand(landTile3)).toBe(true); + + expect(wc.getComponentId(landTile1)).toBe(LAND_MARKER); + expect(wc.getComponentId(landTile2)).toBe(LAND_MARKER); + expect(wc.getComponentId(landTile3)).toBe(LAND_MARKER); + }); + }); + + describe("determinism", () => { + it("produces same component IDs on repeated initialization", () => { + const map = createGameMap(twoComponentsMapData); + const wc1 = new ConnectedComponents(map); + const wc2 = new ConnectedComponents(map); + + wc1.initialize(); + wc2.initialize(); + + // Check all tiles have same component ID + for (let y = 0; y < 5; y++) { + for (let x = 0; x < 7; x++) { + const tile = map.ref(x, y); + expect(wc1.getComponentId(tile)).toBe(wc2.getComponentId(tile)); + } + } + }); + }); + + describe("direct terrain access optimization", () => { + it("produces same results with accessTerrainDirectly=false", () => { + const map = createGameMap(twoComponentsMapData); + const wcDirect = new ConnectedComponents(map, true); + const wcIndirect = new ConnectedComponents(map, false); + + wcDirect.initialize(); + wcIndirect.initialize(); + + // Check all tiles have same component ID + for (let y = 0; y < 5; y++) { + for (let x = 0; x < 7; x++) { + const tile = map.ref(x, y); + expect(wcDirect.getComponentId(tile)).toBe( + wcIndirect.getComponentId(tile), + ); + } + } + }); + }); +}); diff --git a/tests/core/pathfinding/_fixtures.ts b/tests/core/pathfinding/_fixtures.ts new file mode 100644 index 000000000..3e444744f --- /dev/null +++ b/tests/core/pathfinding/_fixtures.ts @@ -0,0 +1,179 @@ +// Minimal test maps for pathfinding unit tests + +import { + Difficulty, + Game, + GameMapSize, + GameMapType, + GameMode, + GameType, +} from "../../../src/core/game/Game"; +import { createGame as createGameImpl } from "../../../src/core/game/GameImpl"; +import { GameMapImpl } from "../../../src/core/game/GameMap"; +import { UserSettings } from "../../../src/core/game/UserSettings"; +import { TestConfig } from "../../util/TestConfig"; +import { TestServerConfig } from "../../util/TestServerConfig"; + +export const W = "W"; // Water +export const L = "L"; // Land + +// Terrain encoding +const WATER_BIT = 0x20; +const LAND_BIT = 0x80; +const SHORELINE_BIT = 6; + +export type TestMapData = { + width: number; + height: number; + grid: string[]; +}; + +// Compute shoreline bit for tiles adjacent to opposite terrain +function computeShoreline( + terrain: Uint8Array, + width: number, + height: number, +): void { + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = y * width + x; + const isLand = (terrain[idx] & LAND_BIT) !== 0; + const neighbors = [ + [x - 1, y], + [x + 1, y], + [x, y - 1], + [x, y + 1], + ]; + + for (const [nx, ny] of neighbors) { + if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; + const neighborIsLand = (terrain[ny * width + nx] & LAND_BIT) !== 0; + if (isLand !== neighborIsLand) { + terrain[idx] |= 1 << SHORELINE_BIT; + break; + } + } + } + } +} + +// 5x5 simple island +export function createIslandMap(): TestMapData { + // prettier-ignore + const grid = [ + W, W, W, W, W, + W, L, L, L, W, + W, L, L, L, W, + W, L, L, L, W, + W, W, W, W, W, + ]; + return { width: 5, height: 5, grid }; +} + +// Create Game from test map data (computes shoreline bits) +export function createGame(data: TestMapData): Game { + const { width, height, grid } = data; + + // Convert string grid to terrain bytes + const terrain = new Uint8Array(width * height); + let numLand = 0; + + for (let i = 0; i < grid.length; i++) { + if (grid[i] === L) { + terrain[i] = LAND_BIT; + numLand++; + } else { + terrain[i] = WATER_BIT; + } + } + + computeShoreline(terrain, width, height); + + const gameMap = new GameMapImpl(width, height, terrain, numLand); + + // Create miniMap (2x2→1, water if ANY water) + const miniWidth = Math.ceil(width / 2); + const miniHeight = Math.ceil(height / 2); + const miniTerrain = new Uint8Array(miniWidth * miniHeight); + let miniNumLand = 0; + + for (let my = 0; my < miniHeight; my++) { + for (let mx = 0; mx < miniWidth; mx++) { + const mIdx = my * miniWidth + mx; + let hasWater = false; + + for (let dy = 0; dy < 2; dy++) { + for (let dx = 0; dx < 2; dx++) { + const x = mx * 2 + dx; + const y = my * 2 + dy; + if (x < width && y < height && !(terrain[y * width + x] & LAND_BIT)) { + hasWater = true; + } + } + } + + if (hasWater) { + miniTerrain[mIdx] = WATER_BIT; + } else { + miniTerrain[mIdx] = LAND_BIT; + miniNumLand++; + } + } + } + + computeShoreline(miniTerrain, miniWidth, miniHeight); + + const miniGameMap = new GameMapImpl( + miniWidth, + miniHeight, + miniTerrain, + miniNumLand, + ); + + const serverConfig = new TestServerConfig(); + const gameConfig = { + gameMap: GameMapType.Asia, + gameMapSize: GameMapSize.Normal, + gameMode: GameMode.FFA, + gameType: GameType.Singleplayer, + difficulty: Difficulty.Medium, + disableNations: false, + donateGold: false, + donateTroops: false, + bots: 0, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + disableNavMesh: false, + randomSpawn: false, + }; + const config = new TestConfig( + serverConfig, + gameConfig, + new UserSettings(), + false, + ); + + return createGameImpl([], [], gameMap, miniGameMap, config); +} + +// Create GameMapImpl from test map data (for map-only tests) +export function createGameMap(data: TestMapData): GameMapImpl { + const { width, height, grid } = data; + + const terrain = new Uint8Array(width * height); + let numLand = 0; + + for (let i = 0; i < grid.length; i++) { + if (grid[i] === L) { + terrain[i] = LAND_BIT; + numLand++; + } else { + terrain[i] = WATER_BIT; + } + } + + computeShoreline(terrain, width, height); + + return new GameMapImpl(width, height, terrain, numLand); +} diff --git a/tests/core/pathfinding/transformers/ComponentCheckTransformer.test.ts b/tests/core/pathfinding/transformers/ComponentCheckTransformer.test.ts new file mode 100644 index 000000000..9ab4397cf --- /dev/null +++ b/tests/core/pathfinding/transformers/ComponentCheckTransformer.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from "vitest"; +import { ComponentCheckTransformer } from "../../../../src/core/pathfinding/transformers/ComponentCheckTransformer"; +import { PathFinder } from "../../../../src/core/pathfinding/types"; + +describe("ComponentCheckTransformer", () => { + // Mock PathFinder that records calls and returns a simple path + function createMockPathFinder(): PathFinder & { + calls: Array<{ from: number | number[]; to: number }>; + } { + const calls: Array<{ from: number | number[]; to: number }> = []; + + return { + calls, + findPath(from: number | number[], to: number): number[] | null { + calls.push({ from, to }); + const start = Array.isArray(from) ? from[0] : from; + return [start, to]; + }, + }; + } + + // Component function: even numbers → component 0, odd → component 1 + const evenOddComponent = (t: number) => t % 2; + + describe("findPath", () => { + it("delegates when source and destination in same component", () => { + const inner = createMockPathFinder(); + const transformer = new ComponentCheckTransformer( + inner, + evenOddComponent, + ); + + const result = transformer.findPath(2, 4); // both even → component 0 + + expect(result).toEqual([2, 4]); + expect(inner.calls).toHaveLength(1); + expect(inner.calls[0]).toEqual({ from: 2, to: 4 }); + }); + + it("returns null when source and destination in different components", () => { + const inner = createMockPathFinder(); + const transformer = new ComponentCheckTransformer( + inner, + evenOddComponent, + ); + + const result = transformer.findPath(2, 3); // even → odd + + expect(result).toBeNull(); + expect(inner.calls).toHaveLength(0); // inner not called + }); + + it("filters multiple sources to only valid ones", () => { + const inner = createMockPathFinder(); + const transformer = new ComponentCheckTransformer( + inner, + evenOddComponent, + ); + + // Sources: 1, 2, 3, 4 → odd, even, odd, even + // Destination: 4 → even + // Valid sources: 2, 4 + const result = transformer.findPath([1, 2, 3, 4], 4); + + expect(result).not.toBeNull(); + expect(inner.calls).toHaveLength(1); + expect(inner.calls[0].from).toEqual([2, 4]); // filtered to valid + expect(inner.calls[0].to).toBe(4); + }); + + it("returns null when no source in same component", () => { + const inner = createMockPathFinder(); + const transformer = new ComponentCheckTransformer( + inner, + evenOddComponent, + ); + + // All sources odd, destination even + const result = transformer.findPath([1, 3, 5], 4); + + expect(result).toBeNull(); + expect(inner.calls).toHaveLength(0); + }); + + it("unwraps single valid source from array", () => { + const inner = createMockPathFinder(); + const transformer = new ComponentCheckTransformer( + inner, + evenOddComponent, + ); + + // Only one source matches + const result = transformer.findPath([1, 2, 3], 4); + + expect(result).not.toBeNull(); + expect(inner.calls).toHaveLength(1); + expect(inner.calls[0].from).toBe(2); + }); + + it("handles single source (not array)", () => { + const inner = createMockPathFinder(); + const transformer = new ComponentCheckTransformer( + inner, + evenOddComponent, + ); + + const result = transformer.findPath(4, 6); + + expect(result).toEqual([4, 6]); + expect(inner.calls[0].from).toBe(4); + }); + + it("propagates null from inner pathfinder", () => { + const inner: PathFinder = { + findPath: () => null, + }; + const transformer = new ComponentCheckTransformer( + inner, + evenOddComponent, + ); + + const result = transformer.findPath(2, 4); + + expect(result).toBeNull(); + }); + + it("propagates path from inner pathfinder", () => { + const inner: PathFinder = { + findPath: () => [10, 20, 30, 40], + }; + const transformer = new ComponentCheckTransformer( + inner, + evenOddComponent, + ); + + const result = transformer.findPath(2, 4); + + expect(result).toEqual([10, 20, 30, 40]); + }); + }); + + describe("edge cases", () => { + it("handles empty source array", () => { + const inner = createMockPathFinder(); + const transformer = new ComponentCheckTransformer( + inner, + evenOddComponent, + ); + + const result = transformer.findPath([], 4); + + expect(result).toBeNull(); + expect(inner.calls).toHaveLength(0); + }); + + it("works with custom component function", () => { + const inner = createMockPathFinder(); + // Component by tens digit: 10-19 → 1, 20-29 → 2, etc. + const tensComponent = (t: number) => Math.floor(t / 10); + const transformer = new ComponentCheckTransformer(inner, tensComponent); + + // Same component + expect(transformer.findPath(15, 18)).not.toBeNull(); + + // Different component + expect(transformer.findPath(15, 25)).toBeNull(); + }); + }); +}); diff --git a/tests/core/pathfinding/transformers/MiniMapTransformer.test.ts b/tests/core/pathfinding/transformers/MiniMapTransformer.test.ts new file mode 100644 index 000000000..9f2cf0e12 --- /dev/null +++ b/tests/core/pathfinding/transformers/MiniMapTransformer.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; +import { GameMapImpl } from "../../../../src/core/game/GameMap"; +import { MiniMapTransformer } from "../../../../src/core/pathfinding/transformers/MiniMapTransformer"; +import { PathFinder } from "../../../../src/core/pathfinding/types"; + +describe("MiniMapTransformer", () => { + // Create test maps: main map is 10x10, minimap is 5x5 (2x downscale) + function createTestMaps() { + const W = 0x20; // Water + const mainTerrain = new Uint8Array(100).fill(W); // 10x10 all water + const miniTerrain = new Uint8Array(25).fill(W); // 5x5 all water + + const map = new GameMapImpl(10, 10, mainTerrain, 0); + const miniMap = new GameMapImpl(5, 5, miniTerrain, 0); + + return { map, miniMap }; + } + + function createMockPathFinder(): PathFinder & { + calls: Array<{ from: number | number[]; to: number }>; + returnPath: number[] | null | undefined; + } { + const mock = { + calls: [] as Array<{ from: number | number[]; to: number }>, + returnPath: undefined as number[] | null | undefined, + findPath(from: number | number[], to: number): number[] | null { + mock.calls.push({ from, to }); + if (mock.returnPath !== undefined) return mock.returnPath; + const start = Array.isArray(from) ? from[0] : from; + return [start, to]; + }, + }; + return mock; + } + + describe("findPath", () => { + it("converts coordinates to minimap scale", () => { + const { map, miniMap } = createTestMaps(); + const inner = createMockPathFinder(); + const transformer = new MiniMapTransformer(inner, map, miniMap); + + const from = map.ref(4, 6); + const to = map.ref(8, 2); + + const miniFrom = miniMap.ref(2, 3); + const miniTo = miniMap.ref(4, 1); + inner.returnPath = [miniFrom, miniTo]; + + transformer.findPath(from, to); + + expect(inner.calls).toHaveLength(1); + expect(inner.calls[0].from).toBe(miniFrom); + expect(inner.calls[0].to).toBe(miniTo); + }); + + it("upscales minimap path back to full resolution", () => { + const { map, miniMap } = createTestMaps(); + const inner = createMockPathFinder(); + const transformer = new MiniMapTransformer(inner, map, miniMap); + + const from = map.ref(0, 0); + const to = map.ref(8, 0); + + // Minimap path: (0,0) → (4,0) - straight horizontal + inner.returnPath = [ + miniMap.ref(0, 0), + miniMap.ref(1, 0), + miniMap.ref(2, 0), + miniMap.ref(3, 0), + miniMap.ref(4, 0), + ]; + + const result = transformer.findPath(from, to); + + expect(result).not.toBeNull(); + expect(result![0]).toBe(from); + expect(result![result!.length - 1]).toBe(to); + }); + + it("returns null when inner returns null", () => { + const { map, miniMap } = createTestMaps(); + const inner = createMockPathFinder(); + inner.returnPath = null; + const transformer = new MiniMapTransformer(inner, map, miniMap); + + const result = transformer.findPath(map.ref(0, 0), map.ref(8, 8)); + + expect(result).toBeNull(); + }); + + it("returns null when inner returns empty path", () => { + const { map, miniMap } = createTestMaps(); + const inner = createMockPathFinder(); + inner.returnPath = []; + const transformer = new MiniMapTransformer(inner, map, miniMap); + + const result = transformer.findPath(map.ref(0, 0), map.ref(8, 8)); + + expect(result).toBeNull(); + }); + + it("handles multiple sources", () => { + const { map, miniMap } = createTestMaps(); + const inner = createMockPathFinder(); + const transformer = new MiniMapTransformer(inner, map, miniMap); + + const from1 = map.ref(0, 0); + const from2 = map.ref(2, 0); + const to = map.ref(8, 0); + + inner.returnPath = [miniMap.ref(0, 0), miniMap.ref(4, 0)]; + + const result = transformer.findPath([from1, from2], to); + + expect(inner.calls).toHaveLength(1); + expect(Array.isArray(inner.calls[0].from)).toBe(true); + expect(result).not.toBeNull(); + }); + + it("fixes path extremes to match original from/to", () => { + const { map, miniMap } = createTestMaps(); + const inner = createMockPathFinder(); + const transformer = new MiniMapTransformer(inner, map, miniMap); + + // From odd coords - won't exactly map to minimap + const from = map.ref(1, 1); + const to = map.ref(9, 9); + + inner.returnPath = [miniMap.ref(0, 0), miniMap.ref(4, 4)]; + + const result = transformer.findPath(from, to); + + expect(result).not.toBeNull(); + expect(result![0]).toBe(from); + expect(result![result!.length - 1]).toBe(to); + }); + }); + + describe("coordinate mapping", () => { + it("maps main coords (0,0) to mini coords (0,0)", () => { + const { map, miniMap } = createTestMaps(); + const inner = createMockPathFinder(); + const transformer = new MiniMapTransformer(inner, map, miniMap); + + inner.returnPath = [miniMap.ref(0, 0)]; + + transformer.findPath(map.ref(0, 0), map.ref(0, 0)); + + expect(inner.calls[0].from).toBe(miniMap.ref(0, 0)); + expect(inner.calls[0].to).toBe(miniMap.ref(0, 0)); + }); + + it("maps main coords (1,1) to mini coords (0,0) (floor division)", () => { + const { map, miniMap } = createTestMaps(); + const inner = createMockPathFinder(); + const transformer = new MiniMapTransformer(inner, map, miniMap); + + inner.returnPath = [miniMap.ref(0, 0)]; + + transformer.findPath(map.ref(1, 1), map.ref(1, 1)); + + expect(inner.calls[0].from).toBe(miniMap.ref(0, 0)); + expect(inner.calls[0].to).toBe(miniMap.ref(0, 0)); + }); + + it("maps main coords (2,2) to mini coords (1,1)", () => { + const { map, miniMap } = createTestMaps(); + const inner = createMockPathFinder(); + const transformer = new MiniMapTransformer(inner, map, miniMap); + + inner.returnPath = [miniMap.ref(1, 1)]; + + transformer.findPath(map.ref(2, 2), map.ref(2, 2)); + + expect(inner.calls[0].from).toBe(miniMap.ref(1, 1)); + expect(inner.calls[0].to).toBe(miniMap.ref(1, 1)); + }); + }); +}); diff --git a/tests/core/pathfinding/transformers/ShoreCoercingTransformer.test.ts b/tests/core/pathfinding/transformers/ShoreCoercingTransformer.test.ts new file mode 100644 index 000000000..bf51248a8 --- /dev/null +++ b/tests/core/pathfinding/transformers/ShoreCoercingTransformer.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from "vitest"; +import { ShoreCoercingTransformer } from "../../../../src/core/pathfinding/transformers/ShoreCoercingTransformer"; +import { PathFinder } from "../../../../src/core/pathfinding/types"; +import { createGameMap, createIslandMap, L, W } from "../_fixtures"; + +describe("ShoreCoercingTransformer", () => { + // Mock PathFinder that records calls and returns configurable path + function createMockPathFinder(): PathFinder & { + calls: Array<{ from: number | number[]; to: number }>; + returnPath: number[] | null | undefined; + } { + const mock = { + calls: [] as Array<{ from: number | number[]; to: number }>, + returnPath: undefined as number[] | null | undefined, + findPath(from: number | number[], to: number): number[] | null { + mock.calls.push({ from, to }); + if (mock.returnPath !== undefined) return mock.returnPath; + const start = Array.isArray(from) ? from[0] : from; + return [start, to]; + }, + }; + return mock; + } + + describe("findPath", () => { + it("passes water tiles unchanged", () => { + const mapData = createIslandMap(); + const map = createGameMap(mapData); + const inner = createMockPathFinder(); + const transformer = new ShoreCoercingTransformer(inner, map); + + const water1 = map.ref(0, 0); + const water2 = map.ref(4, 0); + inner.returnPath = [water1, water2]; + + const result = transformer.findPath(water1, water2); + + expect(result).toEqual([water1, water2]); + expect(inner.calls).toHaveLength(1); + expect(inner.calls[0].from).toBe(water1); + expect(inner.calls[0].to).toBe(water2); + }); + + it("coerces shore start to water and prepends original", () => { + const mapData = createIslandMap(); + const map = createGameMap(mapData); + const inner = createMockPathFinder(); + const transformer = new ShoreCoercingTransformer(inner, map); + + const shore = map.ref(1, 1); + const water = map.ref(4, 4); + const shoreWaterNeighbor = map.ref(1, 0); + + const result = transformer.findPath(shore, water); + + expect(result).not.toBeNull(); + expect(result![0]).toBe(shore); + expect(result![1]).toBe(shoreWaterNeighbor); + }); + + it("coerces shore destination to water and appends original", () => { + const mapData = createIslandMap(); + const map = createGameMap(mapData); + const inner = createMockPathFinder(); + const transformer = new ShoreCoercingTransformer(inner, map); + + const water = map.ref(0, 0); + const shore = map.ref(1, 1); + const shoreWaterNeighbor = map.ref(1, 0); + + const result = transformer.findPath(water, shore); + + expect(result).not.toBeNull(); + expect(result![0]).toBe(water); + expect(result![result!.length - 2]).toBe(shoreWaterNeighbor); + expect(result![result!.length - 1]).toBe(shore); + }); + + it("coerces both shore start and destination", () => { + const mapData = createIslandMap(); + const map = createGameMap(mapData); + const inner = createMockPathFinder(); + const transformer = new ShoreCoercingTransformer(inner, map); + + const shore1 = map.ref(1, 1); + const shore1WaterNeighbor = map.ref(1, 0); + const shore2 = map.ref(3, 3); + const shore2WaterNeighbor = map.ref(3, 4); + + const result = transformer.findPath(shore1, shore2); + + expect(result).not.toBeNull(); + expect(result![0]).toBe(shore1); + expect(result![1]).toBe(shore1WaterNeighbor); + expect(result![result!.length - 2]).toBe(shore2WaterNeighbor); + expect(result![result!.length - 1]).toBe(shore2); + }); + + it("returns null when source has no water neighbor", () => { + const mapData = createIslandMap(); + const map = createGameMap(mapData); + const inner = createMockPathFinder(); + const transformer = new ShoreCoercingTransformer(inner, map); + + // Center land tile (2,2) has no water neighbors + const land = map.ref(2, 2); + const water = map.ref(0, 0); + + const result = transformer.findPath(land, water); + + expect(result).toBeNull(); + expect(inner.calls).toHaveLength(0); + }); + + it("returns null when destination has no water neighbor", () => { + const mapData = createIslandMap(); + const map = createGameMap(mapData); + const inner = createMockPathFinder(); + const transformer = new ShoreCoercingTransformer(inner, map); + + // Center land tile (2,2) has no water neighbors + const land = map.ref(2, 2); + const water = map.ref(0, 0); + + const result = transformer.findPath(water, land); + + expect(result).toBeNull(); + expect(inner.calls).toHaveLength(0); + }); + + it("returns null when inner pathfinder returns null", () => { + const mapData = createIslandMap(); + const map = createGameMap(mapData); + const inner = createMockPathFinder(); + const transformer = new ShoreCoercingTransformer(inner, map); + + inner.returnPath = null; + const result = transformer.findPath(map.ref(0, 0), map.ref(4, 4)); + + expect(result).toBeNull(); + }); + + it("returns null when inner pathfinder returns empty path", () => { + const mapData = createIslandMap(); + const map = createGameMap(mapData); + const inner = createMockPathFinder(); + const transformer = new ShoreCoercingTransformer(inner, map); + + inner.returnPath = []; + const result = transformer.findPath(map.ref(0, 0), map.ref(4, 4)); + + expect(result).toBeNull(); + }); + + it("handles multiple sources, filters invalid ones", () => { + const mapData = createIslandMap(); + const map = createGameMap(mapData); + const inner = createMockPathFinder(); + const transformer = new ShoreCoercingTransformer(inner, map); + + const waterSrc = map.ref(0, 0); + const shoreSrc = map.ref(1, 1); + const landSrc = map.ref(2, 2); + const waterDest = map.ref(4, 4); + + inner.returnPath = [waterSrc, waterDest]; + + const result = transformer.findPath( + [waterSrc, shoreSrc, landSrc], + waterDest, + ); + + expect(result).not.toBeNull(); + expect(inner.calls).toHaveLength(1); + + const fromArg = inner.calls[0].from; + expect(Array.isArray(fromArg)).toBe(true); + expect((fromArg as number[]).length).toBe(2); + }); + + it("returns null when all sources are invalid", () => { + const mapData = createIslandMap(); + const map = createGameMap(mapData); + const inner = createMockPathFinder(); + const transformer = new ShoreCoercingTransformer(inner, map); + + const land = map.ref(2, 2); + + const result = transformer.findPath([land], map.ref(0, 0)); + + expect(result).toBeNull(); + expect(inner.calls).toHaveLength(0); + }); + }); + + describe("determinism", () => { + it("shore with multiple water neighbors selects consistently", () => { + // prettier-ignore + const map = createGameMap({ + width: 5, height: 5, grid: [ + L, L, W, W, W, + L, L, W, W, W, + L, L, W, L, L, + W, W, W, L, L, + W, W, W, L, L, + ], + }); + + const shoreWithMultipleWater = map.ref(1, 2); + const expectedWaterNeighbor = map.ref(1, 3); + + const inner1 = createMockPathFinder(); + const inner2 = createMockPathFinder(); + const transformer1 = new ShoreCoercingTransformer(inner1, map); + const transformer2 = new ShoreCoercingTransformer(inner2, map); + + const waterDest = map.ref(2, 4); + + transformer1.findPath(shoreWithMultipleWater, waterDest); + transformer2.findPath(shoreWithMultipleWater, waterDest); + + // Both select the same water neighbor: (1,3) + expect(inner1.calls[0].from).toBe(expectedWaterNeighbor); + expect(inner2.calls[0].from).toBe(expectedWaterNeighbor); + }); + + it("corner shore with water neighbors works correctly", () => { + const mapData = createIslandMap(); + const map = createGameMap(mapData); + const inner = createMockPathFinder(); + const transformer = new ShoreCoercingTransformer(inner, map); + + const cornerShore = map.ref(1, 1); + const waterNeighbor = map.ref(1, 0); + const waterDest = map.ref(4, 4); + + inner.returnPath = [waterNeighbor, waterDest]; + + const result = transformer.findPath(cornerShore, waterDest); + + expect(result).not.toBeNull(); + expect(result).toEqual([cornerShore, waterNeighbor, waterDest]); + }); + }); +}); diff --git a/tests/core/pathfinding/utils.ts b/tests/core/pathfinding/utils.ts deleted file mode 100644 index b8f19be04..000000000 --- a/tests/core/pathfinding/utils.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { - Difficulty, - Game, - GameMapSize, - GameMapType, - GameMode, - GameType, -} from "../../../src/core/game/Game"; -import { createGame } from "../../../src/core/game/GameImpl"; -import { GameMapImpl } from "../../../src/core/game/GameMap"; -import { UserSettings } from "../../../src/core/game/UserSettings"; -import { TestConfig } from "../../util/TestConfig"; -import { TestServerConfig } from "../../util/TestServerConfig"; - -const LAND_BIT = 7; -const OCEAN_BIT = 5; - -/** - * Creates a Game from inline map strings. - * Each char = 1 tile: W=water (ocean), L=land - * miniMap automatically generated (2x2→1, water if ANY tile water) - * - * Example: - * const game = await gameFromString([ - * 'WWWWW', - * 'WLLLW', - * 'WWWWW' - * ]); - */ -export async function gameFromString(mapRows: string[]): Promise { - const height = mapRows.length; - const width = mapRows[0].length; - - for (const row of mapRows) { - if (row.length !== width) { - throw new Error( - `All rows must have same width. Expected ${width}, got ${row.length}`, - ); - } - } - - const terrainData = new Uint8Array(width * height); - let numLandTiles = 0; - - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const idx = y * width + x; - const char = mapRows[y][x]; - - if (char === "L") { - terrainData[idx] = 1 << LAND_BIT; // Set land bit - numLandTiles++; - } else if (char === "W") { - terrainData[idx] = 1 << OCEAN_BIT; // Set ocean bit (water) - } else { - throw new Error( - `Unknown char '${char}' at (${x},${y}). Use W=water, L=land`, - ); - } - } - } - - const gameMap = new GameMapImpl(width, height, terrainData, numLandTiles); - - // Create miniMap (2x2→1, water if ANY tile water) - const miniWidth = Math.ceil(width / 2); - const miniHeight = Math.ceil(height / 2); - const miniTerrainData = new Uint8Array(miniWidth * miniHeight); - let miniNumLandTiles = 0; - - for (let miniY = 0; miniY < miniHeight; miniY++) { - for (let miniX = 0; miniX < miniWidth; miniX++) { - const miniIdx = miniY * miniWidth + miniX; - - // Check 2x2 chunk: if ANY tile is water, miniMap tile is water - let water = false; - - for (let dy = 0; dy < 2; dy++) { - for (let dx = 0; dx < 2; dx++) { - const x = miniX * 2 + dx; - const y = miniY * 2 + dy; - - if (x < width && y < height) { - const idx = y * width + x; - if (!(terrainData[idx] & (1 << LAND_BIT))) { - water = true; - } - } - } - } - - // Water if ANY tile is water - if (water) { - miniTerrainData[miniIdx] = 1 << OCEAN_BIT; // ocean - } else { - miniTerrainData[miniIdx] = 1 << LAND_BIT; // land - miniNumLandTiles++; - } - } - } - - const miniGameMap = new GameMapImpl( - miniWidth, - miniHeight, - miniTerrainData, - miniNumLandTiles, - ); - - // Create game config - const serverConfig = new TestServerConfig(); - const gameConfig = { - gameMap: GameMapType.Asia, - gameMapSize: GameMapSize.Normal, - gameMode: GameMode.FFA, - gameType: GameType.Singleplayer, - difficulty: Difficulty.Medium, - disableNations: false, - donateGold: false, - donateTroops: false, - bots: 0, - infiniteGold: false, - infiniteTroops: false, - instantBuild: false, - disableNavMesh: false, - randomSpawn: false, - }; - const config = new TestConfig( - serverConfig, - gameConfig, - new UserSettings(), - false, - ); - - return createGame([], [], gameMap, miniGameMap, config); -} diff --git a/tests/pathfinding/benchmark/compare.ts b/tests/pathfinding/benchmark/compare.ts new file mode 100644 index 000000000..e39adb49d --- /dev/null +++ b/tests/pathfinding/benchmark/compare.ts @@ -0,0 +1,180 @@ +#!/usr/bin/env node + +/** + * Compare pathfinding adapters side-by-side + * + * Usage: + * npx tsx tests/pathfinding/benchmark/compare.ts + * npx tsx tests/pathfinding/benchmark/compare.ts --synthetic + * + * Examples: + * npx tsx tests/pathfinding/benchmark/compare.ts default hpa,a.baseline + * npx tsx tests/pathfinding/benchmark/compare.ts --synthetic giantworldmap hpa,hpa.cached,a.full + */ + +import { + type BenchmarkResult, + calculateStats, + getAdapter, + getScenario, + measureExecutionTime, + measurePathLength, +} from "../utils"; + +interface AdapterResults { + adapter: string; + initTime: number; + totalTime: number; + totalDistance: number; + successfulRoutes: number; + totalRoutes: number; +} + +const DEFAULT_ITERATIONS = 1; + +async function runBenchmark( + scenarioName: string, + adapterName: string, +): Promise { + const { game, routes, initTime } = await getScenario( + scenarioName, + adapterName, + ); + const adapter = getAdapter(game, adapterName); + + const results: BenchmarkResult[] = []; + + // Measure path lengths + for (const route of routes) { + const pathLength = measurePathLength(adapter, route); + results.push({ route: route.name, pathLength, executionTime: null }); + } + + // Measure execution times + for (const route of routes) { + const result = results.find((r) => r.route === route.name); + if (result && result.pathLength !== null) { + const execTime = measureExecutionTime(adapter, route, DEFAULT_ITERATIONS); + result.executionTime = execTime; + } + } + + const stats = calculateStats(results); + + return { + adapter: adapterName, + initTime, + totalTime: stats.totalTime, + totalDistance: stats.totalDistance, + successfulRoutes: stats.successfulRoutes, + totalRoutes: stats.totalRoutes, + }; +} + +const TABLE_HEADERS = [ + "Adapter", + "Init (ms)", + "Path (ms)", + "Distance", + "Routes", +]; + +const TABLE_WIDTHS = [20, 12, 12, 12, 10]; + +function printTableHeader(scenarioName: string) { + console.log(`\nResults: ${scenarioName}`); + console.log("=".repeat(70)); + console.log(TABLE_HEADERS.map((h, i) => h.padEnd(TABLE_WIDTHS[i])).join(" ")); + console.log("-".repeat(70)); +} + +function printTableRow(r: AdapterResults) { + const row = [ + r.adapter, + r.initTime.toFixed(2), + r.totalTime.toFixed(2), + r.totalDistance.toString(), + `${r.successfulRoutes}/${r.totalRoutes}`, + ]; + console.log(row.map((c, i) => c.padEnd(TABLE_WIDTHS[i])).join(" ")); +} + +function printTableFooter() { + console.log("-".repeat(70)); +} + +function printUsage() { + console.log(` +Usage: + npx tsx tests/pathfinding/benchmark/compare.ts + npx tsx tests/pathfinding/benchmark/compare.ts --synthetic + +Arguments: + Name of the scenario (default: "default") + Comma-separated list of adapters to compare (e.g., "hpa,a.baseline") + +Examples: + npx tsx tests/pathfinding/benchmark/compare.ts default hpa,a.baseline + npx tsx tests/pathfinding/benchmark/compare.ts --synthetic giantworldmap hpa,hpa.cached,a.full + +Available adapters: + a.baseline - A* on minimap (inlined) + a.generic - A* on minimap (adapter) + a.full - A* on full map + hpa - Hierarchical pathfinding (no cache) + hpa.cached - Hierarchical pathfinding (with cache) +`); +} + +async function main() { + const args = process.argv.slice(2); + + if (args.includes("--help") || args.includes("-h")) { + printUsage(); + process.exit(0); + } + + const isSynthetic = args.includes("--synthetic"); + const nonFlagArgs = args.filter((arg) => !arg.startsWith("--")); + + if (nonFlagArgs.length < 2) { + console.error("Error: requires and arguments"); + printUsage(); + process.exit(1); + } + + const scenarioArg = nonFlagArgs[0]; + const adaptersArg = nonFlagArgs[1]; + const adapters = adaptersArg.split(",").map((a) => a.trim()); + + if (adapters.length < 1) { + console.error("Error: at least one adapter required"); + process.exit(1); + } + + const scenarioName = isSynthetic ? `synthetic/${scenarioArg}` : scenarioArg; + + console.log( + `Comparing ${adapters.length} adapters on scenario: ${scenarioName}`, + ); + console.log(`Adapters: ${adapters.join(", ")}`); + console.log(""); + + printTableHeader(scenarioName); + + for (const adapter of adapters) { + try { + const result = await runBenchmark(scenarioName, adapter); + printTableRow(result); + } catch (error) { + console.log(`${adapter.padEnd(TABLE_WIDTHS[0])} FAILED: ${error}`); + } + } + + printTableFooter(); +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/tests/pathfinding/playground/api/maps.ts b/tests/pathfinding/playground/api/maps.ts index 7d1776931..4bafc6fed 100644 --- a/tests/pathfinding/playground/api/maps.ts +++ b/tests/pathfinding/playground/api/maps.ts @@ -2,10 +2,13 @@ import { readdirSync, readFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; import { Game } from "../../../../src/core/game/Game.js"; -import { TileRef } from "../../../../src/core/game/GameMap.js"; -import { NavMesh } from "../../../../src/core/pathfinding/navmesh/NavMesh.js"; +import { AStarWaterHierarchical } from "../../../../src/core/pathfinding/algorithms/AStar.WaterHierarchical.js"; import { setupFromPath } from "../../utils.js"; +// Available comparison adapters +// Note: "hpa" runs same algorithm without debug overhead for fair timing comparison +export const COMPARISON_ADAPTERS = ["hpa", "a.baseline", "a.generic", "a.full"]; + export interface MapInfo { name: string; displayName: string; @@ -13,7 +16,7 @@ export interface MapInfo { export interface MapCache { game: Game; - navMesh: NavMesh; + hpaStar: AStarWaterHierarchical; } const cache = new Map(); @@ -114,13 +117,20 @@ export async function loadMap(mapName: string): Promise { const mapsDir = getMapsDirectory(); // Use the existing setupFromPath utility to load the map - const game = await setupFromPath(mapsDir, mapName); + const game = await setupFromPath(mapsDir, mapName, { disableNavMesh: false }); - // Initialize NavMesh - const navMesh = new NavMesh(game, { cachePaths: config.cachePaths }); - navMesh.initialize(); + // Get pre-built graph from game + const graph = game.miniWaterGraph(); + if (!graph) { + throw new Error(`No water graph available for map: ${mapName}`); + } - const cacheEntry: MapCache = { game, navMesh }; + // Initialize AStarWaterHierarchical with minimap and graph + const hpaStar = new AStarWaterHierarchical(game.miniMap(), graph, { + cachePaths: config.cachePaths, + }); + + const cacheEntry: MapCache = { game, hpaStar }; // Store in cache cache.set(mapName, cacheEntry); @@ -132,7 +142,7 @@ export async function loadMap(mapName: string): Promise { * Get map metadata for client */ export async function getMapMetadata(mapName: string) { - const { game, navMesh } = await loadMap(mapName); + const { game, hpaStar } = await loadMap(mapName); // Extract map data const mapData: number[] = []; @@ -143,65 +153,48 @@ export async function getMapMetadata(mapName: string) { } } - // Extract static graph data from NavMesh + // Extract static graph data from GameMapHPAStar + // Access internal graph via type casting (test code only) + const graph = (hpaStar as any).graph; const miniMap = game.miniMap(); - const navMeshGraph = (navMesh as any).graph; - // Convert gateways from Map to array - const gatewaysArray = Array.from(navMeshGraph.gateways.values()); - const allGateways = gatewaysArray.map((gw: any) => ({ - id: gw.id, - x: miniMap.x(gw.tile), - y: miniMap.y(gw.tile), + // Convert nodes to client format + const allNodes = graph.getAllNodes().map((node: any) => ({ + id: node.id, + x: miniMap.x(node.tile), + y: miniMap.y(node.tile), })); - // Create a lookup map from gateway ID to gateway for edge conversion - const gatewayById = new Map(gatewaysArray.map((gw: any) => [gw.id, gw])); + // Convert edges to client format + const edges: Array<{ + fromId: number; + toId: number; + from: number[]; + to: number[]; + cost: number; + }> = []; + for (let i = 0; i < graph.edgeCount; i++) { + const edge = graph.getEdge(i); + if (!edge) continue; - // Convert edges from Map to flat array - // The edges Map has gateway IDs as keys, and arrays of edges as values - const allEdges: any[] = []; - for (const edgeArray of navMeshGraph.edges.values()) { - allEdges.push(...edgeArray); + const nodeA = graph.getNode(edge.nodeA); + const nodeB = graph.getNode(edge.nodeB); + if (!nodeA || !nodeB) continue; + + edges.push({ + fromId: edge.nodeA, + toId: edge.nodeB, + from: [miniMap.x(nodeA.tile) * 2, miniMap.y(nodeA.tile) * 2], + to: [miniMap.x(nodeB.tile) * 2, miniMap.y(nodeB.tile) * 2], + cost: edge.cost, + }); } - // Deduplicate edges (they're bidirectional, so each edge appears twice) - const seenEdges = new Set(); - const edges = allEdges - .filter((edge: any) => { - const edgeKey = - edge.from < edge.to - ? `${edge.from}-${edge.to}` - : `${edge.to}-${edge.from}`; - if (seenEdges.has(edgeKey)) return false; - seenEdges.add(edgeKey); - return true; - }) - .map((edge: any) => { - const fromGateway = gatewayById.get(edge.from); - const toGateway = gatewayById.get(edge.to); - - return { - fromId: edge.from, - toId: edge.to, - from: fromGateway - ? [miniMap.x(fromGateway.tile) * 2, miniMap.y(fromGateway.tile) * 2] - : [0, 0], - to: toGateway - ? [miniMap.x(toGateway.tile) * 2, miniMap.y(toGateway.tile) * 2] - : [0, 0], - cost: edge.cost, - path: edge.path - ? edge.path.map((tile: TileRef) => [game.x(tile), game.y(tile)]) - : null, - }; - }); - console.log( - `Map ${mapName}: ${allGateways.length} gateways, ${edges.length} edges`, + `Map ${mapName}: ${allNodes.length} nodes, ${edges.length} edges`, ); - const sectorSize = navMeshGraph.sectorSize; + const clusterSize = graph.clusterSize; return { name: mapName, @@ -209,10 +202,11 @@ export async function getMapMetadata(mapName: string) { height: game.height(), mapData, graphDebug: { - allGateways, + allNodes, edges, - sectorSize, + clusterSize, }, + adapters: COMPARISON_ADAPTERS, }; } diff --git a/tests/pathfinding/playground/api/pathfinding.ts b/tests/pathfinding/playground/api/pathfinding.ts index 102e83310..9cc69aa53 100644 --- a/tests/pathfinding/playground/api/pathfinding.ts +++ b/tests/pathfinding/playground/api/pathfinding.ts @@ -1,39 +1,64 @@ import { TileRef } from "../../../../src/core/game/GameMap.js"; -import { MiniAStarAdapter } from "../../../../src/core/pathfinding/adapters/MiniAStarAdapter.js"; -import { loadMap } from "./maps.js"; +import { AStarWaterHierarchical } from "../../../../src/core/pathfinding/algorithms/AStar.WaterHierarchical.js"; +import { BresenhamSmoothingTransformer } from "../../../../src/core/pathfinding/smoothing/BresenhamPathSmoother.js"; +import { ComponentCheckTransformer } from "../../../../src/core/pathfinding/transformers/ComponentCheckTransformer.js"; +import { MiniMapTransformer } from "../../../../src/core/pathfinding/transformers/MiniMapTransformer.js"; +import { ShoreCoercingTransformer } from "../../../../src/core/pathfinding/transformers/ShoreCoercingTransformer.js"; +import { + PathFinder, + SteppingPathFinder, +} from "../../../../src/core/pathfinding/types.js"; +import { getAdapter } from "../../utils.js"; +import { COMPARISON_ADAPTERS, loadMap } from "./maps.js"; -interface PathfindingOptions { - includePfMini?: boolean; - includeNavMesh?: boolean; -} - -interface NavMeshResult { +// Primary result with debug info +interface PrimaryResult { path: Array<[number, number]> | null; - initialPath: Array<[number, number]> | null; - gateways: Array<[number, number]> | null; - timings: any; length: number; time: number; + debug: { + nodePath: Array<[number, number]> | null; + initialPath: Array<[number, number]> | null; + timings: Record; + }; } -interface PfMiniResult { +// Comparison result (path + timing only) +interface ComparisonResult { + adapter: string; path: Array<[number, number]> | null; length: number; time: number; } -// Cache pathfinding adapters per map -const pfMiniCache = new Map(); +export interface PathfindResult { + primary: PrimaryResult; + comparisons: ComparisonResult[]; +} + +// Cache adapters per map +const adapterCache = new Map< + string, + Map> +>(); /** - * Get or create MiniAStar adapter for a map + * Get or create an adapter for a map */ -function getPfMiniAdapter(mapName: string, game: any): MiniAStarAdapter { - if (!pfMiniCache.has(mapName)) { - const adapter = new MiniAStarAdapter(game, { waterPath: true }); - pfMiniCache.set(mapName, adapter); +function getOrCreateAdapter( + mapName: string, + adapterName: string, + game: any, +): SteppingPathFinder { + if (!adapterCache.has(mapName)) { + adapterCache.set(mapName, new Map()); } - return pfMiniCache.get(mapName)!; + const mapAdapters = adapterCache.get(mapName)!; + + if (!mapAdapters.has(adapterName)) { + mapAdapters.set(adapterName, getAdapter(game, adapterName)); + } + return mapAdapters.get(adapterName)!; } /** @@ -48,110 +73,177 @@ function pathToCoords( } /** - * Compute pathfinding between two points + * Build the full transformer chain like PathFinding.Water() does */ -export async function computePath( - mapName: string, - from: [number, number], - to: [number, number], - options: PathfindingOptions = {}, -): Promise { - const { game, navMesh: navMeshAdapter } = await loadMap(mapName); +function buildWrappedPathFinder( + hpaStar: AStarWaterHierarchical, + game: any, + graph: any, +): PathFinder { + const miniMap = game.miniMap(); + const componentCheckFn = (t: TileRef) => graph.getComponentId(t); - // Convert coordinates to TileRefs - const fromRef = game.ref(from[0], from[1]); - const toRef = game.ref(to[0], to[1]); + // Chain: hpaStar -> ComponentCheck -> Bresenham -> ShoreCoercing -> MiniMap + const withComponentCheck = new ComponentCheckTransformer( + hpaStar, + componentCheckFn, + ); + const withSmoothing = new BresenhamSmoothingTransformer( + withComponentCheck, + miniMap, + ); + const withShoreCoercing = new ShoreCoercingTransformer( + withSmoothing, + miniMap, + ); + const withMiniMap = new MiniMapTransformer(withShoreCoercing, game, miniMap); - // Validate that both points are water tiles - if (!game.isWater(fromRef)) { - throw new Error(`Start point (${from[0]}, ${from[1]}) is not water`); - } - if (!game.isWater(toRef)) { - throw new Error(`End point (${to[0]}, ${to[1]}) is not water`); - } - - // Compute NavMesh path - const navMeshPath = navMeshAdapter.findPath(fromRef, toRef, true); - const path = pathToCoords(navMeshPath, game); + return withMiniMap; +} +/** + * Compute primary path using AStarWaterHierarchical with debug info + * Uses the same transformer chain as PathFinding.Water() + */ +function computePrimaryPath( + hpaStar: AStarWaterHierarchical, + game: any, + graph: any, + fromRef: TileRef, + toRef: TileRef, +): PrimaryResult { const miniMap = game.miniMap(); - // Extract debug info - let gateways: Array<[number, number]> | null = null; + // Build wrapped pathfinder with all transformers + const wrappedPf = buildWrappedPathFinder(hpaStar, game, graph); + + // Enable debug mode to capture internal state + hpaStar.debugMode = true; + + const start = performance.now(); + const path = wrappedPf.findPath(fromRef, toRef); + const time = performance.now() - start; + + const debugInfo = hpaStar.debugInfo; + + // Convert node path (miniMap coords) to full map coords + let nodePath: Array<[number, number]> | null = null; + if (debugInfo?.nodePath) { + nodePath = debugInfo.nodePath.map((tile: TileRef) => { + const x = miniMap.x(tile) * 2; + const y = miniMap.y(tile) * 2; + return [x, y] as [number, number]; + }); + } + + // Convert initialPath (miniMap TileRefs) to full map coords let initialPath: Array<[number, number]> | null = null; - let timings: any = {}; - - if (navMeshAdapter.debugInfo) { - // Convert gatewayPath (TileRefs on miniMap) to full map coordinates - if (navMeshAdapter.debugInfo.gatewayPath) { - gateways = navMeshAdapter.debugInfo.gatewayPath.map((tile: TileRef) => { - const x = miniMap.x(tile) * 2; - const y = miniMap.y(tile) * 2; - return [x, y] as [number, number]; - }); - } - - // Convert initial path - if (navMeshAdapter.debugInfo.initialPath) { - initialPath = navMeshAdapter.debugInfo.initialPath.map( - (tile: TileRef) => [game.x(tile), game.y(tile)] as [number, number], - ); - } - - timings = navMeshAdapter.debugInfo.timings || {}; + if (debugInfo?.initialPath) { + initialPath = debugInfo.initialPath.map((tile: TileRef) => { + const x = miniMap.x(tile) * 2; + const y = miniMap.y(tile) * 2; + return [x, y] as [number, number]; + }); } return { - path, - initialPath, - gateways, - timings, + path: pathToCoords(path, game), length: path ? path.length : 0, - time: timings.total ?? 0, + time, + debug: { + nodePath, + initialPath, + timings: debugInfo?.timings ?? {}, + }, }; } /** - * Compute only PathFinder.Mini path + * Compute comparison path using adapter */ -export async function computePfMiniPath( - mapName: string, - from: [number, number], - to: [number, number], -): Promise { - const { game } = await loadMap(mapName); - - // Convert coordinates to TileRefs - const fromRef = game.ref(from[0], from[1]); - const toRef = game.ref(to[0], to[1]); - - // Validate that both points are water tiles - if (!game.isWater(fromRef)) { - throw new Error(`Start point (${from[0]}, ${from[1]}) is not water`); - } - if (!game.isWater(toRef)) { - throw new Error(`End point (${to[0]}, ${to[1]}) is not water`); - } - - // Compute PathFinder.Mini path - const pfMiniAdapter = getPfMiniAdapter(mapName, game); - const pfMiniStart = performance.now(); - const pfMiniPath = pfMiniAdapter.findPath(fromRef, toRef); - const pfMiniEnd = performance.now(); - - const path = pathToCoords(pfMiniPath, game); - const time = pfMiniEnd - pfMiniStart; +function computeComparisonPath( + adapter: SteppingPathFinder, + game: any, + fromRef: TileRef, + toRef: TileRef, + adapterName: string, +): ComparisonResult { + const start = performance.now(); + const path = adapter.findPath(fromRef, toRef); + const time = performance.now() - start; return { - path, + adapter: adapterName, + path: pathToCoords(path, game), length: path ? path.length : 0, time, }; } +/** + * Compute pathfinding between two points + */ +export async function computePath( + mapName: string, + from: [number, number], + to: [number, number], + options: { adapters?: string[] } = {}, +): Promise { + const { game, hpaStar } = await loadMap(mapName); + const graph = game.miniWaterGraph(); + + // Convert coordinates to TileRefs + const fromRef = game.ref(from[0], from[1]); + const toRef = game.ref(to[0], to[1]); + + // Validate that both points are water tiles + if (!game.isWater(fromRef)) { + throw new Error(`Start point (${from[0]}, ${from[1]}) is not water`); + } + if (!game.isWater(toRef)) { + throw new Error(`End point (${to[0]}, ${to[1]}) is not water`); + } + + // Compute primary path (HPA* with debug) + const primary = computePrimaryPath(hpaStar, game, graph, fromRef, toRef); + + // Compute comparison paths + const selectedAdapters = options.adapters ?? COMPARISON_ADAPTERS; + const comparisons: ComparisonResult[] = []; + + for (const adapterName of selectedAdapters) { + if (!COMPARISON_ADAPTERS.includes(adapterName)) { + console.warn(`Unknown adapter: ${adapterName}, skipping`); + continue; + } + + try { + const adapter = getOrCreateAdapter(mapName, adapterName, game); + const result = computeComparisonPath( + adapter, + game, + fromRef, + toRef, + adapterName, + ); + comparisons.push(result); + } catch (error) { + console.error(`Error with adapter ${adapterName}:`, error); + comparisons.push({ + adapter: adapterName, + path: null, + length: 0, + time: 0, + }); + } + } + + return { primary, comparisons }; +} + /** * Clear pathfinding adapter caches */ export function clearAdapterCaches() { - pfMiniCache.clear(); + adapterCache.clear(); } diff --git a/tests/pathfinding/playground/public/client.js b/tests/pathfinding/playground/public/client.js index 48a222786..8c9d68d8e 100644 --- a/tests/pathfinding/playground/public/client.js +++ b/tests/pathfinding/playground/public/client.js @@ -6,19 +6,26 @@ const state = { mapHeight: 0, startPoint: null, endPoint: null, - navMeshPath: null, - navMeshResult: null, // Store full NavMesh result including timing - pfMiniPath: null, - pfMiniResult: null, // Store full PF.Mini result including timing - graphDebug: null, // Static graph data (gateways, edges, sectorSize) - loaded once per map - debugInfo: null, // Per-path debug data (timings, gatewayWaypoints, initialPath) + hpaPath: null, + hpaResult: null, // Store full HPA* result including timing + comparisons: [], // Array of comparison results + visibleComparisons: new Set(), // Which comparison paths are visible + adapters: [], // Available comparison adapters (loaded from backend) + graphDebug: null, // Static graph data (allNodes, edges, clusterSize) - loaded once per map + debugInfo: null, // Per-path debug data (timings, nodePath, initialPath) isMapLoading: false, // Loading state for map switching - isNavMeshLoading: false, // Separate loading state for NavMesh - isPfMiniLoading: false, // Separate loading state for PF.Mini - showPfMini: false, + isHpaLoading: false, // Separate loading state for HPA* activeRefreshButton: null, // Track which refresh button is spinning }; +// Colors for comparison paths +const COMPARISON_COLORS = { + hpa: "#ff8800", // orange + "a.baseline": "#ff00ff", // magenta + "a.generic": "#88ff00", // lime + "a.full": "#ffff00", // yellow +}; + // Canvas state let zoomLevel = 1.0; let panX = 0; @@ -32,7 +39,7 @@ let dragStartPanY = 0; let mapCanvas, overlayCanvas, interactiveCanvas; let mapCtx, overlayCtx, interactiveCtx; let mapRendered = false; -let hoveredGateway = null; +let hoveredNode = null; let hoveredPoint = null; // 'start', 'end', or null let draggingPoint = null; // 'start', 'end', or null let draggingPointPosition = null; // [x, y] canvas position while dragging @@ -147,21 +154,8 @@ function initializeControls() { } }); - // PF.Mini request button - document.getElementById("requestPfMini").addEventListener("click", () => { - if ( - state.startPoint && - state.endPoint && - !state.pfMiniPath && - !state.isPfMiniLoading - ) { - state.showPfMini = true; - requestPfMiniOnly(state.startPoint, state.endPoint); - } - }); - - // Refresh NavMesh button - document.getElementById("refreshNavMesh").addEventListener("click", (e) => { + // Refresh HPA* button + document.getElementById("refreshHpa").addEventListener("click", (e) => { if (state.startPoint && state.endPoint) { const btn = e.currentTarget; btn.classList.add("spinning"); @@ -170,22 +164,12 @@ function initializeControls() { } }); - // Refresh PF.Mini button - document.getElementById("refreshPfMini").addEventListener("click", (e) => { - if (state.startPoint && state.endPoint && state.pfMiniPath) { - const btn = e.currentTarget; - btn.classList.add("spinning"); - state.activeRefreshButton = btn; - requestPfMiniOnly(state.startPoint, state.endPoint); - } - }); - // Visualization toggles - all buttons [ "showInitialPath", - "showUsedGateways", + "showUsedNodes", "showColoredMap", - "showGateways", + "showNodes", "showSectorGrid", "showEdges", ].forEach((id) => { @@ -197,11 +181,11 @@ function initializeControls() { if (id === "showColoredMap") { renderMapBackground(2); } - // Static overlays (sectors, edges, all gateways) go on overlay canvas - if (["showGateways", "showSectorGrid", "showEdges"].includes(id)) { + // Static overlays (sectors, edges, all nodes) go on overlay canvas + if (["showNodes", "showSectorGrid", "showEdges"].includes(id)) { renderOverlay(2); } - // Dynamic elements (paths, highlighted gateways) go on interactive canvas + // Dynamic elements (paths, highlighted nodes) go on interactive canvas renderInteractive(); }); }); @@ -258,7 +242,8 @@ function schedulePathRecalc() { // Enough time has passed, request immediately lastPathRecalcTime = now; if (state.startPoint && state.endPoint) { - requestPathfinding(state.startPoint, state.endPoint); + // Skip comparisons during drag for snappy feel + requestPathfinding(state.startPoint, state.endPoint, true); } } // If not enough time has passed, skip this call (throttle) @@ -281,11 +266,6 @@ function initializeDragControls() { // Start dragging the point draggingPoint = pointAtMouse; wrapper.style.cursor = "move"; - - // Invalidate PF.Mini path since we're changing the route - state.pfMiniPath = null; - state.pfMiniResult = null; - updatePfMiniButton(); } else { // Start panning the map isDragging = true; @@ -355,57 +335,53 @@ function initializeDragControls() { wrapper.style.cursor = hoveredPoint ? "move" : "grab"; } - // Check for gateway hover (only if gateway visualization is enabled) - const showGateways = - document.getElementById("showGateways").dataset.active === "true"; - const showUsedGateways = - document.getElementById("showUsedGateways").dataset.active === "true"; + // Check for node hover (only if node visualization is enabled) + const showNodes = + document.getElementById("showNodes").dataset.active === "true"; + const showUsedNodes = + document.getElementById("showUsedNodes").dataset.active === "true"; if ( - (showGateways || showUsedGateways) && + (showNodes || showUsedNodes) && state.graphDebug && - state.graphDebug.allGateways + state.graphDebug.allNodes ) { - // Filter gateways based on what's visible - let gatewaysToCheck = state.graphDebug.allGateways; + // Filter nodes based on what's visible + let nodesToCheck = state.graphDebug.allNodes; if ( - showUsedGateways && - !showGateways && + showUsedNodes && + !showNodes && state.debugInfo && - state.debugInfo.gatewayWaypoints + state.debugInfo.nodePath ) { - // Only show tooltips for used gateways - // gatewayWaypoints are coordinates [x, y] matching the map format - const usedGatewayCoords = new Set( - state.debugInfo.gatewayWaypoints.map(([x, y]) => `${x},${y}`), + // Only show tooltips for used nodes + // nodePath are coordinates [x, y] matching the map format + const usedNodeCoords = new Set( + state.debugInfo.nodePath.map(([x, y]) => `${x},${y}`), ); - gatewaysToCheck = state.graphDebug.allGateways.filter((gw) => - usedGatewayCoords.has(`${gw.x * 2},${gw.y * 2}`), + nodesToCheck = state.graphDebug.allNodes.filter((node) => + usedNodeCoords.has(`${node.x * 2},${node.y * 2}`), ); } - const foundGateway = findGatewayAtPosition( - canvasX, - canvasY, - gatewaysToCheck, - ); + const foundNode = findNodeAtPosition(canvasX, canvasY, nodesToCheck); - if (foundGateway !== hoveredGateway) { - hoveredGateway = foundGateway; - if (hoveredGateway) { - showGatewayTooltip(hoveredGateway, e.clientX, e.clientY); + if (foundNode !== hoveredNode) { + hoveredNode = foundNode; + if (hoveredNode) { + showNodeTooltip(hoveredNode, e.clientX, e.clientY); } else { tooltip.classList.remove("visible"); } renderInteractive(); - } else if (hoveredGateway) { + } else if (hoveredNode) { tooltip.style.left = e.clientX + 15 + "px"; tooltip.style.top = e.clientY + 15 + "px"; } } else { - // No gateway visualization enabled, clear any existing tooltip - if (hoveredGateway) { - hoveredGateway = null; + // No node visualization enabled, clear any existing tooltip + if (hoveredNode) { + hoveredNode = null; tooltip.classList.remove("visible"); renderInteractive(); } @@ -451,8 +427,8 @@ function initializeDragControls() { tooltip.classList.remove("visible"); wrapper.style.cursor = "grab"; - const needsRender = hoveredGateway || hoveredPoint; - hoveredGateway = null; + const needsRender = hoveredNode || hoveredPoint; + hoveredNode = null; hoveredPoint = null; if (needsRender) { @@ -485,19 +461,12 @@ function initializeDragControls() { // Initialize timings panel to default state function initializeTimingsPanel() { // Set initial state to match "no path" state - updateTimingsPanel({ navMesh: null, pfMini: null }); - updatePfMiniButton(); + updateTimingsPanel({ primary: null, comparisons: [] }); } // Handle map clicks for point selection function handleMapClick(e) { - if ( - !state.currentMap || - state.isMapLoading || - state.isNavMeshLoading || - state.isPfMiniLoading - ) - return; + if (!state.currentMap || state.isMapLoading || state.isHpaLoading) return; const wrapper = document.getElementById("canvasWrapper"); const rect = wrapper.getBoundingClientRect(); @@ -555,15 +524,12 @@ function handleMapClick(e) { function clearPoints() { state.startPoint = null; state.endPoint = null; - state.navMeshPath = null; - state.navMeshResult = null; - state.pfMiniPath = null; - state.pfMiniResult = null; + state.hpaPath = null; + state.hpaResult = null; + state.comparisons = []; state.debugInfo = null; - state.showPfMini = false; updatePointDisplay(); hidePathInfo(); - updatePfMiniButton(); updateURLState(); // Remove points from URL renderInteractive(); } @@ -682,19 +648,17 @@ async function switchMap(mapName, restorePointsFromURL = false) { state.mapHeight = data.height; state.mapData = data.mapData; state.graphDebug = data.graphDebug; // Store static graph debug data + state.adapters = data.adapters || []; // Store available comparison adapters // Clear paths (but don't update URL yet if we're restoring from URL) state.startPoint = null; state.endPoint = null; - state.navMeshPath = null; - state.navMeshResult = null; - state.pfMiniPath = null; - state.pfMiniResult = null; + state.hpaPath = null; + state.hpaResult = null; + state.comparisons = []; state.debugInfo = null; - state.showPfMini = false; updatePointDisplay(); hidePathInfo(); - updatePfMiniButton(); // Size canvases mapCanvas.width = state.mapWidth * 2; @@ -779,20 +743,26 @@ function hideWelcomeScreen() { document.getElementById("welcomeScreen").classList.add("hidden"); } -// Request pathfinding computation (NavMesh only) -async function requestPathfinding(from, to) { +// Request pathfinding computation (HPA* primary + comparisons) +async function requestPathfinding(from, to, skipComparisons = false) { setStatus("Computing path...", true); - state.isNavMeshLoading = true; + state.isHpaLoading = true; try { + const body = { + map: state.currentMap, + from, + to, + }; + // Skip comparisons during drag for snappy feel + if (skipComparisons) { + body.adapters = []; + } + const response = await fetch("/api/pathfind", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - map: state.currentMap, - from, - to, - }), + body: JSON.stringify(body), }); if (!response.ok) { @@ -802,25 +772,25 @@ async function requestPathfinding(from, to) { const result = await response.json(); - // Update state - state.navMeshPath = result.path; - state.navMeshResult = result; // Store full result for later use - // Don't reset pfMiniPath - preserve it across NavMesh refreshes + // Update state with new API format + state.hpaPath = result.primary.path; + state.hpaResult = result.primary; + state.comparisons = result.comparisons; state.debugInfo = { - initialPath: result.initialPath, - gatewayWaypoints: result.gateways, - timings: result.timings, + initialPath: result.primary.debug.initialPath, + nodePath: result.primary.debug.nodePath, + timings: result.primary.debug.timings, }; - // Update UI - preserve existing PF.Mini if it exists - updatePathInfo({ navMesh: result, pfMini: state.pfMiniResult }); + // Update UI + updatePathInfo(result); renderInteractive(); setStatus("Path computed successfully"); } catch (error) { showError(`Pathfinding failed: ${error.message}`); } finally { - state.isNavMeshLoading = false; + state.isHpaLoading = false; // Stop refresh button spinning if (state.activeRefreshButton) { state.activeRefreshButton.classList.remove("spinning"); @@ -829,124 +799,43 @@ async function requestPathfinding(from, to) { } } -// Request PF.Mini computation only (without re-computing NavMesh) -async function requestPfMiniOnly(from, to) { - setStatus("Computing PF.Mini path...", true); - state.isPfMiniLoading = true; - updatePfMiniButton(); // Update button to show loading state - - try { - const response = await fetch("/api/pathfind-pfmini", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - map: state.currentMap, - from, - to, - }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.message || "Pathfinding failed"); - } - - const result = await response.json(); - - // Update only PF.Mini path (preserve existing NavMesh path and debug info) - state.pfMiniPath = result.path; - state.pfMiniResult = result; // Store full result - - // Update UI (preserve existing NavMesh result) - updatePathInfo({ navMesh: state.navMeshResult, pfMini: result }); - renderInteractive(); - - setStatus("PF.Mini path computed successfully"); - } catch (error) { - showError(`PF.Mini pathfinding failed: ${error.message}`); - } finally { - state.isPfMiniLoading = false; - // Stop refresh button spinning - if (state.activeRefreshButton) { - state.activeRefreshButton.classList.remove("spinning"); - state.activeRefreshButton = null; - } - // Update button state - updatePfMiniButton(); - } -} - // Update point display function updatePointDisplay() { - updatePfMiniButton(); -} - -// Update PF.Mini button state -function updatePfMiniButton() { - const button = document.getElementById("requestPfMini"); - const requestSection = document.getElementById("pfMiniRequestSection"); - - if (state.pfMiniPath) { - // Hide button when PF.Mini is already computed - requestSection.style.display = "none"; - } else if (state.isPfMiniLoading && !state.activeRefreshButton) { - // Show loading spinner when computing PF.Mini (not a refresh) - requestSection.style.display = "block"; - button.disabled = true; - button.innerHTML = 'Computing... '; - } else if (state.startPoint && state.endPoint && state.navMeshPath) { - // Show and enable button when points are set and NavMesh path exists - requestSection.style.display = "block"; - button.disabled = false; - button.textContent = "Request PathFinder.Mini"; - } else { - // Show but disable button when points aren't set - requestSection.style.display = "block"; - button.disabled = true; - button.textContent = "Request PathFinder.Mini"; - } + // No-op now, kept for compatibility } // Update path info in UI function updatePathInfo(result) { - // Update PF.Mini legend visibility - if (result.pfMini) { - document.getElementById("pfMiniLegend").style.display = "flex"; - } else { - document.getElementById("pfMiniLegend").style.display = "none"; - } - // Update timings panel updateTimingsPanel(result); - - // Update PF.Mini button - updatePfMiniButton(); } // Update the dedicated timings panel function updateTimingsPanel(result) { - const navMesh = result.navMesh; + const primary = result.primary; + const timings = primary && primary.debug ? primary.debug.timings : {}; - // Show NavMesh time and path length (or 0.00 in light gray if no data) - const navMeshTimeEl = document.getElementById("navMeshTime"); - if (navMesh && navMesh.time > 0) { - navMeshTimeEl.textContent = `${navMesh.time.toFixed(2)}ms`; - navMeshTimeEl.classList.remove("faded"); + // 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 { - navMeshTimeEl.textContent = "0.00ms"; - navMeshTimeEl.classList.add("faded"); + hpaTimeEl.textContent = "0.00ms"; + hpaTimeEl.classList.add("faded"); } - const navMeshTilesEl = document.getElementById("navMeshTiles"); - if (navMesh && navMesh.length > 0) { - navMeshTilesEl.textContent = `- ${navMesh.length} tiles`; + const hpaTilesEl = document.getElementById("hpaTiles"); + if (primary && primary.length > 0) { + hpaTilesEl.textContent = `- ${primary.length} tiles`; } else { - navMeshTilesEl.textContent = ""; + hpaTilesEl.textContent = ""; } // Show timing breakdown - always visible with gray dashes when no data - const timings = navMesh && navMesh.timings ? navMesh.timings : {}; - // Early Exit const earlyExitEl = document.getElementById("timingEarlyExit"); const earlyExitValueEl = document.getElementById("timingEarlyExitValue"); @@ -959,30 +848,30 @@ function updateTimingsPanel(result) { earlyExitValueEl.style.color = "#666"; } - // Find Gateways - const findGatewaysEl = document.getElementById("timingFindGateways"); - const findGatewaysValueEl = document.getElementById( - "timingFindGatewaysValue", - ); - findGatewaysEl.style.display = "flex"; - if (timings.findGateways !== undefined) { - findGatewaysValueEl.textContent = `${timings.findGateways.toFixed(2)}ms`; - findGatewaysValueEl.style.color = "#f5f5f5"; + // Find Nodes + 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`; + findNodesValueEl.style.color = "#f5f5f5"; } else { - findGatewaysValueEl.textContent = "—"; - findGatewaysValueEl.style.color = "#666"; + findNodesValueEl.textContent = "—"; + findNodesValueEl.style.color = "#666"; } - // Gateway Path - const gatewayPathEl = document.getElementById("timingGatewayPath"); - const gatewayPathValueEl = document.getElementById("timingGatewayPathValue"); - gatewayPathEl.style.display = "flex"; - if (timings.findGatewayPath !== undefined) { - gatewayPathValueEl.textContent = `${timings.findGatewayPath.toFixed(2)}ms`; - gatewayPathValueEl.style.color = "#f5f5f5"; + // Abstract Path + const abstractPathEl = document.getElementById("timingAbstractPath"); + const abstractPathValueEl = document.getElementById( + "timingAbstractPathValue", + ); + abstractPathEl.style.display = "flex"; + if (timings.findAbstractPath !== undefined) { + abstractPathValueEl.textContent = `${timings.findAbstractPath.toFixed(2)}ms`; + abstractPathValueEl.style.color = "#f5f5f5"; } else { - gatewayPathValueEl.textContent = "—"; - gatewayPathValueEl.style.color = "#666"; + abstractPathValueEl.textContent = "—"; + abstractPathValueEl.style.color = "#666"; } // Initial Path @@ -997,55 +886,90 @@ function updateTimingsPanel(result) { initialPathValueEl.style.color = "#666"; } - // Smooth Path - const smoothPathEl = document.getElementById("timingSmoothPath"); - const smoothPathValueEl = document.getElementById("timingSmoothPathValue"); - smoothPathEl.style.display = "flex"; - if (timings.buildSmoothPath !== undefined) { - smoothPathValueEl.textContent = `${timings.buildSmoothPath.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"); + + // Only show comparisons section if we have adapters loaded + if (!state.adapters || state.adapters.length === 0) { + comparisonsSection.style.display = "none"; + return; } + comparisonsSection.style.display = "block"; - // Show PF.Mini time and speedup if available - if (result.pfMini && result.pfMini.time > 0) { - const pfMiniTimeEl = document.getElementById("pfMiniTime"); - pfMiniTimeEl.textContent = `${result.pfMini.time.toFixed(2)}ms`; - pfMiniTimeEl.classList.remove("faded"); - - document.getElementById("pfMiniTiles").textContent = - `- ${result.pfMini.length} tiles`; - document.getElementById("pfMiniTimingSection").style.display = "block"; - - // Calculate and show speedup - if (navMesh && navMesh.time > 0) { - const speedup = result.pfMini.time / navMesh.time; - document.getElementById("speedupValue").textContent = - `${speedup.toFixed(1)}x`; - document.getElementById("speedupSection").style.display = "block"; - } else { - document.getElementById("speedupSection").style.display = "none"; + // Build lookup map for comparison data + const compMap = {}; + if (result.comparisons) { + for (const comp of result.comparisons) { + compMap[comp.adapter] = comp; } - } else if (result.pfMini) { - // PF.Mini exists but time is 0 - const pfMiniTimeEl = document.getElementById("pfMiniTime"); - pfMiniTimeEl.textContent = "—"; - pfMiniTimeEl.classList.add("faded"); - document.getElementById("pfMiniTiles").textContent = ""; - document.getElementById("pfMiniTimingSection").style.display = "block"; - document.getElementById("speedupSection").style.display = "none"; - } else { - document.getElementById("pfMiniTimingSection").style.display = "none"; - document.getElementById("speedupSection").style.display = "none"; } + + // Find fastest time overall (including HPA*) when we have data + const compTimes = result.comparisons + ? result.comparisons.map((c) => c.time).filter((t) => t > 0) + : []; + const fastestCompTime = + compTimes.length > 0 ? Math.min(...compTimes) : Infinity; + + // Update HPA* time color - green if fastest, red if slower than any comparison + const hpaIsFastest = hpaTime > 0 && hpaTime <= fastestCompTime; + const hpaSlower = hpaTime > 0 && fastestCompTime < hpaTime; + const fastestTime = Math.min(hpaTime || Infinity, fastestCompTime); + + if (hpaIsFastest) { + hpaTimeEl.style.color = "#00ff88"; + } else if (hpaSlower) { + hpaTimeEl.style.color = "#ff6666"; + } else { + hpaTimeEl.style.color = "#f5f5f5"; + } + + // Build comparison rows for all known adapters + let html = ""; + for (const adapter of state.adapters) { + const comp = compMap[adapter]; + const pathColor = COMPARISON_COLORS[adapter] || "#ffffff"; + const isActive = state.visibleComparisons.has(adapter); + + // Show actual values or placeholders + const hasData = comp && comp.time > 0; + const isFastest = hasData && comp.time === fastestTime; + const timeColor = isFastest ? "#00ff88" : hasData ? "#f5f5f5" : "#666"; + const tilesText = hasData ? comp.length : "—"; + const timeText = hasData ? `${comp.time.toFixed(2)}ms` : "—"; + + html += ` +
+ + ${adapter} + ${tilesText} + ${timeText} +
+ `; + } + comparisonsContainer.innerHTML = html; + + // Add click handlers to toggle path visibility + comparisonsContainer.querySelectorAll(".comparison-row").forEach((row) => { + row.addEventListener("click", () => { + const adapter = row.dataset.adapter; + if (state.visibleComparisons.has(adapter)) { + state.visibleComparisons.delete(adapter); + row.classList.remove("active"); + } else { + state.visibleComparisons.add(adapter); + row.classList.add("active"); + } + renderInteractive(); + }); + }); } // Reset path info to show dashes function hidePathInfo() { // Don't hide the panel, just reset to show dashes - updateTimingsPanel({ navMesh: null, pfMini: null }); + updateTimingsPanel({ primary: null, comparisons: [] }); } // Set status message @@ -1132,7 +1056,7 @@ function renderMapBackground(scale) { mapCtx.putImageData(imageData, 0, 0); } -// Render static debug overlays (sectors, edges, all gateways) at map scale +// Render static debug overlays (clusters, edges, all nodes) at map scale function renderOverlay(scale) { overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height); @@ -1142,19 +1066,19 @@ function renderOverlay(scale) { document.getElementById("showSectorGrid").dataset.active === "true"; const showEdges = document.getElementById("showEdges").dataset.active === "true"; - const showGateways = - document.getElementById("showGateways").dataset.active === "true"; + const showNodes = + document.getElementById("showNodes").dataset.active === "true"; - // Draw sector grid (sectorSize is in mini map coords, scale 2x for real map) - if (showSectorGrid && state.graphDebug.sectorSize) { - const sectorSize = state.graphDebug.sectorSize * 2; + // Draw cluster grid (clusterSize is in mini map coords, scale 2x for real map) + if (showSectorGrid && state.graphDebug.clusterSize) { + const clusterSize = state.graphDebug.clusterSize * 2; overlayCtx.strokeStyle = "#777777"; overlayCtx.lineWidth = scale * 0.5; overlayCtx.globalAlpha = 0.7; overlayCtx.setLineDash([5 * scale, 5 * scale]); // Vertical lines - for (let x = 0; x <= state.mapWidth; x += sectorSize) { + for (let x = 0; x <= state.mapWidth; x += clusterSize) { overlayCtx.beginPath(); overlayCtx.moveTo(x * scale, 0); overlayCtx.lineTo(x * scale, state.mapHeight * scale); @@ -1162,7 +1086,7 @@ function renderOverlay(scale) { } // Horizontal lines - for (let y = 0; y <= state.mapHeight; y += sectorSize) { + for (let y = 0; y <= state.mapHeight; y += clusterSize) { overlayCtx.beginPath(); overlayCtx.moveTo(0, y * scale); overlayCtx.lineTo(state.mapWidth * scale, y * scale); @@ -1192,17 +1116,17 @@ function renderOverlay(scale) { overlayCtx.globalAlpha = 1.0; } - // Draw all gateways - if (showGateways && state.graphDebug.allGateways) { + // Draw all nodes + if (showNodes && state.graphDebug.allNodes) { overlayCtx.fillStyle = "#aaaaaa"; - const gatewayRadius = scale * 1.5; + const nodeRadius = scale * 1.5; - for (const gw of state.graphDebug.allGateways) { + for (const node of state.graphDebug.allNodes) { overlayCtx.beginPath(); overlayCtx.arc( - (gw.x * 2 + 0.5) * scale, - (gw.y * 2 + 0.5) * scale, - gatewayRadius, + (node.x * 2 + 0.5) * scale, + (node.y * 2 + 0.5) * scale, + nodeRadius, 0, Math.PI * 2, ); @@ -1234,24 +1158,19 @@ function renderInteractive() { const markerSize = Math.max(4, 3 * zoomLevel); // Check what to show - const showUsedGateways = - document.getElementById("showUsedGateways").dataset.active === "true"; + const showUsedNodes = + document.getElementById("showUsedNodes").dataset.active === "true"; const showInitialPath = document.getElementById("showInitialPath").dataset.active === "true"; const showEdges = document.getElementById("showEdges").dataset.active === "true"; - const showGateways = - document.getElementById("showGateways").dataset.active === "true"; + const showNodes = + document.getElementById("showNodes").dataset.active === "true"; - // Draw highlighted edges for hovered gateway only - if ( - hoveredGateway && - showEdges && - state.graphDebug && - state.graphDebug.edges - ) { + // Draw highlighted edges for hovered node only + if (hoveredNode && showEdges && state.graphDebug && state.graphDebug.edges) { const connectedEdges = state.graphDebug.edges.filter( - (e) => e.fromId === hoveredGateway.id || e.toId === hoveredGateway.id, + (e) => e.fromId === hoveredNode.id || e.toId === hoveredNode.id, ); interactiveCtx.strokeStyle = "#00ffaa"; @@ -1270,31 +1189,30 @@ function renderInteractive() { interactiveCtx.globalAlpha = 1.0; } - // Draw highlighted gateways (hovered + connected) only + // Draw highlighted nodes (hovered + connected) only if ( - hoveredGateway && - showGateways && + hoveredNode && + showNodes && state.graphDebug && - state.graphDebug.allGateways + state.graphDebug.allNodes ) { - // Get connected gateways - let connectedGatewayIds = new Set(); + // Get connected nodes + let connectedNodeIds = new Set(); if (state.graphDebug.edges) { const connectedEdges = state.graphDebug.edges.filter( - (e) => e.fromId === hoveredGateway.id || e.toId === hoveredGateway.id, + (e) => e.fromId === hoveredNode.id || e.toId === hoveredNode.id, ); connectedEdges.forEach((edge) => { - if (edge.fromId !== hoveredGateway.id) - connectedGatewayIds.add(edge.fromId); - if (edge.toId !== hoveredGateway.id) connectedGatewayIds.add(edge.toId); + if (edge.fromId !== hoveredNode.id) connectedNodeIds.add(edge.fromId); + if (edge.toId !== hoveredNode.id) connectedNodeIds.add(edge.toId); }); } - // Draw connected gateways - for (const gwId of connectedGatewayIds) { - const gw = state.graphDebug.allGateways.find((g) => g.id === gwId); - if (gw) { - const screen = mapToScreen(gw.x * 2, gw.y * 2); + // Draw connected nodes + for (const nodeId of connectedNodeIds) { + const node = state.graphDebug.allNodes.find((n) => n.id === nodeId); + if (node) { + const screen = mapToScreen(node.x * 2, node.y * 2); interactiveCtx.fillStyle = "#00ff88"; interactiveCtx.strokeStyle = "#ffffff"; interactiveCtx.lineWidth = Math.max(1, zoomLevel * 0.3); @@ -1311,8 +1229,8 @@ function renderInteractive() { } } - // Draw hovered gateway on top - const screen = mapToScreen(hoveredGateway.x * 2, hoveredGateway.y * 2); + // Draw hovered node on top + const screen = mapToScreen(hoveredNode.x * 2, hoveredNode.y * 2); interactiveCtx.fillStyle = "#ffff00"; interactiveCtx.strokeStyle = "#ffffff"; interactiveCtx.lineWidth = Math.max(1, zoomLevel * 0.5); @@ -1353,16 +1271,43 @@ function renderInteractive() { interactiveCtx.stroke(); } - // Draw NavMesh path - if (state.navMeshPath && state.navMeshPath.length > 0) { + // Draw comparison paths (before HPA* so primary is on top) + if (state.comparisons && state.visibleComparisons.size > 0) { + interactiveCtx.lineCap = "round"; + interactiveCtx.lineJoin = "round"; + + for (const comp of state.comparisons) { + if (!state.visibleComparisons.has(comp.adapter)) continue; + if (!comp.path || comp.path.length === 0) continue; + + const color = COMPARISON_COLORS[comp.adapter] || "#ffffff"; + interactiveCtx.strokeStyle = color; + interactiveCtx.lineWidth = Math.max(1, zoomLevel); + interactiveCtx.beginPath(); + + for (let i = 0; i < comp.path.length; i++) { + const [x, y] = comp.path[i]; + const screen = mapToScreen(x + 0.5, y + 0.5); + if (i === 0) { + interactiveCtx.moveTo(screen.x, screen.y); + } else { + interactiveCtx.lineTo(screen.x, screen.y); + } + } + interactiveCtx.stroke(); + } + } + + // Draw HPA* path + if (state.hpaPath && state.hpaPath.length > 0) { interactiveCtx.strokeStyle = "#00ffff"; interactiveCtx.lineWidth = Math.max(1, zoomLevel); interactiveCtx.lineCap = "round"; interactiveCtx.lineJoin = "round"; interactiveCtx.beginPath(); - for (let i = 0; i < state.navMeshPath.length; i++) { - const [x, y] = state.navMeshPath[i]; + for (let i = 0; i < state.hpaPath.length; i++) { + const [x, y] = state.hpaPath[i]; const screen = mapToScreen(x + 0.5, y + 0.5); if (i === 0) { interactiveCtx.moveTo(screen.x, screen.y); @@ -1373,36 +1318,16 @@ function renderInteractive() { interactiveCtx.stroke(); } - // Draw PF.Mini path - if (state.pfMiniPath && state.pfMiniPath.length > 0) { - interactiveCtx.strokeStyle = "#ffaa00"; - interactiveCtx.lineWidth = Math.max(1, zoomLevel); - interactiveCtx.lineCap = "round"; - interactiveCtx.lineJoin = "round"; - interactiveCtx.beginPath(); - - for (let i = 0; i < state.pfMiniPath.length; i++) { - const [x, y] = state.pfMiniPath[i]; - const screen = mapToScreen(x + 0.5, y + 0.5); - if (i === 0) { - interactiveCtx.moveTo(screen.x, screen.y); - } else { - interactiveCtx.lineTo(screen.x, screen.y); - } - } - interactiveCtx.stroke(); - } - - // Draw used gateways (highlighted) - if (showUsedGateways && state.debugInfo && state.debugInfo.gatewayWaypoints) { + // Draw used nodes (highlighted) + if (showUsedNodes && state.debugInfo && state.debugInfo.nodePath) { interactiveCtx.fillStyle = "#ffff00"; - const usedGatewayRadius = Math.max(3, zoomLevel * 2.5); + const usedNodeRadius = Math.max(3, zoomLevel * 2.5); - for (const [x, y] of state.debugInfo.gatewayWaypoints) { - // Gateways are coordinates [x, y] in the same format as path + for (const [x, y] of state.debugInfo.nodePath) { + // Nodes are coordinates [x, y] in the same format as path const screen = mapToScreen(x + 0.5, y + 0.5); interactiveCtx.beginPath(); - interactiveCtx.arc(screen.x, screen.y, usedGatewayRadius, 0, Math.PI * 2); + interactiveCtx.arc(screen.x, screen.y, usedNodeRadius, 0, Math.PI * 2); interactiveCtx.fill(); } } @@ -1472,41 +1397,40 @@ function renderInteractive() { } } -function findGatewayAtPosition(canvasX, canvasY, gatewaysToCheck = null) { - const gateways = - gatewaysToCheck || (state.graphDebug && state.graphDebug.allGateways); - if (!gateways) { +function findNodeAtPosition(canvasX, canvasY, nodesToCheck = null) { + const nodes = nodesToCheck || (state.graphDebug && state.graphDebug.allNodes); + if (!nodes) { return null; } const threshold = 10; - for (const gw of gateways) { - const gwX = gw.x * 2; - const gwY = gw.y * 2; - const dx = Math.abs(canvasX - gwX); - const dy = Math.abs(canvasY - gwY); + for (const node of nodes) { + const nodeX = node.x * 2; + const nodeY = node.y * 2; + const dx = Math.abs(canvasX - nodeX); + const dy = Math.abs(canvasY - nodeY); if (dx < threshold && dy < threshold) { - return gw; + return node; } } return null; } -// Show gateway tooltip -function showGatewayTooltip(gateway, mouseX, mouseY) { +// Show node tooltip +function showNodeTooltip(node, mouseX, mouseY) { const tooltip = document.getElementById("tooltip"); const connectedEdges = state.graphDebug.edges.filter( - (e) => e.fromId === gateway.id || e.toId === gateway.id, + (e) => e.fromId === node.id || e.toId === node.id, ); const selfLoops = connectedEdges.filter((e) => e.fromId === e.toId); - let html = `Gateway ${gateway.id}
`; - html += `Position: (${gateway.x * 2}, ${gateway.y * 2})
`; + let html = `Node ${node.id}
`; + html += `Position: (${node.x * 2}, ${node.y * 2})
`; html += `Edges: ${connectedEdges.length}`; if (selfLoops.length > 0) { @@ -1516,46 +1440,24 @@ function showGatewayTooltip(gateway, mouseX, mouseY) { if (connectedEdges.length > 0) { html += '
'; - const outgoing = connectedEdges.filter( - (e) => e.fromId === gateway.id && e.toId !== gateway.id, - ); - const incoming = connectedEdges.filter( - (e) => e.toId === gateway.id && e.fromId !== gateway.id, - ); + // Edges are bidirectional now, just show connected nodes + const connected = connectedEdges.filter((e) => e.fromId !== e.toId); - if (outgoing.length > 0) { - html += `
Outgoing (${outgoing.length}):
`; - outgoing.slice(0, 5).forEach((edge) => { - const pathLen = edge.path ? edge.path.length : 0; - html += ` → GW ${edge.toId}: cost ${edge.cost.toFixed(1)}`; - if (pathLen > 0) html += ` (${pathLen} tiles)`; - html += "
"; + if (connected.length > 0) { + html += `
Connected (${connected.length}):
`; + connected.slice(0, 8).forEach((edge) => { + const otherId = edge.fromId === node.id ? edge.toId : edge.fromId; + html += ` ↔ Node ${otherId}: cost ${edge.cost.toFixed(1)}
`; }); - if (outgoing.length > 5) { - html += ` ... and ${outgoing.length - 5} more
`; - } - } - - if (incoming.length > 0) { - html += `
Incoming (${incoming.length}):
`; - incoming.slice(0, 5).forEach((edge) => { - const pathLen = edge.path ? edge.path.length : 0; - html += ` ← GW ${edge.fromId}: cost ${edge.cost.toFixed(1)}`; - if (pathLen > 0) html += ` (${pathLen} tiles)`; - html += "
"; - }); - if (incoming.length > 5) { - html += ` ... and ${incoming.length - 5} more
`; + if (connected.length > 8) { + html += ` ... and ${connected.length - 8} more
`; } } if (selfLoops.length > 0) { html += `
Self-loops (${selfLoops.length}):
`; selfLoops.forEach((edge) => { - const pathLen = edge.path ? edge.path.length : 0; - html += ` ⟲ cost ${edge.cost.toFixed(1)}`; - if (pathLen > 0) html += ` (${pathLen} tiles)`; - html += "
"; + html += ` ⟲ cost ${edge.cost.toFixed(1)}
`; }); } diff --git a/tests/pathfinding/playground/public/index.html b/tests/pathfinding/playground/public/index.html index b3180dc45..6dbe07701 100644 --- a/tests/pathfinding/playground/public/index.html +++ b/tests/pathfinding/playground/public/index.html @@ -129,13 +129,13 @@ -
- - NavMesh + HPA*
- +
+ - -
-
- -
- - - - @@ -256,13 +223,9 @@ > End Point -
- NavMesh + HPA*
@@ -273,7 +236,7 @@ class="legend-color" style="background: #ffff00; height: 8px" >
- Used Gateways + Used Nodes
- Gateways + Nodes
{ * map: string, * from: [x, y], * to: [x, y], - * includePfMini?: boolean + * adapters?: string[] // Optional: which comparison adapters to run + * } + * + * Response: + * { + * primary: { path, length, time, debug: { nodePath, initialPath, timings } }, + * comparisons: [{ adapter, path, length, time }, ...] * } */ app.post("/api/pathfind", async (req: Request, res: Response) => { try { - const { map, from, to, includePfMini } = req.body; + const { map, from, to, adapters } = req.body; // Validate request if (!map || !from || !to) { @@ -144,7 +146,7 @@ app.post("/api/pathfind", async (req: Request, res: Response) => { map, from as [number, number], to as [number, number], - { includePfMini: !!includePfMini }, + { adapters }, ); res.json(result); @@ -165,66 +167,6 @@ app.post("/api/pathfind", async (req: Request, res: Response) => { } }); -/** - * POST /api/pathfind-pfmini - * Compute only PathFinder.Mini path - * - * Request body: - * { - * map: string, - * from: [x, y], - * to: [x, y] - * } - */ -app.post("/api/pathfind-pfmini", async (req: Request, res: Response) => { - try { - const { map, from, to } = req.body; - - // Validate request - if (!map || !from || !to) { - return res.status(400).json({ - error: "Invalid request", - message: "Missing required fields: map, from, to", - }); - } - - if ( - !Array.isArray(from) || - from.length !== 2 || - !Array.isArray(to) || - to.length !== 2 - ) { - return res.status(400).json({ - error: "Invalid coordinates", - message: "from and to must be [x, y] coordinate arrays", - }); - } - - // Compute PF.Mini path only - const result = await computePfMiniPath( - map, - from as [number, number], - to as [number, number], - ); - - res.json(result); - } catch (error) { - console.error("Error computing PF.Mini path:", error); - - if (error instanceof Error && error.message.includes("is not water")) { - res.status(400).json({ - error: "Invalid coordinates", - message: error.message, - }); - } else { - res.status(500).json({ - error: "Failed to compute PF.Mini path", - message: error instanceof Error ? error.message : String(error), - }); - } - } -}); - /** * POST /api/cache/clear * Clear all caches (useful for development) diff --git a/tests/pathfinding/utils.ts b/tests/pathfinding/utils.ts index c32cd5f1b..33c157b39 100644 --- a/tests/pathfinding/utils.ts +++ b/tests/pathfinding/utils.ts @@ -17,10 +17,19 @@ import { MapManifest, } from "../../src/core/game/TerrainMapLoader"; import { UserSettings } from "../../src/core/game/UserSettings"; -import { NavMesh } from "../../src/core/pathfinding/navmesh/NavMesh"; -import { PathFinder, PathFinders } from "../../src/core/pathfinding/PathFinder"; +import { AStarWater } from "../../src/core/pathfinding/algorithms/AStar.Water"; +import { AStarWaterHierarchical } from "../../src/core/pathfinding/algorithms/AStar.WaterHierarchical"; +import { PathFinding } from "../../src/core/pathfinding/PathFinder"; +import { PathFinderBuilder } from "../../src/core/pathfinding/PathFinderBuilder"; +import { StepperConfig } from "../../src/core/pathfinding/PathFinderStepper"; +import { MiniMapTransformer } from "../../src/core/pathfinding/transformers/MiniMapTransformer"; +import { + PathStatus, + SteppingPathFinder, +} from "../../src/core/pathfinding/types"; import { GameConfig } from "../../src/core/Schemas"; import { TestConfig } from "../util/TestConfig"; + export type BenchmarkRoute = { name: string; from: TileRef; @@ -42,25 +51,58 @@ export type BenchmarkSummary = { avgTime: number; }; -export function getAdapter(game: Game, name: string): PathFinder { +function tileStepperConfig(game: Game): StepperConfig { + return { + equals: (a, b) => a === b, + distance: (a, b) => game.manhattanDist(a, b), + preCheck: (from, to) => + typeof from !== "number" || + typeof to !== "number" || + !game.isValidRef(from) || + !game.isValidRef(to) + ? { status: PathStatus.NOT_FOUND } + : null, + }; +} + +export function getAdapter( + game: Game, + name: string, +): SteppingPathFinder { switch (name) { - case "legacy": - return PathFinders.WaterLegacy(game, { - iterations: 500_000, - maxTries: 50, - }); + case "a.baseline": { + return PathFinderBuilder.create(new AStarWater(game.miniMap())) + .wrap((pf) => new MiniMapTransformer(pf, game, game.miniMap())) + .buildWithStepper(tileStepperConfig(game)); + } + case "a.generic": { + // Same as baseline - uses AStarWater on minimap + return PathFinderBuilder.create(new AStarWater(game.miniMap())) + .wrap((pf) => new MiniMapTransformer(pf, game, game.miniMap())) + .buildWithStepper(tileStepperConfig(game)); + } + case "a.full": { + return PathFinderBuilder.create( + new AStarWater(game.map()), + ).buildWithStepper(tileStepperConfig(game)); + } case "hpa": { - // Recreate NavMesh without cache, this approach was chosen + // Recreate AStarWaterHierarchical without cache, this approach was chosen // over adding cache toggles to the existing game instance // to avoid adding side effect from benchmark to the game - const navMesh = new NavMesh(game, { cachePaths: false }); - navMesh.initialize(); - (game as any)._navMesh = navMesh; + const graph = game.miniWaterGraph(); + if (!graph) { + throw new Error("miniWaterGraph not available"); + } + const hpa = new AStarWaterHierarchical(game.miniMap(), graph, { + cachePaths: false, + }); + (game as any)._miniWaterHPA = hpa; - return PathFinders.Water(game); + return PathFinding.Water(game); } case "hpa.cached": - return PathFinders.Water(game); + return PathFinding.Water(game); default: throw new Error(`Unknown pathfinding adapter: ${name}`); } @@ -102,7 +144,7 @@ export async function getScenario( } export function measurePathLength( - adapter: PathFinder, + adapter: SteppingPathFinder, route: BenchmarkRoute, ): number | null { const path = adapter.findPath(route.from, route.to); @@ -117,7 +159,7 @@ export function measureTime(fn: () => T): { result: T; time: number } { } export function measureExecutionTime( - adapter: PathFinder, + adapter: SteppingPathFinder, route: BenchmarkRoute, executions: number = 1, ): number | null { diff --git a/tests/perf/AstarPerf.ts b/tests/perf/AstarPerf.ts deleted file mode 100644 index 7b59beadd..000000000 --- a/tests/perf/AstarPerf.ts +++ /dev/null @@ -1,36 +0,0 @@ -import Benchmark from "benchmark"; -import { dirname } from "path"; -import { fileURLToPath } from "url"; -import { MiniPathFinder } from "../../src/core/pathfinding/PathFinding"; -import { setup } from "../util/Setup"; - -const game = await setup( - "giantworldmap", - {}, - [], - dirname(fileURLToPath(import.meta.url)), -); - -new Benchmark.Suite() - .add("top-left-to-bottom-right", () => { - new MiniPathFinder(game, 10_000_000_000, true, 1).nextTile( - game.ref(0, 0), - game.ref(4077, 1929), - ); - }) - .add("hawaii to svalbard", () => { - new MiniPathFinder(game, 10_000_000_000, true, 1).nextTile( - game.ref(186, 800), - game.ref(2205, 52), - ); - }) - .add("black sea to california", () => { - new MiniPathFinder(game, 10_000_000_000, true, 1).nextTile( - game.ref(2349, 455), - game.ref(511, 536), - ); - }) - .on("cycle", (event: any) => { - console.log(String(event.target)); - }) - .run({ async: true });