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"); }); });