Files
healer-man/src/game/partyNavigationDetourArtifact.test.ts
T
2026-10-06 22:25:34 -04:00

130 lines
5.5 KiB
TypeScript

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