Pathfinding Refactor pt. 2 (#2866)

## Playtest

https://pf-pt-2.openfront.dev/

## Pathfinding Refactor pt. 2

<img width="1536" height="1024" alt="image"
src="https://github.com/user-attachments/assets/9477958e-54b7-4c83-b317-ba789e809e9e"
/>


This is a follow-up to a previous PR introducing pathfinding changes.
This time, it introduces a complete refactor of `pathfinding` directory
and breakdown into composable pieces.

### Unified PathFinder interface

`PathFinder<T>` and `SteppingPathFinder<T>` are introduced to unify
**all** pathfinding across the application. First one exposes complete
path, while stepping variant allows the callee to iterate over the path
by calling `.next`. All pathfinders share this one common interface,
which makes them easy to use in any scenario -
`PathFinding.Water(game).search(from, to)`.

`SteppingPathFinder<T>` extends `PathFinder<T>` with an ability to
iterate over the path. It handles caching, storing current index and
invalidation. This allows the units to not care about the inner workings
of the pathfinder and just call `pf.next(current, target)` and receive
instructions on what to do next.

### Common entry point

All pathfinders are now exposed from common `PathFinding` entrypoint:

- `PathFinding.Water`
- `PathFinding.Rail`
- `PathFinding.Stations`
- `PathFinding.Rail`

Additional entry point is introduced for pathfinders which need to work
both in the worker, but also on the frontend, which lacks `Game`
interface. Currently only `UniversalPathFinding.Parabola` is available.

### Spatial Query

New module has been introduced close to `pathfinding` - `SpatialQuery`.
It aims to resolve any questions game may have about finding tiles
meeting criteria. Currently `SpatialQuery.closestShore(player, target)`
and `SpatialQuery.closestShoreByWater(player, target)` are available -
they help answering questions about naval invasion: "What is the best
landing location from user's click?" and "Which our tile should be used
to launch the transport ship?". Under the hood they use very similar
mechanics to pathfinding, so it felt right to put them close by.

### Modular architecture

Pathfinders now support transformers: `MiniMapTransformer`,
`ShoreCoercingTransformer`, `ComponentCheckTransformer`,
`SmoothingTransformer`. Transformers functions like a middleware in the
pathfinding chain. They wrap around the pathfinder and provide
additional functionality. This allows the pathfinder to focus on
actually finding the path instead of doing unrelated things.

Example chain for simple (A*) water pathfinding:
```ts
static WaterSimple(game: Game): SteppingPathFinder<TileRef> {
  const miniMap = game.miniMap();
  const pf = new AStarWater(miniMap);

  return PathFinderBuilder.create(pf)
    .wrap((pf) => new ShoreCoercingTransformer(pf, miniMap))
    .wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap))
    .buildWithStepper(tileStepperConfig(game));
}
```

The Pathfinder - here `AStarWater` - does not care about the conversion
between minimap and main map tiles. It also does not care if the source
or destination is a land tile. The transformers take care of that. The
pathfinder gets a set of valid coordinates and produces the path -
that's it.

Modular approach makes working on a particular set of utilities much
easier - for example map upscaling is handled consistently across all
pathfinders. Additionally, the pathfinders are not tied to the
particular map resolution used. Pass them a different map and they will
work the same.

### Algorithms

Algorithms used are neatly organized inside
`src/core/pathfinding/algorithms`. They are prefixed with the algorithm
name and suffixed with the use case. File without suffix exposes generic
version ready to traverse any graph with adapters. Specialized versions
either use an adapter or inline logic when performance is critical -
using adapters leads to 20-30% performance loss.

The directory includes `A*` and `BFS` but also other useful utils, such
as `AbstractGraph` used to generate... an abstract graph on top of the
tile map and `ConnectedComponents` helping to identify whether two tiles
are connected by a path without actually computing the path.

### Playground

The playground have been updated with new algorithms, including tweaked
very greedy `A*`.

<img width="2175" height="1424" alt="image"
src="https://github.com/user-attachments/assets/1f833651-0024-4299-bf86-882f5368358c"
/>

### Tests

Yeah, there are some, a little too many if I say so myself. But there
are no useless tests. I had to ensure refactored code works somehow
reliably. This PR comes with trust me bro guarantee, but I would
appreciate someone confirming **naval invasions, nukes (esp. MIRV) and
warships**.

### Discord
`moleole`

GL & HF
This commit is contained in:
Arkadiusz Sygulski
2026-01-11 20:11:14 -08:00
committed by GitHub
parent bcec4ad758
commit 0e3ced3bfa
75 changed files with 6800 additions and 4200 deletions
+1
View File
@@ -383,6 +383,7 @@ describe("Disconnected", () => {
player1.conquer(game.map().ref(coastX, 4));
player2.conquer(game.map().ref(coastX, 1));
// Use a far destination so boat is still in transit after attack completes
const enemyShoreTile = game.map().ref(coastX, 15);
game.addExecution(
@@ -1,6 +1,6 @@
import { TradeShipExecution } from "../../../src/core/execution/TradeShipExecution";
import { Game, Player, Unit } from "../../../src/core/game/Game";
import { PathStatus } from "../../../src/core/pathfinding/PathFinder";
import { PathStatus } from "../../../src/core/pathfinding/types";
import { setup } from "../../util/Setup";
describe("TradeShipExecution", () => {
-333
View File
@@ -1,333 +0,0 @@
import { beforeAll, describe, expect, test, vi } from "vitest";
import { Game } from "../../../src/core/game/Game";
import { TileRef } from "../../../src/core/game/GameMap";
import { MiniAStarAdapter } from "../../../src/core/pathfinding/adapters/MiniAStarAdapter";
import { NavMeshAdapter } from "../../../src/core/pathfinding/adapters/NavMeshAdapter";
import {
PathFinder,
PathStatus,
} from "../../../src/core/pathfinding/PathFinder";
import { setup } from "../../util/Setup";
import { gameFromString } from "./utils";
type AdapterFactory = {
name: string;
create: (game: Game) => PathFinder;
};
const adapters: AdapterFactory[] = [
{
name: "MiniAStarAdapter",
create: (game) => new MiniAStarAdapter(game, { waterPath: true }),
},
{
name: "NavMeshAdapter",
create: (game) => new NavMeshAdapter(game),
},
];
// Shared world game instance
let worldGame: Game;
beforeAll(async () => {
worldGame = await setup("world", { disableNavMesh: false });
});
describe.each(adapters)("$name", ({ create }) => {
describe("findPath()", () => {
test("finds path between adjacent tiles", async () => {
const game = await gameFromString(["WWWW"]);
const adapter = create(game);
const src = game.ref(0, 0);
const dst = game.ref(1, 0);
const path = adapter.findPath(src, dst);
expect(path).not.toBeNull();
expect(path![0]).toBe(src);
expect(path![path!.length - 1]).toBe(dst);
});
test("finds path across multiple tiles", async () => {
const game = await gameFromString(["WWWWWW", "WWWWWW", "WWWWWW"]);
const adapter = create(game);
const src = game.ref(0, 0);
const dst = game.ref(5, 2);
const path = adapter.findPath(src, dst);
expect(path).not.toBeNull();
expect(path![0]).toBe(src);
expect(path![path!.length - 1]).toBe(dst);
});
test("returns single-element path for same tile", async () => {
// Old quirk of MiniAStar, we return dst tile twice
// Should probably be fixed to return [] instead
const game = await gameFromString(["WW"]);
const adapter = create(game);
const tile = game.ref(0, 0);
const path = adapter.findPath(tile, tile);
expect(path).not.toBeNull();
expect(path!.length).toBe(1);
expect(path![0]).toBe(tile);
});
test("returns null for blocked path", async () => {
const game = await gameFromString(["WWLLWW"]);
const adapter = create(game);
const src = game.ref(0, 0);
const dst = game.ref(5, 0);
const path = adapter.findPath(src, dst);
expect(path).toBeNull();
});
test("returns null for water to land", () => {
const adapter = create(worldGame);
const src = worldGame.ref(926, 283); // water
const dst = worldGame.ref(950, 230); // land
const path = adapter.findPath(src, dst);
expect(path).toBeNull();
});
test("traverses 3-tile path in 3 tiles", async () => {
// Expected: [1, 2, 3]
const game = await gameFromString(["WWWW"]);
const adapter = create(game);
const src = game.ref(0, 0);
const dst = game.ref(3, 0);
const path = adapter.findPath(src, dst);
expect(path).not.toBeNull();
expect(path).toEqual([
game.ref(0, 0),
game.ref(1, 0),
game.ref(2, 0),
game.ref(3, 0),
]);
});
});
describe("next() state machine", () => {
test("returns NEXT on first call", async () => {
const game = await gameFromString(["WWWW"]);
const adapter = create(game);
const src = game.ref(0, 0);
const dst = game.ref(3, 0);
const result = adapter.next(src, dst);
expect(result.status).toBe(PathStatus.NEXT);
});
test("returns COMPLETE when at destination", async () => {
const game = await gameFromString(["WW"]);
const adapter = create(game);
const tile = game.ref(0, 0);
const result = adapter.next(tile, tile);
expect(result.status).toBe(PathStatus.COMPLETE);
});
test("returns NOT_FOUND for blocked path", async () => {
const game = await gameFromString(["WWLLWW"]);
const adapter = create(game);
const src = game.ref(0, 0);
const dst = game.ref(5, 0);
const result = adapter.next(src, dst);
expect(result.status).toBe(PathStatus.NOT_FOUND);
});
test("traverses 3-tile path in 4 calls", async () => {
// Expected: NEXT(1) -> NEXT(2) -> NEXT(3) -> COMPLETE(4)
const game = await gameFromString(["WWWW"]);
const adapter = create(game);
const src = game.ref(0, 0);
const dst = game.ref(3, 0);
let current = src;
const steps: string[] = [];
// 3 NEXT calls to reach destination
for (let i = 1; i <= 4; i++) {
const result = adapter.next(current, dst);
expect([PathStatus.NEXT, PathStatus.COMPLETE]).toContain(result.status);
current = (result as { node: TileRef }).node;
steps.push(`${PathStatus[result.status]}(${current})`);
}
expect(steps).toEqual(["NEXT(1)", "NEXT(2)", "NEXT(3)", "COMPLETE(3)"]);
});
});
describe("Destination changes", () => {
test("reaches new destination when dest changes", async () => {
const game = await gameFromString(["WWWWWWWW"]); // 8 wide
const adapter = create(game);
const src = game.ref(0, 0);
const dst1 = game.ref(4, 0);
const dst2 = game.ref(7, 0);
// First path exists
expect(adapter.findPath(src, dst1)).not.toBeNull();
// Can still find path to new destination
expect(adapter.findPath(dst1, dst2)).not.toBeNull();
});
test("recomputes when destination changes mid-path", async () => {
const game = await gameFromString(["WWWWWWWWWWWWWWWWWWWW"]); // 20 wide
const adapter = create(game);
const src = game.ref(0, 0);
const dst1 = game.ref(10, 0);
const dst2 = game.ref(19, 0);
// Start pathing to dst1, take one step
const result1 = adapter.next(src, dst1);
expect(result1.status).toBe(PathStatus.NEXT);
// Change destination mid-path, continue from current position
let current = (result1 as { node: TileRef }).node;
let result = adapter.next(current, dst2);
for (let i = 0; i < 100 && result.status === PathStatus.NEXT; i++) {
current = (result as { node: TileRef }).node;
result = adapter.next(current, dst2);
}
expect(result.status).toBe(PathStatus.COMPLETE);
expect(current).toBe(dst2);
});
});
describe("Error handling", () => {
// MiniAStar logs console error when nulls passed, muted in test
test("returns NOT_FOUND for null source", async () => {
const game = await gameFromString(["WWWW"]);
const adapter = create(game);
const dst = game.ref(0, 0);
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
const result = adapter.next(null as unknown as TileRef, dst);
expect(result.status).toBe(PathStatus.NOT_FOUND);
consoleSpy.mockRestore();
});
test("returns NOT_FOUND for null destination", async () => {
const game = await gameFromString(["WWWW"]);
const adapter = create(game);
const src = game.ref(0, 0);
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
const result = adapter.next(src, null as unknown as TileRef);
expect(result.status).toBe(PathStatus.NOT_FOUND);
consoleSpy.mockRestore();
});
});
describe("dist parameter", () => {
test("returns COMPLETE when within dist", () => {
const adapter = create(worldGame);
const src = worldGame.ref(926, 283);
const dst = worldGame.ref(928, 283); // 2 tiles away
const result = adapter.next(src, dst, 5);
expect(result.status).toBe(PathStatus.COMPLETE);
});
test("returns NEXT when beyond dist", () => {
const adapter = create(worldGame);
const src = worldGame.ref(926, 283);
const dst = worldGame.ref(950, 257);
// Adapter may need a few ticks to compute path
let result = adapter.next(src, dst, 5);
for (let i = 0; i < 100 && result.status === PathStatus.PENDING; i++) {
result = adapter.next(src, dst, 5);
}
expect(result.status).toBe(PathStatus.NEXT);
});
});
describe("World map routes", () => {
test("Spain to France (Mediterranean)", () => {
const adapter = create(worldGame);
const path = adapter.findPath(
worldGame.ref(926, 283),
worldGame.ref(950, 257),
);
expect(path).not.toBeNull();
});
test("Miami to Rio (Atlantic)", () => {
const adapter = create(worldGame);
const path = adapter.findPath(
worldGame.ref(488, 355),
worldGame.ref(680, 658),
);
expect(path).not.toBeNull();
expect(path!.length).toBeGreaterThan(100);
});
test("France to Poland (around Europe)", () => {
const adapter = create(worldGame);
const path = adapter.findPath(
worldGame.ref(950, 257),
worldGame.ref(1033, 175),
);
expect(path).not.toBeNull();
});
test("Miami to Spain (transatlantic)", () => {
const adapter = create(worldGame);
const path = adapter.findPath(
worldGame.ref(488, 355),
worldGame.ref(926, 283),
);
expect(path).not.toBeNull();
});
test("Rio to Poland (South Atlantic to Baltic)", () => {
const adapter = create(worldGame);
const path = adapter.findPath(
worldGame.ref(680, 658),
worldGame.ref(1033, 175),
);
expect(path).not.toBeNull();
});
});
describe("Known bugs", () => {
test("path can cross 1-tile land barrier", async () => {
const game = await gameFromString(["WLLWLWWLLW"]);
const adapter = create(game);
const path = adapter.findPath(game.ref(0, 0), game.ref(9, 0));
expect(path).not.toBeNull();
});
test("path can cross diagonal land barrier", async () => {
const game = await gameFromString(["WL", "LW"]);
const adapter = create(game);
const path = adapter.findPath(game.ref(0, 0), game.ref(1, 1));
expect(path).not.toBeNull();
});
});
});
@@ -0,0 +1,179 @@
import { describe, expect, it } from "vitest";
import { PathFinderStepper } from "../../../src/core/pathfinding/PathFinderStepper";
import { PathFinder, PathStatus } from "../../../src/core/pathfinding/types";
describe("PathFinderStepper", () => {
function createMockFinder(
pathMap: Map<string, number[]>,
): PathFinder<number> {
return {
findPath(from: number | number[], to: number): number[] | null {
const fromTile = Array.isArray(from) ? from[0] : from;
const key = `${fromTile}->${to}`;
return pathMap.get(key) ?? null;
},
};
}
describe("next", () => {
it("returns COMPLETE when at destination", () => {
const pathMap = new Map<string, number[]>();
const stepper = new PathFinderStepper(createMockFinder(pathMap));
const result = stepper.next(5, 5);
expect(result.status).toBe(PathStatus.COMPLETE);
expect((result as { node: number }).node).toBe(5);
});
it("returns NEXT with path nodes sequentially", () => {
const pathMap = new Map<string, number[]>([["1->4", [1, 2, 3, 4]]]);
const stepper = new PathFinderStepper(createMockFinder(pathMap));
// First step: 1 -> 4, returns 2
const result1 = stepper.next(1, 4);
expect(result1.status).toBe(PathStatus.NEXT);
expect((result1 as { node: number }).node).toBe(2);
// Second step: from 2, returns 3
const result2 = stepper.next(2, 4);
expect(result2.status).toBe(PathStatus.NEXT);
expect((result2 as { node: number }).node).toBe(3);
// Third step: from 3, returns 4
const result3 = stepper.next(3, 4);
expect(result3.status).toBe(PathStatus.NEXT);
expect((result3 as { node: number }).node).toBe(4);
// Fourth step: at destination
const result4 = stepper.next(4, 4);
expect(result4.status).toBe(PathStatus.COMPLETE);
});
it("returns NOT_FOUND when no path exists", () => {
const pathMap = new Map<string, number[]>();
const stepper = new PathFinderStepper(createMockFinder(pathMap));
const result = stepper.next(1, 99);
expect(result.status).toBe(PathStatus.NOT_FOUND);
});
it("recomputes path when moved off-path", () => {
// Path from 1->5 goes through 2,3,4
// Path from 10->5 goes through 9,8,7,6
const pathMap = new Map<string, number[]>([
["1->5", [1, 2, 3, 4, 5]],
["10->5", [10, 9, 8, 7, 6, 5]],
]);
const stepper = new PathFinderStepper(createMockFinder(pathMap));
// Start on path 1->5
const result1 = stepper.next(1, 5);
expect(result1.status).toBe(PathStatus.NEXT);
expect((result1 as { node: number }).node).toBe(2);
// Move off-path to tile 10 (not on original path)
// Should recompute using path from 10->5
const result2 = stepper.next(10, 5);
expect(result2.status).toBe(PathStatus.NEXT);
expect((result2 as { node: number }).node).toBe(9);
});
it("recomputes path when destination changes", () => {
const pathMap = new Map<string, number[]>([
["1->5", [1, 2, 3, 4, 5]],
["2->9", [2, 6, 7, 8, 9]],
]);
const stepper = new PathFinderStepper(createMockFinder(pathMap));
// Start on path 1->5
const result1 = stepper.next(1, 5);
expect(result1.status).toBe(PathStatus.NEXT);
expect((result1 as { node: number }).node).toBe(2);
// Change destination to 9 (from current position 2)
const result2 = stepper.next(2, 9);
expect(result2.status).toBe(PathStatus.NEXT);
expect((result2 as { node: number }).node).toBe(6);
});
});
describe("invalidate", () => {
it("clears cached path so next recomputes", () => {
let callCount = 0;
const finder: PathFinder<number> = {
findPath(from, to): number[] | null {
callCount++;
const fromTile = Array.isArray(from) ? from[0] : from;
return [fromTile, to];
},
};
const stepper = new PathFinderStepper(finder);
stepper.next(1, 5);
stepper.next(5, 5);
// Second call follows path without recomputing
expect(callCount).toBe(1);
stepper.invalidate();
stepper.next(1, 5);
// Recomputed path after invalidation
expect(callCount).toBe(2);
});
});
describe("findPath", () => {
it("delegates to inner finder", () => {
const pathMap = new Map<string, number[]>([["1->5", [1, 2, 3, 4, 5]]]);
const stepper = new PathFinderStepper(createMockFinder(pathMap));
const path = stepper.findPath(1, 5);
expect(path).toEqual([1, 2, 3, 4, 5]);
});
it("supports multi-source", () => {
const finder: PathFinder<number> = {
findPath(from, to): number[] | null {
const firstFrom = Array.isArray(from) ? from[0] : from;
return [firstFrom, to];
},
};
const stepper = new PathFinderStepper(finder);
const path = stepper.findPath([1, 2, 3], 5);
expect(path).toEqual([1, 5]);
});
});
describe("custom equals", () => {
it("uses custom equals function for position comparison", () => {
type Pos = { x: number; y: number };
const posEquals = (a: Pos, b: Pos) => a.x === b.x && a.y === b.y;
const finder: PathFinder<Pos> = {
findPath(from, to): Pos[] | null {
const f = Array.isArray(from) ? from[0] : from;
return [f, { x: 2, y: 0 }, to];
},
};
const stepper = new PathFinderStepper(finder, { equals: posEquals });
const from1 = { x: 1, y: 0 };
const to = { x: 3, y: 0 };
const result1 = stepper.next(from1, to);
expect(result1.status).toBe(PathStatus.NEXT);
// Use equivalent but different object (a !== b), still on track
const result2 = stepper.next({ x: 2, y: 0 }, to);
expect(result2.status).toBe(PathStatus.NEXT);
expect((result2 as { node: Pos }).node).toEqual({ x: 3, y: 0 });
});
});
});
@@ -0,0 +1,184 @@
import { beforeAll, describe, expect, it } from "vitest";
import { Game } from "../../../src/core/game/Game";
import { TileRef } from "../../../src/core/game/GameMap";
import { PathFinding } from "../../../src/core/pathfinding/PathFinder";
import { SteppingPathFinder } from "../../../src/core/pathfinding/types";
import { setup } from "../../util/Setup";
describe("PathFinding.Air", () => {
let game: Game;
function createPathFinder(): SteppingPathFinder<TileRef> {
return PathFinding.Air(game);
}
beforeAll(async () => {
game = await setup("ocean_and_land");
});
describe("findPath", () => {
it("returns path between any two points (ignores terrain)", () => {
const pathFinder = createPathFinder();
const map = game.map();
// Air pathfinder ignores terrain, so can go anywhere
// (2,2) → (14,14): manhattan = 24, path length = 25
const from = map.ref(2, 2);
const to = map.ref(14, 14);
const path = pathFinder.findPath(from, to);
expect(path).not.toBeNull();
expect(path!.length).toBe(25);
expect(path![0]).toBe(from);
expect(path![path!.length - 1]).toBe(to);
});
it("throws error for multiple start points", () => {
const pathFinder = createPathFinder();
const map = game.map();
const from = [map.ref(2, 2), map.ref(4, 4)];
const to = map.ref(14, 14);
expect(() => pathFinder.findPath(from, to)).toThrow(
"does not support multiple start points",
);
});
it("returns single-tile path when from equals to", () => {
const pathFinder = createPathFinder();
const map = game.map();
const tile = map.ref(8, 8);
const path = pathFinder.findPath(tile, tile);
expect(path).not.toBeNull();
expect(path![0]).toBe(tile);
});
});
describe("path validity", () => {
it("all consecutive tiles in path are adjacent (Manhattan distance 1)", () => {
const pathFinder = createPathFinder();
const map = game.map();
// (2,2) → (14,14): manhattan = 24, path length = 25
const from = map.ref(2, 2);
const to = map.ref(14, 14);
const path = pathFinder.findPath(from, to);
expect(path).not.toBeNull();
expect(path!.length).toBe(25);
// Verify every consecutive pair is adjacent
for (let i = 1; i < path!.length; i++) {
const dist = map.manhattanDist(path![i - 1], path![i]);
expect(dist).toBe(1);
}
});
it("path ends at exact destination", () => {
const pathFinder = createPathFinder();
const map = game.map();
const from = map.ref(5, 5);
const to = map.ref(10, 12);
const path = pathFinder.findPath(from, to);
expect(path).not.toBeNull();
expect(path![path!.length - 1]).toBe(to);
});
});
describe("path shapes", () => {
it("diagonal path has equal X and Y movement", () => {
const pathFinder = createPathFinder();
const map = game.map();
// Equal X and Y offset: (0,0) → (10,10)
const from = map.ref(0, 0);
const to = map.ref(10, 10);
const path = pathFinder.findPath(from, to);
expect(path).not.toBeNull();
let xMoves = 0;
let yMoves = 0;
for (let i = 1; i < path!.length; i++) {
const dx = map.x(path![i]) - map.x(path![i - 1]);
const dy = map.y(path![i]) - map.y(path![i - 1]);
if (dx !== 0) xMoves++;
if (dy !== 0) yMoves++;
}
expect(xMoves).toBe(10);
expect(yMoves).toBe(10);
});
it("horizontal path has only X movement", () => {
const pathFinder = createPathFinder();
const map = game.map();
// Pure horizontal: (0,5) → (15,5)
const from = map.ref(0, 5);
const to = map.ref(15, 5);
const path = pathFinder.findPath(from, to);
expect(path).not.toBeNull();
let xMoves = 0;
let yMoves = 0;
for (let i = 1; i < path!.length; i++) {
const dx = map.x(path![i]) - map.x(path![i - 1]);
const dy = map.y(path![i]) - map.y(path![i - 1]);
if (dx !== 0) xMoves++;
if (dy !== 0) yMoves++;
}
expect(xMoves).toBe(15);
expect(yMoves).toBe(0);
});
it("vertical path has only Y movement", () => {
const pathFinder = createPathFinder();
const map = game.map();
// Pure vertical: (5,0) → (5,15)
const from = map.ref(5, 0);
const to = map.ref(5, 15);
const path = pathFinder.findPath(from, to);
expect(path).not.toBeNull();
let xMoves = 0;
let yMoves = 0;
for (let i = 1; i < path!.length; i++) {
const dx = map.x(path![i]) - map.x(path![i - 1]);
const dy = map.y(path![i]) - map.y(path![i - 1]);
if (dx !== 0) xMoves++;
if (dy !== 0) yMoves++;
}
expect(xMoves).toBe(0);
expect(yMoves).toBe(15);
});
it("adjacent tiles produce minimal path", () => {
const pathFinder = createPathFinder();
const map = game.map();
const from = map.ref(5, 5);
const to = map.ref(6, 5);
const path = pathFinder.findPath(from, to);
expect(path).not.toBeNull();
expect(path!.length).toBe(2);
expect(path![0]).toBe(from);
expect(path![1]).toBe(to);
});
});
});
@@ -0,0 +1,36 @@
import { beforeAll, describe, expect, it } from "vitest";
import { Game } from "../../../src/core/game/Game";
import { TileRef } from "../../../src/core/game/GameMap";
import { PathFinding } from "../../../src/core/pathfinding/PathFinder";
import { SteppingPathFinder } from "../../../src/core/pathfinding/types";
import { setup } from "../../util/Setup";
describe("PathFinding.Rail", () => {
let game: Game;
let pathFinder: SteppingPathFinder<TileRef>;
beforeAll(async () => {
game = await setup("ocean_and_land");
pathFinder = PathFinding.Rail(game);
});
describe("findPath", () => {
it("finds path on land tiles", () => {
const map = game.map();
// Adjacent land tiles: (0,0) and (1,0)
const from = map.ref(0, 0);
const to = map.ref(1, 0);
expect(map.isLand(from)).toBe(true);
expect(map.isLand(to)).toBe(true);
const path = pathFinder.findPath(from, to);
expect(path).not.toBeNull();
expect(path!.length).toBe(2);
expect(path![0]).toBe(from);
expect(path![1]).toBe(to);
});
});
});
@@ -0,0 +1,277 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import { Game } from "../../../src/core/game/Game";
import { TileRef } from "../../../src/core/game/GameMap";
import { PathFinding } from "../../../src/core/pathfinding/PathFinder";
import {
PathStatus,
SteppingPathFinder,
} from "../../../src/core/pathfinding/types";
import { setup } from "../../util/Setup";
import { createGame, L, W } from "./_fixtures";
describe("PathFinding.Water", () => {
let game: Game;
let worldGame: Game;
function createPathFinder(g: Game = game): SteppingPathFinder<TileRef> {
return PathFinding.Water(g);
}
beforeAll(async () => {
game = await setup("ocean_and_land");
worldGame = await setup("world", { disableNavMesh: false });
});
describe("findPath", () => {
it("finds path between adjacent water tiles", () => {
const pathFinder = createPathFinder();
const map = game.map();
const from = map.ref(8, 0);
const to = map.ref(9, 0);
expect(map.isWater(from)).toBe(true);
expect(map.isWater(to)).toBe(true);
const path = pathFinder.findPath(from, to);
expect(path).not.toBeNull();
expect(path!.length).toBe(2);
expect(path![0]).toBe(from);
expect(path![1]).toBe(to);
});
it("returns null for land tiles", () => {
const pathFinder = createPathFinder();
const map = game.map();
const landTile = map.ref(0, 0);
const waterTile = map.ref(8, 0);
expect(map.isLand(landTile)).toBe(true);
expect(map.isShore(landTile)).toBe(false);
expect(map.isWater(waterTile)).toBe(true);
const path = pathFinder.findPath(landTile, waterTile);
expect(path).toBeNull();
});
it("returns single-tile path when from equals to", () => {
const pathFinder = createPathFinder();
const map = game.map();
const waterTile = map.ref(8, 0);
expect(map.isWater(waterTile)).toBe(true);
const path = pathFinder.findPath(waterTile, waterTile);
expect(path).not.toBeNull();
expect(path!.length).toBe(1);
expect(path![0]).toBe(waterTile);
});
it("supports multiple start tiles", () => {
const pathFinder = createPathFinder();
const map = game.map();
const dest = map.ref(8, 0);
const source1 = map.ref(9, 0);
const source2 = map.ref(8, 1);
expect(map.isWater(dest)).toBe(true);
expect(map.isWater(source1)).toBe(true);
expect(map.isWater(source2)).toBe(true);
const from = [source1, source2];
const path = pathFinder.findPath(from, dest);
expect(path).not.toBeNull();
expect(path!.length).toBe(2);
expect(from).toContain(path![0]);
expect(path![1]).toBe(dest);
});
});
describe("path validity", () => {
it("all consecutive tiles in path are connected", () => {
const pathFinder = createPathFinder();
const map = game.map();
// Distant water tiles: (8,0) → (15,4), distance = 11
const from = map.ref(8, 0);
const to = map.ref(15, 4);
expect(map.isWater(from)).toBe(true);
expect(map.isWater(to)).toBe(true);
expect(map.manhattanDist(from, to)).toBe(11);
const path = pathFinder.findPath(from, to);
expect(path).not.toBeNull();
for (let i = 1; i < path!.length; i++) {
const dist = map.manhattanDist(path![i - 1], path![i]);
expect(dist).toEqual(1);
}
});
});
describe("shore handling", () => {
it("path from shore to shore starts and ends on shore", () => {
const pathFinder = createPathFinder();
const map = game.map();
// Shore tiles at (7,0) and (7,6), distance = 6
// Both have water neighbors at (8,0) and (8,6)
const from = map.ref(7, 0);
const to = map.ref(7, 6);
expect(map.isShore(from)).toBe(true);
expect(map.isShore(to)).toBe(true);
expect(map.manhattanDist(from, to)).toBe(6);
const path = pathFinder.findPath(from, to);
expect(path).not.toBeNull();
expect(path![0]).toBe(from);
expect(path![path!.length - 1]).toBe(to);
});
});
describe("determinism", () => {
it("same inputs produce identical paths", () => {
const pathFinder1 = createPathFinder();
const pathFinder2 = createPathFinder();
const map = game.map();
// Distant water tiles: (8,0) → (15,4)
const from = map.ref(8, 0);
const to = map.ref(15, 4);
const path1 = pathFinder1.findPath(from, to);
const path2 = pathFinder2.findPath(from, to);
expect(path1).not.toBeNull();
expect(path2).not.toBeNull();
expect(path1).toEqual(path2);
});
});
describe("World map routes", () => {
it("Spain to France (Mediterranean)", () => {
const pathFinder = createPathFinder(worldGame);
const path = pathFinder.findPath(
worldGame.ref(926, 283),
worldGame.ref(950, 257),
);
expect(path).not.toBeNull();
});
it("Miami to Rio (Atlantic)", () => {
const pathFinder = createPathFinder(worldGame);
const path = pathFinder.findPath(
worldGame.ref(488, 355),
worldGame.ref(680, 658),
);
expect(path).not.toBeNull();
});
it("France to Poland (around Europe)", () => {
const pathFinder = createPathFinder(worldGame);
const path = pathFinder.findPath(
worldGame.ref(950, 257),
worldGame.ref(1033, 175),
);
expect(path).not.toBeNull();
});
it("Miami to Spain (transatlantic)", () => {
const pathFinder = createPathFinder(worldGame);
const path = pathFinder.findPath(
worldGame.ref(488, 355),
worldGame.ref(926, 283),
);
expect(path).not.toBeNull();
});
it("Rio to Poland (South Atlantic to Baltic)", () => {
const pathFinder = createPathFinder(worldGame);
const path = pathFinder.findPath(
worldGame.ref(680, 658),
worldGame.ref(1033, 175),
);
expect(path).not.toBeNull();
});
});
describe("Error handling", () => {
it("returns NOT_FOUND for null source", () => {
const pathFinder = createPathFinder();
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
const result = pathFinder.next(
null as unknown as TileRef,
game.ref(8, 0),
);
expect(result.status).toBe(PathStatus.NOT_FOUND);
consoleSpy.mockRestore();
});
it("returns NOT_FOUND for null destination", () => {
const pathFinder = createPathFinder();
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
const result = pathFinder.next(
game.ref(8, 0),
null as unknown as TileRef,
);
expect(result.status).toBe(PathStatus.NOT_FOUND);
consoleSpy.mockRestore();
});
});
describe("Known bugs", () => {
it("path can cross 1-tile land barrier", () => {
const syntheticGame = createGame({
width: 10,
height: 1,
grid: [W, L, L, W, L, W, W, L, L, W],
});
const pathFinder = createPathFinder(syntheticGame);
const path = pathFinder.findPath(
syntheticGame.ref(0, 0),
syntheticGame.ref(9, 0),
);
expect(path).not.toBeNull();
});
it("path can cross diagonal land barrier", () => {
const syntheticGame = createGame({
width: 2,
height: 2,
grid: [W, L, L, W],
});
const pathFinder = createPathFinder(syntheticGame);
const path = pathFinder.findPath(
syntheticGame.ref(0, 0),
syntheticGame.ref(1, 1),
);
expect(path).not.toBeNull();
});
});
});
+230
View File
@@ -0,0 +1,230 @@
import { describe, expect, it } from "vitest";
import { SpawnExecution } from "../../../src/core/execution/SpawnExecution";
import {
Game,
Player,
PlayerInfo,
PlayerType,
} from "../../../src/core/game/Game";
import { TileRef } from "../../../src/core/game/GameMap";
import { SpatialQuery } from "../../../src/core/pathfinding/spatial/SpatialQuery";
import { createGame, L, W } from "./_fixtures";
// Spawns player and **expands territory** via getSpawnTiles (euclidean dist 4)
// Ref: src/core/execution/Util.ts
function addPlayer(game: Game, tile: TileRef): Player {
const info = new PlayerInfo("test", PlayerType.Human, null, "test_id");
game.addPlayer(info);
game.addExecution(new SpawnExecution("game_id", info, tile));
while (game.inSpawnPhase()) game.executeNextTick();
return game.player(info.id);
}
describe("SpatialQuery", () => {
describe("closestShore", () => {
it("finds shore tile owned by player", () => {
// prettier-ignore
const game = createGame({
width: 5, height: 5, grid: [
W, W, W, W, W,
W, L, L, L, W,
W, L, L, L, W,
W, L, L, L, W,
W, W, W, W, W,
],
});
const spatial = new SpatialQuery(game);
const player = addPlayer(game, game.ref(2, 2));
// All land tiles owned by player because of spawn expansion
const result = spatial.closestShore(player, game.ref(2, 2));
expect(result).not.toBeNull();
expect(game.isShore(result!)).toBe(true);
expect(game.ownerID(result!)).toBe(player.smallID());
});
it("returns null when no shore within maxDist", () => {
// prettier-ignore
const game = createGame({
width: 7, height: 7, grid: [
W, W, W, W, W, W, W,
W, L, L, L, L, L, W,
W, L, L, L, L, L, W,
W, L, L, L, L, L, W,
W, L, L, L, L, L, W,
W, L, L, L, L, L, W,
W, W, W, W, W, W, W,
],
});
const spatial = new SpatialQuery(game);
const player = addPlayer(game, game.ref(3, 3));
// maxDist=1 from center (3,3) - shore is 2 tiles away
const result = spatial.closestShore(player, game.ref(3, 3), 1);
expect(result).toBeNull();
});
it("finds shore on player's island (two separate islands)", () => {
// prettier-ignore
const game = createGame({
width: 8, height: 4, grid: [
L, L, W, W, W, W, L, L,
L, L, W, W, W, W, L, L,
L, L, W, W, W, W, L, L,
L, L, W, W, W, W, L, L,
],
});
const spatial = new SpatialQuery(game);
const player = addPlayer(game, game.ref(0, 0));
const result = spatial.closestShore(player, game.ref(0, 2));
expect(result).not.toBeNull();
expect(game.isShore(result!)).toBe(true);
expect(game.ownerID(result!)).toBe(player.smallID());
expect(game.x(result!)).toBeLessThanOrEqual(2);
});
it("finds shore even if no land path exists (two separate islands)", () => {
// prettier-ignore
const game = createGame({
width: 8, height: 4, grid: [
L, L, W, W, W, W, L, L,
L, L, W, W, W, W, L, L,
L, L, W, W, W, W, L, L,
L, L, W, W, W, W, L, L,
],
});
const spatial = new SpatialQuery(game);
const player = addPlayer(game, game.ref(0, 0));
const result = spatial.closestShore(player, game.ref(7, 2));
expect(result).not.toBeNull();
expect(game.isShore(result!)).toBe(true);
expect(game.ownerID(result!)).toBe(player.smallID());
expect(game.x(result!)).toBeLessThanOrEqual(2);
});
it("finds shore for terra nullius when land is unclaimed", () => {
// prettier-ignore
const game = createGame({
width: 5, height: 5, grid: [
W, W, W, W, W,
W, L, L, L, W,
W, L, L, L, W,
W, L, L, L, W,
W, W, W, W, W,
],
});
const spatial = new SpatialQuery(game);
const terraNullius = game.terraNullius();
const result = spatial.closestShore(terraNullius, game.ref(2, 2));
expect(result).not.toBeNull();
expect(game.isShore(result!)).toBe(true);
});
});
describe("closestShoreByWater", () => {
it("returns null for terra nullius", () => {
// prettier-ignore
const game = createGame({
width: 5, height: 5, grid: [
W, W, W, W, W,
W, L, L, L, W,
W, L, L, L, W,
W, L, L, L, W,
W, W, W, W, W,
],
});
const spatial = new SpatialQuery(game);
const terraNullius = game.terraNullius();
const result = spatial.closestShoreByWater(terraNullius, game.ref(0, 0));
expect(result).toBeNull();
});
it("returns null when target is on land", () => {
// prettier-ignore
const game = createGame({
width: 5, height: 5, grid: [
W, W, W, W, W,
W, L, L, L, W,
W, L, L, L, W,
W, L, L, L, W,
W, W, W, W, W,
],
});
const spatial = new SpatialQuery(game);
const player = addPlayer(game, game.ref(2, 2));
const result = spatial.closestShoreByWater(player, game.ref(2, 2));
expect(result).toBeNull();
});
it("returns null when target is in disconnected water body", () => {
// prettier-ignore
const game = createGame({
width: 14, height: 6, grid: [
W, W, L, L, L, L, L, L, L, L, L, L, W, W,
W, W, L, L, L, L, L, L, L, L, L, L, W, W,
W, W, L, L, L, L, L, L, L, L, L, L, W, W,
W, W, L, L, L, L, L, L, L, L, L, L, W, W,
W, W, L, L, L, L, L, L, L, L, L, L, W, W,
W, W, L, L, L, L, L, L, L, L, L, L, W, W,
],
});
const spatial = new SpatialQuery(game);
const player = addPlayer(game, game.ref(3, 2));
const result = spatial.closestShoreByWater(player, game.ref(13, 2));
expect(result).toBeNull();
});
it("finds shore via long water path around island", () => {
// prettier-ignore
const game = createGame({
width: 18, height: 14, grid: [
W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W,
W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W,
W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W,
W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W,
W, W, W, W, L, L, L, L, L, L, L, L, L, L, W, W, W, W,
W, W, W, W, L, L, L, L, L, L, L, L, L, L, W, W, W, W,
W, W, W, W, L, L, L, L, L, L, L, L, L, L, W, W, W, W,
W, W, W, W, L, L, L, L, L, L, L, L, L, L, W, W, W, W,
W, W, W, W, L, L, L, L, L, L, L, L, L, L, W, W, W, W,
W, W, W, W, L, L, L, L, L, L, L, L, L, L, W, W, W, W,
W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W,
W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W,
W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W,
W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, W, L,
],
});
const spatial = new SpatialQuery(game);
const player = addPlayer(game, game.ref(4, 4));
const target = game.ref(17, 13);
const result = spatial.closestShoreByWater(player, target);
expect(result).not.toBeNull();
expect(game.isShore(result!)).toBe(true);
expect(game.ownerID(result!)).toBe(player.smallID());
});
});
});
@@ -0,0 +1,320 @@
import { describe, expect, it } from "vitest";
import { GameMapImpl } from "../../../src/core/game/GameMap";
import { UniversalPathFinding } from "../../../src/core/pathfinding/PathFinder";
import { PathStatus } from "../../../src/core/pathfinding/types";
describe("UniversalPathFinding.Parabola", () => {
function createLargeMap() {
// Create a larger map for parabola tests (need space for arcs)
const W = 0x20;
const terrain = new Uint8Array(10000).fill(W);
return new GameMapImpl(100, 100, terrain, 0);
}
describe("findPath", () => {
it("returns parabolic arc between two points", () => {
const map = createLargeMap();
const finder = UniversalPathFinding.Parabola(map);
const from = map.ref(10, 50);
const to = map.ref(90, 50);
const path = finder.findPath(from, to);
expect(path).not.toBeNull();
expect(path!.length).toBe(39);
expect(path![0]).toBe(from);
expect(path![path!.length - 1]).toBe(to);
});
it("throws error for multiple start points", () => {
const map = createLargeMap();
const finder = UniversalPathFinding.Parabola(map);
const from = [map.ref(10, 50), map.ref(20, 50)];
const to = map.ref(90, 50);
expect(() => finder.findPath(from, to)).toThrow(
"does not support multiple start points",
);
});
it("handles same start and end point", () => {
const map = createLargeMap();
const finder = UniversalPathFinding.Parabola(map);
const tile = map.ref(50, 50);
const path = finder.findPath(tile, tile);
expect(path).not.toBeNull();
expect(path!.length).toBe(26);
});
it("creates arc across map", () => {
const map = createLargeMap();
const finder = UniversalPathFinding.Parabola(map);
const from = map.ref(0, 50);
const to = map.ref(99, 50);
const path = finder.findPath(from, to);
expect(path).not.toBeNull();
expect(path!.length).toBe(43);
expect(path![0]).toBe(from);
expect(path![path!.length - 1]).toBe(to);
});
});
describe("next (stepping)", () => {
it("returns NEXT with node when not at destination", () => {
const map = createLargeMap();
const finder = UniversalPathFinding.Parabola(map);
const from = map.ref(10, 50);
const to = map.ref(90, 50);
const result = finder.next(from, to);
expect(result.status).toBe(PathStatus.NEXT);
expect("node" in result).toBe(true);
});
it("respects speed parameter (higher speed = further movement)", () => {
const map = createLargeMap();
const finder1 = UniversalPathFinding.Parabola(map);
const finder2 = UniversalPathFinding.Parabola(map);
const from = map.ref(10, 50);
const to = map.ref(90, 50);
// Step with speed 1
const result1 = finder1.next(from, to, 1);
// Step with speed 5
const result2 = finder2.next(from, to, 5);
// Both should be NEXT (not at destination yet)
expect(result1.status).toBe(PathStatus.NEXT);
expect(result2.status).toBe(PathStatus.NEXT);
const node1 = (
result1 as { status: typeof PathStatus.NEXT; node: number }
).node;
const node2 = (
result2 as { status: typeof PathStatus.NEXT; node: number }
).node;
// Speed 5 should move strictly further than speed 1
const dist1 = map.manhattanDist(from, node1);
const dist2 = map.manhattanDist(from, node2);
expect(dist2).toBeGreaterThan(dist1);
expect(finder2.currentIndex()).toBeGreaterThan(finder1.currentIndex());
});
});
describe("options", () => {
it("increment option affects path density", () => {
const map = createLargeMap();
const finder1 = UniversalPathFinding.Parabola(map, { increment: 1 });
const finder2 = UniversalPathFinding.Parabola(map, { increment: 10 });
const from = map.ref(10, 50);
const to = map.ref(90, 50);
const path1 = finder1.findPath(from, to);
const path2 = finder2.findPath(from, to);
expect(path1).not.toBeNull();
expect(path2).not.toBeNull();
expect(path1!.length).toBeGreaterThan(path2!.length);
});
it("distanceBasedHeight option affects arc height", () => {
const map = createLargeMap();
const finder1 = UniversalPathFinding.Parabola(map, {
distanceBasedHeight: true,
});
const finder2 = UniversalPathFinding.Parabola(map, {
distanceBasedHeight: false,
});
const from = map.ref(10, 50);
const to = map.ref(90, 50);
const path1 = finder1.findPath(from, to);
const path2 = finder2.findPath(from, to);
expect(path1).not.toBeNull();
expect(path2).not.toBeNull();
// With distanceBasedHeight=true, path should have Y deviation
// With distanceBasedHeight=false, path should be more direct
const getMaxYDeviation = (path: number[]) => {
const midY = map.y(from);
return Math.max(...path.map((t) => Math.abs(map.y(t) - midY)));
};
const dev1 = getMaxYDeviation(path1!);
const dev2 = getMaxYDeviation(path2!);
expect(dev1).toBeGreaterThan(dev2);
});
it("directionUp option affects arc direction", () => {
const map = createLargeMap();
const finderUp = UniversalPathFinding.Parabola(map, {
directionUp: true,
});
const finderDown = UniversalPathFinding.Parabola(map, {
directionUp: false,
});
const from = map.ref(10, 50);
const to = map.ref(90, 50);
const pathUp = finderUp.findPath(from, to);
const pathDown = finderDown.findPath(from, to);
expect(pathUp).not.toBeNull();
expect(pathDown).not.toBeNull();
// Get midpoint Y values
const midIdx = Math.floor(pathUp!.length / 2);
const midY_Up = map.y(pathUp![midIdx]);
const midY_Down = map.y(pathDown![midIdx]);
const startY = map.y(from);
// directionUp=true means Y decreases (goes "up" on screen)
// directionUp=false means Y increases (goes "down" on screen)
expect(midY_Up).toBeLessThan(startY);
expect(midY_Down).toBeGreaterThan(startY);
});
});
describe("currentIndex", () => {
it("returns 0 when no curve", () => {
const map = createLargeMap();
const finder = UniversalPathFinding.Parabola(map);
expect(finder.currentIndex()).toBe(0);
});
it("increments as path is stepped", () => {
const map = createLargeMap();
const finder = UniversalPathFinding.Parabola(map);
const from = map.ref(10, 50);
const to = map.ref(90, 50);
let current = from;
let previousIndex = 0;
for (let i = 0; i < 50; i++) {
const result = finder.next(current, to);
expect(result.status).toBe(PathStatus.NEXT);
const index = finder.currentIndex();
expect(index).toBeGreaterThanOrEqual(previousIndex);
previousIndex = index;
current = (result as { status: typeof PathStatus.NEXT; node: number })
.node;
}
});
});
describe("short distances", () => {
it("creates valid arc for distance < 50 (PARABOLA_MIN_HEIGHT)", () => {
const map = createLargeMap();
const finder = UniversalPathFinding.Parabola(map, {
distanceBasedHeight: true,
});
// Distance of 30 is less than PARABOLA_MIN_HEIGHT (50)
const from = map.ref(50, 50);
const to = map.ref(80, 50);
const path = finder.findPath(from, to);
expect(path).not.toBeNull();
expect(path!.length).toBe(28);
expect(path![0]).toBe(from);
expect(path![path!.length - 1]).toBe(to);
});
it("creates valid path for adjacent tiles (distance=1)", () => {
const map = createLargeMap();
const finder = UniversalPathFinding.Parabola(map);
const from = map.ref(50, 50);
const to = map.ref(51, 50);
const path = finder.findPath(from, to);
expect(path).not.toBeNull();
expect(path!.length).toBe(26);
expect(path![0]).toBe(from);
expect(path![path!.length - 1]).toBe(to);
});
it("creates valid path for very short distance (distance=5)", () => {
const map = createLargeMap();
const finder = UniversalPathFinding.Parabola(map, {
distanceBasedHeight: true,
});
const from = map.ref(50, 50);
const to = map.ref(55, 50);
const path = finder.findPath(from, to);
expect(path).not.toBeNull();
expect(path![0]).toBe(from);
expect(path![path!.length - 1]).toBe(to);
});
});
describe("map boundary clipping", () => {
it("arc clipped at map top boundary (directionUp near y=0)", () => {
const map = createLargeMap();
const finder = UniversalPathFinding.Parabola(map, {
directionUp: true,
distanceBasedHeight: true,
});
// Start near top of map
const from = map.ref(10, 5);
const to = map.ref(90, 5);
const path = finder.findPath(from, to);
expect(path).not.toBeNull();
for (const t of path!) {
expect(map.y(t)).toBeGreaterThanOrEqual(0);
}
});
it("arc clipped at map bottom boundary (directionDown near y=max)", () => {
const map = createLargeMap();
const finder = UniversalPathFinding.Parabola(map, {
directionUp: false,
distanceBasedHeight: true,
});
const from = map.ref(10, 95);
const to = map.ref(90, 95);
const path = finder.findPath(from, to);
expect(path).not.toBeNull();
for (const t of path!) {
expect(map.y(t)).toBeLessThan(100);
}
});
});
});
@@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest";
import {
ConnectedComponents,
LAND_MARKER,
} from "../../../src/core/pathfinding/algorithms/ConnectedComponents";
import { createGameMap, createIslandMap, L, W } from "./_fixtures";
// prettier-ignore
const twoComponentsMapData = {
width: 7, height: 5, grid: [
W, W, L, L, L, W, W,
W, W, L, L, L, W, W,
W, W, L, L, L, W, W,
W, W, L, L, L, W, W,
W, W, L, L, L, W, W,
],
};
describe("ConnectedComponents", () => {
describe("getComponentId", () => {
it("returns 0 before initialization", () => {
const map = createGameMap(createIslandMap());
const wc = new ConnectedComponents(map);
// Water tile at (0,0) - should return 0 (not initialized)
const waterTile = map.ref(0, 0);
expect(wc.getComponentId(waterTile)).toBe(0);
});
it("returns same component ID for all water tiles in single connected area", () => {
const map = createGameMap(createIslandMap());
const wc = new ConnectedComponents(map);
wc.initialize();
const water1 = map.ref(0, 0);
const water2 = map.ref(4, 0);
const water3 = map.ref(0, 4);
const water4 = map.ref(4, 4);
expect(map.isWater(water1)).toBe(true);
expect(map.isWater(water2)).toBe(true);
expect(map.isWater(water3)).toBe(true);
expect(map.isWater(water4)).toBe(true);
const id1 = wc.getComponentId(water1);
const id2 = wc.getComponentId(water2);
const id3 = wc.getComponentId(water3);
const id4 = wc.getComponentId(water4);
expect(id1).toBe(1);
expect(id2).toBe(id1);
expect(id3).toBe(id1);
expect(id4).toBe(id1);
});
it("returns different component IDs for disconnected water areas", () => {
const map = createGameMap(twoComponentsMapData);
const wc = new ConnectedComponents(map);
wc.initialize();
const leftWater1 = map.ref(0, 0);
const leftWater2 = map.ref(1, 2);
const rightWater1 = map.ref(5, 0);
const rightWater2 = map.ref(6, 4);
expect(map.isWater(leftWater1)).toBe(true);
expect(map.isWater(leftWater2)).toBe(true);
expect(map.isWater(rightWater1)).toBe(true);
expect(map.isWater(rightWater2)).toBe(true);
const leftId1 = wc.getComponentId(leftWater1);
const leftId2 = wc.getComponentId(leftWater2);
const rightId1 = wc.getComponentId(rightWater1);
const rightId2 = wc.getComponentId(rightWater2);
expect(leftId1).not.toBe(rightId1);
expect(leftId1).toBe(leftId2);
expect(leftId1).toBeGreaterThan(0);
expect(leftId1).not.toBe(LAND_MARKER);
expect(rightId1).toBe(rightId2);
expect(rightId1).toBeGreaterThan(0);
expect(rightId1).not.toBe(LAND_MARKER);
});
it("returns LAND_MARKER for land tiles", () => {
const map = createGameMap(twoComponentsMapData);
const wc = new ConnectedComponents(map);
wc.initialize();
const landTile1 = map.ref(2, 0);
const landTile2 = map.ref(3, 2);
const landTile3 = map.ref(4, 4);
expect(map.isLand(landTile1)).toBe(true);
expect(map.isLand(landTile2)).toBe(true);
expect(map.isLand(landTile3)).toBe(true);
expect(wc.getComponentId(landTile1)).toBe(LAND_MARKER);
expect(wc.getComponentId(landTile2)).toBe(LAND_MARKER);
expect(wc.getComponentId(landTile3)).toBe(LAND_MARKER);
});
});
describe("determinism", () => {
it("produces same component IDs on repeated initialization", () => {
const map = createGameMap(twoComponentsMapData);
const wc1 = new ConnectedComponents(map);
const wc2 = new ConnectedComponents(map);
wc1.initialize();
wc2.initialize();
// Check all tiles have same component ID
for (let y = 0; y < 5; y++) {
for (let x = 0; x < 7; x++) {
const tile = map.ref(x, y);
expect(wc1.getComponentId(tile)).toBe(wc2.getComponentId(tile));
}
}
});
});
describe("direct terrain access optimization", () => {
it("produces same results with accessTerrainDirectly=false", () => {
const map = createGameMap(twoComponentsMapData);
const wcDirect = new ConnectedComponents(map, true);
const wcIndirect = new ConnectedComponents(map, false);
wcDirect.initialize();
wcIndirect.initialize();
// Check all tiles have same component ID
for (let y = 0; y < 5; y++) {
for (let x = 0; x < 7; x++) {
const tile = map.ref(x, y);
expect(wcDirect.getComponentId(tile)).toBe(
wcIndirect.getComponentId(tile),
);
}
}
});
});
});
+179
View File
@@ -0,0 +1,179 @@
// Minimal test maps for pathfinding unit tests
import {
Difficulty,
Game,
GameMapSize,
GameMapType,
GameMode,
GameType,
} from "../../../src/core/game/Game";
import { createGame as createGameImpl } from "../../../src/core/game/GameImpl";
import { GameMapImpl } from "../../../src/core/game/GameMap";
import { UserSettings } from "../../../src/core/game/UserSettings";
import { TestConfig } from "../../util/TestConfig";
import { TestServerConfig } from "../../util/TestServerConfig";
export const W = "W"; // Water
export const L = "L"; // Land
// Terrain encoding
const WATER_BIT = 0x20;
const LAND_BIT = 0x80;
const SHORELINE_BIT = 6;
export type TestMapData = {
width: number;
height: number;
grid: string[];
};
// Compute shoreline bit for tiles adjacent to opposite terrain
function computeShoreline(
terrain: Uint8Array,
width: number,
height: number,
): void {
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
const isLand = (terrain[idx] & LAND_BIT) !== 0;
const neighbors = [
[x - 1, y],
[x + 1, y],
[x, y - 1],
[x, y + 1],
];
for (const [nx, ny] of neighbors) {
if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;
const neighborIsLand = (terrain[ny * width + nx] & LAND_BIT) !== 0;
if (isLand !== neighborIsLand) {
terrain[idx] |= 1 << SHORELINE_BIT;
break;
}
}
}
}
}
// 5x5 simple island
export function createIslandMap(): TestMapData {
// prettier-ignore
const grid = [
W, W, W, W, W,
W, L, L, L, W,
W, L, L, L, W,
W, L, L, L, W,
W, W, W, W, W,
];
return { width: 5, height: 5, grid };
}
// Create Game from test map data (computes shoreline bits)
export function createGame(data: TestMapData): Game {
const { width, height, grid } = data;
// Convert string grid to terrain bytes
const terrain = new Uint8Array(width * height);
let numLand = 0;
for (let i = 0; i < grid.length; i++) {
if (grid[i] === L) {
terrain[i] = LAND_BIT;
numLand++;
} else {
terrain[i] = WATER_BIT;
}
}
computeShoreline(terrain, width, height);
const gameMap = new GameMapImpl(width, height, terrain, numLand);
// Create miniMap (2x2→1, water if ANY water)
const miniWidth = Math.ceil(width / 2);
const miniHeight = Math.ceil(height / 2);
const miniTerrain = new Uint8Array(miniWidth * miniHeight);
let miniNumLand = 0;
for (let my = 0; my < miniHeight; my++) {
for (let mx = 0; mx < miniWidth; mx++) {
const mIdx = my * miniWidth + mx;
let hasWater = false;
for (let dy = 0; dy < 2; dy++) {
for (let dx = 0; dx < 2; dx++) {
const x = mx * 2 + dx;
const y = my * 2 + dy;
if (x < width && y < height && !(terrain[y * width + x] & LAND_BIT)) {
hasWater = true;
}
}
}
if (hasWater) {
miniTerrain[mIdx] = WATER_BIT;
} else {
miniTerrain[mIdx] = LAND_BIT;
miniNumLand++;
}
}
}
computeShoreline(miniTerrain, miniWidth, miniHeight);
const miniGameMap = new GameMapImpl(
miniWidth,
miniHeight,
miniTerrain,
miniNumLand,
);
const serverConfig = new TestServerConfig();
const gameConfig = {
gameMap: GameMapType.Asia,
gameMapSize: GameMapSize.Normal,
gameMode: GameMode.FFA,
gameType: GameType.Singleplayer,
difficulty: Difficulty.Medium,
disableNations: false,
donateGold: false,
donateTroops: false,
bots: 0,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
disableNavMesh: false,
randomSpawn: false,
};
const config = new TestConfig(
serverConfig,
gameConfig,
new UserSettings(),
false,
);
return createGameImpl([], [], gameMap, miniGameMap, config);
}
// Create GameMapImpl from test map data (for map-only tests)
export function createGameMap(data: TestMapData): GameMapImpl {
const { width, height, grid } = data;
const terrain = new Uint8Array(width * height);
let numLand = 0;
for (let i = 0; i < grid.length; i++) {
if (grid[i] === L) {
terrain[i] = LAND_BIT;
numLand++;
} else {
terrain[i] = WATER_BIT;
}
}
computeShoreline(terrain, width, height);
return new GameMapImpl(width, height, terrain, numLand);
}
@@ -0,0 +1,169 @@
import { describe, expect, it } from "vitest";
import { ComponentCheckTransformer } from "../../../../src/core/pathfinding/transformers/ComponentCheckTransformer";
import { PathFinder } from "../../../../src/core/pathfinding/types";
describe("ComponentCheckTransformer", () => {
// Mock PathFinder that records calls and returns a simple path
function createMockPathFinder(): PathFinder<number> & {
calls: Array<{ from: number | number[]; to: number }>;
} {
const calls: Array<{ from: number | number[]; to: number }> = [];
return {
calls,
findPath(from: number | number[], to: number): number[] | null {
calls.push({ from, to });
const start = Array.isArray(from) ? from[0] : from;
return [start, to];
},
};
}
// Component function: even numbers → component 0, odd → component 1
const evenOddComponent = (t: number) => t % 2;
describe("findPath", () => {
it("delegates when source and destination in same component", () => {
const inner = createMockPathFinder();
const transformer = new ComponentCheckTransformer(
inner,
evenOddComponent,
);
const result = transformer.findPath(2, 4); // both even → component 0
expect(result).toEqual([2, 4]);
expect(inner.calls).toHaveLength(1);
expect(inner.calls[0]).toEqual({ from: 2, to: 4 });
});
it("returns null when source and destination in different components", () => {
const inner = createMockPathFinder();
const transformer = new ComponentCheckTransformer(
inner,
evenOddComponent,
);
const result = transformer.findPath(2, 3); // even → odd
expect(result).toBeNull();
expect(inner.calls).toHaveLength(0); // inner not called
});
it("filters multiple sources to only valid ones", () => {
const inner = createMockPathFinder();
const transformer = new ComponentCheckTransformer(
inner,
evenOddComponent,
);
// Sources: 1, 2, 3, 4 → odd, even, odd, even
// Destination: 4 → even
// Valid sources: 2, 4
const result = transformer.findPath([1, 2, 3, 4], 4);
expect(result).not.toBeNull();
expect(inner.calls).toHaveLength(1);
expect(inner.calls[0].from).toEqual([2, 4]); // filtered to valid
expect(inner.calls[0].to).toBe(4);
});
it("returns null when no source in same component", () => {
const inner = createMockPathFinder();
const transformer = new ComponentCheckTransformer(
inner,
evenOddComponent,
);
// All sources odd, destination even
const result = transformer.findPath([1, 3, 5], 4);
expect(result).toBeNull();
expect(inner.calls).toHaveLength(0);
});
it("unwraps single valid source from array", () => {
const inner = createMockPathFinder();
const transformer = new ComponentCheckTransformer(
inner,
evenOddComponent,
);
// Only one source matches
const result = transformer.findPath([1, 2, 3], 4);
expect(result).not.toBeNull();
expect(inner.calls).toHaveLength(1);
expect(inner.calls[0].from).toBe(2);
});
it("handles single source (not array)", () => {
const inner = createMockPathFinder();
const transformer = new ComponentCheckTransformer(
inner,
evenOddComponent,
);
const result = transformer.findPath(4, 6);
expect(result).toEqual([4, 6]);
expect(inner.calls[0].from).toBe(4);
});
it("propagates null from inner pathfinder", () => {
const inner: PathFinder<number> = {
findPath: () => null,
};
const transformer = new ComponentCheckTransformer(
inner,
evenOddComponent,
);
const result = transformer.findPath(2, 4);
expect(result).toBeNull();
});
it("propagates path from inner pathfinder", () => {
const inner: PathFinder<number> = {
findPath: () => [10, 20, 30, 40],
};
const transformer = new ComponentCheckTransformer(
inner,
evenOddComponent,
);
const result = transformer.findPath(2, 4);
expect(result).toEqual([10, 20, 30, 40]);
});
});
describe("edge cases", () => {
it("handles empty source array", () => {
const inner = createMockPathFinder();
const transformer = new ComponentCheckTransformer(
inner,
evenOddComponent,
);
const result = transformer.findPath([], 4);
expect(result).toBeNull();
expect(inner.calls).toHaveLength(0);
});
it("works with custom component function", () => {
const inner = createMockPathFinder();
// Component by tens digit: 10-19 → 1, 20-29 → 2, etc.
const tensComponent = (t: number) => Math.floor(t / 10);
const transformer = new ComponentCheckTransformer(inner, tensComponent);
// Same component
expect(transformer.findPath(15, 18)).not.toBeNull();
// Different component
expect(transformer.findPath(15, 25)).toBeNull();
});
});
});
@@ -0,0 +1,179 @@
import { describe, expect, it } from "vitest";
import { GameMapImpl } from "../../../../src/core/game/GameMap";
import { MiniMapTransformer } from "../../../../src/core/pathfinding/transformers/MiniMapTransformer";
import { PathFinder } from "../../../../src/core/pathfinding/types";
describe("MiniMapTransformer", () => {
// Create test maps: main map is 10x10, minimap is 5x5 (2x downscale)
function createTestMaps() {
const W = 0x20; // Water
const mainTerrain = new Uint8Array(100).fill(W); // 10x10 all water
const miniTerrain = new Uint8Array(25).fill(W); // 5x5 all water
const map = new GameMapImpl(10, 10, mainTerrain, 0);
const miniMap = new GameMapImpl(5, 5, miniTerrain, 0);
return { map, miniMap };
}
function createMockPathFinder(): PathFinder<number> & {
calls: Array<{ from: number | number[]; to: number }>;
returnPath: number[] | null | undefined;
} {
const mock = {
calls: [] as Array<{ from: number | number[]; to: number }>,
returnPath: undefined as number[] | null | undefined,
findPath(from: number | number[], to: number): number[] | null {
mock.calls.push({ from, to });
if (mock.returnPath !== undefined) return mock.returnPath;
const start = Array.isArray(from) ? from[0] : from;
return [start, to];
},
};
return mock;
}
describe("findPath", () => {
it("converts coordinates to minimap scale", () => {
const { map, miniMap } = createTestMaps();
const inner = createMockPathFinder();
const transformer = new MiniMapTransformer(inner, map, miniMap);
const from = map.ref(4, 6);
const to = map.ref(8, 2);
const miniFrom = miniMap.ref(2, 3);
const miniTo = miniMap.ref(4, 1);
inner.returnPath = [miniFrom, miniTo];
transformer.findPath(from, to);
expect(inner.calls).toHaveLength(1);
expect(inner.calls[0].from).toBe(miniFrom);
expect(inner.calls[0].to).toBe(miniTo);
});
it("upscales minimap path back to full resolution", () => {
const { map, miniMap } = createTestMaps();
const inner = createMockPathFinder();
const transformer = new MiniMapTransformer(inner, map, miniMap);
const from = map.ref(0, 0);
const to = map.ref(8, 0);
// Minimap path: (0,0) → (4,0) - straight horizontal
inner.returnPath = [
miniMap.ref(0, 0),
miniMap.ref(1, 0),
miniMap.ref(2, 0),
miniMap.ref(3, 0),
miniMap.ref(4, 0),
];
const result = transformer.findPath(from, to);
expect(result).not.toBeNull();
expect(result![0]).toBe(from);
expect(result![result!.length - 1]).toBe(to);
});
it("returns null when inner returns null", () => {
const { map, miniMap } = createTestMaps();
const inner = createMockPathFinder();
inner.returnPath = null;
const transformer = new MiniMapTransformer(inner, map, miniMap);
const result = transformer.findPath(map.ref(0, 0), map.ref(8, 8));
expect(result).toBeNull();
});
it("returns null when inner returns empty path", () => {
const { map, miniMap } = createTestMaps();
const inner = createMockPathFinder();
inner.returnPath = [];
const transformer = new MiniMapTransformer(inner, map, miniMap);
const result = transformer.findPath(map.ref(0, 0), map.ref(8, 8));
expect(result).toBeNull();
});
it("handles multiple sources", () => {
const { map, miniMap } = createTestMaps();
const inner = createMockPathFinder();
const transformer = new MiniMapTransformer(inner, map, miniMap);
const from1 = map.ref(0, 0);
const from2 = map.ref(2, 0);
const to = map.ref(8, 0);
inner.returnPath = [miniMap.ref(0, 0), miniMap.ref(4, 0)];
const result = transformer.findPath([from1, from2], to);
expect(inner.calls).toHaveLength(1);
expect(Array.isArray(inner.calls[0].from)).toBe(true);
expect(result).not.toBeNull();
});
it("fixes path extremes to match original from/to", () => {
const { map, miniMap } = createTestMaps();
const inner = createMockPathFinder();
const transformer = new MiniMapTransformer(inner, map, miniMap);
// From odd coords - won't exactly map to minimap
const from = map.ref(1, 1);
const to = map.ref(9, 9);
inner.returnPath = [miniMap.ref(0, 0), miniMap.ref(4, 4)];
const result = transformer.findPath(from, to);
expect(result).not.toBeNull();
expect(result![0]).toBe(from);
expect(result![result!.length - 1]).toBe(to);
});
});
describe("coordinate mapping", () => {
it("maps main coords (0,0) to mini coords (0,0)", () => {
const { map, miniMap } = createTestMaps();
const inner = createMockPathFinder();
const transformer = new MiniMapTransformer(inner, map, miniMap);
inner.returnPath = [miniMap.ref(0, 0)];
transformer.findPath(map.ref(0, 0), map.ref(0, 0));
expect(inner.calls[0].from).toBe(miniMap.ref(0, 0));
expect(inner.calls[0].to).toBe(miniMap.ref(0, 0));
});
it("maps main coords (1,1) to mini coords (0,0) (floor division)", () => {
const { map, miniMap } = createTestMaps();
const inner = createMockPathFinder();
const transformer = new MiniMapTransformer(inner, map, miniMap);
inner.returnPath = [miniMap.ref(0, 0)];
transformer.findPath(map.ref(1, 1), map.ref(1, 1));
expect(inner.calls[0].from).toBe(miniMap.ref(0, 0));
expect(inner.calls[0].to).toBe(miniMap.ref(0, 0));
});
it("maps main coords (2,2) to mini coords (1,1)", () => {
const { map, miniMap } = createTestMaps();
const inner = createMockPathFinder();
const transformer = new MiniMapTransformer(inner, map, miniMap);
inner.returnPath = [miniMap.ref(1, 1)];
transformer.findPath(map.ref(2, 2), map.ref(2, 2));
expect(inner.calls[0].from).toBe(miniMap.ref(1, 1));
expect(inner.calls[0].to).toBe(miniMap.ref(1, 1));
});
});
});
@@ -0,0 +1,245 @@
import { describe, expect, it } from "vitest";
import { ShoreCoercingTransformer } from "../../../../src/core/pathfinding/transformers/ShoreCoercingTransformer";
import { PathFinder } from "../../../../src/core/pathfinding/types";
import { createGameMap, createIslandMap, L, W } from "../_fixtures";
describe("ShoreCoercingTransformer", () => {
// Mock PathFinder that records calls and returns configurable path
function createMockPathFinder(): PathFinder<number> & {
calls: Array<{ from: number | number[]; to: number }>;
returnPath: number[] | null | undefined;
} {
const mock = {
calls: [] as Array<{ from: number | number[]; to: number }>,
returnPath: undefined as number[] | null | undefined,
findPath(from: number | number[], to: number): number[] | null {
mock.calls.push({ from, to });
if (mock.returnPath !== undefined) return mock.returnPath;
const start = Array.isArray(from) ? from[0] : from;
return [start, to];
},
};
return mock;
}
describe("findPath", () => {
it("passes water tiles unchanged", () => {
const mapData = createIslandMap();
const map = createGameMap(mapData);
const inner = createMockPathFinder();
const transformer = new ShoreCoercingTransformer(inner, map);
const water1 = map.ref(0, 0);
const water2 = map.ref(4, 0);
inner.returnPath = [water1, water2];
const result = transformer.findPath(water1, water2);
expect(result).toEqual([water1, water2]);
expect(inner.calls).toHaveLength(1);
expect(inner.calls[0].from).toBe(water1);
expect(inner.calls[0].to).toBe(water2);
});
it("coerces shore start to water and prepends original", () => {
const mapData = createIslandMap();
const map = createGameMap(mapData);
const inner = createMockPathFinder();
const transformer = new ShoreCoercingTransformer(inner, map);
const shore = map.ref(1, 1);
const water = map.ref(4, 4);
const shoreWaterNeighbor = map.ref(1, 0);
const result = transformer.findPath(shore, water);
expect(result).not.toBeNull();
expect(result![0]).toBe(shore);
expect(result![1]).toBe(shoreWaterNeighbor);
});
it("coerces shore destination to water and appends original", () => {
const mapData = createIslandMap();
const map = createGameMap(mapData);
const inner = createMockPathFinder();
const transformer = new ShoreCoercingTransformer(inner, map);
const water = map.ref(0, 0);
const shore = map.ref(1, 1);
const shoreWaterNeighbor = map.ref(1, 0);
const result = transformer.findPath(water, shore);
expect(result).not.toBeNull();
expect(result![0]).toBe(water);
expect(result![result!.length - 2]).toBe(shoreWaterNeighbor);
expect(result![result!.length - 1]).toBe(shore);
});
it("coerces both shore start and destination", () => {
const mapData = createIslandMap();
const map = createGameMap(mapData);
const inner = createMockPathFinder();
const transformer = new ShoreCoercingTransformer(inner, map);
const shore1 = map.ref(1, 1);
const shore1WaterNeighbor = map.ref(1, 0);
const shore2 = map.ref(3, 3);
const shore2WaterNeighbor = map.ref(3, 4);
const result = transformer.findPath(shore1, shore2);
expect(result).not.toBeNull();
expect(result![0]).toBe(shore1);
expect(result![1]).toBe(shore1WaterNeighbor);
expect(result![result!.length - 2]).toBe(shore2WaterNeighbor);
expect(result![result!.length - 1]).toBe(shore2);
});
it("returns null when source has no water neighbor", () => {
const mapData = createIslandMap();
const map = createGameMap(mapData);
const inner = createMockPathFinder();
const transformer = new ShoreCoercingTransformer(inner, map);
// Center land tile (2,2) has no water neighbors
const land = map.ref(2, 2);
const water = map.ref(0, 0);
const result = transformer.findPath(land, water);
expect(result).toBeNull();
expect(inner.calls).toHaveLength(0);
});
it("returns null when destination has no water neighbor", () => {
const mapData = createIslandMap();
const map = createGameMap(mapData);
const inner = createMockPathFinder();
const transformer = new ShoreCoercingTransformer(inner, map);
// Center land tile (2,2) has no water neighbors
const land = map.ref(2, 2);
const water = map.ref(0, 0);
const result = transformer.findPath(water, land);
expect(result).toBeNull();
expect(inner.calls).toHaveLength(0);
});
it("returns null when inner pathfinder returns null", () => {
const mapData = createIslandMap();
const map = createGameMap(mapData);
const inner = createMockPathFinder();
const transformer = new ShoreCoercingTransformer(inner, map);
inner.returnPath = null;
const result = transformer.findPath(map.ref(0, 0), map.ref(4, 4));
expect(result).toBeNull();
});
it("returns null when inner pathfinder returns empty path", () => {
const mapData = createIslandMap();
const map = createGameMap(mapData);
const inner = createMockPathFinder();
const transformer = new ShoreCoercingTransformer(inner, map);
inner.returnPath = [];
const result = transformer.findPath(map.ref(0, 0), map.ref(4, 4));
expect(result).toBeNull();
});
it("handles multiple sources, filters invalid ones", () => {
const mapData = createIslandMap();
const map = createGameMap(mapData);
const inner = createMockPathFinder();
const transformer = new ShoreCoercingTransformer(inner, map);
const waterSrc = map.ref(0, 0);
const shoreSrc = map.ref(1, 1);
const landSrc = map.ref(2, 2);
const waterDest = map.ref(4, 4);
inner.returnPath = [waterSrc, waterDest];
const result = transformer.findPath(
[waterSrc, shoreSrc, landSrc],
waterDest,
);
expect(result).not.toBeNull();
expect(inner.calls).toHaveLength(1);
const fromArg = inner.calls[0].from;
expect(Array.isArray(fromArg)).toBe(true);
expect((fromArg as number[]).length).toBe(2);
});
it("returns null when all sources are invalid", () => {
const mapData = createIslandMap();
const map = createGameMap(mapData);
const inner = createMockPathFinder();
const transformer = new ShoreCoercingTransformer(inner, map);
const land = map.ref(2, 2);
const result = transformer.findPath([land], map.ref(0, 0));
expect(result).toBeNull();
expect(inner.calls).toHaveLength(0);
});
});
describe("determinism", () => {
it("shore with multiple water neighbors selects consistently", () => {
// prettier-ignore
const map = createGameMap({
width: 5, height: 5, grid: [
L, L, W, W, W,
L, L, W, W, W,
L, L, W, L, L,
W, W, W, L, L,
W, W, W, L, L,
],
});
const shoreWithMultipleWater = map.ref(1, 2);
const expectedWaterNeighbor = map.ref(1, 3);
const inner1 = createMockPathFinder();
const inner2 = createMockPathFinder();
const transformer1 = new ShoreCoercingTransformer(inner1, map);
const transformer2 = new ShoreCoercingTransformer(inner2, map);
const waterDest = map.ref(2, 4);
transformer1.findPath(shoreWithMultipleWater, waterDest);
transformer2.findPath(shoreWithMultipleWater, waterDest);
// Both select the same water neighbor: (1,3)
expect(inner1.calls[0].from).toBe(expectedWaterNeighbor);
expect(inner2.calls[0].from).toBe(expectedWaterNeighbor);
});
it("corner shore with water neighbors works correctly", () => {
const mapData = createIslandMap();
const map = createGameMap(mapData);
const inner = createMockPathFinder();
const transformer = new ShoreCoercingTransformer(inner, map);
const cornerShore = map.ref(1, 1);
const waterNeighbor = map.ref(1, 0);
const waterDest = map.ref(4, 4);
inner.returnPath = [waterNeighbor, waterDest];
const result = transformer.findPath(cornerShore, waterDest);
expect(result).not.toBeNull();
expect(result).toEqual([cornerShore, waterNeighbor, waterDest]);
});
});
});
-135
View File
@@ -1,135 +0,0 @@
import {
Difficulty,
Game,
GameMapSize,
GameMapType,
GameMode,
GameType,
} from "../../../src/core/game/Game";
import { createGame } from "../../../src/core/game/GameImpl";
import { GameMapImpl } from "../../../src/core/game/GameMap";
import { UserSettings } from "../../../src/core/game/UserSettings";
import { TestConfig } from "../../util/TestConfig";
import { TestServerConfig } from "../../util/TestServerConfig";
const LAND_BIT = 7;
const OCEAN_BIT = 5;
/**
* Creates a Game from inline map strings.
* Each char = 1 tile: W=water (ocean), L=land
* miniMap automatically generated (2x21, water if ANY tile water)
*
* Example:
* const game = await gameFromString([
* 'WWWWW',
* 'WLLLW',
* 'WWWWW'
* ]);
*/
export async function gameFromString(mapRows: string[]): Promise<Game> {
const height = mapRows.length;
const width = mapRows[0].length;
for (const row of mapRows) {
if (row.length !== width) {
throw new Error(
`All rows must have same width. Expected ${width}, got ${row.length}`,
);
}
}
const terrainData = new Uint8Array(width * height);
let numLandTiles = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
const char = mapRows[y][x];
if (char === "L") {
terrainData[idx] = 1 << LAND_BIT; // Set land bit
numLandTiles++;
} else if (char === "W") {
terrainData[idx] = 1 << OCEAN_BIT; // Set ocean bit (water)
} else {
throw new Error(
`Unknown char '${char}' at (${x},${y}). Use W=water, L=land`,
);
}
}
}
const gameMap = new GameMapImpl(width, height, terrainData, numLandTiles);
// Create miniMap (2x2→1, water if ANY tile water)
const miniWidth = Math.ceil(width / 2);
const miniHeight = Math.ceil(height / 2);
const miniTerrainData = new Uint8Array(miniWidth * miniHeight);
let miniNumLandTiles = 0;
for (let miniY = 0; miniY < miniHeight; miniY++) {
for (let miniX = 0; miniX < miniWidth; miniX++) {
const miniIdx = miniY * miniWidth + miniX;
// Check 2x2 chunk: if ANY tile is water, miniMap tile is water
let water = false;
for (let dy = 0; dy < 2; dy++) {
for (let dx = 0; dx < 2; dx++) {
const x = miniX * 2 + dx;
const y = miniY * 2 + dy;
if (x < width && y < height) {
const idx = y * width + x;
if (!(terrainData[idx] & (1 << LAND_BIT))) {
water = true;
}
}
}
}
// Water if ANY tile is water
if (water) {
miniTerrainData[miniIdx] = 1 << OCEAN_BIT; // ocean
} else {
miniTerrainData[miniIdx] = 1 << LAND_BIT; // land
miniNumLandTiles++;
}
}
}
const miniGameMap = new GameMapImpl(
miniWidth,
miniHeight,
miniTerrainData,
miniNumLandTiles,
);
// Create game config
const serverConfig = new TestServerConfig();
const gameConfig = {
gameMap: GameMapType.Asia,
gameMapSize: GameMapSize.Normal,
gameMode: GameMode.FFA,
gameType: GameType.Singleplayer,
difficulty: Difficulty.Medium,
disableNations: false,
donateGold: false,
donateTroops: false,
bots: 0,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
disableNavMesh: false,
randomSpawn: false,
};
const config = new TestConfig(
serverConfig,
gameConfig,
new UserSettings(),
false,
);
return createGame([], [], gameMap, miniGameMap, config);
}
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env node
/**
* Compare pathfinding adapters side-by-side
*
* Usage:
* npx tsx tests/pathfinding/benchmark/compare.ts <scenario> <adapters>
* npx tsx tests/pathfinding/benchmark/compare.ts --synthetic <map-name> <adapters>
*
* Examples:
* npx tsx tests/pathfinding/benchmark/compare.ts default hpa,a.baseline
* npx tsx tests/pathfinding/benchmark/compare.ts --synthetic giantworldmap hpa,hpa.cached,a.full
*/
import {
type BenchmarkResult,
calculateStats,
getAdapter,
getScenario,
measureExecutionTime,
measurePathLength,
} from "../utils";
interface AdapterResults {
adapter: string;
initTime: number;
totalTime: number;
totalDistance: number;
successfulRoutes: number;
totalRoutes: number;
}
const DEFAULT_ITERATIONS = 1;
async function runBenchmark(
scenarioName: string,
adapterName: string,
): Promise<AdapterResults> {
const { game, routes, initTime } = await getScenario(
scenarioName,
adapterName,
);
const adapter = getAdapter(game, adapterName);
const results: BenchmarkResult[] = [];
// Measure path lengths
for (const route of routes) {
const pathLength = measurePathLength(adapter, route);
results.push({ route: route.name, pathLength, executionTime: null });
}
// Measure execution times
for (const route of routes) {
const result = results.find((r) => r.route === route.name);
if (result && result.pathLength !== null) {
const execTime = measureExecutionTime(adapter, route, DEFAULT_ITERATIONS);
result.executionTime = execTime;
}
}
const stats = calculateStats(results);
return {
adapter: adapterName,
initTime,
totalTime: stats.totalTime,
totalDistance: stats.totalDistance,
successfulRoutes: stats.successfulRoutes,
totalRoutes: stats.totalRoutes,
};
}
const TABLE_HEADERS = [
"Adapter",
"Init (ms)",
"Path (ms)",
"Distance",
"Routes",
];
const TABLE_WIDTHS = [20, 12, 12, 12, 10];
function printTableHeader(scenarioName: string) {
console.log(`\nResults: ${scenarioName}`);
console.log("=".repeat(70));
console.log(TABLE_HEADERS.map((h, i) => h.padEnd(TABLE_WIDTHS[i])).join(" "));
console.log("-".repeat(70));
}
function printTableRow(r: AdapterResults) {
const row = [
r.adapter,
r.initTime.toFixed(2),
r.totalTime.toFixed(2),
r.totalDistance.toString(),
`${r.successfulRoutes}/${r.totalRoutes}`,
];
console.log(row.map((c, i) => c.padEnd(TABLE_WIDTHS[i])).join(" "));
}
function printTableFooter() {
console.log("-".repeat(70));
}
function printUsage() {
console.log(`
Usage:
npx tsx tests/pathfinding/benchmark/compare.ts <scenario> <adapters>
npx tsx tests/pathfinding/benchmark/compare.ts --synthetic <map-name> <adapters>
Arguments:
<scenario> Name of the scenario (default: "default")
<adapters> Comma-separated list of adapters to compare (e.g., "hpa,a.baseline")
Examples:
npx tsx tests/pathfinding/benchmark/compare.ts default hpa,a.baseline
npx tsx tests/pathfinding/benchmark/compare.ts --synthetic giantworldmap hpa,hpa.cached,a.full
Available adapters:
a.baseline - A* on minimap (inlined)
a.generic - A* on minimap (adapter)
a.full - A* on full map
hpa - Hierarchical pathfinding (no cache)
hpa.cached - Hierarchical pathfinding (with cache)
`);
}
async function main() {
const args = process.argv.slice(2);
if (args.includes("--help") || args.includes("-h")) {
printUsage();
process.exit(0);
}
const isSynthetic = args.includes("--synthetic");
const nonFlagArgs = args.filter((arg) => !arg.startsWith("--"));
if (nonFlagArgs.length < 2) {
console.error("Error: requires <scenario> and <adapters> arguments");
printUsage();
process.exit(1);
}
const scenarioArg = nonFlagArgs[0];
const adaptersArg = nonFlagArgs[1];
const adapters = adaptersArg.split(",").map((a) => a.trim());
if (adapters.length < 1) {
console.error("Error: at least one adapter required");
process.exit(1);
}
const scenarioName = isSynthetic ? `synthetic/${scenarioArg}` : scenarioArg;
console.log(
`Comparing ${adapters.length} adapters on scenario: ${scenarioName}`,
);
console.log(`Adapters: ${adapters.join(", ")}`);
console.log("");
printTableHeader(scenarioName);
for (const adapter of adapters) {
try {
const result = await runBenchmark(scenarioName, adapter);
printTableRow(result);
} catch (error) {
console.log(`${adapter.padEnd(TABLE_WIDTHS[0])} FAILED: ${error}`);
}
}
printTableFooter();
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
+54 -60
View File
@@ -2,10 +2,13 @@ import { readdirSync, readFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { Game } from "../../../../src/core/game/Game.js";
import { TileRef } from "../../../../src/core/game/GameMap.js";
import { NavMesh } from "../../../../src/core/pathfinding/navmesh/NavMesh.js";
import { AStarWaterHierarchical } from "../../../../src/core/pathfinding/algorithms/AStar.WaterHierarchical.js";
import { setupFromPath } from "../../utils.js";
// Available comparison adapters
// Note: "hpa" runs same algorithm without debug overhead for fair timing comparison
export const COMPARISON_ADAPTERS = ["hpa", "a.baseline", "a.generic", "a.full"];
export interface MapInfo {
name: string;
displayName: string;
@@ -13,7 +16,7 @@ export interface MapInfo {
export interface MapCache {
game: Game;
navMesh: NavMesh;
hpaStar: AStarWaterHierarchical;
}
const cache = new Map<string, MapCache>();
@@ -114,13 +117,20 @@ export async function loadMap(mapName: string): Promise<MapCache> {
const mapsDir = getMapsDirectory();
// Use the existing setupFromPath utility to load the map
const game = await setupFromPath(mapsDir, mapName);
const game = await setupFromPath(mapsDir, mapName, { disableNavMesh: false });
// Initialize NavMesh
const navMesh = new NavMesh(game, { cachePaths: config.cachePaths });
navMesh.initialize();
// Get pre-built graph from game
const graph = game.miniWaterGraph();
if (!graph) {
throw new Error(`No water graph available for map: ${mapName}`);
}
const cacheEntry: MapCache = { game, navMesh };
// Initialize AStarWaterHierarchical with minimap and graph
const hpaStar = new AStarWaterHierarchical(game.miniMap(), graph, {
cachePaths: config.cachePaths,
});
const cacheEntry: MapCache = { game, hpaStar };
// Store in cache
cache.set(mapName, cacheEntry);
@@ -132,7 +142,7 @@ export async function loadMap(mapName: string): Promise<MapCache> {
* Get map metadata for client
*/
export async function getMapMetadata(mapName: string) {
const { game, navMesh } = await loadMap(mapName);
const { game, hpaStar } = await loadMap(mapName);
// Extract map data
const mapData: number[] = [];
@@ -143,65 +153,48 @@ export async function getMapMetadata(mapName: string) {
}
}
// Extract static graph data from NavMesh
// Extract static graph data from GameMapHPAStar
// Access internal graph via type casting (test code only)
const graph = (hpaStar as any).graph;
const miniMap = game.miniMap();
const navMeshGraph = (navMesh as any).graph;
// Convert gateways from Map to array
const gatewaysArray = Array.from(navMeshGraph.gateways.values());
const allGateways = gatewaysArray.map((gw: any) => ({
id: gw.id,
x: miniMap.x(gw.tile),
y: miniMap.y(gw.tile),
// Convert nodes to client format
const allNodes = graph.getAllNodes().map((node: any) => ({
id: node.id,
x: miniMap.x(node.tile),
y: miniMap.y(node.tile),
}));
// Create a lookup map from gateway ID to gateway for edge conversion
const gatewayById = new Map(gatewaysArray.map((gw: any) => [gw.id, gw]));
// Convert edges to client format
const edges: Array<{
fromId: number;
toId: number;
from: number[];
to: number[];
cost: number;
}> = [];
for (let i = 0; i < graph.edgeCount; i++) {
const edge = graph.getEdge(i);
if (!edge) continue;
// Convert edges from Map<gatewayId, Edge[]> to flat array
// The edges Map has gateway IDs as keys, and arrays of edges as values
const allEdges: any[] = [];
for (const edgeArray of navMeshGraph.edges.values()) {
allEdges.push(...edgeArray);
const nodeA = graph.getNode(edge.nodeA);
const nodeB = graph.getNode(edge.nodeB);
if (!nodeA || !nodeB) continue;
edges.push({
fromId: edge.nodeA,
toId: edge.nodeB,
from: [miniMap.x(nodeA.tile) * 2, miniMap.y(nodeA.tile) * 2],
to: [miniMap.x(nodeB.tile) * 2, miniMap.y(nodeB.tile) * 2],
cost: edge.cost,
});
}
// Deduplicate edges (they're bidirectional, so each edge appears twice)
const seenEdges = new Set<string>();
const edges = allEdges
.filter((edge: any) => {
const edgeKey =
edge.from < edge.to
? `${edge.from}-${edge.to}`
: `${edge.to}-${edge.from}`;
if (seenEdges.has(edgeKey)) return false;
seenEdges.add(edgeKey);
return true;
})
.map((edge: any) => {
const fromGateway = gatewayById.get(edge.from);
const toGateway = gatewayById.get(edge.to);
return {
fromId: edge.from,
toId: edge.to,
from: fromGateway
? [miniMap.x(fromGateway.tile) * 2, miniMap.y(fromGateway.tile) * 2]
: [0, 0],
to: toGateway
? [miniMap.x(toGateway.tile) * 2, miniMap.y(toGateway.tile) * 2]
: [0, 0],
cost: edge.cost,
path: edge.path
? edge.path.map((tile: TileRef) => [game.x(tile), game.y(tile)])
: null,
};
});
console.log(
`Map ${mapName}: ${allGateways.length} gateways, ${edges.length} edges`,
`Map ${mapName}: ${allNodes.length} nodes, ${edges.length} edges`,
);
const sectorSize = navMeshGraph.sectorSize;
const clusterSize = graph.clusterSize;
return {
name: mapName,
@@ -209,10 +202,11 @@ export async function getMapMetadata(mapName: string) {
height: game.height(),
mapData,
graphDebug: {
allGateways,
allNodes,
edges,
sectorSize,
clusterSize,
},
adapters: COMPARISON_ADAPTERS,
};
}
+191 -99
View File
@@ -1,39 +1,64 @@
import { TileRef } from "../../../../src/core/game/GameMap.js";
import { MiniAStarAdapter } from "../../../../src/core/pathfinding/adapters/MiniAStarAdapter.js";
import { loadMap } from "./maps.js";
import { AStarWaterHierarchical } from "../../../../src/core/pathfinding/algorithms/AStar.WaterHierarchical.js";
import { BresenhamSmoothingTransformer } from "../../../../src/core/pathfinding/smoothing/BresenhamPathSmoother.js";
import { ComponentCheckTransformer } from "../../../../src/core/pathfinding/transformers/ComponentCheckTransformer.js";
import { MiniMapTransformer } from "../../../../src/core/pathfinding/transformers/MiniMapTransformer.js";
import { ShoreCoercingTransformer } from "../../../../src/core/pathfinding/transformers/ShoreCoercingTransformer.js";
import {
PathFinder,
SteppingPathFinder,
} from "../../../../src/core/pathfinding/types.js";
import { getAdapter } from "../../utils.js";
import { COMPARISON_ADAPTERS, loadMap } from "./maps.js";
interface PathfindingOptions {
includePfMini?: boolean;
includeNavMesh?: boolean;
}
interface NavMeshResult {
// Primary result with debug info
interface PrimaryResult {
path: Array<[number, number]> | null;
initialPath: Array<[number, number]> | null;
gateways: Array<[number, number]> | null;
timings: any;
length: number;
time: number;
debug: {
nodePath: Array<[number, number]> | null;
initialPath: Array<[number, number]> | null;
timings: Record<string, number>;
};
}
interface PfMiniResult {
// Comparison result (path + timing only)
interface ComparisonResult {
adapter: string;
path: Array<[number, number]> | null;
length: number;
time: number;
}
// Cache pathfinding adapters per map
const pfMiniCache = new Map<string, MiniAStarAdapter>();
export interface PathfindResult {
primary: PrimaryResult;
comparisons: ComparisonResult[];
}
// Cache adapters per map
const adapterCache = new Map<
string,
Map<string, SteppingPathFinder<TileRef>>
>();
/**
* Get or create MiniAStar adapter for a map
* Get or create an adapter for a map
*/
function getPfMiniAdapter(mapName: string, game: any): MiniAStarAdapter {
if (!pfMiniCache.has(mapName)) {
const adapter = new MiniAStarAdapter(game, { waterPath: true });
pfMiniCache.set(mapName, adapter);
function getOrCreateAdapter(
mapName: string,
adapterName: string,
game: any,
): SteppingPathFinder<TileRef> {
if (!adapterCache.has(mapName)) {
adapterCache.set(mapName, new Map());
}
return pfMiniCache.get(mapName)!;
const mapAdapters = adapterCache.get(mapName)!;
if (!mapAdapters.has(adapterName)) {
mapAdapters.set(adapterName, getAdapter(game, adapterName));
}
return mapAdapters.get(adapterName)!;
}
/**
@@ -48,110 +73,177 @@ function pathToCoords(
}
/**
* Compute pathfinding between two points
* Build the full transformer chain like PathFinding.Water() does
*/
export async function computePath(
mapName: string,
from: [number, number],
to: [number, number],
options: PathfindingOptions = {},
): Promise<NavMeshResult> {
const { game, navMesh: navMeshAdapter } = await loadMap(mapName);
function buildWrappedPathFinder(
hpaStar: AStarWaterHierarchical,
game: any,
graph: any,
): PathFinder<TileRef> {
const miniMap = game.miniMap();
const componentCheckFn = (t: TileRef) => graph.getComponentId(t);
// Convert coordinates to TileRefs
const fromRef = game.ref(from[0], from[1]);
const toRef = game.ref(to[0], to[1]);
// Chain: hpaStar -> ComponentCheck -> Bresenham -> ShoreCoercing -> MiniMap
const withComponentCheck = new ComponentCheckTransformer(
hpaStar,
componentCheckFn,
);
const withSmoothing = new BresenhamSmoothingTransformer(
withComponentCheck,
miniMap,
);
const withShoreCoercing = new ShoreCoercingTransformer(
withSmoothing,
miniMap,
);
const withMiniMap = new MiniMapTransformer(withShoreCoercing, game, miniMap);
// Validate that both points are water tiles
if (!game.isWater(fromRef)) {
throw new Error(`Start point (${from[0]}, ${from[1]}) is not water`);
}
if (!game.isWater(toRef)) {
throw new Error(`End point (${to[0]}, ${to[1]}) is not water`);
}
// Compute NavMesh path
const navMeshPath = navMeshAdapter.findPath(fromRef, toRef, true);
const path = pathToCoords(navMeshPath, game);
return withMiniMap;
}
/**
* Compute primary path using AStarWaterHierarchical with debug info
* Uses the same transformer chain as PathFinding.Water()
*/
function computePrimaryPath(
hpaStar: AStarWaterHierarchical,
game: any,
graph: any,
fromRef: TileRef,
toRef: TileRef,
): PrimaryResult {
const miniMap = game.miniMap();
// Extract debug info
let gateways: Array<[number, number]> | null = null;
// Build wrapped pathfinder with all transformers
const wrappedPf = buildWrappedPathFinder(hpaStar, game, graph);
// Enable debug mode to capture internal state
hpaStar.debugMode = true;
const start = performance.now();
const path = wrappedPf.findPath(fromRef, toRef);
const time = performance.now() - start;
const debugInfo = hpaStar.debugInfo;
// Convert node path (miniMap coords) to full map coords
let nodePath: Array<[number, number]> | null = null;
if (debugInfo?.nodePath) {
nodePath = debugInfo.nodePath.map((tile: TileRef) => {
const x = miniMap.x(tile) * 2;
const y = miniMap.y(tile) * 2;
return [x, y] as [number, number];
});
}
// Convert initialPath (miniMap TileRefs) to full map coords
let initialPath: Array<[number, number]> | null = null;
let timings: any = {};
if (navMeshAdapter.debugInfo) {
// Convert gatewayPath (TileRefs on miniMap) to full map coordinates
if (navMeshAdapter.debugInfo.gatewayPath) {
gateways = navMeshAdapter.debugInfo.gatewayPath.map((tile: TileRef) => {
const x = miniMap.x(tile) * 2;
const y = miniMap.y(tile) * 2;
return [x, y] as [number, number];
});
}
// Convert initial path
if (navMeshAdapter.debugInfo.initialPath) {
initialPath = navMeshAdapter.debugInfo.initialPath.map(
(tile: TileRef) => [game.x(tile), game.y(tile)] as [number, number],
);
}
timings = navMeshAdapter.debugInfo.timings || {};
if (debugInfo?.initialPath) {
initialPath = debugInfo.initialPath.map((tile: TileRef) => {
const x = miniMap.x(tile) * 2;
const y = miniMap.y(tile) * 2;
return [x, y] as [number, number];
});
}
return {
path,
initialPath,
gateways,
timings,
path: pathToCoords(path, game),
length: path ? path.length : 0,
time: timings.total ?? 0,
time,
debug: {
nodePath,
initialPath,
timings: debugInfo?.timings ?? {},
},
};
}
/**
* Compute only PathFinder.Mini path
* Compute comparison path using adapter
*/
export async function computePfMiniPath(
mapName: string,
from: [number, number],
to: [number, number],
): Promise<PfMiniResult> {
const { game } = await loadMap(mapName);
// Convert coordinates to TileRefs
const fromRef = game.ref(from[0], from[1]);
const toRef = game.ref(to[0], to[1]);
// Validate that both points are water tiles
if (!game.isWater(fromRef)) {
throw new Error(`Start point (${from[0]}, ${from[1]}) is not water`);
}
if (!game.isWater(toRef)) {
throw new Error(`End point (${to[0]}, ${to[1]}) is not water`);
}
// Compute PathFinder.Mini path
const pfMiniAdapter = getPfMiniAdapter(mapName, game);
const pfMiniStart = performance.now();
const pfMiniPath = pfMiniAdapter.findPath(fromRef, toRef);
const pfMiniEnd = performance.now();
const path = pathToCoords(pfMiniPath, game);
const time = pfMiniEnd - pfMiniStart;
function computeComparisonPath(
adapter: SteppingPathFinder<TileRef>,
game: any,
fromRef: TileRef,
toRef: TileRef,
adapterName: string,
): ComparisonResult {
const start = performance.now();
const path = adapter.findPath(fromRef, toRef);
const time = performance.now() - start;
return {
path,
adapter: adapterName,
path: pathToCoords(path, game),
length: path ? path.length : 0,
time,
};
}
/**
* Compute pathfinding between two points
*/
export async function computePath(
mapName: string,
from: [number, number],
to: [number, number],
options: { adapters?: string[] } = {},
): Promise<PathfindResult> {
const { game, hpaStar } = await loadMap(mapName);
const graph = game.miniWaterGraph();
// Convert coordinates to TileRefs
const fromRef = game.ref(from[0], from[1]);
const toRef = game.ref(to[0], to[1]);
// Validate that both points are water tiles
if (!game.isWater(fromRef)) {
throw new Error(`Start point (${from[0]}, ${from[1]}) is not water`);
}
if (!game.isWater(toRef)) {
throw new Error(`End point (${to[0]}, ${to[1]}) is not water`);
}
// Compute primary path (HPA* with debug)
const primary = computePrimaryPath(hpaStar, game, graph, fromRef, toRef);
// Compute comparison paths
const selectedAdapters = options.adapters ?? COMPARISON_ADAPTERS;
const comparisons: ComparisonResult[] = [];
for (const adapterName of selectedAdapters) {
if (!COMPARISON_ADAPTERS.includes(adapterName)) {
console.warn(`Unknown adapter: ${adapterName}, skipping`);
continue;
}
try {
const adapter = getOrCreateAdapter(mapName, adapterName, game);
const result = computeComparisonPath(
adapter,
game,
fromRef,
toRef,
adapterName,
);
comparisons.push(result);
} catch (error) {
console.error(`Error with adapter ${adapterName}:`, error);
comparisons.push({
adapter: adapterName,
path: null,
length: 0,
time: 0,
});
}
}
return { primary, comparisons };
}
/**
* Clear pathfinding adapter caches
*/
export function clearAdapterCaches() {
pfMiniCache.clear();
adapterCache.clear();
}
File diff suppressed because it is too large Load Diff
+21 -58
View File
@@ -129,13 +129,13 @@
<button class="toggle-button" id="showInitialPath" data-active="false">
Initial Path
</button>
<button class="toggle-button" id="showUsedGateways" data-active="false">
Used Gateways
<button class="toggle-button" id="showUsedNodes" data-active="false">
Used Nodes
</button>
</div>
<div class="debug-panel-row">
<button class="toggle-button" id="showGateways" data-active="false">
Gateways
<button class="toggle-button" id="showNodes" data-active="false">
Nodes
</button>
<button class="toggle-button" id="showSectorGrid" data-active="false">
Sectors
@@ -166,75 +166,42 @@
<div class="timing-label">
<button
class="refresh-icon"
id="refreshNavMesh"
title="Recompute NavMesh path"
id="refreshHpa"
title="Recompute HPA* path"
>
<span></span>
</button>
NavMesh <span class="timing-label-detail" id="navMeshTiles"></span>
HPA* <span class="timing-label-detail" id="hpaTiles"></span>
</div>
<div class="timing-value-large" id="navMeshTime"></div>
<div class="timing-value-large" id="hpaTime"></div>
<div class="timing-breakdown" id="timingBreakdown">
<div class="timing-item" id="timingEarlyExit" style="display: none">
<span class="timing-name">Early Exit:</span>
<span class="timing-value" id="timingEarlyExitValue"></span>
</div>
<div class="timing-item" id="timingFindNodes" style="display: none">
<span class="timing-name">Find Nodes:</span>
<span class="timing-value" id="timingFindNodesValue"></span>
</div>
<div
class="timing-item"
id="timingFindGateways"
id="timingAbstractPath"
style="display: none"
>
<span class="timing-name">Find Gateways:</span>
<span class="timing-value" id="timingFindGatewaysValue"></span>
</div>
<div class="timing-item" id="timingGatewayPath" style="display: none">
<span class="timing-name">Gateway Path:</span>
<span class="timing-value" id="timingGatewayPathValue"></span>
<span class="timing-name">Abstract Path:</span>
<span class="timing-value" id="timingAbstractPathValue"></span>
</div>
<div class="timing-item" id="timingInitialPath" style="display: none">
<span class="timing-name">Initial Path:</span>
<span class="timing-value" id="timingInitialPathValue"></span>
</div>
<div class="timing-item" id="timingSmoothPath" style="display: none">
<span class="timing-name">Smooth Path:</span>
<span class="timing-value" id="timingSmoothPathValue"></span>
</div>
</div>
</div>
<div class="timing-section" id="pfMiniRequestSection">
<button
id="requestPfMini"
class="timing-button"
title="PathFinder.Mini is slow (50-1800ms per path). Click to compare."
disabled
>
Request PathFinder.Mini
</button>
</div>
<div
class="timing-section"
id="pfMiniTimingSection"
style="display: none"
>
<div class="timing-label">
<button
class="refresh-icon"
id="refreshPfMini"
title="Recompute PF.Mini path"
>
<span></span>
</button>
PF.Mini <span class="timing-label-detail" id="pfMiniTiles"></span>
</div>
<div class="timing-value-large" id="pfMiniTime"></div>
</div>
<div class="timing-section" id="speedupSection" style="display: none">
<div class="timing-label">Speedup</div>
<div class="timing-value-speedup" id="speedupValue"></div>
<div class="timing-section" id="comparisonsSection" style="display: none">
<div class="timing-label">Comparisons</div>
<div id="comparisonsContainer"></div>
</div>
</div>
@@ -256,13 +223,9 @@
></div>
<span>End Point</span>
</div>
<div class="legend-item" id="pfMiniLegend" style="display: none">
<div class="legend-color" style="background: #ffaa00"></div>
<span>PathFinder.Mini</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #00ffff"></div>
<span>NavMesh</span>
<span>HPA*</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #ff00ff"></div>
@@ -273,7 +236,7 @@
class="legend-color"
style="background: #ffff00; height: 8px"
></div>
<span>Used Gateways</span>
<span>Used Nodes</span>
</div>
<div class="legend-item">
<div
@@ -285,7 +248,7 @@
border-radius: 50%;
"
></div>
<span>Gateways</span>
<span>Nodes</span>
</div>
<div class="legend-item">
<div
@@ -500,6 +500,67 @@ canvas {
font-size: 20px;
}
/* Comparison rows */
.comparison-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px;
margin: 0 -8px;
font-size: 14px;
border-bottom: 1px solid #333;
cursor: pointer;
border-radius: 4px;
transition: background 0.15s;
}
.comparison-row:hover {
background: rgba(255, 255, 255, 0.1);
}
.comparison-row.active {
background: rgba(255, 255, 255, 0.15);
}
.comparison-row:last-child {
border-bottom: none;
}
.comp-color {
width: 12px;
height: 12px;
border-radius: 2px;
margin-right: 8px;
flex-shrink: 0;
opacity: 0.4;
transition: opacity 0.15s;
}
.comparison-row.active .comp-color {
opacity: 1;
}
.comp-name {
color: #aaa;
flex: 1;
font-family: monospace;
}
.comp-tiles {
font-family: monospace;
color: #888;
width: 50px;
text-align: right;
margin-right: 10px;
}
.comp-time {
font-family: monospace;
color: #f5f5f5;
width: 60px;
text-align: right;
}
/* Legend panel (right side) */
.legend-panel {
position: fixed;
+10 -68
View File
@@ -8,11 +8,7 @@ import {
listMaps,
setConfig,
} from "./api/maps.js";
import {
clearAdapterCaches,
computePath,
computePfMiniPath,
} from "./api/pathfinding.js";
import { clearAdapterCaches, computePath } from "./api/pathfinding.js";
// Parse command-line arguments
const args = process.argv.slice(2);
@@ -112,12 +108,18 @@ app.get("/api/maps/:name/thumbnail", (req: Request, res: Response) => {
* map: string,
* from: [x, y],
* to: [x, y],
* includePfMini?: boolean
* adapters?: string[] // Optional: which comparison adapters to run
* }
*
* Response:
* {
* primary: { path, length, time, debug: { nodePath, initialPath, timings } },
* comparisons: [{ adapter, path, length, time }, ...]
* }
*/
app.post("/api/pathfind", async (req: Request, res: Response) => {
try {
const { map, from, to, includePfMini } = req.body;
const { map, from, to, adapters } = req.body;
// Validate request
if (!map || !from || !to) {
@@ -144,7 +146,7 @@ app.post("/api/pathfind", async (req: Request, res: Response) => {
map,
from as [number, number],
to as [number, number],
{ includePfMini: !!includePfMini },
{ adapters },
);
res.json(result);
@@ -165,66 +167,6 @@ app.post("/api/pathfind", async (req: Request, res: Response) => {
}
});
/**
* POST /api/pathfind-pfmini
* Compute only PathFinder.Mini path
*
* Request body:
* {
* map: string,
* from: [x, y],
* to: [x, y]
* }
*/
app.post("/api/pathfind-pfmini", async (req: Request, res: Response) => {
try {
const { map, from, to } = req.body;
// Validate request
if (!map || !from || !to) {
return res.status(400).json({
error: "Invalid request",
message: "Missing required fields: map, from, to",
});
}
if (
!Array.isArray(from) ||
from.length !== 2 ||
!Array.isArray(to) ||
to.length !== 2
) {
return res.status(400).json({
error: "Invalid coordinates",
message: "from and to must be [x, y] coordinate arrays",
});
}
// Compute PF.Mini path only
const result = await computePfMiniPath(
map,
from as [number, number],
to as [number, number],
);
res.json(result);
} catch (error) {
console.error("Error computing PF.Mini path:", error);
if (error instanceof Error && error.message.includes("is not water")) {
res.status(400).json({
error: "Invalid coordinates",
message: error.message,
});
} else {
res.status(500).json({
error: "Failed to compute PF.Mini path",
message: error instanceof Error ? error.message : String(error),
});
}
}
});
/**
* POST /api/cache/clear
* Clear all caches (useful for development)
+58 -16
View File
@@ -17,10 +17,19 @@ import {
MapManifest,
} from "../../src/core/game/TerrainMapLoader";
import { UserSettings } from "../../src/core/game/UserSettings";
import { NavMesh } from "../../src/core/pathfinding/navmesh/NavMesh";
import { PathFinder, PathFinders } from "../../src/core/pathfinding/PathFinder";
import { AStarWater } from "../../src/core/pathfinding/algorithms/AStar.Water";
import { AStarWaterHierarchical } from "../../src/core/pathfinding/algorithms/AStar.WaterHierarchical";
import { PathFinding } from "../../src/core/pathfinding/PathFinder";
import { PathFinderBuilder } from "../../src/core/pathfinding/PathFinderBuilder";
import { StepperConfig } from "../../src/core/pathfinding/PathFinderStepper";
import { MiniMapTransformer } from "../../src/core/pathfinding/transformers/MiniMapTransformer";
import {
PathStatus,
SteppingPathFinder,
} from "../../src/core/pathfinding/types";
import { GameConfig } from "../../src/core/Schemas";
import { TestConfig } from "../util/TestConfig";
export type BenchmarkRoute = {
name: string;
from: TileRef;
@@ -42,25 +51,58 @@ export type BenchmarkSummary = {
avgTime: number;
};
export function getAdapter(game: Game, name: string): PathFinder {
function tileStepperConfig(game: Game): StepperConfig<TileRef> {
return {
equals: (a, b) => a === b,
distance: (a, b) => game.manhattanDist(a, b),
preCheck: (from, to) =>
typeof from !== "number" ||
typeof to !== "number" ||
!game.isValidRef(from) ||
!game.isValidRef(to)
? { status: PathStatus.NOT_FOUND }
: null,
};
}
export function getAdapter(
game: Game,
name: string,
): SteppingPathFinder<TileRef> {
switch (name) {
case "legacy":
return PathFinders.WaterLegacy(game, {
iterations: 500_000,
maxTries: 50,
});
case "a.baseline": {
return PathFinderBuilder.create(new AStarWater(game.miniMap()))
.wrap((pf) => new MiniMapTransformer(pf, game, game.miniMap()))
.buildWithStepper(tileStepperConfig(game));
}
case "a.generic": {
// Same as baseline - uses AStarWater on minimap
return PathFinderBuilder.create(new AStarWater(game.miniMap()))
.wrap((pf) => new MiniMapTransformer(pf, game, game.miniMap()))
.buildWithStepper(tileStepperConfig(game));
}
case "a.full": {
return PathFinderBuilder.create(
new AStarWater(game.map()),
).buildWithStepper(tileStepperConfig(game));
}
case "hpa": {
// Recreate NavMesh without cache, this approach was chosen
// Recreate AStarWaterHierarchical without cache, this approach was chosen
// over adding cache toggles to the existing game instance
// to avoid adding side effect from benchmark to the game
const navMesh = new NavMesh(game, { cachePaths: false });
navMesh.initialize();
(game as any)._navMesh = navMesh;
const graph = game.miniWaterGraph();
if (!graph) {
throw new Error("miniWaterGraph not available");
}
const hpa = new AStarWaterHierarchical(game.miniMap(), graph, {
cachePaths: false,
});
(game as any)._miniWaterHPA = hpa;
return PathFinders.Water(game);
return PathFinding.Water(game);
}
case "hpa.cached":
return PathFinders.Water(game);
return PathFinding.Water(game);
default:
throw new Error(`Unknown pathfinding adapter: ${name}`);
}
@@ -102,7 +144,7 @@ export async function getScenario(
}
export function measurePathLength(
adapter: PathFinder,
adapter: SteppingPathFinder<TileRef>,
route: BenchmarkRoute,
): number | null {
const path = adapter.findPath(route.from, route.to);
@@ -117,7 +159,7 @@ export function measureTime<T>(fn: () => T): { result: T; time: number } {
}
export function measureExecutionTime(
adapter: PathFinder,
adapter: SteppingPathFinder<TileRef>,
route: BenchmarkRoute,
executions: number = 1,
): number | null {
-36
View File
@@ -1,36 +0,0 @@
import Benchmark from "benchmark";
import { dirname } from "path";
import { fileURLToPath } from "url";
import { MiniPathFinder } from "../../src/core/pathfinding/PathFinding";
import { setup } from "../util/Setup";
const game = await setup(
"giantworldmap",
{},
[],
dirname(fileURLToPath(import.meta.url)),
);
new Benchmark.Suite()
.add("top-left-to-bottom-right", () => {
new MiniPathFinder(game, 10_000_000_000, true, 1).nextTile(
game.ref(0, 0),
game.ref(4077, 1929),
);
})
.add("hawaii to svalbard", () => {
new MiniPathFinder(game, 10_000_000_000, true, 1).nextTile(
game.ref(186, 800),
game.ref(2205, 52),
);
})
.add("black sea to california", () => {
new MiniPathFinder(game, 10_000_000_000, true, 1).nextTile(
game.ref(2349, 455),
game.ref(511, 536),
);
})
.on("cycle", (event: any) => {
console.log(String(event.target));
})
.run({ async: true });