203 lines
7.5 KiB
JavaScript
203 lines
7.5 KiB
JavaScript
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 });
|
|
}
|