1226 lines
68 KiB
JavaScript
1226 lines
68 KiB
JavaScript
import fs from "node:fs";
|
||
import { createHash } from "node:crypto";
|
||
import path from "node:path";
|
||
import { decodeJsonBytes, resolveBaselinePath } from "./baseline.mjs";
|
||
|
||
const EXPECTED_CLASS_IDS = Array.from({ length: 21 }, (_, index) => index + 12);
|
||
const CANONICAL_GAME_IDS = Object.freeze({
|
||
12: "barbarian", 13: "witch-doctor", 14: "felsworn", 15: "witch-hunter", 16: "stormbringer",
|
||
17: "knight-of-xoroth", 18: "guardian", 19: "templar", 20: "bloodmage", 21: "ranger",
|
||
22: "chronomancer", 23: "necromancer", 24: "pyromancer", 25: "cultist", 26: "starcaller",
|
||
27: "sun-cleric", 28: "tinker", 29: "venomancer", 30: "reaper", 31: "primalist", 32: "runemaster",
|
||
});
|
||
const LEGACY_CLASS_ALIASES = Object.freeze({ demonhunter: 14, monk: 19, sonofarugal: 20 });
|
||
const MAX_BLOCKER_SAMPLES = 10;
|
||
const NODE_FIELDS = [
|
||
"tabId", "x", "y", "name", "nodeType", "entryType", "isPassive", "maxPoints", "aeCost",
|
||
"teCost", "requiredClassPoints", "requiredSpecPoints", "requiredLevel", "requiredIds",
|
||
"connectedNodeIds", "isStartingNode",
|
||
"group", "flags", "sortOrder", "icon", "spellId", "spellIds", "description", "rankDescriptions",
|
||
];
|
||
const SPELL_FIELDS = [
|
||
"name", "rankText", "description", "categoryId", "dispelType", "mechanic", "attributes",
|
||
"castTimeMs", "recoveryTimeMs", "categoryRecoveryTimeMs", "interruptFlags", "auraInterruptFlags",
|
||
"channelInterruptFlags", "procFlags", "procChance", "procCharges", "maxLevel", "baseLevel",
|
||
"spellLevel", "durationMs", "powerType", "powerCost", "powerCostPerLevel", "powerCostPerSecond",
|
||
"powerCostPerSecondPerLevel", "rangeMin", "rangeMax", "projectileSpeed", "stackAmount",
|
||
"equippedItemClass", "equippedItemSubclassMask", "equippedItemInventoryMask", "iconId",
|
||
"powerCostPercentage", "startRecoveryCategory", "startRecoveryTimeMs", "maximumTargets",
|
||
"damageClass", "preventionType", "schoolMask", "runeCostId", "powerDisplayId", "effects",
|
||
];
|
||
|
||
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
||
const asArray = (value) => Array.isArray(value) ? value : [];
|
||
const isPositiveInteger = (value) => Number.isInteger(value) && value > 0;
|
||
const stable = (value) => {
|
||
if (Array.isArray(value)) return "[" + value.map(stable).join(",") + "]";
|
||
if (!isRecord(value)) return JSON.stringify(value);
|
||
return "{" + Object.keys(value).sort().map((key) => JSON.stringify(key) + ":" + stable(value[key])).join(",") + "}";
|
||
};
|
||
const issue = (list, code, message, details = {}) => list.push({ code, message, ...details });
|
||
|
||
function aliasesFor(classes) {
|
||
const aliases = new Map();
|
||
for (const record of asArray(classes)) {
|
||
if (!isRecord(record)) continue;
|
||
for (const value of [record.gameId ?? CANONICAL_GAME_IDS[record.classId], record.className, record.name]) {
|
||
if (typeof value === "string" && value.trim()) {
|
||
aliases.set(value.trim().toLowerCase().replace(/\s+/g, "-"), record.classId);
|
||
}
|
||
}
|
||
}
|
||
return aliases;
|
||
}
|
||
|
||
function normalizeClassId(value, aliases) {
|
||
if (Number.isInteger(value)) return value;
|
||
if (typeof value !== "string") return null;
|
||
if (/^\d+$/.test(value.trim())) return Number(value);
|
||
const normalized = value.trim().toLowerCase().replace(/\s+/g, "-");
|
||
return aliases.get(normalized) ?? LEGACY_CLASS_ALIASES[normalized.replaceAll("-", "")] ?? null;
|
||
}
|
||
|
||
function validateReference(reference) {
|
||
const errors = [];
|
||
const warnings = [];
|
||
const counts = {
|
||
classes: 0, nodes: 0, spells: 0, progressionChains: 0, rankSpellReferences: 0,
|
||
missingRankSpells: 0, missingRankDbcRecords: 0,
|
||
missingNodeRankSpells: 0, missingNodeRankDbcRecords: 0,
|
||
missingProgressionSpells: 0, missingProgressionDbcRecords: 0,
|
||
missingPrerequisites: 0, missingConnections: 0,
|
||
missingTriggeredSpells: 0, choiceGroups: 0,
|
||
};
|
||
const fail = (code, message, details = {}) => errors.push({ code, message, ...details });
|
||
const warn = (code, message, details = {}) => warnings.push({ code, message, ...details });
|
||
if (!isRecord(reference)) {
|
||
fail("invalid-reference", "Reference must be an object.");
|
||
return { valid: false, errors, warnings, counts };
|
||
}
|
||
if (reference.schemaVersion !== 1) fail("unsupported-schema", "Reference schemaVersion must be 1.", { actual: reference.schemaVersion ?? null });
|
||
for (const key of ["classes", "nodes", "spells", "progressionChains", "sources"]) {
|
||
if (!Array.isArray(reference[key])) fail("invalid-collection", "Reference " + key + " must be an array.", { collection: key });
|
||
}
|
||
|
||
const classes = asArray(reference.classes);
|
||
const nodes = asArray(reference.nodes);
|
||
const spells = asArray(reference.spells);
|
||
const chains = asArray(reference.progressionChains);
|
||
const sources = asArray(reference.sources);
|
||
counts.classes = classes.length;
|
||
counts.nodes = nodes.length;
|
||
counts.spells = spells.length;
|
||
counts.progressionChains = chains.length;
|
||
|
||
const classIds = new Set();
|
||
const tabsByClass = new Map();
|
||
for (const [index, record] of classes.entries()) {
|
||
if (!isRecord(record)) {
|
||
fail("invalid-class-record", "Class at index " + index + " must be an object.", { index });
|
||
continue;
|
||
}
|
||
const id = record.classId;
|
||
if (!Number.isInteger(id) || id < 12 || id > 32) {
|
||
fail("invalid-class-id", "Class at index " + index + " has an invalid CoA classId.", { index, classId: id ?? null });
|
||
} else if (classIds.has(id)) {
|
||
fail("duplicate-class-key", "Class key " + id + " occurs more than once.", { classId: id });
|
||
} else classIds.add(id);
|
||
if (Number.isInteger(id) && CANONICAL_GAME_IDS[id] && record.gameId !== undefined && record.gameId !== CANONICAL_GAME_IDS[id]) {
|
||
fail("noncanonical-game-id", "Class " + id + " gameId must be " + CANONICAL_GAME_IDS[id] + ".", {
|
||
classId: id, gameId: record.gameId, expectedGameId: CANONICAL_GAME_IDS[id],
|
||
});
|
||
}
|
||
|
||
if (!Array.isArray(record.tabs)) {
|
||
warn("missing-class-tabs", "Class " + (id ?? "?") + " has no tab array.", { classId: id ?? null });
|
||
tabsByClass.set(id, new Set());
|
||
continue;
|
||
}
|
||
const tabs = new Set();
|
||
for (const [tabIndex, tab] of record.tabs.entries()) {
|
||
if (!isRecord(tab) || !isPositiveInteger(tab.tabId)) {
|
||
fail("invalid-tab-id", "Class " + (id ?? "?") + " has an invalid tab ID at index " + tabIndex + ".", {
|
||
classId: id ?? null, tabIndex, tabId: isRecord(tab) ? tab.tabId ?? null : null,
|
||
});
|
||
} else if (tabs.has(tab.tabId)) {
|
||
fail("duplicate-tab-key", "Class " + id + " has duplicate tab " + tab.tabId + ".", { classId: id, tabId: tab.tabId });
|
||
} else tabs.add(tab.tabId);
|
||
}
|
||
tabsByClass.set(id, tabs);
|
||
}
|
||
for (const id of EXPECTED_CLASS_IDS) {
|
||
if (!classIds.has(id)) warn("missing-class", "Reference does not include CoA class " + id + ".", { classId: id });
|
||
}
|
||
|
||
const nodeByKey = new Map();
|
||
const nodesById = new Map();
|
||
for (const [index, node] of nodes.entries()) {
|
||
if (!isRecord(node)) {
|
||
fail("invalid-node-record", "Node at index " + index + " must be an object.", { index });
|
||
continue;
|
||
}
|
||
if (!isPositiveInteger(node.id)) {
|
||
fail("invalid-node-id", "Node at index " + index + " has an invalid ID.", { index, nodeId: node.id ?? null });
|
||
continue;
|
||
}
|
||
if (!Number.isInteger(node.classId) || node.classId < 12 || node.classId > 32) {
|
||
fail("invalid-node-class-id", "Node " + node.id + " has an invalid CoA classId.", { index, nodeId: node.id, classId: node.classId ?? null });
|
||
}
|
||
const key = node.classId + ":" + node.id;
|
||
if (nodeByKey.has(key)) {
|
||
fail("duplicate-node-key", "Node key " + key + " occurs more than once.", { classId: node.classId, nodeId: node.id });
|
||
continue;
|
||
}
|
||
nodeByKey.set(key, node);
|
||
const instances = nodesById.get(node.id) ?? [];
|
||
instances.push(node);
|
||
nodesById.set(node.id, instances);
|
||
if (!classIds.has(node.classId)) fail("node-class-missing", "Node " + node.id + " refers to absent class " + node.classId + ".", { classId: node.classId, nodeId: node.id });
|
||
if (!isPositiveInteger(node.tabId)) {
|
||
fail("invalid-node-tab-id", "Node " + node.id + " has an invalid tabId.", { classId: node.classId, nodeId: node.id, tabId: node.tabId ?? null });
|
||
} else if (tabsByClass.get(node.classId)?.has(node.tabId) !== true) {
|
||
fail("unknown-node-tab", "Node " + node.id + " refers to absent tab " + node.tabId + " in class " + node.classId + ".", { classId: node.classId, nodeId: node.id, tabId: node.tabId });
|
||
}
|
||
if (!Number.isInteger(node.maxPoints) || node.maxPoints < 0) {
|
||
fail("invalid-node-max-points", "Node " + node.id + " has invalid maxPoints.", { classId: node.classId, nodeId: node.id, maxPoints: node.maxPoints ?? null });
|
||
}
|
||
if (node.group !== undefined && (!Number.isInteger(node.group) || node.group < 0)) {
|
||
fail("invalid-choice-group", "Node " + node.id + " has an invalid group ID.", { classId: node.classId, nodeId: node.id, group: node.group });
|
||
}
|
||
}
|
||
|
||
const groups = new Map();
|
||
for (const node of nodes) {
|
||
if (!isRecord(node) || !isPositiveInteger(node.id) || !Number.isInteger(node.classId) || !Number.isInteger(node.group) || node.group <= 0) continue;
|
||
const key = node.classId + ":" + node.group;
|
||
const members = groups.get(key) ?? [];
|
||
members.push(node);
|
||
groups.set(key, members);
|
||
}
|
||
counts.choiceGroups = groups.size;
|
||
for (const [key, members] of groups) {
|
||
if (new Set(members.map((node) => node.tabId)).size > 1) {
|
||
fail("choice-group-cross-tab", "Choice group " + key + " spans multiple tabs.", {
|
||
classId: members[0].classId, group: members[0].group, nodeIds: members.map((node) => node.id).sort((a, b) => a - b),
|
||
});
|
||
}
|
||
if (members.length < 2) warn("singleton-choice-group", "Choice group " + key + " has fewer than two nodes.", {
|
||
classId: members[0].classId, group: members[0].group, nodeIds: members.map((node) => node.id),
|
||
});
|
||
}
|
||
|
||
const spellById = new Map();
|
||
for (const [index, spell] of spells.entries()) {
|
||
if (!isRecord(spell)) {
|
||
fail("invalid-spell-record", "Spell at index " + index + " must be an object.", { index });
|
||
continue;
|
||
}
|
||
if (!isPositiveInteger(spell.id)) {
|
||
fail("invalid-spell-id", "Spell at index " + index + " has an invalid ID.", { index, spellId: spell.id ?? null });
|
||
continue;
|
||
}
|
||
if (spellById.has(spell.id)) {
|
||
fail("duplicate-spell-key", "Spell key " + spell.id + " occurs more than once.", { spellId: spell.id });
|
||
continue;
|
||
}
|
||
spellById.set(spell.id, spell);
|
||
if (isRecord(spell.dbc) && spell.dbc.id !== undefined && spell.dbc.id !== spell.id) {
|
||
fail("spell-dbc-id-mismatch", "Spell " + spell.id + " contains DBC record " + spell.dbc.id + ".", { spellId: spell.id, dbcSpellId: spell.dbc.id });
|
||
}
|
||
for (const classRef of asArray(spell.classIds)) {
|
||
if (!Number.isInteger(classRef) || classRef <= 0) fail("invalid-spell-class-reference", "Spell " + spell.id + " has a malformed class reference.", { spellId: spell.id, classId: classRef });
|
||
}
|
||
}
|
||
|
||
const ranks = [];
|
||
for (const node of nodes) {
|
||
if (!isRecord(node) || !isPositiveInteger(node.id) || !Number.isInteger(node.classId)) continue;
|
||
for (const field of ["requiredIds", "connectedNodeIds"]) {
|
||
const refs = node[field] === undefined ? [] : node[field];
|
||
if (!Array.isArray(refs)) {
|
||
fail("invalid-node-reference-list", "Node " + node.id + " " + field + " must be an array.", { classId: node.classId, nodeId: node.id, field });
|
||
continue;
|
||
}
|
||
const seenRefs = new Set();
|
||
for (const targetId of refs) {
|
||
if (!isPositiveInteger(targetId)) {
|
||
fail("invalid-node-reference-id", "Node " + node.id + " has an invalid " + field + " ID.", { classId: node.classId, nodeId: node.id, field, targetId });
|
||
continue;
|
||
}
|
||
if (seenRefs.has(targetId)) fail("duplicate-node-reference", "Node " + node.id + " repeats " + targetId + " in " + field + ".", {
|
||
classId: node.classId, nodeId: node.id, field, targetId,
|
||
});
|
||
seenRefs.add(targetId);
|
||
if (nodeByKey.has(node.classId + ":" + targetId)) continue;
|
||
const elsewhere = nodesById.get(targetId) ?? [];
|
||
if (elsewhere.length) {
|
||
fail("cross-class-node-reference", "Node " + node.id + " " + field + " points to node " + targetId + " in another class.", {
|
||
classId: node.classId, nodeId: node.id, field, targetId,
|
||
targetClassIds: [...new Set(elsewhere.map((target) => target.classId))].sort((a, b) => a - b),
|
||
});
|
||
} else {
|
||
const code = field === "requiredIds" ? "missing-prerequisite" : "missing-connection";
|
||
counts[field === "requiredIds" ? "missingPrerequisites" : "missingConnections"] += 1;
|
||
warn(code, "Node " + node.id + " " + field + " refers to absent node " + targetId + ".", {
|
||
classId: node.classId, nodeId: node.id, field, targetId,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
if (node.rankDescriptions !== undefined && !Array.isArray(node.rankDescriptions)) {
|
||
fail("invalid-rank-descriptions", "Node " + node.id + " rankDescriptions must be an array.", { classId: node.classId, nodeId: node.id });
|
||
continue;
|
||
}
|
||
const rankNumbers = new Set();
|
||
for (const [rankIndex, rank] of asArray(node.rankDescriptions).entries()) {
|
||
if (!isRecord(rank) || !isPositiveInteger(rank.rank) || !isPositiveInteger(rank.spellId)) {
|
||
fail("invalid-rank-identity", "Node " + node.id + " has a malformed rank number or spell ID.", {
|
||
classId: node.classId, nodeId: node.id, rankIndex, rank: rank?.rank ?? null, spellId: rank?.spellId ?? null,
|
||
});
|
||
continue;
|
||
}
|
||
if (rankNumbers.has(rank.rank)) fail("duplicate-node-rank", "Node " + node.id + " repeats rank " + rank.rank + ".", {
|
||
classId: node.classId, nodeId: node.id, rank: rank.rank,
|
||
});
|
||
rankNumbers.add(rank.rank);
|
||
ranks.push({ classId: node.classId, nodeId: node.id, rank: rank.rank, spellId: rank.spellId });
|
||
counts.rankSpellReferences += 1;
|
||
if (!spellById.has(rank.spellId)) {
|
||
counts.missingRankSpells += 1;
|
||
counts.missingNodeRankSpells += 1;
|
||
warn("missing-rank-spell", "Node " + node.id + " rank " + rank.rank + " references absent spell " + rank.spellId + ".", {
|
||
classId: node.classId, nodeId: node.id, nodeName: node.name ?? "", rank: rank.rank, spellId: rank.spellId,
|
||
});
|
||
} else if (!isRecord(spellById.get(rank.spellId)?.dbc)) {
|
||
counts.missingRankDbcRecords += 1;
|
||
counts.missingNodeRankDbcRecords += 1;
|
||
warn("missing-rank-dbc", "Node " + node.id + " rank " + rank.rank + " has a spell record without a base DBC row.", {
|
||
classId: node.classId, nodeId: node.id, nodeName: node.name ?? "", rank: rank.rank, spellId: rank.spellId,
|
||
});
|
||
}
|
||
}
|
||
if (Number.isInteger(node.maxPoints) && rankNumbers.size > 0 && rankNumbers.size !== node.maxPoints) {
|
||
warn("rank-count-mismatch", "Node " + node.id + " has " + rankNumbers.size + " descriptions for maxPoints " + node.maxPoints + ".", {
|
||
classId: node.classId, nodeId: node.id, maxPoints: node.maxPoints, rankDescriptionCount: rankNumbers.size,
|
||
});
|
||
}
|
||
}
|
||
|
||
const chainKeys = new Set();
|
||
for (const [index, chain] of chains.entries()) {
|
||
if (!isRecord(chain)) {
|
||
fail("invalid-progression-chain", "Progression chain at index " + index + " must be an object.", { index });
|
||
continue;
|
||
}
|
||
if (typeof chain.id !== "string" || !chain.id.trim()) fail("invalid-progression-key", "Progression chain at index " + index + " has an invalid ID.", { index, chainId: chain.id ?? null });
|
||
if (!Number.isInteger(chain.classId) || chain.classId < 12 || chain.classId > 32) {
|
||
fail("invalid-progression-class-id", "Progression chain " + (chain.id ?? "?") + " has an invalid CoA classId.", { index, classId: chain.classId ?? null });
|
||
} else if (!classIds.has(chain.classId)) fail("progression-class-missing", "Progression chain " + chain.id + " refers to absent class " + chain.classId + ".", { classId: chain.classId, chainId: chain.id });
|
||
const key = chain.classId + ":" + chain.id;
|
||
if (chainKeys.has(key)) fail("duplicate-progression-key", "Progression chain key " + key + " occurs more than once.", { classId: chain.classId, chainId: chain.id });
|
||
chainKeys.add(key);
|
||
if (!Array.isArray(chain.ranks)) {
|
||
fail("invalid-progression-ranks", "Progression chain " + (chain.id ?? "?") + " ranks must be an array.", { classId: chain.classId ?? null, chainId: chain.id ?? null });
|
||
continue;
|
||
}
|
||
const seen = new Set();
|
||
for (const [rankIndex, rank] of chain.ranks.entries()) {
|
||
if (!isRecord(rank) || !isPositiveInteger(rank.rank) || !isPositiveInteger(rank.spellId)) {
|
||
fail("invalid-progression-rank", "Progression chain " + (chain.id ?? "?") + " has a malformed rank.", { classId: chain.classId ?? null, chainId: chain.id ?? null, rankIndex });
|
||
continue;
|
||
}
|
||
if (seen.has(rank.rank)) fail("duplicate-progression-rank", "Progression chain " + chain.id + " repeats rank " + rank.rank + ".", {
|
||
classId: chain.classId, chainId: chain.id, rank: rank.rank,
|
||
});
|
||
seen.add(rank.rank);
|
||
ranks.push({ classId: chain.classId, chainId: chain.id, rank: rank.rank, spellId: rank.spellId });
|
||
counts.rankSpellReferences += 1;
|
||
if (!spellById.has(rank.spellId)) {
|
||
counts.missingRankSpells += 1;
|
||
counts.missingProgressionSpells += 1;
|
||
warn("missing-progression-spell", "Progression chain " + chain.id + " rank " + rank.rank + " references absent spell " + rank.spellId + ".", {
|
||
classId: chain.classId, chainId: chain.id, chainName: chain.name ?? "", rank: rank.rank, spellId: rank.spellId,
|
||
});
|
||
} else if (!isRecord(spellById.get(rank.spellId)?.dbc)) {
|
||
counts.missingRankDbcRecords += 1;
|
||
counts.missingProgressionDbcRecords += 1;
|
||
warn("missing-rank-dbc", "Progression chain " + chain.id + " rank " + rank.rank + " has a spell record without a base DBC row.", {
|
||
classId: chain.classId, chainId: chain.id, chainName: chain.name ?? "", rank: rank.rank, spellId: rank.spellId,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
const sourceKeys = new Set();
|
||
for (const [index, source] of sources.entries()) {
|
||
if (!isRecord(source)) {
|
||
fail("invalid-source-record", "Source at index " + index + " must be an object.", { index });
|
||
continue;
|
||
}
|
||
if (typeof source.id !== "string" || !source.id.trim()) fail("invalid-source-id", "Source at index " + index + " has an invalid ID.", { index, sourceId: source.id ?? null });
|
||
else if (sourceKeys.has(source.id)) fail("duplicate-source-key", "Source key " + source.id + " occurs more than once.", { sourceId: source.id });
|
||
else sourceKeys.add(source.id);
|
||
if (typeof source.path !== "string" || !source.path.trim()) fail("invalid-source-path", "Source " + (source.id ?? index) + " has an invalid path.", { sourceId: source.id ?? null });
|
||
if (typeof source.sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(source.sha256)) fail("invalid-source-sha256", "Source " + (source.id ?? index) + " has an invalid SHA-256 digest.", { sourceId: source.id ?? null });
|
||
if (!Number.isSafeInteger(source.bytes) || source.bytes < 0) fail("invalid-source-byte-count", "Source " + (source.id ?? index) + " has an invalid byte count.", { sourceId: source.id ?? null, bytes: source.bytes ?? null });
|
||
if (typeof source.kind !== "string" || !source.kind.trim()) fail("invalid-source-kind", "Source " + (source.id ?? index) + " has an invalid kind.", { sourceId: source.id ?? null });
|
||
}
|
||
|
||
const outgoing = new Map();
|
||
for (const spell of spellById.values()) {
|
||
const dbc = isRecord(spell.dbc) ? spell.dbc : spell;
|
||
for (const effect of asArray(dbc.effects)) {
|
||
if (!isRecord(effect) || !isPositiveInteger(effect.triggerSpellId)) continue;
|
||
const next = outgoing.get(spell.id) ?? [];
|
||
next.push(effect.triggerSpellId);
|
||
outgoing.set(spell.id, next);
|
||
}
|
||
}
|
||
const missingTriggers = new Set();
|
||
const ranksByClass = new Map();
|
||
for (const rank of ranks) {
|
||
const classRanks = ranksByClass.get(rank.classId) ?? [];
|
||
classRanks.push(rank);
|
||
ranksByClass.set(rank.classId, classRanks);
|
||
}
|
||
for (const [classId, classRanks] of ranksByClass) {
|
||
const pending = classRanks.map((rank) => rank.spellId);
|
||
const rootIds = new Set(pending);
|
||
const visited = new Set();
|
||
const missingForClass = new Set();
|
||
while (pending.length) {
|
||
const spellId = pending.pop();
|
||
if (visited.has(spellId)) continue;
|
||
visited.add(spellId);
|
||
if (!spellById.has(spellId)) {
|
||
if (!rootIds.has(spellId)) {
|
||
missingForClass.add(spellId);
|
||
missingTriggers.add(spellId);
|
||
}
|
||
continue;
|
||
}
|
||
pending.push(...(outgoing.get(spellId) ?? []));
|
||
}
|
||
for (const spellId of [...missingForClass].sort((a, b) => a - b)) {
|
||
warn("unknown-triggered-spell", "Class " + classId + " trigger closure reaches absent spell " + spellId + ".", {
|
||
classId, spellId, reachableSpellCount: visited.size,
|
||
});
|
||
}
|
||
}
|
||
counts.missingTriggeredSpells = missingTriggers.size;
|
||
|
||
if (reference.abilities !== undefined) {
|
||
if (!Array.isArray(reference.abilities)) fail("invalid-abilities-collection", "Reference abilities must be an array when present.");
|
||
else {
|
||
const aliases = aliasesFor(classes);
|
||
const abilityKeys = new Set();
|
||
for (const [index, ability] of reference.abilities.entries()) {
|
||
if (!isRecord(ability)) {
|
||
fail("invalid-ability-record", "Ability at index " + index + " must be an object.", { index });
|
||
continue;
|
||
}
|
||
if (typeof ability.key !== "string" || !ability.key.trim()) fail("invalid-ability-key", "Ability at index " + index + " has an invalid key.", { index });
|
||
else if (abilityKeys.has(ability.key)) fail("duplicate-ability-key", "Ability key " + ability.key + " occurs more than once.", { abilityKey: ability.key });
|
||
else abilityKeys.add(ability.key);
|
||
const classId = normalizeClassId(ability.classId, aliases);
|
||
if (classId === null || classId < 12 || classId > 32) fail("invalid-ability-class-id", "Ability " + (ability.key ?? index) + " has an invalid classId.", {
|
||
abilityKey: ability.key ?? null, classId: ability.classId ?? null,
|
||
});
|
||
for (const field of ["spellIds", "nodeIds", "rankSpellIds"]) {
|
||
if (ability[field] === undefined) continue;
|
||
if (!Array.isArray(ability[field])) {
|
||
fail("invalid-ability-reference-list", "Ability " + (ability.key ?? index) + " " + field + " must be an array.", { abilityKey: ability.key ?? null, field });
|
||
continue;
|
||
}
|
||
for (const id of ability[field]) {
|
||
if (!isPositiveInteger(id)) fail("invalid-ability-reference-id", "Ability " + (ability.key ?? index) + " has a malformed " + field + " ID.", { abilityKey: ability.key ?? null, field, id });
|
||
else if (field === "nodeIds" && !nodeByKey.has(classId + ":" + id)) warn("ability-node-reference-missing", "Ability " + ability.key + " refers to absent node " + id + ".", { classId, abilityKey: ability.key, nodeId: id });
|
||
else if (field !== "nodeIds" && !spellById.has(id)) warn("ability-spell-reference-missing", "Ability " + ability.key + " refers to absent spell " + id + ".", { classId, abilityKey: ability.key, spellId: id, field });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return { valid: errors.length === 0, complete: errors.length === 0 && warnings.length === 0, errors, warnings, counts };
|
||
}
|
||
|
||
function readJson(filePath) {
|
||
try {
|
||
const bytes = fs.readFileSync(filePath);
|
||
return {
|
||
value: JSON.parse(decodeJsonBytes(bytes, filePath).toString("utf8")), error: null, bytes: bytes.byteLength,
|
||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||
};
|
||
} catch (error) {
|
||
return { value: null, error: error instanceof Error ? error.message : String(error), bytes: null, sha256: null };
|
||
}
|
||
}
|
||
|
||
function cleanSourceText(value) {
|
||
return String(value ?? "")
|
||
.replace(/<br\s*\/?\s*>/gi, " ")
|
||
.replace(/<[^>]*>/g, "")
|
||
.replace(/ /gi, " ")
|
||
.replace(/&/gi, "&")
|
||
.replace(/</gi, "<")
|
||
.replace(/>/gi, ">")
|
||
.replace(/"/gi, '"')
|
||
.replace(/�*39;|'/gi, "'")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
}
|
||
|
||
function normalizeRankDescriptions(value) {
|
||
return asArray(value).map((rank) => ({
|
||
rank: rank?.rank ?? null,
|
||
spellId: rank?.spellId ?? null,
|
||
description: cleanSourceText(rank?.description ?? ""),
|
||
}));
|
||
}
|
||
|
||
function normalizeIds(value) {
|
||
return [...new Set(asArray(value).filter((id) => Number.isInteger(id)))].sort((a, b) => a - b);
|
||
}
|
||
|
||
function normalizeIcon(value) {
|
||
if (typeof value !== "string") return null;
|
||
return value.replaceAll("\\", "/").split("/").pop().replace(/\.(?:blp|tga|png|webp)$/i, "").toLowerCase();
|
||
}
|
||
|
||
function projection(node) {
|
||
const ranks = normalizeRankDescriptions(node.rankDescriptions);
|
||
const spellIds = normalizeIds(node.spellIds ?? ranks.map((rank) => rank.spellId));
|
||
return {
|
||
tabId: node.tabId ?? null,
|
||
x: node.x ?? node.column ?? null,
|
||
y: node.y ?? node.row ?? null,
|
||
name: node.name ?? node.title ?? null,
|
||
nodeType: node.nodeType ?? null,
|
||
entryType: node.entryType ?? null,
|
||
isPassive: node.isPassive === undefined ? null : Boolean(node.isPassive),
|
||
maxPoints: node.maxPoints ?? node.maxRank ?? null,
|
||
aeCost: node.aeCost ?? node.abilityEssenceCost ?? null,
|
||
teCost: node.teCost ?? node.talentEssenceCost ?? null,
|
||
requiredClassPoints: node.requiredClassPoints ?? node.reqTabAE ?? null,
|
||
requiredSpecPoints: node.requiredSpecPoints ?? node.reqTabTE ?? null,
|
||
requiredLevel: node.requiredLevel ?? null,
|
||
requiredIds: normalizeIds(node.requiredIds ?? node.prerequisites),
|
||
connectedNodeIds: normalizeIds(node.connectedNodeIds ?? node.connections),
|
||
isStartingNode: node.isStartingNode === undefined ? null : Boolean(node.isStartingNode),
|
||
group: node.group ?? null,
|
||
flags: node.flags ?? null,
|
||
sortOrder: node.sortOrder ?? null,
|
||
icon: normalizeIcon(node.iconPath ?? node.iconBasename),
|
||
spellId: node.spellId ?? spellIds[0] ?? null,
|
||
spellIds,
|
||
description: cleanSourceText(node.description ?? ""),
|
||
rankDescriptions: ranks,
|
||
};
|
||
}
|
||
|
||
function recordId(record) {
|
||
if (!isRecord(record)) return null;
|
||
return record.id ?? record.nodeId ?? record.entryId ?? record.talentId ?? null;
|
||
}
|
||
|
||
function tabIdOf(record, fallback = null) {
|
||
if (isRecord(record) && Number.isInteger(record.tabId)) return record.tabId;
|
||
return fallback;
|
||
}
|
||
|
||
function normalizeSupplement(supplements, aliases) {
|
||
const inputClasses = Array.isArray(supplements?.classes)
|
||
? supplements.classes
|
||
: isRecord(supplements?.classes) ? Object.values(supplements.classes) : [];
|
||
const trees = [];
|
||
const nodes = [];
|
||
const seenNodeKeys = new Set();
|
||
const addNodes = (records, classId, inheritedTabId = null) => {
|
||
for (const node of asArray(records)) {
|
||
if (!isRecord(node)) continue;
|
||
const id = recordId(node);
|
||
const ownClassId = normalizeClassId(node.classId ?? node.gameId, aliases);
|
||
const resolvedClass = ownClassId ?? classId;
|
||
const tabId = tabIdOf(node, inheritedTabId);
|
||
if (id === null) continue;
|
||
const key = String(resolvedClass) + ":" + String(tabId) + ":" + String(id);
|
||
if (seenNodeKeys.has(key)) continue;
|
||
seenNodeKeys.add(key);
|
||
nodes.push({ classId: resolvedClass, tabId, id, record: node });
|
||
}
|
||
};
|
||
for (const classRecord of inputClasses) {
|
||
if (!isRecord(classRecord)) continue;
|
||
const classId = normalizeClassId(classRecord.classId ?? classRecord.gameId ?? classRecord.id, aliases);
|
||
if (classId === null) continue;
|
||
for (const tab of asArray(classRecord.tabs)) {
|
||
if (!isRecord(tab)) continue;
|
||
const id = tab.tabId ?? tab.id ?? tab.key ?? tab.name ?? String(trees.length + 1);
|
||
const tabId = Number.isInteger(tab.tabId) ? tab.tabId : Number.isInteger(tab.id) ? tab.id : null;
|
||
trees.push({
|
||
classId, id: String(id), tabId,
|
||
name: tab.tabName ?? tab.name ?? tab.title ?? null,
|
||
sortOrder: tab.sortOrder ?? tab.order ?? null,
|
||
entryCount: Number.isInteger(tab.entryCount) ? tab.entryCount : null,
|
||
isEmpty: tab.isEmpty === true,
|
||
});
|
||
for (const key of ["nodes", "entries", "talents"]) addNodes(tab[key], classId, tabId);
|
||
}
|
||
for (const key of ["nodes", "entries", "talents"]) addNodes(classRecord[key], classId);
|
||
}
|
||
for (const node of asArray(supplements?.nodes)) {
|
||
if (!isRecord(node)) continue;
|
||
const classId = normalizeClassId(node.classId ?? node.gameId, aliases);
|
||
addNodes([node], classId);
|
||
}
|
||
for (const tab of asArray(supplements?.tabs)) {
|
||
if (!isRecord(tab)) continue;
|
||
const classId = normalizeClassId(tab.classId ?? tab.gameId, aliases);
|
||
if (classId === null) continue;
|
||
const id = tab.tabId ?? tab.id ?? tab.name ?? String(trees.length + 1);
|
||
const tabId = Number.isInteger(tab.tabId) ? tab.tabId : Number.isInteger(tab.id) ? tab.id : null;
|
||
trees.push({
|
||
classId, id: String(id), tabId, name: tab.tabName ?? tab.name ?? tab.title ?? null,
|
||
sortOrder: tab.sortOrder ?? tab.order ?? null,
|
||
entryCount: Number.isInteger(tab.entryCount) ? tab.entryCount : null,
|
||
isEmpty: tab.isEmpty === true,
|
||
});
|
||
for (const key of ["nodes", "entries", "talents"]) addNodes(tab[key], classId, tabId);
|
||
}
|
||
return { trees, nodes };
|
||
}
|
||
|
||
function changedFields(before, after, fields) {
|
||
return fields.filter((field) => stable(before?.[field] ?? null) !== stable(after?.[field] ?? null));
|
||
}
|
||
|
||
function compareJulyToAugust(reference, supplements) {
|
||
if (!supplements) return {
|
||
status: "absent",
|
||
observation: "No supplemental cache was supplied; July baseline records remain unchanged.",
|
||
before: reference?.baseline ?? null,
|
||
after: null,
|
||
counts: { treesAdded: 0, treesRemoved: 0, treesChanged: 0, emptyMetadataTreesAdded: 0, nodesAdded: 0, nodesRemoved: 0, nodesChanged: 0 },
|
||
classes: [],
|
||
changedSamples: [],
|
||
additions: { trees: [], nodes: [] },
|
||
removals: { trees: [], nodes: [] },
|
||
};
|
||
const normalized = normalizeSupplement(supplements, aliasesFor(reference?.classes));
|
||
const beforeTrees = [];
|
||
const beforeNodes = [];
|
||
for (const definition of asArray(reference?.classes)) {
|
||
if (!isRecord(definition)) continue;
|
||
for (const tab of asArray(definition.tabs)) {
|
||
if (!isRecord(tab)) continue;
|
||
beforeTrees.push({
|
||
classId: definition.classId, id: String(tab.tabId), tabId: tab.tabId,
|
||
name: tab.tabName ?? tab.name ?? null, sortOrder: tab.sortOrder ?? null,
|
||
});
|
||
}
|
||
}
|
||
for (const node of asArray(reference?.nodes)) {
|
||
if (isRecord(node)) beforeNodes.push({ classId: node.classId, id: node.id, ...projection(node) });
|
||
}
|
||
const afterTrees = normalized.trees;
|
||
const afterNodes = normalized.nodes.map(({ classId, tabId, id, record }) => ({
|
||
classId, id, ...projection({ ...record, tabId: record.tabId ?? tabId }),
|
||
}));
|
||
const compare = (before, after, fields) => {
|
||
const b = new Map(before.map((record) => [record.classId + ":" + String(record.id), record]));
|
||
const a = new Map(after.map((record) => [record.classId + ":" + String(record.id), record]));
|
||
const added = [];
|
||
const removed = [];
|
||
const changed = [];
|
||
for (const [key, record] of a) {
|
||
if (!b.has(key)) added.push({ key, record });
|
||
else {
|
||
const prior = b.get(key);
|
||
const fieldsChanged = changedFields(prior, record, fields);
|
||
if (fieldsChanged.length) changed.push({ key, before: prior, after: record, changedFields: fieldsChanged });
|
||
}
|
||
}
|
||
for (const [key, record] of b) if (!a.has(key)) removed.push({ key, record });
|
||
return { added, removed, changed };
|
||
};
|
||
const treeDiff = compare(beforeTrees, afterTrees, ["tabId", "name", "sortOrder"]);
|
||
const nodeDiff = compare(beforeNodes, afterNodes, NODE_FIELDS);
|
||
const perClass = new Map();
|
||
const touch = (key) => {
|
||
const classId = Number(key.split(":")[0]);
|
||
if (!perClass.has(classId)) perClass.set(classId, {
|
||
classId, treesAdded: 0, treesRemoved: 0, treesChanged: 0,
|
||
nodesAdded: 0, nodesRemoved: 0, nodesChanged: 0,
|
||
});
|
||
return perClass.get(classId);
|
||
};
|
||
for (const item of treeDiff.added) touch(item.key).treesAdded += 1;
|
||
for (const item of treeDiff.removed) touch(item.key).treesRemoved += 1;
|
||
for (const item of treeDiff.changed) touch(item.key).treesChanged += 1;
|
||
for (const item of nodeDiff.added) touch(item.key).nodesAdded += 1;
|
||
for (const item of nodeDiff.removed) touch(item.key).nodesRemoved += 1;
|
||
for (const item of nodeDiff.changed) touch(item.key).nodesChanged += 1;
|
||
const samples = [
|
||
...treeDiff.changed.map((item) => ({ kind: "tree", classId: Number(item.key.split(":")[0]), key: item.key, changedFields: item.changedFields })),
|
||
...nodeDiff.changed.map((item) => ({
|
||
kind: "node", classId: Number(item.key.split(":")[0]), key: item.key,
|
||
nameBefore: item.before.name ?? null, nameAfter: item.after.name ?? null, changedFields: item.changedFields,
|
||
})),
|
||
].sort((a, b) => a.classId - b.classId || a.kind.localeCompare(b.kind) || a.key.localeCompare(b.key));
|
||
return {
|
||
status: "compared",
|
||
observation: "August supplemental records are compared as a separate source; they are not merged into the July baseline.",
|
||
before: reference?.baseline ?? null,
|
||
after: supplements.source ?? supplements.metadata ?? null,
|
||
counts: {
|
||
treesAdded: treeDiff.added.length, treesRemoved: treeDiff.removed.length, treesChanged: treeDiff.changed.length,
|
||
emptyMetadataTreesAdded: treeDiff.added.filter((item) => item.record.isEmpty === true || item.record.entryCount === 0).length,
|
||
nodesAdded: nodeDiff.added.length, nodesRemoved: nodeDiff.removed.length, nodesChanged: nodeDiff.changed.length,
|
||
},
|
||
classes: [...perClass.values()].sort((a, b) => a.classId - b.classId),
|
||
changedSamples: samples.slice(0, 250),
|
||
changes: {
|
||
trees: treeDiff.changed.map((item) => ({
|
||
classId: Number(item.key.split(":")[0]), treeId: item.after.id, changedFields: item.changedFields,
|
||
before: item.before, after: item.after,
|
||
})),
|
||
nodes: nodeDiff.changed.map((item) => ({
|
||
classId: Number(item.key.split(":")[0]), nodeId: item.after.id, nameBefore: item.before.name ?? null,
|
||
nameAfter: item.after.name ?? null, changedFields: item.changedFields, before: item.before, after: item.after,
|
||
})),
|
||
},
|
||
additions: {
|
||
trees: treeDiff.added.map((item) => ({
|
||
classId: Number(item.key.split(":")[0]), treeId: item.record.id, name: item.record.name,
|
||
emptyMetadata: item.record.isEmpty === true || item.record.entryCount === 0, entryCount: item.record.entryCount,
|
||
})),
|
||
nodes: nodeDiff.added.map((item) => ({ classId: Number(item.key.split(":")[0]), nodeId: item.record.id, name: item.record.name })),
|
||
},
|
||
removals: {
|
||
trees: treeDiff.removed.map((item) => ({ classId: Number(item.key.split(":")[0]), treeId: item.record.id, name: item.record.name })),
|
||
nodes: nodeDiff.removed.map((item) => ({ classId: Number(item.key.split(":")[0]), nodeId: item.record.id, name: item.record.name })),
|
||
},
|
||
};
|
||
}
|
||
|
||
function compareBaseDbcToArea52(reference, area52Path, displayPath = area52Path) {
|
||
const missingBaseRows = asArray(reference?.spells).filter((spell) => !isRecord(spell?.dbc) || !isPositiveInteger(spell.dbc.id));
|
||
const unavailable = (error = null) => ({
|
||
status: "unavailable", path: displayPath, error,
|
||
observation: "The existing Area52 CoA catalog could not be read from projectRoot.",
|
||
counts: {
|
||
baseRecords: asArray(reference?.spells).length - missingBaseRows.length,
|
||
baseMissingDbcRecords: missingBaseRows.length,
|
||
area52Records: 0, baseOnly: 0, area52Only: 0, matchingIds: 0, changedRecords: 0,
|
||
},
|
||
changedFields: {}, samples: [],
|
||
baseMissingDbcSamples: missingBaseRows.slice(0, 20).map((spell) => ({ spellId: spell?.id ?? null, name: spell?.name ?? null, source: spell?.source ?? null })),
|
||
});
|
||
if (!area52Path || !fs.existsSync(area52Path)) return unavailable();
|
||
const result = readJson(area52Path);
|
||
if (result.error || !isRecord(result.value)) return unavailable(result.error ?? "File was not a JSON object.");
|
||
const baseRecords = asArray(reference?.spells).filter((spell) => isRecord(spell) && isRecord(spell.dbc) && isPositiveInteger(spell.dbc.id));
|
||
const base = new Map(baseRecords.map((spell) => [spell.dbc.id, spell.dbc]));
|
||
const areaRecords = asArray(result.value.spells).filter((spell) => isRecord(spell) && isRecord(spell.dbc) && isPositiveInteger(spell.dbc.id));
|
||
const area = new Map(areaRecords.map((spell) => [spell.dbc.id, spell.dbc]));
|
||
const shared = [...base.keys()].filter((id) => area.has(id)).sort((a, b) => a - b);
|
||
const countsByField = {};
|
||
const samples = [];
|
||
let changedRecords = 0;
|
||
for (const id of shared) {
|
||
const left = base.get(id);
|
||
const right = area.get(id);
|
||
const fields = changedFields(left, right, SPELL_FIELDS);
|
||
if (!fields.length) continue;
|
||
changedRecords += 1;
|
||
for (const field of fields) countsByField[field] = (countsByField[field] ?? 0) + 1;
|
||
if (samples.length < 24) {
|
||
samples.push({
|
||
spellId: id, baseName: left.name ?? null, area52Name: right.name ?? null, changedFields: fields,
|
||
base: Object.fromEntries(fields.map((field) => [field, left[field] ?? null])),
|
||
area52: Object.fromEntries(fields.map((field) => [field, right[field] ?? null])),
|
||
});
|
||
}
|
||
}
|
||
return {
|
||
status: "compared",
|
||
path: displayPath,
|
||
observation: "Local base DBC spell values are compared by spell ID with imported Area52 values; neither source is rewritten.",
|
||
counts: {
|
||
baseRecords: base.size, baseMissingDbcRecords: asArray(reference?.spells).length - base.size,
|
||
area52Records: area.size,
|
||
baseOnly: [...base.keys()].filter((id) => !area.has(id)).length,
|
||
area52Only: [...area.keys()].filter((id) => !base.has(id)).length,
|
||
matchingIds: shared.length, changedRecords,
|
||
},
|
||
changedFields: Object.fromEntries(Object.entries(countsByField).sort(([a], [b]) => a.localeCompare(b))),
|
||
changedSpellIds: shared.filter((id) => changedFields(base.get(id), area.get(id), SPELL_FIELDS).length > 0),
|
||
samples,
|
||
baseMissingDbcSamples: missingBaseRows.slice(0, 20).map((spell) => ({ spellId: spell.id ?? null, name: spell.name ?? null, source: spell.source ?? null })),
|
||
source: { sha256: result.sha256, bytes: result.bytes },
|
||
};
|
||
}
|
||
|
||
function buildRuntimeEvidence(projectRoot, classes) {
|
||
const load = (relative) => {
|
||
const fullPath = path.join(projectRoot, relative);
|
||
return { fullPath, ...readJson(fullPath) };
|
||
};
|
||
const ability = load("src/game/generated/coaAbilityRuntimeCatalog.json");
|
||
const talent = load("src/game/generated/talentRuntimeCatalog.json");
|
||
const triggered = load("src/game/generated/coaTriggeredSpellCatalog.json");
|
||
const abilitiesByClass = ability.value?.abilitiesByClass ?? {};
|
||
const triggeredMap = ability.value?.triggeredAbilitiesBySpellId ?? {};
|
||
const talentTrees = asArray(talent.value?.trees);
|
||
const talentNodes = asArray(talent.value?.nodes);
|
||
const triggeredRecords = asArray(triggered.value?.spells);
|
||
const triggeredIds = new Set(triggeredRecords.map((spell) => spell?.id).filter(isPositiveInteger));
|
||
const classRows = asArray(classes).filter((record) => isRecord(record) && Number.isInteger(record.classId)).map((record) => {
|
||
const gameId = record.gameId ?? CANONICAL_GAME_IDS[record.classId] ?? null;
|
||
const abilities = asArray(abilitiesByClass[gameId]);
|
||
const trees = talentTrees.filter((tree) => tree?.classId === gameId && String(tree.id ?? "").startsWith("coa-"));
|
||
const treeIds = new Set(trees.map((tree) => tree.id));
|
||
const nodes = talentNodes.filter((node) => node?.system === "coa" && treeIds.has(node.treeId));
|
||
const triggers = Object.entries(triggeredMap)
|
||
.filter(([, item]) => item?.classId === gameId)
|
||
.map(([spellId, item]) => ({ spellId: Number(spellId), id: item.id ?? null, name: item.name ?? null }));
|
||
return {
|
||
classId: record.classId, gameId,
|
||
runtimeAbilityMappings: abilities.length,
|
||
runtimeAbilityNodeIds: [...new Set(abilities.map((item) => item?.talentEntryId).filter(isPositiveInteger))].sort((a, b) => a - b),
|
||
runtimeAbilitySpellIds: [...new Set(abilities.map((item) => item?.dbcSpellId).filter(isPositiveInteger))].sort((a, b) => a - b),
|
||
runtimeAbilitiesWithoutSourceIds: abilities.filter((item) => !isPositiveInteger(item?.dbcSpellId) && !isPositiveInteger(item?.talentEntryId)).length,
|
||
runtimeTriggeredAbilityMappings: triggers.length,
|
||
runtimeTriggeredSpellIds: triggers.map((item) => item.spellId).sort((a, b) => a - b),
|
||
talentRuntimeTrees: trees.length,
|
||
talentRuntimeNodes: nodes.length,
|
||
talentRuntimeNodeIds: nodes.map((item) => item.index).filter(Number.isInteger).sort((a, b) => a - b),
|
||
};
|
||
});
|
||
const catalog = (item, label) => ({
|
||
status: item.error ? "unavailable" : "available",
|
||
path: path.relative(projectRoot, item.fullPath).replaceAll("\\", "/"),
|
||
error: item.error,
|
||
sha256: item.sha256,
|
||
bytes: item.bytes,
|
||
label,
|
||
});
|
||
const mappedTriggeredIds = new Set(Object.keys(triggeredMap).map(Number).filter(isPositiveInteger));
|
||
return {
|
||
catalogs: {
|
||
coaAbilityRuntimeCatalog: {
|
||
...catalog(ability, "Runtime ability and triggered spell mappings"),
|
||
schemaVersion: ability.value?.schemaVersion ?? null,
|
||
classesWithAbilityMappings: Object.keys(abilitiesByClass).length,
|
||
runtimeAbilityMappings: Object.values(abilitiesByClass).reduce((sum, items) => sum + asArray(items).length, 0),
|
||
triggeredAbilityMappings: Object.keys(triggeredMap).length,
|
||
},
|
||
talentRuntimeCatalog: {
|
||
...catalog(talent, "Talent tree and node mappings"),
|
||
schemaVersion: talent.value?.schemaVersion ?? null,
|
||
treeCount: talentTrees.length,
|
||
nodeCount: talentNodes.length,
|
||
coaNodeCount: talentNodes.filter((node) => node?.system === "coa").length,
|
||
},
|
||
coaTriggeredSpellCatalog: {
|
||
...catalog(triggered, "Triggered spell DBC records"),
|
||
schemaVersion: triggered.value?.schemaVersion ?? null,
|
||
spellCount: triggeredIds.size,
|
||
},
|
||
},
|
||
classes: classRows,
|
||
triggeredSpellCoverage: {
|
||
catalogSpellRecords: triggeredIds.size,
|
||
runtimeTriggeredMappings: mappedTriggeredIds.size,
|
||
runtimeMappingsWithCatalogSpellRecord: [...mappedTriggeredIds].filter((id) => triggeredIds.has(id)).length,
|
||
},
|
||
behavioralMechanics: {
|
||
status: "not-assessed",
|
||
provenImplementedClaims: [],
|
||
note: "Runtime catalogs establish source-to-runtime mappings only. This audit does not infer player or companion behavior from imported effects, descriptions, or mapping records, and does not assess behavioral test coverage.",
|
||
},
|
||
interpretation: "Catalog mappings are not proof that an ability's mechanics execute correctly for a player or a companion.",
|
||
};
|
||
}
|
||
|
||
function compareSupplementFreshness(reference, supplements) {
|
||
if (!supplements) return {
|
||
status: "absent",
|
||
baselineCapturedAt: reference?.baseline?.capturedAt ?? null,
|
||
supplementCapturedAt: null,
|
||
captureDeltaMs: null,
|
||
sourceHashes: { matchingPaths: 0, same: 0, changed: 0, comparisons: [] },
|
||
metadata: null,
|
||
};
|
||
const sourceFiles = asArray(supplements.manifest?.files);
|
||
const baselineFiles = new Map(asArray(reference?.sources).filter((item) => isRecord(item) && typeof item.path === "string").map((item) => [item.path.replaceAll("\\", "/"), item]));
|
||
const comparisons = [];
|
||
for (const file of sourceFiles) {
|
||
if (!isRecord(file) || typeof file.path !== "string") continue;
|
||
const previous = baselineFiles.get(file.path.replaceAll("\\", "/"));
|
||
if (!previous) continue;
|
||
comparisons.push({
|
||
path: file.path,
|
||
baselineSha256: previous.sha256 ?? null,
|
||
supplementSha256: file.sha256 ?? null,
|
||
same: typeof previous.sha256 === "string" && typeof file.sha256 === "string"
|
||
? previous.sha256.toLowerCase() === file.sha256.toLowerCase()
|
||
: null,
|
||
});
|
||
}
|
||
const baselineCapturedAt = reference?.baseline?.capturedAt ?? null;
|
||
const supplementCapturedAt = supplements.source?.capturedAt ?? null;
|
||
const baselineTime = typeof baselineCapturedAt === "string" ? Date.parse(baselineCapturedAt) : Number.NaN;
|
||
const supplementTime = typeof supplementCapturedAt === "string" ? Date.parse(supplementCapturedAt) : Number.NaN;
|
||
const captureDeltaMs = Number.isFinite(baselineTime) && Number.isFinite(supplementTime)
|
||
? supplementTime - baselineTime
|
||
: null;
|
||
return {
|
||
status: "compared",
|
||
baselineCapturedAt,
|
||
supplementCapturedAt,
|
||
captureDeltaMs,
|
||
baselineKind: reference?.baseline?.kind ?? null,
|
||
supplementRevision: supplements.source?.revision ?? null,
|
||
sourceHashes: {
|
||
matchingPaths: comparisons.length,
|
||
same: comparisons.filter((item) => item.same === true).length,
|
||
changed: comparisons.filter((item) => item.same === false).length,
|
||
comparisons,
|
||
},
|
||
metadata: supplements.metadata ?? null,
|
||
};
|
||
}
|
||
|
||
function collectFindings(reference, validation, runtimeEvidence, dbcComparison, supplements) {
|
||
const findings = [];
|
||
for (const [severity, diagnostics] of [["error", validation.errors], ["warning", validation.warnings]]) {
|
||
for (const diagnostic of diagnostics) findings.push({ ...diagnostic, severity, source: "reference-structure" });
|
||
}
|
||
for (const diagnostic of asArray(reference?.diagnostics)) {
|
||
if (isRecord(diagnostic)) findings.push({
|
||
code: diagnostic.code ?? "source-diagnostic",
|
||
severity: diagnostic.severity ?? "warning",
|
||
message: diagnostic.message ?? "Source reported a diagnostic without a message.",
|
||
classId: diagnostic.classId ?? null, nodeId: diagnostic.nodeId ?? null, spellId: diagnostic.spellId ?? null,
|
||
source: "local-source",
|
||
details: Object.fromEntries(Object.entries(diagnostic).filter(([key]) => !["code", "severity", "message", "classId", "nodeId", "spellId"].includes(key))),
|
||
});
|
||
}
|
||
if (!supplements) findings.push({
|
||
code: "supplemental-cache-absent", severity: "warning",
|
||
message: "No August supplemental cache was supplied; July versus August tree and node comparison is unavailable.",
|
||
source: "audit-input",
|
||
});
|
||
if (dbcComparison.status !== "compared") findings.push({
|
||
code: "area52-catalog-unavailable", severity: "warning", message: dbcComparison.observation, source: "audit-input",
|
||
});
|
||
if (dbcComparison.counts.changedRecords > 0) findings.push({
|
||
code: "base-dbc-area52-spell-conflicts", severity: "warning",
|
||
message: dbcComparison.counts.changedRecords + " matching spells contain changed meaningful DBC fields between the local base and imported Area52 values.",
|
||
changedFields: dbcComparison.changedFields, source: "source-comparison",
|
||
});
|
||
for (const [name, item] of Object.entries(runtimeEvidence.catalogs)) {
|
||
if (item.status !== "available") findings.push({
|
||
code: "runtime-catalog-unavailable", severity: "warning", message: name + " could not be read.",
|
||
path: item.path, error: item.error, source: "runtime-catalog",
|
||
});
|
||
}
|
||
return findings.sort((a, b) => String(a.classId ?? "").localeCompare(String(b.classId ?? ""))
|
||
|| String(a.severity).localeCompare(String(b.severity))
|
||
|| String(a.code).localeCompare(String(b.code))
|
||
|| String(a.nodeId ?? a.spellId ?? "").localeCompare(String(b.nodeId ?? b.spellId ?? "")));
|
||
}
|
||
|
||
function summarizeClasses(reference, runtimeEvidence, findings, comparison, dbcComparison) {
|
||
const definitions = asArray(reference?.classes).filter(isRecord);
|
||
const nodes = asArray(reference?.nodes).filter(isRecord);
|
||
const spells = asArray(reference?.spells).filter(isRecord);
|
||
const chains = asArray(reference?.progressionChains).filter(isRecord);
|
||
const aliases = aliasesFor(definitions);
|
||
const runtimeById = new Map(runtimeEvidence.classes.map((row) => [row.classId, row]));
|
||
const comparisonById = new Map(asArray(comparison.classes).map((row) => [row.classId, row]));
|
||
const ids = new Set([...definitions.map((row) => row.classId), ...EXPECTED_CLASS_IDS]);
|
||
return [...ids].sort((a, b) => a - b).map((classId) => {
|
||
const definition = definitions.find((row) => row.classId === classId) ?? null;
|
||
const classNodes = nodes.filter((node) => node.classId === classId);
|
||
const classChains = chains.filter((chain) => chain.classId === classId);
|
||
const classSpellIds = new Set([
|
||
...classNodes.flatMap((node) => asArray(node.rankDescriptions).map((rank) => rank?.spellId)),
|
||
...classChains.flatMap((chain) => asArray(chain.ranks).map((rank) => rank?.spellId)),
|
||
...asArray(reference?.abilities).filter((ability) => normalizeClassId(ability?.classId, aliases) === classId)
|
||
.flatMap((ability) => [...asArray(ability.spellIds), ...asArray(ability.rankSpellIds)]),
|
||
]);
|
||
const classSpells = spells.filter((spell) => asArray(spell.classIds).includes(classId) || classSpellIds.has(spell.id));
|
||
const classFindings = findings.filter((item) => item.classId === classId);
|
||
const missingRankSpellIds = [...new Set(classFindings
|
||
.filter((item) => item.code === "missing-rank-spell" || item.code === "missing-progression-spell" || item.code === "missing-rank-dbc")
|
||
.map((item) => item.spellId))].sort((a, b) => a - b);
|
||
const missingNodeRankSpellIds = [...new Set(classFindings
|
||
.filter((item) => item.nodeId !== undefined && (item.code === "missing-rank-spell" || item.code === "missing-rank-dbc"))
|
||
.map((item) => item.spellId))].sort((a, b) => a - b);
|
||
const missingNodeRankDbcIds = [...new Set(classFindings.filter((item) => item.nodeId !== undefined && item.code === "missing-rank-dbc").map((item) => item.spellId))].sort((a, b) => a - b);
|
||
const missingProgressionRankSpellIds = [...new Set(classFindings
|
||
.filter((item) => item.chainId !== undefined && (item.code === "missing-progression-spell" || item.code === "missing-rank-dbc"))
|
||
.map((item) => item.spellId))].sort((a, b) => a - b);
|
||
const missingProgressionRankDbcIds = [...new Set(classFindings.filter((item) => item.chainId !== undefined && item.code === "missing-rank-dbc").map((item) => item.spellId))].sort((a, b) => a - b);
|
||
const runtime = runtimeById.get(classId) ?? {
|
||
classId, gameId: definition?.gameId ?? CANONICAL_GAME_IDS[classId] ?? null, runtimeAbilityMappings: 0, runtimeTriggeredAbilityMappings: 0,
|
||
talentRuntimeTrees: 0, talentRuntimeNodes: 0, runtimeAbilityNodeIds: [], runtimeAbilitySpellIds: [], runtimeTriggeredSpellIds: [],
|
||
};
|
||
const aug = comparisonById.get(classId) ?? {
|
||
classId, treesAdded: 0, treesRemoved: 0, treesChanged: 0, nodesAdded: 0, nodesRemoved: 0, nodesChanged: 0,
|
||
};
|
||
const backlog = [];
|
||
const sourceConflictSpellIds = asArray(dbcComparison?.changedSpellIds).filter((id) => classSpellIds.has(id));
|
||
const conflictCount = aug.nodesChanged + aug.nodesRemoved + aug.treesChanged + aug.treesRemoved + sourceConflictSpellIds.length;
|
||
if (conflictCount) backlog.push({
|
||
order: 1, priority: "source-conflict", status: "needs-resolution",
|
||
title: "Resolve source-version conflicts", evidenceCount: conflictCount,
|
||
spellIds: sourceConflictSpellIds,
|
||
counts: {
|
||
augustNodesChanged: aug.nodesChanged, augustNodesRemoved: aug.nodesRemoved,
|
||
augustTreesChanged: aug.treesChanged, augustTreesRemoved: aug.treesRemoved,
|
||
baseDbcSpellConflicts: sourceConflictSpellIds.length,
|
||
},
|
||
detail: "Review changed or removed tree, node, and DBC spell records against their provenance before selecting an authoritative value.",
|
||
});
|
||
if (missingRankSpellIds.length) backlog.push({
|
||
order: backlog.length + 1, priority: "rank-identity", status: "unresolved",
|
||
title: "Resolve rank-to-spell identity gaps", evidenceCount: missingRankSpellIds.length, spellIds: missingRankSpellIds,
|
||
detail: "Identify correct spell records for referenced ranks before mechanics work proceeds.",
|
||
});
|
||
const mechanicOrder = backlog.length + 1;
|
||
backlog.push({
|
||
order: mechanicOrder, priority: "player-companion-mechanics", status: "unverified",
|
||
title: "Verify bespoke player and companion mechanics",
|
||
detail: "Use focused behavior evidence for this class. Runtime mappings and imported DBC effects alone do not establish that mechanics work for either actor.",
|
||
runtimeAbilityMappings: runtime.runtimeAbilityMappings,
|
||
});
|
||
backlog.push({
|
||
order: mechanicOrder + 1, priority: "ui-gameplay-balance-review", status: "queued-after-mechanics",
|
||
title: "Review class UI, gameplay flow, and balance",
|
||
detail: "Review presentation and balance after source identity and player/companion behavior are understood.",
|
||
});
|
||
return {
|
||
classId, gameId: definition?.gameId ?? runtime.gameId ?? CANONICAL_GAME_IDS[classId] ?? null,
|
||
name: definition?.className ?? definition?.name ?? ("CoA class " + classId),
|
||
counts: {
|
||
trees: asArray(definition?.tabs).length, nodes: classNodes.length,
|
||
abilityNodes: classNodes.filter((node) => node.entryType === "Ability").length,
|
||
talentNodes: classNodes.filter((node) => node.entryType === "Talent").length,
|
||
spells: classSpells.length, progressionChains: classChains.length,
|
||
rankSpellReferences: classNodes.reduce((sum, node) => sum + asArray(node.rankDescriptions).length, 0)
|
||
+ classChains.reduce((sum, chain) => sum + asArray(chain.ranks).length, 0),
|
||
},
|
||
mechanicDescriptions: {
|
||
describedNodes: classNodes.filter((node) => typeof node.description === "string" && node.description.trim()).length,
|
||
describedRankNodes: classNodes.filter((node) => asArray(node.rankDescriptions).some((rank) => typeof rank?.description === "string" && rank.description.trim())).length,
|
||
describedSpells: classSpells.filter((spell) => typeof spell.description === "string" && spell.description.trim()).length,
|
||
examples: classNodes.filter((node) => typeof node.description === "string" && node.description.trim()).slice(0, 5).map((node) => ({
|
||
nodeId: node.id, name: node.name, description: node.description,
|
||
rankDescriptions: asArray(node.rankDescriptions).map((rank) => ({ rank: rank.rank, spellId: rank.spellId, description: rank.description })),
|
||
})),
|
||
},
|
||
dataGaps: {
|
||
structuralErrorCount: classFindings.filter((item) => item.severity === "error").length,
|
||
missingRankSpellIds,
|
||
missingNodeRankSpellIds,
|
||
missingNodeRankDbcIds,
|
||
missingProgressionRankSpellIds,
|
||
missingProgressionRankDbcIds,
|
||
sourceConflictSpellIds,
|
||
missingPrerequisites: classFindings.filter((item) => item.code === "missing-prerequisite").length,
|
||
missingConnections: classFindings.filter((item) => item.code === "missing-connection").length,
|
||
unknownTriggerSpellIds: [...new Set(classFindings.filter((item) => item.code === "unknown-triggered-spell").map((item) => item.spellId))].sort((a, b) => a - b),
|
||
sourceDiagnosticCount: classFindings.filter((item) => item.source === "local-source").length,
|
||
},
|
||
runtimeCatalogMapping: runtime,
|
||
augustComparison: aug,
|
||
backlog,
|
||
};
|
||
});
|
||
}
|
||
|
||
function auditReference({ reference, supplements = null, projectRoot = process.cwd() }) {
|
||
const validation = validateReference(reference);
|
||
const julyToAugust = compareJulyToAugust(reference, supplements);
|
||
const area52RelativePath = resolveBaselinePath(projectRoot);
|
||
const baseDbcToArea52 = compareBaseDbcToArea52(
|
||
reference,
|
||
path.join(projectRoot, area52RelativePath),
|
||
area52RelativePath,
|
||
);
|
||
const runtimeEvidence = buildRuntimeEvidence(projectRoot, asArray(reference?.classes));
|
||
const findings = collectFindings(reference, validation, runtimeEvidence, baseDbcToArea52, supplements);
|
||
const classes = summarizeClasses(reference, runtimeEvidence, findings, julyToAugust, baseDbcToArea52);
|
||
const supplementalFreshness = compareSupplementFreshness(reference, supplements);
|
||
const globalSourceDifferences = julyToAugust.counts.nodesAdded + julyToAugust.counts.nodesRemoved + julyToAugust.counts.nodesChanged
|
||
+ julyToAugust.counts.treesRemoved + julyToAugust.counts.treesChanged + baseDbcToArea52.counts.changedRecords;
|
||
const globalRankGaps = validation.counts.missingRankSpells + validation.counts.missingRankDbcRecords;
|
||
const globalBacklog = [];
|
||
if (globalSourceDifferences > 0) globalBacklog.push({
|
||
order: 1, priority: "source-conflicts", status: "needs-review", title: "Review source-version differences",
|
||
evidenceCount: globalSourceDifferences,
|
||
detail: "Resolve changed or removed records against provenance, and inspect additions without merging them into the July reference.",
|
||
august: julyToAugust.counts,
|
||
baseDbcArea52SpellConflicts: baseDbcToArea52.counts.changedRecords,
|
||
});
|
||
if (globalRankGaps > 0) globalBacklog.push({
|
||
order: globalBacklog.length + 1, priority: "rank-identity", status: "unresolved",
|
||
title: "Resolve rank-to-spell identity gaps", evidenceCount: globalRankGaps,
|
||
});
|
||
return {
|
||
schemaVersion: 1,
|
||
baseline: reference?.baseline ?? null,
|
||
supplementalCache: supplements
|
||
? { status: "present", source: supplements.source ?? supplements.metadata ?? null }
|
||
: { status: "absent", observation: "No supplemental cache was supplied." },
|
||
supplementalFreshness,
|
||
validation,
|
||
counts: {
|
||
classes: asArray(reference?.classes).length, nodes: asArray(reference?.nodes).length,
|
||
spells: asArray(reference?.spells).length, progressionChains: asArray(reference?.progressionChains).length,
|
||
sourceFiles: asArray(reference?.sources).length, findings: findings.length,
|
||
errors: validation.errors.length,
|
||
warnings: validation.warnings.length + findings.filter((item) => item.severity === "warning" && item.source !== "reference-structure").length,
|
||
},
|
||
comparisons: { julyToAugust, baseDbcToArea52 },
|
||
rankIdentityCoverage: {
|
||
nodeRanks: {
|
||
missingSpellRecords: validation.counts.missingNodeRankSpells,
|
||
missingBaseDbcRows: validation.counts.missingNodeRankDbcRecords,
|
||
},
|
||
progressionRanks: {
|
||
missingSpellRecords: validation.counts.missingProgressionSpells,
|
||
missingBaseDbcRows: validation.counts.missingProgressionDbcRecords,
|
||
},
|
||
},
|
||
runtimeEvidence,
|
||
unknownServerBehavior: [
|
||
{ area: "server stat formulas and effect scaling", status: "unknown-from-supplied-reference", detail: "DBC fields and imported descriptions do not establish Ascension final server-side formulas or per-rank scaling." },
|
||
{ area: "proc timing and chance", status: "unknown-from-supplied-reference", detail: "DBC proc flags and procChance do not prove live server trigger cadence, eligibility, or proc rate." },
|
||
{ area: "player and companion execution", status: "not-assessed", detail: "Catalog mapping and effect import do not prove that an ability executes correctly for a player or companion." },
|
||
],
|
||
classes,
|
||
findings,
|
||
backlog: {
|
||
ordering: [
|
||
"Resolve source conflicts and rank-to-spell identity gaps.",
|
||
"Verify bespoke player and companion mechanics with behavioral evidence.",
|
||
"Review class UI, gameplay flow, and balance.",
|
||
],
|
||
structuralErrors: validation.errors.length,
|
||
classes: classes.map(({ classId, gameId, name, backlog }) => ({ classId, gameId, name, items: backlog })),
|
||
global: [...globalBacklog, {
|
||
order: globalBacklog.length + 1, priority: "server-stat-and-scaling-unknowns", status: "unknown",
|
||
title: "Confirm server-side stat formulas, proc timing, and scaling",
|
||
detail: "Client DBC proc flags, proc chance, base points, level scaling, and trigger links do not establish current Ascension server behavior or final values.",
|
||
}],
|
||
},
|
||
};
|
||
}
|
||
|
||
function md(value) {
|
||
return String(value ?? "").replaceAll("|", "\\|").replaceAll("\n", " ").replaceAll("\r", " ");
|
||
}
|
||
|
||
function renderAuditMarkdown(report) {
|
||
const validation = report?.validation ?? { errors: [], warnings: [] };
|
||
const comparison = report?.comparisons?.julyToAugust ?? { status: "absent", counts: {} };
|
||
const dbc = report?.comparisons?.baseDbcToArea52 ?? { status: "unavailable", counts: {}, changedFields: {} };
|
||
const classes = asArray(report?.classes);
|
||
const lines = [
|
||
"# CoA reference audit",
|
||
"",
|
||
"Baseline: **" + md(report?.baseline?.kind ?? "unknown") + "**" + (report?.baseline?.capturedAt ? ", captured " + md(report.baseline.capturedAt) : "") + ".",
|
||
"Supplemental cache: **" + md(report?.supplementalCache?.status ?? "unknown") + "**" + (report?.supplementalCache?.source?.revision ? " (revision " + md(report.supplementalCache.source.revision) + ")" : "") + ".",
|
||
"Structural validation: **" + validation.errors.length + " errors**, **" + validation.warnings.length + " warnings**. Missing references and unknown spell dependencies are warnings, so structural validity does not establish completeness.",
|
||
"",
|
||
"## Per-class coverage",
|
||
"",
|
||
"| Class | Trees | Nodes | Spells | Chains | Node / progression rank gaps | Runtime ability mappings | Runtime triggered mappings | August changes |",
|
||
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |",
|
||
];
|
||
for (const row of classes) {
|
||
const count = row.counts ?? {};
|
||
const gaps = row.dataGaps ?? {};
|
||
const aug = row.augustComparison ?? {};
|
||
// The spell-ID arrays already include DBC-only gaps. Adding the DBC subset
|
||
// again would count the same unresolved rank twice in this summary table.
|
||
const nodeGaps = gaps.missingNodeRankSpellIds?.length ?? 0;
|
||
const chainGaps = gaps.missingProgressionRankSpellIds?.length ?? 0;
|
||
lines.push("| " + md(row.name) + " (" + row.classId + ") | " + (count.trees ?? 0) + " | " + (count.nodes ?? 0) + " | " + (count.spells ?? 0) + " | " + (count.progressionChains ?? 0) + " | " + nodeGaps + " / " + chainGaps + " | " + (row.runtimeCatalogMapping?.runtimeAbilityMappings ?? 0) + " | " + (row.runtimeCatalogMapping?.runtimeTriggeredAbilityMappings ?? 0) + " | +" + (aug.nodesAdded ?? 0) + " / −" + (aug.nodesRemoved ?? 0) + " / Δ" + (aug.nodesChanged ?? 0) + " nodes; +" + (aug.treesAdded ?? 0) + " / −" + (aug.treesRemoved ?? 0) + " / Δ" + (aug.treesChanged ?? 0) + " trees (" + (aug.emptyMetadataTreesAdded ?? 0) + " empty metadata additions) |");
|
||
}
|
||
const rankCoverage = report?.rankIdentityCoverage;
|
||
lines.push(
|
||
"",
|
||
"Rank reference gaps are shown as node ranks / progression-chain ranks: "
|
||
+ (rankCoverage?.nodeRanks?.missingSpellRecords ?? 0) + " node rank spell records missing and "
|
||
+ (rankCoverage?.nodeRanks?.missingBaseDbcRows ?? 0) + " node rank BASE DBC rows missing; "
|
||
+ (rankCoverage?.progressionRanks?.missingSpellRecords ?? 0) + " chain rank spell records missing and "
|
||
+ (rankCoverage?.progressionRanks?.missingBaseDbcRows ?? 0) + " chain rank BASE DBC rows missing.",
|
||
"",
|
||
"Mechanic descriptions are retained as reference text in the machine report. Runtime ability and talent counts show catalog mappings; they do not prove behavior for a player or companion. Behavioral mechanics coverage is not assessed by this audit.",
|
||
"",
|
||
"## Source comparisons",
|
||
"",
|
||
);
|
||
if (comparison.status === "absent") {
|
||
lines.push("July-to-August tree/node comparison: **unavailable**. " + md(comparison.observation ?? "No supplemental cache was supplied."));
|
||
} else {
|
||
const count = comparison.counts ?? {};
|
||
lines.push("July-to-August comparison: " + (count.treesAdded ?? 0) + " tree records added (" + (count.emptyMetadataTreesAdded ?? 0) + " empty metadata tabs), " + (count.treesRemoved ?? 0) + " removed, " + (count.treesChanged ?? 0) + " changed; " + (count.nodesAdded ?? 0) + " nodes added, " + (count.nodesRemoved ?? 0) + " removed, " + (count.nodesChanged ?? 0) + " changed. Empty tabs are source metadata and do not represent playable trees. The supplemental source remains separate from the baseline.");
|
||
}
|
||
if (dbc.status === "compared") {
|
||
const count = dbc.counts;
|
||
const fields = Object.entries(dbc.changedFields ?? {}).map(([name, number]) => name + " (" + number + ")").join(", ") || "none";
|
||
lines.push("Local base DBC vs imported Area52: " + count.baseRecords + " and " + count.area52Records + " records; " + count.matchingIds + " matching IDs, " + count.baseOnly + " base-only, " + count.area52Only + " Area52-only, and " + count.changedRecords + " matching records with changed fields. Changed field counts: " + fields + ".");
|
||
lines.push("Separate data gap: " + (count.baseMissingDbcRecords ?? 0) + " reference spell records do not include a base DBC row; rank-to-spell identity gaps are counted separately by class.");
|
||
const missingSamples = asArray(dbc.baseMissingDbcSamples).slice(0, 5);
|
||
if (missingSamples.length) {
|
||
lines.push("", "Base DBC-missing spell samples:", "");
|
||
for (const sample of missingSamples) lines.push("- Spell " + md(sample.spellId) + (sample.name ? " (" + md(sample.name) + ")" : "") + (sample.source ? ": " + md(sample.source) : "."));
|
||
}
|
||
} else lines.push("Local base DBC vs imported Area52: **unavailable**. " + md(dbc.observation ?? "Area52 catalog could not be read."));
|
||
|
||
const dbcSamples = asArray(dbc.samples).slice(0, 5);
|
||
if (dbcSamples.length) {
|
||
lines.push("", "Changed spell samples:", "");
|
||
for (const sample of dbcSamples) lines.push("- Spell " + sample.spellId + " (" + md(sample.baseName ?? sample.area52Name) + "): " + sample.changedFields.map(md).join(", ") + ".");
|
||
}
|
||
const changeSamples = asArray(comparison.changedSamples).slice(0, 5);
|
||
if (changeSamples.length) {
|
||
lines.push("", "July/August tree and node samples:", "");
|
||
for (const sample of changeSamples) lines.push("- " + sample.kind + " " + sample.key + ": " + sample.changedFields.map(md).join(", ") + ".");
|
||
}
|
||
|
||
const freshness = report?.supplementalFreshness;
|
||
lines.push("", "## Capture and provenance", "");
|
||
if (freshness?.status === "compared") {
|
||
lines.push("July capture: " + md(freshness.baselineCapturedAt ?? "unknown") + "; supplement capture: " + md(freshness.supplementCapturedAt ?? "unknown") + ". Matching manifest paths: " + (freshness.sourceHashes?.matchingPaths ?? 0) + ", same SHA-256: " + (freshness.sourceHashes?.same ?? 0) + ", changed SHA-256: " + (freshness.sourceHashes?.changed ?? 0) + ".");
|
||
if ((freshness.sourceHashes?.matchingPaths ?? 0) === 0) lines.push("No relative paths matched between the baseline source list and archive manifest, so their hashes could not be compared directly.");
|
||
} else lines.push("Supplement capture and source hashes are unavailable without the cache.");
|
||
const sourceDiagnostics = asArray(report?.findings).filter((item) => item.source === "local-source")
|
||
.sort((left, right) => Number(right.code === "source-order-conflict") - Number(left.code === "source-order-conflict"))
|
||
.slice(0, 5);
|
||
if (sourceDiagnostics.length) {
|
||
lines.push("", "Local-source diagnostics:", "");
|
||
for (const item of sourceDiagnostics) lines.push("- " + md(item.severity) + " " + md(item.code) + ": " + md(item.message));
|
||
}
|
||
if (dbc.counts?.changedRecords > 0) lines.push("Base DBC and Area52 spell values are preserved as separate comparison sources; this report does not select or merge a winner.");
|
||
|
||
const blockerCodes = new Set(["missing-rank-spell", "missing-progression-spell", "missing-prerequisite", "missing-connection", "unknown-triggered-spell", "base-dbc-area52-spell-conflicts"]);
|
||
const blockers = asArray(report?.findings).filter((item) => item.severity === "error" || blockerCodes.has(item.code));
|
||
lines.push("", "## Review blockers", "");
|
||
if (!blockers.length) lines.push("No structural errors or reference blockers were recorded.");
|
||
else {
|
||
for (const item of blockers.slice(0, MAX_BLOCKER_SAMPLES)) {
|
||
const where = [
|
||
item.classId !== undefined && item.classId !== null ? "class " + item.classId : null,
|
||
item.nodeId !== undefined && item.nodeId !== null ? "node " + item.nodeId : null,
|
||
item.spellId !== undefined && item.spellId !== null ? "spell " + item.spellId : null,
|
||
].filter(Boolean).join(", ");
|
||
lines.push("- **" + md(item.severity) + " " + md(item.code) + "**" + (where ? " (" + md(where) + ")" : "") + ": " + md(item.message));
|
||
}
|
||
if (blockers.length > MAX_BLOCKER_SAMPLES) lines.push("- Additional findings are listed in the machine report.");
|
||
}
|
||
lines.push("", "## Prioritized backlog", "");
|
||
for (const row of classes) {
|
||
const first = asArray(row.backlog)[0] ?? { title: "Verify bespoke player and companion mechanics" };
|
||
lines.push("- **" + md(row.name) + ":** " + md(first.title) + (first.evidenceCount ? " (" + first.evidenceCount + " records)" : "") + ".");
|
||
}
|
||
lines.push(
|
||
"",
|
||
"Server stat formulas, proc timing and chance, and final spell scaling remain unknown without independent server evidence.",
|
||
"",
|
||
"Runtime catalogs: ability mappings " + md(report?.runtimeEvidence?.catalogs?.coaAbilityRuntimeCatalog?.status ?? "unknown")
|
||
+ ", talent mappings " + md(report?.runtimeEvidence?.catalogs?.talentRuntimeCatalog?.status ?? "unknown")
|
||
+ ", triggered spell records " + md(report?.runtimeEvidence?.catalogs?.coaTriggeredSpellCatalog?.status ?? "unknown") + ".",
|
||
"",
|
||
);
|
||
return lines.join("\n");
|
||
}
|
||
|
||
export { auditReference, renderAuditMarkdown, validateReference };
|