diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 64fab52a6..21b494036 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -689,6 +689,7 @@ export class GameView implements GameMap { points: Uint32Array; segmentSteps: Uint32Array; segCumSteps: Uint32Array; + lastSegIdx: number; } >(); private trainMotionPlans = new Map(); @@ -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; diff --git a/src/core/game/MotionPlans.ts b/src/core/game/MotionPlans.ts index a55d13f35..bc6e9a6a1 100644 --- a/src/core/game/MotionPlans.ts +++ b/src/core/game/MotionPlans.ts @@ -229,65 +229,3 @@ export function unpackMotionPlans(packed: Uint32Array): MotionPlanRecord[] { return records; } - -export function densePathToKeypointSegments(path: ArrayLike): { - 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), - }; -} diff --git a/src/core/pathfinding/PathFinderStepper.ts b/src/core/pathfinding/PathFinderStepper.ts index 1d0dad1fa..86e90340b 100644 --- a/src/core/pathfinding/PathFinderStepper.ts +++ b/src/core/pathfinding/PathFinderStepper.ts @@ -150,26 +150,24 @@ export class PathFinderStepper implements SteppingPathFinder { } 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 implements SteppingPathFinder { }; } + 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): 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 }; } } diff --git a/src/core/pathfinding/transformers/ComponentCheckTransformer.ts b/src/core/pathfinding/transformers/ComponentCheckTransformer.ts index 833f1cd86..2d1d4d685 100644 --- a/src/core/pathfinding/transformers/ComponentCheckTransformer.ts +++ b/src/core/pathfinding/transformers/ComponentCheckTransformer.ts @@ -9,12 +9,6 @@ import { PathFinder } from "../types"; * Avoids running expensive pathfinding when no path exists. */ export class ComponentCheckTransformer implements PathFinder { - private lastPlanFrom: T | T[] | null = null; - private lastPlanTo: T | null = null; - private lastPlan = null as ReturnType< - NonNullable["planSegments"]> - >; - constructor( private inner: PathFinder, private getComponent: (t: T) => number, @@ -36,43 +30,6 @@ export class ComponentCheckTransformer implements PathFinder { // 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); } } diff --git a/src/core/pathfinding/transformers/MiniMapTransformer.ts b/src/core/pathfinding/transformers/MiniMapTransformer.ts index f4682c5a5..885368716 100644 --- a/src/core/pathfinding/transformers/MiniMapTransformer.ts +++ b/src/core/pathfinding/transformers/MiniMapTransformer.ts @@ -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 { - private lastPlanFrom: TileRef | TileRef[] | null = null; - private lastPlanTo: TileRef | null = null; - private lastPlan: SegmentPlan | null = null; - constructor( private inner: PathFinder, private map: GameMap, @@ -33,9 +29,6 @@ export class MiniMapTransformer implements PathFinder { // 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 { 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), diff --git a/src/core/pathfinding/transformers/ShoreCoercingTransformer.ts b/src/core/pathfinding/transformers/ShoreCoercingTransformer.ts index e872c0e67..d0e8dbe25 100644 --- a/src/core/pathfinding/transformers/ShoreCoercingTransformer.ts +++ b/src/core/pathfinding/transformers/ShoreCoercingTransformer.ts @@ -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 { - private lastPlanFrom: TileRef | TileRef[] | null = null; - private lastPlanTo: TileRef | null = null; - private lastPlan: SegmentPlan | null = null; - constructor( private inner: PathFinder, private map: GameMap, @@ -41,28 +37,13 @@ export class ShoreCoercingTransformer implements PathFinder { 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 { 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. diff --git a/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts b/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts index 00565e62c..848507ac3 100644 --- a/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts +++ b/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts @@ -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 { 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, @@ -42,42 +39,15 @@ export class SmoothingWaterTransformer implements PathFinder { 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 { ); // 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 { } // 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; diff --git a/tests/MiniMapTransformerPlanSegments.test.ts b/tests/MiniMapTransformerPlanSegments.test.ts index 6c869e477..ea56cfed8 100644 --- a/tests/MiniMapTransformerPlanSegments.test.ts +++ b/tests/MiniMapTransformerPlanSegments.test.ts @@ -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); }); }); diff --git a/tests/core/pathfinding/PathFinderStepper.test.ts b/tests/core/pathfinding/PathFinderStepper.test.ts index 5cf0fbc53..05b9e9c19 100644 --- a/tests/core/pathfinding/PathFinderStepper.test.ts +++ b/tests/core/pathfinding/PathFinderStepper.test.ts @@ -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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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]); + }); + }); });