Files
healer-man/scripts/coa-reference/audit.mjs

1226 lines
68 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#0*39;|&apos;/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 };