Files
2026-08-14 15:56:39 -04:00

129 lines
6.0 KiB
JavaScript

import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import {
exists,
loadPopulationRecipe,
pipelineDirectory,
projectPath,
projectRoot,
resolveProject,
} from "./lib/recipe.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 checks = [];
const errors = [];
function check(name, passed, detail) {
checks.push({ name, passed, detail });
if (!passed) errors.push(name + ": " + detail);
}
async function sha256(file) {
return createHash("sha256").update(await readFile(file)).digest("hex");
}
function run(executable, argumentsList, label) {
return new Promise((resolve, reject) => {
const child = spawn(executable, argumentsList, {
cwd: projectRoot,
stdio: "inherit",
windowsHide: true,
});
child.once("error", reject);
child.once("exit", (code) => code === 0 ? resolve() : reject(new Error(label + " exited with " + code)));
});
}
const snapshotFile = resolveProject(recipe.files.snapshot);
const environmentFile = resolveProject(recipe.files.environmentMetadata);
const navigationFile = resolveProject(recipe.files.navigation);
const manifestFile = resolveProject(recipe.files.actorManifest);
const generatedFile = resolveProject(recipe.files.generatedSource);
const reportFile = resolveProject(recipe.files.report);
const mechanicsFile = resolveProject(recipe.files.mechanicsReport
?? "src/assets/game/dungeons/" + recipe.dungeonId + "/mechanics-audit.json");
for (const [name, file] of [
["portable snapshot", snapshotFile],
["environment metadata", environmentFile],
["navigation GLB", navigationFile],
["actor manifest", manifestFile],
["generated population source", generatedFile],
["population report", reportFile],
["mechanics audit", mechanicsFile],
]) {
check(name + " exists", await exists(file), projectPath(file));
}
if (errors.length === 0) {
const [snapshot, environment, manifest, report, mechanics, generated] = await Promise.all([
readFile(snapshotFile, "utf8").then(JSON.parse),
readFile(environmentFile, "utf8").then(JSON.parse),
readFile(manifestFile, "utf8").then(JSON.parse),
readFile(reportFile, "utf8").then(JSON.parse),
readFile(mechanicsFile, "utf8").then(JSON.parse),
readFile(generatedFile, "utf8"),
]);
check("snapshot dungeon id", snapshot.dungeonId === recipe.dungeonId, String(snapshot.dungeonId));
check("snapshot zone id", snapshot.zoneId === recipe.zoneId, String(snapshot.zoneId));
check("snapshot row count", snapshot.rows.length === snapshot.rowCount, String(snapshot.rows.length));
check(
"pinned active row count",
!Number.isInteger(recipe.expectedActiveRows) || snapshot.rows.length === recipe.expectedActiveRows,
String(recipe.expectedActiveRows),
);
check("unique source spawn ids", new Set(snapshot.rows.map((row) => row.spawnId)).size === snapshot.rows.length, "no duplicates");
check("environment id", environment.dungeonId === recipe.dungeonId, String(environment.dungeonId));
check("population report green", report.status === "green", String(report.status));
check("population accounting", report.sourceRowCount === report.importedSpawnCount + report.excludedRowCount,
report.sourceRowCount + " = " + report.importedSpawnCount + " + " + report.excludedRowCount);
check("combat nav projection", report.navmeshProjection.maximumCombatDistance <= report.navmeshProjection.maximumAllowedDistance,
String(report.navmeshProjection.maximumCombatDistance));
check("mechanics is an explicit review gate", mechanics.status === "review-required" || mechanics.status === "green", String(mechanics.status));
const executableSql = /\b(?:SqlConnection|ConnectionPool)\b|(?:from|require\s*\()["'](?:mssql|tedious)|fetch\s*\([^)]*(?:sql|database)/i;
check("no generated SQL dependency", !executableSql.test(generated), "runtime source is portable; provenance strings are allowed");
check("no AzerothCore serverEntry leakage", !/serverEntry\s*:/i.test(generated), "RuneWaker ids stay namespaced");
check("actor manifest id", manifest.dungeonId === recipe.dungeonId, String(manifest.dungeonId));
for (const asset of manifest.assets ?? []) {
const file = path.join(resolveProject(recipe.files.actorOutput), asset.fileName);
const present = await exists(file);
check("actor " + asset.id + " exists", present, projectPath(file));
if (present) {
check("actor " + asset.id + " checksum", await sha256(file) === asset.checksum, asset.checksum);
check("actor " + asset.id + " size", (await stat(file)).size === asset.size, String(asset.size));
}
}
if (!argv.includes("--no-khronos")) {
const validator = path.join(projectRoot, "scripts", "manastorm-assets", "validate-package-glbs.cjs");
await run(process.execPath, [validator, path.dirname(environmentFile)], "environment Khronos validation");
if ((manifest.assets ?? []).length) {
await run(process.execPath, [validator, resolveProject(recipe.files.actorOutput)], "actor Khronos validation");
}
check(
"Khronos validation",
true,
(manifest.assets ?? []).length ? "environment and actors passed" : "environment passed; actor set is empty",
);
}
}
const validationFile = path.join(path.dirname(reportFile), "instance-validation-report.json");
const payload = {
schemaVersion: 1,
dungeonId: recipe.dungeonId,
recipe: projectPath(recipeFile),
status: errors.length ? "red" : "green",
checks,
errors,
};
await mkdir(path.dirname(validationFile), { recursive: true });
await writeFile(validationFile, JSON.stringify(payload, null, 2) + "\n", "utf8");
console.log("[runewaker-validate] " + payload.status + ": " + checks.filter((entry) => entry.passed).length
+ "/" + checks.length + " checks passed.");
console.log("[runewaker-validate] report: " + projectPath(validationFile));
if (errors.length) throw new Error(errors.join("\n"));