import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdtemp, readFile, rm, stat, writeFile, mkdir } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(scriptDirectory, "../.."); const recipeFile = path.join(scriptDirectory, "recipes", "forsaken-abbey-population.json"); const recipe = JSON.parse(await readFile(recipeFile, "utf8")); const resolveProject = (value) => path.resolve(projectRoot, value); const sourceRoot = path.resolve( process.env[recipe.source.rootEnvironmentVariable] ?? path.join(projectRoot, recipe.source.relativeDefault), ); 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(scriptDirectory, "refresh-forsaken-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 projectPath(file) { return path.relative(projectRoot, file).split(path.sep).join("/"); } function cleanSourceText(value) { return String(value ?? "").replaceAll(String.fromCharCode(0), "").trim(); } 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-fa-forensics-")); const rawFile = path.join(temporary, "zone-102.raw.json"); try { await run( "powershell.exe", [ "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", powershellScript, "-ServerInstance", server, "-GlobalBackup", globalBackup, "-ObjectBackup", objectBackup, "-OutputFile", rawFile, ], "restore the two temporary databases, export Zone 102, and drop both databases", ); const raw = JSON.parse((await readFile(rawFile, "utf8")).replace(/^\uFEFF/, "")); if (raw.rowCount !== recipe.expectedActiveRows || raw.rows.length !== recipe.expectedActiveRows) { throw new Error( "Expected " + recipe.expectedActiveRows + " active Zone 102 rows, found " + raw.rows.length + ".", ); } const templates = new Map(recipe.templates.map((template) => [template.id, template])); const unmapped = [...new Set( raw.rows.filter((row) => !templates.has(row.templateId)).map((row) => row.templateId), )].sort((a, b) => a - b); if (unmapped.length) { const audit = unmapped.map((templateId) => { const matches = raw.rows.filter((row) => row.templateId === templateId); const first = matches[0]; return { templateId, spawnCount: matches.length, name: first.localizedName || first.roleName || "", level: first.nativeLevel, sex: first.sex, roles: [...new Set(matches.map((row) => row.roleName).filter(Boolean))], autoPlots: [...new Set(matches.map((row) => row.autoPlot).filter(Boolean))], }; }); throw new Error("Unmapped Zone 102 templates:\n" + JSON.stringify(audit, null, 2)); } const actors = new Map(recipe.actors.map((actor) => [actor.id, actor])); const spawnIds = new Set(); const observedBossTemplates = new Set(); const rows = raw.rows .map((row) => { const template = templates.get(row.templateId); if (!template) throw new Error("Unmapped Zone 102 template " + row.templateId + "."); if (spawnIds.has(row.spawnId)) throw new Error("Duplicate Zone 102 DBID " + row.spawnId + "."); spawnIds.add(row.spawnId); if (!Array.isArray(row.sourcePosition) || row.sourcePosition.some((value) => !Number.isFinite(value))) { throw new Error("Spawn " + row.spawnId + " has invalid source coordinates."); } if (row.sex === 3) observedBossTemplates.add(row.templateId); if (row.nativeLevel !== template.level) { throw new Error( "Template " + row.templateId + " level drift: expected " + template.level + ", database has " + row.nativeLevel + ".", ); } const actor = template.actor ? actors.get(template.actor) : null; return { spawnId: row.spawnId, templateId: row.templateId, name: template.name, sourceName: cleanSourceText(row.localizedName) || cleanSourceText(row.roleName) || template.name, classification: template.classification, level: row.nativeLevel, sex: row.sex, modelPath: actor?.sourceModel ?? null, sourcePosition: row.sourcePosition.map(Number), direction: Number(row.direction), source: { roomId: row.roomId, roleName: cleanSourceText(row.roleName), autoPlot: cleanSourceText(row.autoPlot), plotClassName: cleanSourceText(row.plotClassName), }, }; }) .sort((a, b) => a.spawnId - b.spawnId); const expectedBossTemplates = recipe.templates .filter((template) => template.classification === "boss") .map((template) => template.id) .sort((a, b) => a - b); const actualBossTemplates = [...observedBossTemplates].sort((a, b) => a - b); if (JSON.stringify(actualBossTemplates) !== JSON.stringify(expectedBossTemplates)) { throw new Error( "NPCObjectDB sex=3 classification drift. Expected " + expectedBossTemplates.join(", ") + "; found " + actualBossTemplates.join(", ") + ".", ); } 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: 1, 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 population build."); } finally { await rm(temporary, { recursive: true, force: true }); }