import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { hasFlag, pipelineDirectory, projectPath, projectRoot, recipeArgument, readJson, resolveProject, slugify, sourceRootFor, validatePopulationRecipe, } from "./lib/recipe.mjs"; const argv = process.argv.slice(2); const fallbackRecipe = path.join(pipelineDirectory, "recipes", "forsaken-abbey-population.json"); const recipeFile = recipeArgument(argv, fallbackRecipe); let recipe = validatePopulationRecipe(await readJson(recipeFile), projectPath(recipeFile)); const sourceRoot = sourceRootFor(recipe); const server = process.env[recipe.source.serverEnvironmentVariable] ?? recipe.source.serverDefault; const globalBackup = resolveProject(recipe.source.backups.global); const objectBackup = resolveProject(recipe.source.backups.objects); const snapshotFile = resolveProject(recipe.files.snapshot); const powershellScript = path.join(pipelineDirectory, "refresh-population.ps1"); function run(executable, argumentsList, label) { return new Promise((resolve, reject) => { console.log("\n[runewaker-forensics] " + 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) { try { if (!(await stat(file)).isFile()) throw new Error(); } catch { throw new Error("Required forensic source is missing: " + file); } } async function sha256(file) { return createHash("sha256").update(await readFile(file)).digest("hex"); } function stableHash(value) { return createHash("sha256").update(JSON.stringify(value)).digest("hex"); } function clean(value) { return String(value ?? "").replaceAll(String.fromCharCode(0), "").trim(); } function normalizeModelPath(value) { return clean(value) .replaceAll("\\", "/") .replace(/^\/+/, "") .replace(/^resource\//i, ""); } function colorsFor(id) { const hue = (Number(id) * 47) % 360; return { primaryColor: `hsl(${hue} 22% 38%)`, accentColor: `hsl(${(hue + 28) % 360} 38% 64%)`, }; } function discoveredClassification(row) { if (Number(row.sex) === 3) return "boss"; const model = normalizeModelPath(row.modelPath); if (Number(row.nativeLevel) > 0 && /^model\/character\//i.test(model)) return "combat"; if (Number(row.nativeLevel) === 0 || model) return "deferred-object"; return "unclassified"; } function actorIdFor(modelPath, used) { const base = slugify(path.parse(modelPath).name.replace(/^act[_-]?/i, "")) || "actor"; if (!used.has(base) || used.get(base) === modelPath) return base; return base + "-" + stableHash(modelPath).slice(0, 8); } function updateRecipeFromRaw(raw) { const rowsByTemplate = new Map(); for (const row of raw.rows) { const rows = rowsByTemplate.get(row.templateId) ?? []; rows.push(row); rowsByTemplate.set(row.templateId, rows); } const existingTemplates = new Map(recipe.templates.map((template) => [template.id, template])); const actorsByModel = new Map(recipe.actors.map((actor) => [actor.sourceModel, actor])); const actorIds = new Map(recipe.actors.map((actor) => [actor.id, actor.sourceModel])); const templates = []; for (const [templateId, templateRows] of [...rowsByTemplate.entries()].sort(([a], [b]) => a - b)) { const row = templateRows[0]; const existing = existingTemplates.get(templateId); const modelPath = normalizeModelPath(row.modelPath); const classification = existing?.classification && existing.classification !== "unclassified" ? existing.classification : discoveredClassification(row); let actor = existing?.actor ?? null; if (["combat", "boss"].includes(classification) && modelPath) { let actorDefinition = actorsByModel.get(modelPath); if (!actorDefinition) { const id = actorIdFor(modelPath, actorIds); actorDefinition = { id, sourceModel: modelPath, runtimeRotationY: 0 }; actorsByModel.set(modelPath, actorDefinition); actorIds.set(id, modelPath); } actor = actorDefinition.id; } const sourceName = clean(row.localizedName) || clean(row.roleName) || existing?.name; templates.push({ ...colorsFor(templateId), archetype: existing?.archetype ?? "humanoid", ...existing, id: templateId, name: existing?.name && !existing.name.startsWith("REVIEW template ") ? existing.name : sourceName || "Template " + templateId, level: Number(row.nativeLevel), classification, ...(actor ? { actor } : {}), source: { imageId: Number(row.imageId ?? 0), modelPath: modelPath || null, classificationRule: Number(row.sex) === 3 ? "NPCObjectDB.sex=3" : classification === "combat" ? "positive level plus model/character ROS" : classification === "deferred-object" ? "noncombat/object heuristic; review required" : "unresolved; manual review required", }, }); } for (const template of recipe.templates) { if (!rowsByTemplate.has(template.id)) templates.push(template); } const bossTemplateIds = new Set(templates .filter((template) => template.classification === "boss") .map((template) => template.id)); const updated = { ...recipe, schemaVersion: Math.max(2, Number(recipe.schemaVersion ?? 1)), expectedActiveRows: raw.rows.length, expectedBossSpawns: raw.rows.filter((row) => bossTemplateIds.has(row.templateId)).length, actors: [...actorsByModel.values()].sort((left, right) => left.id.localeCompare(right.id)), templates, }; validatePopulationRecipe(updated, projectPath(recipeFile)); return updated; } await Promise.all([ requireFile(globalBackup), requireFile(objectBackup), requireFile(powershellScript), ...recipe.source.catalogs.map((file) => requireFile(path.join(sourceRoot, file))), ]); const temporary = await mkdtemp(path.join(os.tmpdir(), "healerman-rw-forensics-")); const rawFile = path.join(temporary, "zone-" + recipe.zoneId + ".raw.json"); try { await run( "powershell.exe", [ "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", powershellScript, "-ServerInstance", server, "-GlobalBackup", globalBackup, "-ObjectBackup", objectBackup, "-ZoneId", String(recipe.zoneId), "-DatabaseSuffix", recipe.dungeonId + "_" + process.pid, "-OutputFile", rawFile, ], "restore two temporary read-only databases, export Zone " + recipe.zoneId + ", and drop them", ); const raw = JSON.parse((await readFile(rawFile, "utf8")).replace(/^\uFEFF/, "")); if (raw.zoneId !== recipe.zoneId || raw.rowCount !== raw.rows.length) { throw new Error("Forensic export row count or Zone id is inconsistent."); } if (Number.isInteger(recipe.expectedActiveRows) && raw.rows.length !== recipe.expectedActiveRows) { throw new Error( "Expected " + recipe.expectedActiveRows + " active Zone " + recipe.zoneId + " rows, found " + raw.rows.length + ".", ); } if (hasFlag(argv, "--update-recipe")) { recipe = updateRecipeFromRaw(raw); await writeFile(recipeFile, JSON.stringify(recipe, null, 2) + "\n", "utf8"); console.log("[runewaker-forensics] updated and pinned recipe " + projectPath(recipeFile) + "."); } const templates = new Map(recipe.templates.map((template) => [template.id, template])); const actors = new Map(recipe.actors.map((actor) => [actor.id, actor])); const unmapped = [...new Set(raw.rows .filter((row) => !templates.has(row.templateId)) .map((row) => row.templateId))].sort((a, b) => a - b); const unresolved = recipe.templates.filter((template) => ( template.classification === "unclassified" || (["combat", "boss"].includes(template.classification) && !actors.has(template.actor)) )); const auditFile = path.join( projectRoot, "runewaker-export-work", recipe.dungeonId + "-population", "template-audit.json", ); const audit = { schemaVersion: 1, dungeonId: recipe.dungeonId, zoneId: recipe.zoneId, rowCount: raw.rows.length, unmappedTemplateIds: unmapped, unresolvedTemplates: unresolved, discoveredTemplates: [...new Set(raw.rows.map((row) => row.templateId))].sort((a, b) => a - b), }; await mkdir(path.dirname(auditFile), { recursive: true }); await writeFile(auditFile, JSON.stringify(audit, null, 2) + "\n", "utf8"); if (unmapped.length || unresolved.length) { throw new Error( "Population recipe still has " + unmapped.length + " unmapped and " + unresolved.length + " unresolved templates. Review " + projectPath(auditFile) + " or rerun with --update-recipe.", ); } const spawnIds = new Set(); const rows = raw.rows.map((row) => { const template = templates.get(row.templateId); if (spawnIds.has(row.spawnId)) throw new Error("Duplicate DBID " + row.spawnId + "."); spawnIds.add(row.spawnId); const actor = template.actor ? actors.get(template.actor) : null; const modelPath = normalizeModelPath(row.modelPath) || actor?.sourceModel || null; return { spawnId: row.spawnId, templateId: row.templateId, name: template.name, sourceName: clean(row.localizedName) || clean(row.roleName) || template.name, classification: template.classification, level: Number(row.nativeLevel), sex: Number(row.sex), imageId: Number(row.imageId ?? 0), modelPath, sourcePosition: row.sourcePosition.map(Number), direction: Number(row.direction), source: { roomId: row.roomId, roleName: clean(row.roleName), autoPlot: clean(row.autoPlot), plotClassName: clean(row.plotClassName), templateScripts: Object.fromEntries(Object.entries(row.templateScripts ?? {}) .map(([key, value]) => [key, clean(value)])), spells: row.spells ?? [], }, }; }).sort((left, right) => left.spawnId - right.spawnId); const sourceHashes = { backups: { [projectPath(globalBackup)]: await sha256(globalBackup), [projectPath(objectBackup)]: await sha256(objectBackup), }, catalogs: Object.fromEntries(await Promise.all(recipe.source.catalogs.map(async (file) => { const absolute = path.join(sourceRoot, file); return [file.split(path.sep).join("/"), await sha256(absolute)]; }))), }; const content = { schemaVersion: 2, dungeonId: recipe.dungeonId, zoneId: recipe.zoneId, queryVersion: raw.queryVersion, rowCount: rows.length, sourceHashes, rows, }; const snapshot = { ...content, contentSha256: stableHash(content) }; await mkdir(path.dirname(snapshotFile), { recursive: true }); await writeFile(snapshotFile, JSON.stringify(snapshot, null, 2) + "\n", "utf8"); console.log("\n[runewaker-forensics] wrote portable snapshot " + projectPath(snapshotFile) + " (" + rows.length + " rows, " + snapshot.contentSha256 + ")."); console.log("[runewaker-forensics] SQL Server is not used by the game or normal builds."); } finally { await rm(temporary, { recursive: true, force: true }); }