#!/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;