forked from phenom/healer-man
301 lines
11 KiB
JavaScript
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}`,
|
|
);
|
|
}
|