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}`, ); }