Files
healer-man/src/game/stockadesNavigation.test.ts
T
2026-09-12 14:25:23 -04:00

175 lines
6.7 KiB
TypeScript

import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
import { BufferAttribute, BufferGeometry, Group, Mesh } from "three";
import { beforeAll, describe, expect, it } from "vitest";
import { requireDungeonDefinition } from "./dungeonRegistry";
import {
findNearestPartyNavigationNode,
findNearestPartyNavigationSurface,
findPartyNavigationPath,
type PartyNavigationGraph,
} from "./partyNavigation";
import { preparePartyNavigationGraph } from "./partyNavigationAssetCache";
import { shouldLoadPartyNavigation } from "./partyNavigationPolicy";
import { planPartyRoute } from "./partyPathing";
import { partyCombatMovementGoal, stepPartyPosition } from "./partyMovement";
import { derivePartySpawnSlots } from "./partySpawnSlots";
const STOCKADES_NAVIGATION = "public/assets/game/manastorm/34-stormwindjail/navigation.glb";
const TANK_RANGE = Object.freeze({ minimum: 0, preferred: 2.9, maximum: 3.15 });
const stockades = requireDungeonDefinition("stormwind-stockade");
let graph: PartyNavigationGraph;
beforeAll(async () => {
await MeshoptDecoder.ready;
const document = await new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder })
.read(STOCKADES_NAVIGATION);
const meshNode = document.getRoot().listNodes().find((node) => node.getMesh());
const primitive = meshNode?.getMesh()?.listPrimitives()[0];
const accessor = primitive?.getAttribute("POSITION");
const sourcePositions = accessor?.getArray();
if (!meshNode || !accessor || !sourcePositions) {
throw new Error("Stormwind Stockade navigation GLB is missing its triangle surface.");
}
if (!(sourcePositions instanceof Int16Array) || !accessor.getNormalized()) {
throw new Error("Stormwind Stockade navigation regression expects normalized Int16 positions.");
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(sourcePositions, 3, accessor.getNormalized()));
const sourceIndices = primitive?.getIndices()?.getArray();
if (sourceIndices) geometry.setIndex(Array.from(sourceIndices));
const mesh = new Mesh(geometry);
mesh.matrixAutoUpdate = false;
mesh.matrix.fromArray(meshNode.getWorldMatrix());
const scene = new Group();
scene.add(mesh);
// Exercise the same quantized-buffer and node-transform path as GLTFLoader
// in the shipping runtime, not a separately pre-expanded test geometry.
graph = preparePartyNavigationGraph("/test/stockades-navigation.glb", scene, stockades.navigationLinks);
geometry.dispose();
});
function distance(left: readonly number[], right: readonly number[]): number {
return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]);
}
describe("Stormwind Stockade party navigation", () => {
it("uses the reviewed warm indoor lighting profile", () => {
expect(stockades.presentation).toMatchObject({
background: "#171815",
fog: { color: "#1b1c18", near: 44 },
ambientLight: { color: "#d8c9aa", intensity: 0.58 },
hemisphereLight: { skyColor: "#cfc3a5", groundColor: "#292820", intensity: 0.86 },
directionalLight: { color: "#ffe0a8", intensity: 1.1, offset: [10, 18, -5] },
});
});
it("loads its independently reviewed, budgeted navigation graph", () => {
expect(stockades.validation).toMatchObject({ status: "unvalidated", navigation: "green" });
expect(stockades.navigationLinks).toHaveLength(3);
expect(shouldLoadPartyNavigation("dungeon", stockades, null)).toBe(true);
expect(graph).toMatchObject({
sourceTriangleCount: 3_538,
componentCount: 31,
});
expect(graph.discardedNodeCount).toBeGreaterThan(0);
});
it("projects every entrance formation slot onto the retained walkable surface", () => {
const slots = derivePartySpawnSlots({
footPosition: stockades.entrance.footPosition,
yaw: stockades.entrance.yaw,
});
expect(slots).toHaveLength(5);
expect(findNearestPartyNavigationNode(graph, stockades.entrance.footPosition)?.distance)
.toBeLessThanOrEqual(6);
for (const slot of slots) {
expect(findNearestPartyNavigationSurface(graph, slot.footPosition, 6)).not.toBeNull();
}
});
it("routes from the shipping entrance to every authored boss objective", () => {
const entrance = findNearestPartyNavigationSurface(
graph,
stockades.entrance.footPosition,
6,
);
expect(entrance).not.toBeNull();
for (const objective of stockades.objectiveOrder) {
const approach = findNearestPartyNavigationSurface(graph, objective.position, 15);
expect(approach, objective.name).not.toBeNull();
expect(findPartyNavigationPath(
graph,
entrance!.position,
approach!.position,
{ maxStartSnapDistance: 0.1, maxEndSnapDistance: 0.1 },
), objective.name).not.toBeNull();
}
});
it("gives the tank a usable route into melee range of the first enemy", () => {
const tankSlot = derivePartySpawnSlots({
footPosition: stockades.entrance.footPosition,
yaw: stockades.entrance.yaw,
}, 2)[1];
const tankStart = findNearestPartyNavigationSurface(graph, tankSlot.footPosition, 6);
expect(tankStart).not.toBeNull();
const firstEnemy = [...stockades.staticSpawns].sort((left, right) => (
distance(left.position, stockades.entrance.footPosition)
- distance(right.position, stockades.entrance.footPosition)
))[0];
const desired: [number, number, number] = [0, 0, 0];
expect(partyCombatMovementGoal(
tankStart!.position,
firstEnemy.position,
true,
TANK_RANGE.minimum,
TANK_RANGE.preferred,
TANK_RANGE.maximum,
desired,
)).toBe("approach");
const route = planPartyRoute(
graph,
`tank:${firstEnemy.id}`,
tankStart!.position,
desired,
0,
{
maxOriginSnapDistance: 3,
maxDestinationSnapDistance: 5,
localFinishDistance: TANK_RANGE.maximum,
directSegmentAllowed: () => true,
},
);
expect(route.blocked).toBe(false);
expect(route.waypoints.length).toBeGreaterThan(1);
let current: [number, number, number] = [...tankStart!.position];
const next: [number, number, number] = [0, 0, 0];
for (const waypoint of route.waypoints) {
while (distance(current, waypoint) > 0.01) {
stepPartyPosition(current, waypoint, 0.5, next);
current = [...next];
}
}
expect(distance(current, firstEnemy.position)).toBeLessThanOrEqual(TANK_RANGE.maximum);
expect(partyCombatMovementGoal(
current,
firstEnemy.position,
true,
TANK_RANGE.minimum,
TANK_RANGE.preferred,
TANK_RANGE.maximum,
desired,
)).toBe("hold");
});
});