805 lines
36 KiB
JavaScript
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;
|