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

805 lines
36 KiB
JavaScript

#!/usr/bin/env node
import { open, readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { NodeIO } from "@gltf-transform/core";
import { BufferAttribute, BufferGeometry, Matrix4, Vector3 } from "three";
import {
buildPartyNavigationGraph,
} from "../../src/game/partyNavigation.ts";
import { auditOrderedObjectiveRoutes } from "./lib/objective-route-audit.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const publicRoot = path.join(root, "public");
const reportJson = path.join(root, "scripts/runewaker-pipeline/RUNEWAKER_GAMEPLAY_CONTENT_REPORT.json");
const reportMarkdown = path.join(root, "scripts/runewaker-pipeline/RUNEWAKER_GAMEPLAY_CONTENT_REPORT.md");
const valid = {
animation: new Set(["attack", "cast"]),
delivery: new Set(["melee", "projectile", "area"]),
target: new Set(["primary", "random-party", "nearby-party", "self", "lowest-health-friendly"]),
school: new Set(["physical", "arcane", "fire", "frost", "nature", "shadow", "holy"]),
archetype: new Set([
"humanoid", "raptor", "crocolisk", "ooze", "plant",
"serpent", "turtle", "lizard", "murloc", "winged",
]),
};
const nativeFamilies = {
idle: /stand|idle/i,
move: /walk|run/i,
attack: /attack/i,
cast: /cast/i,
wound: /wound|hit/i,
death: /death|dead/i,
};
const nativeFamilyExemptions = new Map([
["/assets/creatures/sardo-castle/rune-device-red.glb", new Map([
["move", "Rune Device is a source-authored stationary encounter device; "
+ "its native GLB retains stand, attack/activation, cast, wound, and death clips."],
])],
]);
const resolvedRuntimeObservations = [{
dungeonId: "paspers-shrine",
route: "entrance -> Blackhorn Nisorn",
evidence: "artifacts/playtests/paspers-shrine-route-blocked.png",
observed: "Attack initially reported Route blocked.",
resolution: "The objective approach is now projected to the nearest retained navigation "
+ "surface within 6m; targeted tests and a live combat replay advance without console errors.",
}];
const projectPath = (file) => path.relative(root, file).split(path.sep).join("/");
const constantCase = (value) => String(value).replaceAll("-", "_").toUpperCase();
const readJson = async (file) => JSON.parse((await readFile(file, "utf8")).replace(/^\uFEFF/, ""));
const issue = (issues, dungeonId, message) => issues.push(dungeonId + ": " + message);
const samePosition = (left, right) => Array.isArray(left) && Array.isArray(right)
&& left.length === 3 && right.length === 3
&& left.every((value, index) => Number.isFinite(value) && Math.abs(value - right[index]) < 0.000001);
function generatedJson(source, name, suffix) {
const marker = "export const " + name + " = ";
const start = source.indexOf(marker);
if (start < 0) throw new Error("Missing generated constant " + name + ".");
const valueStart = start + marker.length;
const valueEnd = source.indexOf(suffix, valueStart);
if (valueEnd < 0) throw new Error("Missing type suffix for " + name + ".");
return JSON.parse(source.slice(valueStart, valueEnd));
}
async function glbAnimationNames(url) {
const handle = await open(path.join(publicRoot, url.replace(/^\/+/, "")), "r");
try {
const header = Buffer.alloc(20);
await handle.read(header, 0, 20, 0);
if (header.toString("ascii", 0, 4) !== "glTF" || header.readUInt32LE(4) !== 2) {
throw new Error("not a GLB v2 file");
}
if (header.readUInt32LE(16) !== 0x4e4f534a) throw new Error("missing JSON chunk");
const json = Buffer.alloc(header.readUInt32LE(12));
await handle.read(json, 0, json.length, 20);
const document = JSON.parse(json.toString("utf8").trimEnd());
return (document.animations ?? []).map((animation, index) => (
animation.name ?? "animation-" + index
));
} finally {
await handle.close();
}
}
async function navigationGraph(file) {
const document = await new NodeIO().read(file);
const positions = [];
const vertex = new Vector3();
for (const node of document.getRoot().listNodes()) {
const mesh = node.getMesh();
if (!mesh) continue;
const world = new Matrix4().fromArray(node.getWorldMatrix());
for (const primitive of mesh.listPrimitives()) {
const source = primitive.getAttribute("POSITION")?.getArray();
if (!source) continue;
const sourceIndices = primitive.getIndices()?.getArray();
const indices = sourceIndices
? Array.from(sourceIndices)
: Array.from({ length: source.length / 3 }, (_, index) => index);
for (const index of indices) {
vertex.fromArray(source, index * 3).applyMatrix4(world);
positions.push(vertex.x, vertex.y, vertex.z);
}
}
}
if (!positions.length || positions.length % 9 !== 0) {
throw new Error("Navigation GLB contains no complete triangles.");
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(new Float32Array(positions), 3));
try {
return buildPartyNavigationGraph(geometry);
} finally {
geometry.dispose();
}
}
function auditObjectiveRoutes(dungeonId, graph, entrance, bosses, orderProvenance, issues) {
if (!Array.isArray(entrance) || entrance.length !== 3 || !entrance.every(Number.isFinite)) {
issue(issues, dungeonId, "environment metadata has no valid entrance foot position.");
return [];
}
const routes = auditOrderedObjectiveRoutes(graph, entrance, bosses).map((route) => ({
...route,
orderAuthoritative: orderProvenance.authoritative,
orderKind: orderProvenance.kind,
orderEvidence: orderProvenance.evidence,
}));
for (const result of routes) {
if (!result.reachable && result.orderAuthoritative) {
issue(issues, dungeonId, "packaged navigation cannot route " + result.from
+ " -> " + result.to + " with the live 3m/6m endpoint limits.");
}
}
return routes;
}
function validateAttack(dungeonId, entityId, attack, issues) {
const key = entityId + ":" + (attack?.id ?? "unknown");
if (!attack?.id || !attack?.name) issue(issues, dungeonId, key + " lacks an id or name.");
for (const field of ["animation", "delivery", "target", "school"]) {
if (!valid[field].has(attack?.[field])) {
issue(issues, dungeonId, key + " has unsupported " + field + " " + attack?.[field] + ".");
}
}
if (!Number.isFinite(attack?.range) || attack.range < 0) {
issue(issues, dungeonId, key + " has an invalid range.");
}
if (!Number.isFinite(attack?.cooldownMs) || attack.cooldownMs <= 0) {
issue(issues, dungeonId, key + " has an invalid cooldown.");
}
if (!Number.isFinite(attack?.damageMultiplier) || attack.damageMultiplier < 0) {
issue(issues, dungeonId, key + " has an invalid damage multiplier.");
}
}
function renderMarkdown(report) {
const rows = report.dungeons.map((dungeon) => [
"|", dungeon.id, "|", dungeon.sourceRows, "|", dungeon.runtimeSpawns, "|",
dungeon.runtimeEntities, "|", dungeon.bossObjectives, "|", dungeon.runtimeAttacks, "|",
dungeon.sourceSpellTemplates, "|", dungeon.scriptedTemplates, "|",
dungeon.nativeBindings + "/" + dungeon.proceduralBindings + "/" + dungeon.proxyBindings,
"|", dungeon.objectiveOrderAuthoritative
? (dungeon.objectiveRoutes - dungeon.blockedObjectiveRoutes) + "/" + dungeon.objectiveRoutes + " source"
: (dungeon.objectiveRoutes - dungeon.unreachableDerivedObjectiveRoutes) + "/" + dungeon.objectiveRoutes + " derived",
"|", dungeon.mechanicsStatus, "|",
].join(" "));
const summary = report.summary;
return [
"# RuneWaker gameplay content audit",
"",
"Structural validation: **" + report.validationStatus + "** ",
"Original-mechanics fidelity: **" + report.fidelityStatus + "**",
"",
"This offline audit reads packaged recipes, snapshots, generated TypeScript, reports,",
"mechanics evidence, manifests, and creature GLBs. It never connects to SQL Server.",
"",
"## Coverage",
"",
"- " + summary.dungeons + " dungeons, " + summary.sourceRows + " source rows, "
+ summary.runtimeSpawns + " static spawns, and " + summary.runtimeEntities + " combat entities.",
"- " + summary.bossObjectives + " boss objectives and " + summary.runtimeAttacks
+ " executable attacks.",
"- Source-authored objective navigation: "
+ (summary.sourceOrderedObjectiveRoutes - summary.blockedObjectiveRoutes) + "/"
+ summary.sourceOrderedObjectiveRoutes + " ordered entrance/boss legs are mesh-reachable.",
"- HealerMan-derived objective diagnostics: "
+ (summary.derivedObjectiveRoutes - summary.unreachableDerivedObjectiveRoutes) + "/"
+ summary.derivedObjectiveRoutes + " distance-ordered legs are mesh-reachable; unreachable derived legs are review evidence, not structural blockers.",
"- Animation entity bindings: " + summary.nativeBindings + " native, "
+ summary.proceduralBindings + " static procedural, and " + summary.proxyBindings + " proxy.",
"- " + summary.sourceSpellTemplates + " templates preserve source spell evidence ("
+ summary.sourceSpellAssignments + " assignments; " + summary.uniqueSourceSpellIds
+ " unique IDs); " + summary.scriptedTemplates + " preserve AutoPlot/Lua hook evidence.",
"",
"## Authentic source data currently used",
"",
"- Namespaced RuneWaker spawn/template IDs, names, native levels, classifications, positions,",
" directions, model paths, source hashes, and boss spawn membership.",
"- Navmesh-projected placements derived from each packaged environment anchor.",
"- Original static meshes; original GR2 clips only where the manifest and GLB prove they exist.",
"- Spell IDs, Lua hooks, AutoPlot names, and Lua signals are retained as evidence, not guessed.",
"",
"## HealerMan-generic or deferred",
"",
"- " + summary.genericDefaultAttacks + " entities use generated Basic Attack/Corrosive Slam.",
"- " + summary.builderAuthoredAttacks + " attacks are builder exceptions without source mapping.",
"- " + summary.sourceBackedDamageFamilyAttacks
+ " executable attacks carry a conservative source-backed direct-damage family mapping.",
"- Source-backed mapping preserves spell identity, damage family, school, range, and cooldown;",
" HealerMan's 0.85 damage coefficient is intentionally generic, not claimed as source-exact.",
"- Scaling, movement/leash, clustering, aggro, death state, loot, and objective ordering are generic.",
"- Doors, phases, summons, patrols, resets, interactions, and source loot remain review-required.",
"- Procedural animation states are HealerMan-authored; native GLBs must provide idle, move,",
" attack, cast, wound, and death families.",
"",
"## Per-dungeon evidence",
"",
"Animation columns are native/procedural/proxy entity bindings.",
"",
"| Dungeon | Source | Spawns | Entities | Bosses | Attacks | Spell templates | Scripted templates | N/P/X | Routes | Mechanics |",
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |",
...rows,
"",
"## Audited mechanics signals",
"",
...Object.entries(report.mechanicsCategories).map(([kind, count]) => "- " + kind + ": " + count),
"",
"## Remaining blocker groups",
"",
"Navigation: " + report.blockerGroups.navigation.blockedLegs + " blocked source-authored ordered legs across "
+ report.blockerGroups.navigation.affectedDungeons + " dungeons.",
"",
...report.blockerGroups.navigation.entries.map((entry) => "- " + entry.dungeonId
+ ": " + entry.from + " -> " + entry.to),
"",
"Derived-order navigation review: " + report.blockerGroups.navigation.unreachableDerivedLegs
+ " unreachable heuristic legs across "
+ report.blockerGroups.navigation.derivedAffectedDungeons + " dungeons.",
"",
...report.blockerGroups.navigation.derivedEntries.map((entry) => "- " + entry.dungeonId
+ ": " + entry.from + " -> " + entry.to + " (" + entry.orderKind + ")"),
"",
"Unimplemented source mechanics: " + report.blockerGroups.sourceMechanics.scriptedTemplates
+ " templates preserve AutoPlot/Lua evidence, with executable generation deliberately disabled.",
"",
...Object.entries(report.blockerGroups.sourceMechanics.evidenceCounts)
.map(([kind, count]) => "- " + kind + ": " + count),
"",
"## Validation issues",
"",
...(report.issues.length ? report.issues.map((entry) => "- " + entry) : ["- None."]),
"",
"## Documented exceptions",
"",
...(report.documentedExceptions.length
? report.documentedExceptions.map((entry) => "- " + entry)
: ["- None."]),
"",
"## Resolved runtime observations",
"",
...report.resolvedRuntimeObservations.map((entry) => "- " + entry.dungeonId + " "
+ entry.route + ": " + entry.observed + " " + entry.resolution
+ " Evidence: " + entry.evidence + "."),
"",
"## Review notes",
"",
...report.reviewNotes.map((entry) => "- " + entry),
"",
].join("\n");
}
const recipeDirectory = path.join(root, "scripts/runewaker-pipeline/recipes");
const recipeNames = (await readdir(recipeDirectory))
.filter((name) => name.endsWith("-population.json"))
.sort();
const issues = [];
const reviewNotes = [];
const documentedExceptions = [];
const dungeons = [];
const nativeModelUsers = new Map();
const sourceSpellIds = new Set();
const mechanicsCategories = {};
const spellCatalogFile = path.join(
root, "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
);
let spellCatalogSnapshot = null;
let spellCatalog = new Map();
try {
spellCatalogSnapshot = await readJson(spellCatalogFile);
if (spellCatalogSnapshot.status !== "portable"
|| spellCatalogSnapshot.safety?.runtimeSqlDependency !== false
|| spellCatalogSnapshot.safety?.unknownEffectsExecute !== false
|| spellCatalogSnapshot.safety?.coefficientsAreSourceExact !== false) {
issues.push("Portable RuneWaker spell catalog violates its fail-closed safety contract.");
}
if (spellCatalogSnapshot.spellCount !== spellCatalogSnapshot.spells?.length) {
issues.push("Portable RuneWaker spell catalog count does not match its records.");
}
spellCatalog = new Map(
(spellCatalogSnapshot.spells ?? []).map((spell) => [Number(spell.spellId), spell]),
);
} catch (error) {
issues.push("Could not load the portable RuneWaker spell catalog: " + error.message);
}
const registrySource = await readFile(
path.join(root, "src/game/generated/runewakerDungeonDefinitions.generated.ts"),
"utf8",
);
const registeredIds = new Set(
[...registrySource.matchAll(/^ "id": "([^"]+)",$/gm)].map((match) => match[1]),
);
registeredIds.add("forsaken-abbey");
if (recipeNames.length !== 30) {
issues.push("Expected 30 RuneWaker population recipes; found " + recipeNames.length + ".");
}
for (const recipeName of recipeNames) {
let recipe;
try {
recipe = await readJson(path.join(recipeDirectory, recipeName));
const dungeonId = recipe.dungeonId;
const prefix = constantCase(dungeonId);
const mechanicsFile = recipe.files.mechanicsReport
?? "src/assets/game/dungeons/" + dungeonId + "/mechanics-audit.json";
const [snapshot, populationReport, mechanics, manifest, environment, source] = await Promise.all([
readJson(path.resolve(root, recipe.files.snapshot)),
readJson(path.resolve(root, recipe.files.report)),
readJson(path.resolve(root, mechanicsFile)),
readJson(path.resolve(root, recipe.files.actorManifest)),
readJson(path.resolve(root, recipe.files.environmentMetadata)),
readFile(path.resolve(root, recipe.files.generatedSource), "utf8"),
]);
const entities = generatedJson(
source, prefix + "_ENTITIES", " as const satisfies PopulationDefinitionMap;",
);
const spawns = generatedJson(
source, prefix + "_STATIC_SPAWNS",
" as const satisfies readonly StaticMobSpawnDefinition[];",
);
const bosses = generatedJson(
source, prefix + "_BOSSES",
" as const satisfies readonly DungeonBossObjective[];",
);
const provenance = generatedJson(
source, prefix + "_POPULATION_PROVENANCE", " as const;",
);
if (!registeredIds.has(dungeonId)) issue(issues, dungeonId, "has a recipe but is not registered.");
if (snapshot.dungeonId !== dungeonId || mechanics.dungeonId !== dungeonId
|| manifest.dungeonId !== dungeonId) {
issue(issues, dungeonId, "portable artifacts disagree on dungeon id.");
}
if (snapshot.zoneId !== recipe.zoneId || mechanics.zoneId !== recipe.zoneId
|| provenance.zoneId !== recipe.zoneId) {
issue(issues, dungeonId, "portable artifacts disagree on zone id.");
}
if (snapshot.rowCount !== snapshot.rows.length
|| snapshot.rows.length !== populationReport.sourceRowCount) {
issue(issues, dungeonId, "snapshot/report source row counts disagree.");
}
if (Number.isInteger(recipe.expectedActiveRows)
&& snapshot.rows.length !== recipe.expectedActiveRows) {
issue(issues, dungeonId, "expected " + recipe.expectedActiveRows
+ " source rows; found " + snapshot.rows.length + ".");
}
if (populationReport.status !== "green" || manifest.status !== "green") {
issue(issues, dungeonId, "population report or actor manifest is not green.");
}
if (mechanics.status !== "review-required"
|| mechanics.safety?.executableMechanicsGenerated !== false) {
issue(issues, dungeonId, "mechanics evidence is not explicitly fail-closed.");
}
if (snapshot.contentSha256 !== populationReport.sourceContentSha256
|| snapshot.contentSha256 !== provenance.sourceContentSha256
|| snapshot.contentSha256 !== mechanics.source?.snapshotContentSha256) {
issue(issues, dungeonId, "snapshot provenance hashes disagree.");
}
if (populationReport.importedSpawnCount !== spawns.length
|| populationReport.entityCount !== Object.keys(entities).length
|| populationReport.bossSpawnCount !== bosses.length
|| provenance.importedSpawnCount !== spawns.length
|| provenance.bossSpawnCount !== bosses.length) {
issue(issues, dungeonId, "generated runtime counts disagree with reports/provenance.");
}
const templateById = new Map(recipe.templates.map((template) => [template.id, template]));
const candidateById = new Map(
mechanics.templateCandidates.map((candidate) => [candidate.templateId, candidate]),
);
const sourceSpawnIds = new Set();
const sourceTemplateIds = new Set();
for (const row of snapshot.rows) {
if (sourceSpawnIds.has(row.spawnId)) {
issue(issues, dungeonId, "duplicates source spawn " + row.spawnId + ".");
}
sourceSpawnIds.add(row.spawnId);
sourceTemplateIds.add(row.templateId);
const template = templateById.get(row.templateId);
if (!template) issue(issues, dungeonId, "source template " + row.templateId + " is unmapped.");
else if (template.classification !== row.classification) {
issue(issues, dungeonId, "classification drift for template " + row.templateId + ".");
}
}
const counts = {
runtimeAttacks: 0,
genericDefaultAttacks: 0,
builderAuthoredAttacks: 0,
recipeAuthoredAttacks: 0,
sourceBackedDamageFamilyAttacks: 0,
nativeBindings: 0,
proceduralBindings: 0,
proxyBindings: 0,
sourceSpellTemplates: 0,
sourceSpellAssignments: 0,
scriptedTemplates: 0,
objectiveRoutes: 0,
blockedObjectiveRoutes: 0,
sourceOrderedObjectiveRoutes: 0,
derivedObjectiveRoutes: 0,
unreachableDerivedObjectiveRoutes: 0,
};
for (const candidate of mechanics.templateCandidates) {
const sourceSpells = candidate.spells ?? [];
if (sourceSpells.length) counts.sourceSpellTemplates += 1;
counts.sourceSpellAssignments += sourceSpells.length;
for (const spell of sourceSpells) sourceSpellIds.add(spell.id);
if ((candidate.autoPlots?.length ?? 0) || (candidate.plotClassNames?.length ?? 0)
|| (candidate.templateScripts?.length ?? 0)) counts.scriptedTemplates += 1;
}
for (const [kind, count] of Object.entries(mechanics.categories ?? {})) {
mechanicsCategories[kind] = (mechanicsCategories[kind] ?? 0) + Number(count);
}
for (const [entityId, entity] of Object.entries(entities)) {
const templateId = Number(entityId.slice(entityId.lastIndexOf("-") + 1));
const template = templateById.get(templateId);
if (!template || !["combat", "boss"].includes(template.classification)) {
issue(issues, dungeonId, entityId + " does not resolve to a combat template.");
} else {
if (entity.name !== template.name || entity.combat?.level !== template.level) {
issue(issues, dungeonId, entityId + " name or native level drifted.");
}
if ((entity.kind === "boss") !== (template.classification === "boss")) {
issue(issues, dungeonId, entityId + " boss classification drifted.");
}
}
const attacks = entity.combat?.attacks ?? [];
if (!attacks.length) issue(issues, dungeonId, entityId + " has no combat family.");
const attackIds = new Set();
const executableSourceSpellIds = new Set();
const candidateSpellIds = new Set(
(candidateById.get(templateId)?.spells ?? []).map((spell) => Number(spell.id)),
);
for (const attack of attacks) {
counts.runtimeAttacks += 1;
validateAttack(dungeonId, entityId, attack, issues);
if (attackIds.has(attack.id)) {
issue(issues, dungeonId, entityId + " duplicates attack " + attack.id + ".");
}
attackIds.add(attack.id);
const recipeAttack = template?.attacks?.find((entry) => entry.id === attack.id);
const executableSpellId = Number(attack.spellId ?? attack.source?.spellId ?? 0);
if (executableSpellId > 0) {
executableSourceSpellIds.add(executableSpellId);
const catalogSpell = spellCatalog.get(executableSpellId);
if (!candidateSpellIds.has(executableSpellId)) {
issue(issues, dungeonId, entityId + " executes spell " + executableSpellId
+ " without source assignment evidence.");
}
if (!catalogSpell) {
issue(issues, dungeonId, entityId + " executes spell " + executableSpellId
+ " absent from the portable source catalog.");
} else if (catalogSpell.mappingStatus !== "damage-family" || !catalogSpell.runtime) {
issue(issues, dungeonId, entityId + " executes evidence-only spell "
+ executableSpellId + ".");
} else {
counts.sourceBackedDamageFamilyAttacks += 1;
if (attack.spellId && [
"name", "delivery", "target", "school", "animation", "range",
"cooldownMs", "damageMultiplier",
].some((field) => attack[field] !== (
field === "name" ? catalogSpell.name : catalogSpell.runtime[field]
))) {
issue(issues, dungeonId, entityId + " spell " + executableSpellId
+ " drifted from the portable source-backed runtime mapping.");
}
}
}
if (recipeAttack) {
counts.recipeAuthoredAttacks += 1;
const sourceSpellId = Number(recipeAttack.source?.spellId ?? 0);
if (sourceSpellId > 0 && !candidateSpellIds.has(sourceSpellId)) {
issue(issues, dungeonId, entityId + " recipe attack cites unassigned source spell "
+ sourceSpellId + ".");
}
} else if (attack.id.endsWith("-basic-attack")) {
counts.genericDefaultAttacks += 1;
} else if (!attack.spellId) {
counts.builderAuthoredAttacks += 1;
}
}
if (!template?.attacks?.length) {
for (const sourceSpellId of candidateSpellIds) {
const catalogSpell = spellCatalog.get(sourceSpellId);
if (catalogSpell?.mappingStatus === "damage-family"
&& !executableSourceSpellIds.has(sourceSpellId)) {
issue(issues, dungeonId, entityId + " omits source-backed damage spell "
+ sourceSpellId + ".");
}
}
}
const model = entity.visual?.model;
if (model) {
const asset = manifest.assets.find((entry) => entry.url === model.url);
if (!asset) issue(issues, dungeonId, entityId + " model is absent from its manifest.");
if (!["native", "procedural"].includes(model.animationMode)) {
issue(issues, dungeonId, entityId + " has no explicit animation mode.");
} else if (model.animationMode === "native") {
counts.nativeBindings += 1;
if (!(asset?.animations?.length > 0)) {
issue(issues, dungeonId, entityId + " claims native animation without clips.");
}
const users = nativeModelUsers.get(model.url) ?? [];
users.push(dungeonId + ":" + entityId);
nativeModelUsers.set(model.url, users);
} else {
counts.proceduralBindings += 1;
if (asset?.animations?.length) {
issue(issues, dungeonId, entityId + " ignores packaged clips with procedural mode.");
}
if (!valid.archetype.has(entity.visual?.archetype)) {
issue(issues, dungeonId, entityId + " lacks a supported procedural archetype.");
}
}
} else {
counts.proxyBindings += 1;
if (!valid.archetype.has(entity.visual?.archetype)) {
issue(issues, dungeonId, entityId + " has no model or supported proxy.");
}
}
}
const spawnById = new Map();
for (const spawn of spawns) {
if (spawnById.has(spawn.id)) issue(issues, dungeonId, "duplicates spawn " + spawn.id + ".");
spawnById.set(spawn.id, spawn);
if (!entities[spawn.entityId]) issue(issues, dungeonId, spawn.id + " has no entity.");
if (!spawn.id.startsWith("rw-") || !spawn.entityId.startsWith("rw-")) {
issue(issues, dungeonId, spawn.id + " is not RuneWaker-namespaced.");
}
if (!Array.isArray(spawn.position) || spawn.position.length !== 3
|| !spawn.position.every(Number.isFinite)) {
issue(issues, dungeonId, spawn.id + " has an invalid position.");
}
if (!Number.isFinite(spawn.yaw)) issue(issues, dungeonId, spawn.id + " has an invalid yaw.");
}
const bossIds = new Set();
for (const boss of bosses) {
if (bossIds.has(boss.id)) issue(issues, dungeonId, "duplicates objective " + boss.id + ".");
bossIds.add(boss.id);
const spawn = spawnById.get(boss.id);
if (!spawn) issue(issues, dungeonId, boss.id + " objective has no spawn.");
else {
if (entities[spawn.entityId]?.kind !== "boss") {
issue(issues, dungeonId, boss.id + " objective does not target a boss.");
}
if (!samePosition(spawn.position, boss.position)) {
issue(issues, dungeonId, boss.id + " objective position drifted.");
}
}
if (!boss.name?.trim()) issue(issues, dungeonId, boss.id + " objective has no name.");
}
for (const spawn of spawns) {
if (entities[spawn.entityId]?.kind === "boss" && !bossIds.has(spawn.id)) {
issue(issues, dungeonId, spawn.id + " boss has no objective.");
}
}
let objectiveRouteEvidence = [];
const configuredObjectiveOrder = recipe.bossObjectiveOrder;
const objectiveOrderProvenance = configuredObjectiveOrder
? {
authoritative: true,
kind: configuredObjectiveOrder.kind ?? "source-authored",
evidence: configuredObjectiveOrder.evidence ?? [],
}
: {
authoritative: false,
kind: "healerMan-distance-derived",
evidence: ["scripts/runewaker-pipeline/lib/objective-order.mjs"],
};
if (configuredObjectiveOrder && !objectiveOrderProvenance.evidence.length) {
issue(issues, dungeonId, "source-authored boss objective order lacks evidence metadata.");
}
if (bosses.length) {
try {
const graph = await navigationGraph(path.resolve(root, recipe.files.navigation));
objectiveRouteEvidence = auditObjectiveRoutes(
dungeonId,
graph,
environment.entrance?.footPosition,
bosses,
objectiveOrderProvenance,
issues,
);
} catch (error) {
issue(issues, dungeonId, "could not audit objective navigation: " + error.message);
}
}
counts.objectiveRoutes = objectiveRouteEvidence.length;
counts.sourceOrderedObjectiveRoutes = objectiveOrderProvenance.authoritative
? objectiveRouteEvidence.length
: 0;
counts.blockedObjectiveRoutes = objectiveRouteEvidence.filter((route) => (
route.orderAuthoritative && !route.reachable
)).length;
counts.derivedObjectiveRoutes = objectiveOrderProvenance.authoritative
? 0
: objectiveRouteEvidence.length;
counts.unreachableDerivedObjectiveRoutes = objectiveRouteEvidence.filter((route) => (
!route.orderAuthoritative && !route.reachable
)).length;
const expectedEntities = recipe.templates.filter((template) => (
["combat", "boss"].includes(template.classification) && sourceTemplateIds.has(template.id)
)).length;
if (expectedEntities !== Object.keys(entities).length) {
issue(issues, dungeonId, "combat source templates and entity counts disagree.");
}
if (!snapshot.rows.length) {
reviewNotes.push(dungeonId + " has an authoritative empty Zone " + recipe.zoneId
+ " snapshot, so it packages an environment but no combat population.");
}
dungeons.push({
id: dungeonId,
zoneId: recipe.zoneId,
sourceRows: snapshot.rows.length,
runtimeSpawns: spawns.length,
runtimeEntities: Object.keys(entities).length,
bossObjectives: bosses.length,
mechanicsStatus: mechanics.status,
objectiveOrderAuthoritative: objectiveOrderProvenance.authoritative,
objectiveOrderProvenance,
objectiveRouteEvidence,
...counts,
});
} catch (error) {
const dungeonId = recipe?.dungeonId ?? recipeName.replace(/-population\.json$/, "");
issue(issues, dungeonId, "could not validate its package: " + error.message);
}
}
const auditedIds = new Set(dungeons.map((dungeon) => dungeon.id));
for (const dungeonId of registeredIds) {
if (!auditedIds.has(dungeonId)) issue(issues, dungeonId, "registered without a validated recipe.");
}
for (const [url, users] of nativeModelUsers) {
try {
const names = await glbAnimationNames(url);
for (const [family, pattern] of Object.entries(nativeFamilies)) {
if (!names.some((name) => pattern.test(name))) {
const exemption = nativeFamilyExemptions.get(url)?.get(family);
if (exemption) {
documentedExceptions.push(url + ": " + family + " exempt - " + exemption);
} else {
issues.push(url + ": missing native " + family + " family for " + users.join(", ") + ".");
}
}
}
} catch (error) {
issues.push(url + ": could not inspect native animations (" + error.message + ").");
}
}
const summaryKeys = [
"sourceRows", "runtimeSpawns", "runtimeEntities", "bossObjectives", "runtimeAttacks",
"genericDefaultAttacks", "builderAuthoredAttacks", "recipeAuthoredAttacks",
"sourceBackedDamageFamilyAttacks", "sourceSpellTemplates", "sourceSpellAssignments",
"scriptedTemplates", "nativeBindings", "proceduralBindings", "proxyBindings",
"objectiveRoutes", "blockedObjectiveRoutes", "sourceOrderedObjectiveRoutes",
"derivedObjectiveRoutes", "unreachableDerivedObjectiveRoutes",
];
const summary = Object.fromEntries(summaryKeys.map((key) => [
key, dungeons.reduce((total, dungeon) => total + dungeon[key], 0),
]));
summary.dungeons = dungeons.length;
summary.uniqueNativeModels = nativeModelUsers.size;
summary.uniqueSourceSpellIds = sourceSpellIds.size;
summary.portableSpellCatalogRecords = spellCatalog.size;
summary.portableDamageFamilyRecords = spellCatalogSnapshot?.damageFamilyCount ?? 0;
summary.portableEvidenceOnlyRecords = spellCatalogSnapshot?.evidenceOnlyCount ?? 0;
if (summary.sourceBackedDamageFamilyAttacks < summary.runtimeAttacks) {
reviewNotes.push((summary.runtimeAttacks - summary.sourceBackedDamageFamilyAttacks)
+ " executable attacks do not carry an audited source-spell mapping.");
}
if (dungeons.some((dungeon) => dungeon.bossObjectives && !dungeon.sourceBackedDamageFamilyAttacks)) {
reviewNotes.push("Boss objectives are spawn-authentic, but boss attacks and state machines remain generic.");
}
reviewNotes.push("Source-backed spell coefficients are HealerMan-generic; the original RuneWaker "
+ "formula/stat scaling has not been reconstructed.");
const navigationBlockerEntries = dungeons.flatMap((dungeon) => (
dungeon.objectiveRouteEvidence
.filter((route) => route.orderAuthoritative && !route.reachable)
.map((route) => ({
dungeonId: dungeon.id,
from: route.from,
to: route.to,
objectiveId: route.objectiveId,
}))
));
const navigationBlockerDungeons = new Set(
navigationBlockerEntries.map((entry) => entry.dungeonId),
);
const derivedNavigationReviewEntries = dungeons.flatMap((dungeon) => (
dungeon.objectiveRouteEvidence
.filter((route) => !route.orderAuthoritative && !route.reachable)
.map((route) => ({
dungeonId: dungeon.id,
from: route.from,
to: route.to,
objectiveId: route.objectiveId,
orderKind: route.orderKind,
}))
));
const derivedNavigationReviewDungeons = new Set(
derivedNavigationReviewEntries.map((entry) => entry.dungeonId),
);
if (summary.derivedObjectiveRoutes) {
reviewNotes.push(summary.derivedObjectiveRoutes + " objective legs use HealerMan's distance-from-entrance "
+ "fallback because no source-authored boss order is packaged; "
+ summary.unreachableDerivedObjectiveRoutes + " unreachable heuristic legs remain navigation/order review evidence.");
}
const report = {
schemaVersion: 1,
validationStatus: issues.length ? "blocked" : "green",
fidelityStatus: "review-required",
scope: "All configured offline RuneWaker dungeon packages",
summary,
mechanicsCategories: Object.fromEntries(Object.entries(mechanicsCategories).sort()),
authenticRuntimeInputs: [
"namespaced spawn/template ids", "names", "native levels", "classifications",
"source positions/directions", "model paths", "source hashes", "boss spawn membership",
],
genericRuntimeSystems: [
"unmapped attacks", "health/damage scaling", "movement/leash", "clustering/aggro",
"combat/death state", "loot policy", "objective ordering", "procedural animation",
],
deferredMechanics: [
"doors/gates", "summons/waves", "phases", "patrols", "interactions",
"encounter resets", "source loot",
],
blockerGroups: {
navigation: {
blockedLegs: navigationBlockerEntries.length,
affectedDungeons: navigationBlockerDungeons.size,
entries: navigationBlockerEntries,
unreachableDerivedLegs: derivedNavigationReviewEntries.length,
derivedAffectedDungeons: derivedNavigationReviewDungeons.size,
derivedEntries: derivedNavigationReviewEntries,
},
sourceMechanics: {
status: "review-required",
executableMechanicsGenerated: false,
scriptedTemplates: summary.scriptedTemplates,
evidenceCounts: Object.fromEntries(Object.entries(mechanicsCategories).sort()),
},
},
spellCatalog: {
file: projectPath(spellCatalogFile),
status: spellCatalogSnapshot?.status ?? "missing",
safety: spellCatalogSnapshot?.safety ?? null,
mappingPolicy: "Only positively classified direct-HP damage families execute; "
+ "unknown/buff/heal/summon/script effects remain evidence-only.",
},
resolvedRuntimeObservations,
documentedExceptions,
issues,
reviewNotes,
dungeons: dungeons.sort((left, right) => left.id.localeCompare(right.id)),
};
await Promise.all([
writeFile(reportJson, JSON.stringify(report, null, 2) + "\n", "utf8"),
writeFile(reportMarkdown, renderMarkdown(report), "utf8"),
]);
console.log(JSON.stringify({
validationStatus: report.validationStatus,
fidelityStatus: report.fidelityStatus,
reportJson: projectPath(reportJson),
reportMarkdown: projectPath(reportMarkdown),
summary,
issues,
reviewNotes,
}, null, 2));
if (issues.length) process.exitCode = 1;