771 lines
34 KiB
JavaScript
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(/ /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 };
|
|
}
|