310 lines
11 KiB
JavaScript
310 lines
11 KiB
JavaScript
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 });
|
|
}
|