Files
healer-man/src/game/manastormCompoundImporter.test.ts
T
2026-08-14 15:56:39 -04:00

332 lines
12 KiB
TypeScript

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<boolean> {
try {
await access(file);
return true;
} catch {
return false;
}
}
async function sha256(file: string): Promise<string> {
const hash = createHash("sha256");
hash.update(await readFile(file));
return hash.digest("hex");
}
async function writeTriangleGlb(file: string, triangleCount: number): Promise<void> {
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<string, unknown>[], collision: [] as Record<string, unknown>[] };
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",
]);
});
});