Files
healer-man/scripts/coa-reference/promote.mjs
T

771 lines
34 KiB
JavaScript

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(/<br\s*\/?>/gi, " ")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#0*39;|&apos;/gi, "'")
.replace(/\s+/g, " ")
.trim();
}
function 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 };
}