mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-14 08:04:55 +00:00
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:
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user