Files
OpenFrontIO/tests/core/pathfinding/UniversalPathFinding.Parabola.test.ts
Arkadiusz Sygulski 13b4142317 Pathfinding Refactor
2026-01-11 21:34:37 +01: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);
}
});
});
});