import { createHash } from "node:crypto"; import { access, mkdir, mkdtemp, readFile, rm, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { Document, NodeIO } from "@gltf-transform/core"; import { describe, expect, it } from "vitest"; // Node-only pipeline modules intentionally have no browser-facing declarations. // @ts-expect-error Tested directly by Vitest in its Node environment. import { assertRecastCollisionCoverage, normalizeImportAssets } from "../../scripts/manastorm-assets/compound-stage.mjs"; // @ts-expect-error Tested directly by Vitest in its Node environment. import { importExistingStage } from "../../scripts/manastorm-assets/import-existing-stage.mjs"; // @ts-expect-error Tested directly by Vitest in its Node environment. import { collisionAssetsFromConversionReport } from "../../scripts/dungeon-pipeline/recast-bake.mjs"; const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const fixtureRoot = path.join(projectRoot, "scripts/manastorm-assets/fixtures"); type ConversionEntry = { id?: string; file: string; triangles: number; transform?: { position: [number, number, number]; rotation: [number, number, number]; scale: number; }; }; type ConversionFixture = { dungeonId: string; status: "review-required"; environmentMode: "global-wmo" | "adt-hybrid"; sources: string[]; visual: ConversionEntry | ConversionEntry[]; collision: ConversionEntry[]; }; async function exists(file: string): Promise { try { await access(file); return true; } catch { return false; } } async function sha256(file: string): Promise { const hash = createHash("sha256"); hash.update(await readFile(file)); return hash.digest("hex"); } async function writeTriangleGlb(file: string, triangleCount: number): Promise { const document = new Document(); const buffer = document.createBuffer("fixture-buffer"); const positions = new Float32Array(triangleCount * 9); const indices = new Uint32Array(triangleCount * 3); for (let triangle = 0; triangle < triangleCount; triangle += 1) { const offset = triangle * 9; const x = triangle * 2; positions.set([x, 0, 0, x + 1, 0, 0, x, 0, 1], offset); indices.set([triangle * 3, triangle * 3 + 1, triangle * 3 + 2], triangle * 3); } const positionAccessor = document .createAccessor("positions", buffer) .setType("VEC3") .setArray(positions); const indexAccessor = document .createAccessor("indices", buffer) .setType("SCALAR") .setArray(indices); const primitive = document .createPrimitive() .setAttribute("POSITION", positionAccessor) .setIndices(indexAccessor); const mesh = document.createMesh("fixture").addPrimitive(primitive); document.createScene("fixture").addChild(document.createNode("fixture").setMesh(mesh)); await mkdir(path.dirname(file), { recursive: true }); await new NodeIO().write(file, document); } async function prepareImportFixture(fixtureName: string, slug: string) { const fixture = JSON.parse( await readFile(path.join(fixtureRoot, fixtureName), "utf8"), ) as ConversionFixture; const temporaryProject = await mkdtemp(path.join(tmpdir(), "healer-man-compound-")); const workRoot = path.join(temporaryProject, "dungeon-pipeline/manastorm"); const assetRoot = path.join(temporaryProject, "assets"); const reportsRoot = path.join(temporaryProject, "reports"); await mkdir(path.join(workRoot, "imports"), { recursive: true }); await mkdir(reportsRoot, { recursive: true }); const visualEntries = Array.isArray(fixture.visual) ? fixture.visual : [fixture.visual]; const collisionEntries = fixture.collision; const descriptors = { visual: [] as Record[], collision: [] as Record[] }; for (const [role, entries] of [ ["visual", visualEntries], ["collision", collisionEntries], ] as const) { for (let index = 0; index < entries.length; index += 1) { const entry = entries[index]; const file = path.join(assetRoot, entry.file); await writeTriangleGlb(file, entry.triangles); descriptors[role].push({ id: entry.id ?? `${role}-${index}`, path: path.relative(temporaryProject, file).replace(/\\/g, "/"), checksum: await sha256(file), triangleCount: entry.triangles, transform: entry.transform ?? { position: [0, 0, 0], rotation: [0, 0, 0], scale: 1, }, provenance: { kind: fixture.environmentMode, sourceIndex: index, }, }); } } const navigationFile = path.join(assetRoot, "navigation.glb"); await writeTriangleGlb(navigationFile, 1); const navigationPath = path.relative(temporaryProject, navigationFile).replace(/\\/g, "/"); const conversionFile = path.join(reportsRoot, "conversion-report.json"); const recastFile = path.join(reportsRoot, "recast-report.json"); await writeFile(conversionFile, `${JSON.stringify(fixture, null, 2)}\n`); await writeFile(recastFile, `${JSON.stringify({ schemaVersion: 1, status: "green", inputChunks: descriptors.collision.map((chunk) => chunk.path), output: navigationPath, geometry: { triangles: 1, vertices: 3 }, }, null, 2)}\n`); const commonAnchor = { sourcePosition: [0.2, 0, 0.2], provenance: "authored", source: "Synthetic navigation fixture.", }; const importManifest = { schemaVersion: 1, slug, mapId: fixture.environmentMode === "adt-hybrid" ? 9002 : 9001, reviewStatus: "provisional", warnings: [], coordinateSystem: { units: "meters", upAxis: "+Y", fromWow: "three(x,y,z)=(-wow.x,wow.z,wow.y)", scale: 1, }, sourcePackage: { conversionReport: path.relative(temporaryProject, conversionFile).replace(/\\/g, "/"), navigationReport: path.relative(temporaryProject, recastFile).replace(/\\/g, "/"), evidence: [], }, assets: { visual: descriptors.visual, collision: descriptors.collision, navigation: { id: "navigation", path: navigationPath, checksum: await sha256(navigationFile), triangleCount: 1, transform: { position: [0, 0, 0], rotation: [0, 0, 0], scale: 1, }, provenance: { kind: "synthetic-recast" }, }, }, anchors: [ { id: "player", kind: "player", ...commonAnchor }, { id: "trash", kind: "trash", ...commonAnchor }, { id: "boss", kind: "boss", ...commonAnchor }, { id: "portal", kind: "portal", ...commonAnchor }, { id: "resurrection", kind: "resurrection", ...commonAnchor }, ], }; const manifestFile = path.join(workRoot, "imports", `${slug}.json`); await writeFile(manifestFile, `${JSON.stringify(importManifest, null, 2)}\n`); return { temporaryProject, workRoot, manifestFile, importManifest, visualCount: visualEntries.length, collisionCount: collisionEntries.length, }; } describe("compound Manastorm asset import", () => { it("stages every visual and collision root with transform and provenance metadata", async () => { const fixture = await prepareImportFixture("multi-root-conversion.json", "fixture-multi-root"); try { const report = await importExistingStage("fixture-multi-root", { projectRoot: fixture.temporaryProject, workRoot: fixture.workRoot, }); expect(report.assets.visual).toHaveLength(2); expect(report.assets.collision).toHaveLength(2); const stageSource = JSON.parse(await readFile( path.join(fixture.workRoot, "staging/fixture-multi-root/stage-source.json"), "utf8", )); expect(stageSource.assets.visual).toHaveLength(2); expect(stageSource.assets.collision).toHaveLength(2); expect(stageSource.assets.visual[1].transform.position).toEqual([12, 0, -4]); expect(stageSource.assets.visual[1].provenance).toEqual({ kind: "global-wmo", sourceIndex: 1, }); for (const chunk of [...stageSource.assets.visual, ...stageSource.assets.collision]) { expect(await exists(path.join( fixture.workRoot, "staging/fixture-multi-root", chunk.path, ))).toBe(true); } } finally { await rm(fixture.temporaryProject, { recursive: true, force: true }); } }); it("stages an ADT-hybrid visual with its full collision chunk set", async () => { const fixture = await prepareImportFixture("adt-hybrid-conversion.json", "fixture-adt-hybrid"); try { const normalized = normalizeImportAssets(fixture.importManifest.assets, "fixture-adt-hybrid"); const recast = { inputChunks: normalized.collision.map((chunk: { path: string }) => chunk.path), }; expect(() => assertRecastCollisionCoverage( normalized.collision, recast, "fixture-adt-hybrid", )).not.toThrow(); await importExistingStage("fixture-adt-hybrid", { projectRoot: fixture.temporaryProject, workRoot: fixture.workRoot, }); const stageSource = JSON.parse(await readFile( path.join(fixture.workRoot, "staging/fixture-adt-hybrid/stage-source.json"), "utf8", )); expect(stageSource.assets.visual).toHaveLength(1); expect(stageSource.assets.collision).toHaveLength(3); expect(stageSource.assets.collision.map((chunk: { id: string }) => chunk.id)).toEqual([ "adt-30-30", "adt-30-31", "adt-31-30", ]); } finally { await rm(fixture.temporaryProject, { recursive: true, force: true }); } }); it("leaves the previous staging generation and anchors intact when any chunk fails validation", async () => { const fixture = await prepareImportFixture("multi-root-conversion.json", "fixture-atomic"); try { await importExistingStage("fixture-atomic", { projectRoot: fixture.temporaryProject, workRoot: fixture.workRoot, }); const staging = path.join(fixture.workRoot, "staging/fixture-atomic"); const anchors = path.join(fixture.workRoot, "anchors/fixture-atomic.json"); const stageSourceBefore = await readFile(path.join(staging, "stage-source.json"), "utf8"); const anchorsBefore = await readFile(anchors, "utf8"); await writeFile(path.join(staging, "generation.marker"), "keep-current-generation"); const broken = structuredClone(fixture.importManifest); broken.assets.collision[1].checksum = "0".repeat(64); await writeFile(fixture.manifestFile, `${JSON.stringify(broken, null, 2)}\n`); await expect(importExistingStage("fixture-atomic", { projectRoot: fixture.temporaryProject, workRoot: fixture.workRoot, })).rejects.toThrow(/checksum differs/); expect(await readFile(path.join(staging, "generation.marker"), "utf8")) .toBe("keep-current-generation"); expect(await readFile(path.join(staging, "stage-source.json"), "utf8")) .toBe(stageSourceBefore); expect(await readFile(anchors, "utf8")).toBe(anchorsBefore); } finally { await rm(fixture.temporaryProject, { recursive: true, force: true }); } }); it("rejects navigation reports that omit or add collision chunks", () => { const chunks = [ { path: "collision/a.glb" }, { path: "collision/b.glb" }, ]; expect(() => assertRecastCollisionCoverage( chunks, { inputChunks: ["collision/a.glb"] }, "fixture", )).toThrow(/do not exactly match/); expect(() => assertRecastCollisionCoverage( chunks, { inputChunks: ["collision/a.glb", "collision/b.glb", "collision/c.glb"] }, "fixture", )).toThrow(/do not exactly match/); }); it("builds conversion-report Recast input from every ADT collision chunk", async () => { const conversion = JSON.parse(await readFile( path.join(fixtureRoot, "adt-hybrid-conversion.json"), "utf8", )); const assets = collisionAssetsFromConversionReport( "fixture-adt-hybrid", path.join(projectRoot, "dungeon-pipeline/work/fixture/staging/environment/conversion-report.json"), conversion, ); expect(assets).toHaveLength(3); expect(assets.map((asset: { path: string }) => path.basename(asset.path))).toEqual([ "collision-000.glb", "collision-001.glb", "collision-002.glb", ]); }); });