206 lines
8.6 KiB
JavaScript
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));
|