737 lines
31 KiB
JavaScript
737 lines
31 KiB
JavaScript
import { spawn } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { NodeIO } from "@gltf-transform/core";
|
|
import { BufferAttribute, BufferGeometry, Matrix4, Quaternion, Vector3 } from "three";
|
|
import { MeshBVH } from "three-mesh-bvh";
|
|
import {
|
|
constantCase,
|
|
loadPopulationRecipe,
|
|
pipelineDirectory,
|
|
projectPath,
|
|
projectRoot,
|
|
publicUrlFor,
|
|
resolveProject,
|
|
sourceRootFor,
|
|
} from "./lib/recipe.mjs";
|
|
import { orderBossObjectives } from "./lib/objective-order.mjs";
|
|
|
|
const argv = process.argv.slice(2);
|
|
const fallbackRecipe = path.join(pipelineDirectory, "recipes", "forsaken-abbey-population.json");
|
|
const { file: recipeFile, recipe } = await loadPopulationRecipe(argv, fallbackRecipe);
|
|
const sourceRoot = sourceRootFor(recipe);
|
|
const resourceRoot = path.join(sourceRoot, recipe.source.resourceRoot);
|
|
const workRoot = path.join(projectRoot, "runewaker-export-work", recipe.dungeonId + "-population");
|
|
const actorOutput = resolveProject(recipe.files.actorOutput);
|
|
const actorCacheDirectory = path.join(projectRoot, "runewaker-export-work", "shared-actor-cache");
|
|
const nativeExporter = path.join(pipelineDirectory, "native", "bin", "runewaker_model_exporter.exe");
|
|
const blender = path.resolve(process.env.BLENDER_BIN ?? path.join(
|
|
process.env.USERPROFILE ?? "", "blender-portable", "blender-5.0.1-windows-x64", "blender.exe",
|
|
));
|
|
const texconv = path.resolve(process.env.TEXCONV_BIN ?? path.join(
|
|
process.env.LOCALAPPDATA ?? "", "Microsoft", "WinGet", "Packages",
|
|
"Microsoft.DirectXTex.Texconv_Microsoft.Winget.Source_8wekyb3d8bbwe", "texconv.exe",
|
|
));
|
|
const blenderScript = path.join(pipelineDirectory, "blender", "convert-runewaker-actor.py");
|
|
const rigInjector = path.join(pipelineDirectory, "inject-actor-rig.mjs");
|
|
const gltfTransform = path.join(projectRoot, "node_modules", "@gltf-transform", "cli", "bin", "cli.js");
|
|
const khronosValidator = path.join(projectRoot, "scripts", "manastorm-assets", "validate-package-glbs.cjs");
|
|
const spellCatalogFile = path.join(
|
|
pipelineDirectory,
|
|
"snapshots",
|
|
"runewaker-npc-spell-catalog.json",
|
|
);
|
|
const paperdollCatalogFile = path.join(
|
|
pipelineDirectory,
|
|
"snapshots",
|
|
"runewaker-paperdoll-appearances.json",
|
|
);
|
|
const runtimePrefix = recipe.runtimeIdPrefix ?? "rw-" + recipe.dungeonId;
|
|
const actorCacheVersion = "gr2-native-animation-v5-z-reflection-complete-cast-semantics-30hz";
|
|
|
|
function run(executable, argumentsList, label) {
|
|
return new Promise((resolve, reject) => {
|
|
console.log("\n[runewaker-population] " + label);
|
|
const child = spawn(executable, argumentsList, {
|
|
cwd: projectRoot,
|
|
stdio: "inherit",
|
|
windowsHide: true,
|
|
});
|
|
child.once("error", reject);
|
|
child.once("exit", (code) => {
|
|
if (code === 0) resolve();
|
|
else reject(new Error(label + " exited with code " + code + "."));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function requireFile(file, guidance) {
|
|
try {
|
|
if (!(await stat(file)).isFile()) throw new Error();
|
|
} catch {
|
|
throw new Error(guidance + "\nMissing: " + file);
|
|
}
|
|
}
|
|
|
|
async function sha256(file) {
|
|
return createHash("sha256").update(await readFile(file)).digest("hex");
|
|
}
|
|
|
|
function normalizeModelPath(value) {
|
|
const separator = String.fromCharCode(92);
|
|
return String(value ?? "").replaceAll(separator, "/").replace(/^\/+/, "").toLowerCase();
|
|
/* Superseded by the character-code implementation above.
|
|
return String(value ?? "").replaceAll("\\", "/").replace(/^\\/+/, "").toLowerCase();
|
|
*/
|
|
}
|
|
|
|
function paperdollAssetId(actorId, imageId) {
|
|
return actorId + "-image-" + Number(imageId);
|
|
}
|
|
|
|
function paperdollAppearanceKey(sourceModel, imageId) {
|
|
return normalizeModelPath(sourceModel) + "\0" + Number(imageId);
|
|
}
|
|
|
|
function paperdollAppearanceSignature(appearance) {
|
|
return appearance
|
|
? createHash("sha256").update(JSON.stringify(appearance)).digest("hex")
|
|
: null;
|
|
}
|
|
|
|
async function writePaperdollAssembly(file, appearance) {
|
|
const lines = [
|
|
"# HealerMan RuneWaker paperdoll assembly schemaVersion=1",
|
|
["@skin", "", appearance.skinColor ?? 0, 0].join("\t"),
|
|
["@hair", "", appearance.hairColor ?? 0, 0].join("\t"),
|
|
];
|
|
for (const part of ["head", "hair", "helmet", "shoulder", "torso", "hand", "belt", "leg", "foot", "back"]) {
|
|
const component = appearance.components?.[part] ?? {};
|
|
lines.push([
|
|
part,
|
|
String(component.name ?? ""),
|
|
Number(component.mainColor ?? 0),
|
|
Number(component.offColor ?? 0),
|
|
].join("\t"));
|
|
}
|
|
await writeFile(file, lines.join("\n") + "\n", "utf8");
|
|
}
|
|
|
|
async function prepareActor(actor, appearance = null, appearanceCatalogHash = null) {
|
|
const normalizedSourceModel = normalizeModelPath(actor.sourceModel);
|
|
const customAssemblyFile = actor.paperdollAssembly
|
|
? resolveProject(actor.paperdollAssembly)
|
|
: null;
|
|
const appearanceSignature = appearance
|
|
? paperdollAppearanceSignature(appearance)
|
|
: customAssemblyFile
|
|
? await sha256(customAssemblyFile)
|
|
: null;
|
|
const assetId = appearance ? paperdollAssetId(actor.id, appearance.imageId) : actor.id;
|
|
const cacheMaterial = actorCacheVersion + "\0" + normalizedSourceModel
|
|
+ (appearanceSignature ? "\0paperdoll\0" + appearanceSignature : "");
|
|
const cacheKey = createHash("sha256")
|
|
.update(cacheMaterial)
|
|
.digest("hex")
|
|
.slice(0, 24);
|
|
const cachedGlb = path.join(actorCacheDirectory, cacheKey + ".glb");
|
|
const cachedMetadata = path.join(actorCacheDirectory, cacheKey + ".json");
|
|
const shipping = path.join(actorOutput, assetId + ".glb");
|
|
await Promise.all([
|
|
mkdir(actorOutput, { recursive: true }),
|
|
mkdir(actorCacheDirectory, { recursive: true }),
|
|
]);
|
|
try {
|
|
const cached = JSON.parse(await readFile(cachedMetadata, "utf8"));
|
|
await stat(cachedGlb);
|
|
if (cached.sourceModel === normalizedSourceModel
|
|
&& (cached.appearanceSignature ?? null) === appearanceSignature) {
|
|
await copyFile(cachedGlb, shipping);
|
|
return {
|
|
...cached,
|
|
id: assetId,
|
|
baseActorId: actor.id,
|
|
...(appearance ? { sourceImageId: appearance.imageId } : {}),
|
|
sourceModel: actor.sourceModel,
|
|
fileName: assetId + ".glb",
|
|
url: publicUrlFor(shipping),
|
|
rotationY: actor.runtimeRotationY ?? 0,
|
|
};
|
|
}
|
|
} catch {
|
|
// Cache miss; convert from the preserved ROS below.
|
|
}
|
|
const actorRoot = path.join(workRoot, "actors", assetId);
|
|
const raw = path.join(actorRoot, "raw");
|
|
const prepared = path.join(actorRoot, "prepared");
|
|
const converted = path.join(actorRoot, "converted");
|
|
await rm(actorRoot, { recursive: true, force: true });
|
|
await mkdir(raw, { recursive: true });
|
|
await mkdir(path.join(prepared, "textures"), { recursive: true });
|
|
await mkdir(converted, { recursive: true });
|
|
const assemblyFile = appearance
|
|
? path.join(actorRoot, "paperdoll-assembly.tsv")
|
|
: customAssemblyFile;
|
|
if (appearance) await writePaperdollAssembly(assemblyFile, appearance);
|
|
const exporterArguments = [resourceRoot, path.normalize(actor.sourceModel), raw];
|
|
if (assemblyFile) exporterArguments.push(assemblyFile);
|
|
await run(nativeExporter, exporterArguments, "export actor " + assetId);
|
|
const ddsFiles = (await readdir(path.join(raw, "textures")))
|
|
.filter((file) => file.toLowerCase().endsWith(".dds"))
|
|
.sort()
|
|
.map((file) => path.join(raw, "textures", file));
|
|
if (!ddsFiles.length) throw new Error(assetId + " exported without DDS textures.");
|
|
await run(
|
|
texconv,
|
|
["-ft", "PNG", "-o", path.join(prepared, "textures"), "-y", "--permissive", ...ddsFiles],
|
|
"decode " + assetId + " textures",
|
|
);
|
|
await copyFile(path.join(raw, "scene.obj"), path.join(prepared, "scene.obj"));
|
|
await copyFile(path.join(raw, "export-report.json"), path.join(prepared, "export-report.json"));
|
|
const sourceMtl = await readFile(path.join(raw, "scene.mtl"), "utf8");
|
|
await writeFile(
|
|
path.join(prepared, "scene.mtl"),
|
|
sourceMtl.replace(/^(map_Kd\s+.+)\.dds\s*$/gim, "$1.png"),
|
|
"utf8",
|
|
);
|
|
const rawGlb = path.join(converted, assetId + ".raw.glb");
|
|
const blenderReportFile = path.join(converted, assetId + ".blender-report.json");
|
|
await run(
|
|
blender,
|
|
[
|
|
"--background", "--factory-startup", "--python", blenderScript, "--",
|
|
path.join(prepared, "scene.obj"), rawGlb, blenderReportFile, assetId,
|
|
String(recipe.coordinateSystem.metersPerSourceUnit),
|
|
],
|
|
"convert " + assetId + " to GLB",
|
|
);
|
|
const rigMetadata = JSON.parse(await readFile(path.join(raw, "actor-rig.json"), "utf8"));
|
|
let optimizationInput = rawGlb;
|
|
let animationReport = { animations: [] };
|
|
if (rigMetadata.status === "animated") {
|
|
optimizationInput = path.join(converted, assetId + ".animated.raw.glb");
|
|
const animationReportFile = path.join(converted, assetId + ".animation-report.json");
|
|
await run(
|
|
process.execPath,
|
|
[
|
|
rigInjector,
|
|
rawGlb,
|
|
path.join(raw, "actor-rig.json"),
|
|
optimizationInput,
|
|
animationReportFile,
|
|
],
|
|
"inject original GR2 animations into " + assetId,
|
|
);
|
|
animationReport = JSON.parse(await readFile(animationReportFile, "utf8"));
|
|
}
|
|
const optimized = path.join(converted, assetId + ".glb");
|
|
const optimizeArguments = [
|
|
gltfTransform,
|
|
"optimize",
|
|
optimizationInput,
|
|
optimized,
|
|
"--compress",
|
|
"meshopt",
|
|
"--meshopt-level",
|
|
"high",
|
|
"--texture-size",
|
|
"1024",
|
|
];
|
|
if (animationReport.animations.length) {
|
|
optimizeArguments.push("--flatten", "false", "--join", "false", "--instance", "false");
|
|
}
|
|
await run(
|
|
process.execPath,
|
|
optimizeArguments,
|
|
"meshopt-compress " + assetId,
|
|
);
|
|
await copyFile(optimized, shipping);
|
|
const conversion = JSON.parse(await readFile(blenderReportFile, "utf8"));
|
|
const reusable = {
|
|
sourceModel: normalizedSourceModel,
|
|
appearanceSignature,
|
|
sourceModelSha256: await sha256(path.join(resourceRoot, actor.sourceModel)),
|
|
checksum: await sha256(shipping),
|
|
size: (await stat(shipping)).size,
|
|
triangles: conversion.triangles,
|
|
bounds: conversion.bounds,
|
|
...conversion.runtime,
|
|
animations: animationReport.animations,
|
|
...(appearance ? {
|
|
paperdollAppearance: {
|
|
catalog: projectPath(paperdollCatalogFile),
|
|
catalogContentSha256: appearanceCatalogHash,
|
|
imageId: appearance.imageId,
|
|
sourceRow: appearance.sourceRow,
|
|
components: appearance.components,
|
|
skinColor: appearance.skinColor,
|
|
hairColor: appearance.hairColor,
|
|
requiresColorLayerBake: appearance.requiresColorLayerBake === true,
|
|
texturePolicy: appearance.requiresColorLayerBake
|
|
? "source base texture; original runtime layer colors retained as provenance pending mask bake"
|
|
: "source appearance texture",
|
|
},
|
|
} : customAssemblyFile ? {
|
|
paperdollAssembly: projectPath(customAssemblyFile),
|
|
paperdollAssemblySha256: appearanceSignature,
|
|
texturePolicy: "source-native modular starter parts",
|
|
} : {}),
|
|
};
|
|
await Promise.all([
|
|
copyFile(optimized, cachedGlb),
|
|
writeFile(cachedMetadata, JSON.stringify(reusable, null, 2) + "\n", "utf8"),
|
|
]);
|
|
return {
|
|
...reusable,
|
|
id: assetId,
|
|
baseActorId: actor.id,
|
|
...(appearance ? { sourceImageId: appearance.imageId } : {}),
|
|
sourceModel: actor.sourceModel,
|
|
fileName: assetId + ".glb",
|
|
url: publicUrlFor(shipping),
|
|
rotationY: actor.runtimeRotationY ?? 0,
|
|
};
|
|
}
|
|
|
|
async function buildActors() {
|
|
await Promise.all([
|
|
requireFile(nativeExporter, "Build the preserved RuneWaker exporter first."),
|
|
requireFile(blender, "Set BLENDER_BIN to Blender 5."),
|
|
requireFile(texconv, "Set TEXCONV_BIN to Microsoft texconv."),
|
|
requireFile(rigInjector, "Restore scripts/runewaker-pipeline/inject-actor-rig.mjs."),
|
|
requireFile(gltfTransform, "Run npm install in HealerMan."),
|
|
requireFile(paperdollCatalogFile, "Run npm run runewaker:paperdolls:refresh once to create the portable appearance catalog."),
|
|
]);
|
|
const paperdollCatalog = JSON.parse(await readFile(paperdollCatalogFile, "utf8"));
|
|
if (paperdollCatalog.status !== "portable"
|
|
|| paperdollCatalog.safety?.runtimeSqlDependency !== false
|
|
|| !Array.isArray(paperdollCatalog.appearances)) {
|
|
throw new Error("RuneWaker paperdoll appearance catalog safety/provenance contract is invalid.");
|
|
}
|
|
const appearances = new Map(paperdollCatalog.appearances.map((appearance) => [
|
|
paperdollAppearanceKey(appearance.sourceModel, appearance.imageId),
|
|
appearance,
|
|
]));
|
|
const paperdollModels = new Set(paperdollCatalog.appearances.map((appearance) => (
|
|
normalizeModelPath(appearance.sourceModel)
|
|
)));
|
|
const assets = [];
|
|
const proceduralFallbacks = [];
|
|
await mkdir(actorOutput, { recursive: true });
|
|
for (const actor of recipe.actors) {
|
|
const normalizedSourceModel = normalizeModelPath(actor.sourceModel);
|
|
const requestedImageIds = [...new Set(recipe.templates
|
|
.filter((template) => template.actor === actor.id
|
|
&& ["combat", "boss"].includes(template.classification)
|
|
&& Number(template.source?.imageId) > 0)
|
|
.map((template) => Number(template.source.imageId)))]
|
|
.sort((left, right) => left - right);
|
|
if (paperdollModels.has(normalizedSourceModel) && requestedImageIds.length) {
|
|
for (const imageId of requestedImageIds) {
|
|
const appearance = appearances.get(paperdollAppearanceKey(actor.sourceModel, imageId));
|
|
if (!appearance) {
|
|
throw new Error("Paperdoll catalog is missing " + actor.sourceModel + " image " + imageId + ".");
|
|
}
|
|
assets.push(await prepareActor(actor, appearance, paperdollCatalog.contentSha256));
|
|
}
|
|
continue;
|
|
}
|
|
try {
|
|
assets.push(await prepareActor(actor));
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
if (!/export actor .+ exited with code 7\.$/.test(message)) throw error;
|
|
proceduralFallbacks.push({
|
|
id: actor.id,
|
|
sourceModel: actor.sourceModel,
|
|
reason: "The preserved ROS is a runtime/dynamic display container and exports no static triangle meshes.",
|
|
});
|
|
console.warn("[runewaker-population] " + actor.id + " has no static mesh; using the procedural runtime visual.");
|
|
}
|
|
}
|
|
if (assets.length) {
|
|
await run(process.execPath, [khronosValidator, actorOutput], "validate creature GLBs with Khronos");
|
|
}
|
|
const manifest = {
|
|
schemaVersion: 2,
|
|
dungeonId: recipe.dungeonId,
|
|
status: "green",
|
|
animationPolicy: assets.some((asset) => asset.animations?.length)
|
|
? "authentic ROS models with original sampled RAS skeletal animations"
|
|
: "authentic static ROS models with HealerMan procedural movement",
|
|
paperdollCatalog: {
|
|
file: projectPath(paperdollCatalogFile),
|
|
contentSha256: paperdollCatalog.contentSha256,
|
|
appearanceAssets: assets.filter((asset) => asset.sourceImageId).length,
|
|
unresolvedColorLayerBakes: assets.filter((asset) => (
|
|
asset.paperdollAppearance?.requiresColorLayerBake
|
|
)).length,
|
|
},
|
|
proceduralFallbacks,
|
|
assets,
|
|
};
|
|
const manifestFile = resolveProject(recipe.files.actorManifest);
|
|
await mkdir(path.dirname(manifestFile), { recursive: true });
|
|
await writeFile(manifestFile, JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
|
return manifest;
|
|
}
|
|
|
|
async function navigationProjector() {
|
|
const navigationFile = resolveProject(recipe.files.navigation);
|
|
await requireFile(navigationFile, "Build the dungeon environment/navigation first.");
|
|
const document = await new NodeIO().read(navigationFile);
|
|
const positions = [];
|
|
const vertex = new Vector3();
|
|
for (const node of document.getRoot().listNodes()) {
|
|
const mesh = node.getMesh();
|
|
if (!mesh) continue;
|
|
const world = new Matrix4().fromArray(node.getWorldMatrix());
|
|
for (const primitive of mesh.listPrimitives()) {
|
|
const source = primitive.getAttribute("POSITION")?.getArray();
|
|
if (!source) continue;
|
|
const sourceIndices = primitive.getIndices()?.getArray();
|
|
const indices = sourceIndices
|
|
? Array.from(sourceIndices)
|
|
: Array.from({ length: source.length / 3 }, (_, index) => index);
|
|
for (const index of indices) {
|
|
vertex.fromArray(source, index * 3).applyMatrix4(world);
|
|
positions.push(vertex.x, vertex.y, vertex.z);
|
|
}
|
|
}
|
|
}
|
|
if (!positions.length) throw new Error("Navigation GLB contains no triangles.");
|
|
const geometry = new BufferGeometry();
|
|
geometry.setAttribute("position", new BufferAttribute(new Float32Array(positions), 3));
|
|
const bvh = new MeshBVH(geometry);
|
|
return {
|
|
project(point) {
|
|
const hit = bvh.closestPointToPoint(new Vector3(...point));
|
|
if (!hit) throw new Error("Could not project a spawn to navigation.");
|
|
return {
|
|
position: hit.point.toArray().map((value) => Number(value.toFixed(6))),
|
|
distance: Number(hit.distance.toFixed(6)),
|
|
};
|
|
},
|
|
dispose() { geometry.dispose(); },
|
|
};
|
|
}
|
|
|
|
function sourceToLocal(sourcePosition, placement) {
|
|
const point = new Vector3(...sourcePosition).sub(new Vector3(...placement.translation));
|
|
point.applyQuaternion(new Quaternion(...placement.rotation).invert());
|
|
point.divide(new Vector3(...placement.scale));
|
|
const meters = recipe.coordinateSystem.metersPerSourceUnit;
|
|
return [point.x * meters, point.y * meters, -point.z * meters];
|
|
}
|
|
|
|
function yawFromDirection(direction) {
|
|
const turn = recipe.coordinateSystem.directionUnitsPerTurn;
|
|
return Number(((((direction % turn) + turn) % turn) * Math.PI * 2 / turn).toFixed(9));
|
|
}
|
|
|
|
function attacksFor(template, sourceSpells, spellCatalog) {
|
|
if (Array.isArray(template.attacks) && template.attacks.length) return template.attacks;
|
|
const ooze = template.archetype === "ooze";
|
|
const basicAttack = {
|
|
id: runtimePrefix + "-basic-attack",
|
|
name: ooze ? "Corrosive Slam" : "Basic Attack",
|
|
delivery: "melee",
|
|
target: "primary",
|
|
school: ooze ? "nature" : "physical",
|
|
animation: "attack",
|
|
range: 4.5,
|
|
cooldownMs: 1900,
|
|
damageMultiplier: 1,
|
|
};
|
|
const sourceAttacks = (sourceSpells ?? []).flatMap((sourceSpell) => {
|
|
const spell = spellCatalog.get(Number(sourceSpell.id));
|
|
if (!spell || spell.mappingStatus !== "damage-family" || !spell.runtime) return [];
|
|
return [{
|
|
id: runtimePrefix + "-spell-" + spell.spellId,
|
|
name: spell.name,
|
|
spellId: spell.spellId,
|
|
delivery: spell.runtime.delivery,
|
|
target: spell.runtime.target,
|
|
school: spell.runtime.school,
|
|
animation: spell.runtime.animation,
|
|
range: spell.runtime.range,
|
|
cooldownMs: spell.runtime.cooldownMs,
|
|
initialCooldownMs: [spell.runtime.cooldownMs, spell.runtime.cooldownMs],
|
|
repeatCooldownMs: [spell.runtime.cooldownMs, spell.runtime.cooldownMs],
|
|
damageMultiplier: spell.runtime.damageMultiplier,
|
|
}];
|
|
});
|
|
return [basicAttack, ...sourceAttacks];
|
|
}
|
|
|
|
function entityFor(template, actor, sourceSpells, spellCatalog) {
|
|
const boss = template.classification === "boss";
|
|
const defaults = recipe.combatDefaults ?? {};
|
|
const combat = template.combat ?? {};
|
|
return {
|
|
id: runtimePrefix + "-" + template.id,
|
|
name: template.name,
|
|
kind: boss ? "boss" : "mob",
|
|
...(boss && template.title ? { title: template.title } : {}),
|
|
hasLoot: template.hasLoot === true,
|
|
combat: {
|
|
level: template.level,
|
|
healthMultiplier: combat.healthMultiplier ?? (boss ? defaults.bossHealthMultiplier ?? 7.5 : defaults.healthMultiplier ?? 1),
|
|
damageMultiplier: combat.damageMultiplier ?? (boss ? defaults.bossDamageMultiplier ?? 1.3 : defaults.damageMultiplier ?? 1),
|
|
moveSpeed: combat.moveSpeed ?? (boss ? defaults.bossMoveSpeed ?? 2.25 : defaults.moveSpeed ?? 2.15),
|
|
leashRange: combat.leashRange ?? (boss ? defaults.bossLeashRange ?? 32 : defaults.leashRange ?? 24),
|
|
attacks: attacksFor(template, sourceSpells, spellCatalog),
|
|
},
|
|
visual: {
|
|
primaryColor: template.primaryColor,
|
|
accentColor: template.accentColor,
|
|
scale: template.visualScale ?? 1,
|
|
archetype: template.archetype ?? "humanoid",
|
|
...(actor ? {
|
|
model: {
|
|
url: actor.url,
|
|
rotationY: actor.rotationY,
|
|
groundOffset: actor.groundOffset,
|
|
labelHeight: actor.labelHeight,
|
|
markerRadius: actor.markerRadius,
|
|
animationMode: actor.animations?.length ? "native" : "procedural",
|
|
},
|
|
} : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
function validateSnapshot(snapshot, templates) {
|
|
if (![1, 2].includes(snapshot.schemaVersion) || snapshot.dungeonId !== recipe.dungeonId) {
|
|
throw new Error("Population snapshot schema or dungeon id is invalid.");
|
|
}
|
|
if (snapshot.zoneId !== recipe.zoneId || snapshot.rows.length !== snapshot.rowCount) {
|
|
throw new Error("Population snapshot Zone id or row count is inconsistent.");
|
|
}
|
|
if (Number.isInteger(recipe.expectedActiveRows) && snapshot.rows.length !== recipe.expectedActiveRows) {
|
|
throw new Error("Expected " + recipe.expectedActiveRows + " active rows, found " + snapshot.rows.length + ".");
|
|
}
|
|
const ids = new Set();
|
|
for (const row of snapshot.rows) {
|
|
if (ids.has(row.spawnId)) throw new Error("Duplicate RuneWaker spawn id " + row.spawnId + ".");
|
|
ids.add(row.spawnId);
|
|
const template = templates.get(row.templateId);
|
|
if (!template) throw new Error("Unmapped RuneWaker template " + row.templateId + ".");
|
|
if (template.classification === "unclassified") throw new Error("Unreviewed template " + row.templateId + ".");
|
|
if (row.classification !== template.classification) {
|
|
throw new Error("Classification drift for template " + row.templateId + ".");
|
|
}
|
|
const actor = recipe.actors.find((entry) => entry.id === template.actor);
|
|
if (row.modelPath && actor && normalizeModelPath(row.modelPath) !== normalizeModelPath(actor.sourceModel)) {
|
|
throw new Error("Model-path drift for template " + row.templateId + ".");
|
|
}
|
|
}
|
|
}
|
|
|
|
async function generatePopulation() {
|
|
const snapshotFile = resolveProject(recipe.files.snapshot);
|
|
const manifestFile = resolveProject(recipe.files.actorManifest);
|
|
await Promise.all([
|
|
requireFile(snapshotFile, "Run the explicit forensic refresh once to create a portable snapshot."),
|
|
requireFile(manifestFile, "Run build-population.mjs --actors-only first."),
|
|
requireFile(resolveProject(recipe.files.environmentMetadata), "Build the environment first."),
|
|
requireFile(spellCatalogFile, "Run npm run runewaker:spells:refresh once to create the portable spell catalog."),
|
|
]);
|
|
const [snapshot, manifest, environment, spellCatalogSnapshot] = await Promise.all([
|
|
readFile(snapshotFile, "utf8").then(JSON.parse),
|
|
readFile(manifestFile, "utf8").then(JSON.parse),
|
|
readFile(resolveProject(recipe.files.environmentMetadata), "utf8").then(JSON.parse),
|
|
readFile(spellCatalogFile, "utf8").then(JSON.parse),
|
|
]);
|
|
if (spellCatalogSnapshot.status !== "portable"
|
|
|| spellCatalogSnapshot.safety?.runtimeSqlDependency !== false
|
|
|| spellCatalogSnapshot.safety?.unknownEffectsExecute !== false
|
|
|| spellCatalogSnapshot.safety?.coefficientsAreSourceExact !== false) {
|
|
throw new Error("RuneWaker spell catalog safety/provenance contract is invalid.");
|
|
}
|
|
const spellCatalog = new Map(
|
|
spellCatalogSnapshot.spells.map((spell) => [Number(spell.spellId), spell]),
|
|
);
|
|
const sourceSpellsByTemplate = new Map();
|
|
for (const row of snapshot.rows) {
|
|
const spells = sourceSpellsByTemplate.get(row.templateId) ?? new Map();
|
|
for (const sourceSpell of row.source?.spells ?? []) {
|
|
if (!spellCatalog.has(Number(sourceSpell.id))) {
|
|
throw new Error("Portable spell catalog is missing source spell " + sourceSpell.id + ".");
|
|
}
|
|
spells.set(Number(sourceSpell.id), {
|
|
id: Number(sourceSpell.id),
|
|
level: Number(sourceSpell.level ?? 0),
|
|
});
|
|
}
|
|
sourceSpellsByTemplate.set(row.templateId, spells);
|
|
}
|
|
const templates = new Map(recipe.templates.map((template) => [template.id, template]));
|
|
const actors = new Map(manifest.assets.map((actor) => [actor.id, actor]));
|
|
const actorForTemplate = (template) => actors.get(paperdollAssetId(
|
|
template.actor,
|
|
template.source?.imageId,
|
|
)) ?? actors.get(template.actor);
|
|
const fallbackActorIds = new Set((manifest.proceduralFallbacks ?? []).map((actor) => actor.id));
|
|
validateSnapshot(snapshot, templates);
|
|
for (const template of recipe.templates.filter((entry) => ["combat", "boss"].includes(entry.classification))) {
|
|
if (!actorForTemplate(template) && !fallbackActorIds.has(template.actor) && template.proceduralFallback !== true) {
|
|
throw new Error("Actor manifest is missing " + template.actor + ".");
|
|
}
|
|
}
|
|
const placement = environment.source?.wdb?.primaryPlacement
|
|
?? environment.source?.wdbReport?.primaryPlacement;
|
|
if (!placement) throw new Error("Environment metadata has no primary WDB placement.");
|
|
const projector = await navigationProjector();
|
|
const entities = {};
|
|
const staticSpawns = [];
|
|
const records = [];
|
|
const rawBosses = [];
|
|
const countsByTemplate = {};
|
|
try {
|
|
for (const row of snapshot.rows) {
|
|
const template = templates.get(row.templateId);
|
|
const local = sourceToLocal(row.sourcePosition, placement);
|
|
const projected = projector.project(local);
|
|
if (projected.distance > recipe.coordinateSystem.maximumProjectionDistance && template.classification !== "deferred-object") {
|
|
throw new Error("Spawn " + row.spawnId + " is " + projected.distance + "m from navigation.");
|
|
}
|
|
const record = {
|
|
sourceSpawnId: row.spawnId,
|
|
sourceTemplateId: row.templateId,
|
|
name: row.name,
|
|
classification: row.classification,
|
|
sourcePosition: row.sourcePosition,
|
|
sourceDirection: row.direction,
|
|
localPosition: local.map((value) => Number(value.toFixed(6))),
|
|
projectedPosition: projected.position,
|
|
projectionDistance: projected.distance,
|
|
modelPath: row.modelPath,
|
|
};
|
|
records.push(record);
|
|
countsByTemplate[row.templateId] = (countsByTemplate[row.templateId] ?? 0) + 1;
|
|
if (template.classification === "deferred-object") continue;
|
|
const entityId = runtimePrefix + "-" + row.templateId;
|
|
if (!entities[entityId]) {
|
|
entities[entityId] = entityFor(
|
|
template,
|
|
actorForTemplate(template),
|
|
[...(sourceSpellsByTemplate.get(template.id)?.values() ?? [])],
|
|
spellCatalog,
|
|
);
|
|
}
|
|
const spawn = {
|
|
id: runtimePrefix + "-spawn-" + row.spawnId,
|
|
entityId,
|
|
position: projected.position,
|
|
yaw: yawFromDirection(row.direction),
|
|
spawnMask: template.spawnMask ?? 1,
|
|
};
|
|
staticSpawns.push(spawn);
|
|
if (template.classification === "boss") {
|
|
rawBosses.push({
|
|
id: spawn.id,
|
|
sourceSpawnId: row.spawnId,
|
|
templateId: template.id,
|
|
name: template.name,
|
|
position: spawn.position,
|
|
});
|
|
}
|
|
}
|
|
} finally {
|
|
projector.dispose();
|
|
}
|
|
if (Number.isInteger(recipe.expectedBossSpawns) && rawBosses.length !== recipe.expectedBossSpawns) {
|
|
throw new Error("Expected " + recipe.expectedBossSpawns + " boss spawns, found " + rawBosses.length + ".");
|
|
}
|
|
const entrance = environment.entrance?.footPosition ?? [0, 0, 0];
|
|
const objectiveOrder = orderBossObjectives(rawBosses, entrance, recipe.bossObjectiveOrder);
|
|
const ordered = objectiveOrder.ordered;
|
|
const occurrence = new Map();
|
|
const bosses = ordered.map((boss) => {
|
|
const template = templates.get(boss.templateId);
|
|
const index = (occurrence.get(boss.templateId) ?? 0) + 1;
|
|
occurrence.set(boss.templateId, index);
|
|
return {
|
|
id: boss.id,
|
|
name: template.objectiveNamePattern
|
|
? template.objectiveNamePattern.replaceAll("{index}", String(index))
|
|
: boss.name,
|
|
position: boss.position,
|
|
};
|
|
});
|
|
const excluded = records.filter((record) => record.classification === "deferred-object");
|
|
const observed = records.map((record) => record.projectionDistance);
|
|
const combatObserved = records
|
|
.filter((record) => record.classification !== "deferred-object")
|
|
.map((record) => record.projectionDistance);
|
|
const provenance = {
|
|
schemaVersion: 2,
|
|
dungeonId: recipe.dungeonId,
|
|
zoneId: recipe.zoneId,
|
|
sourceRowCount: snapshot.rows.length,
|
|
importedSpawnCount: staticSpawns.length,
|
|
excludedRowCount: excluded.length,
|
|
bossSpawnCount: bosses.length,
|
|
objectiveOrder: objectiveOrder.provenance,
|
|
sourceSnapshot: projectPath(snapshotFile),
|
|
sourceContentSha256: snapshot.contentSha256,
|
|
sourceHashes: snapshot.sourceHashes,
|
|
coordinateTransform: "threeLocal=(inverse(WDB placement)*server)*(meters,meters,-meters)",
|
|
navmeshProjection: {
|
|
maximumAllowedDistance: recipe.coordinateSystem.maximumProjectionDistance,
|
|
maximumObservedDistance: Number(Math.max(...observed, 0).toFixed(6)),
|
|
maximumCombatDistance: Number(Math.max(...combatObserved, 0).toFixed(6)),
|
|
deferredRowsBeyondCombatLimit: excluded.filter((record) => (
|
|
record.projectionDistance > recipe.coordinateSystem.maximumProjectionDistance
|
|
)).length,
|
|
},
|
|
runeWakerIdsAreNamespaced: true,
|
|
serverEntryPolicy: "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
|
sourceSpellCatalog: projectPath(spellCatalogFile),
|
|
sourceSpellCatalogSha256: await sha256(spellCatalogFile),
|
|
sourceSpellPolicy: "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
|
};
|
|
const reportFile = resolveProject(recipe.files.report);
|
|
await mkdir(path.dirname(reportFile), { recursive: true });
|
|
await writeFile(reportFile, JSON.stringify({
|
|
...provenance,
|
|
status: "green",
|
|
recipe: projectPath(recipeFile),
|
|
actorManifest: projectPath(manifestFile),
|
|
entityCount: Object.keys(entities).length,
|
|
countsByTemplate,
|
|
bosses,
|
|
excluded,
|
|
records,
|
|
deferred: recipe.deferred,
|
|
}, null, 2) + "\n", "utf8");
|
|
const prefix = constantCase(recipe.dungeonId);
|
|
const generated = [
|
|
"/* Generated by scripts/runewaker-pipeline/build-population.mjs. */",
|
|
'import type { DungeonBossObjective } from "../dungeonTypes";',
|
|
'import type { PopulationDefinitionMap, StaticMobSpawnDefinition } from "../mobPopulation";',
|
|
"",
|
|
"export const " + prefix + "_ENTITIES = " + JSON.stringify(entities, null, 2) + " as const satisfies PopulationDefinitionMap;",
|
|
"",
|
|
"export const " + prefix + "_STATIC_SPAWNS = " + JSON.stringify(staticSpawns, null, 2) + " as const satisfies readonly StaticMobSpawnDefinition[];",
|
|
"",
|
|
"export const " + prefix + "_BOSSES = " + JSON.stringify(bosses, null, 2) + " as const satisfies readonly DungeonBossObjective[];",
|
|
"",
|
|
"export const " + prefix + "_OBJECTIVE_ORDER = " + prefix + "_BOSSES;",
|
|
"",
|
|
"export const " + prefix + "_POPULATION_PROVENANCE = " + JSON.stringify(provenance, null, 2) + " as const;",
|
|
"",
|
|
].join("\n");
|
|
const generatedFile = resolveProject(recipe.files.generatedSource);
|
|
await mkdir(path.dirname(generatedFile), { recursive: true });
|
|
await writeFile(generatedFile, generated, "utf8");
|
|
console.log("\n[runewaker-population] generated " + staticSpawns.length + " combat spawns, "
|
|
+ bosses.length + " objectives, and " + excluded.length + " deferred objects.");
|
|
}
|
|
|
|
const actorsOnly = argv.includes("--actors-only");
|
|
if (actorsOnly || argv.includes("--actors")) await buildActors();
|
|
if (!actorsOnly) await generatePopulation();
|