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