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(//gi, " ") .replace(/<[^>]+>/g, "") .replace(/ /gi, " ") .replace(/&/gi, "&") .replace(/</gi, "<") .replace(/>/gi, ">") .replace(/"/gi, '"') .replace(/�*39;|'/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));