mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-16 09:53:26 +00:00
HPA* Pathfinding (#2815)
## Pathfinding with HPA*
Hi! The primary objective of this PR is to replace per-tile A* with
hierarchical pathfinding - HPA*. In practice, this means we create an
abstract graph on top of the actual map with far fewer points and use it
to decide on general path structure. Only then we go back to tile-level
and build path between selected waypoints. This speeds up long distance
pathfinding by over 1000x in some cases. To make the review easier, it
comes with a benchmark and visual playground.
## PREPROCESSING
H part of HPA* means "hierarchical" and requires preprocessing.
This PR includes pre-processing as part inside `new Game()` constructor.
It takes about 135ms for `giantworldmap` on my machine, which increases
the effective initialization from ~95ms to ~230ms. This time could be
reduced in different ways, which are **out of scope** for this PR.
After confirming the initialization time is bearable on low-end devices,
I argue merging this PR as-is is acceptable tradeoff. It creates small
lag at the beginning of a round but pays for itself in the first minute
of the match.
## Nerdy details
**Architecture**
- HPA*-style hierarchical pathfinding
- 32×32 sectors on minimap with gateway nodes on borders
- Gateway graph built via BFS during preprocessing
- Water component optimization skips unreachable gateway pairs
- A* on gateway graph → local A* within sectors → Bresenham path
smoothing
- Minimap upscaling identical to currently used in MiniAStar
**Key Optimizations**
- Typed arrays instead of high-level primitives
- Stamp-based visited tracking (no need to recreate buffers, O(1)
clearing)
- Optional - enabled by default - caching of tile paths between gateways
- Line of sight smoothing for the final path
## Review Focus
Play with included tools, benchmark and visualization. Pathfinding
should be safe to merge as a black box - you do not need to understand
the details. Outcomes can be tested empirically in-game. Visualize (and
share!) edge cases with included playground. Confirm the 100x speedup is
real with benchmark.
If you plan to dive into the code, I suggest the following order:
- Pathfinding abstraction in `src/core/pathfinding/`
- Pathfinding tests in `tests/core/pathfinding/`
- NavMesh in `src/core/pathfinding/navmesh/` + integration with
`Game.ts`
- Benchmark in `tests/pathfinding/benchmark/`
Do not look at playground's code, it has been created with a clanker.
The design is 100% mine and I spent way too long polishing it, but I
haven't even once edited the code manually. There is probably no
abstraction whatsoever, just do not look at the code, let it play.
## Core Changes
#### Pathfinding (`src/core/pathfinding/navmesh/`)
- HPA* + refinement -> three phased pathfinding: A* over the graph ->
naive path -> refinement
- comes with A* and BFS optimized for for specific needs
#### Pre-Processing (`src/core/pathfinding/navmesh/`)
- identify water bodies to avoid pathfinding between disconnected nodes
- create high-level graph of gateways on top of tile map
#### Abstraction (`src/core/pathfinding/`)
- common `PathFinder` interface that can return full path and also act
as state machine (`.next()`)
- adapters for both new and legacy algorithm with fallback to legacy if
navigation mesh not available
#### Benchmark (`tests/pathfinding/benchmark/`)
- `npx tsx tests/pathfinding/benchmark/run.ts` - no guesswork, numbers
- `npx tsx tests/pathfinding/benchmark/run.ts --synthetic` - 1000s of
synthetic paths
- `npx tsc tests/pathfinding/benchmark/generate.ts` - generate more as
needed, test new maps
- includes ONE synthetic scenario to avoid PR bloat, generate more
locally / later
#### Playground (`tests/pathfinding/playground/`)
- `npx tsx tests/pathfinding/playground/server.ts` - visualize paths
with both new and legacy algorithm
## Benchmarks
### Compared with legacy in default - hand picked - scenario:
```
Initialization: 95.95ms -> 227.29ms
Pathfinding: 3038.43ms -> 6.45ms
Distance: 26972 -> 26810 tiles
```
### 42,000 synthetic routes across all maps
```
Running 42 synthetic scenarios with hpa.cached adapter...
✅ synthetic/achiran | Init: 93.42ms | Path: 139.07ms | Dist: 1481630 tiles | Routes: 1000/1000
✅ synthetic/africa | Init: 87.14ms | Path: 155.08ms | Dist: 1829414 tiles | Routes: 1000/1000
✅ synthetic/asia | Init: 57.60ms | Path: 112.55ms | Dist: 1204082 tiles | Routes: 1000/1000
✅ synthetic/australia | Init: 78.18ms | Path: 77.12ms | Dist: 978375 tiles | Routes: 1000/1000
✅ synthetic/baikal | Init: 78.26ms | Path: 152.14ms | Dist: 1600016 tiles | Routes: 1000/1000
✅ synthetic/baikalnukewars | Init: 81.44ms | Path: 165.90ms | Dist: 1699283 tiles | Routes: 1000/1000
✅ synthetic/betweentwoseas | Init: 29.29ms | Path: 114.99ms | Dist: 1338075 tiles | Routes: 1000/1000
✅ synthetic/blacksea | Init: 30.66ms | Path: 93.14ms | Dist: 949217 tiles | Routes: 1000/1000
✅ synthetic/britannia | Init: 74.12ms | Path: 85.62ms | Dist: 866752 tiles | Routes: 1000/1000
✅ synthetic/deglaciatedantarctica | Init: 105.49ms | Path: 192.93ms | Dist: 1574684 tiles | Routes: 1000/1000
✅ synthetic/didier | Init: 81.51ms | Path: 153.70ms | Dist: 1734876 tiles | Routes: 1000/1000
✅ synthetic/eastasia | Init: 49.29ms | Path: 128.63ms | Dist: 1410270 tiles | Routes: 1000/1000
✅ synthetic/europe | Init: 92.55ms | Path: 178.35ms | Dist: 1525216 tiles | Routes: 1000/1000
✅ synthetic/europeclassic | Init: 33.50ms | Path: 104.40ms | Dist: 1209759 tiles | Routes: 1000/1000
✅ synthetic/falklandislands | Init: 63.00ms | Path: 107.41ms | Dist: 1080251 tiles | Routes: 1000/1000
✅ synthetic/faroeislands | Init: 71.91ms | Path: 49.52ms | Dist: 604613 tiles | Routes: 1000/1000
✅ synthetic/fourislands | Init: 45.75ms | Path: 78.91ms | Dist: 937439 tiles | Routes: 1000/1000
✅ synthetic/gatewaytotheatlantic | Init: 81.00ms | Path: 257.06ms | Dist: 2555551 tiles | Routes: 1000/1000
✅ synthetic/giantworldmap | Init: 214.25ms | Path: 220.42ms | Dist: 1976693 tiles | Routes: 1000/1000
✅ synthetic/gulfofstlawrence | Init: 45.16ms | Path: 96.05ms | Dist: 1014604 tiles | Routes: 1000/1000
✅ synthetic/halkidiki | Init: 74.68ms | Path: 149.39ms | Dist: 1546781 tiles | Routes: 1000/1000
✅ synthetic/iceland | Init: 58.72ms | Path: 78.16ms | Dist: 1001554 tiles | Routes: 1000/1000
✅ synthetic/italia | Init: 29.78ms | Path: 139.93ms | Dist: 1412024 tiles | Routes: 1000/1000
✅ synthetic/japan | Init: 161.07ms | Path: 118.65ms | Dist: 1154393 tiles | Routes: 1000/1000
✅ synthetic/lemnos | Init: 52.59ms | Path: 136.69ms | Dist: 1481101 tiles | Routes: 1000/1000
✅ synthetic/lisbon | Init: 49.27ms | Path: 86.53ms | Dist: 1032011 tiles | Routes: 1000/1000
✅ synthetic/manicouagan | Init: 53.74ms | Path: 110.52ms | Dist: 1307630 tiles | Routes: 1000/1000
✅ synthetic/mars | Init: 29.39ms | Path: 80.55ms | Dist: 1091702 tiles | Routes: 1000/1000
✅ synthetic/mena | Init: 26.37ms | Path: 120.09ms | Dist: 1272751 tiles | Routes: 1000/1000
✅ synthetic/montreal | Init: 26.08ms | Path: 106.77ms | Dist: 1187736 tiles | Routes: 1000/1000
✅ synthetic/newyorkcity | Init: 56.60ms | Path: 181.19ms | Dist: 1753875 tiles | Routes: 1000/1000
✅ synthetic/northamerica | Init: 96.29ms | Path: 123.02ms | Dist: 1217221 tiles | Routes: 1000/1000
✅ synthetic/oceania | Init: 52.81ms | Path: 51.96ms | Dist: 482373 tiles | Routes: 1000/1000
✅ synthetic/pangaea | Init: 21.29ms | Path: 56.58ms | Dist: 716189 tiles | Routes: 1000/1000
✅ synthetic/pluto | Init: 53.89ms | Path: 141.62ms | Dist: 1304362 tiles | Routes: 1000/1000
✅ synthetic/southamerica | Init: 85.19ms | Path: 123.03ms | Dist: 1301403 tiles | Routes: 1000/1000
✅ synthetic/straitofgibraltar | Init: 76.68ms | Path: 108.30ms | Dist: 1304592 tiles | Routes: 1000/1000
✅ synthetic/straitofhormuz | Init: 38.97ms | Path: 67.78ms | Dist: 754920 tiles | Routes: 1000/1000
✅ synthetic/surrounded | Init: 95.35ms | Path: 90.18ms | Dist: 1017142 tiles | Routes: 1000/1000
✅ synthetic/svalmel | Init: 60.58ms | Path: 104.75ms | Dist: 1235501 tiles | Routes: 1000/1000
✅ synthetic/twolakes | Init: 62.05ms | Path: 94.54ms | Dist: 1140807 tiles | Routes: 1000/1000
✅ synthetic/world | Init: 41.43ms | Path: 93.42ms | Dist: 873406 tiles | Routes: 1000/1000
Completed 42 scenarios
Total Initialization Time: 2796.32ms
Total Pathfinding Time: 5026.64ms
Total Distance: 53160274 tiles
```
## Playground
**That's the fun part**. Watch NavMesh running circles around legacy
`PathFinder.Mini` in real time. Debug inner workings, test edge cases,
share URLs for debugging.
https://github.com/user-attachments/assets/34e2e3f5-fbc1-4b1f-917d-820766e98d5d
## Discord Tag
`moleole`
This commit is contained in:
@@ -1,5 +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 { setup } from "../../util/Setup";
|
||||
|
||||
describe("TradeShipExecution", () => {
|
||||
@@ -83,7 +84,7 @@ describe("TradeShipExecution", () => {
|
||||
tradeShipExecution = new TradeShipExecution(origOwner, srcPort, dstPort);
|
||||
tradeShipExecution.init(game, 0);
|
||||
tradeShipExecution["pathFinder"] = {
|
||||
nextTile: vi.fn(() => ({ type: 0, node: 2001 })),
|
||||
next: vi.fn(() => ({ status: PathStatus.NEXT, node: 2001 })),
|
||||
} as any;
|
||||
tradeShipExecution["tradeShip"] = tradeShip;
|
||||
});
|
||||
@@ -114,7 +115,7 @@ describe("TradeShipExecution", () => {
|
||||
|
||||
it("should complete trade and award gold", () => {
|
||||
tradeShipExecution["pathFinder"] = {
|
||||
nextTile: vi.fn(() => ({ type: 2, node: 2001 })),
|
||||
next: vi.fn(() => ({ status: PathStatus.COMPLETE, node: 2001 })),
|
||||
} as any;
|
||||
tradeShipExecution.tick(1);
|
||||
expect(tradeShip.delete).toHaveBeenCalledWith(false);
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
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,135 @@
|
||||
import {
|
||||
Difficulty,
|
||||
Game,
|
||||
GameMapSize,
|
||||
GameMapType,
|
||||
GameMode,
|
||||
GameType,
|
||||
} from "../../../src/core/game/Game";
|
||||
import { createGame } from "../../../src/core/game/GameImpl";
|
||||
import { GameMapImpl } from "../../../src/core/game/GameMap";
|
||||
import { UserSettings } from "../../../src/core/game/UserSettings";
|
||||
import { TestConfig } from "../../util/TestConfig";
|
||||
import { TestServerConfig } from "../../util/TestServerConfig";
|
||||
|
||||
const LAND_BIT = 7;
|
||||
const OCEAN_BIT = 5;
|
||||
|
||||
/**
|
||||
* Creates a Game from inline map strings.
|
||||
* Each char = 1 tile: W=water (ocean), L=land
|
||||
* miniMap automatically generated (2x2→1, water if ANY tile water)
|
||||
*
|
||||
* Example:
|
||||
* const game = await gameFromString([
|
||||
* 'WWWWW',
|
||||
* 'WLLLW',
|
||||
* 'WWWWW'
|
||||
* ]);
|
||||
*/
|
||||
export async function gameFromString(mapRows: string[]): Promise<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);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
# Pathfinding Tests
|
||||
|
||||
This directory contains benchmarking tools, scenario generators, and an interactive playground for testing and optimizing pathfinding algorithms in OpenFrontIO.
|
||||
|
||||
## TLDR
|
||||
|
||||
```bash
|
||||
npx tsx tests/pathfinding/benchmark/run.ts --synthetic --all
|
||||
npx tsx tests/pathfinding/playground/server.ts
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
tests/pathfinding/
|
||||
├── benchmark.ts # Benchmarking tool
|
||||
├── scenarios/ # Scenarios for benchmarks
|
||||
│ ├── default.ts # Hand-picked scenario
|
||||
│ └── synthetic/ # Auto-generated synthetic scenarios
|
||||
└── playground/ # Interactive web-based visualization
|
||||
```
|
||||
|
||||
## Available algorithms
|
||||
|
||||
- **NavSat** - **future** implementation - NavigationSatellite (HPA\*)
|
||||
- **PF.Mini** - **current** implementation - PathFinder.Mini (A\*)
|
||||
|
||||
## Benchmarking
|
||||
|
||||
### Running a Single Scenario
|
||||
|
||||
```bash
|
||||
# Run default scenario with default adapter (NavSat)
|
||||
npx tsx tests/pathfinding/benchmark/run.ts
|
||||
|
||||
# Run specific scenario
|
||||
npx tsx tests/pathfinding/benchmark/run.ts default
|
||||
|
||||
# Run with specific adapter
|
||||
npx tsx tests/pathfinding/benchmark/run.ts default legacy
|
||||
```
|
||||
|
||||
### Running Synthetic Scenarios
|
||||
|
||||
Synthetic scenarios are auto-generated from maps with random port selections and routes.
|
||||
|
||||
```bash
|
||||
# Run single synthetic scenario
|
||||
npx tsx tests/pathfinding/benchmark/run.ts --synthetic iceland
|
||||
|
||||
# Run single synthetic scenario with specific adapter
|
||||
npx tsx tests/pathfinding/benchmark/run.ts --synthetic iceland legacy
|
||||
|
||||
# Run ALL synthetic scenarios (comprehensive benchmark)
|
||||
npx tsx tests/pathfinding/benchmark/run.ts --synthetic --all
|
||||
|
||||
# Run all with specific adapter
|
||||
npx tsx tests/pathfinding/benchmark/run.ts --synthetic --all legacy
|
||||
```
|
||||
|
||||
### Benchmark Metrics
|
||||
|
||||
The benchmark measures three key metrics:
|
||||
|
||||
1. **Initialization Time** - How long it takes to preprocess the map
|
||||
2. **Path Distance** - Total distance across all routes (quality metric)
|
||||
3. **Pathfinding Time** - How long it takes to compute paths (performance metric)
|
||||
|
||||
### Example Output
|
||||
|
||||
```
|
||||
================================================================================
|
||||
METRIC 1: INITIALIZATION TIME
|
||||
================================================================================
|
||||
|
||||
Initialization time: 45.32ms
|
||||
|
||||
================================================================================
|
||||
METRIC 2: PATH DISTANCE
|
||||
================================================================================
|
||||
|
||||
Route Path Length
|
||||
Miami → Boston 346 tiles
|
||||
Miami → Houston 212 tiles
|
||||
...
|
||||
|
||||
Total distance: 52432 tiles
|
||||
Routes completed: 22 / 22
|
||||
|
||||
================================================================================
|
||||
METRIC 3: PATHFINDING TIME
|
||||
================================================================================
|
||||
|
||||
Route Time
|
||||
Miami → Boston 2.45ms
|
||||
Miami → Houston 1.82ms
|
||||
...
|
||||
|
||||
Total time: 156.34ms
|
||||
Average time: 7.11ms
|
||||
Routes benchmarked: 22 / 22
|
||||
|
||||
================================================================================
|
||||
SUMMARY
|
||||
================================================================================
|
||||
|
||||
Adapter: default
|
||||
Scenario: default
|
||||
|
||||
Scores:
|
||||
Initialization: 45.32ms
|
||||
Pathfinding: 156.34ms
|
||||
Distance: 52432 tiles
|
||||
```
|
||||
|
||||
## Generating Scenarios
|
||||
|
||||
### Generate Synthetic Scenarios
|
||||
|
||||
Synthetic scenarios are generated by:
|
||||
|
||||
1. Finding all water shoreline tiles on a map
|
||||
2. Randomly selecting 200 ports
|
||||
3. Creating 1000 routes connecting nearby ports
|
||||
|
||||
```bash
|
||||
# Generate scenario for a single map
|
||||
npx tsx tests/pathfinding/benchmark/generate.ts iceland
|
||||
|
||||
# Generate scenarios for all maps
|
||||
npx tsx tests/pathfinding/benchmark/generate.ts --all
|
||||
|
||||
# Force overwrite existing scenarios
|
||||
npx tsx tests/pathfinding/benchmark/generate.ts iceland --force
|
||||
npx tsx tests/pathfinding/benchmark/generate.ts --all --force
|
||||
```
|
||||
|
||||
## Interactive Playground
|
||||
|
||||
The playground provides a web-based UI for visualizing pathfinding results, comparing algorithms, and debugging.
|
||||
|
||||
### Starting the Playground
|
||||
|
||||
```bash
|
||||
# Start with path caching enabled (default)
|
||||
npx tsx tests/pathfinding/playground/server.ts
|
||||
|
||||
# Start without path caching (to measure uncached performance)
|
||||
npx tsx tests/pathfinding/playground/server.ts --no-cache
|
||||
```
|
||||
|
||||
Then open http://localhost:5555 in your browser.
|
||||
@@ -0,0 +1,310 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Generate synthetic benchmark scenarios for pathfinding tests
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx tests/pathfinding/benchmark/generate.ts <map-name> [--force]
|
||||
* npx tsx tests/pathfinding/benchmark/generate.ts --all [--force]
|
||||
*
|
||||
* Examples:
|
||||
* npx tsx tests/pathfinding/benchmark/generate.ts iceland
|
||||
* npx tsx tests/pathfinding/benchmark/generate.ts giantworldmap --force
|
||||
* npx tsx tests/pathfinding/benchmark/generate.ts --all
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { setupFromPath } from "../utils";
|
||||
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
const pathfindingDir = dirname(currentFile);
|
||||
const projectRoot = join(pathfindingDir, "../../..");
|
||||
const mapsDirectory = join(projectRoot, "resources/maps");
|
||||
const scenariosDir = join(pathfindingDir, "scenarios", "synthetic");
|
||||
|
||||
const NUM_PORTS = 200;
|
||||
const NUM_ROUTES = 1000;
|
||||
const ROUTES_PER_PORT = 5;
|
||||
|
||||
interface GenerationOptions {
|
||||
force: boolean;
|
||||
silent: boolean;
|
||||
}
|
||||
|
||||
async function generateScenarioForMap(
|
||||
mapName: string,
|
||||
options: GenerationOptions,
|
||||
): Promise<"created" | "skipped" | "error"> {
|
||||
const outputPath = join(scenariosDir, `${mapName}.ts`);
|
||||
|
||||
// Check if file exists and --force not provided
|
||||
if (existsSync(outputPath) && !options.force) {
|
||||
if (!options.silent) {
|
||||
console.log(
|
||||
`⚠️ ${mapName}: File already exists (use --force to overwrite)`,
|
||||
);
|
||||
}
|
||||
|
||||
return "skipped";
|
||||
}
|
||||
|
||||
try {
|
||||
const game = await setupFromPath(mapsDirectory, mapName);
|
||||
const map = game.map();
|
||||
|
||||
// Find all water shoreline tiles
|
||||
const shorelinePorts: Array<[number, number]> = [];
|
||||
|
||||
map.forEachTile((tile) => {
|
||||
if (map.isOcean(tile) && map.isShoreline(tile)) {
|
||||
shorelinePorts.push([map.x(tile), map.y(tile)]);
|
||||
}
|
||||
});
|
||||
|
||||
if (shorelinePorts.length < 10) {
|
||||
console.log(
|
||||
`❌ ${mapName}: Not enough water shoreline tiles (minimum 10 required)`,
|
||||
);
|
||||
return "error";
|
||||
}
|
||||
|
||||
// Select random ports
|
||||
const numPortsToSelect = Math.min(NUM_PORTS, shorelinePorts.length);
|
||||
const selectedPorts: Array<[number, number]> = [];
|
||||
const shuffled = shorelinePorts.sort(() => Math.random() - 0.5);
|
||||
for (let i = 0; i < numPortsToSelect; i++) {
|
||||
selectedPorts.push(shuffled[i]);
|
||||
}
|
||||
|
||||
// Build ports array
|
||||
const ports: Port[] = selectedPorts.map((coord, index) => ({
|
||||
name: `Port${String(index + 1).padStart(3, "0")}`,
|
||||
coords: coord,
|
||||
}));
|
||||
|
||||
// Build routes array
|
||||
const routes: Route[] = [];
|
||||
|
||||
// Generate routes: each port connects to next N ports
|
||||
for (let i = 0; i < selectedPorts.length; i++) {
|
||||
for (
|
||||
let j = 1;
|
||||
j <= ROUTES_PER_PORT && i + j < selectedPorts.length;
|
||||
j++
|
||||
) {
|
||||
routes.push({
|
||||
from: `Port${String(i + 1).padStart(3, "0")}`,
|
||||
to: `Port${String(i + j + 1).padStart(3, "0")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add extra routes to reach target (or as many as possible)
|
||||
const targetRoutes = Math.min(NUM_ROUTES, routes.length + 200);
|
||||
const additionalRoutesNeeded = targetRoutes - routes.length;
|
||||
if (additionalRoutesNeeded > 0) {
|
||||
let added = 0;
|
||||
for (
|
||||
let i = 0;
|
||||
i < selectedPorts.length && added < additionalRoutesNeeded;
|
||||
i++
|
||||
) {
|
||||
for (
|
||||
let j = ROUTES_PER_PORT + 1;
|
||||
j <= ROUTES_PER_PORT + 3 &&
|
||||
i + j < selectedPorts.length &&
|
||||
added < additionalRoutesNeeded;
|
||||
j++
|
||||
) {
|
||||
routes.push({
|
||||
from: `Port${String(i + 1).padStart(3, "0")}`,
|
||||
to: `Port${String(i + j + 1).padStart(3, "0")}`,
|
||||
});
|
||||
added++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate content from template
|
||||
const content = generateScenarioContent({
|
||||
mapName,
|
||||
ports,
|
||||
routes,
|
||||
});
|
||||
|
||||
const routeCount = routes.length;
|
||||
|
||||
// Ensure directory exists
|
||||
mkdirSync(scenariosDir, { recursive: true });
|
||||
|
||||
// Write to file
|
||||
writeFileSync(outputPath, content);
|
||||
|
||||
console.log(
|
||||
`✅ ${mapName} generated with ${numPortsToSelect} ports and ${routeCount} routes`,
|
||||
);
|
||||
|
||||
return "created";
|
||||
} catch (error) {
|
||||
console.error(`❌ ${mapName}:`, error);
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`
|
||||
Usage:
|
||||
npx tsx tests/pathfinding/benchmark/generate.ts <map-name> [--force]
|
||||
npx tsx tests/pathfinding/benchmark/generate.ts --all [--force]
|
||||
|
||||
Arguments:
|
||||
<map-name> Name of the map to generate scenario for (e.g., iceland, giantworldmap)
|
||||
--all Generate scenarios for all available maps
|
||||
--force Overwrite existing scenario files
|
||||
|
||||
Examples:
|
||||
npx tsx tests/pathfinding/benchmark/generate.ts iceland
|
||||
npx tsx tests/pathfinding/benchmark/generate.ts giantworldmap --force
|
||||
npx tsx tests/pathfinding/benchmark/generate.ts --all
|
||||
|
||||
Available maps:
|
||||
Run 'ls resources/maps' to see all available maps
|
||||
`);
|
||||
}
|
||||
|
||||
// Parse command-line arguments
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const options: GenerationOptions = {
|
||||
force: args.includes("--force"),
|
||||
silent: args.includes("--all"),
|
||||
};
|
||||
|
||||
const nonFlagArgs = args.filter((arg) => !arg.startsWith("--"));
|
||||
|
||||
if (args.includes("--all")) {
|
||||
// Generate for all maps
|
||||
const maps = readdirSync(mapsDirectory, { withFileTypes: true })
|
||||
.filter((dirent) => dirent.isDirectory())
|
||||
.map((dirent) => dirent.name)
|
||||
.sort();
|
||||
|
||||
console.log(`Generating synthetic scenarios for ${maps.length} maps...`);
|
||||
console.log(`Config: ${NUM_PORTS} ports, ${NUM_ROUTES} routes`);
|
||||
console.log(`Force overwrite: ${options.force}`);
|
||||
console.log(``);
|
||||
|
||||
let createdCount = 0;
|
||||
let skippedCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const mapName of maps) {
|
||||
const result = await generateScenarioForMap(mapName, options);
|
||||
|
||||
if (result === "created") {
|
||||
createdCount++;
|
||||
} else if (result === "skipped") {
|
||||
skippedCount++;
|
||||
} else if (result === "error") {
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (createdCount + errorCount > 0) {
|
||||
console.log(``);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Created: ${createdCount}, Skipped: ${skippedCount}, Errors: ${errorCount}`,
|
||||
);
|
||||
} else if (nonFlagArgs.length === 1) {
|
||||
// Generate for single map
|
||||
const mapName = nonFlagArgs[0];
|
||||
const mapPath = join(mapsDirectory, mapName);
|
||||
|
||||
if (!existsSync(mapPath)) {
|
||||
console.error(`Map not found: ${mapName}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Generating synthetic scenario for ${mapName}...`);
|
||||
console.log(`Config: ${NUM_PORTS} ports, ${NUM_ROUTES} routes`);
|
||||
console.log(`Force overwrite: ${options.force}`);
|
||||
console.log(``);
|
||||
|
||||
const result = await generateScenarioForMap(mapName, options);
|
||||
|
||||
if (result === "created") {
|
||||
console.log(``);
|
||||
console.log(`Scenario generated successfully!`);
|
||||
console.log(
|
||||
`You can now run: npx tsx tests/pathfinding/benchmark/run.ts --synthetic ${mapName}`,
|
||||
);
|
||||
} else {
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.error(`Invalid arguments.`);
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run main function
|
||||
main().catch((error) => {
|
||||
console.error(`Fatal error:`, error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* Template for generating synthetic benchmark scenarios
|
||||
*/
|
||||
|
||||
interface Port {
|
||||
name: string;
|
||||
coords: [number, number];
|
||||
}
|
||||
|
||||
interface Route {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
interface TemplateParams {
|
||||
mapName: string;
|
||||
ports: Port[];
|
||||
routes: Route[];
|
||||
}
|
||||
|
||||
function generateScenarioContent(params: TemplateParams): string {
|
||||
const { mapName, ports, routes } = params;
|
||||
|
||||
let content = ``;
|
||||
|
||||
// Simplified format - just data, no setup function
|
||||
content += `export const MAP_NAME = "${mapName}";\n\n`;
|
||||
|
||||
// Generate PORTS object
|
||||
content += `export const PORTS: { [k: string]: [number, number] } = {\n`;
|
||||
ports.forEach((port) => {
|
||||
content += ` ${port.name}: [${port.coords[0]}, ${port.coords[1]}],\n`;
|
||||
});
|
||||
content += `};\n\n`;
|
||||
|
||||
// Generate ROUTES array
|
||||
content += `export const ROUTES: Array<[keyof typeof PORTS, keyof typeof PORTS]> = [\n`;
|
||||
routes.forEach((route) => {
|
||||
content += ` ["${route.from}", "${route.to}"],\n`;
|
||||
});
|
||||
content += `];\n`;
|
||||
|
||||
return content;
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Benchmark pathfinding adapters on various scenarios
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx tests/pathfinding/benchmark/run.ts [<scenario> [<adapter>]]
|
||||
* npx tsx tests/pathfinding/benchmark/run.ts --synthetic <map-name> [<adapter>]
|
||||
* npx tsx tests/pathfinding/benchmark/run.ts --synthetic --all [<adapter>]
|
||||
*
|
||||
* Examples:
|
||||
* npx tsx tests/pathfinding/benchmark/run.ts
|
||||
* npx tsx tests/pathfinding/benchmark/run.ts default legacy
|
||||
* npx tsx tests/pathfinding/benchmark/run.ts --synthetic --all
|
||||
* npx tsx tests/pathfinding/benchmark/run.ts --synthetic iceland legacy
|
||||
*/
|
||||
|
||||
import { readdirSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import {
|
||||
type BenchmarkResult,
|
||||
calculateStats,
|
||||
getAdapter,
|
||||
getScenario,
|
||||
measureExecutionTime,
|
||||
measurePathLength,
|
||||
printHeader,
|
||||
printRow,
|
||||
} from "../utils";
|
||||
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
const pathfindingDir = dirname(currentFile);
|
||||
const syntheticScenariosDir = join(pathfindingDir, "scenarios", "synthetic");
|
||||
|
||||
interface RunOptions {
|
||||
silent?: boolean;
|
||||
iterations?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_ADAPTER = "hpa";
|
||||
const DEFAULT_SCENARIO = "default";
|
||||
const DEFAULT_ITERATIONS = 10;
|
||||
|
||||
async function runScenario(
|
||||
adapterName: string,
|
||||
scenarioName: string,
|
||||
options: RunOptions = {},
|
||||
) {
|
||||
const { game, routes, initTime } = await getScenario(
|
||||
scenarioName,
|
||||
adapterName,
|
||||
);
|
||||
const adapter = getAdapter(game, adapterName);
|
||||
const { silent = false } = options;
|
||||
|
||||
if (!silent) {
|
||||
console.log(`Date: ${new Date().toISOString()}`);
|
||||
console.log(`Benchmarking: ${adapterName}`);
|
||||
console.log(`Scenario: ${scenarioName}`);
|
||||
console.log(`Routes: ${routes.length}`);
|
||||
console.log(``);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
if (!silent) {
|
||||
printHeader("METRIC 1: INITIALIZATION TIME");
|
||||
}
|
||||
|
||||
const initializationTime = initTime;
|
||||
|
||||
if (!silent) {
|
||||
console.log(`Initialization time: ${initializationTime.toFixed(2)}ms`);
|
||||
console.log(``);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
if (!silent) {
|
||||
printHeader("METRIC 2: PATH DISTANCE");
|
||||
printRow(["Route", "Path Length"], [40, 12]);
|
||||
}
|
||||
|
||||
const results: BenchmarkResult[] = [];
|
||||
|
||||
for (const route of routes) {
|
||||
const pathLength = measurePathLength(adapter, route);
|
||||
results.push({ route: route.name, pathLength, executionTime: null });
|
||||
if (!silent) {
|
||||
printRow(
|
||||
[route.name, pathLength !== null ? `${pathLength} tiles` : "FAILED"],
|
||||
[40, 12],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { totalDistance, successfulRoutes, totalRoutes } =
|
||||
calculateStats(results);
|
||||
|
||||
if (!silent) {
|
||||
console.log(``);
|
||||
console.log(`Total distance: ${totalDistance} tiles`);
|
||||
console.log(`Routes completed: ${successfulRoutes} / ${totalRoutes}`);
|
||||
console.log(``);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
if (!silent) {
|
||||
printHeader("METRIC 3: PATHFINDING TIME");
|
||||
printRow(["Route", "Time"], [40, 12]);
|
||||
}
|
||||
|
||||
for (const route of routes) {
|
||||
const result = results.find((r) => r.route === route.name);
|
||||
|
||||
if (result && result.pathLength !== null) {
|
||||
const execTime = measureExecutionTime(
|
||||
adapter,
|
||||
route,
|
||||
options.iterations ?? DEFAULT_ITERATIONS,
|
||||
);
|
||||
result.executionTime = execTime;
|
||||
|
||||
if (!silent) {
|
||||
printRow([route.name, `${execTime!.toFixed(2)}ms`], [40, 12]);
|
||||
}
|
||||
} else {
|
||||
if (!silent) {
|
||||
printRow([route.name, "FAILED"], [40, 12]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stats = calculateStats(results);
|
||||
|
||||
if (!silent) {
|
||||
console.log(``);
|
||||
console.log(`Total time: ${stats.totalTime.toFixed(2)}ms`);
|
||||
console.log(`Average time: ${stats.avgTime.toFixed(2)}ms`);
|
||||
console.log(
|
||||
`Routes benchmarked: ${stats.timedRoutes} / ${stats.totalRoutes}`,
|
||||
);
|
||||
console.log(``);
|
||||
|
||||
// =============================================================================
|
||||
|
||||
printHeader("SUMMARY");
|
||||
|
||||
console.log(`Adapter: ${adapterName}`);
|
||||
console.log(`Scenario: ${scenarioName}`);
|
||||
console.log(``);
|
||||
|
||||
if (stats.successfulRoutes < stats.totalRoutes) {
|
||||
console.log(
|
||||
`Warning: Only ${stats.successfulRoutes} out of ${stats.totalRoutes} routes were completed successfully!`,
|
||||
);
|
||||
console.log(``);
|
||||
}
|
||||
|
||||
console.log("Scores:");
|
||||
console.log(` Initialization: ${initializationTime.toFixed(2)}ms`);
|
||||
console.log(` Pathfinding: ${stats.totalTime.toFixed(2)}ms`);
|
||||
console.log(` Distance: ${totalDistance} tiles`);
|
||||
console.log(``);
|
||||
} else {
|
||||
// Silent mode - just print a summary line
|
||||
const status = stats.successfulRoutes < stats.totalRoutes ? "⚠️ " : "✅";
|
||||
console.log(
|
||||
`${status} ${scenarioName.padEnd(35)} | Init: ${initializationTime.toFixed(2).padStart(8)}ms | Path: ${stats.totalTime.toFixed(2).padStart(9)}ms | Dist: ${totalDistance.toString().padStart(7)} tiles | Routes: ${stats.successfulRoutes}/${stats.totalRoutes}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
initializationTime,
|
||||
totalTime: stats.totalTime,
|
||||
totalDistance: totalDistance,
|
||||
};
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`
|
||||
Usage:
|
||||
npx tsx tests/pathfinding/benchmark/run.ts [<scenario> [<adapter>]]
|
||||
npx tsx tests/pathfinding/benchmark/run.ts --synthetic <map-name> [<adapter>]
|
||||
npx tsx tests/pathfinding/benchmark/run.ts --synthetic --all [<adapter>]
|
||||
|
||||
Arguments:
|
||||
<scenario> Name of the scenario to benchmark (default: "default" -> giantworldmap with handpicked ports)
|
||||
<adapter> Pathfinding adapter: "hpa" (default), "hpa.cached", "legacy"
|
||||
--silent Minimize output, only print summary lines
|
||||
--synthetic Run synthetic scenarios
|
||||
--all Run all synthetic scenarios (requires --synthetic)
|
||||
|
||||
Examples:
|
||||
npx tsx tests/pathfinding/benchmark/run.ts
|
||||
npx tsx tests/pathfinding/benchmark/run.ts default legacy
|
||||
npx tsx tests/pathfinding/benchmark/run.ts --synthetic --all
|
||||
npx tsx tests/pathfinding/benchmark/run.ts --synthetic iceland legacy
|
||||
|
||||
Available synthetic scenarios:
|
||||
Run 'ls tests/pathfinding/benchmark/scenarios/synthetic' to see all available scenarios
|
||||
`);
|
||||
}
|
||||
|
||||
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 isAll = args.includes("--all");
|
||||
const isSilent = args.includes("--silent");
|
||||
const nonFlagArgs = args.filter((arg) => !arg.startsWith("--"));
|
||||
|
||||
if (isSynthetic) {
|
||||
if (isAll) {
|
||||
// Run all synthetic scenarios
|
||||
const adapterName = nonFlagArgs[0] || DEFAULT_ADAPTER;
|
||||
|
||||
// Find all synthetic scenario files
|
||||
const scenarioFiles = readdirSync(syntheticScenariosDir)
|
||||
.filter((file) => file.endsWith(".ts"))
|
||||
.map((file) => file.replace(".ts", ""))
|
||||
.sort();
|
||||
|
||||
console.log(
|
||||
`Running ${scenarioFiles.length} synthetic scenarios with ${adapterName} adapter...`,
|
||||
);
|
||||
console.log(``);
|
||||
|
||||
const results: {
|
||||
initializationTime: number;
|
||||
totalTime: number;
|
||||
totalDistance: number;
|
||||
}[] = [];
|
||||
|
||||
for (let i = 0; i < scenarioFiles.length; i++) {
|
||||
const mapName = scenarioFiles[i];
|
||||
const scenarioName = `synthetic/${mapName}`;
|
||||
const result = await runScenario(adapterName, scenarioName, {
|
||||
silent: true,
|
||||
iterations: 1,
|
||||
});
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
console.log(``);
|
||||
console.log(`Completed ${scenarioFiles.length} scenarios`);
|
||||
console.log(
|
||||
`Total Initialization Time: ${results.reduce((sum, r) => sum + r.initializationTime, 0).toFixed(2)}ms`,
|
||||
);
|
||||
console.log(
|
||||
`Total Pathfinding Time: ${results.reduce((sum, r) => sum + r.totalTime, 0).toFixed(2)}ms`,
|
||||
);
|
||||
console.log(
|
||||
`Total Distance: ${results.reduce((sum, r) => sum + r.totalDistance, 0)} tiles`,
|
||||
);
|
||||
} else if (nonFlagArgs.length >= 1) {
|
||||
// Run single synthetic scenario
|
||||
const mapName = nonFlagArgs[0];
|
||||
const adapterName = nonFlagArgs[1] || DEFAULT_ADAPTER;
|
||||
const scenarioName = `synthetic/${mapName}`;
|
||||
|
||||
await runScenario(adapterName, scenarioName, { silent: isSilent });
|
||||
} else {
|
||||
console.error("Error: --synthetic requires a map name or --all flag");
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// Standard mode with positional arguments
|
||||
const scenarioName = nonFlagArgs[0] || DEFAULT_SCENARIO;
|
||||
const adapterName = nonFlagArgs[1] || DEFAULT_ADAPTER;
|
||||
|
||||
await runScenario(adapterName, scenarioName, { silent: isSilent });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("Fatal error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
export const MAP_NAME = "giantworldmap";
|
||||
|
||||
export const PORTS: { [k: string]: [number, number] } = {
|
||||
Miami: [1014, 694],
|
||||
Boston: [1120, 498],
|
||||
Houston: [844, 657],
|
||||
Chicago: [928, 508],
|
||||
Cleveland: [995, 514],
|
||||
Barcelona: [1943, 518],
|
||||
"Sao Paulo": [1396, 1288],
|
||||
"San Francisco": [520, 547],
|
||||
"Falkland Islands": [1259, 1616],
|
||||
"San Salvador": [901, 842],
|
||||
Luxor: [2302, 700],
|
||||
Venice: [2068, 468],
|
||||
"Kongo River": [2142, 965],
|
||||
Shanghai: [3317, 639],
|
||||
Tokyo: [3525, 583],
|
||||
"Abu Zabi": [2554, 711],
|
||||
Gdansk: [2143, 362],
|
||||
Neapol: [2081, 516],
|
||||
"San Felipe": [617, 637],
|
||||
Anchorage: [217, 281],
|
||||
Honolulu: [156, 768],
|
||||
"Ghana River": [1877, 869],
|
||||
"Guinea River": [1824, 868],
|
||||
"Peru River": [1097, 1162],
|
||||
"China Desert River": [2965, 599],
|
||||
"Australia River": [3663, 1343],
|
||||
};
|
||||
|
||||
export const ROUTES: Array<[keyof typeof PORTS, keyof typeof PORTS]> = [
|
||||
["Miami", "Boston"],
|
||||
["Miami", "Houston"],
|
||||
["Miami", "Chicago"],
|
||||
["Miami", "Cleveland"],
|
||||
["Miami", "Barcelona"],
|
||||
["Miami", "Sao Paulo"],
|
||||
["Miami", "San Francisco"],
|
||||
["Miami", "Falkland Islands"],
|
||||
["Miami", "San Salvador"],
|
||||
["Luxor", "Venice"],
|
||||
["Luxor", "Kongo River"],
|
||||
["Shanghai", "Tokyo"],
|
||||
["Shanghai", "San Francisco"],
|
||||
["Abu Zabi", "Gdansk"],
|
||||
["Chicago", "Cleveland"],
|
||||
["Barcelona", "Neapol"],
|
||||
["San Francisco", "San Felipe"],
|
||||
["Anchorage", "Honolulu"],
|
||||
["Kongo River", "Ghana River"],
|
||||
["Kongo River", "Guinea River"],
|
||||
["Peru River", "China Desert River"],
|
||||
["China Desert River", "Australia River"],
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
# Pathfinding Playground
|
||||
|
||||
Interactive web-based visualization tool for exploring and comparing pathfinding algorithms.
|
||||
|
||||
## Usage
|
||||
|
||||
Start the server from the project root:
|
||||
|
||||
```bash
|
||||
# With path caching (default)
|
||||
npx tsx tests/pathfinding/playground/server.ts
|
||||
|
||||
# Without path caching
|
||||
npx tsx tests/pathfinding/playground/server.ts --no-cache
|
||||
```
|
||||
|
||||
Then open http://localhost:5555 in your browser.
|
||||
|
||||
## Options
|
||||
|
||||
- `--no-cache` - Disable path caching in NavMesh to measure uncached performance
|
||||
|
||||
## Clanker Disclosure
|
||||
|
||||
The source code of both the UI and the backend is mostly generated by a robot and therefore probably unmaintainable by humans. Since this is meant to be one-off playground project the author has not put additional thought into the inner workings. The UI however is very highly opinionated, if you believe a clanker could do this first pass, you live in the (possibly not far off) future.
|
||||
@@ -0,0 +1,224 @@
|
||||
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 { setupFromPath } from "../../utils.js";
|
||||
|
||||
export interface MapInfo {
|
||||
name: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface MapCache {
|
||||
game: Game;
|
||||
navMesh: NavMesh;
|
||||
}
|
||||
|
||||
const cache = new Map<string, MapCache>();
|
||||
|
||||
/**
|
||||
* Global configuration for map loading
|
||||
*/
|
||||
let config = {
|
||||
cachePaths: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Set configuration options
|
||||
*/
|
||||
export function setConfig(options: { cachePaths?: boolean }) {
|
||||
config = { ...config, ...options };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resources/maps directory path
|
||||
*/
|
||||
function getMapsDirectory(): string {
|
||||
return join(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../../resources/maps",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format map name to title case with proper spacing
|
||||
* Handles: underscores, camelCase, existing spaces, and parentheses
|
||||
*/
|
||||
function formatMapName(name: string): string {
|
||||
return (
|
||||
name
|
||||
// Replace underscores with spaces
|
||||
.replace(/_/g, " ")
|
||||
// Add space before capital letters (for camelCase)
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
// Convert to lowercase first
|
||||
.toLowerCase()
|
||||
// Capitalize first letter of string
|
||||
.replace(/^\w/, (char) => char.toUpperCase())
|
||||
// Capitalize after spaces and opening parentheses
|
||||
.replace(/(\s+|[(])\w/g, (match) => match.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available maps by reading the resources/maps directory
|
||||
*/
|
||||
export function listMaps(): MapInfo[] {
|
||||
const mapsDir = getMapsDirectory();
|
||||
const maps: MapInfo[] = [];
|
||||
|
||||
try {
|
||||
const entries = readdirSync(mapsDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const name = entry.name;
|
||||
let displayName = formatMapName(name);
|
||||
|
||||
// Try to read displayName from manifest.json
|
||||
try {
|
||||
const manifestPath = join(mapsDir, name, "manifest.json");
|
||||
const manifestData = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
||||
if (manifestData.name) {
|
||||
displayName = formatMapName(manifestData.name);
|
||||
}
|
||||
} catch (e) {
|
||||
// If manifest doesn't exist or doesn't have name, use formatted folder name
|
||||
console.warn(
|
||||
`Could not read manifest for ${name}:`,
|
||||
e instanceof Error ? e.message : e,
|
||||
);
|
||||
}
|
||||
|
||||
maps.push({ name, displayName });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to read maps directory:", e);
|
||||
}
|
||||
|
||||
return maps.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a map from cache or disk
|
||||
*/
|
||||
export async function loadMap(mapName: string): Promise<MapCache> {
|
||||
// Check cache first
|
||||
if (cache.has(mapName)) {
|
||||
return cache.get(mapName)!;
|
||||
}
|
||||
|
||||
const mapsDir = getMapsDirectory();
|
||||
|
||||
// Use the existing setupFromPath utility to load the map
|
||||
const game = await setupFromPath(mapsDir, mapName);
|
||||
|
||||
// Initialize NavMesh
|
||||
const navMesh = new NavMesh(game, { cachePaths: config.cachePaths });
|
||||
navMesh.initialize();
|
||||
|
||||
const cacheEntry: MapCache = { game, navMesh };
|
||||
|
||||
// Store in cache
|
||||
cache.set(mapName, cacheEntry);
|
||||
|
||||
return cacheEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get map metadata for client
|
||||
*/
|
||||
export async function getMapMetadata(mapName: string) {
|
||||
const { game, navMesh } = await loadMap(mapName);
|
||||
|
||||
// Extract map data
|
||||
const mapData: number[] = [];
|
||||
for (let y = 0; y < game.height(); y++) {
|
||||
for (let x = 0; x < game.width(); x++) {
|
||||
const tile = game.ref(x, y);
|
||||
mapData.push(game.isWater(tile) ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract static graph data from NavMesh
|
||||
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),
|
||||
}));
|
||||
|
||||
// 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 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);
|
||||
}
|
||||
|
||||
// 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`,
|
||||
);
|
||||
|
||||
const sectorSize = navMeshGraph.sectorSize;
|
||||
|
||||
return {
|
||||
name: mapName,
|
||||
width: game.width(),
|
||||
height: game.height(),
|
||||
mapData,
|
||||
graphDebug: {
|
||||
allGateways,
|
||||
edges,
|
||||
sectorSize,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear map cache
|
||||
*/
|
||||
export function clearCache() {
|
||||
cache.clear();
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { TileRef } from "../../../../src/core/game/GameMap.js";
|
||||
import { MiniAStarAdapter } from "../../../../src/core/pathfinding/adapters/MiniAStarAdapter.js";
|
||||
import { loadMap } from "./maps.js";
|
||||
|
||||
interface PathfindingOptions {
|
||||
includePfMini?: boolean;
|
||||
includeNavMesh?: boolean;
|
||||
}
|
||||
|
||||
interface NavMeshResult {
|
||||
path: Array<[number, number]> | null;
|
||||
initialPath: Array<[number, number]> | null;
|
||||
gateways: Array<[number, number]> | null;
|
||||
timings: any;
|
||||
length: number;
|
||||
time: number;
|
||||
}
|
||||
|
||||
interface PfMiniResult {
|
||||
path: Array<[number, number]> | null;
|
||||
length: number;
|
||||
time: number;
|
||||
}
|
||||
|
||||
// Cache pathfinding adapters per map
|
||||
const pfMiniCache = new Map<string, MiniAStarAdapter>();
|
||||
|
||||
/**
|
||||
* Get or create MiniAStar 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);
|
||||
}
|
||||
return pfMiniCache.get(mapName)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert TileRef array to coordinate array
|
||||
*/
|
||||
function pathToCoords(
|
||||
path: TileRef[] | null,
|
||||
game: any,
|
||||
): Array<[number, number]> | null {
|
||||
if (!path) return null;
|
||||
return path.map((tile) => [game.x(tile), game.y(tile)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute pathfinding between two points
|
||||
*/
|
||||
export async function computePath(
|
||||
mapName: string,
|
||||
from: [number, number],
|
||||
to: [number, number],
|
||||
options: PathfindingOptions = {},
|
||||
): Promise<NavMeshResult> {
|
||||
const { game, navMesh: navMeshAdapter } = 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 NavMesh path
|
||||
const navMeshPath = navMeshAdapter.findPath(fromRef, toRef, true);
|
||||
const path = pathToCoords(navMeshPath, game);
|
||||
|
||||
const miniMap = game.miniMap();
|
||||
|
||||
// Extract debug info
|
||||
let gateways: Array<[number, number]> | null = null;
|
||||
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 || {};
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
initialPath,
|
||||
gateways,
|
||||
timings,
|
||||
length: path ? path.length : 0,
|
||||
time: timings.total ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute only PathFinder.Mini path
|
||||
*/
|
||||
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;
|
||||
|
||||
return {
|
||||
path,
|
||||
length: path ? path.length : 0,
|
||||
time,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear pathfinding adapter caches
|
||||
*/
|
||||
export function clearAdapterCaches() {
|
||||
pfMiniCache.clear();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,315 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Pathfinding Playground - Interactive Visualization</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Fullscreen map container -->
|
||||
<div class="canvas-container">
|
||||
<div class="canvas-wrapper" id="canvasWrapper">
|
||||
<canvas id="mapCanvas"></canvas>
|
||||
<canvas id="overlayCanvas"></canvas>
|
||||
</div>
|
||||
<!-- Interactive canvas added dynamically outside wrapper -->
|
||||
</div>
|
||||
|
||||
<!-- Welcome screen -->
|
||||
<div class="welcome-screen" id="welcomeScreen">
|
||||
<div class="welcome-content">
|
||||
<h1>Pathfinding Playground</h1>
|
||||
<p>Interactive visualization for naval pathfinding algorithms</p>
|
||||
|
||||
<!-- Map grid -->
|
||||
<div class="map-grid" id="mapGrid">
|
||||
<div class="map-card" data-map-index="0">
|
||||
<img
|
||||
width="360"
|
||||
height="180"
|
||||
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='180'%3E%3Crect fill='%23333' width='360' height='180'/%3E%3C/svg%3E"
|
||||
alt="Loading..."
|
||||
/>
|
||||
<div class="map-card-name" style="opacity: 0">Map 1</div>
|
||||
</div>
|
||||
<div class="map-card" data-map-index="1">
|
||||
<img
|
||||
width="360"
|
||||
height="180"
|
||||
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='180'%3E%3Crect fill='%23333' width='360' height='180'/%3E%3C/svg%3E"
|
||||
alt="Loading..."
|
||||
/>
|
||||
<div class="map-card-name" style="opacity: 0">Map 2</div>
|
||||
</div>
|
||||
<div class="map-card" data-map-index="2">
|
||||
<img
|
||||
width="360"
|
||||
height="180"
|
||||
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='180'%3E%3Crect fill='%23333' width='360' height='180'/%3E%3C/svg%3E"
|
||||
alt="Loading..."
|
||||
/>
|
||||
<div class="map-card-name" style="opacity: 0">Map 3</div>
|
||||
</div>
|
||||
<div class="map-card" data-map-index="3">
|
||||
<img
|
||||
width="360"
|
||||
height="180"
|
||||
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='180'%3E%3Crect fill='%23333' width='360' height='180'/%3E%3C/svg%3E"
|
||||
alt="Loading..."
|
||||
/>
|
||||
<div class="map-card-name" style="opacity: 0">Map 4</div>
|
||||
</div>
|
||||
<div class="map-card" data-map-index="4">
|
||||
<img
|
||||
width="360"
|
||||
height="180"
|
||||
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='180'%3E%3Crect fill='%23333' width='360' height='180'/%3E%3C/svg%3E"
|
||||
alt="Loading..."
|
||||
/>
|
||||
<div class="map-card-name" style="opacity: 0">Map 5</div>
|
||||
</div>
|
||||
<div class="map-card" data-map-index="5">
|
||||
<img
|
||||
width="360"
|
||||
height="180"
|
||||
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='180'%3E%3Crect fill='%23333' width='360' height='180'/%3E%3C/svg%3E"
|
||||
alt="Loading..."
|
||||
/>
|
||||
<div class="map-card-name" style="opacity: 0">Map 6</div>
|
||||
</div>
|
||||
<div class="map-card" data-map-index="6">
|
||||
<img
|
||||
width="360"
|
||||
height="180"
|
||||
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='180'%3E%3Crect fill='%23333' width='360' height='180'/%3E%3C/svg%3E"
|
||||
alt="Loading..."
|
||||
/>
|
||||
<div class="map-card-name" style="opacity: 0">Map 7</div>
|
||||
</div>
|
||||
<div class="map-card" data-map-index="7">
|
||||
<img
|
||||
width="360"
|
||||
height="180"
|
||||
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='180'%3E%3Crect fill='%23333' width='360' height='180'/%3E%3C/svg%3E"
|
||||
alt="Loading..."
|
||||
/>
|
||||
<div class="map-card-name" style="opacity: 0">Map 8</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown for other maps -->
|
||||
<div class="welcome-selector">
|
||||
<label for="welcomeMapSelect">Or select from all maps:</label>
|
||||
<select id="welcomeMapSelect">
|
||||
<option value="">Loading maps...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top overlay panel -->
|
||||
<div class="top-panel">
|
||||
<h1>Pathfinding Playground</h1>
|
||||
|
||||
<div class="scenario-selector">
|
||||
<select id="scenarioSelect">
|
||||
<option value="">Loading...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="status-section">
|
||||
<span id="status">Select a scenario to begin</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Debug controls panel (left) -->
|
||||
<div class="debug-panel">
|
||||
<div class="debug-panel-row">
|
||||
<button class="toggle-button" id="showInitialPath" data-active="false">
|
||||
Initial Path
|
||||
</button>
|
||||
<button class="toggle-button" id="showUsedGateways" data-active="false">
|
||||
Used Gateways
|
||||
</button>
|
||||
</div>
|
||||
<div class="debug-panel-row">
|
||||
<button class="toggle-button" id="showGateways" data-active="false">
|
||||
Gateways
|
||||
</button>
|
||||
<button class="toggle-button" id="showSectorGrid" data-active="false">
|
||||
Sectors
|
||||
</button>
|
||||
<button class="toggle-button" id="showEdges" data-active="false">
|
||||
Edges
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View controls panel (right) -->
|
||||
<div class="view-panel">
|
||||
<div class="zoom-control">
|
||||
<input type="range" id="zoom" min="0.1" max="5" step="0.1" value="1" />
|
||||
<span id="zoomValue">1.0x</span>
|
||||
</div>
|
||||
<button class="toggle-button" id="showColoredMap" data-active="false">
|
||||
Colored Map
|
||||
</button>
|
||||
<button id="clearPoints" class="clear-button">Clear Points</button>
|
||||
</div>
|
||||
|
||||
<!-- Timings panel (left side) -->
|
||||
<div class="timings-panel" id="timingsPanel">
|
||||
<div class="timings-header">Performance</div>
|
||||
|
||||
<div class="timing-section">
|
||||
<div class="timing-label">
|
||||
<button
|
||||
class="refresh-icon"
|
||||
id="refreshNavMesh"
|
||||
title="Recompute NavMesh path"
|
||||
>
|
||||
<span>↻</span>
|
||||
</button>
|
||||
NavMesh <span class="timing-label-detail" id="navMeshTiles"></span>
|
||||
</div>
|
||||
<div class="timing-value-large" id="navMeshTime">—</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="timingFindGateways"
|
||||
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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- Legend panel -->
|
||||
<div class="legend-panel">
|
||||
<div class="legend-header">Legend</div>
|
||||
<div class="legend">
|
||||
<div class="legend-item">
|
||||
<div
|
||||
class="legend-color"
|
||||
style="background: #ff4444; height: 8px"
|
||||
></div>
|
||||
<span>Start Point</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div
|
||||
class="legend-color"
|
||||
style="background: #44ff44; height: 8px"
|
||||
></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>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background: #ff00ff"></div>
|
||||
<span>Initial Path</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div
|
||||
class="legend-color"
|
||||
style="background: #ffff00; height: 8px"
|
||||
></div>
|
||||
<span>Used Gateways</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div
|
||||
class="legend-color"
|
||||
style="
|
||||
background: #aaaaaa;
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
border-radius: 50%;
|
||||
"
|
||||
></div>
|
||||
<span>Gateways</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div
|
||||
class="legend-color"
|
||||
style="background: #777777; height: 2px"
|
||||
></div>
|
||||
<span>Sectors</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div
|
||||
class="legend-color"
|
||||
style="background: #00ffaa; height: 2px"
|
||||
></div>
|
||||
<span>Edges</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error notification (toast) -->
|
||||
<div id="error" class="error-toast"></div>
|
||||
|
||||
<!-- Tooltip -->
|
||||
<div id="tooltip"></div>
|
||||
|
||||
<script src="client.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,795 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #3c3c3c;
|
||||
color: #e0e0e0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Welcome screen */
|
||||
.welcome-screen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(28, 28, 28, 0.98);
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.welcome-screen.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
text-align: center;
|
||||
max-width: 1100px;
|
||||
padding: 40px;
|
||||
background: rgba(42, 42, 42, 0.95);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.7);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.welcome-content h1 {
|
||||
font-size: 42px;
|
||||
color: #fff;
|
||||
margin: 0 0 16px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.welcome-content p {
|
||||
font-size: 18px;
|
||||
color: #aaa;
|
||||
margin: 0 0 30px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Map grid */
|
||||
.map-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.map-card {
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #404040;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.map-card:hover {
|
||||
border-color: #0066cc;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 102, 204, 0.3);
|
||||
}
|
||||
|
||||
.map-card img {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.map-card-name {
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
color: #e0e0e0;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
background: #2a2a2a;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.welcome-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.welcome-selector label {
|
||||
font-size: 14px;
|
||||
color: #aaa;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.welcome-selector select {
|
||||
width: 400px;
|
||||
padding: 14px 18px;
|
||||
font-size: 16px;
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
border: 2px solid #404040;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.welcome-selector select:hover {
|
||||
border-color: #0066cc;
|
||||
}
|
||||
|
||||
.welcome-selector select:focus {
|
||||
outline: none;
|
||||
border-color: #0066cc;
|
||||
}
|
||||
|
||||
/* Fullscreen canvas container */
|
||||
.canvas-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #3c3c3c;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.canvas-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.canvas-wrapper:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.canvas-wrapper.selecting {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
#mapCanvas {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#overlayCanvas {
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Top panel - overlay on map */
|
||||
.top-panel {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
max-width: 900px;
|
||||
background: rgba(42, 42, 42, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
z-index: 10;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.top-panel h1 {
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
margin: 0 0 15px 0;
|
||||
}
|
||||
|
||||
.scenario-selector {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.status-section {
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #404040;
|
||||
}
|
||||
|
||||
.scenario-selector select {
|
||||
width: 350px;
|
||||
padding: 12px 16px;
|
||||
font-size: 16px;
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #404040;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scenario-selector select:focus {
|
||||
outline: none;
|
||||
border-color: #0066cc;
|
||||
}
|
||||
|
||||
.info {
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #404040;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Debug panel (bottom left) */
|
||||
.debug-panel {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
background: rgba(42, 42, 42, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
z-index: 10;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.debug-panel-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* View panel (bottom right) */
|
||||
.view-panel {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: rgba(42, 42, 42, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
z-index: 10;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.zoom-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #404040;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.zoom-control input[type="range"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.zoom-control span {
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
background: #cc3333;
|
||||
color: white;
|
||||
border: 2px solid #dd4444;
|
||||
padding: 10px 12px 10px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.clear-button:hover {
|
||||
background: #aa2222;
|
||||
border-color: #cc3333;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
background: #333;
|
||||
color: #e0e0e0;
|
||||
border: 2px solid #555;
|
||||
padding: 10px 12px 10px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
min-width: 100px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toggle-button::before {
|
||||
content: "☐";
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.toggle-button:hover {
|
||||
background: #404040;
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
.toggle-button[data-active="true"] {
|
||||
background: #0066cc;
|
||||
border-color: #0088ff;
|
||||
color: white;
|
||||
box-shadow: 0 0 10px rgba(0, 102, 204, 0.4);
|
||||
}
|
||||
|
||||
.toggle-button[data-active="true"]::before {
|
||||
content: "☑";
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.toggle-button[data-active="true"]:hover {
|
||||
background: #0052a3;
|
||||
border-color: #0066cc;
|
||||
}
|
||||
|
||||
/* Timings panel (left side) */
|
||||
.timings-panel {
|
||||
position: fixed;
|
||||
top: 250px;
|
||||
left: 20px;
|
||||
background: rgba(42, 42, 42, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
padding: 20px 20px 15px 20px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
z-index: 10;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.timings-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timing-section {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.timing-section + .timing-section {
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #404040;
|
||||
}
|
||||
|
||||
.timing-label {
|
||||
font-size: 18px;
|
||||
color: #e0e0e0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.refresh-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #00aaff;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
line-height: 1;
|
||||
transition: color 0.2s;
|
||||
border-radius: 4px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.refresh-icon span {
|
||||
display: block;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.refresh-icon:hover {
|
||||
color: #00ccff;
|
||||
background: rgba(0, 170, 255, 0.1);
|
||||
}
|
||||
|
||||
.refresh-icon.spinning span {
|
||||
animation: spin 0.6s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.timing-label-detail {
|
||||
color: #888;
|
||||
text-transform: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.timing-value-large {
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
color: #00ff88;
|
||||
font-family: "Courier New", monospace;
|
||||
line-height: 1;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.timing-value-large.faded {
|
||||
color: #888888;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.timing-value-speedup {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #ffaa00;
|
||||
font-family: "Courier New", monospace;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.timing-breakdown {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.timing-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 3px 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.timing-name {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.timing-value {
|
||||
font-family:
|
||||
"Consolas", "Monaco", "SF Mono", "Roboto Mono", "Courier New", monospace;
|
||||
color: #f5f5f5;
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* Legend panel (right side) */
|
||||
.legend-panel {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
right: 20px;
|
||||
transform: translateY(-50%);
|
||||
background: rgba(42, 42, 42, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
z-index: 10;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.legend-header {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #404040;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 28px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Form elements */
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #0066cc;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #0052a3;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #404040;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.timing-button {
|
||||
width: 100%;
|
||||
background: #555;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.timing-button:hover:not(:disabled) {
|
||||
background: #666;
|
||||
}
|
||||
|
||||
.timing-button:disabled {
|
||||
background: #404040;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
#status {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#status.loading {
|
||||
color: #00aaff;
|
||||
}
|
||||
|
||||
#status.error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
/* Error toast notification */
|
||||
.error-toast {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(138, 26, 26, 0.98);
|
||||
color: #ff6b6b;
|
||||
padding: 20px 30px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.7);
|
||||
z-index: 100;
|
||||
display: none;
|
||||
max-width: 500px;
|
||||
text-align: center;
|
||||
border: 2px solid #ff6b6b;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.error-toast.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translate(-50%, -60%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
#tooltip {
|
||||
position: fixed;
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
color: #fff;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
border: 1px solid #666;
|
||||
max-width: 350px;
|
||||
line-height: 1.5;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
#tooltip.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #404040;
|
||||
border-top-color: #00aaff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 1200px) {
|
||||
.top-panel {
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.debug-panel {
|
||||
left: 10px;
|
||||
padding: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.debug-panel-row {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.view-panel {
|
||||
right: 10px;
|
||||
padding: 12px;
|
||||
gap: 8px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
min-width: auto;
|
||||
padding: 8px 10px 8px 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
padding: 8px 10px 8px 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.legend-panel {
|
||||
right: 10px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.top-panel h1 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.debug-panel {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.debug-panel-row {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.view-panel {
|
||||
gap: 6px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
min-width: auto;
|
||||
padding: 7px 8px 7px 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
padding: 7px 8px 7px 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.timings-panel {
|
||||
top: auto;
|
||||
bottom: 180px;
|
||||
left: 10px;
|
||||
transform: none;
|
||||
min-width: auto;
|
||||
max-width: calc(100vw - 20px);
|
||||
}
|
||||
|
||||
.legend-panel {
|
||||
top: auto;
|
||||
bottom: 200px;
|
||||
right: 10px;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import compression from "compression";
|
||||
import express, { Request, Response } from "express";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import {
|
||||
clearCache as clearMapCache,
|
||||
getMapMetadata,
|
||||
listMaps,
|
||||
setConfig,
|
||||
} from "./api/maps.js";
|
||||
import {
|
||||
clearAdapterCaches,
|
||||
computePath,
|
||||
computePfMiniPath,
|
||||
} from "./api/pathfinding.js";
|
||||
|
||||
// Parse command-line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const noCache = args.includes("--no-cache");
|
||||
|
||||
// Configure map loading
|
||||
if (noCache) {
|
||||
setConfig({ cachePaths: false });
|
||||
console.log("Path caching disabled (--no-cache)");
|
||||
}
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT ?? 5555;
|
||||
|
||||
// Middleware
|
||||
app.use(compression()); // gzip compression for large responses
|
||||
app.use(express.json({ limit: "50mb" })); // JSON body parser with larger limit
|
||||
|
||||
// Serve static files from public directory
|
||||
const publicDir = join(dirname(fileURLToPath(import.meta.url)), "public");
|
||||
app.use(express.static(publicDir));
|
||||
|
||||
// API Routes
|
||||
|
||||
/**
|
||||
* GET /api/maps
|
||||
* List all available maps
|
||||
*/
|
||||
app.get("/api/maps", (req: Request, res: Response) => {
|
||||
try {
|
||||
const maps = listMaps();
|
||||
res.json({ maps });
|
||||
} catch (error) {
|
||||
console.error("Error listing maps:", error);
|
||||
res.status(500).json({
|
||||
error: "Failed to list maps",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/maps/:name
|
||||
* Get map metadata (map data, dimensions)
|
||||
*/
|
||||
app.get("/api/maps/:name", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const metadata = await getMapMetadata(name);
|
||||
res.json(metadata);
|
||||
} catch (error) {
|
||||
console.error(`Error loading map ${req.params.name}:`, error);
|
||||
|
||||
if (error instanceof Error && error.message.includes("ENOENT")) {
|
||||
res.status(404).json({
|
||||
error: "Map not found",
|
||||
message: `Map "${req.params.name}" does not exist`,
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
error: "Failed to load map",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/maps/:name/thumbnail
|
||||
* Get map thumbnail image
|
||||
*/
|
||||
app.get("/api/maps/:name/thumbnail", (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const thumbnailPath = join(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../resources/maps",
|
||||
name,
|
||||
"thumbnail.webp",
|
||||
);
|
||||
res.sendFile(thumbnailPath);
|
||||
} catch (error) {
|
||||
console.error(`Error loading thumbnail for ${req.params.name}:`, error);
|
||||
res.status(404).json({
|
||||
error: "Thumbnail not found",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/pathfind
|
||||
* Compute pathfinding between two points
|
||||
*
|
||||
* Request body:
|
||||
* {
|
||||
* map: string,
|
||||
* from: [x, y],
|
||||
* to: [x, y],
|
||||
* includePfMini?: boolean
|
||||
* }
|
||||
*/
|
||||
app.post("/api/pathfind", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { map, from, to, includePfMini } = 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 paths
|
||||
const result = await computePath(
|
||||
map,
|
||||
from as [number, number],
|
||||
to as [number, number],
|
||||
{ includePfMini: !!includePfMini },
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error("Error computing 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 path",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
app.post("/api/cache/clear", (req: Request, res: Response) => {
|
||||
try {
|
||||
clearMapCache();
|
||||
clearAdapterCaches();
|
||||
res.json({ message: "Caches cleared successfully" });
|
||||
} catch (error) {
|
||||
console.error("Error clearing caches:", error);
|
||||
res.status(500).json({
|
||||
error: "Failed to clear caches",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err: Error, req: Request, res: Response, next: any) => {
|
||||
console.error("Unhandled error:", err);
|
||||
res.status(500).json({
|
||||
error: "Internal server error",
|
||||
message: err.message,
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Pathfinding Playground Server ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
|
||||
Server running at: http://localhost:${PORT}
|
||||
|
||||
Configuration:
|
||||
- Path caching: ${noCache ? "disabled" : "enabled"}
|
||||
|
||||
Press Ctrl+C to stop
|
||||
`);
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import fs from "fs";
|
||||
import path, { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import {
|
||||
Difficulty,
|
||||
Game,
|
||||
GameMapSize,
|
||||
GameMapType,
|
||||
GameMode,
|
||||
GameType,
|
||||
PlayerInfo,
|
||||
} from "../../src/core/game/Game";
|
||||
import { createGame } from "../../src/core/game/GameImpl";
|
||||
import { TileRef } from "../../src/core/game/GameMap";
|
||||
import {
|
||||
genTerrainFromBin,
|
||||
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 { GameConfig } from "../../src/core/Schemas";
|
||||
import { TestConfig } from "../util/TestConfig";
|
||||
export type BenchmarkRoute = {
|
||||
name: string;
|
||||
from: TileRef;
|
||||
to: TileRef;
|
||||
};
|
||||
|
||||
export type BenchmarkResult = {
|
||||
route: string;
|
||||
executionTime: number | null;
|
||||
pathLength: number | null;
|
||||
};
|
||||
|
||||
export type BenchmarkSummary = {
|
||||
totalRoutes: number;
|
||||
successfulRoutes: number;
|
||||
timedRoutes: number;
|
||||
totalDistance: number;
|
||||
totalTime: number;
|
||||
avgTime: number;
|
||||
};
|
||||
|
||||
export function getAdapter(game: Game, name: string): PathFinder {
|
||||
switch (name) {
|
||||
case "legacy":
|
||||
return PathFinders.WaterLegacy(game, {
|
||||
iterations: 500_000,
|
||||
maxTries: 50,
|
||||
});
|
||||
case "hpa": {
|
||||
// Recreate NavMesh 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;
|
||||
|
||||
return PathFinders.Water(game);
|
||||
}
|
||||
case "hpa.cached":
|
||||
return PathFinders.Water(game);
|
||||
default:
|
||||
throw new Error(`Unknown pathfinding adapter: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getScenario(
|
||||
scenarioName: string,
|
||||
adapterName: string = "hpa",
|
||||
) {
|
||||
const scenario = await import(`./benchmark/scenarios/${scenarioName}.js`);
|
||||
const enableNavMesh = adapterName.startsWith("hpa");
|
||||
|
||||
// Time game creation (includes NavMesh initialization for default adapter)
|
||||
const start = performance.now();
|
||||
const currentDir = dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.join(currentDir, "../..");
|
||||
const mapsDirectory = path.join(projectRoot, "resources/maps");
|
||||
const game = await setupFromPath(mapsDirectory, scenario.MAP_NAME, {
|
||||
disableNavMesh: !enableNavMesh,
|
||||
});
|
||||
const initTime = performance.now() - start;
|
||||
|
||||
const routes = scenario.ROUTES.map(([fromName, toName]: [string, string]) => {
|
||||
const fromCoord: [number, number] = scenario.PORTS[fromName];
|
||||
const toCoord: [number, number] = scenario.PORTS[toName];
|
||||
|
||||
return {
|
||||
name: `${fromName} → ${toName}`,
|
||||
from: game.ref(fromCoord[0], fromCoord[1]),
|
||||
to: game.ref(toCoord[0], toCoord[1]),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
game,
|
||||
routes,
|
||||
initTime,
|
||||
};
|
||||
}
|
||||
|
||||
export function measurePathLength(
|
||||
adapter: PathFinder,
|
||||
route: BenchmarkRoute,
|
||||
): number | null {
|
||||
const path = adapter.findPath(route.from, route.to);
|
||||
return path ? path.length : null;
|
||||
}
|
||||
|
||||
export function measureTime<T>(fn: () => T): { result: T; time: number } {
|
||||
const start = performance.now();
|
||||
const result = fn();
|
||||
const end = performance.now();
|
||||
return { result, time: end - start };
|
||||
}
|
||||
|
||||
export function measureExecutionTime(
|
||||
adapter: PathFinder,
|
||||
route: BenchmarkRoute,
|
||||
executions: number = 1,
|
||||
): number | null {
|
||||
const { time } = measureTime(() => {
|
||||
for (let i = 0; i < executions; i++) {
|
||||
adapter.findPath(route.from, route.to);
|
||||
}
|
||||
});
|
||||
|
||||
return time / executions;
|
||||
}
|
||||
|
||||
export function calculateStats(results: BenchmarkResult[]): BenchmarkSummary {
|
||||
const successful = results.filter((r) => r.pathLength !== null);
|
||||
const timed = results.filter((r) => r.executionTime !== null);
|
||||
|
||||
const totalDistance = successful.reduce((sum, r) => sum + r.pathLength!, 0);
|
||||
const totalTime = timed.reduce((sum, r) => sum + r.executionTime!, 0);
|
||||
const avgTime = timed.length > 0 ? totalTime / timed.length : 0;
|
||||
|
||||
return {
|
||||
totalRoutes: results.length,
|
||||
successfulRoutes: successful.length,
|
||||
timedRoutes: timed.length,
|
||||
totalDistance,
|
||||
totalTime,
|
||||
avgTime,
|
||||
};
|
||||
}
|
||||
|
||||
export function printRow(columns: (string | number)[], widths: number[]): void {
|
||||
const formatted = columns.map((col, i) => {
|
||||
const str = typeof col === "number" ? col.toString() : col;
|
||||
return str.padEnd(widths[i]);
|
||||
});
|
||||
|
||||
console.log(formatted.join(" "));
|
||||
}
|
||||
|
||||
export function printSeparator(width: number = 80): void {
|
||||
console.log("-".repeat(width));
|
||||
}
|
||||
|
||||
export function printHeader(title: string, width: number = 80): void {
|
||||
printSeparator(width);
|
||||
console.log(title);
|
||||
printSeparator(width);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
export async function setupFromPath(
|
||||
mapDirectory: string,
|
||||
mapName: string,
|
||||
gameConfig: Partial<GameConfig> = {},
|
||||
humans: PlayerInfo[] = [],
|
||||
): Promise<Game> {
|
||||
// Suppress console.debug for tests
|
||||
console.debug = () => {};
|
||||
|
||||
// Load map files from specified directory
|
||||
const mapBinPath = path.join(mapDirectory, mapName, "map.bin");
|
||||
const miniMapBinPath = path.join(mapDirectory, mapName, "map4x.bin");
|
||||
const manifestPath = path.join(mapDirectory, mapName, "manifest.json");
|
||||
|
||||
// Check if files exist
|
||||
if (!fs.existsSync(mapBinPath)) {
|
||||
throw new Error(`Map not found: ${mapBinPath}`);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(miniMapBinPath)) {
|
||||
throw new Error(`Mini map not found: ${miniMapBinPath}`);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
throw new Error(`Manifest not found: ${manifestPath}`);
|
||||
}
|
||||
|
||||
const mapBinBuffer = fs.readFileSync(mapBinPath);
|
||||
const miniMapBinBuffer = fs.readFileSync(miniMapBinPath);
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(manifestPath, "utf8"),
|
||||
) satisfies MapManifest;
|
||||
|
||||
const gameMap = await genTerrainFromBin(manifest.map, mapBinBuffer);
|
||||
const miniGameMap = await genTerrainFromBin(manifest.map4x, miniMapBinBuffer);
|
||||
|
||||
// Configure the game
|
||||
const config = new TestConfig(
|
||||
new (await import("../util/TestServerConfig")).TestServerConfig(),
|
||||
{
|
||||
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,
|
||||
randomSpawn: false,
|
||||
...gameConfig,
|
||||
},
|
||||
new UserSettings(),
|
||||
false,
|
||||
);
|
||||
|
||||
return createGame(humans, [], gameMap, miniGameMap, config);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import Benchmark from "benchmark";
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { PathFinder } from "../../src/core/pathfinding/PathFinding";
|
||||
import { MiniPathFinder } from "../../src/core/pathfinding/PathFinding";
|
||||
import { setup } from "../util/Setup";
|
||||
|
||||
const game = await setup(
|
||||
@@ -13,19 +13,19 @@ const game = await setup(
|
||||
|
||||
new Benchmark.Suite()
|
||||
.add("top-left-to-bottom-right", () => {
|
||||
PathFinder.Mini(game, 10_000_000_000, true, 1).nextTile(
|
||||
new MiniPathFinder(game, 10_000_000_000, true, 1).nextTile(
|
||||
game.ref(0, 0),
|
||||
game.ref(4077, 1929),
|
||||
);
|
||||
})
|
||||
.add("hawaii to svalbard", () => {
|
||||
PathFinder.Mini(game, 10_000_000_000, true, 1).nextTile(
|
||||
new MiniPathFinder(game, 10_000_000_000, true, 1).nextTile(
|
||||
game.ref(186, 800),
|
||||
game.ref(2205, 52),
|
||||
);
|
||||
})
|
||||
.add("black sea to california", () => {
|
||||
PathFinder.Mini(game, 10_000_000_000, true, 1).nextTile(
|
||||
new MiniPathFinder(game, 10_000_000_000, true, 1).nextTile(
|
||||
game.ref(2349, 455),
|
||||
game.ref(511, 536),
|
||||
);
|
||||
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
{
|
||||
"map": {
|
||||
"height": 1000,
|
||||
"num_land_tiles": 651761,
|
||||
"width": 2000
|
||||
},
|
||||
"map16x": {
|
||||
"height": 250,
|
||||
"num_land_tiles": 37185,
|
||||
"width": 500
|
||||
},
|
||||
"map4x": {
|
||||
"height": 500,
|
||||
"num_land_tiles": 157860,
|
||||
"width": 1000
|
||||
},
|
||||
"name": "World",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [375, 272],
|
||||
"flag": "us",
|
||||
"name": "United States"
|
||||
},
|
||||
{
|
||||
"coordinates": [372, 136],
|
||||
"flag": "ca",
|
||||
"name": "Canada"
|
||||
},
|
||||
{
|
||||
"coordinates": [375, 374],
|
||||
"flag": "mx",
|
||||
"name": "Mexico"
|
||||
},
|
||||
{
|
||||
"coordinates": [500, 378],
|
||||
"flag": "cu",
|
||||
"name": "Cuba"
|
||||
},
|
||||
{
|
||||
"coordinates": [524, 474],
|
||||
"flag": "co",
|
||||
"name": "Colombia"
|
||||
},
|
||||
{
|
||||
"coordinates": [593, 473],
|
||||
"flag": "ve",
|
||||
"name": "Venezuela"
|
||||
},
|
||||
{
|
||||
"coordinates": [596, 705],
|
||||
"flag": "ar",
|
||||
"name": "Argentina"
|
||||
},
|
||||
{
|
||||
"coordinates": [637, 567],
|
||||
"flag": "br",
|
||||
"name": "Brazil"
|
||||
},
|
||||
{
|
||||
"coordinates": [1280, 975],
|
||||
"flag": "aq",
|
||||
"name": "Antarctica"
|
||||
},
|
||||
{
|
||||
"coordinates": [709, 57],
|
||||
"flag": "gl",
|
||||
"name": "Greenland"
|
||||
},
|
||||
{
|
||||
"coordinates": [831, 112],
|
||||
"flag": "is",
|
||||
"name": "Iceland"
|
||||
},
|
||||
{
|
||||
"coordinates": [925, 186],
|
||||
"flag": "gb",
|
||||
"name": "United Kingdom"
|
||||
},
|
||||
{
|
||||
"coordinates": [887, 183],
|
||||
"flag": "ie",
|
||||
"name": "Ireland"
|
||||
},
|
||||
{
|
||||
"coordinates": [908, 264],
|
||||
"flag": "es",
|
||||
"name": "Spain"
|
||||
},
|
||||
{
|
||||
"coordinates": [1004, 250],
|
||||
"flag": "it",
|
||||
"name": "Italy"
|
||||
},
|
||||
{
|
||||
"coordinates": [958, 220],
|
||||
"flag": "fr",
|
||||
"name": "France"
|
||||
},
|
||||
{
|
||||
"coordinates": [997, 205],
|
||||
"flag": "de",
|
||||
"name": "Germany"
|
||||
},
|
||||
{
|
||||
"coordinates": [1064, 101],
|
||||
"flag": "se",
|
||||
"name": "Sweden"
|
||||
},
|
||||
{
|
||||
"coordinates": [1046, 193],
|
||||
"flag": "pl",
|
||||
"name": "Poland"
|
||||
},
|
||||
{
|
||||
"coordinates": [1061, 188],
|
||||
"flag": "by",
|
||||
"name": "Belarus"
|
||||
},
|
||||
{
|
||||
"coordinates": [1073, 243],
|
||||
"flag": "ro",
|
||||
"name": "Romania"
|
||||
},
|
||||
{
|
||||
"coordinates": [1161, 274],
|
||||
"flag": "tr",
|
||||
"name": "Turkey"
|
||||
},
|
||||
{
|
||||
"coordinates": [969, 133],
|
||||
"flag": "no",
|
||||
"name": "Norway"
|
||||
},
|
||||
{
|
||||
"coordinates": [1062, 133],
|
||||
"flag": "fi",
|
||||
"name": "Finland"
|
||||
},
|
||||
{
|
||||
"coordinates": [1099, 211],
|
||||
"flag": "ua",
|
||||
"name": "Ukraine"
|
||||
},
|
||||
{
|
||||
"coordinates": [1344, 136],
|
||||
"flag": "ru",
|
||||
"name": "Russia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1537, 186],
|
||||
"flag": "mn",
|
||||
"name": "Mongolia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1524, 328],
|
||||
"flag": "cn",
|
||||
"name": "China"
|
||||
},
|
||||
{
|
||||
"coordinates": [1368, 373],
|
||||
"flag": "in",
|
||||
"name": "India"
|
||||
},
|
||||
{
|
||||
"coordinates": [1276, 239],
|
||||
"flag": "kz",
|
||||
"name": "Kazakhstan"
|
||||
},
|
||||
{
|
||||
"coordinates": [1238, 309],
|
||||
"flag": "ir",
|
||||
"name": "Islamic Republic Of Iran"
|
||||
},
|
||||
{
|
||||
"coordinates": [1178, 351],
|
||||
"flag": "sa",
|
||||
"name": "Saudi Arabia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1679, 657],
|
||||
"flag": "au",
|
||||
"name": "Australia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1890, 775],
|
||||
"flag": "nz",
|
||||
"name": "New Zealand"
|
||||
},
|
||||
{
|
||||
"coordinates": [918, 342],
|
||||
"flag": "dz",
|
||||
"name": "Algeria"
|
||||
},
|
||||
{
|
||||
"coordinates": [1030, 332],
|
||||
"flag": "ly",
|
||||
"name": "Libyan Arab Jamahiriya"
|
||||
},
|
||||
{
|
||||
"coordinates": [1092, 335],
|
||||
"flag": "eg",
|
||||
"name": "Egypt"
|
||||
},
|
||||
{
|
||||
"coordinates": [963, 410],
|
||||
"flag": "ne",
|
||||
"name": "Niger"
|
||||
},
|
||||
{
|
||||
"coordinates": [1112, 406],
|
||||
"flag": "sd",
|
||||
"name": "Sudan"
|
||||
},
|
||||
{
|
||||
"coordinates": [1074, 508],
|
||||
"flag": "cd",
|
||||
"name": "DR Congo"
|
||||
},
|
||||
{
|
||||
"coordinates": [1154, 443],
|
||||
"flag": "et",
|
||||
"name": "Ethiopia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1075, 707],
|
||||
"flag": "za",
|
||||
"name": "South Africa"
|
||||
},
|
||||
{
|
||||
"coordinates": [1194, 627],
|
||||
"flag": "mg",
|
||||
"name": "Madagascar"
|
||||
},
|
||||
{
|
||||
"coordinates": [1052, 420],
|
||||
"flag": "td",
|
||||
"name": "Chad"
|
||||
},
|
||||
{
|
||||
"coordinates": [1030, 665],
|
||||
"flag": "na",
|
||||
"name": "Namibia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1632, 465],
|
||||
"flag": "ph",
|
||||
"name": "Philippines"
|
||||
},
|
||||
{
|
||||
"coordinates": [1537, 426],
|
||||
"flag": "th",
|
||||
"name": "Thailand"
|
||||
},
|
||||
{
|
||||
"coordinates": [1610, 364],
|
||||
"flag": "tw",
|
||||
"name": "Taiwan"
|
||||
},
|
||||
{
|
||||
"coordinates": [1710, 290],
|
||||
"flag": "jp",
|
||||
"name": "Japan"
|
||||
},
|
||||
{
|
||||
"coordinates": [1869, 119],
|
||||
"flag": "ru",
|
||||
"name": "Siberia"
|
||||
},
|
||||
{
|
||||
"coordinates": [74, 117],
|
||||
"flag": "polar_bears",
|
||||
"name": "Polar Bears"
|
||||
},
|
||||
{
|
||||
"coordinates": [419, 975],
|
||||
"flag": "aq",
|
||||
"name": "West Antarctica"
|
||||
},
|
||||
{
|
||||
"coordinates": [542, 603],
|
||||
"flag": "pe",
|
||||
"name": "Peru"
|
||||
},
|
||||
{
|
||||
"coordinates": [1075, 615],
|
||||
"flag": "zm",
|
||||
"name": "Zambia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1099, 165],
|
||||
"flag": "lv",
|
||||
"name": "Latvia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1427, 336],
|
||||
"flag": "bt",
|
||||
"name": "Bhutan"
|
||||
},
|
||||
{
|
||||
"coordinates": [1511, 524],
|
||||
"flag": "id",
|
||||
"name": "Indonesia"
|
||||
},
|
||||
{
|
||||
"coordinates": [1809, 977],
|
||||
"flag": "aq",
|
||||
"name": "East Antarctica"
|
||||
},
|
||||
{
|
||||
"coordinates": [1255, 382],
|
||||
"flag": "om",
|
||||
"name": "Oman"
|
||||
},
|
||||
{
|
||||
"coordinates": [853, 373],
|
||||
"flag": "ma",
|
||||
"name": "Morocco"
|
||||
},
|
||||
{
|
||||
"coordinates": [656, 678],
|
||||
"flag": "uy",
|
||||
"name": "Uruguay"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -14,6 +14,10 @@ export class TestConfig extends DefaultConfig {
|
||||
private _defaultNukeSpeed: number = 4;
|
||||
private _spawnImmunityDuration: number = 0;
|
||||
|
||||
disableNavMesh(): boolean {
|
||||
return this.gameConfig().disableNavMesh ?? true;
|
||||
}
|
||||
|
||||
radiusPortSpawn(): number {
|
||||
return 1;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user