154 lines
5.3 KiB
TypeScript
154 lines
5.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { PartyNavigationGraph } from "./partyNavigation";
|
|
import {
|
|
emptyPartyRouteState,
|
|
nextPartyRouteRecoveryWaypoint,
|
|
nextPartyRouteWaypoint,
|
|
partyRouteNeedsPlanning,
|
|
planPartyRoute,
|
|
} from "./partyPathing";
|
|
|
|
const corridorGraph: PartyNavigationGraph = {
|
|
nodes: [
|
|
{ id: 0, position: [0, 0, 0], neighbors: [{ to: 1, cost: 2 }] },
|
|
{ id: 1, position: [2, 0, 0], neighbors: [{ to: 0, cost: 2 }, { to: 2, cost: 2 }] },
|
|
{ id: 2, position: [2, 0, 2], neighbors: [{ to: 1, cost: 2 }] },
|
|
],
|
|
weldTolerance: 0.001,
|
|
sourceVertexCount: 3,
|
|
sourceTriangleCount: 1,
|
|
weldedNodeCount: 3,
|
|
componentCount: 1,
|
|
discardedNodeCount: 0,
|
|
};
|
|
|
|
describe("party path controller", () => {
|
|
it("returns approved corridor points in order and appends only a short local finish", () => {
|
|
const route = planPartyRoute(corridorGraph, "boss", [0, 0, 0], [2.5, 0, 2], 100, {
|
|
maxSnapDistance: 1,
|
|
localFinishDistance: 1,
|
|
waypointReachedDistance: 0.2,
|
|
directSegmentAllowed: () => true,
|
|
});
|
|
expect(route.blocked).toBe(false);
|
|
expect(route.waypoints).toEqual([
|
|
[0, 0, 0],
|
|
[2, 0, 0],
|
|
[2, 0, 2],
|
|
[2.5, 0, 2],
|
|
]);
|
|
|
|
const out: [number, number, number] = [99, 99, 99];
|
|
expect(nextPartyRouteWaypoint(route, [0, 0, 0], out, { waypointReachedDistance: 0.2 })).toBe(true);
|
|
expect(out).toEqual([2, 0, 0]);
|
|
});
|
|
|
|
it("holds instead of exposing a raw destination when no approved route exists", () => {
|
|
const route = planPartyRoute(corridorGraph, "unreachable", [0, 0, 0], [50, 0, 50], 100, {
|
|
maxSnapDistance: 2,
|
|
});
|
|
const out: [number, number, number] = [7, 8, 9];
|
|
expect(route.blocked).toBe(true);
|
|
expect(route.waypoints).toEqual([]);
|
|
expect(nextPartyRouteWaypoint(route, [0, 0, 0], out)).toBe(false);
|
|
expect(out).toEqual([7, 8, 9]);
|
|
});
|
|
|
|
it("replans on goal changes, material destination movement, and blocked retries", () => {
|
|
const empty = emptyPartyRouteState();
|
|
expect(partyRouteNeedsPlanning(empty, "player", [0, 0, 0], [1, 0, 0], 0)).toBe(true);
|
|
|
|
const route = planPartyRoute(corridorGraph, "player", [0, 0, 0], [2, 0, 2], 100);
|
|
expect(partyRouteNeedsPlanning(route, "mob", [0, 0, 0], [2, 0, 2], 200)).toBe(true);
|
|
expect(partyRouteNeedsPlanning(route, "player", [0, 0, 0], [4.5, 0, 2], 800)).toBe(true);
|
|
|
|
const blocked = planPartyRoute(null, "boss", [0, 0, 0], [2, 0, 2], 100);
|
|
expect(partyRouteNeedsPlanning(blocked, "boss", [0, 0, 0], [2, 0, 2], 1_000)).toBe(false);
|
|
expect(partyRouteNeedsPlanning(blocked, "boss", [0, 0, 0], [2, 0, 2], 1_600)).toBe(true);
|
|
});
|
|
|
|
it("rejects an off-surface connector unless collision validation approves it", () => {
|
|
const rejected = planPartyRoute(corridorGraph, "mob", [0, 0, 0], [2.5, 0, 2], 100, {
|
|
maxSnapDistance: 1,
|
|
localFinishDistance: 1,
|
|
directSegmentAllowed: () => false,
|
|
});
|
|
expect(rejected.blocked).toBe(true);
|
|
|
|
const approved = planPartyRoute(corridorGraph, "mob", [0, 0, 0], [2.5, 0, 2], 100, {
|
|
maxSnapDistance: 1,
|
|
localFinishDistance: 1,
|
|
directSegmentAllowed: () => true,
|
|
});
|
|
expect(approved.blocked).toBe(false);
|
|
});
|
|
|
|
it("rejects a graph segment without agent-radius route clearance", () => {
|
|
const route = planPartyRoute(corridorGraph, "corridor", [0, 0, 0], [2, 0, 2], 100, {
|
|
maxSnapDistance: 1,
|
|
directSegmentAllowed: () => true,
|
|
routeSegmentAllowed: (start, end) => !(start[0] === 2 && end[2] === 2),
|
|
});
|
|
expect(route.blocked).toBe(true);
|
|
});
|
|
|
|
it("accepts an in-range surface finish only when combat sight approves it", () => {
|
|
const options = { maxSnapDistance: 1, localFinishDistance: 1, directSegmentAllowed: () => false };
|
|
const visible = planPartyRoute(corridorGraph, "mob", [0, 0, 0], [2.5, 0, 2], 100, {
|
|
...options, destinationReached: (surface, target) => Math.hypot(surface[0] - target[0], surface[2] - target[2]) <= 1,
|
|
});
|
|
expect(visible.blocked).toBe(false);
|
|
expect(visible.waypoints.at(-1)).toEqual([2, 0, 2]);
|
|
const obstructed = planPartyRoute(corridorGraph, "mob", [0, 0, 0], [2.5, 0, 2], 100, {
|
|
...options, destinationReached: () => false,
|
|
});
|
|
expect(obstructed.blocked).toBe(true);
|
|
});
|
|
|
|
it("advances only one close-spaced waypoint at a corner", () => {
|
|
const route = {
|
|
...emptyPartyRouteState(),
|
|
goalKey: "corner",
|
|
destination: [0.45, 0, 0.45] as const,
|
|
waypoints: [
|
|
[0, 0, 0] as const,
|
|
[0.45, 0, 0] as const,
|
|
[0.45, 0, 0.45] as const,
|
|
],
|
|
};
|
|
const out: [number, number, number] = [0, 0, 0];
|
|
expect(nextPartyRouteWaypoint(route, [0, 0, 0], out, {
|
|
waypointReachedDistance: 0.12,
|
|
})).toBe(true);
|
|
expect(out).toEqual([0.45, 0, 0]);
|
|
});
|
|
|
|
it("advances only through the validated prefix of a route blocked later", () => {
|
|
const route = {
|
|
...emptyPartyRouteState(),
|
|
blocked: true,
|
|
waypoints: [
|
|
[0, 0, 0],
|
|
[1, 0, 0],
|
|
[2, 0, 0],
|
|
] as const,
|
|
};
|
|
const out: [number, number, number] = [9, 9, 9];
|
|
|
|
expect(nextPartyRouteRecoveryWaypoint(
|
|
route,
|
|
[0, 0, 0],
|
|
out,
|
|
(_start, end) => end[0] <= 1,
|
|
)).toBe(true);
|
|
expect(out).toEqual([1, 0, 0]);
|
|
|
|
expect(nextPartyRouteRecoveryWaypoint(
|
|
route,
|
|
[1, 0, 0],
|
|
out,
|
|
(_start, end) => end[0] <= 1,
|
|
)).toBe(false);
|
|
});
|
|
});
|