mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 13:33:15 +00:00
**Approved and assigned issue:** #4643 Resolves #4643 ## Description `TradeShipExecution` currently calls `WaterPathFinder.next()` and then performs a second one-shot `findPath()` for the same destination when it records a motion plan. `next()` has already calculated and cached the traversal route, so the second call repeats the expensive water-path calculation solely to recreate the route for the client. This change lets `PathFinderStepper` return a copy of the active path after `next()` advances it. Numeric paths remain `Uint32Array`s, matching the compact representation already used by the stepper and supported by motion-plan packing. `WaterPathFinder.pathForTraversal()` retains the existing second water-graph freshness check. If the graph refresh replaces the stepper between `next()` and motion-plan recording, it falls back to the original one-shot query. Otherwise it returns the cached remainder. The method also normalizes the result to begin at the tile returned by `next()`, so `TradeShipExecution` does not need to mutate the path. `findPath()` remains a stateless one-shot query. The change is limited to TradeShip and the minimal pathfinder support; it includes no TransportShip changes. ## Performance Current upstream `main` (`da2e0918`) was compared with the same commit plus only this change. Each variant was run once per replay on macOS arm64 with CPU profiling disabled. The replay intents were identical between variants, and the current-client final hash and hash tick matched for every pair. | Game ID | Workload | Whole replay | TradeShip execution | Throughput | | --- | --- | ---: | ---: | ---: | | `DWmULj4H` | Antarctica, TradeShip-heavy | 68.960 s → 60.271 s (**-12.60%**) | 18.981 s → 11.844 s (**-37.60%**) | 446 → 510 ticks/s | | `efA7EZv9` | Australia | 14.660 s → 14.346 s (**-2.14%**) | 1.216 s → 0.920 s (**-24.34%**) | 441 → 450 ticks/s | | `waxvMCue` | Svalmel | 16.666 s → 16.537 s (**-0.77%**) | 1.169 s → 0.931 s (**-20.36%**) | 551 → 555 ticks/s | Matched current-client final hashes: - `DWmULj4H`: `49212092378184090` at tick 31,060 - `efA7EZv9`: `82334231069422620` at tick 6,760 - `waxvMCue`: `34697926985435590` at tick 9,480 ## Validation - `npm run build-prod` - Full Node 24 coverage suite: 174 test files and 2,091 tests passed - Full ESLint check - Full Prettier check - Current-main baseline/patched replay hashes matched for all three benchmark games ## Checklist - [x] No UI updates; screenshots are not applicable. - [x] No user-facing text was added or changed. - [x] Relevant tests were added. ## LLM disclosure This change, PR description, and benchmark analysis were authored by **GPT-5.6 Sol**, an LLM, under human direction.
196 lines
6.5 KiB
TypeScript
196 lines
6.5 KiB
TypeScript
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("pathAfterNext", () => {
|
|
it("returns a copy of the cached remainder", () => {
|
|
const pathMap = new Map<string, number[]>([["1->5", [1, 2, 3, 4, 5]]]);
|
|
const stepper = new PathFinderStepper(createMockFinder(pathMap));
|
|
|
|
expect(stepper.pathAfterNext()).toBeNull();
|
|
expect(stepper.next(1, 5)).toEqual({ status: PathStatus.NEXT, node: 2 });
|
|
const path = stepper.pathAfterNext();
|
|
expect(path).toBeInstanceOf(Uint32Array);
|
|
expect(Array.from(path!)).toEqual([2, 3, 4, 5]);
|
|
|
|
path![0] = 99;
|
|
expect(stepper.next(2, 5)).toEqual({ status: PathStatus.NEXT, node: 3 });
|
|
});
|
|
});
|
|
|
|
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 });
|
|
});
|
|
});
|
|
});
|