320 lines
12 KiB
JavaScript
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; |