1019 lines
45 KiB
JavaScript
1019 lines
45 KiB
JavaScript
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import crypto from "node:crypto";
|
||
import { fileURLToPath } from "node:url";
|
||
import { decodeJsonBytes, resolveBaselinePath } from "./baseline.mjs";
|
||
import { archiveRowsToDbc } from "./archive-dbc.mjs";
|
||
|
||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||
const defaultProjectRoot = path.resolve(scriptDirectory, "..", "..");
|
||
const baseSpellRelativePath = "client-current/patch-T/DBFilesClient/Spell.dbc";
|
||
const overlaySpellRelativePath = "client-current/area-52/patch-D/DBFilesClient/Spell.dbc";
|
||
const spellSourceId = "global-spell-base";
|
||
const classAliases = new Map([
|
||
[14, ["DemonHunter"]],
|
||
[19, ["Monk"]],
|
||
[20, ["SonOfArugal"]],
|
||
]);
|
||
|
||
function normalizeClassName(value) {
|
||
return String(value ?? "").toLocaleLowerCase("en-US").replace(/[^a-z0-9]/g, "");
|
||
}
|
||
|
||
function gameIdForClassName(value) {
|
||
return String(value ?? "").toLocaleLowerCase("en-US")
|
||
.replace(/['’]/g, "")
|
||
.replace(/[^a-z0-9]+/g, "-")
|
||
.replace(/^-|-$/g, "");
|
||
}
|
||
|
||
function sha256(buffer) {
|
||
return crypto.createHash("sha256").update(buffer).digest("hex");
|
||
}
|
||
|
||
function pathExists(directory) {
|
||
try {
|
||
return fs.statSync(directory).isDirectory();
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function rootsForDrive(projectRoot, drive) {
|
||
const parent = path.resolve(projectRoot, "..");
|
||
if (drive === "H") {
|
||
return {
|
||
clientRoot: "H:\\resources\\client",
|
||
extractedRoot: "H:\\LadiksMPQEditor",
|
||
};
|
||
}
|
||
return {
|
||
clientRoot: path.join(parent, "resources", "client"),
|
||
extractedRoot: path.join(parent, "LadiksMPQEditor"),
|
||
};
|
||
}
|
||
|
||
/** Resolve a client and extraction directory as one source set. */
|
||
export function resolveSourceRoots({
|
||
projectRoot = defaultProjectRoot,
|
||
clientRoot,
|
||
extractedRoot,
|
||
} = {}) {
|
||
const resolvedProjectRoot = path.resolve(projectRoot);
|
||
const hasClient = clientRoot !== undefined;
|
||
const hasExtracted = extractedRoot !== undefined;
|
||
if (hasClient !== hasExtracted) {
|
||
throw new Error("Pass clientRoot and extractedRoot together so the source directories stay paired");
|
||
}
|
||
|
||
if (clientRoot !== undefined && extractedRoot !== undefined) {
|
||
const roots = { clientRoot: path.resolve(clientRoot), extractedRoot: path.resolve(extractedRoot) };
|
||
if (!pathExists(roots.clientRoot)) throw new Error(`Client source root does not exist: ${roots.clientRoot}`);
|
||
if (!pathExists(roots.extractedRoot)) throw new Error(`Extracted source root does not exist: ${roots.extractedRoot}`);
|
||
return { projectRoot: resolvedProjectRoot, ...roots, rootSet: "supplied" };
|
||
}
|
||
|
||
const hRoots = rootsForDrive(resolvedProjectRoot, "H");
|
||
if (pathExists(hRoots.clientRoot) && pathExists(hRoots.extractedRoot)) {
|
||
return { projectRoot: resolvedProjectRoot, ...hRoots, rootSet: "H" };
|
||
}
|
||
const siblingRoots = rootsForDrive(resolvedProjectRoot, "F");
|
||
if (pathExists(siblingRoots.clientRoot) && pathExists(siblingRoots.extractedRoot)) {
|
||
return { projectRoot: resolvedProjectRoot, ...siblingRoots, rootSet: "F" };
|
||
}
|
||
throw new Error("Could not find a complete local client/extraction root pair; pass both clientRoot and extractedRoot");
|
||
}
|
||
|
||
function assert(condition, message) {
|
||
if (!condition) throw new Error(message);
|
||
}
|
||
|
||
function readJson(buffer, displayPath) {
|
||
let parsed;
|
||
try {
|
||
parsed = JSON.parse(decodeJsonBytes(buffer, displayPath).toString("utf8"));
|
||
} catch (error) {
|
||
throw new Error(`${displayPath} is malformed JSON: ${error.message}`);
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
function inspectDbc(buffer, displayPath, minimumFields = 1) {
|
||
assert(buffer.length >= 20, `${displayPath} has a truncated WDBC header`);
|
||
assert(buffer.toString("ascii", 0, 4) === "WDBC", `${displayPath} is not a WDBC file`);
|
||
const recordCount = buffer.readUInt32LE(4);
|
||
const fieldCount = buffer.readUInt32LE(8);
|
||
const recordSize = buffer.readUInt32LE(12);
|
||
const stringSize = buffer.readUInt32LE(16);
|
||
assert(fieldCount >= minimumFields, `${displayPath} has ${fieldCount} fields; expected at least ${minimumFields}`);
|
||
assert(recordSize >= fieldCount * 4, `${displayPath} record size is smaller than its field count`);
|
||
const expectedLength = 20 + recordCount * recordSize + stringSize;
|
||
assert(Number.isSafeInteger(expectedLength) && expectedLength === buffer.length, `${displayPath} has inconsistent WDBC record/string lengths`);
|
||
return { recordCount, fieldCount, recordSize, stringSize, stringsOffset: 20 + recordCount * recordSize };
|
||
}
|
||
|
||
function field(buffer, header, rowBase, index) {
|
||
assert(index >= 0 && index < header.fieldCount, `DBC field ${index} is outside the declared schema`);
|
||
return buffer.readUInt32LE(rowBase + index * 4);
|
||
}
|
||
|
||
function signedField(buffer, header, rowBase, index) {
|
||
assert(index >= 0 && index < header.fieldCount, `DBC field ${index} is outside the declared schema`);
|
||
return buffer.readInt32LE(rowBase + index * 4);
|
||
}
|
||
|
||
function floatField(buffer, header, rowBase, index) {
|
||
assert(index >= 0 && index < header.fieldCount, `DBC field ${index} is outside the declared schema`);
|
||
return buffer.readFloatLE(rowBase + index * 4);
|
||
}
|
||
|
||
function stringAt(buffer, header, offset) {
|
||
if (!offset || offset >= header.stringSize) return "";
|
||
const start = header.stringsOffset + offset;
|
||
const end = buffer.indexOf(0, start);
|
||
return buffer.toString("utf8", start, end < 0 ? buffer.length : end);
|
||
}
|
||
|
||
function localizedString(buffer, header, rowBase, firstField, localeCount = 16) {
|
||
for (let locale = 0; locale < localeCount; locale += 1) {
|
||
const value = stringAt(buffer, header, field(buffer, header, rowBase, firstField + locale));
|
||
if (value) return value;
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function cleanClientText(value) {
|
||
return String(value ?? "")
|
||
.replace(/\|c[0-9a-f]{8}/gi, "")
|
||
.replace(/\|r/gi, "")
|
||
.replace(/[\r\n]+/g, " ")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
}
|
||
|
||
function tableMap(buffer, header, valueField, valueKind) {
|
||
const values = new Map();
|
||
for (let row = 0; row < header.recordCount; row += 1) {
|
||
const base = 20 + row * header.recordSize;
|
||
const id = field(buffer, header, base, 0);
|
||
const value = valueKind === "float"
|
||
? floatField(buffer, header, base, valueField)
|
||
: signedField(buffer, header, base, valueField);
|
||
if (!values.has(id)) values.set(id, value);
|
||
}
|
||
return values;
|
||
}
|
||
|
||
function rangeTable(buffer, header) {
|
||
const values = new Map();
|
||
for (let row = 0; row < header.recordCount; row += 1) {
|
||
const base = 20 + row * header.recordSize;
|
||
const id = field(buffer, header, base, 0);
|
||
values.set(id, {
|
||
min: Math.max(0, floatField(buffer, header, base, 1), floatField(buffer, header, base, 2)),
|
||
max: Math.max(0, floatField(buffer, header, base, 3), floatField(buffer, header, base, 4)),
|
||
});
|
||
}
|
||
return values;
|
||
}
|
||
|
||
function collectClassMaps(buffer, header, classes) {
|
||
const namesById = new Map();
|
||
const idsByName = new Map();
|
||
const rows = [];
|
||
for (let index = 0; index < header.recordCount; index += 1) {
|
||
const base = 20 + index * header.recordSize;
|
||
const id = field(buffer, header, base, 0);
|
||
const name = localizedString(buffer, header, base, 4);
|
||
if (!name) continue;
|
||
namesById.set(id, name);
|
||
const normalized = normalizeClassName(name);
|
||
const matches = idsByName.get(normalized) ?? [];
|
||
matches.push(id);
|
||
idsByName.set(normalized, matches);
|
||
if (classes.some((definition) => normalizeClassName(definition.className) === normalized)) {
|
||
rows.push({ id, name, fields: Array.from({ length: header.fieldCount }, (_, fieldIndex) => field(buffer, header, base, fieldIndex)) });
|
||
}
|
||
}
|
||
return { namesById, idsByName, rows };
|
||
}
|
||
|
||
function allStringsInRow(buffer, header, rowBase) {
|
||
const strings = {};
|
||
for (let index = 0; index < header.fieldCount; index += 1) {
|
||
const offset = field(buffer, header, rowBase, index);
|
||
const value = stringAt(buffer, header, offset);
|
||
if (value) strings[index] = value;
|
||
}
|
||
return strings;
|
||
}
|
||
|
||
function collectSpecEvidence(buffer, header, wantedNames) {
|
||
const rows = [];
|
||
for (let index = 0; index < header.recordCount; index += 1) {
|
||
const base = 20 + index * header.recordSize;
|
||
const strings = allStringsInRow(buffer, header, base);
|
||
const matches = [...wantedNames].filter((name) => Object.values(strings).some((value) => normalizeClassName(value) === normalizeClassName(name)));
|
||
if (matches.length === 0) continue;
|
||
rows.push({
|
||
id: field(buffer, header, base, 0),
|
||
classNamesMatched: matches,
|
||
fields: Array.from({ length: header.fieldCount }, (_, fieldIndex) => field(buffer, header, base, fieldIndex)),
|
||
strings,
|
||
});
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
function buildSkillLineIndex(buffer, header) {
|
||
const bySpellId = new Map();
|
||
for (let row = 0; row < header.recordCount; row += 1) {
|
||
const base = 20 + row * header.recordSize;
|
||
const skillLineId = field(buffer, header, base, 1);
|
||
const spellId = field(buffer, header, base, 2);
|
||
const classMask = field(buffer, header, base, 5);
|
||
const entries = bySpellId.get(spellId) ?? [];
|
||
if (!entries.some((entry) => entry.skillLineId === skillLineId && entry.classMask === classMask)) {
|
||
entries.push({ skillLineId, classMask });
|
||
}
|
||
bySpellId.set(spellId, entries);
|
||
}
|
||
return bySpellId;
|
||
}
|
||
|
||
function classIdsFromMask(mask) {
|
||
const ids = [];
|
||
if (mask === 0 || mask === 0xffffffff) return ids;
|
||
for (let classId = 1; classId <= 32; classId += 1) {
|
||
if ((mask & (1 << (classId - 1))) !== 0) ids.push(classId);
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
function makeSpellDbc(buffer, header, id, supports) {
|
||
const base = header.idToBase.get(id);
|
||
if (base === undefined) return null;
|
||
const unsigned = (index) => field(buffer, header, base, index);
|
||
const signed = (index) => signedField(buffer, header, base, index);
|
||
const floating = (index) => floatField(buffer, header, base, index);
|
||
const localized = (index) => cleanClientText(localizedString(buffer, header, base, index));
|
||
const durationIndex = unsigned(40);
|
||
const castTimeIndex = unsigned(28);
|
||
const rangeIndex = unsigned(46);
|
||
const effects = Array.from({ length: 3 }, (_, effectIndex) => ({
|
||
index: effectIndex,
|
||
effectType: unsigned(71 + effectIndex),
|
||
auraType: unsigned(95 + effectIndex),
|
||
basePoints: signed(80 + effectIndex) + 1,
|
||
dieSides: signed(74 + effectIndex),
|
||
pointsPerLevel: floating(77 + effectIndex),
|
||
mechanic: unsigned(83 + effectIndex),
|
||
implicitTargetA: unsigned(86 + effectIndex),
|
||
implicitTargetB: unsigned(89 + effectIndex),
|
||
radiusIndex: unsigned(92 + effectIndex),
|
||
radius: lookupSupport(supports.radii, unsigned(92 + effectIndex)),
|
||
periodMs: unsigned(98 + effectIndex),
|
||
valueMultiplier: floating(101 + effectIndex),
|
||
chainTargets: unsigned(104 + effectIndex),
|
||
itemType: unsigned(107 + effectIndex),
|
||
miscValue: signed(110 + effectIndex),
|
||
miscValueB: signed(113 + effectIndex),
|
||
triggerSpellId: unsigned(116 + effectIndex),
|
||
pointsPerCombo: floating(119 + effectIndex),
|
||
coefficient: floating(229 + effectIndex),
|
||
})).filter((effect) => (
|
||
effect.effectType || effect.auraType || effect.basePoints !== 1 || effect.triggerSpellId
|
||
));
|
||
|
||
return {
|
||
id,
|
||
name: localized(136),
|
||
rankText: localized(153),
|
||
description: localized(170) || localized(187),
|
||
categoryId: unsigned(1),
|
||
dispelType: unsigned(2),
|
||
mechanic: unsigned(3),
|
||
attributes: Array.from({ length: 8 }, (_, attribute) => unsigned(4 + attribute)),
|
||
castTimeIndex,
|
||
castTimeMs: lookupSupport(supports.castTimes, castTimeIndex),
|
||
recoveryTimeMs: unsigned(29),
|
||
categoryRecoveryTimeMs: unsigned(30),
|
||
interruptFlags: unsigned(31),
|
||
auraInterruptFlags: unsigned(32),
|
||
channelInterruptFlags: unsigned(33),
|
||
procFlags: unsigned(34),
|
||
procChance: unsigned(35),
|
||
procCharges: unsigned(36),
|
||
maxLevel: unsigned(37),
|
||
baseLevel: unsigned(38),
|
||
spellLevel: unsigned(39),
|
||
durationIndex,
|
||
durationMs: lookupSupport(supports.durations, durationIndex),
|
||
powerType: signed(41),
|
||
powerCost: unsigned(42),
|
||
powerCostPerLevel: unsigned(43),
|
||
powerCostPerSecond: unsigned(44),
|
||
powerCostPerSecondPerLevel: unsigned(45),
|
||
rangeIndex,
|
||
rangeMin: supports.ranges ? supports.ranges.get(rangeIndex)?.min ?? (rangeIndex === 0 ? 0 : null) : (rangeIndex === 0 ? 0 : null),
|
||
rangeMax: supports.ranges ? supports.ranges.get(rangeIndex)?.max ?? (rangeIndex === 0 ? 0 : null) : (rangeIndex === 0 ? 0 : null),
|
||
projectileSpeed: floating(47),
|
||
stackAmount: unsigned(49),
|
||
equippedItemClass: signed(67),
|
||
equippedItemSubclassMask: signed(68),
|
||
equippedItemInventoryMask: signed(69),
|
||
iconId: unsigned(133),
|
||
powerCostPercentage: unsigned(204),
|
||
startRecoveryCategory: unsigned(205),
|
||
startRecoveryTimeMs: unsigned(206),
|
||
maximumTargets: unsigned(212),
|
||
damageClass: unsigned(213),
|
||
preventionType: unsigned(214),
|
||
schoolMask: unsigned(225),
|
||
runeCostId: unsigned(226),
|
||
powerDisplayId: unsigned(228),
|
||
effects,
|
||
};
|
||
}
|
||
|
||
function lookupSupport(table, id) {
|
||
if (id === 0) return 0;
|
||
if (!table) return null;
|
||
return table.get(id) ?? null;
|
||
}
|
||
|
||
function addDiagnostic(diagnostics, diagnostic, keys) {
|
||
const key = keys ? keys(diagnostic) : `${diagnostic.code}:${diagnostic.message}`;
|
||
if (diagnostics._keys.has(key)) return;
|
||
diagnostics._keys.add(key);
|
||
diagnostics.push(diagnostic);
|
||
}
|
||
|
||
function evidenceKey(classId, row) {
|
||
return `coa-${classId}-cad-${Number(row.ID)}`;
|
||
}
|
||
|
||
function parseSpellRows(buffer, header, ids, supports, diagnostics, archive = null) {
|
||
const idToBase = new Map();
|
||
const duplicateIds = new Set();
|
||
for (let index = 0; index < header.recordCount; index += 1) {
|
||
const base = 20 + index * header.recordSize;
|
||
const id = field(buffer, header, base, 0);
|
||
if (idToBase.has(id)) duplicateIds.add(id);
|
||
else idToBase.set(id, base);
|
||
}
|
||
header.idToBase = idToBase;
|
||
for (const id of duplicateIds) {
|
||
addDiagnostic(diagnostics, {
|
||
code: "duplicate-spell-id",
|
||
severity: "warning",
|
||
message: `Base Spell.dbc contains duplicate spell ID ${id}; the first row was retained.`,
|
||
spellId: id,
|
||
}, (item) => `${item.code}:${item.spellId}`);
|
||
}
|
||
|
||
const records = new Map();
|
||
const archivedIds = new Set();
|
||
let archivedBuffer;
|
||
let archivedHeader;
|
||
if (archive) {
|
||
archivedBuffer = archiveRowsToDbc(archive.records, archive.fieldTypes);
|
||
archivedHeader = inspectDbc(archivedBuffer, "pinned August global Spell rows", 234);
|
||
archivedHeader.idToBase = new Map(archive.records.map((record, index) => [record.f0, 20 + index * archivedHeader.recordSize]));
|
||
}
|
||
const triggerEvidenceBySpellId = new Map();
|
||
const pending = [...ids].sort((left, right) => left - right);
|
||
const queued = new Set(pending);
|
||
while (pending.length) {
|
||
const id = pending.shift();
|
||
if (records.has(id)) continue;
|
||
let dbc = makeSpellDbc(buffer, header, id, supports);
|
||
if (!dbc && archivedHeader) {
|
||
dbc = makeSpellDbc(archivedBuffer, archivedHeader, id, supports);
|
||
if (dbc) archivedIds.add(id);
|
||
}
|
||
if (!dbc) {
|
||
records.set(id, null);
|
||
continue;
|
||
}
|
||
records.set(id, dbc);
|
||
for (const effect of dbc.effects) {
|
||
const triggerSpellId = Number(effect.triggerSpellId) || (effect.effectType === 168 ? Number(effect.miscValue) : 0);
|
||
if (!triggerSpellId) continue;
|
||
const references = triggerEvidenceBySpellId.get(triggerSpellId) ?? [];
|
||
references.push({ kind: "dbc-effect-trigger", sourceSpellId: id, effectIndex: effect.index });
|
||
triggerEvidenceBySpellId.set(triggerSpellId, references);
|
||
if (queued.has(triggerSpellId)) continue;
|
||
queued.add(triggerSpellId);
|
||
pending.push(triggerSpellId);
|
||
}
|
||
}
|
||
return { records, triggerEvidenceBySpellId, archivedIds };
|
||
}
|
||
|
||
function makeSourceRecord({ id, portablePath, kind, role, buffer, header }) {
|
||
return {
|
||
id,
|
||
path: portablePath,
|
||
sha256: sha256(buffer),
|
||
bytes: buffer.length,
|
||
kind,
|
||
role,
|
||
...(header ? {
|
||
recordCount: header.recordCount,
|
||
fieldCount: header.fieldCount,
|
||
recordSize: header.recordSize,
|
||
stringSize: header.stringSize,
|
||
} : {}),
|
||
};
|
||
}
|
||
|
||
function addFileSource(sources, id, absolutePath, portablePath, kind, role, required = false, minFields = 1) {
|
||
if (!fs.existsSync(absolutePath)) {
|
||
if (required) throw new Error(`Required source file is missing: ${portablePath}`);
|
||
return null;
|
||
}
|
||
const buffer = fs.readFileSync(absolutePath);
|
||
let header = null;
|
||
if (kind === "dbc") header = inspectDbc(buffer, portablePath, minFields);
|
||
const source = makeSourceRecord({ id, portablePath, kind, role, buffer, header });
|
||
sources.push(source);
|
||
return { source, buffer, header };
|
||
}
|
||
|
||
function buildClassCatalogs(classes, cadRows, classDbc, specEvidence, diagnostics) {
|
||
const byName = new Map();
|
||
for (const row of classDbc.rows) {
|
||
const normalized = normalizeClassName(row.name);
|
||
const matches = byName.get(normalized) ?? [];
|
||
matches.push(row);
|
||
byName.set(normalized, matches);
|
||
}
|
||
const catalogs = [];
|
||
const classIdByNormalizedName = new Map();
|
||
for (const definition of classes) {
|
||
const normalized = normalizeClassName(definition.className);
|
||
const candidates = byName.get(normalized) ?? [];
|
||
definition.gameId = gameIdForClassName(definition.className);
|
||
const aliases = classAliases.get(Number(definition.classId)) ?? [];
|
||
if (candidates.length === 1) {
|
||
definition.clientClassId = candidates[0].id;
|
||
classIdByNormalizedName.set(normalized, Number(definition.classId));
|
||
} else {
|
||
definition.clientClassId = null;
|
||
addDiagnostic(diagnostics, {
|
||
code: candidates.length ? "ambiguous-game-class-id" : "missing-game-class-id",
|
||
severity: "warning",
|
||
message: candidates.length
|
||
? `Class ${definition.className} matches multiple ChrClasses rows.`
|
||
: `Class ${definition.className} has no matching ChrClasses row.`,
|
||
classId: Number(definition.classId),
|
||
candidateGameIds: candidates.map((candidate) => candidate.id),
|
||
}, (item) => `${item.code}:${item.classId}`);
|
||
}
|
||
if (aliases.length) definition.aliases = aliases;
|
||
catalogs.push({
|
||
classId: Number(definition.classId),
|
||
className: definition.className,
|
||
gameId: definition.gameId,
|
||
clientClassId: definition.clientClassId,
|
||
...(aliases.length ? { aliases } : {}),
|
||
advancementRows: [],
|
||
chrClassEvidence: candidates.length === 1 ? candidates[0] : null,
|
||
chrSpecEvidence: specEvidence.filter((row) => row.classNamesMatched.some((name) => (
|
||
normalizeClassName(name) === normalized || aliases.some((alias) => normalizeClassName(alias) === normalizeClassName(name))
|
||
))),
|
||
});
|
||
for (const alias of aliases) classIdByNormalizedName.set(normalizeClassName(alias), Number(definition.classId));
|
||
}
|
||
const catalogByClassId = new Map(catalogs.map((catalog) => [catalog.classId, catalog]));
|
||
const filteredCadRows = [];
|
||
for (const row of cadRows) {
|
||
const classId = classIdByNormalizedName.get(normalizeClassName(row.Class));
|
||
if (classId === undefined) continue;
|
||
const catalog = catalogByClassId.get(classId);
|
||
catalog.advancementRows.push(row);
|
||
filteredCadRows.push({ classId, row });
|
||
}
|
||
return { catalogs, filteredCadRows, classIdByNormalizedName };
|
||
}
|
||
|
||
function buildAbilities({ filteredCadRows, nodes, spellRankData, skillLineBySpellId, diagnostics }) {
|
||
const ownersBySpellId = new Map();
|
||
for (const { classId, row } of filteredCadRows) {
|
||
for (const rawSpellId of Array.isArray(row.Spells) ? row.Spells : []) {
|
||
const spellId = Number(rawSpellId);
|
||
if (!Number.isInteger(spellId) || spellId <= 0) continue;
|
||
const owners = ownersBySpellId.get(spellId) ?? new Set();
|
||
owners.add(classId);
|
||
ownersBySpellId.set(spellId, owners);
|
||
}
|
||
}
|
||
const rankRowsByFirstSpell = new Map();
|
||
const rankRowsBySpell = new Map();
|
||
for (const rankRow of spellRankData) {
|
||
const spellId = Number(rankRow.spellId);
|
||
const firstSpellId = Number(rankRow.firstSpellId);
|
||
if (!Number.isInteger(spellId) || spellId <= 0 || !Number.isInteger(firstSpellId) || firstSpellId <= 0) continue;
|
||
const family = rankRowsByFirstSpell.get(firstSpellId) ?? [];
|
||
family.push(rankRow);
|
||
rankRowsByFirstSpell.set(firstSpellId, family);
|
||
rankRowsBySpell.set(spellId, rankRow);
|
||
}
|
||
const nodeByClassAndId = new Map(nodes.map((node) => [`${Number(node.classId)}:${Number(node.id)}`, node]));
|
||
const abilities = [];
|
||
for (const { classId, row } of filteredCadRows) {
|
||
const key = evidenceKey(classId, row);
|
||
const spellIds = [...new Set((Array.isArray(row.Spells) ? row.Spells : []).map(Number).filter((id) => Number.isInteger(id) && id > 0))].sort((a, b) => a - b);
|
||
const node = nodeByClassAndId.get(`${classId}:${Number(row.ID)}`);
|
||
const selectedRankSpellIds = new Set();
|
||
for (const spellId of spellIds) {
|
||
const family = rankRowsByFirstSpell.get(spellId)
|
||
?? (rankRowsBySpell.has(spellId) ? [rankRowsBySpell.get(spellId)] : []);
|
||
const candidates = [...new Map(family.map((candidate) => [Number(candidate.spellId), candidate])).values()];
|
||
const sameClass = candidates.filter((candidate) => {
|
||
const candidateId = Number(candidate.spellId);
|
||
const cadOwner = ownersBySpellId.get(candidateId)?.has(classId) ?? false;
|
||
const maskOwner = (skillLineBySpellId.get(candidateId) ?? []).some((entry) => classIdsFromMask(entry.classMask).includes(classId));
|
||
return cadOwner || maskOwner || candidateId === spellId;
|
||
});
|
||
const chosen = sameClass.length ? sameClass : candidates;
|
||
for (const candidate of chosen) selectedRankSpellIds.add(Number(candidate.spellId));
|
||
if (candidates.length > 0 && sameClass.length === 0) {
|
||
addDiagnostic(diagnostics, {
|
||
code: "rank-identity-no-same-class-match",
|
||
severity: "warning",
|
||
message: `No same-class local rank identity was found for ${row.Name} in ${row.Class}; unscoped candidates are retained as evidence.`,
|
||
classId,
|
||
abilityKey: key,
|
||
firstSpellId: spellId,
|
||
candidateSpellIds: candidates.map((candidate) => Number(candidate.spellId)).sort((a, b) => a - b),
|
||
}, (item) => `${item.code}:${item.abilityKey}:${item.firstSpellId}`);
|
||
}
|
||
const byRank = new Map();
|
||
for (const candidate of sameClass) {
|
||
const rank = Number(candidate.rank) || null;
|
||
if (rank === null) continue;
|
||
const rankIds = byRank.get(rank) ?? [];
|
||
rankIds.push(Number(candidate.spellId));
|
||
byRank.set(rank, rankIds);
|
||
}
|
||
for (const [rank, candidateSpellIds] of byRank) {
|
||
if (candidateSpellIds.length > 1) {
|
||
addDiagnostic(diagnostics, {
|
||
code: "ambiguous-rank-join",
|
||
severity: "warning",
|
||
message: `Multiple same-class spell IDs match rank ${rank} of ${row.Name}.`,
|
||
classId,
|
||
abilityKey: key,
|
||
firstSpellId: spellId,
|
||
rank,
|
||
candidateSpellIds: candidateSpellIds.sort((a, b) => a - b),
|
||
}, (item) => `${item.code}:${item.abilityKey}:${item.firstSpellId}:${item.rank}`);
|
||
}
|
||
}
|
||
}
|
||
const evidence = [{
|
||
kind: "local-character-advancement-row",
|
||
sourceId: "client-cad",
|
||
rowId: Number(row.ID),
|
||
rowClass: row.Class,
|
||
}];
|
||
if (node) evidence.push({ kind: "july-node-baseline-id-match", sourceId: "july-coa-node-baseline", nodeId: Number(node.id), nodeName: node.name });
|
||
if (String(row.Realms ?? "")) evidence.push({ kind: "uninterpreted-realm-mask", value: String(row.Realms) });
|
||
abilities.push({
|
||
key,
|
||
classId,
|
||
name: String(row.Name ?? ""),
|
||
spellIds,
|
||
nodeIds: node ? [Number(node.id)] : [],
|
||
rankSpellIds: [...selectedRankSpellIds].sort((a, b) => {
|
||
const rankDiff = (Number(rankRowsBySpell.get(a)?.rank) || 0) - (Number(rankRowsBySpell.get(b)?.rank) || 0);
|
||
return rankDiff || a - b;
|
||
}),
|
||
live: null,
|
||
evidence,
|
||
});
|
||
}
|
||
return { abilities, ownersBySpellId, rankRowsBySpell, rankRowsByFirstSpell };
|
||
}
|
||
|
||
function buildSpellEvidence({ nodes, extraNodes = [], baselineSpells = [], progressionChains, abilities, spellRankData, ownersBySpellId }) {
|
||
const evidenceBySpell = new Map();
|
||
const classIdsBySpell = new Map();
|
||
const add = (spellId, evidence, classId) => {
|
||
if (!Number.isInteger(spellId) || spellId <= 0) return;
|
||
const evidenceItems = evidenceBySpell.get(spellId) ?? [];
|
||
evidenceItems.push(evidence);
|
||
evidenceBySpell.set(spellId, evidenceItems);
|
||
if (Number.isInteger(classId)) {
|
||
const classes = classIdsBySpell.get(spellId) ?? new Set();
|
||
classes.add(classId);
|
||
classIdsBySpell.set(spellId, classes);
|
||
}
|
||
};
|
||
const nodeNameBySpell = new Map();
|
||
for (const node of nodes) {
|
||
for (const rank of Array.isArray(node.rankDescriptions) ? node.rankDescriptions : []) {
|
||
const spellId = Number(rank.spellId);
|
||
add(spellId, { kind: "july-node-rank", sourceId: "july-coa-node-baseline", nodeId: Number(node.id), classId: Number(node.classId), rank: Number(rank.rank) }, Number(node.classId));
|
||
const names = nodeNameBySpell.get(spellId) ?? [];
|
||
names.push({ name: node.name, description: rank.description, rank: rank.rank });
|
||
nodeNameBySpell.set(spellId, names);
|
||
}
|
||
}
|
||
for (const node of extraNodes) {
|
||
for (const rank of node.rankDescriptions ?? []) {
|
||
add(Number(rank.spellId), { kind: "august-node-rank", sourceId: "august-voljin-supplement", nodeId: Number(node.id), classId: Number(node.classId), rank: Number(rank.rank) }, Number(node.classId));
|
||
}
|
||
}
|
||
for (const spell of baselineSpells) {
|
||
const spellId = Number(spell?.id);
|
||
const classIds = [...new Set((spell?.classIds ?? []).map(Number).filter((classId) => Number.isInteger(classId) && classId > 11))];
|
||
for (const classId of classIds) {
|
||
add(spellId, {
|
||
kind: "july-spell-identity",
|
||
sourceId: "july-coa-node-baseline",
|
||
classId,
|
||
name: String(spell?.name ?? ""),
|
||
rank: String(spell?.rank ?? ""),
|
||
}, classId);
|
||
}
|
||
}
|
||
for (const ability of abilities) {
|
||
for (const spellId of ability.spellIds) {
|
||
add(spellId, { kind: "local-cad-ability", sourceId: "client-cad", abilityKey: ability.key, classId: ability.classId }, ability.classId);
|
||
}
|
||
for (const spellId of ability.rankSpellIds) {
|
||
add(spellId, { kind: "local-cad-rank-family", sourceId: "client-spell-ranks", abilityKey: ability.key, classId: ability.classId }, ability.classId);
|
||
}
|
||
}
|
||
for (const chain of progressionChains) {
|
||
for (const rank of Array.isArray(chain.ranks) ? chain.ranks : []) {
|
||
add(Number(rank.spellId), {
|
||
kind: "july-progression-chain-rank",
|
||
sourceId: "july-coa-node-baseline",
|
||
chainId: chain.id,
|
||
classId: Number(chain.classId),
|
||
rank: Number(rank.rank),
|
||
}, Number(chain.classId));
|
||
}
|
||
}
|
||
const sourceById = new Map();
|
||
for (const rankRow of spellRankData) {
|
||
const spellId = Number(rankRow.spellId);
|
||
if (!Number.isInteger(spellId) || spellId <= 0) continue;
|
||
const rows = sourceById.get(spellId) ?? [];
|
||
rows.push(rankRow);
|
||
sourceById.set(spellId, rows);
|
||
}
|
||
for (const [spellId, rows] of sourceById) {
|
||
if (!evidenceBySpell.has(spellId)) continue;
|
||
for (const row of rows) add(spellId, {
|
||
kind: "local-spell-rank-data",
|
||
sourceId: "client-spell-ranks",
|
||
firstSpellId: Number(row.firstSpellId),
|
||
rank: Number(row.rank),
|
||
level: Number(row.level),
|
||
});
|
||
}
|
||
for (const [spellId, owners] of ownersBySpellId) {
|
||
if (!classIdsBySpell.has(spellId)) classIdsBySpell.set(spellId, new Set());
|
||
for (const owner of owners) classIdsBySpell.get(spellId).add(owner);
|
||
}
|
||
return { evidenceBySpell, classIdsBySpell, nodeNameBySpell };
|
||
}
|
||
|
||
function propagateTriggerClassOwnership(classIdsBySpell, evidenceBySpell, triggerEvidenceBySpellId) {
|
||
const targetsBySource = new Map();
|
||
for (const [targetSpellId, references] of triggerEvidenceBySpellId) {
|
||
for (const reference of references) {
|
||
const sourceSpellId = Number(reference.sourceSpellId);
|
||
const targets = targetsBySource.get(sourceSpellId) ?? new Set();
|
||
targets.add(targetSpellId);
|
||
targetsBySource.set(sourceSpellId, targets);
|
||
}
|
||
}
|
||
const queue = [];
|
||
const queued = new Set();
|
||
const enqueue = (spellId, classId) => {
|
||
const key = `${spellId}:${classId}`;
|
||
if (queued.has(key)) return;
|
||
queued.add(key);
|
||
queue.push({ spellId, classId });
|
||
};
|
||
for (const [spellId, classIds] of classIdsBySpell) {
|
||
for (const classId of classIds) enqueue(spellId, classId);
|
||
}
|
||
for (let cursor = 0; cursor < queue.length; cursor += 1) {
|
||
const { spellId: sourceSpellId, classId } = queue[cursor];
|
||
for (const targetSpellId of targetsBySource.get(sourceSpellId) ?? []) {
|
||
const targetClasses = classIdsBySpell.get(targetSpellId) ?? new Set();
|
||
if (!targetClasses.has(classId)) {
|
||
targetClasses.add(classId);
|
||
const evidenceItems = evidenceBySpell.get(targetSpellId) ?? [];
|
||
evidenceItems.push({
|
||
kind: "trigger-payload-owner",
|
||
sourceId: "dbc-effect-trigger",
|
||
sourceSpellId,
|
||
classId,
|
||
});
|
||
evidenceBySpell.set(targetSpellId, evidenceItems);
|
||
}
|
||
classIdsBySpell.set(targetSpellId, targetClasses);
|
||
enqueue(targetSpellId, classId);
|
||
}
|
||
}
|
||
}
|
||
|
||
function makeSpellRecords({ spellRecords, evidenceBySpell, classIdsBySpell, nodeNameBySpell, rankRowsBySpell, diagnostics, overlayContains, archivedIds, archiveSource }) {
|
||
const spellIds = new Set([...evidenceBySpell.keys(), ...spellRecords.keys()]);
|
||
const spells = [];
|
||
for (const id of [...spellIds].sort((left, right) => left - right)) {
|
||
const dbc = spellRecords.get(id) ?? null;
|
||
const evidence = evidenceBySpell.get(id) ?? [];
|
||
const nodeName = nodeNameBySpell.get(id)?.find((item) => item.name)?.name ?? "";
|
||
const classRow = evidence.find((item) => item.kind === "local-cad-ability");
|
||
const rankRow = rankRowsBySpell.get(id);
|
||
const classCatalogName = classRow ? classRow.abilityKey : "";
|
||
const cadAbility = classCatalogName ? classCatalogName : null;
|
||
const name = dbc?.name || nodeName || "";
|
||
const rank = dbc?.rankText || (rankRow?.rank ? `Rank ${rankRow.rank}` : "");
|
||
const nodeDescription = nodeNameBySpell.get(id)?.find((item) => item.description)?.description ?? "";
|
||
if (!dbc) {
|
||
addDiagnostic(diagnostics, {
|
||
code: "missing-spell-reference",
|
||
severity: "warning",
|
||
message: overlayContains.has(id)
|
||
? `Spell ID ${id} is referenced by the selected local baseline but absent from BASE patch-T Spell.dbc; the comparative patch-D row was not used.`
|
||
: `Spell ID ${id} is referenced by the selected local baseline but absent from BASE patch-T Spell.dbc.`,
|
||
spellId: id,
|
||
overlayOnly: overlayContains.has(id),
|
||
references: evidence,
|
||
}, (item) => `${item.code}:${item.spellId}`);
|
||
}
|
||
spells.push({
|
||
id,
|
||
classIds: [...(classIdsBySpell.get(id) ?? [])].sort((a, b) => a - b),
|
||
name,
|
||
rank,
|
||
description: nodeDescription || dbc?.description || "",
|
||
source: archivedIds.has(id) ? archiveSource.path : "client-current/patch-T/DBFilesClient/Spell.dbc",
|
||
sourceEvidence: [
|
||
...evidence,
|
||
...(rankRow ? [{ kind: "local-spell-rank-data", firstSpellId: Number(rankRow.firstSpellId), rank: Number(rankRow.rank), level: Number(rankRow.level) }] : []),
|
||
...(dbc ? [{ kind: "base-spell-dbc-row", sourceId: archivedIds.has(id) ? archiveSource.id : spellSourceId }] : []),
|
||
...(!dbc && overlayContains.has(id) ? [{ kind: "comparative-overlay-presence-only", sourceId: "area52-spell-overlay" }] : []),
|
||
...(cadAbility ? [{ kind: "cad-ability-key", abilityKey: cadAbility }] : []),
|
||
],
|
||
...(dbc ? { dbc } : {}),
|
||
});
|
||
}
|
||
return spells;
|
||
}
|
||
|
||
function requiredJsonSource(sources, id, absolutePath, portablePath, role, shape) {
|
||
const source = addFileSource(sources, id, absolutePath, portablePath, "json", role, true);
|
||
const value = readJson(source.buffer, portablePath);
|
||
if (shape === "array") assert(Array.isArray(value), `${portablePath} must contain a JSON array`);
|
||
if (shape === "object") assert(value && typeof value === "object" && !Array.isArray(value), `${portablePath} must contain a JSON object`);
|
||
return { source: source.source, value };
|
||
}
|
||
|
||
/** Build an offline, source-labelled reference object from the July baseline and local client files. */
|
||
export function buildLocalReference(options = {}) {
|
||
const roots = resolveSourceRoots(options);
|
||
const sources = [];
|
||
const diagnostics = [];
|
||
diagnostics._keys = new Set();
|
||
const baselineRelativePath = resolveBaselinePath(roots.projectRoot);
|
||
const baselinePath = path.join(roots.projectRoot, baselineRelativePath);
|
||
const baselineSource = requiredJsonSource(sources, "july-coa-node-baseline", baselinePath, baselineRelativePath, "baseline", "object");
|
||
const baseline = baselineSource.value;
|
||
assert(Array.isArray(baseline.classes), `${baselineRelativePath} has no classes array`);
|
||
assert(Array.isArray(baseline.entries), `${baselineRelativePath} has no entries array`);
|
||
assert(Array.isArray(baseline.progressionChains), `${baselineRelativePath} has no progressionChains array`);
|
||
assert(baseline.spells === undefined || Array.isArray(baseline.spells), `${baselineRelativePath} spells must be an array when present`);
|
||
assert(baseline.source && typeof baseline.source.synchronizedAt === "string", `${baselineRelativePath} has no fixed snapshot timestamp`);
|
||
assert(Number.isFinite(Date.parse(baseline.source.synchronizedAt)), `${baselineRelativePath} has an invalid snapshot timestamp`);
|
||
|
||
const cadPath = path.join(roots.clientRoot, "Data", "Content", "CharacterAdvancementData.json");
|
||
const rankPath = path.join(roots.clientRoot, "Data", "Content", "SpellRankData.json");
|
||
const cad = requiredJsonSource(sources, "client-cad", cadPath, "client/Data/Content/CharacterAdvancementData.json", "content", "array");
|
||
const rankData = requiredJsonSource(sources, "client-spell-ranks", rankPath, "client/Data/Content/SpellRankData.json", "content", "array");
|
||
for (const [index, row] of cad.value.entries()) {
|
||
assert(row && typeof row === "object" && !Array.isArray(row), `CharacterAdvancementData.json row ${index} must be an object`);
|
||
}
|
||
for (const [index, row] of rankData.value.entries()) {
|
||
assert(row && typeof row === "object" && !Array.isArray(row), `SpellRankData.json row ${index} must be an object`);
|
||
}
|
||
|
||
const baseSpellPath = path.join(roots.extractedRoot, ...baseSpellRelativePath.split("/"));
|
||
const baseSpellSource = addFileSource(sources, spellSourceId, baseSpellPath, `extracts/${baseSpellRelativePath}`, "dbc", "base", true, 234);
|
||
assert(baseSpellSource.header.fieldCount >= 234, "BASE patch-T Spell.dbc does not expose the expected 234-field schema");
|
||
const spellHeader = baseSpellSource.header;
|
||
const classRoot = path.join(roots.extractedRoot, "client-current", "patch-M", "DBFilesClient");
|
||
const classDbcSource = addFileSource(sources, "global-chr-classes", path.join(classRoot, "ChrClasses.dbc"), "extracts/client-current/patch-M/DBFilesClient/ChrClasses.dbc", "dbc", "class-metadata", true, 20);
|
||
const specDbcSource = addFileSource(sources, "global-chr-specs", path.join(classRoot, "ChrSpecs.dbc"), "extracts/client-current/patch-M/DBFilesClient/ChrSpecs.dbc", "dbc", "class-metadata", true, 10);
|
||
const skillDbcSource = addFileSource(sources, "global-skill-line-ability", path.join(classRoot, "SkillLineAbility.dbc"), "extracts/client-current/patch-M/DBFilesClient/SkillLineAbility.dbc", "dbc", "class-ability-metadata", true, 6);
|
||
const classDbc = collectClassMaps(classDbcSource.buffer, classDbcSource.header, baseline.classes);
|
||
const specNames = new Set(baseline.classes.flatMap((definition) => [
|
||
definition.className,
|
||
...(classAliases.get(Number(definition.classId)) ?? []),
|
||
]));
|
||
const specEvidence = collectSpecEvidence(specDbcSource.buffer, specDbcSource.header, specNames);
|
||
const classes = baseline.classes.map((definition) => ({ ...definition }));
|
||
const classCatalogResult = buildClassCatalogs(classes, cad.value, classDbc, specEvidence, diagnostics);
|
||
for (const { classId, row } of classCatalogResult.filteredCadRows) {
|
||
const numericId = Number(row.ID);
|
||
if (!Number.isInteger(numericId)) {
|
||
addDiagnostic(diagnostics, {
|
||
code: "invalid-cad-row-id",
|
||
severity: "warning",
|
||
message: `Local CAD row for ${row.Class} has no numeric ID.`,
|
||
classId,
|
||
name: row.Name ?? "",
|
||
});
|
||
}
|
||
}
|
||
const classIds = new Set(classes.map((definition) => Number(definition.classId)));
|
||
const nodes = baseline.entries.filter((node) => classIds.has(Number(node.classId))).map((node) => ({ ...node }));
|
||
const progressionChains = baseline.progressionChains
|
||
.filter((chain) => classIds.has(Number(chain.classId)))
|
||
.map((chain) => ({ ...chain, ranks: (chain.ranks ?? []).map((rank) => ({ ...rank })) }));
|
||
const skillLineBySpellId = buildSkillLineIndex(skillDbcSource.buffer, skillDbcSource.header);
|
||
|
||
const supportRoot = path.join(roots.extractedRoot, "client-current", "patch-S", "DBFilesClient");
|
||
const supportDefinitions = [
|
||
["spell-cast-times", "SpellCastTimes.dbc", 2, "number"],
|
||
["spell-durations", "SpellDuration.dbc", 4, "number"],
|
||
["spell-radii", "SpellRadius.dbc", 4, "float"],
|
||
["spell-ranges", "SpellRange.dbc", 5, "range"],
|
||
];
|
||
const supports = { castTimes: null, durations: null, radii: null, ranges: null };
|
||
for (const [id, fileName, minimumFields, valueKind] of supportDefinitions) {
|
||
const displayPath = `extracts/client-current/patch-S/DBFilesClient/${fileName}`;
|
||
const source = addFileSource(sources, id, path.join(supportRoot, fileName), displayPath, "dbc", "support", false, minimumFields);
|
||
if (!source) {
|
||
addDiagnostic(diagnostics, {
|
||
code: "missing-optional-support-dbc",
|
||
severity: "warning",
|
||
message: `${displayPath} is missing; dependent support values remain null when an index is nonzero.`,
|
||
sourceId: id,
|
||
}, (item) => `${item.code}:${item.sourceId}`);
|
||
continue;
|
||
}
|
||
if (valueKind === "range") supports.ranges = rangeTable(source.buffer, source.header);
|
||
else if (valueKind === "float") supports.radii = tableMap(source.buffer, source.header, 3, "float");
|
||
else if (fileName === "SpellCastTimes.dbc") supports.castTimes = tableMap(source.buffer, source.header, 1, "number");
|
||
else supports.durations = tableMap(source.buffer, source.header, 3, "number");
|
||
}
|
||
const overlayPath = path.join(roots.extractedRoot, ...overlaySpellRelativePath.split("/"));
|
||
const overlaySource = addFileSource(sources, "area52-spell-overlay", overlayPath, `extracts/${overlaySpellRelativePath}`, "dbc", "comparative-overlay", false, 234);
|
||
const overlayContains = new Set();
|
||
if (overlaySource) {
|
||
for (let row = 0; row < overlaySource.header.recordCount; row += 1) {
|
||
overlayContains.add(field(overlaySource.buffer, overlaySource.header, 20 + row * overlaySource.header.recordSize, 0));
|
||
}
|
||
} else {
|
||
addDiagnostic(diagnostics, {
|
||
code: "missing-comparative-spell-overlay",
|
||
severity: "info",
|
||
message: `Comparative overlay ${overlaySpellRelativePath} was not available; BASE patch-T remains the only Spell.dbc source.`,
|
||
sourceId: "area52-spell-overlay",
|
||
});
|
||
}
|
||
|
||
const area52ListArchivePath = path.join(roots.clientRoot, "Data", "area-52", "listarchive");
|
||
const area52ListArchive = addFileSource(sources, "area52-listarchive", area52ListArchivePath, "client/Data/area-52/listarchive", "text", "source-order-evidence");
|
||
const globalListArchivePath = path.join(roots.clientRoot, "Data", "listarchive");
|
||
const globalListArchive = addFileSource(sources, "global-listarchive", globalListArchivePath, "client/Data/listarchive", "text", "source-order-evidence");
|
||
const extractionReadmePath = path.join(roots.extractedRoot, "client-current", "README.md");
|
||
const extractionReadme = addFileSource(sources, "extraction-readme", extractionReadmePath, "extracts/client-current/README.md", "text", "source-order-evidence");
|
||
const areaArchiveText = area52ListArchive?.buffer.toString("utf8").trim() ?? "";
|
||
const READMEText = extractionReadme?.buffer.toString("utf8") ?? "";
|
||
const hasPatchDArchive = /(?:^|\s)patch-D\.MPQ(?:\s|$)/im.test(areaArchiveText);
|
||
const readmeClaimsRealmOverride = /area-52\/patch-D[\s\S]{0,180}realm override/i.test(READMEText);
|
||
const readmeClaimsPatchTFallback = /patch-T[\s\S]{0,140}fallback/i.test(READMEText);
|
||
if (!globalListArchive || hasPatchDArchive || (readmeClaimsRealmOverride && readmeClaimsPatchTFallback)) {
|
||
addDiagnostic(diagnostics, {
|
||
code: "source-order-conflict",
|
||
severity: "warning",
|
||
message: "Local source notes point to area-52 patch-D while this reference export selects global patch-T as BASE. The archive list and extraction README do not independently establish the runtime merge order; patch-D is recorded as a comparative overlay only.",
|
||
evidence: {
|
||
globalListArchivePresent: Boolean(globalListArchive),
|
||
area52ListArchiveSelectsPatchD: hasPatchDArchive,
|
||
extractionReadmeClaimsRealmOverride: readmeClaimsRealmOverride,
|
||
extractionReadmeClaimsPatchTFallback: readmeClaimsPatchTFallback,
|
||
},
|
||
}, (item) => item.code);
|
||
}
|
||
if (!globalListArchive) {
|
||
addDiagnostic(diagnostics, {
|
||
code: "global-listarchive-missing",
|
||
severity: "info",
|
||
message: "client/Data/listarchive is absent; only the area-52 listarchive is present in the local client tree.",
|
||
sourceId: "global-listarchive",
|
||
}, (item) => item.code);
|
||
}
|
||
|
||
const classFilteredRows = classCatalogResult.filteredCadRows;
|
||
const { abilities, ownersBySpellId, rankRowsBySpell } = buildAbilities({
|
||
filteredCadRows: classFilteredRows,
|
||
nodes,
|
||
spellRankData: rankData.value,
|
||
skillLineBySpellId,
|
||
diagnostics,
|
||
});
|
||
const spellEvidence = buildSpellEvidence({
|
||
nodes,
|
||
extraNodes: options.extraNodes ?? [],
|
||
baselineSpells: baseline.spells ?? [],
|
||
progressionChains,
|
||
abilities,
|
||
spellRankData: rankData.value,
|
||
ownersBySpellId,
|
||
});
|
||
const seedSpellIds = new Set(spellEvidence.evidenceBySpell.keys());
|
||
const { records: spellRecords, triggerEvidenceBySpellId, archivedIds } = parseSpellRows(baseSpellSource.buffer, spellHeader, seedSpellIds, supports, diagnostics, options.archivedSpells);
|
||
if (options.archivedSpells) sources.push({ ...options.archivedSpells.source, role: "missing-global-spell-supplement", kind: "raw-archive-dbc" });
|
||
for (const [spellId, triggerEvidence] of triggerEvidenceBySpellId) {
|
||
const existing = spellEvidence.evidenceBySpell.get(spellId) ?? [];
|
||
existing.push(...triggerEvidence);
|
||
spellEvidence.evidenceBySpell.set(spellId, existing);
|
||
}
|
||
propagateTriggerClassOwnership(
|
||
spellEvidence.classIdsBySpell,
|
||
spellEvidence.evidenceBySpell,
|
||
triggerEvidenceBySpellId,
|
||
);
|
||
const spells = makeSpellRecords({
|
||
spellRecords,
|
||
evidenceBySpell: spellEvidence.evidenceBySpell,
|
||
classIdsBySpell: spellEvidence.classIdsBySpell,
|
||
nodeNameBySpell: spellEvidence.nodeNameBySpell,
|
||
rankRowsBySpell,
|
||
diagnostics,
|
||
overlayContains,
|
||
archivedIds,
|
||
archiveSource: options.archivedSpells?.source,
|
||
});
|
||
|
||
const spellById = new Map(spells.map((spell) => [Number(spell.id), spell]));
|
||
for (const chain of progressionChains) {
|
||
for (const rank of chain.ranks ?? []) {
|
||
if (spellById.get(Number(rank.spellId))?.dbc) continue;
|
||
addDiagnostic(diagnostics, {
|
||
code: "missing-progression-spell",
|
||
severity: "warning",
|
||
message: `Progression chain ${chain.id} references spell ${rank.spellId}, which has no local BASE Spell.dbc row.`,
|
||
sourceId: "july-coa-node-baseline",
|
||
chainId: chain.id,
|
||
classId: Number(chain.classId),
|
||
spellId: Number(rank.spellId),
|
||
}, (item) => `${item.code}:${item.chainId}:${item.spellId}`);
|
||
}
|
||
}
|
||
|
||
const inventory = {
|
||
rootSet: roots.rootSet,
|
||
spellTables: {
|
||
base: {
|
||
sourceId: spellSourceId,
|
||
path: `extracts/${baseSpellRelativePath}`,
|
||
sha256: baseSpellSource.source.sha256,
|
||
bytes: baseSpellSource.source.bytes,
|
||
recordCount: spellHeader.recordCount,
|
||
fieldCount: spellHeader.fieldCount,
|
||
},
|
||
overlay: overlaySource ? {
|
||
sourceId: "area52-spell-overlay",
|
||
path: `extracts/${overlaySpellRelativePath}`,
|
||
sha256: overlaySource.source.sha256,
|
||
bytes: overlaySource.source.bytes,
|
||
recordCount: overlaySource.header.recordCount,
|
||
fieldCount: overlaySource.header.fieldCount,
|
||
} : null,
|
||
},
|
||
};
|
||
delete diagnostics._keys;
|
||
return {
|
||
schemaVersion: 1,
|
||
classes,
|
||
nodes,
|
||
spells,
|
||
progressionChains,
|
||
abilities,
|
||
sources,
|
||
diagnostics,
|
||
baseline: {
|
||
kind: "local-july",
|
||
capturedAt: baseline.source.synchronizedAt,
|
||
sourceId: "july-coa-node-baseline",
|
||
},
|
||
classCatalogs: classCatalogResult.catalogs,
|
||
inventory,
|
||
...(options.archivedSpells ? { archivedSpellSupplement: { source: options.archivedSpells.source, usedSpellIds: [...archivedIds].sort((a, b) => a - b), files: options.archivedSpells.files } } : {}),
|
||
};
|
||
}
|