#!/usr/bin/env node import { createHash } from "node:crypto"; import { access, copyFile, mkdir, readFile, readdir, stat, writeFile, } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { NodeIO } from "@gltf-transform/core"; import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; import { getBounds } from "@gltf-transform/functions"; import { MeshoptDecoder } from "meshoptimizer"; import { discoverDungeon, projectRoot, workRoot, } from "./lib.mjs"; import { exportClientDiscoveryTables, exportCreatureSource, } from "./export-source.mjs"; import { CREATURE_RUNTIME_ANIMATION_POLICY, semanticCreatureAnimations, trimCreatureRuntimeAnimations, } from "./runtime-creature-animations.mjs"; const campaignFile = path.join(projectRoot, "dungeon-pipeline", "dungeon-campaign.json"); const recipesRoot = path.join(projectRoot, "dungeon-pipeline", "recipes"); const shippingRoot = path.join(projectRoot, "public", "assets", "game", "dungeons"); const registryFile = path.join( projectRoot, "src", "game", "generated", "fivePlayerDungeonImports.json", ); const manastormRegistryFile = path.join( projectRoot, "src", "game", "generated", "manastormAssetPackages.json", ); 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 writeJson(file, value) { await mkdir(path.dirname(file), { recursive: true }); await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } // Several CreatureDisplayInfo rows name this optional OgreMage overlay, but the // file is absent from both the installed Epoch client and the local clean 3.3.5a // client. Keep each authoritative display/model/body skin and omit only the // broken secondary reference so the exporter can package the native model. const VERIFIED_ABSENT_OPTIONAL_TEXTURES = new Set([ "creature\\ogre\\ogremageskin2copper.blp", "creature\\ogre\\ogremageskin2white.blp", ]); async function sha256(file) { return createHash("sha256").update(await readFile(file)).digest("hex"); } function relativeProjectPath(file) { return path.relative(projectRoot, file).replace(/\\/g, "/"); } function rounded(value) { return Number(value.toFixed(4)); } function slugPart(value) { return String(value ?? "") .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, "") || "unknown"; } function excludedAmbientEntity(entity, bossEntries) { if (bossEntries.has(Number(entity.entry))) return false; if ((Number(entity.unitFlags) & 0x02000002) !== 0) return true; if ([31, 35].includes(Number(entity.faction))) return true; const maximumLevel = Math.max( ...(entity.level ?? []).map(Number).filter(Number.isFinite), 0, ); if (maximumLevel <= 1) return true; return /\b(?:bunny|trigger|invisible|camera|helper|marker|rat|snake|frog|roach|critter|maggot|beetle)\b/i .test(entity.name); } function requiredCreatureSources(draft) { const bossEntries = new Set( (draft.encounterRows ?? []) .map((row) => Number(row.creatureEntry ?? row.creditEntry)) .filter(Boolean), ); const includedEntries = new Set( (draft.entities ?? []) .filter((entity) => !excludedAmbientEntity(entity, bossEntries)) .map((entity) => Number(entity.entry)), ); return (draft.creatureSources ?? []) .filter((source) => includedEntries.has(Number(source.entry))) .map((source) => { const omittedOptionalTextures = (source.skinTextures ?? []).filter( (texture) => VERIFIED_ABSENT_OPTIONAL_TEXTURES.has(String(texture).toLowerCase()), ); if (!omittedOptionalTextures.length) return source; return { ...source, skinTextures: (source.skinTextures ?? []).filter( (texture) => !VERIFIED_ABSENT_OPTIONAL_TEXTURES.has(String(texture).toLowerCase()), ), omittedOptionalTextures, }; }) .sort((left, right) => Number(left.entry) - Number(right.entry)); } async function prepareCreatureRecipe(slug, recipe, draft) { const creatures = requiredCreatureSources(draft); const unresolved = creatures.filter((creature) => ( creature.unresolvedComposite || !creature.displayId || !creature.modelPath )); if (unresolved.length) { throw new Error( `${slug}: required combat models are unresolved: ${unresolved.map((entry) => ( `${entry.name} (${entry.entry})` )).join(", ")}.`, ); } if (!creatures.length) throw new Error(`${slug}: no combat creature models were selected.`); const output = path.join(workRoot, slug, "creature-export.recipe.json"); await writeJson(output, { schemaVersion: 1, slug, title: recipe.title, creatures, }); return { output, creatures }; } let glbIo; async function readGlb(file) { await MeshoptDecoder.ready; glbIo ??= new NodeIO() .registerExtensions(ALL_EXTENSIONS) .registerDependencies({ "meshopt.decoder": MeshoptDecoder }); return glbIo.read(file); } function triangleCount(document) { 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); } async function assetDescriptor(file, id, url, extra = {}) { const document = await readGlb(file); if (!document.getRoot().listScenes().length) { throw new Error(`${relativeProjectPath(file)} has no GLB scene.`); } for (const accessor of document.getRoot().listAccessors()) { const values = accessor.getArray(); if (values && !Array.from(values).every(Number.isFinite)) { throw new Error(`${relativeProjectPath(file)} has non-finite accessor data.`); } } return { id, url, checksum: await sha256(file), byteLength: (await stat(file)).size, triangleCount: triangleCount(document), ...extra, }; } async function packageCreatures(slug, selectedSources, exportResult, target) { const exportedByEntry = new Map( (exportResult.creatures ?? []).map((creature) => [Number(creature.entry), creature]), ); const descriptorsByDisplay = new Map(); const creatureModels = {}; for (const source of selectedSources) { const exported = exportedByEntry.get(Number(source.entry)); if (!exported?.outputGLB || !await exists(exported.outputGLB)) { throw new Error(`${slug}: ${source.name} (${source.entry}) has no exported GLB.`); } let descriptor = descriptorsByDisplay.get(Number(source.displayId)); if (!descriptor) { const sourceGlb = path.resolve(exported.outputGLB); const document = await readGlb(sourceGlb); const scene = document.getRoot().getDefaultScene() ?? document.getRoot().listScenes()[0]; if (!scene) throw new Error(`${slug}: ${source.name} has no exported scene.`); const sourceAnimations = document.getRoot().listAnimations() .map((animation) => animation.getName()) .filter(Boolean); if (!sourceAnimations.length) { throw new Error(`${slug}: ${source.name} has no animation clips.`); } const animationOptimization = await trimCreatureRuntimeAnimations(document); const animations = animationOptimization.retainedNames; const semantics = semanticCreatureAnimations(animations); const missingSemantics = [ !semantics.stand && "stand", !(semantics.walk || semantics.run) && "locomotion", !semantics.attack && "attack", !semantics.death && "death", ].filter(Boolean); const fileName = `display-${source.displayId}-animated.glb`; const output = path.join(target, "creatures", fileName); await mkdir(path.dirname(output), { recursive: true }); await glbIo.write(output, document); const bounds = getBounds(scene); const groundOffset = Math.max(0, -bounds.min[1]); const height = Math.max(0.5, bounds.max[1] - bounds.min[1]); const horizontalSpan = Math.max( bounds.max[0] - bounds.min[0], bounds.max[2] - bounds.min[2], ); descriptor = { url: `/assets/game/dungeons/${slug}/creatures/${fileName}`, rotationY: -Math.PI / 2, groundOffset: rounded(groundOffset), labelHeight: rounded(groundOffset + bounds.max[1] + Math.max(0.35, height * 0.12)), markerRadius: rounded(Math.max(0.65, Math.min(4.5, horizontalSpan * 0.4))), displayId: Number(source.displayId), checksum: await sha256(output), byteLength: (await stat(output)).size, animationClips: animations.length, sourceAnimationClips: sourceAnimations.length, animationOptimization: { policy: CREATURE_RUNTIME_ANIMATION_POLICY, removedClips: animationOptimization.removedNames.length, }, animations: semantics, missingAnimationSemantics: missingSemantics, }; descriptorsByDisplay.set(Number(source.displayId), descriptor); } creatureModels[String(source.entry)] = descriptor; } return creatureModels; } function conversionEntries(conversion, role) { const value = conversion[role]; return Array.isArray(value) ? value : value ? [value] : []; } async function packageEnvironment(slug, recipe, target, legacyPackage) { const work = path.join(workRoot, slug); const conversionFile = path.join(work, "staging", "environment", "conversion-report.json"); const recastFile = path.join(work, "recast-report.json"); if (!await exists(conversionFile) || !await exists(recastFile)) { throw new Error(`${slug}: converted environment or Recast report is missing.`); } const conversion = await readJson(conversionFile); const recast = await readJson(recastFile); if (!["green", "review-required"].includes(conversion.status)) { throw new Error(`${slug}: environment conversion is not reviewable.`); } const chunks = { visual: [], collision: [], navigation: null }; for (const role of ["visual", "collision"]) { const entries = conversionEntries(conversion, role); if (!entries.length) throw new Error(`${slug}: conversion has no ${role} chunks.`); for (let index = 0; index < entries.length; index += 1) { const entry = entries[index]; const source = path.join(path.dirname(conversionFile), entry.file); if (!await exists(source)) throw new Error(`${slug}: missing ${role} ${entry.file}.`); const fileName = entries.length === 1 ? `${role}.glb` : `${role}-${String(index).padStart(3, "0")}.glb`; const output = path.join(target, fileName); await copyFile(source, output); const descriptor = await assetDescriptor( output, entry.id ?? (entries.length === 1 ? role : `${role}-${index}`), `/assets/game/dungeons/${slug}/${fileName}`, { provenance: { kind: "five-player-dungeon-conversion", report: relativeProjectPath(conversionFile), sourceFiles: entry.sources ?? conversion.sources ?? [], }, }, ); if (role === "collision" && descriptor.triangleCount > 100_000) { throw new Error(`${slug}: ${fileName} exceeds 100,000 collision triangles.`); } chunks[role].push(descriptor); } } const navigationSource = path.resolve(projectRoot, recast.output); if (!await exists(navigationSource)) throw new Error(`${slug}: navigation GLB is missing.`); const navigationOutput = path.join(target, "navigation.glb"); await copyFile(navigationSource, navigationOutput); chunks.navigation = await assetDescriptor( navigationOutput, "navigation", `/assets/game/dungeons/${slug}/navigation.glb`, { provenance: { kind: "five-player-dungeon-recast", report: relativeProjectPath(recastFile), }, }, ); if (!legacyPackage?.anchors?.length) { throw new Error(`${slug}: reviewed/provisional navigation anchors are missing.`); } const provenanceFile = path.join(work, "provenance.lock.json"); const provenance = await exists(provenanceFile) ? await readJson(provenanceFile) : {}; return { schemaVersion: 1, id: `five-player-dungeon:assets:${slug}`, mapId: recipe.mapId, location: recipe.title, visual: chunks.visual, collision: chunks.collision, navigation: chunks.navigation, transform: legacyPackage.transform ?? { position: [0, 0, 0], rotation: [0, 0, 0], scale: 1, fromWow: "three(x,y,z)=(-wow.x,wow.z,wow.y)", }, anchors: legacyPackage.anchors, review: { anchors: legacyPackage.review?.anchors ?? "provisional", warnings: [ ...(legacyPackage.review?.warnings ?? []), "Anchor coordinates were retained while the environment source moved from the legacy Manastorm package to the five-player dungeon package.", ], }, source: { clientSnapshot: recipe.clientBuild, mapDirectory: recipe.mapDirectory, checksums: provenance.clientArchiveHashes ?? {}, population: "pinned AzerothCore five-player instance snapshot", }, }; } async function rebuildRegistry() { const imports = []; if (await exists(shippingRoot)) { for (const entry of await readdir(shippingRoot, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const manifest = path.join(shippingRoot, entry.name, "dungeon-pack.json"); if (await exists(manifest)) imports.push(await readJson(manifest)); } } imports.sort((left, right) => left.slug.localeCompare(right.slug)); const registry = { schemaVersion: 1, generatedAt: "deterministic", imports, }; await writeJson(registryFile, registry); return registry; } async function runImport(slug, { refresh }) { if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) { throw new Error(`Invalid dungeon slug: ${slug}`); } const campaign = await readJson(campaignFile); const campaignEntry = campaign.dungeons.find((entry) => entry.slug === slug); if (!campaignEntry) throw new Error(`${slug}: not present in the five-player campaign.`); if (slug === "wailing-caverns") { throw new Error("wailing-caverns already has the native golden-fixture import."); } const recipe = await readJson(path.join(recipesRoot, `${slug}.json`)); if (!process.env.WOW_SERVER_DATA) { process.env.WOW_SERVER_DATA = path.join(workRoot, "server-data"); } const draftFile = path.join(workRoot, slug, "runtime-draft.json"); if (refresh || !await exists(draftFile)) { await exportClientDiscoveryTables(slug); await discoverDungeon(slug); } if (!await exists(draftFile)) throw new Error(`${slug}: runtime draft was not generated.`); const draft = await readJson(draftFile); const prepared = await prepareCreatureRecipe(slug, recipe, draft); const automationFile = path.join(workRoot, slug, "creature-source", "automation-result.json"); if (refresh || !await exists(automationFile)) { const creatureResult = await exportCreatureSource(slug); if (creatureResult.status !== "green") { throw new Error(`${slug}: creature export blocked: ${(creatureResult.blockers ?? []).join(" ")}`); } } const automation = await readJson(automationFile); if (!automation.ok || !automation.animated) { throw new Error(`${slug}: creature exporter did not produce animated models.`); } const manastormRegistry = await readJson(manastormRegistryFile); const legacyPackage = manastormRegistry.packages.find( (entry) => Number(entry.mapId) === Number(recipe.mapId), ); const target = path.join(shippingRoot, slug); await mkdir(target, { recursive: true }); if (!legacyPackage) { throw new Error(`${slug}: optimized canonical map package is missing.`); } const environment = legacyPackage; const creatureModels = await packageCreatures( slug, prepared.creatures, automation, target, ); const manifest = { schemaVersion: 1, slug, title: recipe.title, mapId: recipe.mapId, status: "green", environment, creatureModels, counts: { creatureEntries: Object.keys(creatureModels).length, creatureDisplays: new Set( Object.values(creatureModels).map((model) => model.displayId), ).size, bosses: new Set( (draft.encounterRows ?? []) .map((row) => Number(row.creatureEntry ?? row.creditEntry)) .filter(Boolean), ).size, spawns: (draft.spawns ?? []).filter((spawn) => creatureModels[ String(spawn.entityId).replace("entry-", "") ]).length, }, provenance: { recipe: `dungeon-pipeline/recipes/${slug}.json`, sourceSnapshot: `dungeon-pipeline/work/${slug}/source-snapshot.json`, runtimeDraft: `dungeon-pipeline/work/${slug}/runtime-draft.json`, creatureRecipe: relativeProjectPath(prepared.output), }, }; await writeJson(path.join(target, "dungeon-pack.json"), manifest); await rebuildRegistry(); return manifest; } const argumentsList = process.argv.slice(2); const slug = argumentsList.find((argument) => !argument.startsWith("--")); const refresh = argumentsList.includes("--refresh"); try { if (!slug) { throw new Error( "Usage: npm run dungeon:five-player:import -- [--refresh]", ); } const result = await runImport(slug, { refresh }); console.log(JSON.stringify({ status: result.status, slug: result.slug, mapId: result.mapId, counts: result.counts, output: `public/assets/game/dungeons/${result.slug}/dungeon-pack.json`, }, null, 2)); } catch (error) { console.error(JSON.stringify({ status: "error", slug, error: error instanceof Error ? error.message : String(error), }, null, 2)); process.exitCode = 1; }