forked from phenom/healer-man
130 lines
5.5 KiB
TypeScript
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,
|
|
);
|
|
});
|