Files
healer-man/scripts/sync-ascension-coa.mjs
T
2026-08-14 15:56:39 -04:00

784 lines
30 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";
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 realmSpellDbc = path.join(
extractedClientRoot,
"area-52",
"patch-D",
"DBFilesClient",
"Spell.dbc",
);
const globalSpellDbc = path.join(
extractedClientRoot,
"patch-T",
"DBFilesClient",
"Spell.dbc",
);
const legacySpellDbc = path.resolve(
projectRoot,
"..",
"LadiksMPQEditor",
"patch-T",
"DBFilesClient",
"Spell.dbc",
);
const realmSkillLineAbilityDbc = path.join(
extractedClientRoot,
"area-52",
"patch-D",
"DBFilesClient",
"SkillLineAbility.dbc",
);
const globalSkillLineAbilityDbc = path.join(
extractedClientRoot,
"patch-M",
"DBFilesClient",
"SkillLineAbility.dbc",
);
const legacySkillLineAbilityDbc = path.resolve(
projectRoot,
"..",
"LadiksMPQEditor",
"patch-M",
"DBFilesClient",
"SkillLineAbility.dbc",
);
const defaultSpellDbc = fs.existsSync(realmSpellDbc)
? realmSpellDbc
: fs.existsSync(globalSpellDbc)
? globalSpellDbc
: legacySpellDbc;
const defaultSkillLineAbilityDbc = fs.existsSync(realmSkillLineAbilityDbc)
? realmSkillLineAbilityDbc
: fs.existsSync(globalSkillLineAbilityDbc)
? globalSkillLineAbilityDbc
: legacySkillLineAbilityDbc;
const cliArguments = process.argv.slice(2);
const fetchLiveTooltips = process.env.ASCENSION_COA_FETCH_TOOLTIPS === "1"
|| cliArguments.includes("--tooltips");
const spellDbcArgument = cliArguments.find((argument) => argument !== "--tooltips");
const spellDbcPath = path.resolve(spellDbcArgument ?? defaultSpellDbc);
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);
}
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 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(67),
equippedItemSubclassMask: signed(68),
equippedItemInventoryMask: signed(69),
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),
})),
};
}
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`);
const progressionNames = new Set(classSpellRows
.filter((spell) => (
!spell.excluded
&& !spell.advancementType
&& /^Rank \d+$/i.test(spell.rank)
&& !isDeprecated(`${spell.name} ${spell.rank}`)
))
.map((spell) => spell.name));
assert(fs.existsSync(spellDbcPath), `Spell.dbc was not found at ${spellDbcPath}`);
const spellDbcBuffer = fs.readFileSync(spellDbcPath);
const siblingSkillLineAbilityDbc = path.join(path.dirname(spellDbcPath), "SkillLineAbility.dbc");
const spellSkillLines = dbcSkillLines(
fs.existsSync(siblingSkillLineAbilityDbc) ? siblingSkillLineAbilityDbc : defaultSkillLineAbilityDbc,
);
const progressionSkillLines = new Map();
for (const spell of classSpellRows.filter((candidate) => progressionNames.has(candidate.name))) {
const skillLines = progressionSkillLines.get(spell.name) ?? new Set();
for (const skillLineId of spell.skillLineIds) skillLines.add(skillLineId);
progressionSkillLines.set(spell.name, skillLines);
}
const rankCandidates = dbcRankCandidates(spellDbcBuffer, progressionNames, spellSkillLines)
.filter((candidate) => candidate.skillLineIds.some((skillLineId) => progressionSkillLines.get(candidate.name)?.has(skillLineId)));
const wantedSpellIds = new Set([
...classSpellRows.map((spell) => spell.id),
...entries.flatMap((entry) => entry.rankDescriptions.map((rank) => rank.spellId)),
...rankCandidates.map((candidate) => candidate.id),
]);
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 dbcById = dbcSpells(spellDbcBuffer, wantedSpellIds, castTimes, durations, radii, ranges);
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),
...rankCandidates.map((candidate) => candidate.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();
for (const spell of classSpellRows) {
const existing = spellClassIds.get(spell.id) ?? new Set();
existing.add(spell.classId);
spellClassIds.set(spell.id, existing);
}
const classRowBySpellId = new Map(classSpellRows.map((spell) => [spell.id, spell]));
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 || "",
...(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 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),
}],
});
}
}
for (const chain of bySignature.values()) {
const {
name,
skillLineKey,
skillLineIds: chainSkillLines,
iconBasename,
talented,
advancementType,
ranks,
} = chain;
for (const candidate of rankCandidates.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: spellDbcPath.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,
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,
};
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, `${JSON.stringify(output)}\n`, "utf8");
console.log(JSON.stringify({ outputPath, ...output.counts }, null, 2));