789 lines
30 KiB
JavaScript
789 lines
30 KiB
JavaScript
#!/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;
|