import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { GLTFLoader } from "three-stdlib"; import { MeshoptDecoder } from "three-stdlib"; import { describe, expect, it } from "vitest"; import { DUNGEON_REGISTRY } from "./dungeonRegistry"; import { findNearestPartyNavigationSurface, findPartyNavigationPath, type PartyNavigationDetourTopology, type PartyNavigationGraph } from "./partyNavigation"; import { preparePartyNavigationGraph } from "./partyNavigationAssetCache"; const originDirectory = "src/assets/game/dungeons/the-origin"; const originNavigation = DUNGEON_REGISTRY["the-origin"].assets.navigation; const navigationGlb = `${originDirectory}/${originNavigation?.fileName ?? "the-origin-navigation.glb"}`; const recastReport = `${originDirectory}/recast-report.json`; const aeternalDirectory = "src/assets/game/dungeons/aeternal-circle"; const aeternalNavigation = DUNGEON_REGISTRY["aeternal-circle"].assets.navigation; const aeternalNavigationGlb = `${aeternalDirectory}/${aeternalNavigation?.fileName ?? "aeternal-circle-navigation.glb"}`; function navigationComponentRepresentatives(graph: PartyNavigationGraph) { const visited = new Uint8Array(graph.nodes.length); const representatives = []; for (const node of graph.nodes) { if (visited[node.id]) continue; representatives.push(node.position); const queue = [node.id]; visited[node.id] = 1; for (let cursor = 0; cursor < queue.length; cursor += 1) { const current = graph.nodes[queue[cursor]]; for (const edge of current.neighbors) { if (visited[edge.to]) continue; visited[edge.to] = 1; queue.push(edge.to); } } } return representatives; } describe("packaged Origin Detour navigation GLB", () => { it.skipIf(!originNavigation || !existsSync(navigationGlb) || !existsSync(recastReport))( "loads the current shipping topology and preserves its reviewed connected and disconnected routes", async () => { const [bytes, reportText] = await Promise.all([ readFile(navigationGlb), readFile(recastReport, "utf8"), ]); const report = JSON.parse(reportText); const summary = originNavigation!.detourTopology!; const actualChecksum = createHash("sha256").update(bytes).digest("hex"); expect(actualChecksum).toBe(originNavigation!.checksum); expect(summary.assetChecksum).toBe(actualChecksum); expect(report.detourTopology).toMatchObject({ schemaVersion: summary.schemaVersion, polygonCount: summary.polygonCount, directedLinkCount: summary.directedLinkCount, detailTriangleCount: summary.detailTriangleCount, }); const gltf = await new GLTFLoader().parseAsync( bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), "", ); const topology = gltf.userData.runewakerNavigationTopology as PartyNavigationDetourTopology; const graph = preparePartyNavigationGraph( navigationGlb, gltf.scene, [], [], undefined, false, topology, summary, undefined, actualChecksum, ); const entry = [-370.023404, -13.612361, -256.342010] as const; const lifeLeecher = [-168.465661, -12.445927, -193.003705] as const; const falynum = [-79.280276, -7.834747, 307.181620] as const; expect(graph.nodes).toHaveLength(summary.polygonCount); expect(graph.sourceTriangleCount).toBe(summary.detailTriangleCount); expect(findNearestPartyNavigationSurface(graph, entry, 3)?.distance).toBeLessThan(0.1); expect(findNearestPartyNavigationSurface(graph, lifeLeecher, 8)?.distance).toBeLessThan(0.25); expect(findPartyNavigationPath(graph, entry, lifeLeecher, { maxStartSnapDistance: 3, maxEndSnapDistance: 8, })).not.toBeNull(); expect(findNearestPartyNavigationSurface(graph, falynum, 8)?.distance).toBeLessThan(0.1); expect(findPartyNavigationPath(graph, lifeLeecher, falynum, { maxStartSnapDistance: 8, maxEndSnapDistance: 8, })).toBeNull(); }, 120_000, ); it.skipIf(!aeternalNavigation || !existsSync(aeternalNavigationGlb))( "uses generated Aeternal metadata to retain disconnected source support islands without routing between them", async () => { const bytes = await readFile(aeternalNavigationGlb); const actualChecksum = createHash("sha256").update(bytes).digest("hex"); expect(aeternalNavigation!.navigationComponentPolicy).toBe("all-components"); expect(aeternalNavigation!.checksum).toBe(actualChecksum); const gltf = await new GLTFLoader().setMeshoptDecoder(MeshoptDecoder).parseAsync( bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), "", ); const graph = preparePartyNavigationGraph( aeternalNavigationGlb, gltf.scene, DUNGEON_REGISTRY["aeternal-circle"].navigationLinks, DUNGEON_REGISTRY["aeternal-circle"].traversalRamps, undefined, false, undefined, undefined, aeternalNavigation!.navigationComponentPolicy, actualChecksum, ); const representatives = navigationComponentRepresentatives(graph); expect(graph.sourceTriangleCount).toBe(aeternalNavigation!.triangleCount); expect(graph.discardedNodeCount).toBe(0); expect(representatives.length).toBeGreaterThan(1); expect(findPartyNavigationPath(graph, representatives[0], representatives[1])).toBeNull(); }, 120_000, ); });