Files
healer-man/scripts/sync-ascension-coa.mjs
2026-10-06 22:25:34 -04:00

988 lines
40 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { spellDbcEquipmentMetadata } from "./spellDbcEquipmentMetadata.mjs";
import {
collectRealmSpellIds,
expandCoaSpellIds,
resolveCoaSourcePaths,
sharedRealmSpellIds,
} from "./coa-source-policy.mjs";
const projectRoot = path.resolve(import.meta.dirname, "..");
const outputPath = path.join(projectRoot, "src", "game", "coaLiveData.generated.json");
const tooltipCachePath = path.join(projectRoot, "data", "coa-tooltip-cache.json");
const extractedClientRoot = path.resolve(
projectRoot,
"..",
"LadiksMPQEditor",
"client-current",
);
const cliArguments = process.argv.slice(2);
const equipmentMetadataOnly = cliArguments.includes("--equipment-metadata-only");
const fetchLiveTooltips = process.env.ASCENSION_COA_FETCH_TOOLTIPS === "1"
|| cliArguments.includes("--tooltips");
const spellDbcArgument = cliArguments.find((argument) => (
argument !== "--tooltips" && argument !== "--equipment-metadata-only"
));
const legacyExtractedRoot = path.resolve(projectRoot, "..", "LadiksMPQEditor");
const sourcePaths = resolveCoaSourcePaths({
extractedClientRoot,
legacyExtractedRoot,
classicSpellOverride: spellDbcArgument,
});
const classicSpellDbcPath = sourcePaths.classicSpellDbcPath;
const coaSpellDbcPath = sourcePaths.coaSpellDbcPath;
const legacySupportDbcDirectory = path.resolve(
projectRoot,
"..",
"wow335a",
"dungeon-export-work",
"HealerMan",
"talent-assets",
"dbc",
);
const currentSupportDbcDirectory = path.join(extractedClientRoot, "patch-S", "DBFilesClient");
const supportDbcDirectory = fs.existsSync(path.join(currentSupportDbcDirectory, "SpellCastTimes.dbc"))
? currentSupportDbcDirectory
: legacySupportDbcDirectory;
const builderUrl = "https://api.ascension.gg/api/v3/builder/coa";
const databaseClassUrl = "https://db.ascension.gg/?class=";
const realmSlug = process.env.ASCENSION_COA_REALM ?? "voljin";
const classicClassIds = [1, 2, 3, 4, 5, 6, 7, 8, 9, 11];
const coaClassIds = Array.from({ length: 21 }, (_, index) => index + 12);
const databaseClassIds = [...classicClassIds, ...coaClassIds];
const expectedClassNames = new Map([
[12, "Barbarian"],
[13, "Witch Doctor"],
[14, "Felsworn"],
[15, "Witch Hunter"],
[16, "Stormbringer"],
[17, "Knight of Xoroth"],
[18, "Guardian"],
[19, "Templar"],
[20, "Bloodmage"],
[21, "Ranger"],
[22, "Chronomancer"],
[23, "Necromancer"],
[24, "Pyromancer"],
[25, "Cultist"],
[26, "Starcaller"],
[27, "Sun Cleric"],
[28, "Tinker"],
[29, "Venomancer"],
[30, "Reaper"],
[31, "Primalist"],
[32, "Runemaster"],
]);
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function canonicalJson(value) {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
function withoutEquipmentMetadataAndTimestamp(value) {
const copy = JSON.parse(JSON.stringify(value));
if (copy.source) delete copy.source.synchronizedAt;
for (const row of [...(copy.spells ?? []), ...(copy.coaSpellOverrides ?? [])]) {
if (!row.dbc) continue;
delete row.dbc.equippedItemClass;
delete row.dbc.equippedItemSubclassMask;
delete row.dbc.equippedItemInventoryMask;
}
return copy;
}
function metadataProfileForSpell(row, sourceClassesBySpellId) {
const classIds = [
...(Array.isArray(row.classIds) ? row.classIds.map(Number) : []),
...(sourceClassesBySpellId.get(Number(row.id)) ?? []),
];
if (classIds.some((classId) => classicClassIds.includes(classId))) return "classic";
if (classIds.some((classId) => coaClassIds.includes(classId))) return "coa";
return null;
}
function assertEquipmentMetadataSourceProfile(data) {
const profiles = data?.source?.spellDbcSources;
assert(profiles?.classic?.sourceId === "classic-area52-preserved", "Generated CoA data is not bound to the pinned classic Area 52 Spell.dbc profile");
assert(profiles?.coaBase?.sourceId === "global-spell-base", "Generated CoA data is not bound to the pinned global CoA Spell.dbc profile");
const classicProfilePath = path.resolve(extractedClientRoot, "area-52", "patch-D", "DBFilesClient", "Spell.dbc");
const coaProfilePath = path.resolve(extractedClientRoot, "patch-T", "DBFilesClient", "Spell.dbc");
assert(path.resolve(classicSpellDbcPath).toLowerCase() === classicProfilePath.toLowerCase(), `Classic Spell.dbc resolved outside the pinned profile: ${classicSpellDbcPath}`);
assert(path.resolve(coaSpellDbcPath).toLowerCase() === coaProfilePath.toLowerCase(), `CoA Spell.dbc resolved outside the pinned profile: ${coaSpellDbcPath}`);
const storedClassicPath = String(profiles.classic.path ?? "").replaceAll("\\", "/").toLowerCase();
const storedCoaPath = String(profiles.coaBase.path ?? "").replaceAll("\\", "/").toLowerCase();
assert(storedClassicPath.includes("/area-52/patch-d/dbfilesclient/spell.dbc"), "Generated classic source path does not identify Area 52 patch-D");
assert(storedCoaPath.includes("/patch-t/dbfilesclient/spell.dbc"), "Generated CoA source path does not identify global patch-T");
}
function applyEquipmentMetadataRefresh(data, classicMetadata, coaMetadata, synchronizedAt) {
assertEquipmentMetadataSourceProfile(data);
const counts = { classic: 0, coa: 0, skippedUnprofiled: 0, missingDbc: 0, missingSpell: 0 };
const sourceClassesBySpellId = new Map();
const addSourceClass = (spellId, classId) => {
const classes = sourceClassesBySpellId.get(Number(spellId)) ?? new Set();
classes.add(Number(classId));
sourceClassesBySpellId.set(Number(spellId), classes);
};
for (const chain of data.progressionChains ?? []) {
if (!Number.isFinite(Number(chain.classId))) continue;
for (const rank of chain.ranks ?? []) addSourceClass(rank.spellId, chain.classId);
}
for (const entry of data.entries ?? []) {
if (!Number.isFinite(Number(entry.classId))) continue;
for (const rank of entry.rankDescriptions ?? []) addSourceClass(rank.spellId, entry.classId);
}
for (const row of data.spells ?? []) {
if (!row.dbc) {
counts.missingDbc += 1;
continue;
}
const profile = metadataProfileForSpell(row, sourceClassesBySpellId);
if (!profile) {
counts.skippedUnprofiled += 1;
continue;
}
const metadata = (profile === "classic" ? classicMetadata : coaMetadata).get(Number(row.id));
if (!metadata) {
counts.missingSpell += 1;
continue;
}
Object.assign(row.dbc, metadata);
counts[profile] += 1;
}
for (const row of data.coaSpellOverrides ?? []) {
if (!row.dbc) {
counts.missingDbc += 1;
continue;
}
if (!Array.isArray(row.classIds) || !row.classIds.some((classId) => coaClassIds.includes(Number(classId)))) {
counts.skippedUnprofiled += 1;
continue;
}
const metadata = coaMetadata.get(Number(row.id));
if (!metadata) {
counts.missingSpell += 1;
continue;
}
Object.assign(row.dbc, metadata);
counts.coa += 1;
}
assert(data.source && typeof data.source === "object", "Generated CoA data has no source metadata");
data.source.synchronizedAt = synchronizedAt;
return counts;
}
function refreshEquipmentMetadataOnly() {
assert(spellDbcArgument === undefined, "--equipment-metadata-only does not accept a Spell.dbc path override");
assert(fs.existsSync(classicSpellDbcPath), `Classic Spell.dbc was not found at ${classicSpellDbcPath}`);
assert(fs.existsSync(coaSpellDbcPath), `CoA base Spell.dbc was not found at ${coaSpellDbcPath}`);
const classicMetadata = spellDbcEquipmentMetadata(fs.readFileSync(classicSpellDbcPath));
const coaMetadata = spellDbcEquipmentMetadata(fs.readFileSync(coaSpellDbcPath));
for (let attempt = 1; attempt <= 5; attempt += 1) {
assert(fs.existsSync(outputPath), `Generated CoA data was not found at ${outputPath}`);
const originalBuffer = fs.readFileSync(outputPath);
const originalHash = crypto.createHash("sha256").update(originalBuffer).digest("hex");
const data = JSON.parse(originalBuffer.toString("utf8"));
const counts = applyEquipmentMetadataRefresh(data, classicMetadata, coaMetadata, new Date().toISOString());
const original = JSON.parse(originalBuffer.toString("utf8"));
assert(
canonicalJson(withoutEquipmentMetadataAndTimestamp(original))
=== canonicalJson(withoutEquipmentMetadataAndTimestamp(data)),
"Equipment metadata refresh changed fields outside the three equipment values and source timestamp",
);
const latestHash = crypto.createHash("sha256").update(fs.readFileSync(outputPath)).digest("hex");
if (latestHash !== originalHash) continue;
fs.writeFileSync(outputPath, `${JSON.stringify(data)}\n`, "utf8");
const writtenHash = crypto.createHash("sha256").update(fs.readFileSync(outputPath)).digest("hex");
console.log(JSON.stringify({
mode: "equipment-metadata-only",
outputPath,
sourcePaths: {
classicSpellDbcPath: path.resolve(classicSpellDbcPath),
coaSpellDbcPath: path.resolve(coaSpellDbcPath),
},
sourceHashes: {
classic: crypto.createHash("sha256").update(fs.readFileSync(classicSpellDbcPath)).digest("hex"),
coa: crypto.createHash("sha256").update(fs.readFileSync(coaSpellDbcPath)).digest("hex"),
},
counts,
beforeSha256: originalHash,
afterSha256: writtenHash,
}, null, 2));
return;
}
throw new Error("Generated CoA data changed during five metadata refresh attempts; no output was written");
}
async function fetchText(url, attempts = 4) {
let lastError = null;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const response = await fetch(url, {
headers: {
Accept: "application/json,text/html;q=0.9,*/*;q=0.8",
Origin: "https://ascension.gg",
Referer: "https://ascension.gg/",
"User-Agent": "Healer-Man CoA data synchronizer",
},
});
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
return await response.text();
} catch (error) {
lastError = error;
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, attempt * 350));
}
}
throw new Error(`Failed to fetch ${url}: ${lastError}`);
}
function parseSpellListview(html, classId) {
const marker = 'new Listview({"template":"spell"';
const listviewStart = html.indexOf(marker);
assert(listviewStart >= 0, `Class ${classId} did not contain the spell Listview`);
const dataMarker = html.indexOf('"data":', listviewStart);
const arrayStart = html.indexOf("[", dataMarker);
assert(dataMarker >= 0 && arrayStart >= 0, `Class ${classId} spell Listview had no data array`);
let depth = 0;
let quoted = false;
let escaped = false;
for (let index = arrayStart; index < html.length; index += 1) {
const character = html[index];
if (quoted) {
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === '"') quoted = false;
continue;
}
if (character === '"') quoted = true;
else if (character === "[") depth += 1;
else if (character === "]") {
depth -= 1;
if (depth === 0) return JSON.parse(html.slice(arrayStart, index + 1));
}
}
throw new Error(`Class ${classId} spell Listview data array was not terminated`);
}
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 cleanClientText(value = "") {
return String(value)
.replace(/\|c[0-9a-f]{8}/gi, "")
.replace(/\|r/gi, "")
.replace(/[\r\n]+/g, " ")
.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 isDeprecated(value) {
return /\b(?:deprecated|depprecated|deprectaed|unused|nyi|wip|test|old)\b/i.test(value);
}
function parseSpellTooltip(html, spellId) {
const expression = new RegExp(`_\\[${spellId}\\]\\.tooltip_enus\\s*=\\s*("(?:\\\\.|[^"\\\\])*")`);
const match = html.match(expression);
if (!match) return "";
try {
return cleanHtml(JSON.parse(match[1]));
} catch {
return "";
}
}
async function mapConcurrent(values, concurrency, mapper) {
const results = new Array(values.length);
let cursor = 0;
async function worker() {
while (cursor < values.length) {
const index = cursor;
cursor += 1;
results[index] = await mapper(values[index], index);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, worker));
return results;
}
function dbcNumberTable(filePath, valueField, floating = false) {
if (!fs.existsSync(filePath)) return new Map();
const buffer = fs.readFileSync(filePath);
assert(buffer.toString("ascii", 0, 4) === "WDBC", `${path.basename(filePath)} is not a WDBC file`);
const recordCount = buffer.readUInt32LE(4);
const recordSize = buffer.readUInt32LE(12);
return new Map(Array.from({ length: recordCount }, (_, index) => {
const base = 20 + index * recordSize;
return [
buffer.readUInt32LE(base),
floating
? buffer.readFloatLE(base + valueField * 4)
: buffer.readInt32LE(base + valueField * 4),
];
}));
}
function dbcRangeTable(filePath) {
if (!fs.existsSync(filePath)) return new Map();
const buffer = fs.readFileSync(filePath);
assert(buffer.toString("ascii", 0, 4) === "WDBC", `${path.basename(filePath)} is not a WDBC file`);
const recordCount = buffer.readUInt32LE(4);
const recordSize = buffer.readUInt32LE(12);
return new Map(Array.from({ length: recordCount }, (_, index) => {
const base = 20 + index * recordSize;
return [
buffer.readUInt32LE(base),
{
min: Math.max(0, buffer.readFloatLE(base + 4), buffer.readFloatLE(base + 8)),
max: Math.max(0, buffer.readFloatLE(base + 12), buffer.readFloatLE(base + 16)),
},
];
}));
}
function dbcSkillLines(filePath) {
if (!fs.existsSync(filePath)) return new Map();
const buffer = fs.readFileSync(filePath);
assert(buffer.toString("ascii", 0, 4) === "WDBC", `${path.basename(filePath)} is not a WDBC file`);
const recordCount = buffer.readUInt32LE(4);
const recordSize = buffer.readUInt32LE(12);
const spellSkillLines = new Map();
for (let index = 0; index < recordCount; index += 1) {
const base = 20 + index * recordSize;
const skillLineId = buffer.readUInt32LE(base + 4);
const spellId = buffer.readUInt32LE(base + 8);
const skillLines = spellSkillLines.get(spellId) ?? [];
if (!skillLines.includes(skillLineId)) skillLines.push(skillLineId);
spellSkillLines.set(spellId, skillLines);
}
return spellSkillLines;
}
function dbcRankCandidates(buffer, names, spellSkillLines) {
const recordCount = buffer.readUInt32LE(4);
const recordSize = buffer.readUInt32LE(12);
const stringSize = buffer.readUInt32LE(16);
const stringsOffset = 20 + recordCount * recordSize;
const readString = (offset) => {
if (!offset || offset >= stringSize) return "";
const start = stringsOffset + offset;
const end = buffer.indexOf(0, start);
return buffer.toString("utf8", start, end < 0 ? buffer.length : end);
};
const candidates = [];
for (let index = 0; index < recordCount; index += 1) {
const base = 20 + index * recordSize;
const unsigned = (field) => buffer.readUInt32LE(base + field * 4);
let name = "";
for (let locale = 0; locale < 16 && !name; locale += 1) name = cleanName(readString(unsigned(136 + locale)));
if (!names.has(name)) continue;
let rankText = "";
for (let locale = 0; locale < 16 && !rankText; locale += 1) rankText = cleanClientText(readString(unsigned(153 + locale)));
const rank = Number(rankText.match(/^Rank\s+(\d+)$/i)?.[1] ?? 0);
if (!rank) continue;
candidates.push({
id: unsigned(0),
name,
rank,
level: unsigned(39),
skillLineIds: spellSkillLines.get(unsigned(0)) ?? [],
});
}
return candidates;
}
function rankCandidatesForRows(classSpellRows, spellDbcBuffer, skillLineAbilityDbcPath) {
const progressionRows = classSpellRows.filter((spell) => (
!spell.excluded
&& !spell.advancementType
&& /^Rank \d+$/i.test(spell.rank)
&& !isDeprecated(`${spell.name} ${spell.rank}`)
));
const progressionNames = new Set(progressionRows.map((spell) => spell.name));
const progressionSkillLines = new Map();
for (const spell of progressionRows) {
const skillLines = progressionSkillLines.get(spell.name) ?? new Set();
for (const skillLineId of spell.skillLineIds) skillLines.add(skillLineId);
progressionSkillLines.set(spell.name, skillLines);
}
const spellSkillLines = dbcSkillLines(skillLineAbilityDbcPath);
const candidates = dbcRankCandidates(spellDbcBuffer, progressionNames, spellSkillLines)
.filter((candidate) => candidate.skillLineIds.some((skillLineId) => progressionSkillLines.get(candidate.name)?.has(skillLineId)));
return { progressionNames, candidates };
}
function dbcSpells(buffer, wantedIds, castTimes, durations, radii, ranges) {
assert(buffer.toString("ascii", 0, 4) === "WDBC", "Spell.dbc is not a WDBC file");
const recordCount = buffer.readUInt32LE(4);
const fieldCount = buffer.readUInt32LE(8);
const recordSize = buffer.readUInt32LE(12);
const stringSize = buffer.readUInt32LE(16);
assert(fieldCount >= 234 && recordSize >= fieldCount * 4, "Unexpected Spell.dbc schema");
const stringsOffset = 20 + recordCount * recordSize;
const readString = (offset) => {
if (!offset || offset >= stringSize) return "";
const start = stringsOffset + offset;
const end = buffer.indexOf(0, start);
return buffer.toString("utf8", start, end < 0 ? buffer.length : end);
};
const spells = new Map();
for (let index = 0; index < recordCount; index += 1) {
const base = 20 + index * recordSize;
const id = buffer.readUInt32LE(base);
if (!wantedIds.has(id)) continue;
const unsigned = (field) => buffer.readUInt32LE(base + field * 4);
const signed = (field) => buffer.readInt32LE(base + field * 4);
const float = (field) => buffer.readFloatLE(base + field * 4);
const localized = (firstField) => {
for (let locale = 0; locale < 16; locale += 1) {
const value = readString(unsigned(firstField + locale));
if (value) return value;
}
return "";
};
const effects = Array.from({ length: 3 }, (_, effectIndex) => ({
index: effectIndex,
effectType: unsigned(71 + effectIndex),
auraType: unsigned(95 + effectIndex),
basePoints: signed(80 + effectIndex) + 1,
dieSides: signed(74 + effectIndex),
pointsPerLevel: float(77 + effectIndex),
mechanic: unsigned(83 + effectIndex),
implicitTargetA: unsigned(86 + effectIndex),
implicitTargetB: unsigned(89 + effectIndex),
radiusIndex: unsigned(92 + effectIndex),
radius: radii.get(unsigned(92 + effectIndex)) ?? 0,
periodMs: unsigned(98 + effectIndex),
valueMultiplier: float(101 + effectIndex),
chainTargets: unsigned(104 + effectIndex),
itemType: unsigned(107 + effectIndex),
miscValue: signed(110 + effectIndex),
miscValueB: signed(113 + effectIndex),
triggerSpellId: unsigned(116 + effectIndex),
pointsPerCombo: float(119 + effectIndex),
coefficient: float(229 + effectIndex),
})).filter((effect) => (
effect.effectType
|| effect.auraType
|| effect.basePoints !== 1
|| effect.triggerSpellId
));
spells.set(id, {
id,
name: cleanClientText(localized(136)),
rankText: cleanClientText(localized(153)),
description: cleanClientText(localized(170) || localized(187)),
categoryId: unsigned(1),
dispelType: unsigned(2),
mechanic: unsigned(3),
attributes: Array.from({ length: 8 }, (_, attribute) => unsigned(4 + attribute)),
castTimeIndex: unsigned(28),
castTimeMs: Math.max(0, castTimes.get(unsigned(28)) ?? 0),
recoveryTimeMs: unsigned(29),
categoryRecoveryTimeMs: unsigned(30),
interruptFlags: unsigned(31),
auraInterruptFlags: unsigned(32),
channelInterruptFlags: unsigned(33),
procFlags: unsigned(34),
procChance: unsigned(35),
procCharges: unsigned(36),
maxLevel: unsigned(37),
baseLevel: unsigned(38),
spellLevel: unsigned(39),
durationIndex: unsigned(40),
durationMs: durations.get(unsigned(40)) ?? 0,
powerType: signed(41),
powerCost: unsigned(42),
powerCostPerLevel: unsigned(43),
powerCostPerSecond: unsigned(44),
powerCostPerSecondPerLevel: unsigned(45),
rangeIndex: unsigned(46),
rangeMin: ranges.get(unsigned(46))?.min ?? 0,
rangeMax: ranges.get(unsigned(46))?.max ?? 0,
projectileSpeed: float(47),
stackAmount: unsigned(49),
equippedItemClass: signed(68),
equippedItemSubclassMask: signed(69),
equippedItemInventoryMask: unsigned(70),
iconId: unsigned(133),
powerCostPercentage: unsigned(204),
startRecoveryCategory: unsigned(205),
startRecoveryTimeMs: unsigned(206),
maximumTargets: unsigned(212),
damageClass: unsigned(213),
preventionType: unsigned(214),
schoolMask: unsigned(225),
runeCostId: unsigned(226),
powerDisplayId: unsigned(228),
effects,
});
}
return spells;
}
function slimClassSpell(row, classId) {
return {
id: Number(row.id),
classId,
name: cleanName(row.name),
iconBasename: iconBasename(row.icon),
level: Number(row.level) || 0,
rank: String(row.rank ?? ""),
schoolMask: Number(row.school) || 0,
categoryId: Number(row.cat) || 0,
trainingCost: Number(row.trainingcost) || 0,
skillLineIds: Array.isArray(row.skill) ? row.skill.map(Number) : [],
sourceIds: Array.isArray(row.source) ? row.source.map(Number) : [],
excluded: Boolean(row.excluded),
talented: Boolean(row.talented),
talentedSpell: Number(row.talentedSpell) || 0,
advancementType: String(row.advType ?? ""),
advancementClassId: Number(row.advClass) || 0,
advancementTabId: Number(row.advTab) || 0,
advancementRequiredLevel: Number(row.advRequiredLevelWildcard) || 0,
isCoaClass: Boolean(row.isCoaClass),
spellFamilyId: Number(row.spellFamilyId) || 0,
};
}
function slimEntry(entry) {
return {
id: Number(entry.id),
classId: Number(entry.classId),
tabId: Number(entry.tabId),
x: Number(entry.x),
y: Number(entry.y),
name: cleanName(entry.name),
iconBasename: iconBasename(entry.iconPath),
nodeType: String(entry.nodeType),
entryType: String(entry.entryType),
isPassive: Boolean(entry.isPassive),
maxPoints: Number(entry.maxPoints) || Math.max(1, entry.spellIds?.length ?? 1),
aeCost: Number(entry.aeCost) || 0,
teCost: Number(entry.teCost) || 0,
requiredClassPoints: Number(entry.reqTabAE) || 0,
requiredSpecPoints: Number(entry.reqTabTE) || 0,
requiredIds: (entry.requiredIds ?? []).map(Number).filter(Boolean),
connectedNodeIds: (entry.connectedNodeIds ?? []).map(Number).filter(Boolean),
requiredLevel: Number(entry.requiredLevel) || 0,
isStartingNode: Boolean(entry.isStartingNode),
flags: Number(entry.flags) || 0,
group: Number(entry.group) || 0,
sortOrder: Number(entry.sortOrder) || 0,
description: cleanHtml(entry.description),
rankDescriptions: (entry.rankDescriptions ?? []).map((rank) => ({
rank: Number(rank.rank),
spellId: Number(rank.spellId),
description: cleanHtml(rank.description),
})),
};
}
if (equipmentMetadataOnly) {
refreshEquipmentMetadataOnly();
process.exit(0);
}
const builderPayload = JSON.parse(await fetchText(builderUrl));
assert(Array.isArray(builderPayload), "CoA builder response was not an array");
const realm = builderPayload.find((candidate) => candidate.slug === realmSlug);
assert(realm, `CoA builder did not contain realm ${realmSlug}`);
assert(realm.talents?.classes?.length === 21, `Expected 21 CoA classes, received ${realm.talents?.classes?.length}`);
const classPages = await Promise.all(databaseClassIds.map(async (classId) => {
const rows = parseSpellListview(await fetchText(`${databaseClassUrl}${classId}`), classId);
return [classId, rows];
}));
const classSpellRows = classPages.flatMap(([classId, rows]) => rows.map((row) => slimClassSpell(row, classId)));
assert(classSpellRows.length >= 5_000, `Only received ${classSpellRows.length} class spell rows`);
const entries = Object.values(realm.talents.entriesByTab)
.flat()
.map(slimEntry)
.sort((left, right) => left.classId - right.classId || left.tabId - right.tabId || left.y - right.y || left.x - right.x || left.id - right.id);
assert(entries.length >= 3_500, `Only received ${entries.length} CoA tree entries`);
assert(fs.existsSync(classicSpellDbcPath), `Classic Spell.dbc was not found at ${classicSpellDbcPath}`);
assert(fs.existsSync(coaSpellDbcPath), `CoA base Spell.dbc was not found at ${coaSpellDbcPath}`);
assert(fs.existsSync(sourcePaths.classicSkillLineAbilityDbcPath), `Classic SkillLineAbility.dbc was not found at ${sourcePaths.classicSkillLineAbilityDbcPath}`);
assert(fs.existsSync(sourcePaths.coaSkillLineAbilityDbcPath), `CoA SkillLineAbility.dbc was not found at ${sourcePaths.coaSkillLineAbilityDbcPath}`);
const classicSpellDbcBuffer = fs.readFileSync(classicSpellDbcPath);
const coaSpellDbcBuffer = fs.readFileSync(coaSpellDbcPath);
const classicProgression = rankCandidatesForRows(
classSpellRows.filter((spell) => classicClassIds.includes(spell.classId)),
classicSpellDbcBuffer,
sourcePaths.classicSkillLineAbilityDbcPath,
);
const coaProgression = rankCandidatesForRows(
classSpellRows.filter((spell) => coaClassIds.includes(spell.classId)),
coaSpellDbcBuffer,
sourcePaths.coaSkillLineAbilityDbcPath,
);
const progressionNames = new Set([...classicProgression.progressionNames, ...coaProgression.progressionNames]);
const classicRankCandidates = classicProgression.candidates;
const coaRankCandidates = coaProgression.candidates;
const rankCandidates = [...classicRankCandidates, ...coaRankCandidates];
const coaEntrySpellIds = entries.flatMap((entry) => entry.rankDescriptions.map((rank) => rank.spellId));
const { classicIds: classicSpellIds, coaIds: coaRootSpellIds } = collectRealmSpellIds({
classicClassIds,
coaClassIds,
classSpellRows,
classicRankCandidates,
coaRankCandidates,
coaEntrySpellIds,
});
const coaSpellIds = expandCoaSpellIds(coaSpellDbcBuffer, coaRootSpellIds);
const wantedSpellIds = new Set([
...classSpellRows.map((spell) => spell.id),
...coaEntrySpellIds,
...rankCandidates.map((candidate) => candidate.id),
...coaSpellIds,
]);
const castTimes = dbcNumberTable(path.join(supportDbcDirectory, "SpellCastTimes.dbc"), 1);
const durations = dbcNumberTable(path.join(supportDbcDirectory, "SpellDuration.dbc"), 3);
const radii = dbcNumberTable(path.join(supportDbcDirectory, "SpellRadius.dbc"), 3, true);
const ranges = dbcRangeTable(path.join(supportDbcDirectory, "SpellRange.dbc"));
const classicDbcById = dbcSpells(classicSpellDbcBuffer, classicSpellIds, castTimes, durations, radii, ranges);
const coaDbcById = dbcSpells(coaSpellDbcBuffer, coaSpellIds, castTimes, durations, radii, ranges);
const dbcById = new Map();
for (const spellId of wantedSpellIds) {
const dbc = classicSpellIds.has(spellId)
? classicDbcById.get(spellId)
: coaDbcById.get(spellId);
if (dbc) dbcById.set(spellId, dbc);
}
const entryRankBySpellId = new Map(entries.flatMap((entry) => (
entry.rankDescriptions.map((rank) => [rank.spellId, { ...rank, entryId: entry.id }])
)));
const previousData = fs.existsSync(outputPath)
? JSON.parse(fs.readFileSync(outputPath, "utf8"))
: null;
const previousTooltips = new Map(
(previousData?.spells ?? [])
.filter((spell) => spell.liveTooltip)
.map((spell) => [Number(spell.id), spell.liveTooltip]),
);
if (fs.existsSync(tooltipCachePath)) {
for (const [spellId, tooltip] of Object.entries(JSON.parse(fs.readFileSync(tooltipCachePath, "utf8")))) {
if (tooltip) previousTooltips.set(Number(spellId), tooltip);
}
}
const progressionTooltipIds = [...new Set([
...classSpellRows
.filter((spell) => progressionNames.has(spell.name) && /^Rank \d+$/i.test(spell.rank))
.map((spell) => spell.id),
...classicRankCandidates.map((candidate) => candidate.id),
])].filter((id) => classicSpellIds.has(id));
if (fetchLiveTooltips) {
const uncached = progressionTooltipIds.filter((spellId) => !previousTooltips.has(spellId));
console.log(`Fetching ${uncached.length} live spell tooltips (${previousTooltips.size} cached)`);
const fetched = await mapConcurrent(uncached, 16, async (spellId, index) => {
let tooltip = "";
try {
tooltip = parseSpellTooltip(await fetchText(`https://db.ascension.gg/?spell=${spellId}`), spellId);
if (tooltip) previousTooltips.set(spellId, tooltip);
} catch (error) {
console.warn(`Skipping live tooltip ${spellId}: ${error}`);
}
if ((index + 1) % 100 === 0 || index + 1 === uncached.length) {
console.log(`Fetched ${index + 1}/${uncached.length} live spell tooltips`);
fs.mkdirSync(path.dirname(tooltipCachePath), { recursive: true });
fs.writeFileSync(tooltipCachePath, `${JSON.stringify(Object.fromEntries(previousTooltips))}\n`, "utf8");
}
return [spellId, tooltip];
});
for (const [spellId, tooltip] of fetched) {
if (tooltip) previousTooltips.set(spellId, tooltip);
}
fs.mkdirSync(path.dirname(tooltipCachePath), { recursive: true });
fs.writeFileSync(tooltipCachePath, `${JSON.stringify(Object.fromEntries(previousTooltips))}\n`, "utf8");
}
const spellClassIds = new Map();
const coaSpellClassIds = new Map();
for (const spell of classSpellRows) {
const existing = spellClassIds.get(spell.id) ?? new Set();
existing.add(spell.classId);
spellClassIds.set(spell.id, existing);
if (coaClassIds.includes(spell.classId)) {
const coaExisting = coaSpellClassIds.get(spell.id) ?? new Set();
coaExisting.add(spell.classId);
coaSpellClassIds.set(spell.id, coaExisting);
}
}
const classRowBySpellId = new Map(classSpellRows.map((spell) => [spell.id, spell]));
const coaClassSpellRows = classSpellRows.filter((spell) => coaClassIds.includes(spell.classId));
const coaClassRowBySpellId = new Map(coaClassSpellRows.map((spell) => [spell.id, spell]));
const coaEntryBySpellId = new Map();
for (const entry of entries) {
for (const rank of entry.rankDescriptions) {
coaSpellClassIds.set(rank.spellId, (coaSpellClassIds.get(rank.spellId) ?? new Set()).add(entry.classId));
if (!coaEntryBySpellId.has(rank.spellId)) coaEntryBySpellId.set(rank.spellId, entry);
}
}
for (const candidate of coaRankCandidates) {
for (const spell of coaClassSpellRows) {
if (spell.name !== candidate.name || !spell.skillLineIds.some((id) => candidate.skillLineIds.includes(id))) continue;
coaSpellClassIds.set(candidate.id, (coaSpellClassIds.get(candidate.id) ?? new Set()).add(spell.classId));
}
}
const spells = [...wantedSpellIds]
.sort((left, right) => left - right)
.map((id) => {
const classRow = classRowBySpellId.get(id);
const entryRank = entryRankBySpellId.get(id);
const dbc = dbcById.get(id);
return {
id,
classIds: [...(spellClassIds.get(id) ?? [])].sort((left, right) => left - right),
name: classRow?.name || dbc?.name || "",
iconBasename: classRow?.iconBasename || "",
level: classRow?.level ?? dbc?.spellLevel ?? 0,
rank: classRow?.rank || dbc?.rankText || (entryRank ? `Rank ${entryRank.rank}` : ""),
description: entryRank?.description || dbc?.description || "",
...(classicSpellIds.has(id) && previousTooltips.has(id) ? { liveTooltip: previousTooltips.get(id) } : {}),
sourceIds: classRow?.sourceIds ?? [],
advancementType: classRow?.advancementType || "",
advancementTabId: classRow?.advancementTabId || 0,
talented: classRow?.talented ?? false,
excluded: classRow?.excluded ?? false,
...(dbc ? { dbc } : {}),
};
});
const coaSpellOverrides = sharedRealmSpellIds(classicSpellIds, coaSpellIds)
.map((id) => {
const classRow = coaClassRowBySpellId.get(id);
const entry = coaEntryBySpellId.get(id);
const entryRank = entryRankBySpellId.get(id);
const dbc = coaDbcById.get(id);
return {
id,
classIds: [...(coaSpellClassIds.get(id) ?? [])].sort((left, right) => left - right),
name: classRow?.name || entry?.name || dbc?.name || "",
iconBasename: classRow?.iconBasename || entry?.iconBasename || "",
level: classRow?.level ?? dbc?.spellLevel ?? 0,
rank: classRow?.rank || dbc?.rankText || (entryRank ? `Rank ${entryRank.rank}` : ""),
description: entryRank?.description || dbc?.description || "",
sourceIds: classRow?.sourceIds ?? [],
advancementType: classRow?.advancementType || "",
advancementTabId: classRow?.advancementTabId || 0,
talented: classRow?.talented ?? false,
excluded: classRow?.excluded ?? false,
...(dbc ? { dbc } : {}),
};
});
const progressionChains = [];
for (const [classId, rows] of classPages) {
const eligible = rows
.map((row) => slimClassSpell(row, classId))
.filter((spell) => (
!spell.excluded
&& !spell.advancementType
&& /^Rank \d+$/i.test(spell.rank)
&& !isDeprecated(`${spell.name} ${spell.rank}`)
));
const bySignature = new Map();
for (const spell of eligible) {
const rank = Number(spell.rank.match(/\d+/)?.[0] ?? 1);
const skillLineKey = [...spell.skillLineIds].sort((left, right) => left - right).join("-") || "none";
const signature = `${spell.name}\0${skillLineKey}`;
const chain = bySignature.get(signature) ?? {
name: spell.name,
skillLineKey,
skillLineIds: new Set(spell.skillLineIds),
iconBasename: spell.iconBasename,
talented: spell.talented,
advancementType: spell.advancementType,
ranks: [],
};
const existing = chain.ranks.findIndex((candidate) => candidate.rank === rank);
const definition = {
spellId: spell.id,
rank,
level: spell.level,
trainer: spell.sourceIds.includes(6),
};
if (existing < 0) {
chain.ranks.push(definition);
} else if (definition.trainer && !chain.ranks[existing].trainer) {
chain.ranks[existing] = definition;
}
chain.talented ||= spell.talented;
bySignature.set(signature, chain);
}
if (classicClassIds.includes(classId)) {
const unrankedClassSpells = rows
.map((row) => slimClassSpell(row, classId))
.filter((spell) => (
!spell.excluded
&& !spell.advancementType
&& !spell.talented
&& !/^Rank \d+$/i.test(spell.rank)
&& !isDeprecated(`${spell.name} ${spell.rank}`)
&& !/\b(?:dummy|unused|inactive)\b/i.test(`${spell.name} ${spell.rank}`)
&& !/\b(?:ritual of doom|curse of doom|rapid recuperation) effect\b/i.test(spell.name)
));
for (const spell of unrankedClassSpells) {
const skillLineKey = [...spell.skillLineIds].sort((left, right) => left - right).join("-") || "none";
const signature = `${spell.name}\0${skillLineKey}`;
if (bySignature.has(signature) || [...bySignature.values()].some((chain) => chain.name === spell.name)) continue;
bySignature.set(signature, {
name: spell.name,
skillLineKey,
skillLineIds: new Set(spell.skillLineIds),
iconBasename: spell.iconBasename,
talented: false,
advancementType: "",
ranks: [{
spellId: spell.id,
rank: 1,
level: spell.level,
trainer: spell.sourceIds.includes(6),
}],
});
}
}
const classRankCandidates = classicClassIds.includes(classId)
? classicRankCandidates
: coaRankCandidates;
for (const chain of bySignature.values()) {
const {
name,
skillLineKey,
skillLineIds: chainSkillLines,
iconBasename,
talented,
advancementType,
ranks,
} = chain;
for (const candidate of classRankCandidates.filter((rank) => (
rank.name === name
&& rank.skillLineIds.some((skillLineId) => chainSkillLines.has(skillLineId))
))) {
if (!ranks.some((rank) => rank.rank === candidate.rank)) {
ranks.push({ spellId: candidate.id, rank: candidate.rank, level: candidate.level });
}
}
ranks.sort((left, right) => left.rank - right.rank || left.level - right.level);
progressionChains.push({
id: `coa-${classId}-${name.toLowerCase().replace(/['’]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}-${skillLineKey}-${ranks[0].spellId}`,
classId,
name,
iconBasename,
talented,
advancementType,
trainer: ranks.some((rank) => rank.trainer),
ranks: ranks.map(({ spellId, rank, level }) => ({ spellId, rank, level })),
});
}
}
progressionChains.sort((left, right) => left.classId - right.classId || left.name.localeCompare(right.name));
assert(progressionChains.length >= 500, `Only generated ${progressionChains.length} progression spell chains`);
const classes = realm.talents.classes
.map((definition) => {
const classId = Number(definition.classId);
assert(expectedClassNames.get(classId) === definition.className, `Unexpected class ${classId}: ${definition.className}`);
const essence = realm.talents.essenceByClass[String(classId)] ?? realm.talents.essenceByClass[classId];
return {
classId,
className: definition.className,
maxTalentEssence: Number(essence?.maxTalentEssence) || 0,
maxAbilityEssence: Number(essence?.maxAbilityEssence) || 0,
tabs: definition.tabs
.filter((tab) => entries.some((entry) => entry.classId === classId && entry.tabId === Number(tab.tabId)))
.map((tab) => ({
tabId: Number(tab.tabId),
tabName: tab.tabName,
sortOrder: Number(tab.sortOrder) || 0,
})),
};
})
.sort((left, right) => left.classId - right.classId);
const output = {
source: {
builderUrl,
databaseClassUrl: `${databaseClassUrl}{classId}`,
realmId: Number(realm.id),
realmSlug: realm.slug,
realmName: realm.name,
maximumLevel: Number(realm.max_level),
schemaVersion: realm.schema_version,
synchronizedAt: new Date().toISOString(),
spellDbcPath: classicSpellDbcPath.replaceAll("\\", "/"),
classicSpellDbcPath: classicSpellDbcPath.replaceAll("\\", "/"),
classicSkillLineAbilityDbcPath: sourcePaths.classicSkillLineAbilityDbcPath.replaceAll("\\", "/"),
coaSpellDbcPath: coaSpellDbcPath.replaceAll("\\", "/"),
coaSkillLineAbilityDbcPath: sourcePaths.coaSkillLineAbilityDbcPath.replaceAll("\\", "/"),
supportDbcDirectory: supportDbcDirectory.replaceAll("\\", "/"),
},
counts: {
classes: classes.length,
tabs: classes.reduce((total, definition) => total + definition.tabs.length, 0),
entries: entries.length,
abilityEntries: entries.filter((entry) => entry.entryType === "Ability").length,
talentEntries: entries.filter((entry) => entry.entryType === "Talent").length,
rankedNodeSpells: entries.reduce((total, entry) => total + entry.rankDescriptions.length, 0),
classSpellRows: classSpellRows.length,
classicClassSpellRows: classSpellRows.filter((spell) => classicClassIds.includes(spell.classId)).length,
coaClassSpellRows: classSpellRows.filter((spell) => coaClassIds.includes(spell.classId)).length,
uniqueSpells: spells.length,
coaSpellOverrides: coaSpellOverrides.length,
dbcMatchedSpells: spells.filter((spell) => spell.dbc).length,
progressionChains: progressionChains.length,
classicProgressionChains: progressionChains.filter((chain) => classicClassIds.includes(chain.classId)).length,
classicBaseProgressionChains: progressionChains.filter((chain) => (
classicClassIds.includes(chain.classId) && !chain.talented
)).length,
coaProgressionChains: progressionChains.filter((chain) => coaClassIds.includes(chain.classId)).length,
progressionRanks: progressionChains.reduce((total, chain) => total + chain.ranks.length, 0),
liveTooltips: spells.filter((spell) => spell.liveTooltip).length,
},
classes,
entries,
progressionChains,
spells,
coaSpellOverrides,
};
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, `${JSON.stringify(output)}\n`, "utf8");
console.log(JSON.stringify({ outputPath, ...output.counts }, null, 2));