Files
2026-08-14 15:56:39 -04:00

933 lines
32 KiB
JavaScript

import fs from "node:fs";
import path from "node:path";
import { createHash } from "node:crypto";
export const MANASTORM_CATALOG_SCHEMA_VERSION = 1;
export const MANASTORM_IMPORTER_VERSION = "1.1.0";
const WDBC_HEADER_SIZE = 20;
const SOURCE_SCHEMAS = Object.freeze({
manastorm: { fieldCount: 9, recordSize: 36 },
messages: { fieldCount: 39, recordSize: 156 },
modifiers: { fieldCount: 15, recordSize: 60 },
groupModifiers: { fieldCount: 5, recordSize: 20 },
map: { fieldCount: 66, recordSize: 264 },
dungeonEncounter: { fieldCount: 23, recordSize: 92 },
dungeonEncounterExtra: { fieldCount: 4, recordSize: 16 },
spell: { minimumFieldCount: 234 },
spellIcon: { fieldCount: 2, recordSize: 8 },
});
export class ManastormImportError extends Error {
constructor(code, message, details = {}) {
super(message);
this.name = "ManastormImportError";
this.code = code;
this.details = details;
}
}
function fail(code, message, details = {}) {
throw new ManastormImportError(code, message, details);
}
function assertInteger(value, label) {
if (!Number.isInteger(value) || value < 0) {
fail("INVALID_INTEGER", `${label} must be a non-negative integer.`, { value });
}
}
function normalizeRelativePath(projectRoot, filePath) {
return path.relative(projectRoot, filePath).replaceAll("\\", "/");
}
function stableId(kind, sourceId) {
return `manastorm:${kind}:${sourceId}`;
}
function cleanText(value) {
return value
.replace(/\|c[0-9a-f]{8}/gi, "")
.replace(/\|r/gi, "")
.replace(/[\r\n]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function iconBasename(value) {
return value
.replace(/^.*[\\/]/, "")
.replace(/\.(?:blp|tga)$/i, "")
.trim();
}
function normalizedIconKey(value) {
return iconBasename(value).toLowerCase().replace(/\s+/g, "");
}
function indexInstalledIconFiles(iconDirectory, projectRoot) {
const byKey = new Map();
if (!iconDirectory || !fs.existsSync(iconDirectory)) return byKey;
const pending = [iconDirectory];
while (pending.length) {
const directory = pending.pop();
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) {
pending.push(absolute);
continue;
}
if (!/\.(?:blp|tga|png)$/i.test(entry.name)) continue;
const basename = iconBasename(entry.name);
const key = normalizedIconKey(basename);
if (!byKey.has(key)) {
byKey.set(key, {
basename,
path: normalizeRelativePath(projectRoot, absolute),
sha256: hashFile(absolute),
});
}
}
}
return byKey;
}
export function parseWdbcBuffer(buffer, sourceLabel, schema = {}) {
if (!Buffer.isBuffer(buffer)) {
fail("INVALID_BUFFER", `${sourceLabel} was not supplied as a Buffer.`);
}
if (buffer.length < WDBC_HEADER_SIZE) {
fail("TRUNCATED_HEADER", `${sourceLabel} is shorter than the 20-byte WDBC header.`, {
byteLength: buffer.length,
});
}
const magic = buffer.toString("ascii", 0, 4);
if (magic !== "WDBC") {
fail("INVALID_MAGIC", `${sourceLabel} has magic ${JSON.stringify(magic)}, expected WDBC.`);
}
const recordCount = buffer.readUInt32LE(4);
const fieldCount = buffer.readUInt32LE(8);
const recordSize = buffer.readUInt32LE(12);
const stringBlockSize = buffer.readUInt32LE(16);
if (fieldCount === 0 || recordSize === 0 || recordSize !== fieldCount * 4) {
fail(
"INVALID_RECORD_LAYOUT",
`${sourceLabel} must contain fixed-width 32-bit WDBC fields.`,
{ fieldCount, recordSize },
);
}
if (schema.fieldCount !== undefined && fieldCount !== schema.fieldCount) {
fail(
"UNEXPECTED_FIELD_COUNT",
`${sourceLabel} has ${fieldCount} fields; expected ${schema.fieldCount}.`,
{ fieldCount, expected: schema.fieldCount },
);
}
if (schema.minimumFieldCount !== undefined && fieldCount < schema.minimumFieldCount) {
fail(
"UNEXPECTED_FIELD_COUNT",
`${sourceLabel} has ${fieldCount} fields; expected at least ${schema.minimumFieldCount}.`,
{ fieldCount, minimum: schema.minimumFieldCount },
);
}
if (schema.recordSize !== undefined && recordSize !== schema.recordSize) {
fail(
"UNEXPECTED_RECORD_SIZE",
`${sourceLabel} has ${recordSize}-byte records; expected ${schema.recordSize}.`,
{ recordSize, expected: schema.recordSize },
);
}
const recordsByteLength = recordCount * recordSize;
const stringBlockOffset = WDBC_HEADER_SIZE + recordsByteLength;
const expectedByteLength = stringBlockOffset + stringBlockSize;
if (!Number.isSafeInteger(expectedByteLength) || expectedByteLength !== buffer.length) {
fail(
"INVALID_FILE_LENGTH",
`${sourceLabel} length does not match its WDBC header.`,
{ byteLength: buffer.length, expectedByteLength },
);
}
const fieldOffset = (recordIndex, fieldIndex) => {
assertInteger(recordIndex, "recordIndex");
assertInteger(fieldIndex, "fieldIndex");
if (recordIndex >= recordCount || fieldIndex >= fieldCount) {
fail("FIELD_OUT_OF_RANGE", `${sourceLabel} field access is out of range.`, {
recordIndex,
fieldIndex,
recordCount,
fieldCount,
});
}
return WDBC_HEADER_SIZE + recordIndex * recordSize + fieldIndex * 4;
};
const uint = (recordIndex, fieldIndex) => (
buffer.readUInt32LE(fieldOffset(recordIndex, fieldIndex))
);
const float = (recordIndex, fieldIndex) => {
const value = buffer.readFloatLE(fieldOffset(recordIndex, fieldIndex));
if (!Number.isFinite(value)) {
fail("NON_FINITE_FLOAT", `${sourceLabel} contains a non-finite floating-point value.`, {
recordIndex,
fieldIndex,
});
}
return value;
};
const string = (stringOffset, context = {}) => {
assertInteger(stringOffset, "stringOffset");
if (stringOffset >= stringBlockSize) {
fail("STRING_OFFSET_OUT_OF_RANGE", `${sourceLabel} has an invalid string offset.`, {
stringOffset,
stringBlockSize,
...context,
});
}
const start = stringBlockOffset + stringOffset;
const end = buffer.indexOf(0, start);
if (end < 0 || end >= expectedByteLength) {
fail("UNTERMINATED_STRING", `${sourceLabel} contains an unterminated string.`, {
stringOffset,
...context,
});
}
return buffer.toString("utf8", start, end);
};
const localizedString = (recordIndex, firstField, localeCount = 16) => {
for (let locale = 0; locale < localeCount; locale += 1) {
const stringOffset = uint(recordIndex, firstField + locale);
const value = string(stringOffset, { recordIndex, fieldIndex: firstField + locale });
if (value) return cleanText(value);
}
return "";
};
return {
buffer,
sourceLabel,
header: {
recordCount,
fieldCount,
recordSize,
stringBlockSize,
},
uint,
float,
string,
localizedString,
};
}
function readWdbc(filePath, role) {
if (!fs.existsSync(filePath)) {
fail("SOURCE_NOT_FOUND", `Required ${role} source was not found: ${filePath}`, {
role,
filePath,
});
}
return parseWdbcBuffer(fs.readFileSync(filePath), path.basename(filePath), SOURCE_SCHEMAS[role]);
}
function hashFile(filePath) {
const hash = createHash("sha256");
const file = fs.openSync(filePath, "r");
const chunk = Buffer.allocUnsafe(1024 * 1024);
try {
for (;;) {
const bytesRead = fs.readSync(file, chunk, 0, chunk.length, null);
if (!bytesRead) break;
hash.update(chunk.subarray(0, bytesRead));
}
} finally {
fs.closeSync(file);
}
return hash.digest("hex");
}
function sourceProvenance(projectRoot, role, filePath, table) {
return {
role,
path: normalizeRelativePath(projectRoot, filePath),
sha256: hashFile(filePath),
byteLength: table.buffer.length,
dbc: table.header,
};
}
function ensureUniqueSourceIds(rows, source) {
const seen = new Set();
for (const row of rows) {
if (seen.has(row.sourceRecordId)) {
fail("DUPLICATE_SOURCE_ID", `${source} contains duplicate ID ${row.sourceRecordId}.`, {
source,
recordId: row.sourceRecordId,
});
}
seen.add(row.sourceRecordId);
}
}
function parseManastormRows(table) {
const rows = Array.from({ length: table.header.recordCount }, (_, recordIndex) => ({
sourceRecordId: table.uint(recordIndex, 0),
mapId: table.uint(recordIndex, 1),
modeId: table.uint(recordIndex, 2),
encounterId: table.uint(recordIndex, 3),
scalars: Object.freeze(Array.from(
{ length: 5 },
(_, scalarIndex) => table.float(recordIndex, 4 + scalarIndex),
)),
}));
ensureUniqueSourceIds(rows, table.sourceLabel);
return rows.sort((left, right) => left.sourceRecordId - right.sourceRecordId);
}
function parseMaps(table) {
const maps = new Map();
for (let recordIndex = 0; recordIndex < table.header.recordCount; recordIndex += 1) {
const mapId = table.uint(recordIndex, 0);
if (maps.has(mapId)) {
fail("DUPLICATE_MAP_ID", `${table.sourceLabel} contains duplicate map ID ${mapId}.`, {
mapId,
});
}
const name = table.localizedString(recordIndex, 5);
const directory = cleanText(table.string(table.uint(recordIndex, 1), {
recordIndex,
fieldIndex: 1,
}));
maps.set(mapId, {
mapId,
name,
directory,
instanceType: table.uint(recordIndex, 2),
flags: table.uint(recordIndex, 3),
expansionId: table.uint(recordIndex, 63),
maxPlayers: table.uint(recordIndex, 65),
});
}
return maps;
}
function parseDungeonEncounters(table) {
const encounters = new Map();
for (let recordIndex = 0; recordIndex < table.header.recordCount; recordIndex += 1) {
const encounterId = table.uint(recordIndex, 0);
if (encounters.has(encounterId)) {
fail(
"DUPLICATE_DUNGEON_ENCOUNTER_ID",
`${table.sourceLabel} contains duplicate encounter ID ${encounterId}.`,
{ encounterId },
);
}
encounters.set(encounterId, {
encounterId,
mapId: table.uint(recordIndex, 1),
difficultyId: table.uint(recordIndex, 2),
orderIndex: table.uint(recordIndex, 3),
bit: table.uint(recordIndex, 4),
name: table.localizedString(recordIndex, 5),
});
}
return encounters;
}
function parseDungeonEncounterExtras(table) {
const extras = new Map();
for (let recordIndex = 0; recordIndex < table.header.recordCount; recordIndex += 1) {
const encounterId = table.uint(recordIndex, 0);
const creatureIds = extras.get(encounterId) ?? new Set();
const creatureId = table.uint(recordIndex, 1);
if (creatureId) creatureIds.add(creatureId);
extras.set(encounterId, creatureIds);
}
return new Map(
[...extras.entries()].map(([encounterId, creatureIds]) => [
encounterId,
{
encounterId,
creatureIds: [...creatureIds].sort((left, right) => left - right),
},
]),
);
}
export function assertResolvedMaps(sourceRows, mapsById) {
const unresolved = [...new Set(
sourceRows
.map((row) => row.mapId)
.filter((mapId) => !mapsById.has(mapId)),
)].sort((left, right) => left - right);
if (unresolved.length) {
fail(
"UNRESOLVED_MAP_REFERENCE",
`Manastorm.dbc references map IDs absent from Map.dbc: ${unresolved.join(", ")}.`,
{ mapIds: unresolved },
);
}
for (const mapId of new Set(sourceRows.map((row) => row.mapId))) {
const map = mapsById.get(mapId);
if (!map?.name || !map.directory) {
fail(
"INVALID_MAP_METADATA",
`Map ${mapId} is missing its authoritative client name or directory.`,
{ mapId, map },
);
}
}
}
function parseSpellIcons(table) {
const byBasename = new Map();
const byId = new Map();
for (let recordIndex = 0; recordIndex < table.header.recordCount; recordIndex += 1) {
const spellIconId = table.uint(recordIndex, 0);
const iconPath = cleanText(table.string(table.uint(recordIndex, 1), {
recordIndex,
fieldIndex: 1,
}));
const basename = iconBasename(iconPath);
const normalized = basename.toLowerCase();
const existing = byBasename.get(normalized) ?? {
basename,
paths: new Set(),
spellIconIds: [],
};
existing.paths.add(iconPath);
existing.spellIconIds.push(spellIconId);
byBasename.set(normalized, existing);
byId.set(spellIconId, { spellIconId, iconPath, basename });
}
for (const entry of byBasename.values()) {
entry.spellIconIds.sort((left, right) => left - right);
}
return { byBasename, byId };
}
function indexSpellIconUsage(table) {
const spellIdsByIconId = new Map();
for (let recordIndex = 0; recordIndex < table.header.recordCount; recordIndex += 1) {
const spellId = table.uint(recordIndex, 0);
const spellIconId = table.uint(recordIndex, 133);
if (!spellIconId) continue;
const spellIds = spellIdsByIconId.get(spellIconId) ?? [];
spellIds.push(spellId);
spellIdsByIconId.set(spellIconId, spellIds);
}
return spellIdsByIconId;
}
function unresolvedReference(kind, reason, extra = {}) {
return { kind, status: "unresolved", id: null, reason, ...extra };
}
function parseMessages(
table,
spellIcons,
spellIdsByIconId,
installedIconFiles,
validationIssues,
) {
const rows = Array.from({ length: table.header.recordCount }, (_, recordIndex) => {
const sourceRecordId = table.uint(recordIndex, 0);
const rawIcon = cleanText(table.string(table.uint(recordIndex, 4), {
recordIndex,
fieldIndex: 4,
}));
const basename = iconBasename(rawIcon);
const icon = spellIcons.byBasename.get(basename.toLowerCase());
const installedIcon = installedIconFiles.get(normalizedIconKey(basename));
const spellIconIds = icon?.spellIconIds ?? [];
const candidateSpellCount = new Set(
spellIconIds.flatMap((spellIconId) => spellIdsByIconId.get(spellIconId) ?? []),
).size;
const iconReference = icon
? {
kind: "icon",
status: "resolved",
basename,
spellIconIds,
paths: [...icon.paths].sort((left, right) => left.localeCompare(right)),
resolutionSource: "SpellIcon.dbc",
}
: installedIcon
? {
kind: "icon",
status: "resolved",
basename: installedIcon.basename,
spellIconIds: [],
paths: [installedIcon.path],
sha256: installedIcon.sha256,
resolutionSource: "installed-icon-file",
}
: unresolvedReference(
"icon",
"The message icon basename is absent from the installed SpellIcon.dbc.",
{ basename },
);
if (!icon && !installedIcon) {
validationIssues.push({
severity: "warning",
code: "UNRESOLVED_MESSAGE_ICON",
source: table.sourceLabel,
recordId: sourceRecordId,
field: "icon",
value: basename,
message: `Message ${sourceRecordId} icon ${JSON.stringify(basename)} is not in SpellIcon.dbc.`,
});
}
return {
id: stableId("message", sourceRecordId),
sourceRecordId,
modeId: table.uint(recordIndex, 1),
progressionValue: table.uint(recordIndex, 2),
groupId: table.uint(recordIndex, 3),
iconReference,
spellReference: unresolvedReference(
"spell",
"ManastormMessages.dbc has no authoritative SpellID field; shared icon usage is not a spell identity.",
{ candidateSpellCount },
),
title: table.localizedString(recordIndex, 5),
description: table.localizedString(recordIndex, 22),
titleFlags: table.uint(recordIndex, 21),
descriptionFlags: table.uint(recordIndex, 38),
};
});
ensureUniqueSourceIds(rows, table.sourceLabel);
return rows.sort((left, right) => left.sourceRecordId - right.sourceRecordId);
}
function parseModifiers(table) {
const rows = Array.from({ length: table.header.recordCount }, (_, recordIndex) => {
const sourceRecordId = table.uint(recordIndex, 0);
return {
id: stableId("modifier", sourceRecordId),
sourceRecordId,
modeId: table.uint(recordIndex, 1),
level: table.uint(recordIndex, 2),
tuning: Array.from(
{ length: 11 },
(_, tuningIndex) => table.float(recordIndex, 3 + tuningIndex),
),
rawFlag: table.uint(recordIndex, 14),
};
});
ensureUniqueSourceIds(rows, table.sourceLabel);
return rows.sort((left, right) => left.sourceRecordId - right.sourceRecordId);
}
function parseGroupModifiers(table) {
const rows = Array.from({ length: table.header.recordCount }, (_, recordIndex) => {
const sourceRecordId = table.uint(recordIndex, 0);
return {
id: stableId("group-modifier", sourceRecordId),
sourceRecordId,
playerCount: table.uint(recordIndex, 1),
modeId: table.uint(recordIndex, 2),
multipliers: [table.float(recordIndex, 3), table.float(recordIndex, 4)],
};
});
ensureUniqueSourceIds(rows, table.sourceLabel);
return rows.sort((left, right) => left.sourceRecordId - right.sourceRecordId);
}
function sourcePaths(options) {
const projectRoot = path.resolve(options.projectRoot);
const installedClientRoot = path.resolve(
options.installedClientRoot
?? path.join(projectRoot, "..", "LadiksMPQEditor", "client-current"),
);
const realmDbcRoot = path.resolve(
options.realmDbcRoot
?? path.join(installedClientRoot, "area-52", "patch-D", "DBFilesClient"),
);
const currentGlobalDbcRoot = path.join(installedClientRoot, "patch-M", "DBFilesClient");
const legacyGlobalDbcRoot = path.join(
projectRoot,
"..",
"LadiksMPQEditor",
"patch-M",
"DBFilesClient",
);
const globalDbcPath = (fileName) => {
const currentPath = path.join(currentGlobalDbcRoot, fileName);
return fs.existsSync(currentPath) ? currentPath : path.join(legacyGlobalDbcRoot, fileName);
};
return {
projectRoot,
manastorm: path.resolve(options.manastormPath ?? path.join(realmDbcRoot, "Manastorm.dbc")),
messages: path.resolve(options.messagesPath ?? path.join(realmDbcRoot, "ManastormMessages.dbc")),
modifiers: path.resolve(options.modifiersPath ?? path.join(realmDbcRoot, "ManastormModifiers.dbc")),
groupModifiers: path.resolve(
options.groupModifiersPath
?? path.join(realmDbcRoot, "ManastormPlayerGroupModifiers.dbc"),
),
map: path.resolve(options.mapPath ?? globalDbcPath("Map.dbc")),
dungeonEncounter: path.resolve(
options.dungeonEncounterPath ?? globalDbcPath("DungeonEncounter.dbc"),
),
dungeonEncounterExtra: path.resolve(
options.dungeonEncounterExtraPath ?? globalDbcPath("DungeonEncounterExtra.dbc"),
),
spell: path.resolve(options.spellPath ?? path.join(realmDbcRoot, "Spell.dbc")),
spellIcon: path.resolve(
options.spellIconPath
?? path.join(installedClientRoot, "patch-S", "DBFilesClient", "SpellIcon.dbc"),
),
iconDirectory: path.resolve(
options.iconDirectory
?? path.join(projectRoot, "..", "LadiksMPQEditor", "patch-I", "Interface", "Icons"),
),
};
}
export function generateManastormCatalog(options = {}) {
const paths = sourcePaths({
...options,
projectRoot: options.projectRoot ?? process.cwd(),
});
const tables = {
manastorm: readWdbc(paths.manastorm, "manastorm"),
messages: readWdbc(paths.messages, "messages"),
modifiers: readWdbc(paths.modifiers, "modifiers"),
groupModifiers: readWdbc(paths.groupModifiers, "groupModifiers"),
map: readWdbc(paths.map, "map"),
dungeonEncounter: readWdbc(paths.dungeonEncounter, "dungeonEncounter"),
dungeonEncounterExtra: readWdbc(paths.dungeonEncounterExtra, "dungeonEncounterExtra"),
spell: readWdbc(paths.spell, "spell"),
spellIcon: readWdbc(paths.spellIcon, "spellIcon"),
};
const sourceRows = parseManastormRows(tables.manastorm);
const allMaps = parseMaps(tables.map);
const dungeonEncounters = parseDungeonEncounters(tables.dungeonEncounter);
const dungeonEncounterExtras = parseDungeonEncounterExtras(tables.dungeonEncounterExtra);
assertResolvedMaps(sourceRows, allMaps);
const spellIcons = parseSpellIcons(tables.spellIcon);
const installedIconFiles = indexInstalledIconFiles(paths.iconDirectory, paths.projectRoot);
const spellIdsByIconId = indexSpellIconUsage(tables.spell);
const validationIssues = [];
const messages = parseMessages(
tables.messages,
spellIcons,
spellIdsByIconId,
installedIconFiles,
validationIssues,
);
const modifiers = parseModifiers(tables.modifiers);
const groupModifiers = parseGroupModifiers(tables.groupModifiers);
const rowsByMapId = new Map();
const rowsByEncounterId = new Map();
for (const row of sourceRows) {
const mapRows = rowsByMapId.get(row.mapId) ?? [];
mapRows.push(row);
rowsByMapId.set(row.mapId, mapRows);
const encounterRows = rowsByEncounterId.get(row.encounterId) ?? [];
encounterRows.push(row);
rowsByEncounterId.set(row.encounterId, encounterRows);
}
const maps = [...rowsByMapId.entries()]
.sort(([left], [right]) => left - right)
.map(([mapId, rows]) => {
const map = allMaps.get(mapId);
return {
id: stableId("map", mapId),
...map,
sourceRowIds: rows.map((row) => stableId("row", row.sourceRecordId)),
encounterIds: [...new Set(rows.map((row) => row.encounterId))]
.sort((left, right) => left - right)
.map((encounterId) => stableId("encounter", encounterId)),
};
});
const encounters = [...rowsByEncounterId.entries()]
.sort(([left], [right]) => left - right)
.map(([encounterId, rows]) => {
const dungeonEncounter = dungeonEncounters.get(encounterId);
const dungeonEncounterExtra = dungeonEncounterExtras.get(encounterId);
const resolvedCreatureId = dungeonEncounterExtra?.creatureIds.length === 1
? dungeonEncounterExtra.creatureIds[0]
: null;
const mapIds = [...new Set(rows.map((row) => row.mapId))]
.sort((left, right) => left - right);
const relationMatchesMap = dungeonEncounter
? mapIds.includes(dungeonEncounter.mapId)
: false;
const creatureReference = encounterId === 0
? unresolvedReference(
"creature",
"Encounter ID 0 is a source sentinel and does not identify a creature.",
{ sourceEncounterId: encounterId },
)
: dungeonEncounter && resolvedCreatureId && relationMatchesMap
? {
kind: "creature",
status: "resolved",
id: resolvedCreatureId,
sourceEncounterId: encounterId,
source: "DungeonEncounterExtra.dbc",
}
: unresolvedReference(
"creature",
dungeonEncounter && !relationMatchesMap
? "DungeonEncounter.dbc maps this encounter to a different map than Manastorm.dbc."
: dungeonEncounterExtra?.creatureIds.length > 1
? "DungeonEncounterExtra.dbc maps this encounter to multiple creatures; a single runtime boss identity cannot be selected safely."
: "DungeonEncounter.dbc or DungeonEncounterExtra.dbc does not contain a complete encounter-to-creature relation.",
{
sourceEncounterId: encounterId,
...(dungeonEncounterExtra?.creatureIds.length
? { candidateCreatureIds: dungeonEncounterExtra.creatureIds }
: {}),
},
);
return {
id: stableId("encounter", encounterId),
encounterId,
mapIds,
modeIds: [...new Set(rows.map((row) => row.modeId))].sort((left, right) => left - right),
sourceRowIds: rows.map((row) => stableId("row", row.sourceRecordId)),
...(dungeonEncounter
? {
name: dungeonEncounter.name,
difficultyId: dungeonEncounter.difficultyId,
orderIndex: dungeonEncounter.orderIndex,
dungeonEncounterBit: dungeonEncounter.bit,
}
: {}),
creatureReference,
spellReference: unresolvedReference(
"spell",
"The installed Manastorm client tables do not provide an authoritative encounter SpellID.",
),
iconReference: unresolvedReference(
"icon",
"The installed Manastorm client tables do not provide an authoritative encounter icon.",
),
};
});
const catalogRows = sourceRows.map((row) => ({
id: stableId("row", row.sourceRecordId),
sourceRecordId: row.sourceRecordId,
mapId: row.mapId,
mapCatalogId: stableId("map", row.mapId),
modeId: row.modeId,
encounterId: row.encounterId,
encounterCatalogId: stableId("encounter", row.encounterId),
scalars: row.scalars,
}));
validationIssues.sort((left, right) => (
left.code.localeCompare(right.code)
|| left.source.localeCompare(right.source)
|| left.recordId - right.recordId
));
const sources = [
["manastorm", paths.manastorm],
["messages", paths.messages],
["modifiers", paths.modifiers],
["groupModifiers", paths.groupModifiers],
["map", paths.map],
["dungeonEncounter", paths.dungeonEncounter],
["dungeonEncounterExtra", paths.dungeonEncounterExtra],
["spell", paths.spell],
["spellIcon", paths.spellIcon],
].map(([role, filePath]) => sourceProvenance(paths.projectRoot, role, filePath, tables[role]));
const resolvedMessageIcons = messages.filter(
(message) => message.iconReference.status === "resolved",
).length;
return {
schemaVersion: MANASTORM_CATALOG_SCHEMA_VERSION,
catalogId: "ascension-manastorm-installed-client",
provenance: {
importerVersion: MANASTORM_IMPORTER_VERSION,
generatedFrom: sources.map((source) => source.path),
sources,
},
counts: {
sourceRows: catalogRows.length,
maps: maps.length,
encounters: encounters.length,
modifiers: modifiers.length,
groupModifiers: groupModifiers.length,
messages: messages.length,
spellRecords: tables.spell.header.recordCount,
spellIconRecords: tables.spellIcon.header.recordCount,
resolvedMessageIcons,
unresolvedMessageIcons: messages.length - resolvedMessageIcons,
resolvedEncounterCreatures: encounters.filter(
(encounter) => encounter.creatureReference.status === "resolved",
).length,
validationIssues: validationIssues.length,
},
referencePolicy: {
map: "Map IDs must resolve to a named Map.dbc row; unresolved map references abort generation.",
encounter: "Encounter IDs are preserved exactly from Manastorm.dbc, including the zero sentinel.",
creature: "Creature IDs resolve only through matching DungeonEncounter.dbc and DungeonEncounterExtra.dbc rows on the same Manastorm map.",
spell: "No spell identity is inferred from progression values, group IDs, or shared icon usage.",
icon: "Message icons resolve by exact case-insensitive SpellIcon.dbc basename, then by normalized exact basename in the installed icon files; missing references remain unresolved.",
},
validationIssues,
maps,
encounters,
sourceRows: catalogRows,
modifiers,
groupModifiers,
messages,
};
}
export function writeManastormCatalog(catalog, outputPath) {
const serialized = `${JSON.stringify(catalog, null, 2)}\n`;
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, serialized);
return Buffer.byteLength(serialized);
}
export function writeManastormCatalogSummary(catalog, outputPath) {
const summary = {
catalogId: catalog.catalogId,
sourceRows: catalog.counts.sourceRows,
maps: catalog.counts.maps,
encounters: catalog.counts.encounters,
modifiers: catalog.counts.modifiers,
groupModifiers: catalog.counts.groupModifiers,
messages: catalog.counts.messages,
};
const serialized = [
"/** Generated by scripts/manastorm-import; safe to load before the full runtime catalog. */",
`export const MANASTORM_CATALOG_SUMMARY = Object.freeze(${JSON.stringify(summary, null, 2)} as const);`,
"",
].join("\n");
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, serialized);
return Buffer.byteLength(serialized);
}
export function writeManastormRuntimeCatalog(catalog, outputPath, modeId = 0) {
const modeIds = [...new Set([
...catalog.sourceRows.map((row) => row.modeId),
...catalog.modifiers.map((modifier) => modifier.modeId),
...catalog.messages.map((message) => message.modeId),
])].sort((left, right) => left - right);
const runtimeModes = modeIds.map((currentModeId) => {
const modifiers = catalog.modifiers
.filter((modifier) => modifier.modeId === currentModeId)
.sort((left, right) => left.level - right.level)
.map((modifier) => [modifier.level, ...modifier.tuning]);
return {
modeId: currentModeId,
key: currentModeId === 0
? "leveling"
: currentModeId === 2
? "endgame"
: `mode-${currentModeId}`,
maximumLevel: modifiers.reduce(
(maximum, modifier) => Math.max(maximum, modifier[0]),
1,
),
modifiers,
messages: catalog.messages
.filter((message) => message.modeId === currentModeId)
.map((message) => ({
progressionValue: message.progressionValue,
groupId: message.groupId,
title: message.title,
description: message.description,
...(message.iconReference.status === "resolved"
? { iconReference: message.iconReference.basename }
: {}),
})),
};
});
const primaryMode = runtimeModes.find((mode) => mode.modeId === modeId)
?? runtimeModes[0]
?? { modeId, maximumLevel: 1, modifiers: [], messages: [] };
const rowsByEncounterId = new Map();
for (const row of catalog.sourceRows) {
const rows = rowsByEncounterId.get(row.encounterId) ?? [];
rows.push(row);
rowsByEncounterId.set(row.encounterId, rows);
}
const encounters = catalog.encounters
.filter((encounter) => (
encounter.creatureReference.status === "resolved"
&& rowsByEncounterId.has(encounter.encounterId)
))
.map((encounter) => {
const rows = rowsByEncounterId.get(encounter.encounterId);
return {
encounterId: encounter.encounterId,
name: encounter.name,
creatureId: encounter.creatureReference.id,
mapIds: [...new Set(rows.map((row) => row.mapId))].sort((left, right) => left - right),
modeIds: [...new Set(rows.map((row) => row.modeId))].sort((left, right) => left - right),
sourceRowIds: rows.map((row) => row.id),
};
});
const encounterSources = catalog.provenance.sources
.filter((source) => (
source.role === "dungeonEncounter"
|| source.role === "dungeonEncounterExtra"
))
.map((source) => ({
role: source.role,
path: source.path,
sha256: source.sha256,
dbc: source.dbc,
}));
const encounterModesByMap = [...new Set(catalog.sourceRows.map((row) => row.mapId))]
.sort((left, right) => left - right)
.map((mapId) => ({
mapId,
modeIds: [...new Set(
catalog.sourceRows
.filter((row) => row.mapId === mapId)
.map((row) => row.modeId),
)].sort((left, right) => left - right),
}));
const sourceRows = catalog.sourceRows.map((row) => ({
sourceRecordId: row.sourceRecordId,
mapId: row.mapId,
modeId: row.modeId,
encounterId: row.encounterId,
scalars: row.scalars,
}));
const runtimeCatalog = {
schemaVersion: 2,
catalogId: catalog.catalogId,
modeId,
defaultModeId: modeId,
modeIds,
maximumLevel: runtimeModes.reduce(
(maximum, mode) => Math.max(maximum, mode.maximumLevel),
1,
),
modes: runtimeModes,
encounters,
sourceRows,
encounterSources,
encounterModesByMap,
// Retained for consumers of the original single-mode snapshot schema.
modifiers: primaryMode.modifiers,
groupModifiers: catalog.groupModifiers
.filter((modifier) => modifier.modeId === modeId)
.sort((left, right) => left.playerCount - right.playerCount)
.map((modifier) => [modifier.playerCount, ...modifier.multipliers]),
messages: primaryMode.messages,
};
const serialized = `${JSON.stringify(runtimeCatalog)}\n`;
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, serialized);
return Buffer.byteLength(serialized);
}