Files
healer-man/scripts/dungeon-pipeline/import-five-player-dungeon.mjs
T
2026-08-14 15:56:39 -04:00

503 lines
18 KiB
JavaScript

#!/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 -- <slug> [--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;
}