784 lines
30 KiB
JavaScript
784 lines
30 KiB
JavaScript
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(/ /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));
|