import { isDeepStrictEqual } from "node:util"; export const COA_PROMOTION_ARCHIVE = Object.freeze({ repository: "https://github.com/srhinos/coa-datamine", revision: "5078f9ac22541c45c17a866c23aac6c12073de23", capturedAt: "2026-08-12T21:02:51+00:00", realmSlug: "voljin", realmName: "Vol'Jin", coaBaseSpellPath: "client-current/patch-T/DBFilesClient/Spell.dbc", classicSpellPath: "client-current/area-52/patch-D/DBFilesClient/Spell.dbc", }); const CLASSIC_CLASS_MAX = 11; const EXPECTED_COA_CLASS_IDS = Array.from({ length: 21 }, (_, index) => index + 12); function assert(condition, message) { if (!condition) throw new TypeError(message); } function numberOrZero(value) { return Number(value) || 0; } function cleanHtml(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 cleanName(value = "") { return String(value).replace(/^@/, "").replace(/^6(?=[A-Z])/, "").trim(); } function iconBasename(value = "") { return String(value) .replace(/^.*[\\/]/, "") .replace(/\.(?:blp|tga)$/i, "") .toLowerCase(); } function numberList(value) { return (Array.isArray(value) ? value : []).map(Number).filter(Boolean); } function compareEntries(left, right) { return left.classId - right.classId || left.tabId - right.tabId || left.y - right.y || left.x - right.x || left.id - right.id; } function compareNumbers(left, right) { return left - right; } function stable(value) { if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`; if (value && typeof value === "object") { return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(",")}}`; } return JSON.stringify(value); } function changedFields(before, after) { const keys = [...new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])].sort(); return keys.filter((key) => stable(before?.[key]) !== stable(after?.[key])); } function changedValues(before, after, fields = changedFields(before, after)) { return Object.fromEntries(fields.map((field) => { const beforeValue = before?.[field]; const afterValue = after?.[field]; if (field === "dbc" && beforeValue && afterValue && typeof beforeValue === "object" && typeof afterValue === "object") { const dbcFields = changedFields(beforeValue, afterValue); return [field, Object.fromEntries(dbcFields.map((dbcField) => [dbcField, { ...(beforeValue[dbcField] === undefined ? {} : { before: clone(beforeValue[dbcField]) }), ...(afterValue[dbcField] === undefined ? {} : { after: clone(afterValue[dbcField]) }), }]))]; } return [field, { ...(beforeValue === undefined ? {} : { before: clone(beforeValue) }), ...(afterValue === undefined ? {} : { after: clone(afterValue) }), }]; })); } function spellSummary(spell) { return { id: Number(spell.id), classIds: clone(spell.classIds ?? []), name: spell.name ?? "", rank: spell.rank ?? "", description: spell.description ?? "", hasBaseDbc: Boolean(spell.dbc), }; } function clone(value) { return structuredClone(value); } function classNameById(classes, label) { const result = new Map(); for (const definition of classes) { const classId = Number(definition?.classId); assert(Number.isInteger(classId), `${label} contains a class without an integer classId`); assert(!result.has(classId), `${label} contains duplicate classId ${classId}`); result.set(classId, String(definition.className ?? definition.builderClassName ?? definition.class ?? "")); } return result; } function assertUniqueById(records, label) { const seen = new Set(); for (const record of records) { const id = Number(record?.id); assert(Number.isSafeInteger(id) && id > 0, `${label} contains invalid id ${String(record?.id)}`); assert(!seen.has(id), `${label} contains duplicate id ${id}`); seen.add(id); } return seen; } function previousSpellWrapper(spell) { const { id, classIds = [], name = "", iconBasename: icon = "", level = 0, rank = "", description = "", liveTooltip, sourceIds = [], advancementType = "", advancementTabId = 0, talented = false, excluded = false, dbc, } = spell; return { id: Number(id), classIds: clone(classIds), name, iconBasename: icon, level, rank, description, ...(liveTooltip ? { liveTooltip } : {}), sourceIds: clone(sourceIds), advancementType, advancementTabId, talented, excluded, ...(dbc ? { dbc: clone(dbc) } : {}), }; } function referenceSpellWrapper({ id, referenceSpell, previousSpell, owners, nodeOwner, descriptionOverride, dbcSourceOnly = true }) { const dbc = referenceSpell?.dbc; const node = nodeOwner?.node; const description = descriptionOverride ?? nodeOwner?.rankDescription?.description ?? node?.description ?? referenceSpell?.description ?? (dbcSourceOnly ? "" : previousSpell?.description) ?? dbc?.description ?? ""; const ownerClassIds = [...new Set([...(owners ?? [])])].filter((classId) => classId > CLASSIC_CLASS_MAX).sort(compareNumbers); const icon = node?.iconBasename || previousSpell?.iconBasename || ""; const rankDescriptionRank = nodeOwner?.rankDescription?.rank; return { id, classIds: ownerClassIds.length ? ownerClassIds : clone(referenceSpell?.classIds ?? previousSpell?.classIds ?? []), name: referenceSpell?.name || node?.name || previousSpell?.name || dbc?.name || "", iconBasename: icon, level: referenceSpell?.level ?? previousSpell?.level ?? dbc?.spellLevel ?? 0, rank: rankDescriptionRank !== undefined ? `Rank ${rankDescriptionRank}` : referenceSpell?.rank || previousSpell?.rank || dbc?.rankText || "", description, sourceIds: clone(previousSpell?.sourceIds ?? []), advancementType: previousSpell?.advancementType ?? "", advancementTabId: previousSpell?.advancementTabId ?? 0, talented: previousSpell?.talented ?? false, excluded: previousSpell?.excluded ?? false, ...(dbc ? { dbc: clone(dbc) } : {}), }; } function rankOwnerMap(entries) { const owners = new Map(); for (const node of entries) { for (const rankDescription of node.rankDescriptions) { const spellId = rankDescription.spellId; const existing = owners.get(spellId) ?? []; existing.push({ classId: node.classId, node, rankDescription }); owners.set(spellId, existing); } } for (const ownerList of owners.values()) { ownerList.sort((left, right) => compareEntries(left.node, right.node) || left.rankDescription.rank - right.rankDescription.rank); } return owners; } function firstNodeOwner(ownerList) { return ownerList?.[0] ?? null; } function mapOfSpells(spells, label) { assert(Array.isArray(spells), `${label} must be an array`); assertUniqueById(spells, label); return new Map(spells.map((spell) => [Number(spell.id), spell])); } function chainSpellIds(chain) { return (Array.isArray(chain?.ranks) ? chain.ranks : []).map((rank) => Number(rank.spellId)); } function spellIdsForClassicChains(chains) { const ids = new Set(); for (const chain of chains) { if (Number(chain.classId) > CLASSIC_CLASS_MAX) continue; for (const spellId of chainSpellIds(chain)) if (Number.isSafeInteger(spellId) && spellId > 0) ids.add(spellId); } return ids; } function classicOwnedSpellIds(spells, classicChainSpellIds) { const ids = new Set(classicChainSpellIds); for (const spell of spells) { if ((spell.classIds ?? []).some((classId) => Number(classId) > 0 && Number(classId) <= CLASSIC_CLASS_MAX)) { ids.add(Number(spell.id)); } } return ids; } function classicProtectionClosure({ previousSpells, classicChains, classicTriggeredSpells = [] }) { const mainById = new Map(previousSpells.map((spell) => [Number(spell.id), spell])); const triggeredById = new Map(); for (const spell of classicTriggeredSpells) { const id = Number(spell?.id); if (Number.isSafeInteger(id) && id > 0 && !triggeredById.has(id)) triggeredById.set(id, spell); } const seeds = classicOwnedSpellIds(previousSpells, spellIdsForClassicChains(classicChains)); const referencedIds = new Set(seeds); const protectedIds = new Set([...seeds].filter((id) => mainById.has(id))); const queue = [...seeds].sort(compareNumbers); const visited = new Set(); for (let cursor = 0; cursor < queue.length; cursor += 1) { const spellId = queue[cursor]; if (visited.has(spellId)) continue; visited.add(spellId); const rows = [mainById.get(spellId), triggeredById.get(spellId)].filter(Boolean); const childIds = new Set(); for (const row of rows) { const effects = row.dbc?.effects ?? row.effects ?? []; for (const effect of effects) { const triggerSpellId = Number(effect.triggerSpellId); if (Number.isSafeInteger(triggerSpellId) && triggerSpellId > 0) childIds.add(triggerSpellId); const miscValue = Number(effect.miscValue); if (Number(effect.effectType) === 168 && Number.isSafeInteger(miscValue) && miscValue > 0) childIds.add(miscValue); } } for (const childId of [...childIds].sort(compareNumbers)) { referencedIds.add(childId); if (mainById.has(childId)) protectedIds.add(childId); if (!visited.has(childId)) queue.push(childId); } } return { protectedIds, referencedIds }; } function closureForCoaSpells({ entries, chains, referenceSpells }) { const ownersBySpellId = new Map(); const addOwner = (spellId, classIds) => { const id = Number(spellId); if (!Number.isSafeInteger(id) || id <= 0) return false; const owners = ownersBySpellId.get(id) ?? new Set(); const before = owners.size; for (const classId of classIds) { const numericClassId = Number(classId); if (numericClassId > CLASSIC_CLASS_MAX) owners.add(numericClassId); } ownersBySpellId.set(id, owners); return owners.size !== before; }; for (const node of entries) { for (const rank of node.rankDescriptions) addOwner(rank.spellId, [node.classId]); } for (const chain of chains) { if (Number(chain.classId) <= CLASSIC_CLASS_MAX) continue; for (const rank of chain.ranks ?? []) addOwner(rank.spellId, [chain.classId]); } for (const spell of referenceSpells) { const classes = (spell.classIds ?? []).filter((classId) => Number(classId) > CLASSIC_CLASS_MAX); if (classes.length) addOwner(spell.id, classes); } const byId = new Map(referenceSpells.map((spell) => [Number(spell.id), spell])); const processedOwners = new Map(); const queue = [...ownersBySpellId.keys()].sort(compareNumbers); for (let cursor = 0; cursor < queue.length; cursor += 1) { const spellId = queue[cursor]; const owners = ownersBySpellId.get(spellId) ?? new Set(); const processed = processedOwners.get(spellId) ?? new Set(); const newOwners = [...owners].filter((classId) => !processed.has(classId)); if (newOwners.length === 0) continue; for (const classId of newOwners) processed.add(classId); processedOwners.set(spellId, processed); const dbc = byId.get(spellId)?.dbc; for (const effect of dbc?.effects ?? []) { const childIds = []; const triggerSpellId = Number(effect.triggerSpellId); if (Number.isSafeInteger(triggerSpellId) && triggerSpellId > 0) childIds.push(triggerSpellId); const miscValueId = Number(effect.miscValue); if (Number(effect.effectType) === 168 && Number.isSafeInteger(miscValueId) && miscValueId > 0) childIds.push(miscValueId); for (const childId of childIds) { if (addOwner(childId, newOwners)) queue.push(childId); } } } return ownersBySpellId; } function preserveChecks({ previous, candidate, oldEntriesById, oldChainById, oldSpellById, protectedSpellIds, classicReachableSpellIds, coaOwnersBySpellId }) { const classicChains = previous.progressionChains.filter((chain) => Number(chain.classId) <= CLASSIC_CLASS_MAX); const candidateClassicChains = candidate.progressionChains.filter((chain) => Number(chain.classId) <= CLASSIC_CLASS_MAX); const classicProtected = [...protectedSpellIds].sort(compareNumbers); const classicSpellRecords = classicProtected.flatMap((id) => { const before = oldSpellById.get(id); if (!before) return []; const after = candidate.spells.find((spell) => Number(spell.id) === id); return [{ id, before, after: after ?? null }]; }); const missingOldNodeIds = []; const crossClassRewires = []; for (const [id, oldNode] of oldEntriesById) { const nextNode = candidate.entries.find((node) => Number(node.id) === id); if (!nextNode) missingOldNodeIds.push(id); else if (Number(nextNode.classId) !== Number(oldNode.classId)) { crossClassRewires.push({ id, beforeClassId: oldNode.classId, afterClassId: nextNode.classId }); } } const missingOldChainIds = []; const changedCoaChainRanks = []; const candidateChainMap = new Map(candidate.progressionChains.map((chain) => [chain.id, chain])); for (const [id, oldChain] of oldChainById) { const nextChain = candidateChainMap.get(id); if (!nextChain) missingOldChainIds.push(id); else if (!isDeepStrictEqual(chainSpellIds(oldChain), chainSpellIds(nextChain))) { changedCoaChainRanks.push({ id, before: chainSpellIds(oldChain), after: chainSpellIds(nextChain) }); } } const classicRecordsUnchanged = classicSpellRecords.every(({ before, after }) => after && isDeepStrictEqual(before, after)); return { classicProgressionChains: { passed: isDeepStrictEqual(classicChains, candidateClassicChains), count: classicChains.length, }, classicProtectedSpellRecords: { passed: classicRecordsUnchanged, recordCount: classicSpellRecords.length, protectedMainIdCount: classicProtected.length, classicReachableIdCount: classicReachableSpellIds.size, }, previousCoaNodeIds: { passed: missingOldNodeIds.length === 0 && crossClassRewires.length === 0, previousCount: oldEntriesById.size, missingIds: missingOldNodeIds.sort(compareNumbers), crossClassRewires: crossClassRewires.sort((left, right) => left.id - right.id), }, previousProgressionChainIdentities: { passed: missingOldChainIds.length === 0 && changedCoaChainRanks.length === 0, previousCount: oldChainById.size, missingIds: missingOldChainIds.sort(), changedCoaRanks: changedCoaChainRanks.sort((left, right) => String(left.id).localeCompare(String(right.id))), }, coaSpellOverrideCoverage: { passed: candidate.coaSpellOverrides.every((spell) => (coaOwnersBySpellId.get(spell.id)?.size ?? 0) > 0), overrideCount: candidate.coaSpellOverrides.length, }, }; } function validationError(message, report) { const error = new Error(message); error.name = "CoaPromotionValidationError"; error.report = report; return error; } function makeSource(previous, reference, supplements) { const prior = previous.source ?? {}; const baseSpellSource = reference.sources?.find((source) => source.id === "global-spell-base") ?? null; return { ...clone(prior), realmSlug: COA_PROMOTION_ARCHIVE.realmSlug, realmName: COA_PROMOTION_ARCHIVE.realmName, synchronizedAt: supplements.source.capturedAt, classSpellRowsCapturedAt: prior.synchronizedAt, spellDbcPath: COA_PROMOTION_ARCHIVE.coaBaseSpellPath, supportDbcDirectory: "client-current/patch-S/DBFilesClient", coaSpellDbcPath: COA_PROMOTION_ARCHIVE.coaBaseSpellPath, classicSpellDbcPath: COA_PROMOTION_ARCHIVE.classicSpellPath, coaSkillLineAbilityDbcPath: "client-current/patch-M/DBFilesClient/SkillLineAbility.dbc", ...(reference.archivedSpellSupplement ? { archivedSpellSupplement: clone(reference.archivedSpellSupplement) } : {}), spellDbcSources: { coaBase: { patch: "T", path: baseSpellSource?.path ?? `extracts/${COA_PROMOTION_ARCHIVE.coaBaseSpellPath}`, sourceId: baseSpellSource?.id ?? "global-spell-base", }, classic: { patch: "area-52", path: prior.spellDbcPath?.includes("area-52") ? prior.spellDbcPath : COA_PROMOTION_ARCHIVE.classicSpellPath, sourceId: "classic-area52-preserved", }, }, promotion: { archiveRepository: supplements.source.repository, archiveRevision: supplements.source.revision, archiveCapturedAt: supplements.source.capturedAt, builderSnapshotPath: supplements.source.builderSnapshotPath, builderSnapshotSha256: supplements.source.builderSnapshotSha256, realmSlug: COA_PROMOTION_ARCHIVE.realmSlug, coaSpellSource: "client-current/patch-T/DBFilesClient/Spell.dbc", classicSpellSource: "client-current/area-52/patch-D/DBFilesClient/Spell.dbc", }, }; } function makeCounts(candidate, previous) { const classSpells = candidate.spells.flatMap((spell) => spell.classIds ?? []); return { classes: candidate.classes.length, tabs: candidate.classes.reduce((total, definition) => total + definition.tabs.length, 0), entries: candidate.entries.length, abilityEntries: candidate.entries.filter((entry) => entry.entryType === "Ability").length, talentEntries: candidate.entries.filter((entry) => entry.entryType === "Talent").length, rankedNodeSpells: candidate.entries.reduce((total, entry) => total + entry.rankDescriptions.length, 0), classSpellRows: previous.counts?.classSpellRows ?? classSpells.length, classicClassSpellRows: previous.counts?.classicClassSpellRows ?? classSpells.filter((classId) => classId <= CLASSIC_CLASS_MAX).length, coaClassSpellRows: previous.counts?.coaClassSpellRows ?? classSpells.filter((classId) => classId > CLASSIC_CLASS_MAX).length, spellClassMemberships: classSpells.length, uniqueSpells: candidate.spells.length, dbcMatchedSpells: candidate.spells.filter((spell) => spell.dbc).length, progressionChains: candidate.progressionChains.length, classicProgressionChains: candidate.progressionChains.filter((chain) => Number(chain.classId) <= CLASSIC_CLASS_MAX).length, classicBaseProgressionChains: candidate.progressionChains.filter((chain) => Number(chain.classId) <= CLASSIC_CLASS_MAX && !chain.talented).length, coaProgressionChains: candidate.progressionChains.filter((chain) => Number(chain.classId) > CLASSIC_CLASS_MAX).length, progressionRanks: candidate.progressionChains.reduce((total, chain) => total + (chain.ranks?.length ?? 0), 0), liveTooltips: candidate.spells.filter((spell) => spell.liveTooltip).length, coaSpellOverrides: candidate.coaSpellOverrides.length, }; } /** Convert one pinned archive tree node to the active CoA entry shape used by the old sync. */ export function normalizeArchiveNode(raw) { assert(raw && typeof raw === "object" && !Array.isArray(raw), "Archive node must be an object"); const id = Number(raw.id); const classId = Number(raw.classId); const tabId = Number(raw.tabId); assert(Number.isSafeInteger(id) && id > 0, `Archive node has invalid id ${String(raw.id)}`); assert(Number.isInteger(classId) && EXPECTED_COA_CLASS_IDS.includes(classId), `Archive node ${id} has noncanonical classId ${String(raw.classId)}`); assert(Number.isInteger(tabId) && tabId > 0, `Archive node ${id} has invalid tabId ${String(raw.tabId)}`); const x = Number(raw.x ?? raw.column); const y = Number(raw.y ?? raw.row); assert(Number.isFinite(x) && Number.isFinite(y), `Archive node ${id} has invalid coordinates`); const rankDescriptions = (Array.isArray(raw.rankDescriptions) ? raw.rankDescriptions : []).map((rank, index) => { const rankNumber = Number(rank?.rank); const spellId = Number(rank?.spellId); assert(Number.isInteger(rankNumber) && rankNumber > 0, `Archive node ${id} has invalid rank number at index ${index}`); assert(Number.isSafeInteger(spellId) && spellId > 0, `Archive node ${id} has invalid rank spellId at index ${index}`); return { rank: rankNumber, spellId, description: cleanHtml(rank?.description), }; }); const spellIdCount = Array.isArray(raw.spellIds) ? raw.spellIds.length : rankDescriptions.length; return { id, classId, tabId, x, y, name: cleanName(raw.name), iconBasename: iconBasename(raw.iconPath ?? raw.icon), nodeType: String(raw.nodeType ?? ""), entryType: String(raw.entryType ?? ""), isPassive: Boolean(raw.isPassive), maxPoints: numberOrZero(raw.maxPoints ?? raw.maxRank) || Math.max(1, spellIdCount), aeCost: numberOrZero(raw.aeCost ?? raw.abilityEssenceCost), teCost: numberOrZero(raw.teCost ?? raw.talentEssenceCost), requiredClassPoints: numberOrZero(raw.reqTabAE ?? raw.requiredClassPoints), requiredSpecPoints: numberOrZero(raw.reqTabTE ?? raw.requiredSpecPoints), requiredIds: numberList(raw.requiredIds ?? raw.prerequisites), connectedNodeIds: numberList(raw.connectedNodeIds ?? raw.connections), requiredLevel: numberOrZero(raw.requiredLevel), isStartingNode: Boolean(raw.isStartingNode), flags: numberOrZero(raw.flags), group: numberOrZero(raw.group), sortOrder: numberOrZero(raw.sortOrder), description: cleanHtml(raw.description), rankDescriptions, }; } /** Build an in-memory, validated CoA promotion candidate and an exact machine-readable diff. */ export function buildCoaPromotion({ previous, reference, supplements }) { assert(previous && typeof previous === "object", "previous CoA data is required"); assert(reference && typeof reference === "object", "local CoA reference is required"); assert(supplements && typeof supplements === "object", "pinned CoA supplements are required"); assert(Array.isArray(previous.classes), "previous CoA data has no classes array"); assert(Array.isArray(previous.entries), "previous CoA data has no entries array"); assert(Array.isArray(previous.progressionChains), "previous CoA data has no progressionChains array"); assert(Array.isArray(previous.spells), "previous CoA data has no spells array"); assert(Array.isArray(reference.classes), "local reference has no classes array"); assert(Array.isArray(reference.nodes), "local reference has no nodes array"); assert(Array.isArray(reference.progressionChains), "local reference has no progressionChains array"); assert(Array.isArray(reference.spells), "local reference has no spells array"); assert(Array.isArray(reference.sources), "local reference has no sources array"); assert(Array.isArray(supplements.classes), "pinned supplements have no classes array"); assert(supplements.source?.revision === COA_PROMOTION_ARCHIVE.revision, "pinned supplement revision does not match the approved August archive"); assert(supplements.source?.capturedAt === COA_PROMOTION_ARCHIVE.capturedAt, "pinned supplement capture time does not match the approved August archive"); const canonicalNames = classNameById(previous.classes, "previous classes"); const previousClassIds = [...canonicalNames.keys()].sort(compareNumbers); assert(isDeepStrictEqual(previousClassIds, EXPECTED_COA_CLASS_IDS), "previous CoA classes must contain canonical IDs 12 through 32"); const referenceNames = classNameById(reference.classes, "reference classes"); const archiveNames = classNameById(supplements.classes, "supplement classes"); for (const classId of EXPECTED_COA_CLASS_IDS) { const canonicalName = canonicalNames.get(classId); assert(canonicalName, `previous CoA class ${classId} has no canonical className`); assert(referenceNames.get(classId) === canonicalName, `reference class ${classId} does not match canonical name ${canonicalName}`); const archiveClass = supplements.classes.find((definition) => Number(definition.classId) === classId); assert(archiveClass, `pinned archive is missing CoA class ${classId}`); assert(archiveNames.get(classId) === canonicalName, `pinned archive class ${classId} does not match canonical name ${canonicalName}`); assert((archiveClass.builderClassName ?? archiveClass.class) === canonicalName, `pinned builder class name for ${classId} does not match ${canonicalName}`); } assert(supplements.classes.length === EXPECTED_COA_CLASS_IDS.length, "pinned archive must contain exactly 21 CoA classes"); const entries = supplements.classes.flatMap((definition) => definition.nodes.map(normalizeArchiveNode)).sort(compareEntries); assert(entries.length > 0, "pinned archive has no CoA nodes"); assertUniqueById(entries, "pinned archive nodes"); const entryIds = new Set(entries.map((entry) => entry.id)); const priorEntryIds = assertUniqueById(previous.entries, "previous CoA entries"); const oldEntriesById = new Map(previous.entries.map((entry) => [Number(entry.id), entry])); const oldIdsMissing = [...priorEntryIds].filter((id) => !entryIds.has(id)).sort(compareNumbers); const crossClassRewires = previous.entries.flatMap((oldNode) => { const newNode = entries.find((node) => node.id === Number(oldNode.id)); return newNode && newNode.classId !== Number(oldNode.classId) ? [{ id: Number(oldNode.id), beforeClassId: Number(oldNode.classId), afterClassId: newNode.classId }] : []; }); assert(oldIdsMissing.length === 0, `August archive removes July node IDs: ${oldIdsMissing.join(", ")}`); assert(crossClassRewires.length === 0, `August archive rewires node IDs across classes: ${crossClassRewires.map((item) => item.id).join(", ")}`); const classes = previous.classes.map((definition) => { const classId = Number(definition.classId); const archiveClass = supplements.classes.find((item) => Number(item.classId) === classId); const classNodes = entries.filter((node) => node.classId === classId); const nodeTabs = new Set(classNodes.map((node) => node.tabId)); const tabs = (archiveClass.tabs ?? []) .filter((tab) => tab?.isEmpty !== true && Number(tab?.entryCount ?? 1) > 0 && nodeTabs.has(Number(tab.tabId))) .map((tab) => ({ tabId: Number(tab.tabId), tabName: String(tab.tabName ?? tab.name ?? ""), sortOrder: numberOrZero(tab.sortOrder), })) .sort((left, right) => left.sortOrder - right.sortOrder || left.tabId - right.tabId); assert(tabs.length > 0, `pinned archive class ${classId} has no nonempty tabs`); const tabIds = new Set(tabs.map((tab) => tab.tabId)); assert(classNodes.every((node) => tabIds.has(node.tabId)), `pinned archive class ${classId} contains a node outside a nonempty tab`); return { ...clone(definition), tabs }; }); const priorChainIds = new Set(previous.progressionChains.map((chain) => String(chain.id))); assert(priorChainIds.size === previous.progressionChains.length, "previous progression chains contain duplicate IDs"); const oldChainById = new Map(previous.progressionChains.map((chain) => [String(chain.id), chain])); const progressionChains = clone(previous.progressionChains); const referenceSpellById = mapOfSpells(reference.spells, "reference spells"); const oldSpellById = mapOfSpells(previous.spells, "previous spells"); const classicProtection = classicProtectionClosure({ previousSpells: previous.spells, classicChains: previous.progressionChains, classicTriggeredSpells: previous.classicTriggeredSpells ?? [], }); const protectedSpellIds = classicProtection.protectedIds; const rankOwners = rankOwnerMap(entries); const coaOwnersBySpellId = closureForCoaSpells({ entries, chains: progressionChains, referenceSpells: reference.spells, }); const allSpellIds = new Set([ ...oldSpellById.keys(), ...referenceSpellById.keys(), ...rankOwners.keys(), ...[...coaOwnersBySpellId.keys()], ...progressionChains.flatMap((chain) => chainSpellIds(chain)), ]); const sortedSpellIds = [...allSpellIds].sort(compareNumbers); const spells = []; const coaSpellOverrides = []; for (const id of sortedSpellIds) { const previousSpell = oldSpellById.get(id); const referenceSpell = referenceSpellById.get(id); const ownerList = rankOwners.get(id) ?? []; const nodeOwner = firstNodeOwner(ownerList); const coaOwners = [...(coaOwnersBySpellId.get(id) ?? [])].sort(compareNumbers); if (protectedSpellIds.has(id) && previousSpell) { spells.push(clone(previousSpell)); } else if (referenceSpell || previousSpell || nodeOwner) { spells.push(referenceSpellWrapper({ id, referenceSpell, previousSpell, owners: coaOwners, nodeOwner })); } if (protectedSpellIds.has(id) && coaOwners.length > 0) { coaSpellOverrides.push(referenceSpellWrapper({ id, referenceSpell, previousSpell, owners: coaOwners, nodeOwner, })); } } coaSpellOverrides.sort((left, right) => left.id - right.id); const candidate = { source: makeSource(previous, reference, supplements), counts: {}, classes, entries, progressionChains, spells, ...(coaSpellOverrides.length ? { coaSpellOverrides } : {}), }; candidate.coaSpellOverrides ??= []; candidate.counts = makeCounts(candidate, previous); const coaChainSpellIds = new Set(progressionChains .filter((chain) => Number(chain.classId) > CLASSIC_CLASS_MAX) .flatMap((chain) => chainSpellIds(chain))); const coaRequiredIds = new Set([...coaOwnersBySpellId.keys(), ...coaChainSpellIds]); const missingBaseSpellIds = [...coaRequiredIds] .filter((id) => !referenceSpellById.get(id)?.dbc) .sort(compareNumbers); const missingBaseProgressionRankIds = [...coaChainSpellIds] .filter((id) => !referenceSpellById.get(id)?.dbc) .sort(compareNumbers); const previousSpellIds = new Set(oldSpellById.keys()); const candidateSpellIds = new Set(spells.map((spell) => Number(spell.id))); const addedSpellIds = [...candidateSpellIds].filter((id) => !previousSpellIds.has(id)).sort(compareNumbers); const removedSpellIds = [...previousSpellIds].filter((id) => !candidateSpellIds.has(id)).sort(compareNumbers); const changedSpells = []; for (const spell of spells) { const before = oldSpellById.get(Number(spell.id)); if (before && !isDeepStrictEqual(before, spell)) { const fields = changedFields(before, spell); changedSpells.push({ id: Number(spell.id), changedFields: fields, changes: changedValues(before, spell, fields) }); } } const oldEntryById = oldEntriesById; const newEntryById = new Map(entries.map((entry) => [entry.id, entry])); const addedNodeIds = [...newEntryById.keys()].filter((id) => !oldEntryById.has(id)).sort(compareNumbers); const removedNodeIds = [...oldEntryById.keys()].filter((id) => !newEntryById.has(id)).sort(compareNumbers); const changedNodes = []; for (const node of entries) { const before = oldEntryById.get(node.id); if (before && !isDeepStrictEqual(before, node)) { changedNodes.push({ id: node.id, classId: node.classId, changedFields: changedFields(before, node), before: clone(before), after: clone(node) }); } } const overrideChanges = coaSpellOverrides.map((after) => { const before = oldSpellById.get(after.id) ?? null; const fields = before ? changedFields(before, after) : Object.keys(after).sort(); return { id: after.id, protectedClassicRecord: Boolean(before), classIds: clone(after.classIds), description: after.description, hasBaseDbc: Boolean(after.dbc), changedFields: fields, changes: before ? changedValues(before, after, fields) : undefined, }; }); const report = { schemaVersion: 1, status: "validated", sources: { previous: clone(previous.source ?? null), reference: { baseSpellDbc: clone(reference.sources.find((source) => source.id === "global-spell-base") ?? null), classSources: clone(reference.sources.filter((source) => /class|skill-line/i.test(`${source.id} ${source.role}`))), }, archive: clone(supplements.source), }, preserveChecks: {}, nodeChanges: { previousCount: previous.entries.length, promotedCount: entries.length, pinnedArchiveExpectedCount: 3618, pinnedArchiveCountMatches: entries.length === 3618, added: addedNodeIds.map((id) => clone(newEntryById.get(id))), removed: removedNodeIds.map((id) => clone(oldEntryById.get(id))), changed: changedNodes, unexpectedRemovals: removedNodeIds, crossClassRewires, }, spellChanges: { previousCount: previous.spells.length, promotedCount: spells.length, added: addedSpellIds.map((id) => spellSummary(spells.find((spell) => spell.id === id))), removed: removedSpellIds.map((id) => spellSummary(oldSpellById.get(id))), changed: changedSpells, coaOverrides: overrideChanges, protectedClassicIdCount: protectedSpellIds.size, coaContextSpellCount: coaOwnersBySpellId.size, sharedProtectedIds: overrideChanges.map((item) => item.id), }, gaps: { missingBaseSpellIds, missingBaseSpellCount: missingBaseSpellIds.length, missingBaseProgressionRankIds, missingBaseProgressionRankCount: missingBaseProgressionRankIds.length, }, provenance: { realmSlug: COA_PROMOTION_ARCHIVE.realmSlug, archiveRevision: supplements.source.revision, archiveCapturedAt: supplements.source.capturedAt, coaBaseSpellPath: COA_PROMOTION_ARCHIVE.coaBaseSpellPath, classicSpellPath: candidate.source.spellDbcSources.classic.path, coaSkillLineAbilityDbcPath: candidate.source.coaSkillLineAbilityDbcPath, fixedCaptureTime: true, generationTimestampAdded: false, }, }; report.preserveChecks = preserveChecks({ previous, candidate, oldEntriesById, oldChainById, oldSpellById, protectedSpellIds, classicReachableSpellIds: classicProtection.referencedIds, coaOwnersBySpellId, }); const failedChecks = Object.entries(report.preserveChecks).filter(([, result]) => !result.passed).map(([name]) => name); if (failedChecks.length) { report.status = "blocked"; throw validationError(`CoA promotion failed preservation checks: ${failedChecks.join(", ")}`, report); } const invalidDbc = candidate.spells.filter((spell) => spell.dbc && Number(spell.dbc.id) !== Number(spell.id)); const invalidOverrideDbc = candidate.coaSpellOverrides.filter((spell) => spell.dbc && Number(spell.dbc.id) !== Number(spell.id)); if (invalidDbc.length || invalidOverrideDbc.length) { report.status = "blocked"; throw validationError(`CoA promotion contains mismatched BASE DBC rows for IDs: ${[...invalidDbc, ...invalidOverrideDbc].map((spell) => spell.id).join(", ")}`, report); } return { candidate, report }; }