Files
healer-man/scripts/runewaker-pipeline/audit-environment-assembly.mjs
T
2026-09-08 10:40:26 -04:00

182 lines
8.1 KiB
JavaScript

#!/usr/bin/env node
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { buildWdbPlacementPlan } from "./lib/environment-assembly.mjs";
import { sourceRootFor } from "./lib/recipe.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const recipeDirectory = path.join(root, "scripts/runewaker-pipeline/recipes");
const reportJsonFile = path.join(
root,
"scripts/runewaker-pipeline/RUNEWAKER_ENVIRONMENT_ASSEMBLY_REPORT.json",
);
const reportMarkdownFile = path.join(
root,
"scripts/runewaker-pipeline/RUNEWAKER_ENVIRONMENT_ASSEMBLY_REPORT.md",
);
const projectPath = (file) => path.relative(root, file).split(path.sep).join("/");
async function exists(file) {
try {
await stat(file);
return true;
} catch {
return false;
}
}
function renderMarkdown(report) {
const rows = report.dungeons.map((dungeon) => [
"|", dungeon.id, "|", dungeon.mode, "|", dungeon.sourceDescriptors, "|",
dungeon.uniqueResources, "|", dungeon.staticPlacements, "|",
dungeon.dynamicPlacements, "|", dungeon.dynamicReviewCandidates, "|",
dungeon.unsupportedPlacements, "|", dungeon.missingSourceResources, "|",
].join(" "));
return [
"# RuneWaker WDB environment assembly audit",
"",
"Structural validation: **" + report.validationStatus + "**",
"Source-behavior review: **" + report.reviewStatus + "**",
"",
"This deterministic audit reads the checked-in WDB reports and environment recipes. It does",
"not convert or replace shipping GLBs. The builder now supports every valid ROS placement",
"resource with normalized-path model caching validated by source hashes and the complete translation/quaternion/scale",
"transform relative to the reviewed primary placement.",
"",
"## Coverage",
"",
"- " + report.summary.dungeons + " dungeon WDB packages and "
+ report.summary.sourceDescriptors + " source descriptors.",
"- " + report.summary.uniqueDungeonResources
+ " per-dungeon unique ROS resources (resources reused across dungeons share cache keys).",
"- " + report.summary.staticPlacements + " static placements and "
+ report.summary.dynamicPlacements + " explicitly reviewed dynamic placements.",
"- " + report.summary.dynamicReviewCandidates
+ " filename-based dynamic candidates remain static and review-required; names never prove behavior.",
"- " + report.summary.unsupportedPlacements + " unsupported placement records and "
+ report.summary.missingSourceResources + " missing source ROS resources.",
"",
"| Dungeon | Mode | Descriptors | Resources | Static | Dynamic | Dynamic review | Unsupported | Missing ROS |",
"| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
...rows,
"",
"## Runtime-facing output",
"",
"A full build writes `wdb-placement-manifest.json` beside the environment package. Each",
"placement has a stable WDB GUID ID, normalized resource/cache key, source TRS, primary-local",
"Three.js matrix, bounds, flags, asset status, and optional reviewed dynamic definition.",
"Static placements are assembled into the optimized visual and navigation collision. Reviewed",
"dynamic placements are omitted from static collision and packaged as separately addressable GLBs.",
"",
"## Unsupported or review-required behavior",
"",
...report.unsupportedResourceTypes.map((item) => "- " + item),
"- The primary-only top-down preview and editable `.blend` do not include the final merged WDB",
" assembly; the generated GLB and placement manifest are authoritative.",
"- Dynamic-candidate filenames are not enough to remove geometry from the static package. Add a",
" reviewed `source.wdbAssembly.dynamicPlacements` rule with evidence first.",
"",
"## Validation issues",
"",
...(report.issues.length ? report.issues.map((item) => "- " + item) : ["- None."]),
"",
].join("\n");
}
export async function buildEnvironmentAssemblyAudit() {
const recipeNames = (await readdir(recipeDirectory))
.filter((name) => name.endsWith(".json")
&& !name.endsWith("-population.json")
&& name !== "runewaker-player-avatars.json")
.sort();
const dungeons = [];
const issues = [];
for (const recipeName of recipeNames) {
const recipe = JSON.parse(await readFile(path.join(recipeDirectory, recipeName), "utf8"));
const wdbReportFile = path.join(
root,
"src/assets/game/dungeons",
recipe.slug,
"wdb-report.json",
);
if (!await exists(wdbReportFile)) {
issues.push(recipe.slug + ": checked-in WDB report is missing.");
continue;
}
try {
const wdbReport = JSON.parse(await readFile(wdbReportFile, "utf8"));
const plan = buildWdbPlacementPlan(wdbReport, recipe);
const resourceRoot = path.join(sourceRootFor(recipe), recipe.source.resourceRoot);
const missingResources = [];
for (const resource of plan.resources) {
const file = path.join(resourceRoot, ...resource.normalizedResource.split("\\"));
if (!await exists(file)) missingResources.push(resource.resource);
}
dungeons.push({
id: recipe.slug,
mode: recipe.source.proceduralTerrainFallback
? "primary-only-procedural-fallback"
: recipe.source.wdbAssembly?.mode ?? "all-ros",
recipe: projectPath(path.join(recipeDirectory, recipeName)),
wdbReport: projectPath(wdbReportFile),
sourceDescriptors: plan.sourceDescriptorCount,
uniqueResources: plan.resources.length,
staticPlacements: plan.staticPlacements.length,
dynamicPlacements: plan.dynamicPlacements.length,
dynamicReviewCandidates: plan.reviewRequiredPlacements.length,
unsupportedPlacements: plan.unsupportedPlacements.length,
missingSourceResources: missingResources.length,
missingResources,
unsupported: plan.unsupportedPlacements,
});
} catch (error) {
issues.push(recipe.slug + ": " + error.message);
}
}
dungeons.sort((left, right) => left.id.localeCompare(right.id));
const sum = (field) => dungeons.reduce((total, dungeon) => total + dungeon[field], 0);
const unsupportedResourceTypes = [
"WDB placement resources other than ROS are recorded but not converted.",
"Particle, light, sound, camera, and script-only components absent from ROS mesh descriptors remain unsupported.",
"Animated/stateful behavior remains unsupported until an exact reviewed dynamic-placement rule supplies its kind, initial state, collision policy, and evidence.",
"A ROS that the preserved exporter cannot decode is skipped, recorded with its resource path and failure, and never replaced with guessed geometry.",
];
return {
schemaVersion: 1,
generatedBy: "scripts/runewaker-pipeline/audit-environment-assembly.mjs",
deterministic: true,
validationStatus: issues.length ? "blocked" : "green",
reviewStatus: sum("dynamicReviewCandidates") || sum("unsupportedPlacements")
|| sum("missingSourceResources") ? "review-required" : "green",
summary: {
dungeons: dungeons.length,
sourceDescriptors: sum("sourceDescriptors"),
uniqueDungeonResources: sum("uniqueResources"),
staticPlacements: sum("staticPlacements"),
dynamicPlacements: sum("dynamicPlacements"),
dynamicReviewCandidates: sum("dynamicReviewCandidates"),
unsupportedPlacements: sum("unsupportedPlacements"),
missingSourceResources: sum("missingSourceResources"),
},
unsupportedResourceTypes,
issues,
dungeons,
};
}
const report = await buildEnvironmentAssemblyAudit();
await Promise.all([
writeFile(reportJsonFile, JSON.stringify(report, null, 2) + "\n", "utf8"),
writeFile(reportMarkdownFile, renderMarkdown(report), "utf8"),
]);
console.log(JSON.stringify({
validationStatus: report.validationStatus,
reviewStatus: report.reviewStatus,
reportJson: projectPath(reportJsonFile),
reportMarkdown: projectPath(reportMarkdownFile),
summary: report.summary,
issues: report.issues,
}, null, 2));
if (report.issues.length) process.exitCode = 1;