Files
healer-man/scripts/dungeon-pipeline/audit-wow-dungeon-content.mjs
T
2026-08-14 15:56:39 -04:00

320 lines
12 KiB
JavaScript

#!/usr/bin/env node
import { open, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const publicRoot = path.join(projectRoot, "public");
const reportJsonFile = path.join(projectRoot, "dungeon-pipeline", "WOW_DUNGEON_CONTENT_REPORT.json");
const reportMarkdownFile = path.join(projectRoot, "dungeon-pipeline", "WOW_DUNGEON_CONTENT_REPORT.md");
const supportedArchetypes = new Set([
"humanoid", "raptor", "crocolisk", "ooze", "plant",
"serpent", "turtle", "lizard", "murloc", "winged",
]);
const validAnimations = new Set(["attack", "cast"]);
const validDeliveries = new Set(["melee", "projectile", "area"]);
const validTargets = new Set([
"primary", "random-party", "nearby-party", "self", "lowest-health-friendly",
]);
const validSchools = new Set(["physical", "arcane", "fire", "frost", "nature", "shadow", "holy"]);
const validEffects = new Set([
"damage", "heal", "aura", "control", "transform", "summon", "call-for-help", "encounter-event",
]);
const basicOnlyBossExceptions = new Map([
[
"wailing-caverns:entry-3653",
"Kresh is melee-only in the pinned AzerothCore snapshot; the historical server has no spell or SmartAI row for entry 3653.",
],
[
"wailing-caverns:deviate-faerie-dragon",
"The server-faithful rare entry 5912 has no spell rows and is despawned by the Wailing Caverns initialization event.",
],
]);
const noTrashExceptions = new Map([
[
"trial-of-the-champion",
"Trial of the Champion is an encounter arena; its imported combat population is the five Grand Champion bosses, not a trash route.",
],
]);
const animationIds = {
idle: new Set([0]),
move: new Set([4, 5]),
attack: new Set([16, 17, 18, 19, 57, 58, 85, 87, 88, 95, 118]),
death: new Set([1]),
wound: new Set([8, 9, 10]),
};
const readJson = async (relativeFile) => JSON.parse(
await readFile(path.join(projectRoot, relativeFile), "utf8"),
);
function slug(value) {
return String(value)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function animationId(name) {
const match = /\(ID (\d+) variation \d+\)$/i.exec(name ?? "");
return match ? Number(match[1]) : null;
}
async function readGlbAnimationNames(url) {
const file = path.join(publicRoot, url.replace(/^\/+/, ""));
const handle = await open(file, "r");
try {
const header = Buffer.alloc(20);
await handle.read(header, 0, header.length, 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 GLB JSON chunk");
const jsonLength = header.readUInt32LE(12);
const json = Buffer.alloc(jsonLength);
await handle.read(json, 0, jsonLength, 20);
const document = JSON.parse(json.toString("utf8").trimEnd());
return (document.animations ?? []).map(
(animation, index) => animation.name ?? "animation-" + index,
);
} finally {
await handle.close();
}
}
function wailingFixtureModels(fixture) {
return new Map((fixture.creatures ?? []).map((creature) => [
creature.id,
"/" + creature.path.replace(/\\/g, "/").replace(/^public\//, ""),
]));
}
function modelUrlFor(dungeon, entity, wailingModels) {
if (entity.visual?.model?.url) return entity.visual.model.url;
if (dungeon.id === "wailing-caverns") return wailingModels.get(slug(entity.name));
return undefined;
}
function validateAttack(dungeonId, entityId, attack, issues) {
const key = dungeonId + ":" + entityId + ":" + (attack?.id ?? "unknown");
if (!attack?.id || !attack?.name) issues.push(key + ": attack is missing an id or name.");
if (!validAnimations.has(attack?.animation)) issues.push(key + ": unsupported animation " + attack?.animation + ".");
if (!validDeliveries.has(attack?.delivery)) issues.push(key + ": unsupported delivery " + attack?.delivery + ".");
if (!validTargets.has(attack?.target)) issues.push(key + ": unsupported target " + attack?.target + ".");
if (!validSchools.has(attack?.school)) issues.push(key + ": unsupported school " + attack?.school + ".");
if (!Number.isFinite(attack?.cooldownMs) || attack.cooldownMs <= 0) issues.push(key + ": invalid cooldown.");
if (!Number.isFinite(attack?.range) || attack.range < 0) issues.push(key + ": invalid range.");
if (attack?.effect && !validEffects.has(attack.effect.kind)) {
issues.push(key + ": unsupported effect " + attack.effect.kind + ".");
}
}
function reportMarkdown(report) {
const rows = report.dungeons.map((dungeon) => [
"|", dungeon.title, "|", dungeon.mobs, "|", dungeon.bosses, "|",
dungeon.attacks, "|", dungeon.specialAttacks, "|", dungeon.nativeModels, "|",
dungeon.proceduralModels, "|", dungeon.status, "|",
].join(" "));
return [
"# WoW dungeon content report",
"",
"Status: **" + report.status + "**",
"",
"Coverage: " + report.summary.dungeons + " dungeons, " + report.summary.mobs
+ " mobs, " + report.summary.bosses + " bosses, " + report.summary.attacks
+ " executable attacks, and " + report.summary.specialAttacks + " special attacks/mechanics.",
"",
"Animation paths: " + report.summary.nativeModels + " native model bindings and "
+ report.summary.proceduralModels + " procedural bindings across "
+ report.summary.uniqueNativeModels + " unique native GLBs; "
+ report.summary.nativeStateFallbacks + " missing native states use the shared procedural fallback.",
"",
"| Dungeon | Mobs | Bosses | Attacks | Specials | Native | Procedural | Status |",
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |",
...rows,
"",
"## Documented source-faithful exceptions",
"",
...report.documentedExceptions.map((entry) => "- " + entry.key + ": " + entry.reason),
"",
"## Issues",
"",
...(report.issues.length ? report.issues.map((issue) => "- " + issue) : ["- None."]),
"",
].join("\n");
}
const [campaign, catalog, wailingFixture] = await Promise.all([
readJson("dungeon-pipeline/dungeon-campaign.json"),
readJson("src/game/generated/dungeonCampaignCatalog.json"),
readJson("dungeon-pipeline/fixtures/wailing-caverns/runtime-fixture.json"),
]);
const campaignIds = new Set(campaign.dungeons.map((dungeon) => dungeon.slug));
const definitions = catalog.definitions.filter((definition) => campaignIds.has(definition.id));
const coverage = new Map(catalog.coverage.map((entry) => [entry.dungeonId, entry]));
const wailingModels = wailingFixtureModels(wailingFixture);
const issues = [];
const modelUsers = new Map();
const animationFallbacks = [];
const dungeons = [];
if (campaign.dungeons.length !== 54) {
issues.push("Expected 54 campaign dungeons; found " + campaign.dungeons.length + ".");
}
if (definitions.length !== campaign.dungeons.length) {
issues.push(
"Expected " + campaign.dungeons.length + " compiled dungeon definitions; found "
+ definitions.length + ".",
);
}
for (const dungeon of definitions) {
const entities = Object.entries(dungeon.entities ?? {});
const mobs = entities.filter(([, entity]) => entity.kind === "mob");
const bosses = entities.filter(([, entity]) => entity.kind === "boss");
const dungeonIssues = [];
let attacks = 0;
let specialAttacks = 0;
let nativeModels = 0;
let proceduralModels = 0;
if (!entities.length) dungeonIssues.push("has no combat entities.");
if (!bosses.length) dungeonIssues.push("has no bosses.");
if (!mobs.length && !noTrashExceptions.has(dungeon.id)) dungeonIssues.push("has no trash mobs.");
if (!(dungeon.staticSpawns?.length || dungeon.roamingPacks?.length)) dungeonIssues.push("has no combat spawns.");
for (const spawn of dungeon.staticSpawns ?? []) {
if (!dungeon.entities?.[spawn.entityId]) {
dungeonIssues.push("spawn " + spawn.id + " references missing entity " + spawn.entityId + ".");
}
}
for (const pack of dungeon.roamingPacks ?? []) {
for (const member of pack.members ?? []) {
if (!dungeon.entities?.[member.entityId]) {
dungeonIssues.push("pack " + pack.id + " references missing entity " + member.entityId + ".");
}
}
}
for (const [entityId, entity] of entities) {
const entityAttacks = entity.combat?.attacks ?? [];
if (!entityAttacks.length) dungeonIssues.push(entityId + " has no executable attack.");
for (const attack of entityAttacks) validateAttack(dungeon.id, entityId, attack, dungeonIssues);
attacks += entityAttacks.length;
const specials = entityAttacks.filter((attack) => attack.id !== "basic-attack");
specialAttacks += specials.length;
if (entity.kind === "boss" && !specials.length) {
const exceptionKey = dungeon.id + ":" + entityId;
if (!basicOnlyBossExceptions.has(exceptionKey)) {
dungeonIssues.push(entityId + " has no boss mechanic beyond its basic attack.");
}
}
const modelUrl = modelUrlFor(dungeon, entity, wailingModels);
if (modelUrl && entity.visual?.model?.animationMode !== "procedural") {
nativeModels += 1;
const users = modelUsers.get(modelUrl) ?? [];
users.push(dungeon.id + ":" + entityId);
modelUsers.set(modelUrl, users);
} else {
proceduralModels += 1;
if (!supportedArchetypes.has(entity.visual?.archetype)) {
dungeonIssues.push(
entityId + " lacks both a native model and a supported procedural archetype.",
);
}
}
}
issues.push(...dungeonIssues.map((issue) => dungeon.id + ": " + issue));
dungeons.push({
id: dungeon.id,
title: dungeon.title,
source: coverage.get(dungeon.id)?.source ?? "unknown",
mobs: mobs.length,
bosses: bosses.length,
attacks,
specialAttacks,
nativeModels,
proceduralModels,
status: dungeonIssues.length ? "blocked" : "green",
});
}
for (const [url, users] of modelUsers) {
try {
const names = await readGlbAnimationNames(url);
const ids = new Set(names.map(animationId).filter((id) => id !== null));
for (const [role, required] of Object.entries(animationIds)) {
if (![...required].some((id) => ids.has(id))) {
animationFallbacks.push({
url,
state: role,
users,
renderer: role === "move" || role === "idle"
? "AnimatedMobBody locomotion fallback"
: "OriginalCreatureModel combat fallback",
});
}
}
} catch (error) {
issues.push(
url + ": unable to inspect native animations (" + error.message + ") for "
+ users.join(", ") + ".",
);
}
}
const documentedExceptions = [
...[...basicOnlyBossExceptions].map(([key, reason]) => ({
type: "basic-only-boss", key, reason,
})),
...[...noTrashExceptions].map(([key, reason]) => ({
type: "no-trash-instance", key, reason,
})),
];
const summary = dungeons.reduce((result, dungeon) => ({
dungeons: result.dungeons + 1,
mobs: result.mobs + dungeon.mobs,
bosses: result.bosses + dungeon.bosses,
attacks: result.attacks + dungeon.attacks,
specialAttacks: result.specialAttacks + dungeon.specialAttacks,
nativeModels: result.nativeModels + dungeon.nativeModels,
proceduralModels: result.proceduralModels + dungeon.proceduralModels,
uniqueNativeModels: modelUsers.size,
nativeStateFallbacks: animationFallbacks.length,
}), {
dungeons: 0,
mobs: 0,
bosses: 0,
attacks: 0,
specialAttacks: 0,
nativeModels: 0,
proceduralModels: 0,
uniqueNativeModels: 0,
nativeStateFallbacks: 0,
});
const report = {
schemaVersion: 1,
status: issues.length ? "blocked" : "green",
scope: "HealerMan WoW five-player campaign",
summary,
documentedExceptions,
animationFallbacks,
issues,
dungeons,
};
await Promise.all([
writeFile(reportJsonFile, JSON.stringify(report, null, 2) + "\n", "utf8"),
writeFile(reportMarkdownFile, reportMarkdown(report), "utf8"),
]);
console.log(JSON.stringify({
status: report.status,
reportJson: path.relative(projectRoot, reportJsonFile).split(path.sep).join("/"),
reportMarkdown: path.relative(projectRoot, reportMarkdownFile).split(path.sep).join("/"),
summary: report.summary,
documentedExceptions: report.documentedExceptions,
issues: report.issues,
}, null, 2));
if (issues.length) process.exitCode = 1;