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(//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 };