Files
healer-man/scripts/runewaker-pipeline/build-population.mjs
T
2026-08-14 15:56:39 -04:00

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();