Pathfinding Refactor

This commit is contained in:
Arkadiusz Sygulski
2026-01-11 21:34:37 +01:00
parent 8235da9335
commit 13b4142317
75 changed files with 6809 additions and 4200 deletions
@@ -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.
+17 -8
View File
@@ -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);
}
}
+14 -15
View File
@@ -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<TileRef> | 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,
+8 -8
View File
@@ -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<TileRef>;
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);
}
}
}
+8 -7
View File
@@ -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<TileRef>;
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);
}
}
}
+4 -3
View File
@@ -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<TileRef>;
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 {
+22 -60
View File
@@ -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<TileRef>;
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;
}
}
+27 -7
View File
@@ -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<TileRef>;
private lastShellAttack = 0;
private alreadySentShell = new Set<Unit>();
@@ -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(
@@ -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;
}
+6 -2
View File
@@ -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<number> | null;
miniWaterGraph(): AbstractGraph | null;
getWaterComponent(tile: TileRef): number | null;
hasWaterComponent(tile: TileRef, component: number): boolean;
}
export interface PlayerActions {
+89 -6
View File
@@ -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<number> | 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)) {
+1 -1
View File
@@ -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;
}
}
+2
View File
@@ -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;
}
+28 -32
View File
@@ -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<TrainStation>;
getById(id: number): TrainStation | undefined;
count(): number;
}
export class StationManagerImpl implements StationManager {
private stations: Set<TrainStation> = 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<TrainStation> {
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(
+1 -24
View File
@@ -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<Record<UnitType, TrainStopHandler>> =
{};
private cluster: Cluster | null;
@@ -171,29 +171,6 @@ export class TrainStation {
}
}
/**
* Make the trainstation usable with A*
*/
export class TrainStationMapAdapter implements GraphAdapter<TrainStation> {
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
*/
+9 -226
View File
@@ -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<string, TileRef | null> = {
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);
}
-31
View File
@@ -1,31 +0,0 @@
export interface AStar<NodeType> {
compute(): PathFindResultType;
reconstructPath(): NodeType[];
}
export enum PathFindResultType {
NextTile,
Pending,
Completed,
PathNotFound,
}
export type AStarResult<NodeType> =
| {
type: PathFindResultType.NextTile;
node: NodeType;
}
| {
type: PathFindResultType.Pending;
}
| {
type: PathFindResultType.Completed;
node: NodeType;
}
| {
type: PathFindResultType.PathNotFound;
};
export interface Point {
x: number;
y: number;
}
-177
View File
@@ -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<TileRef> {
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<TileRef> {
private aStar: AStar<TileRef>;
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;
}
+58
View File
@@ -0,0 +1,58 @@
import { Game } from "../game/Game";
import { TileRef } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
import { PathFinder } from "./types";
export class AirPathFinder implements PathFinder<TileRef> {
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 (random.chance(ratio) && x !== dstX) {
nextX += x < dstX ? 1 : -1;
} else {
nextY += y < dstY ? 1 : -1;
}
return this.game.ref(nextX, nextY);
}
}
@@ -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<TileRef>
{
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<TileRef> {
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;
}
}
@@ -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<TrainStation> {
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);
}
}
+93 -32
View File
@@ -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<TileRef> {
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<TileRef> {
const miniMap = game.miniMap();
const pf = new AStarWater(miniMap);
return PathFinderBuilder.create(pf)
.wrap((pf) => new ShoreCoercingTransformer(pf, miniMap))
.wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap))
.buildWithStepper(tileStepperConfig(game));
}
static Rail(game: Game): SteppingPathFinder<TileRef> {
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<TrainStation> {
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<TileRef> {
const pf = new AirPathFinder(game);
return PathFinderBuilder.create(pf).buildWithStepper({
equals: (a, b) => a === b,
});
}
}
function tileStepperConfig(game: Game): StepperConfig<TileRef> {
return {
equals: (a, b) => a === b,
distance: (a, b) => game.manhattanDist(a, b),
preCheck: (from, to) =>
typeof from !== "number" ||
typeof to !== "number" ||
!game.isValidRef(from) ||
!game.isValidRef(to)
? { status: PathStatus.NOT_FOUND }
: null,
};
}
+42
View File
@@ -0,0 +1,42 @@
import { PathFinderStepper, StepperConfig } from "./PathFinderStepper";
import { PathFinder, SteppingPathFinder } from "./types";
type WrapFactory<T> = (pf: PathFinder<T>) => PathFinder<T>;
/**
* 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<T> {
private wrappers: WrapFactory<T>[] = [];
private constructor(private core: PathFinder<T>) {}
static create<T>(core: PathFinder<T>): PathFinderBuilder<T> {
return new PathFinderBuilder(core);
}
wrap(factory: WrapFactory<T>): this {
this.wrappers.push(factory);
return this;
}
build(): PathFinder<T> {
return this.wrappers.reduce(
(pf, wrapper) => wrapper(pf),
this.core as PathFinder<T>,
);
}
/**
* Build and wrap with PathFinderStepper for step-by-step traversal.
*/
buildWithStepper(config: StepperConfig<T>): SteppingPathFinder<T> {
return new PathFinderStepper(this.build(), config);
}
}
+112
View File
@@ -0,0 +1,112 @@
import {
PathFinder,
PathResult,
PathStatus,
SteppingPathFinder,
} from "./types";
export interface StepperConfig<T> {
equals: (a: T, b: T) => boolean;
distance?: (a: T, b: T) => number;
preCheck?: (from: T, to: T) => PathResult<T> | null;
}
/**
* PathFinderStepper - wraps a PathFinder and provides step-by-step traversal
*
* Handles path caching, invalidation, and incremental movement.
* Generic over any PathFinder<T> implementation.
*/
export class PathFinderStepper<T> implements SteppingPathFinder<T> {
private path: T[] | null = null;
private pathIndex = 0;
private lastTo: T | null = null;
constructor(
private finder: PathFinder<T>,
private config: StepperConfig<T> = { 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<T> {
// 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 first = Array.isArray(from) ? from[0] : from;
const result = this.config.preCheck(first, to);
if (result?.status === PathStatus.NOT_FOUND) return null;
}
return this.finder.findPath(from, to);
}
}
-217
View File
@@ -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<TileRef>;
private computeFinished = true;
constructor(
private game: Game,
private iterations: number,
private waterPath: boolean,
private maxTries: number,
) {}
private createAStar(curr: TileRef, dst: TileRef): AStar<TileRef> {
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<TileRef> {
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;
}
}
-189
View File
@@ -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<NodeType> {
neighbors(node: NodeType): NodeType[];
cost(node: NodeType): number;
position(node: NodeType): { x: number; y: number };
isTraversable(from: NodeType, to: NodeType): boolean;
}
export class SerialAStar<NodeType> implements AStar<NodeType> {
private fwdOpenSet: FastPriorityQueue<{
tile: NodeType;
fScore: number;
}>;
private bwdOpenSet: FastPriorityQueue<{
tile: NodeType;
fScore: number;
}>;
private fwdCameFrom = new Map<NodeType, NodeType>();
private bwdCameFrom = new Map<NodeType, NodeType>();
private fwdGScore = new Map<NodeType, number>();
private bwdGScore = new Map<NodeType, number>();
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<NodeType>,
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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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<number> {
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;
}
}
@@ -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<number> {
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;
}
}
@@ -0,0 +1,101 @@
import { GameMap } from "../../game/GameMap";
import { PathFinder } from "../types";
import { AStar, AStarAdapter } from "./AStar";
export class AStarRail implements PathFinder<number> {
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));
}
}
@@ -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<number> {
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;
}
}
@@ -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<number> {
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<nodeId, sourceTile>
resolveSourcesToNodes(sources: TileRef[]): Map<number, TileRef> {
const nodeToSource = new Map<number, TileRef>();
const nodeToDist = new Map<number, number>();
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;
}
}
+127
View File
@@ -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<number> {
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;
}
}
@@ -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<TileRef, AbstractNode>();
private nextNodeId = 0;
private nextEdgeId = 0;
private edgeBetween = new Map<number, Map<number, AbstractEdge>>();
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<number, number> {
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<TileRef, number>();
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<number, number>();
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;
}
}
@@ -1,11 +1,7 @@
export interface FastBFSAdapter<T> {
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<T>(
/**
* 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<R>(
width: number,
height: number,
start: number,
start: number | number[],
maxDistance: number,
isValidNode: FastBFSAdapter<T>["isValidNode"],
visitor: FastBFSAdapter<T>["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;
}
+64
View File
@@ -0,0 +1,64 @@
// Generic BFS implementation with adapter interface
export interface BFSAdapter<T> {
neighbors(node: T): T[];
}
export class BFS<T> {
constructor(private adapter: BFSAdapter<T>) {}
/**
* 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<R>(
start: T | T[],
maxDistance: number,
visitor: (node: T, dist: number) => R | null | undefined,
): R | null {
const visited = new Set<T>();
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;
}
}
@@ -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;
@@ -0,0 +1,150 @@
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(capacity: number) {
this.heap = new Int32Array(capacity);
this.priorities = new Float32Array(capacity);
}
push(node: number, priority: number): void {
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;
}
}
-202
View File
@@ -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;
}
}
@@ -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
}
}
@@ -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<number, Sector>,
readonly gateways: ReadonlyMap<number, Gateway>,
readonly edges: ReadonlyMap<number, Edge[]>,
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<number, Sector>();
private gateways = new Map<number, Gateway>();
private tileToGateway = new Map<TileRef, Gateway>();
private edges = new Map<number, Edge[]>();
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<number, number> {
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<TileRef, number>();
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<number, number>();
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;
}
}
-819
View File
@@ -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;
}
}
@@ -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<TileRef> {
constructor(
private map: GameMap,
private isTraversable: (tile: TileRef) => boolean,
) {}
smooth(path: TileRef[]): TileRef[] {
if (path.length <= 2) {
return path;
}
const smoothed: TileRef[] = [];
let current = 0;
while (current < path.length - 1) {
let farthest = current + 1;
let bestTrace: TileRef[] | null = null;
for (
let i = current + 2;
i < path.length;
i += Math.max(1, Math.floor(path.length / 20))
) {
const trace = this.tracePath(path[current], path[i]);
if (trace !== null) {
farthest = i;
bestTrace = trace;
} else {
break;
}
}
if (
farthest < path.length - 1 &&
(path.length - 1 - current) % 10 !== 0
) {
const trace = this.tracePath(path[current], path[path.length - 1]);
if (trace !== null) {
farthest = path.length - 1;
bestTrace = trace;
}
}
if (bestTrace !== null && farthest > current + 1) {
smoothed.push(...bestTrace.slice(0, -1));
} else {
smoothed.push(path[current]);
}
current = farthest;
}
smoothed.push(path[path.length - 1]);
return smoothed;
}
private tracePath(from: TileRef, to: TileRef): TileRef[] | null {
const x0 = this.map.x(from);
const y0 = this.map.y(from);
const x1 = this.map.x(to);
const y1 = this.map.y(to);
const tiles: TileRef[] = [];
const dx = Math.abs(x1 - x0);
const dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
let err = dx - dy;
let x = x0;
let y = y0;
const maxTiles = 100000;
let iterations = 0;
while (true) {
if (iterations++ > maxTiles) {
return null;
}
const tile = this.map.ref(x, y);
if (!this.isTraversable(tile)) {
return null;
}
tiles.push(tile);
if (x === x1 && y === y1) {
break;
}
const e2 = 2 * err;
const shouldMoveX = e2 > -dy;
const shouldMoveY = e2 < dx;
if (shouldMoveX && shouldMoveY) {
x += sx;
err -= dy;
const intermediateTile = this.map.ref(x, y);
if (!this.isTraversable(intermediateTile)) {
x -= sx;
err += dy;
y += sy;
err += dx;
const altTile = this.map.ref(x, y);
if (!this.isTraversable(altTile)) {
return null;
}
tiles.push(altTile);
x += sx;
err -= dy;
} else {
tiles.push(intermediateTile);
y += sy;
err += dx;
}
} else {
if (shouldMoveX) {
x += sx;
err -= dy;
}
if (shouldMoveY) {
y += sy;
err += dx;
}
}
}
return tiles;
}
}
/**
* Ready-to-use transformer that applies Bresenham smoothing.
* Defaults to water traversability.
*/
export class BresenhamSmoothingTransformer implements PathFinder<TileRef> {
private smoother: BresenhamPathSmoother;
constructor(
private inner: PathFinder<TileRef>,
map: GameMap,
isTraversable: (tile: TileRef) => boolean = (t) => map.isWater(t),
) {
this.smoother = new BresenhamPathSmoother(map, isTraversable);
}
findPath(from: TileRef | TileRef[], to: TileRef): TileRef[] | null {
const path = this.inner.findPath(from, to);
return path ? this.smoother.smooth(path) : null;
}
}
@@ -0,0 +1,7 @@
/**
* PathSmoother - interface for path smoothing algorithms.
* Takes a path and returns a smoothed version.
*/
export interface PathSmoother<T> {
smooth(path: T[]): T[];
}
@@ -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<T> implements PathFinder<T> {
constructor(
private inner: PathFinder<T>,
private smoother: PathSmoother<T>,
) {}
findPath(from: T | T[], to: T): T[] | null {
const path = this.inner.findPath(from, to);
return path ? this.smoother.smooth(path) : null;
}
}
@@ -0,0 +1,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;
}
}
@@ -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<T> implements PathFinder<T> {
constructor(
private inner: PathFinder<T>,
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);
}
}
@@ -0,0 +1,128 @@
import { Cell } from "../../game/Game";
import { GameMap, TileRef } from "../../game/GameMap";
import { PathFinder } from "../types";
export class MiniMapTransformer implements PathFinder<number> {
constructor(
private inner: PathFinder<number>,
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;
}
}
@@ -0,0 +1,120 @@
// 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<number> {
constructor(
private inner: PathFinder<number>,
private map: GameMap,
) {}
findPath(from: TileRef | TileRef[], to: TileRef): TileRef[] | null {
// Coerce from tiles
const fromArray = Array.isArray(from) ? from : [from];
const coercedFromArray: Array<{
water: TileRef;
original: TileRef | null;
}> = [];
for (const f of fromArray) {
const coerced = this.coerceToWater(f);
if (coerced.water !== null) {
coercedFromArray.push({
water: coerced.water,
original: coerced.original,
});
}
}
if (coercedFromArray.length === 0) {
return null;
}
// Coerce to tile
const coercedTo = this.coerceToWater(to);
if (coercedTo.water === null) {
return null;
}
// Build water-only from array
const waterFrom =
coercedFromArray.length === 1
? coercedFromArray[0].water
: coercedFromArray.map((c) => c.water);
// Search on water tiles
const path = this.inner.findPath(waterFrom, coercedTo.water);
if (!path || path.length === 0) {
return null;
}
// Fix extremes: find which source was used and prepend/append originals
const result = [...path];
// Find the original for the source that was used (closest to path start)
if (coercedFromArray.length > 0) {
const pathStart = result[0];
let bestOriginal: TileRef | null = null;
let minDist = Infinity;
for (const { water, original } of coercedFromArray) {
if (original !== null) {
const dist = this.map.manhattanDist(pathStart, water);
if (dist < minDist) {
minDist = dist;
bestOriginal = original;
}
}
}
// Prepend original if we have one and it's not already at start
if (bestOriginal !== null && result[0] !== bestOriginal) {
result.unshift(bestOriginal);
}
}
// Append original to if different
if (
coercedTo.original !== null &&
result[result.length - 1] !== coercedTo.original
) {
result.push(coercedTo.original);
}
return result;
}
/**
* 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 };
}
}
+34
View File
@@ -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<T> =
| { 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<T> {
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<T> extends PathFinder<T> {
next(from: T, to: T, dist?: number): PathResult<T>;
invalidate(): void;
}
+1
View File
@@ -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(
@@ -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", () => {
-333
View File
@@ -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();
});
});
});
@@ -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<string, number[]>,
): PathFinder<number> {
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<string, number[]>();
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<string, number[]>([["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<string, number[]>();
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<string, number[]>([
["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<string, number[]>([
["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<number> = {
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<string, number[]>([["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<number> = {
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<Pos> = {
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 });
});
});
});
@@ -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<TileRef> {
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);
});
});
});
@@ -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<TileRef>;
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);
});
});
});
@@ -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<TileRef> {
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();
});
});
});
+230
View File
@@ -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());
});
});
});
@@ -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);
}
});
});
});
@@ -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),
);
}
}
});
});
});
+179
View File
@@ -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);
}
@@ -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<number> & {
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<number> = {
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<number> = {
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();
});
});
});
@@ -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<number> & {
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));
});
});
});
@@ -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<number> & {
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]);
});
});
});
-135
View File
@@ -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 (2x21, water if ANY tile water)
*
* Example:
* const game = await gameFromString([
* 'WWWWW',
* 'WLLLW',
* 'WWWWW'
* ]);
*/
export async function gameFromString(mapRows: string[]): Promise<Game> {
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);
}
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env node
/**
* Compare pathfinding adapters side-by-side
*
* Usage:
* npx tsx tests/pathfinding/benchmark/compare.ts <scenario> <adapters>
* npx tsx tests/pathfinding/benchmark/compare.ts --synthetic <map-name> <adapters>
*
* Examples:
* npx tsx tests/pathfinding/benchmark/compare.ts default hpa,legacy
* npx tsx tests/pathfinding/benchmark/compare.ts --synthetic giantworldmap hpa,legacy,a.optimized
*/
import {
type BenchmarkResult,
calculateStats,
getAdapter,
getScenario,
measureExecutionTime,
measurePathLength,
} from "../utils";
interface AdapterResults {
adapter: string;
initTime: number;
totalTime: number;
totalDistance: number;
successfulRoutes: number;
totalRoutes: number;
}
const DEFAULT_ITERATIONS = 1;
async function runBenchmark(
scenarioName: string,
adapterName: string,
): Promise<AdapterResults> {
const { game, routes, initTime } = await getScenario(
scenarioName,
adapterName,
);
const adapter = getAdapter(game, adapterName);
const results: BenchmarkResult[] = [];
// Measure path lengths
for (const route of routes) {
const pathLength = measurePathLength(adapter, route);
results.push({ route: route.name, pathLength, executionTime: null });
}
// Measure execution times
for (const route of routes) {
const result = results.find((r) => r.route === route.name);
if (result && result.pathLength !== null) {
const execTime = measureExecutionTime(adapter, route, DEFAULT_ITERATIONS);
result.executionTime = execTime;
}
}
const stats = calculateStats(results);
return {
adapter: adapterName,
initTime,
totalTime: stats.totalTime,
totalDistance: stats.totalDistance,
successfulRoutes: stats.successfulRoutes,
totalRoutes: stats.totalRoutes,
};
}
const TABLE_HEADERS = [
"Adapter",
"Init (ms)",
"Path (ms)",
"Distance",
"Routes",
];
const TABLE_WIDTHS = [20, 12, 12, 12, 10];
function printTableHeader(scenarioName: string) {
console.log(`\nResults: ${scenarioName}`);
console.log("=".repeat(70));
console.log(TABLE_HEADERS.map((h, i) => h.padEnd(TABLE_WIDTHS[i])).join(" "));
console.log("-".repeat(70));
}
function printTableRow(r: AdapterResults) {
const row = [
r.adapter,
r.initTime.toFixed(2),
r.totalTime.toFixed(2),
r.totalDistance.toString(),
`${r.successfulRoutes}/${r.totalRoutes}`,
];
console.log(row.map((c, i) => c.padEnd(TABLE_WIDTHS[i])).join(" "));
}
function printTableFooter() {
console.log("-".repeat(70));
}
function printUsage() {
console.log(`
Usage:
npx tsx tests/pathfinding/benchmark/compare.ts <scenario> <adapters>
npx tsx tests/pathfinding/benchmark/compare.ts --synthetic <map-name> <adapters>
Arguments:
<scenario> Name of the scenario (default: "default")
<adapters> Comma-separated list of adapters to compare (e.g., "hpa,legacy,a.optimized")
Examples:
npx tsx tests/pathfinding/benchmark/compare.ts default hpa,legacy
npx tsx tests/pathfinding/benchmark/compare.ts --synthetic giantworldmap hpa,legacy,a.optimized
Available adapters:
hpa - Hierarchical pathfinding (no cache)
hpa.cached - Hierarchical pathfinding (with cache)
legacy - Legacy A* algorithm
a.optimized - Optimized A* algorithm
`);
}
async function main() {
const args = process.argv.slice(2);
if (args.includes("--help") || args.includes("-h")) {
printUsage();
process.exit(0);
}
const isSynthetic = args.includes("--synthetic");
const nonFlagArgs = args.filter((arg) => !arg.startsWith("--"));
if (nonFlagArgs.length < 2) {
console.error("Error: requires <scenario> and <adapters> arguments");
printUsage();
process.exit(1);
}
const scenarioArg = nonFlagArgs[0];
const adaptersArg = nonFlagArgs[1];
const adapters = adaptersArg.split(",").map((a) => a.trim());
if (adapters.length < 1) {
console.error("Error: at least one adapter required");
process.exit(1);
}
const scenarioName = isSynthetic ? `synthetic/${scenarioArg}` : scenarioArg;
console.log(
`Comparing ${adapters.length} adapters on scenario: ${scenarioName}`,
);
console.log(`Adapters: ${adapters.join(", ")}`);
console.log("");
printTableHeader(scenarioName);
for (const adapter of adapters) {
try {
const result = await runBenchmark(scenarioName, adapter);
printTableRow(result);
} catch (error) {
console.log(`${adapter.padEnd(TABLE_WIDTHS[0])} FAILED: ${error}`);
}
}
printTableFooter();
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
+54 -60
View File
@@ -2,10 +2,13 @@ import { readdirSync, readFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { Game } from "../../../../src/core/game/Game.js";
import { TileRef } from "../../../../src/core/game/GameMap.js";
import { NavMesh } from "../../../../src/core/pathfinding/navmesh/NavMesh.js";
import { AStarWaterHierarchical } from "../../../../src/core/pathfinding/algorithms/AStar.WaterHierarchical.js";
import { setupFromPath } from "../../utils.js";
// Available comparison adapters
// Note: "hpa" runs same algorithm without debug overhead for fair timing comparison
export const COMPARISON_ADAPTERS = ["hpa", "a.baseline", "a.generic", "a.full"];
export interface MapInfo {
name: string;
displayName: string;
@@ -13,7 +16,7 @@ export interface MapInfo {
export interface MapCache {
game: Game;
navMesh: NavMesh;
hpaStar: AStarWaterHierarchical;
}
const cache = new Map<string, MapCache>();
@@ -114,13 +117,20 @@ export async function loadMap(mapName: string): Promise<MapCache> {
const mapsDir = getMapsDirectory();
// Use the existing setupFromPath utility to load the map
const game = await setupFromPath(mapsDir, mapName);
const game = await setupFromPath(mapsDir, mapName, { disableNavMesh: false });
// Initialize NavMesh
const navMesh = new NavMesh(game, { cachePaths: config.cachePaths });
navMesh.initialize();
// Get pre-built graph from game
const graph = game.miniWaterGraph();
if (!graph) {
throw new Error(`No water graph available for map: ${mapName}`);
}
const cacheEntry: MapCache = { game, navMesh };
// Initialize AStarWaterHierarchical with minimap and graph
const hpaStar = new AStarWaterHierarchical(game.miniMap(), graph, {
cachePaths: config.cachePaths,
});
const cacheEntry: MapCache = { game, hpaStar };
// Store in cache
cache.set(mapName, cacheEntry);
@@ -132,7 +142,7 @@ export async function loadMap(mapName: string): Promise<MapCache> {
* Get map metadata for client
*/
export async function getMapMetadata(mapName: string) {
const { game, navMesh } = await loadMap(mapName);
const { game, hpaStar } = await loadMap(mapName);
// Extract map data
const mapData: number[] = [];
@@ -143,65 +153,48 @@ export async function getMapMetadata(mapName: string) {
}
}
// Extract static graph data from NavMesh
// Extract static graph data from GameMapHPAStar
// Access internal graph via type casting (test code only)
const graph = (hpaStar as any).graph;
const miniMap = game.miniMap();
const navMeshGraph = (navMesh as any).graph;
// Convert gateways from Map to array
const gatewaysArray = Array.from(navMeshGraph.gateways.values());
const allGateways = gatewaysArray.map((gw: any) => ({
id: gw.id,
x: miniMap.x(gw.tile),
y: miniMap.y(gw.tile),
// Convert nodes to client format
const allNodes = graph.getAllNodes().map((node: any) => ({
id: node.id,
x: miniMap.x(node.tile),
y: miniMap.y(node.tile),
}));
// Create a lookup map from gateway ID to gateway for edge conversion
const gatewayById = new Map(gatewaysArray.map((gw: any) => [gw.id, gw]));
// Convert edges to client format
const edges: Array<{
fromId: number;
toId: number;
from: number[];
to: number[];
cost: number;
}> = [];
for (let i = 0; i < graph.edgeCount; i++) {
const edge = graph.getEdge(i);
if (!edge) continue;
// Convert edges from Map<gatewayId, Edge[]> to flat array
// The edges Map has gateway IDs as keys, and arrays of edges as values
const allEdges: any[] = [];
for (const edgeArray of navMeshGraph.edges.values()) {
allEdges.push(...edgeArray);
const nodeA = graph.getNode(edge.nodeA);
const nodeB = graph.getNode(edge.nodeB);
if (!nodeA || !nodeB) continue;
edges.push({
fromId: edge.nodeA,
toId: edge.nodeB,
from: [miniMap.x(nodeA.tile) * 2, miniMap.y(nodeA.tile) * 2],
to: [miniMap.x(nodeB.tile) * 2, miniMap.y(nodeB.tile) * 2],
cost: edge.cost,
});
}
// Deduplicate edges (they're bidirectional, so each edge appears twice)
const seenEdges = new Set<string>();
const edges = allEdges
.filter((edge: any) => {
const edgeKey =
edge.from < edge.to
? `${edge.from}-${edge.to}`
: `${edge.to}-${edge.from}`;
if (seenEdges.has(edgeKey)) return false;
seenEdges.add(edgeKey);
return true;
})
.map((edge: any) => {
const fromGateway = gatewayById.get(edge.from);
const toGateway = gatewayById.get(edge.to);
return {
fromId: edge.from,
toId: edge.to,
from: fromGateway
? [miniMap.x(fromGateway.tile) * 2, miniMap.y(fromGateway.tile) * 2]
: [0, 0],
to: toGateway
? [miniMap.x(toGateway.tile) * 2, miniMap.y(toGateway.tile) * 2]
: [0, 0],
cost: edge.cost,
path: edge.path
? edge.path.map((tile: TileRef) => [game.x(tile), game.y(tile)])
: null,
};
});
console.log(
`Map ${mapName}: ${allGateways.length} gateways, ${edges.length} edges`,
`Map ${mapName}: ${allNodes.length} nodes, ${edges.length} edges`,
);
const sectorSize = navMeshGraph.sectorSize;
const clusterSize = graph.clusterSize;
return {
name: mapName,
@@ -209,10 +202,11 @@ export async function getMapMetadata(mapName: string) {
height: game.height(),
mapData,
graphDebug: {
allGateways,
allNodes,
edges,
sectorSize,
clusterSize,
},
adapters: COMPARISON_ADAPTERS,
};
}
+191 -99
View File
@@ -1,39 +1,64 @@
import { TileRef } from "../../../../src/core/game/GameMap.js";
import { MiniAStarAdapter } from "../../../../src/core/pathfinding/adapters/MiniAStarAdapter.js";
import { loadMap } from "./maps.js";
import { AStarWaterHierarchical } from "../../../../src/core/pathfinding/algorithms/AStar.WaterHierarchical.js";
import { BresenhamSmoothingTransformer } from "../../../../src/core/pathfinding/smoothing/BresenhamPathSmoother.js";
import { ComponentCheckTransformer } from "../../../../src/core/pathfinding/transformers/ComponentCheckTransformer.js";
import { MiniMapTransformer } from "../../../../src/core/pathfinding/transformers/MiniMapTransformer.js";
import { ShoreCoercingTransformer } from "../../../../src/core/pathfinding/transformers/ShoreCoercingTransformer.js";
import {
PathFinder,
SteppingPathFinder,
} from "../../../../src/core/pathfinding/types.js";
import { getAdapter } from "../../utils.js";
import { COMPARISON_ADAPTERS, loadMap } from "./maps.js";
interface PathfindingOptions {
includePfMini?: boolean;
includeNavMesh?: boolean;
}
interface NavMeshResult {
// Primary result with debug info
interface PrimaryResult {
path: Array<[number, number]> | null;
initialPath: Array<[number, number]> | null;
gateways: Array<[number, number]> | null;
timings: any;
length: number;
time: number;
debug: {
nodePath: Array<[number, number]> | null;
initialPath: Array<[number, number]> | null;
timings: Record<string, number>;
};
}
interface PfMiniResult {
// Comparison result (path + timing only)
interface ComparisonResult {
adapter: string;
path: Array<[number, number]> | null;
length: number;
time: number;
}
// Cache pathfinding adapters per map
const pfMiniCache = new Map<string, MiniAStarAdapter>();
export interface PathfindResult {
primary: PrimaryResult;
comparisons: ComparisonResult[];
}
// Cache adapters per map
const adapterCache = new Map<
string,
Map<string, SteppingPathFinder<TileRef>>
>();
/**
* Get or create MiniAStar adapter for a map
* Get or create an adapter for a map
*/
function getPfMiniAdapter(mapName: string, game: any): MiniAStarAdapter {
if (!pfMiniCache.has(mapName)) {
const adapter = new MiniAStarAdapter(game, { waterPath: true });
pfMiniCache.set(mapName, adapter);
function getOrCreateAdapter(
mapName: string,
adapterName: string,
game: any,
): SteppingPathFinder<TileRef> {
if (!adapterCache.has(mapName)) {
adapterCache.set(mapName, new Map());
}
return pfMiniCache.get(mapName)!;
const mapAdapters = adapterCache.get(mapName)!;
if (!mapAdapters.has(adapterName)) {
mapAdapters.set(adapterName, getAdapter(game, adapterName));
}
return mapAdapters.get(adapterName)!;
}
/**
@@ -48,110 +73,177 @@ function pathToCoords(
}
/**
* Compute pathfinding between two points
* Build the full transformer chain like PathFinding.Water() does
*/
export async function computePath(
mapName: string,
from: [number, number],
to: [number, number],
options: PathfindingOptions = {},
): Promise<NavMeshResult> {
const { game, navMesh: navMeshAdapter } = await loadMap(mapName);
function buildWrappedPathFinder(
hpaStar: AStarWaterHierarchical,
game: any,
graph: any,
): PathFinder<TileRef> {
const miniMap = game.miniMap();
const componentCheckFn = (t: TileRef) => graph.getComponentId(t);
// Convert coordinates to TileRefs
const fromRef = game.ref(from[0], from[1]);
const toRef = game.ref(to[0], to[1]);
// Chain: hpaStar -> ComponentCheck -> Bresenham -> ShoreCoercing -> MiniMap
const withComponentCheck = new ComponentCheckTransformer(
hpaStar,
componentCheckFn,
);
const withSmoothing = new BresenhamSmoothingTransformer(
withComponentCheck,
miniMap,
);
const withShoreCoercing = new ShoreCoercingTransformer(
withSmoothing,
miniMap,
);
const withMiniMap = new MiniMapTransformer(withShoreCoercing, game, miniMap);
// Validate that both points are water tiles
if (!game.isWater(fromRef)) {
throw new Error(`Start point (${from[0]}, ${from[1]}) is not water`);
}
if (!game.isWater(toRef)) {
throw new Error(`End point (${to[0]}, ${to[1]}) is not water`);
}
// Compute NavMesh path
const navMeshPath = navMeshAdapter.findPath(fromRef, toRef, true);
const path = pathToCoords(navMeshPath, game);
return withMiniMap;
}
/**
* Compute primary path using AStarWaterHierarchical with debug info
* Uses the same transformer chain as PathFinding.Water()
*/
function computePrimaryPath(
hpaStar: AStarWaterHierarchical,
game: any,
graph: any,
fromRef: TileRef,
toRef: TileRef,
): PrimaryResult {
const miniMap = game.miniMap();
// Extract debug info
let gateways: Array<[number, number]> | null = null;
// Build wrapped pathfinder with all transformers
const wrappedPf = buildWrappedPathFinder(hpaStar, game, graph);
// Enable debug mode to capture internal state
hpaStar.debugMode = true;
const start = performance.now();
const path = wrappedPf.findPath(fromRef, toRef);
const time = performance.now() - start;
const debugInfo = hpaStar.debugInfo;
// Convert node path (miniMap coords) to full map coords
let nodePath: Array<[number, number]> | null = null;
if (debugInfo?.nodePath) {
nodePath = debugInfo.nodePath.map((tile: TileRef) => {
const x = miniMap.x(tile) * 2;
const y = miniMap.y(tile) * 2;
return [x, y] as [number, number];
});
}
// Convert initialPath (miniMap TileRefs) to full map coords
let initialPath: Array<[number, number]> | null = null;
let timings: any = {};
if (navMeshAdapter.debugInfo) {
// Convert gatewayPath (TileRefs on miniMap) to full map coordinates
if (navMeshAdapter.debugInfo.gatewayPath) {
gateways = navMeshAdapter.debugInfo.gatewayPath.map((tile: TileRef) => {
const x = miniMap.x(tile) * 2;
const y = miniMap.y(tile) * 2;
return [x, y] as [number, number];
});
}
// Convert initial path
if (navMeshAdapter.debugInfo.initialPath) {
initialPath = navMeshAdapter.debugInfo.initialPath.map(
(tile: TileRef) => [game.x(tile), game.y(tile)] as [number, number],
);
}
timings = navMeshAdapter.debugInfo.timings || {};
if (debugInfo?.initialPath) {
initialPath = debugInfo.initialPath.map((tile: TileRef) => {
const x = miniMap.x(tile) * 2;
const y = miniMap.y(tile) * 2;
return [x, y] as [number, number];
});
}
return {
path,
initialPath,
gateways,
timings,
path: pathToCoords(path, game),
length: path ? path.length : 0,
time: timings.total ?? 0,
time,
debug: {
nodePath,
initialPath,
timings: debugInfo?.timings ?? {},
},
};
}
/**
* Compute only PathFinder.Mini path
* Compute comparison path using adapter
*/
export async function computePfMiniPath(
mapName: string,
from: [number, number],
to: [number, number],
): Promise<PfMiniResult> {
const { game } = await loadMap(mapName);
// Convert coordinates to TileRefs
const fromRef = game.ref(from[0], from[1]);
const toRef = game.ref(to[0], to[1]);
// Validate that both points are water tiles
if (!game.isWater(fromRef)) {
throw new Error(`Start point (${from[0]}, ${from[1]}) is not water`);
}
if (!game.isWater(toRef)) {
throw new Error(`End point (${to[0]}, ${to[1]}) is not water`);
}
// Compute PathFinder.Mini path
const pfMiniAdapter = getPfMiniAdapter(mapName, game);
const pfMiniStart = performance.now();
const pfMiniPath = pfMiniAdapter.findPath(fromRef, toRef);
const pfMiniEnd = performance.now();
const path = pathToCoords(pfMiniPath, game);
const time = pfMiniEnd - pfMiniStart;
function computeComparisonPath(
adapter: SteppingPathFinder<TileRef>,
game: any,
fromRef: TileRef,
toRef: TileRef,
adapterName: string,
): ComparisonResult {
const start = performance.now();
const path = adapter.findPath(fromRef, toRef);
const time = performance.now() - start;
return {
path,
adapter: adapterName,
path: pathToCoords(path, game),
length: path ? path.length : 0,
time,
};
}
/**
* Compute pathfinding between two points
*/
export async function computePath(
mapName: string,
from: [number, number],
to: [number, number],
options: { adapters?: string[] } = {},
): Promise<PathfindResult> {
const { game, hpaStar } = await loadMap(mapName);
const graph = game.miniWaterGraph();
// Convert coordinates to TileRefs
const fromRef = game.ref(from[0], from[1]);
const toRef = game.ref(to[0], to[1]);
// Validate that both points are water tiles
if (!game.isWater(fromRef)) {
throw new Error(`Start point (${from[0]}, ${from[1]}) is not water`);
}
if (!game.isWater(toRef)) {
throw new Error(`End point (${to[0]}, ${to[1]}) is not water`);
}
// Compute primary path (HPA* with debug)
const primary = computePrimaryPath(hpaStar, game, graph, fromRef, toRef);
// Compute comparison paths
const selectedAdapters = options.adapters ?? COMPARISON_ADAPTERS;
const comparisons: ComparisonResult[] = [];
for (const adapterName of selectedAdapters) {
if (!COMPARISON_ADAPTERS.includes(adapterName)) {
console.warn(`Unknown adapter: ${adapterName}, skipping`);
continue;
}
try {
const adapter = getOrCreateAdapter(mapName, adapterName, game);
const result = computeComparisonPath(
adapter,
game,
fromRef,
toRef,
adapterName,
);
comparisons.push(result);
} catch (error) {
console.error(`Error with adapter ${adapterName}:`, error);
comparisons.push({
adapter: adapterName,
path: null,
length: 0,
time: 0,
});
}
}
return { primary, comparisons };
}
/**
* Clear pathfinding adapter caches
*/
export function clearAdapterCaches() {
pfMiniCache.clear();
adapterCache.clear();
}
File diff suppressed because it is too large Load Diff
+21 -58
View File
@@ -129,13 +129,13 @@
<button class="toggle-button" id="showInitialPath" data-active="false">
Initial Path
</button>
<button class="toggle-button" id="showUsedGateways" data-active="false">
Used Gateways
<button class="toggle-button" id="showUsedNodes" data-active="false">
Used Nodes
</button>
</div>
<div class="debug-panel-row">
<button class="toggle-button" id="showGateways" data-active="false">
Gateways
<button class="toggle-button" id="showNodes" data-active="false">
Nodes
</button>
<button class="toggle-button" id="showSectorGrid" data-active="false">
Sectors
@@ -166,75 +166,42 @@
<div class="timing-label">
<button
class="refresh-icon"
id="refreshNavMesh"
title="Recompute NavMesh path"
id="refreshHpa"
title="Recompute HPA* path"
>
<span></span>
</button>
NavMesh <span class="timing-label-detail" id="navMeshTiles"></span>
HPA* <span class="timing-label-detail" id="hpaTiles"></span>
</div>
<div class="timing-value-large" id="navMeshTime"></div>
<div class="timing-value-large" id="hpaTime"></div>
<div class="timing-breakdown" id="timingBreakdown">
<div class="timing-item" id="timingEarlyExit" style="display: none">
<span class="timing-name">Early Exit:</span>
<span class="timing-value" id="timingEarlyExitValue"></span>
</div>
<div class="timing-item" id="timingFindNodes" style="display: none">
<span class="timing-name">Find Nodes:</span>
<span class="timing-value" id="timingFindNodesValue"></span>
</div>
<div
class="timing-item"
id="timingFindGateways"
id="timingAbstractPath"
style="display: none"
>
<span class="timing-name">Find Gateways:</span>
<span class="timing-value" id="timingFindGatewaysValue"></span>
</div>
<div class="timing-item" id="timingGatewayPath" style="display: none">
<span class="timing-name">Gateway Path:</span>
<span class="timing-value" id="timingGatewayPathValue"></span>
<span class="timing-name">Abstract Path:</span>
<span class="timing-value" id="timingAbstractPathValue"></span>
</div>
<div class="timing-item" id="timingInitialPath" style="display: none">
<span class="timing-name">Initial Path:</span>
<span class="timing-value" id="timingInitialPathValue"></span>
</div>
<div class="timing-item" id="timingSmoothPath" style="display: none">
<span class="timing-name">Smooth Path:</span>
<span class="timing-value" id="timingSmoothPathValue"></span>
</div>
</div>
</div>
<div class="timing-section" id="pfMiniRequestSection">
<button
id="requestPfMini"
class="timing-button"
title="PathFinder.Mini is slow (50-1800ms per path). Click to compare."
disabled
>
Request PathFinder.Mini
</button>
</div>
<div
class="timing-section"
id="pfMiniTimingSection"
style="display: none"
>
<div class="timing-label">
<button
class="refresh-icon"
id="refreshPfMini"
title="Recompute PF.Mini path"
>
<span></span>
</button>
PF.Mini <span class="timing-label-detail" id="pfMiniTiles"></span>
</div>
<div class="timing-value-large" id="pfMiniTime"></div>
</div>
<div class="timing-section" id="speedupSection" style="display: none">
<div class="timing-label">Speedup</div>
<div class="timing-value-speedup" id="speedupValue"></div>
<div class="timing-section" id="comparisonsSection" style="display: none">
<div class="timing-label">Comparisons</div>
<div id="comparisonsContainer"></div>
</div>
</div>
@@ -256,13 +223,9 @@
></div>
<span>End Point</span>
</div>
<div class="legend-item" id="pfMiniLegend" style="display: none">
<div class="legend-color" style="background: #ffaa00"></div>
<span>PathFinder.Mini</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #00ffff"></div>
<span>NavMesh</span>
<span>HPA*</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #ff00ff"></div>
@@ -273,7 +236,7 @@
class="legend-color"
style="background: #ffff00; height: 8px"
></div>
<span>Used Gateways</span>
<span>Used Nodes</span>
</div>
<div class="legend-item">
<div
@@ -285,7 +248,7 @@
border-radius: 50%;
"
></div>
<span>Gateways</span>
<span>Nodes</span>
</div>
<div class="legend-item">
<div
@@ -500,6 +500,67 @@ canvas {
font-size: 20px;
}
/* Comparison rows */
.comparison-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px;
margin: 0 -8px;
font-size: 14px;
border-bottom: 1px solid #333;
cursor: pointer;
border-radius: 4px;
transition: background 0.15s;
}
.comparison-row:hover {
background: rgba(255, 255, 255, 0.1);
}
.comparison-row.active {
background: rgba(255, 255, 255, 0.15);
}
.comparison-row:last-child {
border-bottom: none;
}
.comp-color {
width: 12px;
height: 12px;
border-radius: 2px;
margin-right: 8px;
flex-shrink: 0;
opacity: 0.4;
transition: opacity 0.15s;
}
.comparison-row.active .comp-color {
opacity: 1;
}
.comp-name {
color: #aaa;
flex: 1;
font-family: monospace;
}
.comp-tiles {
font-family: monospace;
color: #888;
width: 50px;
text-align: right;
margin-right: 10px;
}
.comp-time {
font-family: monospace;
color: #f5f5f5;
width: 60px;
text-align: right;
}
/* Legend panel (right side) */
.legend-panel {
position: fixed;
+10 -68
View File
@@ -8,11 +8,7 @@ import {
listMaps,
setConfig,
} from "./api/maps.js";
import {
clearAdapterCaches,
computePath,
computePfMiniPath,
} from "./api/pathfinding.js";
import { clearAdapterCaches, computePath } from "./api/pathfinding.js";
// Parse command-line arguments
const args = process.argv.slice(2);
@@ -112,12 +108,18 @@ app.get("/api/maps/:name/thumbnail", (req: Request, res: Response) => {
* map: string,
* from: [x, y],
* to: [x, y],
* includePfMini?: boolean
* adapters?: string[] // Optional: which comparison adapters to run
* }
*
* Response:
* {
* primary: { path, length, time, debug: { nodePath, initialPath, timings } },
* comparisons: [{ adapter, path, length, time }, ...]
* }
*/
app.post("/api/pathfind", async (req: Request, res: Response) => {
try {
const { map, from, to, includePfMini } = req.body;
const { map, from, to, adapters } = req.body;
// Validate request
if (!map || !from || !to) {
@@ -144,7 +146,7 @@ app.post("/api/pathfind", async (req: Request, res: Response) => {
map,
from as [number, number],
to as [number, number],
{ includePfMini: !!includePfMini },
{ adapters },
);
res.json(result);
@@ -165,66 +167,6 @@ app.post("/api/pathfind", async (req: Request, res: Response) => {
}
});
/**
* POST /api/pathfind-pfmini
* Compute only PathFinder.Mini path
*
* Request body:
* {
* map: string,
* from: [x, y],
* to: [x, y]
* }
*/
app.post("/api/pathfind-pfmini", async (req: Request, res: Response) => {
try {
const { map, from, to } = req.body;
// Validate request
if (!map || !from || !to) {
return res.status(400).json({
error: "Invalid request",
message: "Missing required fields: map, from, to",
});
}
if (
!Array.isArray(from) ||
from.length !== 2 ||
!Array.isArray(to) ||
to.length !== 2
) {
return res.status(400).json({
error: "Invalid coordinates",
message: "from and to must be [x, y] coordinate arrays",
});
}
// Compute PF.Mini path only
const result = await computePfMiniPath(
map,
from as [number, number],
to as [number, number],
);
res.json(result);
} catch (error) {
console.error("Error computing PF.Mini path:", error);
if (error instanceof Error && error.message.includes("is not water")) {
res.status(400).json({
error: "Invalid coordinates",
message: error.message,
});
} else {
res.status(500).json({
error: "Failed to compute PF.Mini path",
message: error instanceof Error ? error.message : String(error),
});
}
}
});
/**
* POST /api/cache/clear
* Clear all caches (useful for development)
+58 -16
View File
@@ -17,10 +17,19 @@ import {
MapManifest,
} from "../../src/core/game/TerrainMapLoader";
import { UserSettings } from "../../src/core/game/UserSettings";
import { NavMesh } from "../../src/core/pathfinding/navmesh/NavMesh";
import { PathFinder, PathFinders } from "../../src/core/pathfinding/PathFinder";
import { AStarWater } from "../../src/core/pathfinding/algorithms/AStar.Water";
import { AStarWaterHierarchical } from "../../src/core/pathfinding/algorithms/AStar.WaterHierarchical";
import { PathFinding } from "../../src/core/pathfinding/PathFinder";
import { PathFinderBuilder } from "../../src/core/pathfinding/PathFinderBuilder";
import { StepperConfig } from "../../src/core/pathfinding/PathFinderStepper";
import { MiniMapTransformer } from "../../src/core/pathfinding/transformers/MiniMapTransformer";
import {
PathStatus,
SteppingPathFinder,
} from "../../src/core/pathfinding/types";
import { GameConfig } from "../../src/core/Schemas";
import { TestConfig } from "../util/TestConfig";
export type BenchmarkRoute = {
name: string;
from: TileRef;
@@ -42,25 +51,58 @@ export type BenchmarkSummary = {
avgTime: number;
};
export function getAdapter(game: Game, name: string): PathFinder {
function tileStepperConfig(game: Game): StepperConfig<TileRef> {
return {
equals: (a, b) => a === b,
distance: (a, b) => game.manhattanDist(a, b),
preCheck: (from, to) =>
typeof from !== "number" ||
typeof to !== "number" ||
!game.isValidRef(from) ||
!game.isValidRef(to)
? { status: PathStatus.NOT_FOUND }
: null,
};
}
export function getAdapter(
game: Game,
name: string,
): SteppingPathFinder<TileRef> {
switch (name) {
case "legacy":
return PathFinders.WaterLegacy(game, {
iterations: 500_000,
maxTries: 50,
});
case "a.baseline": {
return PathFinderBuilder.create(new AStarWater(game.miniMap()))
.wrap((pf) => new MiniMapTransformer(pf, game, game.miniMap()))
.buildWithStepper(tileStepperConfig(game));
}
case "a.generic": {
// Same as baseline - uses AStarWater on minimap
return PathFinderBuilder.create(new AStarWater(game.miniMap()))
.wrap((pf) => new MiniMapTransformer(pf, game, game.miniMap()))
.buildWithStepper(tileStepperConfig(game));
}
case "a.full": {
return PathFinderBuilder.create(
new AStarWater(game.map()),
).buildWithStepper(tileStepperConfig(game));
}
case "hpa": {
// Recreate NavMesh without cache, this approach was chosen
// Recreate AStarWaterHierarchical without cache, this approach was chosen
// over adding cache toggles to the existing game instance
// to avoid adding side effect from benchmark to the game
const navMesh = new NavMesh(game, { cachePaths: false });
navMesh.initialize();
(game as any)._navMesh = navMesh;
const graph = game.miniWaterGraph();
if (!graph) {
throw new Error("miniWaterGraph not available");
}
const hpa = new AStarWaterHierarchical(game.miniMap(), graph, {
cachePaths: false,
});
(game as any)._miniWaterHPA = hpa;
return PathFinders.Water(game);
return PathFinding.Water(game);
}
case "hpa.cached":
return PathFinders.Water(game);
return PathFinding.Water(game);
default:
throw new Error(`Unknown pathfinding adapter: ${name}`);
}
@@ -102,7 +144,7 @@ export async function getScenario(
}
export function measurePathLength(
adapter: PathFinder,
adapter: SteppingPathFinder<TileRef>,
route: BenchmarkRoute,
): number | null {
const path = adapter.findPath(route.from, route.to);
@@ -117,7 +159,7 @@ export function measureTime<T>(fn: () => T): { result: T; time: number } {
}
export function measureExecutionTime(
adapter: PathFinder,
adapter: SteppingPathFinder<TileRef>,
route: BenchmarkRoute,
executions: number = 1,
): number | null {
-36
View File
@@ -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 });