#!/usr/bin/env node import { createHash } from "node:crypto"; import { access, readFile, readdir, writeFile, } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { NodeIO } from "@gltf-transform/core"; import { assertRecastCollisionCoverage, IDENTITY_CHUNK_TRANSFORM, normalizeChunkTransform, } from "./compound-stage.mjs"; const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); async function exists(file) { try { await access(file); return true; } catch { return false; } } async function readJson(file) { return JSON.parse(await readFile(file, "utf8")); } async function sha256(file) { const hash = createHash("sha256"); hash.update(await readFile(file)); return hash.digest("hex"); } async function inspectTriangleCount(file) { const document = await new NodeIO().read(file); return document.getRoot().listMeshes().reduce((total, mesh) => ( total + mesh.listPrimitives().reduce((meshTotal, primitive) => { const indices = primitive.getIndices()?.getCount(); const positions = primitive.getAttribute("POSITION")?.getCount() ?? 0; return meshTotal + Math.floor((indices ?? positions) / 3); }, 0) ), 0); } function relativeProjectPath(file) { return path.relative(projectRoot, file).replace(/\\/g, "/"); } function rounded(point) { return point.map((value) => Number(value.toFixed(6))); } async function navigationAnalysis(file, { anchorComponentMinimumHeight = null, anchorComponentMaximumHorizontalSpan = null, semanticPositions = null, } = {}) { const document = await new NodeIO().read(file); const primitive = document.getRoot().listMeshes()[0]?.listPrimitives()[0]; const positions = primitive?.getAttribute("POSITION")?.getArray(); const sourceIndices = primitive?.getIndices()?.getArray(); if (!positions) throw new Error(`${relativeProjectPath(file)} has no navigation positions.`); const indices = sourceIndices ? Array.from(sourceIndices) : Array.from({ length: positions.length / 3 }, (_, index) => index); const welded = new Map(); const vertexIds = []; const uniquePoints = []; for (let index = 0; index < positions.length; index += 3) { const point = [positions[index], positions[index + 1], positions[index + 2]]; const key = point.map((value) => Math.round(value * 1000)).join(":"); if (!welded.has(key)) { welded.set(key, welded.size); uniquePoints.push(point); } vertexIds.push(welded.get(key)); } const triangleCount = Math.floor(indices.length / 3); const parent = Array.from({ length: triangleCount }, (_, index) => index); const owner = new Map(); const root = (start) => { let value = start; while (parent[value] !== value) { parent[value] = parent[parent[value]]; value = parent[value]; } return value; }; const union = (left, right) => { const leftRoot = root(left); const rightRoot = root(right); if (leftRoot !== rightRoot) parent[rightRoot] = leftRoot; }; for (let triangle = 0; triangle < triangleCount; triangle += 1) { for (let corner = 0; corner < 3; corner += 1) { const vertex = vertexIds[indices[triangle * 3 + corner]]; if (owner.has(vertex)) union(triangle, owner.get(vertex)); else owner.set(vertex, triangle); } } const components = new Map(); for (let triangle = 0; triangle < triangleCount; triangle += 1) { const component = root(triangle); if (!components.has(component)) components.set(component, []); components.get(component).push(triangle); } const sortedComponents = [...components.values()].sort((left, right) => right.length - left.length); const largest = sortedComponents[0]; if (!largest?.length) throw new Error(`${relativeProjectPath(file)} has no navigation triangles.`); const componentVertices = (component) => { const result = new Set(); for (const triangle of component) { for (let corner = 0; corner < 3; corner += 1) { result.add(vertexIds[indices[triangle * 3 + corner]]); } } return result; }; const axisRange = (points, axis) => { let minimum = Infinity; let maximum = -Infinity; for (const point of points) { minimum = Math.min(minimum, point[axis]); maximum = Math.max(maximum, point[axis]); } return [minimum, maximum]; }; const largestVertices = componentVertices(largest); let anchorComponent = largest; let anchorVertices = largestVertices; let anchorSelection = "largest"; const selectionConstraints = []; if (Number.isFinite(anchorComponentMinimumHeight)) { selectionConstraints.push(`minimum-height:${anchorComponentMinimumHeight}`); } if (Number.isFinite(anchorComponentMaximumHorizontalSpan)) { selectionConstraints.push(`maximum-horizontal-span:${anchorComponentMaximumHorizontalSpan}`); } if (selectionConstraints.length) { for (const component of sortedComponents) { const vertices = componentVertices(component); const points = [...vertices].map((index) => uniquePoints[index]); const minimumHeight = axisRange(points, 1)[0]; const horizontalX = axisRange(points, 0); const horizontalZ = axisRange(points, 2); const horizontalSpan = Math.max( Math.abs(horizontalX[1] - horizontalX[0]), Math.abs(horizontalZ[1] - horizontalZ[0]), ); const satisfiesMinimumHeight = !Number.isFinite(anchorComponentMinimumHeight) || minimumHeight >= anchorComponentMinimumHeight; const satisfiesMaximumSpan = !Number.isFinite(anchorComponentMaximumHorizontalSpan) || horizontalSpan <= anchorComponentMaximumHorizontalSpan; if (satisfiesMinimumHeight && satisfiesMaximumSpan) { anchorComponent = component; anchorVertices = vertices; anchorSelection = selectionConstraints.join(","); break; } } } const selectedVertices = new Set(); for (const triangle of anchorComponent) { for (let corner = 0; corner < 3; corner += 1) { selectedVertices.add(vertexIds[indices[triangle * 3 + corner]]); } } const points = uniquePoints.filter((_, index) => selectedVertices.has(index)); const average = (values) => values.reduce((sum, value) => sum + value, 0) / values.length; const ranges = [0, 1, 2].map((axis) => axisRange(points, axis)); const dominantAxis = [0, 2].sort((left, right) => ( (ranges[right][1] - ranges[right][0]) - (ranges[left][1] - ranges[left][0]) ))[0]; const low = ranges[dominantAxis][0]; const high = ranges[dominantAxis][1]; const span = high - low; const startBand = points.filter((point) => point[dominantAxis] < low + span * 0.08); const endBand = points.filter((point) => point[dominantAxis] > high - span * 0.08); const nearest = (target) => points.reduce((best, point) => ( Math.hypot(...point.map((value, axis) => value - target[axis])) < Math.hypot(...best.map((value, axis) => value - target[axis])) ? point : best )); const centroid = [0, 1, 2].map((axis) => average(points.map((point) => point[axis]))); const startTarget = [0, 1, 2].map((axis) => average(startBand.map((point) => point[axis]))); const endTarget = [0, 1, 2].map((axis) => average(endBand.map((point) => point[axis]))); startTarget[dominantAxis] = low + span * 0.04; endTarget[dominantAxis] = high - span * 0.04; const semanticAnchors = semanticPositions ? Object.fromEntries(Object.entries(semanticPositions).map(([kind, point]) => [ kind, rounded(nearest(point)), ])) : undefined; return { triangles: triangleCount, components: components.size, largestComponentTriangles: largest.length, largestComponentVertices: largestVertices.size, anchorComponentTriangles: anchorComponent.length, anchorComponentVertices: anchorVertices.size, anchorSelection, dominantAxis: dominantAxis === 0 ? "X" : "Z", anchors: { start: rounded(nearest(startTarget)), middle: rounded(nearest(centroid)), end: rounded(nearest(endTarget)), }, ...(semanticAnchors ? { semanticAnchors } : {}), }; } async function placementAnalysis(sourceDirectory) { const files = (await readdir(sourceDirectory)) .filter((name) => name.endsWith("_ModelPlacementInformation.csv")) .sort(); let rows = 0; let unresolvedRows = 0; const uniqueModels = new Set(); const unresolvedModels = new Set(); for (const name of files) { const lines = (await readFile(path.join(sourceDirectory, name), "utf8")) .split(/\r?\n/).slice(1).filter(Boolean); for (const line of lines) { const rawModel = line.split(";")[0]; const model = rawModel.startsWith("\"") && rawModel.endsWith("\"") ? rawModel.slice(1, -1).replace(/""/g, "\"") : rawModel; if (!model) continue; rows += 1; uniqueModels.add(model.toLowerCase()); const resolved = path.join(sourceDirectory, ...model.split(/[\\/]/)); if (!await exists(resolved)) { unresolvedRows += 1; unresolvedModels.add(model.toLowerCase()); } } } return { files: files.length, rows, uniqueModels: uniqueModels.size, unresolvedRows, unresolvedModels: unresolvedModels.size, }; } function anchor(id, kind, sourcePosition, source, note) { return { id, kind, sourcePosition, provenance: "authored", source, note, }; } function reportEntries(value) { return Array.isArray(value) ? value : value ? [value] : []; } function epochAdtBasis(position) { return [position[2], position[1], -position[0]]; } function semanticRuntimePositions(runtimeDraft) { if (!runtimeDraft?.entrance) return null; const player = epochAdtBasis(runtimeDraft.entrance); const mappedBosses = (runtimeDraft.bossCandidates ?? []).map((spawn) => ( epochAdtBasis(spawn.position) )); const distanceSquared = (left, right) => ( (left[0] - right[0]) ** 2 + (left[2] - right[2]) ** 2 ); const boss = mappedBosses.sort((left, right) => ( distanceSquared(right, player) - distanceSquared(left, player) ))[0]; if (!boss) return null; const middleTarget = player.map((value, axis) => (value + boss[axis]) / 2); const mappedTrash = (runtimeDraft.spawns ?? []) .filter((spawn) => !(runtimeDraft.bossCandidates ?? []).some( (bossSpawn) => bossSpawn.id === spawn.id, )) .map((spawn) => epochAdtBasis(spawn.position)); const trash = mappedTrash.sort((left, right) => ( distanceSquared(left, middleTarget) - distanceSquared(right, middleTarget) ))[0] ?? middleTarget; return { player, trash, boss }; } async function conversionAssetDescriptor({ conversion, conversionFile, entry, index, count, role, }) { const file = path.join(path.dirname(conversionFile), entry.file); if (!await exists(file)) throw new Error(`${conversion.dungeonId}: missing ${role} chunk ${entry.file}.`); const actualTriangles = await inspectTriangleCount(file); if (role !== "visual" && entry.triangles !== undefined && entry.triangles !== actualTriangles) { throw new Error( `${conversion.dungeonId}: ${role} chunk ${entry.file} reports ${entry.triangles} triangles but contains ${actualTriangles}.`, ); } const id = entry.id ?? (count === 1 ? role : `${role}-${String(index).padStart(3, "0")}`); return { id, path: relativeProjectPath(file), checksum: await sha256(file), triangleCount: actualTriangles, ...(role === "visual" && entry.triangles !== undefined ? { instanceTriangleCount: entry.triangles } : {}), transform: normalizeChunkTransform( entry.transform ?? IDENTITY_CHUNK_TRANSFORM, `${conversion.dungeonId}: ${role} chunk ${id}`, ), provenance: { kind: "blender-conversion-report", report: relativeProjectPath(conversionFile), sourceFiles: entry.sources ?? conversion.sources ?? [], ...(entry.provenance ?? {}), }, }; } export async function scaffold(manastormSlug, dungeonSlug) { const manastormRecipeFile = path.join( projectRoot, "dungeon-pipeline/manastorm/recipes", `${manastormSlug}.json`, ); const manastormRecipe = await readJson(manastormRecipeFile); const dungeonRecipe = await readJson(path.join( projectRoot, "dungeon-pipeline/recipes", `${dungeonSlug}.json`, )); const work = path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons", dungeonSlug); const sourceDirectory = path.join(work, "source-export"); const conversionFile = path.join(work, "staging/environment/conversion-report.json"); const recastFile = path.join(work, "recast-report.json"); const conversion = await readJson(conversionFile); const recast = await readJson(recastFile); if (!["green", "review-required"].includes(conversion.status)) { throw new Error(`${dungeonSlug}: conversion is not reviewable.`); } const visualEntries = reportEntries(conversion.visual); const collisionEntries = reportEntries(conversion.collision); if (!visualEntries.length) throw new Error(`${dungeonSlug}: conversion has no visual chunks.`); if (!collisionEntries.length) throw new Error(`${dungeonSlug}: conversion has no collision chunks.`); const visualAssets = await Promise.all(visualEntries.map((entry, index) => conversionAssetDescriptor({ conversion, conversionFile, entry, index, count: visualEntries.length, role: "visual", }))); const collisionAssets = await Promise.all(collisionEntries.map((entry, index) => conversionAssetDescriptor({ conversion, conversionFile, entry, index, count: collisionEntries.length, role: "collision", }))); assertRecastCollisionCoverage(collisionAssets, recast, dungeonSlug); const navigation = path.resolve(projectRoot, recast.output); if (!await exists(navigation)) throw new Error(`${dungeonSlug}: navigation GLB is missing.`); const navigationTriangles = await inspectTriangleCount(navigation); if (recast.geometry?.triangles !== undefined && recast.geometry.triangles !== navigationTriangles) { throw new Error(`${dungeonSlug}: navigation report triangle count does not match its GLB.`); } const navigationAsset = { id: "navigation", path: relativeProjectPath(navigation), checksum: await sha256(navigation), triangleCount: navigationTriangles, transform: normalizeChunkTransform( recast.transform ?? IDENTITY_CHUNK_TRANSFORM, `${dungeonSlug}: navigation chunk`, ), provenance: { kind: "recast-navigation-report", report: relativeProjectPath(recastFile), collisionChunkIds: collisionAssets.map((asset) => asset.id), collisionInputPaths: [...recast.inputChunks], }, }; const automationFile = path.join(sourceDirectory, "automation-result.json"); const automation = await readJson(automationFile); if (!automation.ok || automation.missingDependencies?.length) { throw new Error(`${dungeonSlug}: source export has unresolved dependencies.`); } const sourceObjects = await Promise.all((conversion.sources ?? []).map(async (source) => { const file = path.join(sourceDirectory, source); if (!await exists(file)) throw new Error(`${dungeonSlug}: conversion source ${source} is missing.`); return file; })); if (!sourceObjects.length) throw new Error(`${dungeonSlug}: conversion has no source objects.`); const rootWmoRecords = (automation.manifest ?? []).filter((entry) => entry.type === "WMO"); const rootWmos = (await Promise.all(rootWmoRecords.map(async (entry) => { const file = path.join(sourceDirectory, entry.file); return await exists(file) ? file : null; }))).filter(Boolean); if (conversion.environmentMode === "global-wmo" && !rootWmos.length) { throw new Error(`${dungeonSlug}: source WMO evidence is missing.`); } const runtimeDraftFile = path.join(work, "runtime-draft.json"); const runtimeDraft = await exists(runtimeDraftFile) ? await readJson(runtimeDraftFile) : null; const semanticPositions = dungeonRecipe.clientBuild === "3.3.5a-Epoch" && conversion.environmentMode === "adt-hybrid" ? semanticRuntimePositions(runtimeDraft) : null; const nav = await navigationAnalysis(navigation, { anchorComponentMinimumHeight: dungeonRecipe.navigation?.anchorComponentMinimumHeight, anchorComponentMaximumHorizontalSpan: dungeonRecipe.navigation?.anchorComponentMaximumHorizontalSpan, semanticPositions, }); const placements = await placementAnalysis(sourceDirectory); const warnings = [ nav.anchorSelection === "largest" ? `The Recast result has ${nav.components} connected components; provisional anchors stay on the largest ${nav.largestComponentTriangles.toLocaleString("en-US")}-triangle component.` : `The Recast result has ${nav.components} connected components; provisional anchors use the largest component satisfying ${nav.anchorSelection}, with ${nav.anchorComponentTriangles.toLocaleString("en-US")} triangles (the largest overall component has ${nav.largestComponentTriangles.toLocaleString("en-US")}).`, "Entrance, boss, portal, and resurrection semantics require encounter-data and playtest review.", ...(semanticPositions ? ["Player, trash, and boss anchors were projected from AzerothCore server coordinates onto the selected Recast component."] : []), ...(conversion.warnings ?? []), ...(dungeonRecipe.warnings ?? []), ]; const cappedCollision = collisionAssets.filter((asset) => asset.triangleCount >= 99_999); if (cappedCollision.length) { warnings.push( `${cappedCollision.length} collision ${cappedCollision.length === 1 ? "chunk reached" : "chunks reached"} the 100,000-triangle conversion cap.`, ); } if (placements.unresolvedRows) { warnings.push( `${placements.unresolvedRows.toLocaleString("en-US")} of ${placements.rows.toLocaleString("en-US")} decorative placement rows reference ${placements.unresolvedModels.toLocaleString("en-US")} unavailable source-root OBJ paths; architectural WMO/collision completeness is unaffected, but decoration requires visual review.`, ); } const start = nav.semanticAnchors?.player ?? nav.anchors.start; const middle = nav.semanticAnchors?.trash ?? nav.anchors.middle; const end = nav.semanticAnchors?.boss ?? nav.anchors.end; const anchorSource = semanticPositions ? "AzerothCore server coordinate projected to the selected Recast component." : "Geometry-derived provisional Recast component anchor."; const config = { schemaVersion: 1, slug: manastormSlug, mapId: manastormRecipe.mapId, reviewStatus: "provisional", warnings, coordinateSystem: { units: "meters", upAxis: "+Y", fromWow: "three(x,y,z)=(-wow.x,wow.z,wow.y)", scale: 1, }, sourcePackage: { conversionReport: relativeProjectPath(conversionFile), navigationReport: relativeProjectPath(recastFile), omissions: automation.sourceOmissions ?? [], fallbacks: automation.sourceFallbacks ?? [], evidence: [ { id: "wow-export-result", path: relativeProjectPath(automationFile) }, ...sourceObjects.map((file, index) => ({ id: `wow-export-obj-${String(index).padStart(3, "0")}`, path: relativeProjectPath(file), })), ...rootWmos.map((file, index) => ({ id: `client-root-wmo-${String(index).padStart(3, "0")}`, path: relativeProjectPath(file), })), { id: "blender-conversion-report", path: relativeProjectPath(conversionFile) }, { id: "recast-navigation-report", path: relativeProjectPath(recastFile) }, ], }, assets: { visual: visualAssets, collision: collisionAssets, navigation: navigationAsset, }, navigationAnalysis: nav, placementAnalysis: placements, anchors: [ anchor("player-provisional-main-component", "player", start, anchorSource, "Provisional projected entrance."), anchor("trash-provisional-main-component", "trash", middle, anchorSource, "Provisional projected trash anchor."), anchor("boss-provisional-main-component", "boss", end, anchorSource, "Provisional projected boss anchor."), anchor("portal-provisional-main-component", "portal", start, "Reuses the provisional player anchor for the stage exit.", "Provisional until portal placement is playtested."), anchor("resurrection-provisional-main-component", "resurrection", start, "Reuses the provisional player anchor as a safe navigation fallback.", "Provisional until resurrection placement is playtested."), ], }; const output = path.join( projectRoot, "dungeon-pipeline/manastorm/imports", `${manastormSlug}.json`, ); await writeFile(output, `${JSON.stringify(config, null, 2)}\n`, "utf8"); return { status: "green", mapId: config.mapId, slug: manastormSlug, output: relativeProjectPath(output), navigation: nav, placements, warnings, }; } async function main() { const [, , manastormSlug, dungeonSlug] = process.argv; try { if (!manastormSlug || !dungeonSlug) { throw new Error("Usage: node scripts/manastorm-assets/scaffold-converted-import.mjs MANASTORM_SLUG DUNGEON_SLUG"); } console.log(JSON.stringify(await scaffold(manastormSlug, dungeonSlug), null, 2)); } catch (error) { console.error(JSON.stringify({ status: "error", manastormSlug, dungeonSlug, error: error instanceof Error ? error.message : String(error), }, null, 2)); process.exitCode = 1; } } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { await main(); }