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 });