Pathfinding Refactor

This commit is contained in:
Arkadiusz Sygulski
2026-01-11 21:34:37 +01:00
parent 8235da9335
commit 13b4142317
75 changed files with 6809 additions and 4200 deletions
@@ -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]);
});
});
});