Make stepper authoritative for sparse motion segments

This commit is contained in:
scamiv
2026-02-28 18:20:33 +01:00
parent 5e0b2f9adb
commit c1936fb289
9 changed files with 322 additions and 449 deletions
+30 -14
View File
@@ -689,6 +689,7 @@ export class GameView implements GameMap {
points: Uint32Array;
segmentSteps: Uint32Array;
segCumSteps: Uint32Array;
lastSegIdx: number;
}
>();
private trainMotionPlans = new Map<number, TrainPlanState>();
@@ -741,6 +742,7 @@ export class GameView implements GameMap {
points: Uint32Array;
segmentSteps: Uint32Array;
segCumSteps: Uint32Array;
lastSegIdx: number;
}
> {
return this.unitMotionPlans;
@@ -938,22 +940,35 @@ export class GameView implements GameMap {
} else if (segmentSteps.length === 0 || idx >= totalSteps) {
newTile = points[points.length - 1] as TileRef;
} else {
let seg = 0;
let lo = 0;
let hi = segmentSteps.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
const start = segCumSteps[mid] >>> 0;
const end = segCumSteps[mid + 1] >>> 0;
if (idx < start) {
hi = mid - 1;
} else if (idx >= end) {
lo = mid + 1;
} else {
seg = mid;
break;
const segmentCount = segmentSteps.length;
let seg = plan.lastSegIdx >>> 0;
if (seg >= segmentCount) {
seg = segmentCount - 1;
}
const currentStart = segCumSteps[seg] >>> 0;
if (idx < currentStart) {
let lo = 0;
let hi = segmentCount - 1;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
const start = segCumSteps[mid] >>> 0;
const end = segCumSteps[mid + 1] >>> 0;
if (idx < start) {
hi = mid - 1;
} else if (idx >= end) {
lo = mid + 1;
} else {
seg = mid;
break;
}
}
} else {
while (seg + 1 < segmentCount && idx >= (segCumSteps[seg + 1] >>> 0)) {
seg++;
}
}
plan.lastSegIdx = seg;
const localStep = idx - (segCumSteps[seg] >>> 0);
const p0 = points[seg] as TileRef;
@@ -1147,6 +1162,7 @@ export class GameView implements GameMap {
points,
segmentSteps,
segCumSteps,
lastSegIdx: 0,
});
this.markMotionPlannedUnitIdsDirty();
break;
-62
View File
@@ -229,65 +229,3 @@ export function unpackMotionPlans(packed: Uint32Array): MotionPlanRecord[] {
return records;
}
export function densePathToKeypointSegments(path: ArrayLike<number>): {
points: Uint32Array;
segmentSteps: Uint32Array;
} | null {
const len = path.length >>> 0;
if (len === 0) {
return null;
}
const first = path[0] >>> 0;
if (len === 1) {
return {
points: Uint32Array.from([first]),
segmentSteps: new Uint32Array(0),
};
}
const points: number[] = [first];
const segmentSteps: number[] = [];
let last = first;
let dirDelta: number | null = null;
let runSteps = 0;
for (let i = 1; i < len; i++) {
const cur = path[i] >>> 0;
const delta = (cur - last) | 0;
if (delta === 0) {
last = cur;
continue;
}
if (dirDelta === null) {
dirDelta = delta;
runSteps = 1;
} else if (delta === dirDelta) {
runSteps++;
} else {
points.push(last);
segmentSteps.push(runSteps);
dirDelta = delta;
runSteps = 1;
}
last = cur;
}
if (dirDelta === null) {
return {
points: Uint32Array.from([first]),
segmentSteps: new Uint32Array(0),
};
}
points.push(last);
segmentSteps.push(runSteps);
return {
points: Uint32Array.from(points),
segmentSteps: Uint32Array.from(segmentSteps),
};
}
+162 -17
View File
@@ -150,26 +150,24 @@ export class PathFinderStepper<T> implements SteppingPathFinder<T> {
}
planSegments(from: T | T[], to: T): SegmentPlan | null {
if (!this.finder.planSegments) {
return null;
}
// If called with multi-source, don't try to prime the step cache (next() uses single-source).
if (Array.isArray(from)) {
// Still compute a path first so inner transformers can cache their segment plan off findPath().
this.findPath(from, to);
return this.finder.planSegments(from, to);
}
// Mirror next() pre-check behavior.
if (this.config.preCheck) {
const result = this.config.preCheck(from, to);
if (result && result.status === PathStatus.NOT_FOUND) {
return null;
if (Array.isArray(from)) {
const allFailed = from.every((f) => {
const result = this.config.preCheck!(f, to);
return result?.status === PathStatus.NOT_FOUND;
});
if (allFailed) {
return null;
}
} else {
const result = this.config.preCheck(from, to);
if (result?.status === PathStatus.NOT_FOUND) {
return null;
}
}
}
if (this.config.equals(from, to)) {
if (!Array.isArray(from) && this.config.equals(from, to)) {
if (typeof (from as any) !== "number") {
return null;
}
@@ -179,11 +177,158 @@ export class PathFinderStepper<T> implements SteppingPathFinder<T> {
};
}
if (Array.isArray(from)) {
const path = this.findPath(from, to);
if (path === null) {
return null;
}
return this.compressDenseTilePath(path);
}
const cachedDense = this.cachedDenseSuffix(from, to);
if (cachedDense !== null) {
return this.compressDenseTilePath(cachedDense);
}
const path = this.findPath(from, to);
if (path === null) {
return null;
}
return this.finder.planSegments(from, to);
return this.compressDenseTilePath(
this.normalizeSingleSourceDensePath(from, path),
);
}
private cachedDenseSuffix(from: T, to: T): T[] | null {
if (
this.path === null ||
this.lastTo === null ||
!this.config.equals(this.lastTo, to)
) {
return null;
}
if (this.pathIndex <= 0) {
return null;
}
const expectedPos = this.path[this.pathIndex - 1];
if (!this.config.equals(from, expectedPos)) {
return null;
}
return this.path.slice(this.pathIndex - 1);
}
private normalizeSingleSourceDensePath(from: T, path: T[]): T[] {
if (path.length === 0) {
return [from];
}
if (this.config.equals(path[0], from)) {
return path;
}
return [from, ...path];
}
private compressDenseTilePath(path: ArrayLike<T>): SegmentPlan | null {
const count = path.length >>> 0;
if (count === 0) {
return null;
}
const first = path[0];
if (typeof first !== "number") {
return null;
}
let segmentCount = 0;
let pointCount = 1;
let prev = first as number;
let hasRun = false;
let runDelta = 0;
for (let i = 1; i < count; i++) {
const node = path[i];
if (typeof node !== "number") {
return null;
}
const cur = node as number;
const delta = cur - prev;
prev = cur;
if (delta === 0) {
continue;
}
if (!hasRun) {
hasRun = true;
runDelta = delta;
segmentCount = 1;
pointCount = 2;
continue;
}
if (delta !== runDelta) {
runDelta = delta;
segmentCount++;
pointCount++;
}
}
if (segmentCount === 0) {
return {
points: Uint32Array.from([(first as number) >>> 0]),
segmentSteps: new Uint32Array(0),
};
}
const points = new Uint32Array(pointCount);
const segmentSteps = new Uint32Array(segmentCount);
points[0] = (first as number) >>> 0;
let seg = 0;
let steps = 0;
runDelta = 0;
prev = first as number;
for (let i = 1; i < count; i++) {
const cur = path[i] as number;
const delta = cur - prev;
if (delta === 0) {
prev = cur;
continue;
}
if (steps === 0) {
runDelta = delta;
steps = 1;
prev = cur;
continue;
}
if (delta === runDelta) {
steps++;
prev = cur;
continue;
}
const runEnd = path[i - 1];
if (typeof runEnd !== "number") {
return null;
}
segmentSteps[seg] = steps >>> 0;
points[seg + 1] = runEnd >>> 0;
seg++;
runDelta = delta;
steps = 1;
prev = cur;
}
segmentSteps[seg] = steps >>> 0;
points[seg + 1] = prev >>> 0;
return { points, segmentSteps };
}
}
@@ -9,12 +9,6 @@ import { PathFinder } from "../types";
* Avoids running expensive pathfinding when no path exists.
*/
export class ComponentCheckTransformer<T> implements PathFinder<T> {
private lastPlanFrom: T | T[] | null = null;
private lastPlanTo: T | null = null;
private lastPlan = null as ReturnType<
NonNullable<PathFinder<T>["planSegments"]>
>;
constructor(
private inner: PathFinder<T>,
private getComponent: (t: T) => number,
@@ -36,43 +30,6 @@ export class ComponentCheckTransformer<T> implements PathFinder<T> {
// Delegate with only valid sources
const delegateFrom =
validSources.length === 1 ? validSources[0] : validSources;
const path = this.inner.findPath(delegateFrom, to);
this.lastPlanFrom = from;
this.lastPlanTo = to;
this.lastPlan = this.inner.planSegments?.(delegateFrom, to) ?? null;
return path;
}
planSegments(from: T | T[], to: T) {
if (
this.lastPlanTo === to &&
this.lastPlanFrom === from &&
this.lastPlan !== null
) {
return this.lastPlan;
}
const toComponent = this.getComponent(to);
const fromArray = Array.isArray(from) ? from : [from];
const validSources = fromArray.filter(
(f) => this.getComponent(f) === toComponent,
);
if (validSources.length === 0) {
this.lastPlanFrom = from;
this.lastPlanTo = to;
this.lastPlan = null;
return null;
}
const delegateFrom =
validSources.length === 1 ? validSources[0] : validSources;
// Ensure inner has a fresh cached plan (if any) for these args.
this.inner.findPath(delegateFrom, to);
this.lastPlanFrom = from;
this.lastPlanTo = to;
this.lastPlan = this.inner.planSegments?.(delegateFrom, to) ?? null;
return this.lastPlan;
return this.inner.findPath(delegateFrom, to);
}
}
@@ -1,12 +1,8 @@
import { Cell } from "../../game/Game";
import { GameMap, TileRef } from "../../game/GameMap";
import { PathFinder, SegmentPlan } from "../types";
import { PathFinder } from "../types";
export class MiniMapTransformer implements PathFinder<number> {
private lastPlanFrom: TileRef | TileRef[] | null = null;
private lastPlanTo: TileRef | null = null;
private lastPlan: SegmentPlan | null = null;
constructor(
private inner: PathFinder<number>,
private map: GameMap,
@@ -33,9 +29,6 @@ export class MiniMapTransformer implements PathFinder<number> {
// Search on minimap
const path = this.inner.findPath(miniFrom, miniTo);
if (!path || path.length === 0) {
this.lastPlanFrom = from;
this.lastPlanTo = to;
this.lastPlan = null;
return null;
}
@@ -67,129 +60,9 @@ export class MiniMapTransformer implements PathFinder<number> {
const cellTo = new Cell(this.map.x(to), this.map.y(to));
const upscaled = this.fixExtremes(upscaledPath, cellTo, cellFrom);
const miniPlan = this.inner.planSegments?.(miniFrom, miniTo) ?? null;
this.lastPlanFrom = from;
this.lastPlanTo = to;
this.lastPlan = miniPlan
? this.upscaleSegmentPlan(miniPlan, cellFrom, cellTo)
: null;
return upscaled.map((c) => this.map.ref(c.x, c.y));
}
planSegments(from: TileRef | TileRef[], to: TileRef): SegmentPlan | null {
if (this.lastPlanFrom === from && this.lastPlanTo === to) {
return this.lastPlan;
}
this.findPath(from, to);
return this.lastPlan;
}
private upscaleSegmentPlan(
plan: SegmentPlan,
cellFrom: Cell | undefined,
cellTo: Cell,
scaleFactor: number = 2,
): SegmentPlan {
const dstRef = this.map.ref(cellTo.x, cellTo.y);
const points: number[] = [];
for (let i = 0; i < plan.points.length; i++) {
const miniRef = plan.points[i] as unknown as TileRef;
const x = this.miniMap.x(miniRef) * scaleFactor;
const y = this.miniMap.y(miniRef) * scaleFactor;
points.push(this.map.ref(x, y) >>> 0);
}
const steps: number[] = new Array(plan.segmentSteps.length);
for (let i = 0; i < plan.segmentSteps.length; i++) {
steps[i] = (plan.segmentSteps[i] * scaleFactor) >>> 0;
}
if (cellFrom !== undefined && points.length > 0) {
const srcRef = this.map.ref(cellFrom.x, cellFrom.y);
if (points[0] !== srcRef >>> 0) {
const a = srcRef;
const b = points[0] as TileRef;
const dx = this.map.x(b) - this.map.x(a);
const dy = this.map.y(b) - this.map.y(a);
const segSteps = Math.max(Math.abs(dx), Math.abs(dy)) || 1;
points.unshift(srcRef >>> 0);
steps.unshift(segSteps >>> 0);
}
}
if (points.length > 0 && points[points.length - 1] !== dstRef >>> 0) {
const a = points[points.length - 1] as TileRef;
const b = dstRef;
const dx = this.map.x(b) - this.map.x(a);
const dy = this.map.y(b) - this.map.y(a);
const segSteps = Math.max(Math.abs(dx), Math.abs(dy)) || 1;
points.push(dstRef >>> 0);
steps.push(segSteps >>> 0);
}
const compressed = this.compressCollinearSegments(points, steps);
return {
points: Uint32Array.from(compressed.points),
segmentSteps: Uint32Array.from(compressed.segmentSteps),
};
}
private compressCollinearSegments(
points: number[],
segmentSteps: number[],
): { points: number[]; segmentSteps: number[] } {
if (points.length <= 2 || segmentSteps.length <= 1) {
return { points, segmentSteps };
}
const outPoints: number[] = [points[0] >>> 0];
const outSteps: number[] = [];
let runSteps = segmentSteps[0] >>> 0;
let runDir = this.segmentDirection(points[0] as TileRef, points[1] as TileRef);
for (let i = 1; i < segmentSteps.length; i++) {
const segDir = this.segmentDirection(
points[i] as TileRef,
points[i + 1] as TileRef,
);
if (segDir.dx === runDir.dx && segDir.dy === runDir.dy) {
runSteps = (runSteps + (segmentSteps[i] >>> 0)) >>> 0;
continue;
}
outPoints.push(points[i] >>> 0);
outSteps.push(runSteps >>> 0);
runDir = segDir;
runSteps = segmentSteps[i] >>> 0;
}
outPoints.push(points[points.length - 1] >>> 0);
outSteps.push(runSteps >>> 0);
return {
points: outPoints,
segmentSteps: outSteps,
};
}
private segmentDirection(
from: TileRef,
to: TileRef,
): { dx: number; dy: number } {
const dx = this.map.x(to) - this.map.x(from);
const dy = this.map.y(to) - this.map.y(from);
return {
dx: Math.sign(dx),
dy: Math.sign(dy),
};
}
private upscalePath(path: Cell[], scaleFactor: number = 2): Cell[] {
const scaledPath = path.map(
(point) => new Cell(point.x * scaleFactor, point.y * scaleFactor),
@@ -1,5 +1,5 @@
import { GameMap, TileRef } from "../../game/GameMap";
import { PathFinder, SegmentPlan } from "../types";
import { PathFinder } from "../types";
/**
* Wraps a PathFinder to handle shore tiles.
@@ -7,10 +7,6 @@ import { PathFinder, SegmentPlan } from "../types";
* then fixes the path extremes to include the original shore tiles.
*/
export class ShoreCoercingTransformer implements PathFinder<number> {
private lastPlanFrom: TileRef | TileRef[] | null = null;
private lastPlanTo: TileRef | null = null;
private lastPlan: SegmentPlan | null = null;
constructor(
private inner: PathFinder<number>,
private map: GameMap,
@@ -41,28 +37,13 @@ export class ShoreCoercingTransformer implements PathFinder<number> {
const fromTiles = waterFrom.length === 1 ? waterFrom[0] : waterFrom;
const path = this.inner.findPath(fromTiles, coercedTo.water);
if (!path || path.length === 0) {
this.lastPlanFrom = from;
this.lastPlanTo = to;
this.lastPlan = null;
return null;
}
const innerPlan = this.inner.planSegments?.(fromTiles, coercedTo.water);
const planPoints: number[] | null = innerPlan
? Array.from(innerPlan.points)
: null;
const planSteps: number[] | null = innerPlan
? Array.from(innerPlan.segmentSteps)
: null;
// Restore original start shore tile
const originalShore = waterToOriginal.get(path[0]);
if (originalShore !== undefined && originalShore !== null) {
path.unshift(originalShore);
if (planPoints && planSteps) {
planPoints.unshift(originalShore >>> 0);
planSteps.unshift(1);
}
}
// Append original to if different
@@ -71,34 +52,11 @@ export class ShoreCoercingTransformer implements PathFinder<number> {
path[path.length - 1] !== coercedTo.original
) {
path.push(coercedTo.original);
if (planPoints && planSteps) {
planPoints.push(coercedTo.original >>> 0);
planSteps.push(1);
}
}
this.lastPlanFrom = from;
this.lastPlanTo = to;
this.lastPlan =
planPoints && planSteps
? {
points: Uint32Array.from(planPoints),
segmentSteps: Uint32Array.from(planSteps),
}
: null;
return path;
}
planSegments(from: TileRef | TileRef[], to: TileRef): SegmentPlan | null {
if (this.lastPlanFrom === from && this.lastPlanTo === to) {
return this.lastPlan;
}
this.findPath(from, to);
return this.lastPlan;
}
/**
* Coerce a tile to water for pathfinding.
* If tile is already water, returns it unchanged.
@@ -4,7 +4,7 @@ import {
AStarWaterBounded,
SearchBounds,
} from "../algorithms/AStar.WaterBounded";
import { PathFinder, SegmentPlan } from "../types";
import { PathFinder } from "../types";
const ENDPOINT_REFINEMENT_TILES = 50;
const LOCAL_ASTAR_MAX_AREA = 100 * 100;
@@ -23,9 +23,6 @@ export class SmoothingWaterTransformer implements PathFinder<TileRef> {
private readonly localAStar: AStarWaterBounded;
private readonly terrain: Uint8Array;
private readonly isTraversable: (tile: TileRef) => boolean;
private lastPlanFrom: TileRef | TileRef[] | null = null;
private lastPlanTo: TileRef | null = null;
private lastPlan: SegmentPlan | null = null;
constructor(
private inner: PathFinder<TileRef>,
@@ -42,42 +39,15 @@ export class SmoothingWaterTransformer implements PathFinder<TileRef> {
const path = this.inner.findPath(from, to);
if (!path) {
this.lastPlanFrom = from;
this.lastPlanTo = to;
this.lastPlan = null;
return null;
}
return DebugSpan.wrap("smoothingTransformer", () => {
const { dense, plan } = this.smoothWithPlan(path);
this.lastPlanFrom = from;
this.lastPlanTo = to;
this.lastPlan = plan;
return dense;
});
return DebugSpan.wrap("smoothingTransformer", () => this.smooth(path));
}
planSegments(from: TileRef | TileRef[], to: TileRef): SegmentPlan | null {
if (this.lastPlanFrom === from && this.lastPlanTo === to) {
return this.lastPlan;
}
this.findPath(from, to);
return this.lastPlan;
}
private smoothWithPlan(path: TileRef[]): {
dense: TileRef[];
plan: SegmentPlan;
} {
private smooth(path: TileRef[]): TileRef[] {
if (path.length <= 2) {
const points =
path.length === 2
? Uint32Array.from([path[0] >>> 0, path[1] >>> 0])
: Uint32Array.from([path[0] >>> 0]);
const segmentSteps =
path.length === 2 ? Uint32Array.from([1]) : new Uint32Array(0);
return { dense: path, plan: { points, segmentSteps } };
return path;
}
// Pass 1: LOS smoothing with binary search
@@ -91,29 +61,13 @@ export class SmoothingWaterTransformer implements PathFinder<TileRef> {
);
// Pass 3: LOS smoothing again, farther from the shore
const capture = { points: [] as number[], segmentSteps: [] as number[] };
const dense = DebugSpan.wrap("smoother:los2", () =>
this.losSmooth(smoothed, LOS_MIN_MAGNITUDE_PASS2, capture),
return DebugSpan.wrap("smoother:los2", () =>
this.losSmooth(smoothed, LOS_MIN_MAGNITUDE_PASS2),
);
return {
dense,
plan: {
points: Uint32Array.from(capture.points),
segmentSteps: Uint32Array.from(capture.segmentSteps),
},
};
}
private losSmooth(
path: TileRef[],
minMagnitude: number,
capture?: { points: number[]; segmentSteps: number[] },
): TileRef[] {
private losSmooth(path: TileRef[], minMagnitude: number): TileRef[] {
const result: TileRef[] = [path[0]];
if (capture) {
capture.points.push(path[0] >>> 0);
}
let current = 0;
while (current < path.length - 1) {
@@ -133,26 +87,14 @@ export class SmoothingWaterTransformer implements PathFinder<TileRef> {
}
// Trace the path to farthest visible point
let segSteps = 1;
if (farthest > current + 1) {
const trace = this.tracePath(path[current], path[farthest]);
if (trace) {
segSteps = trace.length - 1;
// Add all intermediate tiles except the last (will be added in next iteration or at end)
for (let i = 1; i < trace.length - 1; i++) {
result.push(trace[i]);
}
}
if (!trace) {
segSteps = (farthest - current) >>> 0;
}
} else if (farthest > current) {
segSteps = 1;
}
if (capture) {
capture.points.push(path[farthest] >>> 0);
capture.segmentSteps.push(segSteps >>> 0);
}
current = farthest;
+5 -76
View File
@@ -1,67 +1,13 @@
import { describe, expect, it } from "vitest";
import { GameMapImpl } from "../src/core/game/GameMap";
import { densePathToKeypointSegments } from "../src/core/game/MotionPlans";
import { MiniMapTransformer } from "../src/core/pathfinding/transformers/MiniMapTransformer";
function makeMap(width: number, height: number): GameMapImpl {
return new GameMapImpl(width, height, new Uint8Array(width * height), 0);
}
function expandPlanDda(
map: GameMapImpl,
points: Uint32Array,
segmentSteps: Uint32Array,
): number[] {
const out: number[] = [];
if (points.length === 0) return out;
out.push(points[0] >>> 0);
for (let i = 0; i < segmentSteps.length; i++) {
const steps = segmentSteps[i] >>> 0;
const a = points[i] >>> 0;
const b = points[i + 1] >>> 0;
const ax = map.x(a);
const ay = map.y(a);
const bx = map.x(b);
const by = map.y(b);
const dx = bx - ax;
const dy = by - ay;
for (let t = 1; t <= steps; t++) {
out.push(
map.ref(
Math.round(ax + (dx * t) / steps),
Math.round(ay + (dy * t) / steps),
) >>> 0,
);
}
}
return out;
}
describe("densePathToKeypointSegments", () => {
it("expands back to the dense path for axis segments", () => {
const map = makeMap(10, 10);
const dense = [
map.ref(1, 1),
map.ref(2, 1),
map.ref(3, 1),
map.ref(4, 1),
map.ref(4, 2),
map.ref(4, 3),
map.ref(4, 4),
];
const plan = densePathToKeypointSegments(dense);
expect(plan).not.toBeNull();
if (!plan) return;
const expanded = expandPlanDda(map, plan.points, plan.segmentSteps);
expect(expanded).toEqual(dense.map((t) => t >>> 0));
});
});
describe("MiniMapTransformer planSegments compression", () => {
it("preserves endpoints and total steps while merging collinear runs", () => {
describe("MiniMapTransformer", () => {
it("preserves dense path endpoints after upscaling/fixing extremes", () => {
const map = makeMap(10, 10);
const miniMap = makeMap(5, 5);
@@ -77,12 +23,6 @@ describe("MiniMapTransformer planSegments compression", () => {
findPath() {
return miniPath.slice();
},
planSegments() {
return {
points: Uint32Array.from(miniPath),
segmentSteps: Uint32Array.from([1, 1, 1, 1]),
};
},
};
const transformer = new MiniMapTransformer(inner as any, map, miniMap);
@@ -91,19 +31,8 @@ describe("MiniMapTransformer planSegments compression", () => {
const dense = transformer.findPath(from, to);
expect(dense).not.toBeNull();
const plan = transformer.planSegments(from, to);
expect(plan).not.toBeNull();
if (!plan) return;
expect(Array.from(plan.points)).toEqual([
from >>> 0,
map.ref(4, 0) >>> 0,
to >>> 0,
]);
expect(Array.from(plan.segmentSteps)).toEqual([4, 4]);
const totalSteps = Array.from(plan.segmentSteps).reduce((a, b) => a + b, 0);
expect(totalSteps).toBe(8);
if (!dense) return;
expect(dense[0]).toBe(from);
expect(dense[dense.length - 1]).toBe(to);
});
});
@@ -176,4 +176,119 @@ describe("PathFinderStepper", () => {
expect((result2 as { node: Pos }).node).toEqual({ x: 3, y: 0 });
});
});
describe("planSegments", () => {
it("compresses dense paths into delta runs", () => {
const path = [10, 11, 12, 13, 23, 33, 43];
const stepper = new PathFinderStepper<number>({
findPath: () => path.slice(),
});
const plan = stepper.planSegments(10, 43);
expect(plan).not.toBeNull();
if (!plan) return;
expect(Array.from(plan.points)).toEqual([10, 13, 43]);
expect(Array.from(plan.segmentSteps)).toEqual([3, 3]);
});
it("reuses cached suffix after next() without an extra findPath call", () => {
let calls = 0;
const path = [1, 2, 3, 4, 14, 24];
const stepper = new PathFinderStepper<number>({
findPath: () => {
calls++;
return path.slice();
},
});
const r1 = stepper.next(1, 24);
expect(r1.status).toBe(PathStatus.NEXT);
const r2 = stepper.next(2, 24);
expect(r2.status).toBe(PathStatus.NEXT);
expect(calls).toBe(1);
const plan = stepper.planSegments(3, 24);
expect(plan).not.toBeNull();
if (!plan) return;
expect(calls).toBe(1);
expect(Array.from(plan.points)).toEqual([3, 4, 24]);
expect(Array.from(plan.segmentSteps)).toEqual([1, 2]);
});
it("prepends source when the returned dense path omits it", () => {
const stepper = new PathFinderStepper<number>({
findPath: () => [11, 12, 22],
});
const plan = stepper.planSegments(10, 22);
expect(plan).not.toBeNull();
if (!plan) return;
expect(Array.from(plan.points)).toEqual([10, 12, 22]);
expect(Array.from(plan.segmentSteps)).toEqual([2, 1]);
});
it("skips zero-delta nodes while preserving run counts", () => {
const stepper = new PathFinderStepper<number>({
findPath: () => [10, 10, 11, 12, 22, 22, 32, 31],
});
const plan = stepper.planSegments(10, 31);
expect(plan).not.toBeNull();
if (!plan) return;
expect(Array.from(plan.points)).toEqual([10, 12, 32, 31]);
expect(Array.from(plan.segmentSteps)).toEqual([2, 2, 1]);
});
it("returns a single-point plan when from equals to", () => {
let calls = 0;
const stepper = new PathFinderStepper<number>({
findPath: () => {
calls++;
return [5];
},
});
const plan = stepper.planSegments(5, 5);
expect(plan).not.toBeNull();
if (!plan) return;
expect(calls).toBe(0);
expect(Array.from(plan.points)).toEqual([5]);
expect(plan.segmentSteps.length).toBe(0);
});
it("returns null when no path exists", () => {
const stepper = new PathFinderStepper<number>({
findPath: () => null,
});
const plan = stepper.planSegments(1, 99);
expect(plan).toBeNull();
});
it("supports multi-source by compressing the returned dense path once", () => {
let calls = 0;
const stepper = new PathFinderStepper<number>({
findPath: (from) => {
calls++;
if (!Array.isArray(from)) {
return null;
}
return [from[1], from[1] + 1, from[1] + 2];
},
});
const plan = stepper.planSegments([10, 20], 22);
expect(plan).not.toBeNull();
if (!plan) return;
expect(calls).toBe(1);
expect(Array.from(plan.points)).toEqual([20, 22]);
expect(Array.from(plan.segmentSteps)).toEqual([2]);
});
});
});