Files
healer-man/scripts/dungeon-pipeline/verify-five-player-dungeon-imports.mjs
2026-08-14 15:56:39 -04:00

206 lines
8.6 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { open, readFile, stat } 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 creaturesOnly = process.argv.includes("--creatures-only");
const readJson = async (file) => JSON.parse(await readFile(path.join(projectRoot, file), "utf8"));
const allowedExternalBlockers = new Set([
"tinkertech-showdown",
"frozen-reach",
"forgotten-mine",
]);
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function sha256(file) {
return new Promise((resolve, reject) => {
const hash = createHash("sha256");
createReadStream(file)
.on("error", reject)
.on("data", (chunk) => hash.update(chunk))
.on("end", () => resolve(hash.digest("hex")));
});
}
function assetDescriptors(value) {
if (!value) return [];
if (Array.isArray(value)) return value.flatMap(assetDescriptors);
if (value.url) return [value];
if (typeof value === "object") return Object.values(value).flatMap(assetDescriptors);
return [];
}
function shippingIdentity(descriptor) {
return {
url: descriptor.url,
checksum: descriptor.checksum,
size: Number(descriptor.size ?? descriptor.byteLength),
triangleCount: Number(descriptor.triangleCount),
};
}
async function verifyAsset(slug, descriptor, expectedPrefixes) {
const prefixes = Array.isArray(expectedPrefixes) ? expectedPrefixes : [expectedPrefixes];
assert(
prefixes.some((prefix) => descriptor.url.startsWith(prefix)),
`${slug}: unexpected asset URL ${descriptor.url}; expected one of ${prefixes.join(", ")}`,
);
const file = path.join(projectRoot, "public", descriptor.url.replace(/^\/+/, ""));
const details = await stat(file);
const expectedSize = Number(descriptor.byteLength ?? descriptor.size);
if (Number.isFinite(expectedSize)) {
assert(details.size === expectedSize, `${slug}: size mismatch for ${descriptor.url}`);
}
if (descriptor.checksum) {
assert(await sha256(file) === descriptor.checksum, `${slug}: checksum mismatch for ${descriptor.url}`);
}
}
async function readGlbDrawCalls(url) {
const file = path.join(projectRoot, "public", url.replace(/^\/+/, ""));
const handle = await open(file, "r");
try {
const header = Buffer.alloc(20);
await handle.read(header, 0, header.length, 0);
assert(header.toString("ascii", 0, 4) === "glTF", `${url}: invalid GLB magic.`);
const jsonLength = header.readUInt32LE(12);
const json = Buffer.alloc(jsonLength);
await handle.read(json, 0, jsonLength, 20);
const gltf = JSON.parse(json.toString("utf8").trimEnd());
return (gltf.meshes ?? []).reduce((total, mesh) => total + (mesh.primitives?.length ?? 0), 0);
} finally {
await handle.close();
}
}
const [campaign, registry, catalog, runReport, packageRegistry] = await Promise.all([
readJson("dungeon-pipeline/dungeon-campaign.json"),
readJson("src/game/generated/fivePlayerDungeonImports.json"),
readJson("src/game/generated/dungeonCampaignCatalog.json"),
readJson("dungeon-pipeline/FIVE_PLAYER_IMPORT_RUN_REPORT.json"),
readJson("src/game/generated/manastormAssetPackages.json"),
]);
const failed = runReport.results.filter((result) => result.status === "error");
const blockedSlugs = new Set(failed.map((result) => result.slug));
assert(campaign.dungeons.length === 54, `Expected 54 campaign dungeons; found ${campaign.dungeons.length}.`);
assert(
[...blockedSlugs].every((slug) => allowedExternalBlockers.has(slug)),
`Unexpected batch failures: ${[...blockedSlugs].join(", ")}`,
);
assert(
registry.imports.length === campaign.dungeons.length - blockedSlugs.size - 1,
`Expected ${campaign.dungeons.length - blockedSlugs.size - 1} packaged native imports; found ${registry.imports.length}.`,
);
assert(catalog.definitions.length === 54, `Expected 54 compiled definitions; found ${catalog.definitions.length}.`);
const definitions = new Map(catalog.definitions.map((definition) => [definition.id, definition]));
const coverage = new Map(catalog.coverage.map((entry) => [entry.dungeonId, entry]));
const packagesByMap = new Map(
packageRegistry.packages.map((assetPackage) => [Number(assetPackage.mapId), assetPackage]),
);
const uniqueAssets = new Map();
let creatureEntries = 0;
for (const imported of registry.imports) {
assert(imported.status === "green", `${imported.slug}: native pack is not green.`);
assert(!blockedSlugs.has(imported.slug), `${imported.slug}: blocked slug unexpectedly has a native pack.`);
const definition = definitions.get(imported.slug);
assert(definition, `${imported.slug}: compiled definition is missing.`);
assert(coverage.get(imported.slug)?.source === "azerothcore", `${imported.slug}: compiled source is not authoritative.`);
if (!creaturesOnly) {
const canonicalEnvironment = packagesByMap.get(Number(imported.mapId));
assert(
canonicalEnvironment,
`${imported.slug}: optimized canonical environment is missing.`,
);
for (const role of ["visual", "collision", "navigation"]) {
assert(
JSON.stringify(assetDescriptors(imported.environment[role]).map(shippingIdentity))
=== JSON.stringify(assetDescriptors(canonicalEnvironment[role]).map(shippingIdentity)),
`${imported.slug}: manifest ${role} does not use its canonical optimized package.`,
);
assert(
JSON.stringify(assetDescriptors(definition.assets[role]).map(shippingIdentity))
=== JSON.stringify(assetDescriptors(canonicalEnvironment[role]).map(shippingIdentity)),
`${imported.slug}: compiled ${role} does not use its canonical optimized package.`,
);
}
for (const descriptor of assetDescriptors(imported.environment)) {
uniqueAssets.set(descriptor.url, [imported.slug, descriptor, "/assets/game/manastorm/"]);
}
}
for (const descriptor of assetDescriptors(imported.creatureModels)) {
uniqueAssets.set(descriptor.url, [imported.slug, descriptor, [
`/assets/game/dungeons/${imported.slug}/creatures/`,
"/assets/game/creatures/shared/",
]]);
}
for (const [entry, model] of Object.entries(imported.creatureModels)) {
creatureEntries += 1;
assert(model.animationClips > 0, `${imported.slug}: entry ${entry} has no native animation clips.`);
assert(
model.meshOptimization?.policy === "wow-creature-material-join-v1",
`${imported.slug}: entry ${entry} is missing the creature mesh optimization policy.`,
);
assert(
Number(model.meshOptimization.runtimeDrawCalls) >= 0
&& Number(model.meshOptimization.sourceDrawCalls) >= 0
&& Number(model.meshOptimization.runtimeDrawCalls)
<= Number(model.meshOptimization.sourceDrawCalls),
`${imported.slug}: entry ${entry} has invalid optimized draw-call counts.`,
);
const entityModel = definition.entities?.[`entry-${entry}`]?.visual?.model;
assert(entityModel?.url === model.url, `${imported.slug}: entry ${entry} did not compile with its native model.`);
}
}
for (const slug of blockedSlugs) {
assert(!registry.imports.some((entry) => entry.slug === slug), `${slug}: unexpected native pack.`);
assert(coverage.get(slug)?.source === "procedural-fallback", `${slug}: expected documented fallback.`);
}
assert(
coverage.get("wailing-caverns")?.source === "azerothcore",
"wailing-caverns: golden fixture is not authoritative.",
);
assert(
runReport.results.filter((result) => result.status === "green").length
=== runReport.requested.length - blockedSlugs.size,
"Batch green/error totals do not cover every requested dungeon.",
);
assert(
failed.length === blockedSlugs.size,
`Unexpected batch failures: ${failed.map((result) => result.slug).join(", ")}`,
);
for (const [slug, descriptor, expectedPrefix] of uniqueAssets.values()) {
await verifyAsset(slug, descriptor, expectedPrefix);
if (descriptor.meshOptimization) {
const actualDrawCalls = await readGlbDrawCalls(descriptor.url);
assert(
actualDrawCalls === Number(descriptor.meshOptimization.runtimeDrawCalls),
`${slug}: draw-call audit mismatch for ${descriptor.url}`,
);
}
}
console.log(JSON.stringify({
status: blockedSlugs.size ? "green-with-documented-blockers" : "green",
campaignDungeons: campaign.dungeons.length,
authoritativeDungeons: [...coverage.values()].filter((entry) => entry.source === "azerothcore").length,
packagedNativeImports: registry.imports.length,
nativeCreatureEntries: creatureEntries,
verifiedUniqueAssets: uniqueAssets.size,
documentedBlockers: [...blockedSlugs],
scope: creaturesOnly ? "creatures" : "all-assets",
}, null, 2));