503 lines
18 KiB
JavaScript
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;
|
|
}
|