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"));