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