Files
OpenFrontIO/tests/core/pathfinding/UniversalPathFinding.Parabola.test.ts
T
Arkadiusz Sygulski 0e3ced3bfa 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
2026-01-11 20:11:14 -08:00

321 lines
9.5 KiB
TypeScript

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