#!/usr/bin/env node import { execFile } from "node:child_process"; import { open, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { projectPath, projectRoot, readJson, sourceRootFor, validatePopulationRecipe, } from "./lib/recipe.mjs"; const recipeRoot = path.join(projectRoot, "scripts", "runewaker-pipeline", "recipes"); const outputRoot = path.join(projectRoot, "artifacts", "animation-audit"); const jsonFile = path.join(outputRoot, "inventory.json"); const markdownFile = path.join(outputRoot, "inventory.md"); const copyZones = new Map([ ["demon-stronghold-125", "demon-stronghold"], ["zurhidon-stronghold-124", "zurhidon-stronghold"], ]); const expected = { dungeons: 30, entityTemplates: 436, nativeEntityMappings: 435, poseOnlyEntityMappings: 1, actorPackages: 268, shippingAssetVariants: 322, emptyCopyZones: 2, }; const glbCache = new Map(); const execFileAsync = promisify(execFile); function constantCase(value) { return String(value).replaceAll("-", "_").toUpperCase(); } /* Superseded malformed separator literal retained only as inert text. function normalizeResource(value) { return String(value ?? "").replaceAll("\", "/").replace(/^\/+/, "").toLowerCase(); } */ function normalizeResource(value) { const separator = String.fromCharCode(92); return String(value ?? "").replaceAll(separator, "/").replace(/^[/]+/, "").toLowerCase(); } function resourceFile(resourceRoot, resource) { return path.join(resourceRoot, ...normalizeResource(resource).split("/")); } 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 generated suffix for " + name + "."); return JSON.parse(source.slice(valueStart, valueEnd)); } async function fileInfo(file) { try { const value = await stat(file); return { exists: value.isFile(), size: value.isFile() ? value.size : null }; } catch (error) { if (error?.code === "ENOENT") return { exists: false, size: null }; throw error; } } async function convertedRawCandidates(directory) { try { return (await readdir(directory, { withFileTypes: true })) .filter((entry) => entry.isFile() && /\.raw\.glb$/i.test(entry.name)) .map((entry) => projectPath(path.join(directory, entry.name))) .sort(); } catch (error) { if (error?.code === "ENOENT") return []; throw error; } } async function inspectGlb(file) { if (!glbCache.has(file)) { glbCache.set(file, (async () => { let handle; try { handle = await open(file, "r"); const header = Buffer.alloc(20); const headerRead = await handle.read(header, 0, header.length, 0); if (headerRead.bytesRead < 20 || header.toString("ascii", 0, 4) !== "glTF") { throw new Error("not a GLB"); } if (header.readUInt32LE(4) !== 2 || header.readUInt32LE(16) !== 0x4e4f534a) { throw new Error("unsupported GLB header"); } const jsonLength = header.readUInt32LE(12); const jsonBytes = Buffer.alloc(jsonLength); const jsonRead = await handle.read(jsonBytes, 0, jsonLength, 20); if (jsonRead.bytesRead !== jsonLength) throw new Error("truncated GLB JSON"); const document = JSON.parse(jsonBytes.toString("utf8").replace(/\0+$/, "").trim()); return { exists: true, valid: true, animationNames: (document.animations ?? []) .map((animation, index) => animation.name ?? "animation-" + index) .sort(), }; } catch (error) { if (error?.code === "ENOENT") { return { exists: false, valid: false, error: "missing GLB", animationNames: [] }; } return { exists: true, valid: false, error: error instanceof Error ? error.message : String(error), animationNames: [], }; } finally { await handle?.close(); } })()); } return glbCache.get(file); } function semanticFamily(name) { const prefix = String(name).split(" - ", 1)[0].trim().toLowerCase(); if (prefix === "stand" || prefix === "idle") return "idle"; if (prefix === "walk" || prefix === "run") return "move"; if (prefix === "attack") return "attack"; if (prefix === "cast") return "cast"; if (prefix === "wound" || prefix === "hit") return "wound"; if (prefix === "death" || prefix === "dead" || prefix === "terminal pose") return "death"; if (prefix === "action") return "action"; return "other"; } function semanticClips(names) { const result = {}; for (const name of [...names].sort()) { const family = semanticFamily(name); if (!result[family]) result[family] = []; result[family].push(name); } return Object.fromEntries(Object.entries(result).sort(([left], [right]) => left.localeCompare(right))); } function sameValues(left, right) { return [...left].sort().join("\n") === [...right].sort().join("\n"); } function markdownSafe(value) { return String(value ?? "").replaceAll("|", "\\|").replaceAll("\n", " "); } function countBy(rows, key) { const result = {}; for (const row of rows) result[row[key]] = (result[row[key]] ?? 0) + 1; return Object.fromEntries(Object.entries(result).sort(([left], [right]) => left.localeCompare(right))); } const findings = []; function addFinding(severity, scope, key, code, message, paths = []) { const finding = { severity, scope, key, code, message, paths: [...paths].sort() }; findings.push(finding); return code; } const recipeNames = (await readdir(recipeRoot)) .filter((name) => name.endsWith("-population.json")) .sort(); const dungeons = []; const actorPackages = []; const shippingAssetVariants = []; const entityTemplates = []; const unusedRecipeActors = []; let allManifestAssetVariants = 0; let unusedManifestAssetVariants = 0; const readinessScript = path.join( projectRoot, "scripts", "runewaker-pipeline", "audit-animation-readiness.mjs", ); let readinessOutput; try { readinessOutput = await execFileAsync(process.execPath, [readinessScript, "--json"], { cwd: projectRoot, windowsHide: true, maxBuffer: 64 * 1024 * 1024, }); } catch (error) { if (!error?.stdout) throw error; readinessOutput = { stdout: error.stdout }; } const readiness = JSON.parse(readinessOutput.stdout); const readinessActors = new Map(); const readinessIssues = new Map(); for (const dungeon of readiness.dungeons ?? []) { for (const actor of dungeon.actors ?? []) { readinessActors.set(dungeon.dungeonId + ":" + actor.id, actor); } for (const sourceIssue of dungeon.issues ?? []) { if (!sourceIssue.actorId) continue; const key = dungeon.dungeonId + ":" + sourceIssue.actorId; const rows = readinessIssues.get(key) ?? []; rows.push(sourceIssue); readinessIssues.set(key, rows); } } for (const recipeName of recipeNames) { const recipeFile = path.join(recipeRoot, recipeName); const recipe = validatePopulationRecipe(await readJson(recipeFile), projectPath(recipeFile)); const [manifest, generatedSource] = await Promise.all([ readJson(path.resolve(projectRoot, recipe.files.actorManifest)), readFile(path.resolve(projectRoot, recipe.files.generatedSource), "utf8"), ]); const entities = generatedJson( generatedSource, constantCase(recipe.dungeonId) + "_ENTITIES", " as const satisfies PopulationDefinitionMap;", ); const combatTemplates = recipe.templates .filter((template) => ["combat", "boss"].includes(template.classification)) .sort((left, right) => Number(left.id) - Number(right.id) || left.name.localeCompare(right.name)); const configuredActorIds = new Set(combatTemplates.map((template) => template.actor)); const actorsById = new Map(recipe.actors.map((actor) => [actor.id, actor])); const manifestGroups = new Map(); for (const asset of manifest.assets ?? []) { allManifestAssetVariants += 1; const baseActorId = asset.baseActorId ?? asset.id; const group = manifestGroups.get(baseActorId) ?? []; group.push(asset); manifestGroups.set(baseActorId, group); if (!configuredActorIds.has(baseActorId)) unusedManifestAssetVariants += 1; } for (const group of manifestGroups.values()) group.sort((left, right) => left.id.localeCompare(right.id)); const fallbackById = new Map( (manifest.proceduralFallbacks ?? []).map((fallback) => [fallback.id, fallback]), ); const resourceRoot = path.join(sourceRootFor(recipe), recipe.source.resourceRoot); const actorPackageById = new Map(); const assetVariantByUrl = new Map(); for (const actor of recipe.actors) { if (!configuredActorIds.has(actor.id)) { unusedRecipeActors.push({ dungeonId: recipe.dungeonId, actorId: actor.id, sourceRos: normalizeResource(actor.sourceModel), manifestAssetVariants: (manifestGroups.get(actor.id) ?? []).map((asset) => asset.id), proceduralFallback: fallbackById.has(actor.id), }); } } for (const actorId of [...configuredActorIds].sort()) { const packageKey = recipe.dungeonId + ":" + actorId; const actor = actorsById.get(actorId); const readinessActor = readinessActors.get(packageKey) ?? null; const sourceGraphIssues = readinessIssues.get(packageKey) ?? []; const assets = manifestGroups.get(actorId) ?? []; const fallback = fallbackById.get(actorId) ?? null; const localFindingCodes = []; if (!actor) { localFindingCodes.push(addFinding( "blocker", "actor-package", packageKey, "recipe-actor-missing", "Configured templates reference an actor absent from the recipe.", )); } if (assets.length && fallback) { localFindingCodes.push(addFinding( "blocker", "actor-package", packageKey, "manifest-path-ambiguous", "Actor is represented by both shipping GLB assets and a procedural fallback.", )); } if (!assets.length && !fallback) { localFindingCodes.push(addFinding( "blocker", "actor-package", packageKey, "manifest-path-missing", "Actor has neither shipping GLB assets nor a procedural fallback.", )); } const sourceRos = normalizeResource(actor?.sourceModel); const sourceFile = actor ? resourceFile(resourceRoot, sourceRos) : null; const sourceInfo = sourceFile ? await fileInfo(sourceFile) : { exists: false, size: null }; if (!sourceInfo.exists) { localFindingCodes.push(addFinding( "warning", "actor-package", packageKey, "source-ros-missing", "The configured source ROS is absent from the preserved resource tree.", sourceFile ? [projectPath(sourceFile)] : [], )); } for (const sourceIssue of sourceGraphIssues) { localFindingCodes.push(addFinding( sourceIssue.severity, "source-animation-graph", packageKey, sourceIssue.code, sourceIssue.message, [sourceIssue.message], )); } const animationMode = fallback ? "dynamic-proxy" : actor?.animationException?.kind === "pose-only" ? "pose-only" : "native"; const packageRow = { key: packageKey, dungeonId: recipe.dungeonId, actorId, animationMode, sourceRos: { resource: sourceRos || null, resolvedPath: sourceFile ? projectPath(sourceFile) : null, exists: sourceInfo.exists, size: sourceInfo.size, }, sourceAnimationGraph: readinessActor?.source ?? null, shippingAssetVariantKeys: [], templateEntityKeys: [], fallback: fallback ? { sourceModel: fallback.sourceModel ?? null, reason: fallback.reason ?? null, } : null, findingCodes: localFindingCodes, }; actorPackages.push(packageRow); actorPackageById.set(actorId, packageRow); for (const asset of assets) { const assetKey = recipe.dungeonId + ":" + asset.id; const shippingFile = path.resolve( projectRoot, "public", String(asset.url ?? "").replace(/^\/+/, ""), ); const convertedDirectory = path.join( projectRoot, "runewaker-export-work", recipe.dungeonId + "-population", "actors", asset.id, "converted", ); const rawAnimatedFile = path.join(convertedDirectory, asset.id + ".animated.raw.glb"); const rawStaticFile = path.join(convertedDirectory, asset.id + ".raw.glb"); const [shippingInfo, shippingStat, rawAnimatedInfo, rawStaticInfo, rawCandidates] = await Promise.all([ inspectGlb(shippingFile), fileInfo(shippingFile), fileInfo(rawAnimatedFile), fileInfo(rawStaticFile), convertedRawCandidates(convertedDirectory), ]); const manifestClipNames = [...(asset.animations ?? [])].sort(); const actualClipNames = shippingInfo.animationNames; const localAssetFindings = []; if (!asset.url) { localAssetFindings.push(addFinding( "blocker", "shipping-asset", assetKey, "shipping-url-missing", "Configured manifest asset has no authoritative shipping URL.", )); } if (!shippingInfo.exists || !shippingInfo.valid) { localAssetFindings.push(addFinding( "blocker", "shipping-asset", assetKey, "shipping-glb-missing-or-invalid", shippingInfo.error ?? "Shipping GLB is missing or invalid.", [projectPath(shippingFile)], )); } if (shippingInfo.valid && !sameValues(manifestClipNames, actualClipNames)) { localAssetFindings.push(addFinding( "blocker", "shipping-asset", assetKey, "shipping-clip-manifest-mismatch", "Shipping GLB animation names differ from its authoritative manifest.", [projectPath(shippingFile)], )); } if (!rawAnimatedInfo.exists) { localAssetFindings.push(addFinding( "warning", "shipping-asset", assetKey, "raw-animation-cache-missing", "The expected uncompressed animated raw GLB is absent; shipping manifest URL remains authoritative.", [projectPath(rawAnimatedFile), ...rawCandidates], )); } if (asset.sourceModel && normalizeResource(asset.sourceModel) !== sourceRos) { localAssetFindings.push(addFinding( "warning", "shipping-asset", assetKey, "source-ros-ambiguous", "Manifest asset sourceModel differs from the configured base actor source ROS.", [sourceRos, normalizeResource(asset.sourceModel)], )); } const clips = semanticClips(actualClipNames); const assetRow = { key: assetKey, dungeonId: recipe.dungeonId, actorPackageKey: packageKey, actorId, assetId: asset.id, baseActorId: asset.baseActorId ?? asset.id, sourceImageId: asset.sourceImageId ?? null, animationMode, sourceRos: normalizeResource(asset.sourceModel ?? actor?.sourceModel) || null, shippingGlb: { url: asset.url ?? null, path: projectPath(shippingFile), exists: shippingInfo.exists, valid: shippingInfo.valid, size: shippingStat.size, }, rebuiltAnimatedRawGlb: { expectedPath: projectPath(rawAnimatedFile), exists: rawAnimatedInfo.exists, size: rawAnimatedInfo.size, cacheCandidates: rawCandidates, }, rebuiltStaticRawGlb: { path: projectPath(rawStaticFile), exists: rawStaticInfo.exists, size: rawStaticInfo.size, }, semanticClipFamilies: Object.keys(clips), semanticClips: clips, clipCount: actualClipNames.length, manifestClipCount: manifestClipNames.length, templateEntityKeys: [], findingCodes: localAssetFindings, }; shippingAssetVariants.push(assetRow); packageRow.shippingAssetVariantKeys.push(assetKey); if (asset.url) assetVariantByUrl.set(asset.url, assetRow); } } for (const template of combatTemplates) { const entityId = recipe.runtimeIdPrefix + "-" + template.id; const entityKey = recipe.dungeonId + ":" + template.id; const entity = entities[entityId]; const actorPackage = actorPackageById.get(template.actor); const shippingUrl = entity?.visual?.model?.url ?? null; const asset = shippingUrl ? assetVariantByUrl.get(shippingUrl) : null; const localFindingCodes = []; if (!entity) { localFindingCodes.push(addFinding( "blocker", "entity-template", entityKey, "generated-entity-missing", "Configured combat template has no generated entity.", )); } if (entity && entity.name !== template.name) { localFindingCodes.push(addFinding( "blocker", "entity-template", entityKey, "generated-entity-name-mismatch", "Generated entity name differs from the configured template name.", )); } if (shippingUrl && !asset) { localFindingCodes.push(addFinding( "blocker", "entity-template", entityKey, "entity-shipping-path-ambiguous", "Generated entity shipping URL does not resolve to its configured manifest actor group.", [shippingUrl], )); } if (!shippingUrl && actorPackage?.animationMode !== "dynamic-proxy") { localFindingCodes.push(addFinding( "blocker", "entity-template", entityKey, "entity-shipping-path-missing", "Generated entity has no shipping GLB URL and is not a documented dynamic proxy.", )); } if (normalizeResource(template.source?.modelPath) && actorPackage?.sourceRos.resource && normalizeResource(template.source.modelPath) !== actorPackage.sourceRos.resource) { localFindingCodes.push(addFinding( "warning", "entity-template", entityKey, "template-source-ros-ambiguous", "Template source modelPath differs from its configured actor source ROS.", [normalizeResource(template.source.modelPath), actorPackage.sourceRos.resource], )); } const animationMode = actorPackage?.animationMode ?? "missing"; const record = { key: entityKey, dungeonId: recipe.dungeonId, templateId: Number(template.id), entityId, templateName: template.name, entityName: entity?.name ?? null, classification: template.classification, runtimeKind: entity?.kind ?? null, actorId: template.actor, actorPackageKey: actorPackage?.key ?? null, actorSourceRos: actorPackage?.sourceRos.resource ?? null, templateSourceRos: normalizeResource(template.source?.modelPath) || null, shippingAssetKey: asset?.key ?? null, shippingGlbUrl: shippingUrl, shippingGlbPath: asset?.shippingGlb.path ?? null, rebuiltAnimatedRawGlbPath: asset?.rebuiltAnimatedRawGlb.expectedPath ?? null, rebuiltAnimatedRawGlbExists: asset?.rebuiltAnimatedRawGlb.exists ?? false, animationMode, runtimeAnimationMode: entity?.visual?.model?.animationMode ?? (animationMode === "dynamic-proxy" ? "procedural-proxy" : null), semanticClipFamilies: asset?.semanticClipFamilies ?? [], clipCount: asset?.clipCount ?? 0, findingCodes: [ ...(actorPackage?.findingCodes ?? []), ...(asset?.findingCodes ?? []), ...localFindingCodes, ].filter((code, index, values) => values.indexOf(code) === index).sort(), }; entityTemplates.push(record); if (actorPackage) actorPackage.templateEntityKeys.push(entityKey); if (asset) asset.templateEntityKeys.push(entityKey); } const emptyCopyZoneOf = copyZones.get(recipe.dungeonId) ?? null; dungeons.push({ dungeonId: recipe.dungeonId, zoneId: recipe.zoneId, recipe: projectPath(recipeFile), entityTemplates: combatTemplates.length, actorPackages: configuredActorIds.size, shippingAssetVariants: [...configuredActorIds] .reduce((sum, actorId) => sum + (manifestGroups.get(actorId)?.length ?? 0), 0), manifestAssetVariants: manifest.assets?.length ?? 0, emptyCopyZone: Boolean(emptyCopyZoneOf), copiesEnvironmentFrom: emptyCopyZoneOf, status: emptyCopyZoneOf ? "authoritative-empty-copy-zone" : "configured", }); } for (const asset of shippingAssetVariants) { asset.templateEntityKeys.sort(); if (!asset.templateEntityKeys.length) { asset.findingCodes.push(addFinding( "warning", "shipping-asset", asset.key, "configured-variant-unreferenced", "Configured actor package variant is not referenced by a generated combat entity.", [asset.shippingGlb.url ?? asset.shippingGlb.path], )); asset.findingCodes.sort(); } } for (const actor of actorPackages) { actor.shippingAssetVariantKeys.sort(); actor.templateEntityKeys.sort(); actor.findingCodes.sort(); } dungeons.sort((left, right) => left.dungeonId.localeCompare(right.dungeonId)); actorPackages.sort((left, right) => left.key.localeCompare(right.key)); shippingAssetVariants.sort((left, right) => left.key.localeCompare(right.key)); entityTemplates.sort((left, right) => ( left.dungeonId.localeCompare(right.dungeonId) || left.templateId - right.templateId || left.key.localeCompare(right.key) )); unusedRecipeActors.sort((left, right) => ( left.dungeonId.localeCompare(right.dungeonId) || left.actorId.localeCompare(right.actorId) )); const summary = { dungeons: dungeons.length, entityTemplates: entityTemplates.length, entityAnimationModes: countBy(entityTemplates, "animationMode"), actorPackages: actorPackages.length, actorPackageAnimationModes: countBy(actorPackages, "animationMode"), shippingAssetVariants: shippingAssetVariants.length, shippingAssetAnimationModes: countBy(shippingAssetVariants, "animationMode"), runtimeReferencedShippingAssetVariants: shippingAssetVariants .filter((asset) => asset.templateEntityKeys.length).length, allManifestAssetVariants, unusedManifestAssetVariants, unusedRecipeActors: unusedRecipeActors.length, uniqueActorIdsAcrossDungeons: new Set(actorPackages.map((actor) => actor.actorId)).size, uniqueSourceRos: new Set(actorPackages.map((actor) => actor.sourceRos.resource).filter(Boolean)).size, uniqueShippingGlbUrls: new Set( shippingAssetVariants.map((asset) => asset.shippingGlb.url).filter(Boolean), ).size, rebuiltAnimatedRawGlbsPresent: shippingAssetVariants .filter((asset) => asset.rebuiltAnimatedRawGlb.exists).length, rebuiltAnimatedRawGlbsMissing: shippingAssetVariants .filter((asset) => !asset.rebuiltAnimatedRawGlb.exists).length, sourceAnimationGraph: { rosFiles: readiness.totals?.sourceRosFiles ?? 0, rasReferences: readiness.totals?.rasReferences ?? 0, gr2Ras: readiness.totals?.gr2Ras ?? 0, warnings: findings.filter((finding) => ( finding.scope === "source-animation-graph" && finding.severity === "warning" )).length, }, emptyCopyZones: dungeons.filter((dungeon) => dungeon.emptyCopyZone).length, findings: { blockers: findings.filter((finding) => finding.severity === "blocker").length, warnings: findings.filter((finding) => finding.severity === "warning").length, }, }; for (const [key, expectedValue] of Object.entries(expected)) { const observed = key === "nativeEntityMappings" ? summary.entityAnimationModes.native ?? 0 : key === "poseOnlyEntityMappings" ? summary.entityAnimationModes["pose-only"] ?? 0 : summary[key]; if (observed !== expectedValue) { addFinding( "blocker", "inventory", "summary", "contract-count-mismatch", key + " expected " + expectedValue + " but observed " + observed + ".", ); } } summary.findings = { blockers: findings.filter((finding) => finding.severity === "blocker").length, warnings: findings.filter((finding) => finding.severity === "warning").length, }; const poseOnlyMappings = entityTemplates .filter((entry) => entry.animationMode === "pose-only") .map((entry) => ({ dungeonId: entry.dungeonId, templateId: entry.templateId, entityId: entry.entityId, name: entry.templateName, actorId: entry.actorId, shippingGlbUrl: entry.shippingGlbUrl, })); const report = { schemaVersion: 1, status: summary.findings.blockers ? "blocked" : "green", command: "npm run runewaker:audit:animation-inventory", scope: "All configured combat and boss templates in 30 RuneWaker population recipes", authority: { shippingPath: "actor manifest URL", sourceActor: "population recipe actor sourceModel", runtimeBinding: "generated entity visual.model", rebuiltRawPath: "deterministic conversion work path; absence may mean cache not retained", sourceAnimationGraph: "read-only traversal from audit-animation-readiness.mjs", }, expectedContract: expected, summary, poseOnlyMappings, emptyCopyZones: dungeons.filter((dungeon) => dungeon.emptyCopyZone), dungeons, actorPackages, shippingAssetVariants, entityTemplates, unusedRecipeActors, findings, }; function renderMarkdown() { const lines = [ "# RuneWaker combat actor and animation inventory", "", "Status: **" + report.status.toUpperCase() + "**", "", "Reproduce with: npm run runewaker:audit:animation-inventory", "", "Shipping manifest URLs are authoritative. Rebuilt .animated.raw.glb paths are", "development-cache evidence and may be absent without making the shipped GLB invalid.", "", "## Accounting", "", "- " + summary.entityTemplates + " combat/boss template-entity definitions: " + (summary.entityAnimationModes.native ?? 0) + " native and " + (summary.entityAnimationModes["pose-only"] ?? 0) + " pose-only.", "- " + summary.actorPackages + " configured actor packages expand to " + summary.shippingAssetVariants + " configured shipping GLB variants.", "- " + summary.runtimeReferencedShippingAssetVariants + " variants are referenced by generated entities; " + summary.allManifestAssetVariants + " assets exist across the full manifests.", "- " + summary.rebuiltAnimatedRawGlbsPresent + " rebuilt animated raw GLBs are present; " + summary.rebuiltAnimatedRawGlbsMissing + " expected work-cache paths are absent.", "- Source traversal found " + summary.sourceAnimationGraph.rosFiles + " ROS files and " + summary.sourceAnimationGraph.rasReferences + " RAS references, including " + summary.sourceAnimationGraph.gr2Ras + " GR2-backed references and " + summary.sourceAnimationGraph.warnings + " source-graph warnings.", "- " + summary.uniqueActorIdsAcrossDungeons + " global actor IDs, " + summary.uniqueSourceRos + " unique source ROS resources, and " + summary.uniqueShippingGlbUrls + " unique shipping URLs.", "", "Pose-only mapping: " + poseOnlyMappings.map((entry) => ( entry.dungeonId + " template " + entry.templateId + " " + entry.name + " via " + entry.actorId )).join("; "), "", "## Dungeon accounting", "", "| Dungeon | Zone | Entities | Actor packages | GLB variants | Manifest variants | Status |", "|---|---:|---:|---:|---:|---:|---|", ]; for (const dungeon of dungeons) { lines.push("| " + [ dungeon.dungeonId, dungeon.zoneId, dungeon.entityTemplates, dungeon.actorPackages, dungeon.shippingAssetVariants, dungeon.manifestAssetVariants, dungeon.status, ].map(markdownSafe).join(" | ") + " |"); } lines.push( "", "## Configured actor packages", "", "| Actor package | Mode | Source ROS | Variants | Entities | Findings |", "|---|---|---|---:|---:|---|", ); for (const actor of actorPackages) { lines.push("| " + [ actor.key, actor.animationMode, actor.sourceRos.resource, actor.shippingAssetVariantKeys.length, actor.templateEntityKeys.length, actor.findingCodes.join(", ") || "none", ].map(markdownSafe).join(" | ") + " |"); } lines.push( "", "## Configured shipping GLB variants", "", "| Asset | Mode | Shipping GLB | Animated raw rebuild | Families | Entity refs | Findings |", "|---|---|---|---|---|---:|---|", ); for (const asset of shippingAssetVariants) { lines.push("| " + [ asset.key, asset.animationMode, asset.shippingGlb.url, asset.rebuiltAnimatedRawGlb.expectedPath + (asset.rebuiltAnimatedRawGlb.exists ? " (present)" : " (cache missing)"), asset.semanticClipFamilies.join(", "), asset.templateEntityKeys.length, asset.findingCodes.join(", ") || "none", ].map(markdownSafe).join(" | ") + " |"); } lines.push( "", "## Combat and boss template-entity definitions", "", "| Dungeon | Template | Entity | Name | Class | Actor | Mode | Shipping GLB | Animated raw | Families | Findings |", "|---|---:|---|---|---|---|---|---|---|---|---|", ); for (const entity of entityTemplates) { lines.push("| " + [ entity.dungeonId, entity.templateId, entity.entityId, entity.templateName, entity.classification, entity.actorId, entity.animationMode, entity.shippingGlbUrl ?? "none", entity.rebuiltAnimatedRawGlbPath ? entity.rebuiltAnimatedRawGlbPath + (entity.rebuiltAnimatedRawGlbExists ? " (present)" : " (cache missing)") : "none", entity.semanticClipFamilies.join(", "), entity.findingCodes.join(", ") || "none", ].map(markdownSafe).join(" | ") + " |"); } lines.push("", "## Missing or ambiguous paths", ""); const pathFindings = findings.filter((finding) => /path|missing|cache|ambiguous/.test(finding.code)); if (!pathFindings.length) lines.push("- None."); for (const finding of pathFindings) { lines.push("- [" + finding.severity + "] " + finding.key + " " + finding.code + ": " + finding.message + (finding.paths.length ? " Paths: " + finding.paths.join(", ") : "")); } lines.push("", "## All findings", ""); if (!findings.length) lines.push("- None."); for (const finding of findings) { lines.push("- [" + finding.severity + "] " + finding.scope + " " + finding.key + " " + finding.code + ": " + finding.message); } return lines.join("\n") + "\n"; } await mkdir(outputRoot, { recursive: true }); await Promise.all([ writeFile(jsonFile, JSON.stringify(report, null, 2) + "\n", "utf8"), writeFile(markdownFile, renderMarkdown(), "utf8"), ]); console.log(JSON.stringify({ status: report.status, json: projectPath(jsonFile), markdown: projectPath(markdownFile), summary, poseOnlyMappings, }, null, 2)); if (summary.findings.blockers) process.exitCode = 1;