Files
healer-man/scripts/dungeon-pipeline/validate-ascension-custom-dungeon-recovery.mjs
T
2026-09-09 11:21:33 -04:00

301 lines
11 KiB
JavaScript

import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(scriptDirectory, "..", "..");
const manifestPath = path.join(
projectRoot,
"dungeon-pipeline",
"ascension",
"ASCENSION_CUSTOM_DUNGEON_RECOVERY.json",
);
function fail(message) {
throw new Error(message);
}
function assert(condition, message) {
if (!condition) {
fail(message);
}
}
function assertDeepEqual(actual, expected, message) {
assert(JSON.stringify(actual) === JSON.stringify(expected), message);
}
async function readJson(relativePath) {
const contents = await readFile(path.join(projectRoot, relativePath), "utf8");
return JSON.parse(contents);
}
function resolveDbcPath(manifest) {
const dbcArgumentIndex = process.argv.indexOf("--dbc");
if (dbcArgumentIndex !== -1) {
const value = process.argv[dbcArgumentIndex + 1];
assert(value, "--dbc requires a path");
return path.resolve(process.cwd(), value);
}
if (process.env.ASCENSION_SPELL_DBC) {
return path.resolve(process.env.ASCENSION_SPELL_DBC);
}
const source = manifest.sources.find(
(candidate) => candidate.id === "ascension-area52-spell-dbc",
);
assert(source?.pathHint, "Spell.dbc source path is missing from the manifest");
return path.resolve(projectRoot, source.pathHint);
}
function readCString(buffer, offset) {
const end = buffer.indexOf(0, offset);
assert(end !== -1, `Unterminated DBC string at byte ${offset}`);
return buffer.toString("utf8", offset, end);
}
async function validateSpellDbc(manifest) {
if (process.argv.includes("--skip-dbc")) {
return { skipped: true, reason: "requested with --skip-dbc" };
}
const source = manifest.sources.find(
(candidate) => candidate.id === "ascension-area52-spell-dbc",
);
assert(source, "Spell.dbc source metadata is missing");
const dbcPath = resolveDbcPath(manifest);
let buffer;
try {
buffer = await readFile(dbcPath);
} catch (error) {
if (error?.code === "ENOENT") {
return { skipped: true, reason: `not installed at ${dbcPath}` };
}
throw error;
}
const digest = createHash("sha256").update(buffer).digest("hex");
assert(digest === source.sha256, `Spell.dbc SHA-256 mismatch: ${digest}`);
const header = {
signature: buffer.toString("ascii", 0, 4),
recordCount: buffer.readUInt32LE(4),
fieldCount: buffer.readUInt32LE(8),
recordSize: buffer.readUInt32LE(12),
stringBlockSize: buffer.readUInt32LE(16),
};
assertDeepEqual(header, source.header, "Spell.dbc header does not match the manifest");
const expectedNames = new Map();
const summonLinks = new Map();
for (const dungeon of manifest.dungeons) {
for (const [spellId, name] of Object.entries(dungeon.sourceRecovery.spellRows)) {
expectedNames.set(Number(spellId), name);
}
for (const link of dungeon.sourceRecovery.summonLinks ?? []) {
const links = summonLinks.get(link.spellId) ?? [];
links.push({ ...link, mapId: dungeon.mapId });
summonLinks.set(link.spellId, links);
}
}
const recordStart = 20;
const stringStart = recordStart + header.recordCount * header.recordSize;
const expectedLength = stringStart + header.stringBlockSize;
assert(
expectedLength === buffer.length,
`Spell.dbc length mismatch: expected ${expectedLength}, got ${buffer.length}`,
);
const found = new Map();
for (let recordIndex = 0; recordIndex < header.recordCount; recordIndex += 1) {
const offset = recordStart + recordIndex * header.recordSize;
const spellId = buffer.readUInt32LE(offset);
if (!expectedNames.has(spellId)) {
continue;
}
const nameOffset = buffer.readUInt32LE(offset + 136 * 4);
found.set(spellId, {
name: readCString(buffer, stringStart + nameOffset),
offset,
});
}
for (const [spellId, expectedName] of expectedNames) {
const row = found.get(spellId);
assert(row, `Spell.dbc row ${spellId} is missing`);
assert(
row.name === expectedName,
`Spell.dbc row ${spellId} name mismatch: expected ${JSON.stringify(expectedName)}, got ${JSON.stringify(row.name)}`,
);
}
for (const [spellId, links] of summonLinks) {
const row = found.get(spellId);
assert(row, `Summon spell ${spellId} was not loaded`);
for (const link of links) {
const effectType = buffer.readUInt32LE(row.offset + (71 + link.effectIndex) * 4);
const entryId = buffer.readInt32LE(row.offset + (110 + link.effectIndex) * 4);
assert(
effectType === link.effectType,
`Map ${link.mapId} spell ${spellId} effect ${link.effectIndex} type mismatch: expected ${link.effectType}, got ${effectType}`,
);
assert(
entryId === link.entryId,
`Map ${link.mapId} spell ${spellId} effect ${link.effectIndex} entry mismatch: expected ${link.entryId}, got ${entryId}`,
);
}
}
return {
skipped: false,
path: dbcPath,
spellRows: found.size,
summonLinks: [...summonLinks.values()].flat().length,
};
}
function validateCatalogs(manifest, campaign, runtime, assets) {
assert(manifest.schemaVersion === 1, "Unsupported recovery manifest schema");
assert(manifest.status === "partial-authoritative-recovery", "Unexpected manifest status");
assertDeepEqual(
manifest.dungeons.map((dungeon) => dungeon.mapId),
[821, 908, 1781],
"Recovery manifest must contain exactly maps 821, 908, and 1781 in that order",
);
let spellRows = 0;
let summonLinks = 0;
for (const dungeon of manifest.dungeons) {
const definition = campaign.definitions.find(
(candidate) => candidate.mapId === dungeon.mapId,
);
assert(definition, `Campaign definition for map ${dungeon.mapId} is missing`);
assert(definition.id === dungeon.id, `Map ${dungeon.mapId} campaign ID drifted`);
assert(definition.title === dungeon.title, `Map ${dungeon.mapId} title drifted`);
const assetPackage = assets.packages.find(
(candidate) => candidate.mapId === dungeon.mapId,
);
assert(assetPackage, `Asset package for map ${dungeon.mapId} is missing`);
assert(
assetPackage.source.mapDirectory === dungeon.mapDirectory,
`Map ${dungeon.mapId} client map directory drifted`,
);
assert(
Object.values(assetPackage.source.checksums).includes(dungeon.environment.sourceHash),
`Map ${dungeon.mapId} source checksum drifted`,
);
for (const kind of ["player", "trash", "boss"]) {
const anchor = assetPackage.anchors.find((candidate) => candidate.kind === kind);
assert(anchor, `Map ${dungeon.mapId} ${kind} anchor is missing`);
assertDeepEqual(
anchor.position,
dungeon.environment.anchors[kind],
`Map ${dungeon.mapId} ${kind} anchor drifted`,
);
}
for (const encounter of dungeon.encounters) {
const runtimeEncounter = runtime.encounters.find(
(candidate) =>
candidate.encounterId === encounter.encounterId &&
candidate.mapIds.includes(dungeon.mapId) &&
candidate.modeIds.includes(encounter.modeId),
);
assert(
runtimeEncounter,
`Map ${dungeon.mapId} encounter ${encounter.encounterId} mode ${encounter.modeId} is missing`,
);
assert(
runtimeEncounter.name === encounter.name,
`Encounter ${encounter.encounterId} name drifted`,
);
assert(
runtimeEncounter.creatureId === encounter.creatureId,
`Encounter ${encounter.encounterId} creature ID drifted`,
);
}
const bossEntity = definition.entities[dungeon.currentRuntime.bossEntityId];
const bossCreatureId = dungeon.encounters[0].creatureId;
assert(bossEntity, `Map ${dungeon.mapId} current boss entity is missing`);
assert(
bossEntity.combat.serverEntry === bossCreatureId,
`Map ${dungeon.mapId} current boss server entry drifted`,
);
const generatedTrash = definition.staticSpawns.filter((spawn) =>
spawn.id.startsWith("generated-trash-"),
);
assert(
generatedTrash.length === dungeon.currentRuntime.proceduralTrashSpawnCount,
`Map ${dungeon.mapId} procedural trash count drifted`,
);
for (const proceduralEntity of dungeon.currentRuntime.proceduralMobEntries) {
const entity = definition.entities[proceduralEntity.entityId];
assert(entity, `Map ${dungeon.mapId} procedural entity ${proceduralEntity.entityId} is missing`);
assert(
entity.combat.serverEntry === proceduralEntity.serverEntry,
`Map ${dungeon.mapId} procedural entity ${proceduralEntity.entityId} server entry drifted`,
);
}
const rowIds = new Set(
Object.keys(dungeon.sourceRecovery.spellRows).map((spellId) => Number(spellId)),
);
spellRows += rowIds.size;
for (const [family, familyRows] of Object.entries(dungeon.sourceRecovery.families)) {
for (const spellId of familyRows) {
assert(
rowIds.has(spellId),
`Map ${dungeon.mapId} family ${family} references absent spell ${spellId}`,
);
}
}
for (const link of dungeon.sourceRecovery.summonLinks ?? []) {
summonLinks += 1;
assert(rowIds.has(link.spellId), `Map ${dungeon.mapId} summon spell ${link.spellId} is absent`);
const targetCollection = link.targetKind === "gameobject" ? dungeon.gameObjects : dungeon.entities;
assert(
targetCollection?.some((target) => target.entryId === link.entryId),
`Map ${dungeon.mapId} summon target ${link.targetKind} ${link.entryId} is absent`,
);
}
assert(
dungeon.adaptation.mustBeCustomAuthored.length > 0,
`Map ${dungeon.mapId} must explicitly list custom-authored gaps`,
);
}
return { dungeons: manifest.dungeons.length, spellRows, summonLinks };
}
const manifest = await readJson(
"dungeon-pipeline/ascension/ASCENSION_CUSTOM_DUNGEON_RECOVERY.json",
);
const campaign = await readJson("src/game/generated/dungeonCampaignCatalog.json");
const runtime = await readJson("src/game/generated/manastormRuntimeCatalog.json");
const assets = await readJson("src/game/generated/manastormAssetPackages.json");
const catalogResult = validateCatalogs(manifest, campaign, runtime, assets);
const dbcResult = await validateSpellDbc(manifest);
console.log(
`Validated ${catalogResult.dungeons} Ascension custom dungeons, ${catalogResult.spellRows} recovered spell rows, and ${catalogResult.summonLinks} summon links.`,
);
if (dbcResult.skipped) {
console.log(`Spell.dbc verification skipped: ${dbcResult.reason}.`);
} else {
console.log(
`Spell.dbc verified (${dbcResult.spellRows} rows, ${dbcResult.summonLinks} summon links): ${dbcResult.path}`,
);
}