480 lines
21 KiB
JavaScript
480 lines
21 KiB
JavaScript
import fs from "node:fs";
|
||
import path from "node:path";
|
||
|
||
const [luaPath, spellDbcPath, outputPath, talentDbcPath, baselineTalentDbcArgument] = process.argv.slice(2);
|
||
if (!luaPath || !spellDbcPath || !outputPath) {
|
||
throw new Error("Usage: node generate-wow335-talents.mjs <PBM_TalentData.lua> <Spell.dbc> <output.ts> [Talent.dbc] [baseline Talent.dbc]");
|
||
}
|
||
const defaultBaselineTalentDbcPath = path.resolve(
|
||
import.meta.dirname,
|
||
"..",
|
||
"..",
|
||
"wow335a",
|
||
"dungeon-export-work",
|
||
"HealerMan",
|
||
"talent-assets",
|
||
"dbc",
|
||
"Talent.dbc",
|
||
);
|
||
const baselineTalentDbcPath = baselineTalentDbcArgument
|
||
? path.resolve(baselineTalentDbcArgument)
|
||
: defaultBaselineTalentDbcPath;
|
||
|
||
const treeMetadata = {
|
||
DeathKnight: [
|
||
["death-knight-blood", "Blood", 398],
|
||
["death-knight-frost", "Frost", 399],
|
||
["death-knight-unholy", "Unholy", 400],
|
||
],
|
||
Druid: [
|
||
["druid-balance", "Balance", 283],
|
||
["druid-feral-combat", "Feral Combat", 281],
|
||
["druid-restoration", "Restoration", 282],
|
||
],
|
||
Hunter: [
|
||
["hunter-beast-mastery", "Beast Mastery", 361],
|
||
["hunter-marksmanship", "Marksmanship", 363],
|
||
["hunter-survival", "Survival", 362],
|
||
],
|
||
Mage: [
|
||
["mage-arcane", "Arcane", 81],
|
||
["mage-fire", "Fire", 41],
|
||
["mage-frost", "Frost", 61],
|
||
],
|
||
Paladin: [
|
||
["paladin-holy", "Holy", 382],
|
||
["paladin-protection", "Protection", 383],
|
||
["paladin-retribution", "Retribution", 381],
|
||
],
|
||
Priest: [
|
||
["priest-discipline", "Discipline", 201],
|
||
["priest-holy", "Holy", 202],
|
||
["priest-shadow", "Shadow", 203],
|
||
],
|
||
Rogue: [
|
||
["rogue-assassination", "Assassination", 182],
|
||
["rogue-combat", "Combat", 181],
|
||
["rogue-subtlety", "Subtlety", 183],
|
||
],
|
||
Shaman: [
|
||
["shaman-elemental", "Elemental", 261],
|
||
["shaman-enhancement", "Enhancement", 263],
|
||
["shaman-restoration", "Restoration", 262],
|
||
],
|
||
Warlock: [
|
||
["warlock-affliction", "Affliction", 302],
|
||
["warlock-demonology", "Demonology", 303],
|
||
["warlock-destruction", "Destruction", 301],
|
||
],
|
||
Warrior: [
|
||
["warrior-arms", "Arms", 161],
|
||
["warrior-fury", "Fury", 164],
|
||
["warrior-protection", "Protection", 163],
|
||
],
|
||
};
|
||
|
||
function dbcSpells(buffer) {
|
||
if (buffer.toString("ascii", 0, 4) !== "WDBC") throw new Error("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);
|
||
if (fieldCount < 188 || recordSize < fieldCount * 4) throw new Error("Unexpected 3.3.5 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);
|
||
};
|
||
// The custom client DBC has six numeric columns inserted immediately before
|
||
// the localized strings. Gameplay/effect columns retain the stock 3.3.5a
|
||
// indices, but Name/Rank/Description/AuraDescription begin six fields later.
|
||
const localizedFieldShift = fieldCount >= 239 ? 6 : 0;
|
||
const spellEffectField = (field) => field >= 77 ? field + localizedFieldShift : field;
|
||
const readLocalizedString = (base, firstField) => {
|
||
for (let locale = 0; locale < 16; locale += 1) {
|
||
const value = readString(buffer.readUInt32LE(base + (firstField + localizedFieldShift + locale) * 4));
|
||
if (value) return value;
|
||
}
|
||
return "";
|
||
};
|
||
const spells = new Map();
|
||
for (let index = 0; index < recordCount; index += 1) {
|
||
const base = 20 + index * recordSize;
|
||
const id = buffer.readUInt32LE(base);
|
||
const unsigned = (field) => buffer.readUInt32LE(base + field * 4);
|
||
const signed = (field) => buffer.readInt32LE(base + field * 4);
|
||
const float = (field) => buffer.readFloatLE(base + field * 4);
|
||
spells.set(id, {
|
||
id,
|
||
name: readLocalizedString(base, 136),
|
||
rankText: readLocalizedString(base, 153),
|
||
description: readLocalizedString(base, 170),
|
||
auraDescription: readLocalizedString(base, 187),
|
||
procChance: unsigned(35),
|
||
procCharges: unsigned(36),
|
||
stackAmount: unsigned(49),
|
||
maxTargets: unsigned(spellEffectField(212)),
|
||
durationIndex: unsigned(40),
|
||
recoveryTimeMs: unsigned(29),
|
||
categoryRecoveryTimeMs: unsigned(30),
|
||
effects: Array.from({ length: 3 }, (_, effectIndex) => ({
|
||
index: effectIndex,
|
||
effectType: unsigned(71 + effectIndex),
|
||
auraType: unsigned(spellEffectField(95 + effectIndex)),
|
||
basePoints: signed(spellEffectField(80 + effectIndex)) + 1,
|
||
dieSides: signed(74 + effectIndex),
|
||
pointsPerLevel: float(spellEffectField(77 + effectIndex)),
|
||
mechanic: unsigned(spellEffectField(83 + effectIndex)),
|
||
implicitTargetA: unsigned(spellEffectField(86 + effectIndex)),
|
||
implicitTargetB: unsigned(spellEffectField(89 + effectIndex)),
|
||
radiusIndex: unsigned(spellEffectField(92 + effectIndex)),
|
||
periodMs: unsigned(spellEffectField(98 + effectIndex)),
|
||
chainTargets: unsigned(spellEffectField(104 + effectIndex)),
|
||
miscValue: signed(spellEffectField(110 + effectIndex)),
|
||
miscValueB: signed(spellEffectField(113 + effectIndex)),
|
||
triggerSpellId: unsigned(spellEffectField(116 + effectIndex)),
|
||
classMask: [
|
||
unsigned(spellEffectField(122 + effectIndex)),
|
||
unsigned(spellEffectField(125 + effectIndex)),
|
||
unsigned(spellEffectField(128 + effectIndex)),
|
||
],
|
||
coefficient: float(spellEffectField(229 + effectIndex)),
|
||
})).filter((effect) => effect.effectType || effect.auraType || effect.basePoints !== 1 || effect.triggerSpellId),
|
||
});
|
||
}
|
||
return spells;
|
||
}
|
||
|
||
function dbcDurations(buffer) {
|
||
if (buffer.toString("ascii", 0, 4) !== "WDBC") throw new Error("SpellDuration.dbc is not a WDBC file");
|
||
const recordCount = buffer.readUInt32LE(4);
|
||
const recordSize = buffer.readUInt32LE(12);
|
||
const durations = new Map();
|
||
for (let index = 0; index < recordCount; index += 1) {
|
||
const base = 20 + index * recordSize;
|
||
const id = buffer.readUInt32LE(base);
|
||
// The maximum-duration column is the stable value in both the stock and
|
||
// custom client files. A value of -1 means the effect lasts until removed.
|
||
durations.set(id, buffer.readInt32LE(base + 3 * 4));
|
||
}
|
||
return durations;
|
||
}
|
||
|
||
function dbcRadii(buffer) {
|
||
if (buffer.toString("ascii", 0, 4) !== "WDBC") throw new Error("SpellRadius.dbc is not a WDBC file");
|
||
const recordCount = buffer.readUInt32LE(4);
|
||
const recordSize = buffer.readUInt32LE(12);
|
||
const radii = new Map();
|
||
for (let index = 0; index < recordCount; index += 1) {
|
||
const base = 20 + index * recordSize;
|
||
const id = buffer.readUInt32LE(base);
|
||
const radius = buffer.readFloatLE(base + 3 * 4) || buffer.readFloatLE(base + 1 * 4);
|
||
radii.set(id, radius);
|
||
}
|
||
return radii;
|
||
}
|
||
|
||
function cleanDescription(value) {
|
||
return value
|
||
.replace(/\|c[0-9a-f]{8}/gi, "")
|
||
.replace(/\|r/gi, "")
|
||
.replace(/@ext:/gi, "")
|
||
.replace(/:ext@/gi, "")
|
||
.replace(/[\r\n]+/g, " ")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
}
|
||
|
||
const source = fs.readFileSync(luaPath, "utf8");
|
||
const sectionStart = source.indexOf("PBM.data.talent.talents = {");
|
||
const sectionEnd = source.indexOf("PBM.data.talent.glyphs = {");
|
||
if (sectionStart < 0 || sectionEnd < 0) throw new Error("Talent section was not found in PBM_TalentData.lua");
|
||
const talentSource = source.slice(sectionStart, sectionEnd);
|
||
const spells = dbcSpells(fs.readFileSync(spellDbcPath));
|
||
const extractedSupportDbcDirectory = path.resolve(
|
||
path.dirname(spellDbcPath),
|
||
"..",
|
||
"..",
|
||
"..",
|
||
"patch-S",
|
||
"DBFilesClient",
|
||
);
|
||
const siblingDurationPath = path.join(path.dirname(spellDbcPath), "SpellDuration.dbc");
|
||
const durationPath = fs.existsSync(siblingDurationPath)
|
||
? siblingDurationPath
|
||
: path.join(extractedSupportDbcDirectory, "SpellDuration.dbc");
|
||
const durations = fs.existsSync(durationPath) ? dbcDurations(fs.readFileSync(durationPath)) : new Map();
|
||
const siblingRadiusPath = path.join(path.dirname(spellDbcPath), "SpellRadius.dbc");
|
||
const radiusPath = fs.existsSync(siblingRadiusPath)
|
||
? siblingRadiusPath
|
||
: path.join(extractedSupportDbcDirectory, "SpellRadius.dbc");
|
||
const radii = fs.existsSync(radiusPath) ? dbcRadii(fs.readFileSync(radiusPath)) : new Map([[18, 15]]);
|
||
|
||
function formatNumber(value) {
|
||
if (!Number.isFinite(value)) return "0";
|
||
return Number.isInteger(value) ? String(value) : String(Math.round(value * 100) / 100);
|
||
}
|
||
|
||
function formatDuration(milliseconds) {
|
||
if (milliseconds === -1) return "until cancelled";
|
||
if (!Number.isFinite(milliseconds) || milliseconds <= 0) return "the listed duration";
|
||
if (milliseconds % 60000 === 0) {
|
||
const minutes = milliseconds / 60000;
|
||
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
|
||
}
|
||
const seconds = milliseconds / 1000;
|
||
return `${formatNumber(seconds)} ${seconds === 1 ? "second" : "seconds"}`;
|
||
}
|
||
|
||
function spellEffect(spell, oneBasedIndex) {
|
||
return spell?.effects.find((effect) => effect.index === Math.max(0, Number(oneBasedIndex || 1) - 1));
|
||
}
|
||
|
||
function resolveDescription(value, owningSpell) {
|
||
const raw = cleanDescription(value);
|
||
if (!raw) return "No client description is available for this rank.";
|
||
return raw
|
||
.replace(/\$\{\$([mMsS])(\d+)\/(\d+)\}/g, (_token, operator, effectIndex, divisor) => {
|
||
const effect = spellEffect(owningSpell, effectIndex);
|
||
const amount = Math.abs(operator === "M"
|
||
? (effect?.basePoints ?? 0) + Math.max(0, (effect?.dieSides ?? 1) - 1)
|
||
: effect?.basePoints ?? 0) / Number(divisor);
|
||
return formatNumber(amount);
|
||
})
|
||
.replace(/\$\{\$([mMsS])(\d+)\/(\d+)\*\$(AP|RAP|SPH|SPN)\}/g, (_token, operator, effectIndex, divisor, stat) => {
|
||
const effect = spellEffect(owningSpell, effectIndex);
|
||
const amount = Math.abs(operator === "M"
|
||
? (effect?.basePoints ?? 0) + Math.max(0, (effect?.dieSides ?? 1) - 1)
|
||
: effect?.basePoints ?? 0) / Number(divisor);
|
||
const statName = { AP: "attack power", RAP: "ranged attack power", SPH: "healing spell power", SPN: "spell power" }[stat];
|
||
return `${formatNumber(amount)} × ${statName}`;
|
||
})
|
||
.replace(/\$\*(\d+(?:\.\d+)?);(\d+)?[sS](\d+)/g, (_token, multiplier, referencedSpellId, effectIndex) => {
|
||
const spell = referencedSpellId ? spells.get(Number(referencedSpellId)) : owningSpell;
|
||
return formatNumber(Math.abs(spellEffect(spell, effectIndex)?.basePoints ?? 0) * Number(multiplier));
|
||
})
|
||
.replace(/\$\/(\d+(?:\.\d+)?);(\d+)?[sS](\d+)/g, (_token, divisor, referencedSpellId, effectIndex) => {
|
||
const spell = referencedSpellId ? spells.get(Number(referencedSpellId)) : owningSpell;
|
||
return formatNumber(Math.abs(spellEffect(spell, effectIndex)?.basePoints ?? 0) / Number(divisor));
|
||
})
|
||
.replace(/\$AP\b/g, "attack power")
|
||
.replace(/\$RAP\b/g, "ranged attack power")
|
||
.replace(/\$SPH\b/g, "healing spell power")
|
||
.replace(/\$SPN\b/g, "spell power")
|
||
.replace(/\$mw\b/gi, "weapon damage")
|
||
.replace(/\$(\d+)?([sSmMtohdnui])(\d+)?/g, (_token, referencedSpellId, operator, effectIndex) => {
|
||
const spell = referencedSpellId ? spells.get(Number(referencedSpellId)) : owningSpell;
|
||
if (!spell) return "the listed amount";
|
||
const effect = spellEffect(spell, effectIndex);
|
||
if (operator === "d") return formatDuration(durations.get(spell.durationIndex));
|
||
if (operator === "h") return formatNumber(spell.procChance);
|
||
if (operator === "n") return formatNumber(spell.procCharges);
|
||
if (operator === "u") return formatNumber(spell.stackAmount);
|
||
if (operator === "i") return formatNumber(spell.maxTargets);
|
||
if (operator === "t") return effect?.periodMs ? formatNumber(effect.periodMs / 1000) : "the listed interval";
|
||
if (operator === "o") {
|
||
const duration = durations.get(spell.durationIndex);
|
||
return effect?.periodMs && duration > 0
|
||
? formatNumber(Math.abs(effect.basePoints) * duration / effect.periodMs)
|
||
: formatNumber(Math.abs(effect?.basePoints ?? 0));
|
||
}
|
||
if (!effect) return "the listed amount";
|
||
if (operator === "M") return formatNumber(Math.abs(effect.basePoints + Math.max(0, effect.dieSides - 1)));
|
||
return formatNumber(Math.abs(effect.basePoints));
|
||
})
|
||
.replace(/\$a(\d+)/gi, (_token, effectIndex) => {
|
||
const radiusIndex = spellEffect(owningSpell, effectIndex)?.radiusIndex ?? 0;
|
||
return formatNumber(radii.get(radiusIndex) ?? radiusIndex);
|
||
})
|
||
.replace(/\$l([^:;]+):([^;]+);/gi, "$2")
|
||
.replace(/\$g([^:;]+):([^;]+);/gi, "$1")
|
||
.replace(/\$[A-Za-z0-9?<>.:{}()+*/-]+/g, "the listed amount")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
}
|
||
let nodes = [];
|
||
let currentClass = null;
|
||
let currentTree = 0;
|
||
|
||
for (const line of talentSource.split(/\r?\n/)) {
|
||
const classMatch = line.match(/^\["([A-Za-z]+)"\]\s*=\s*\{$/);
|
||
if (classMatch && treeMetadata[classMatch[1]]) {
|
||
currentClass = classMatch[1];
|
||
currentTree = 0;
|
||
continue;
|
||
}
|
||
const treeMatch = line.match(/^\[(\d+)\]\s*=\s*\{$/);
|
||
if (treeMatch && currentClass) {
|
||
currentTree = Number(treeMatch[1]);
|
||
continue;
|
||
}
|
||
const talentMatch = line.match(/^\[(\d+)\]\s*=\s*"([^"]+)"/);
|
||
if (!talentMatch || !currentClass || currentTree < 1 || currentTree > 3) continue;
|
||
const index = Number(talentMatch[1]);
|
||
const parts = talentMatch[2].split(",").map((part) => part.trim());
|
||
const prerequisiteIndex = Number(parts[0]);
|
||
const column = Number(parts[1]);
|
||
const row = Number(parts[2]);
|
||
const iconBasename = parts[3].toLowerCase();
|
||
const rankSpellIds = parts.slice(4).map(Number).filter(Number.isFinite);
|
||
const [treeId] = treeMetadata[currentClass][currentTree - 1];
|
||
const firstSpell = spells.get(rankSpellIds[0]);
|
||
const lastSpell = spells.get(rankSpellIds.at(-1));
|
||
const rankSpells = rankSpellIds.map((spellId) => spells.get(spellId)).filter(Boolean);
|
||
const rawRankDescriptions = rankSpells.map((spell) => cleanDescription(spell.description || spell.auraDescription));
|
||
const rankDescriptions = rankSpells.map((spell) => resolveDescription(spell.description || spell.auraDescription, spell));
|
||
nodes.push({
|
||
id: `${treeId}-${index}`,
|
||
treeId,
|
||
index,
|
||
prerequisiteId: prerequisiteIndex > 0 ? `${treeId}-${prerequisiteIndex}` : null,
|
||
prerequisiteRank: null,
|
||
prerequisites: [],
|
||
column,
|
||
row,
|
||
icon: `/assets/ui/talents/icons/${iconBasename}.png`,
|
||
iconBasename,
|
||
rankSpellIds,
|
||
name: firstSpell?.name || `Talent ${rankSpellIds[0] ?? index}`,
|
||
description: rankDescriptions.at(-1) || resolveDescription(lastSpell?.description || firstSpell?.description || "No client description is available for this rank.", lastSpell || firstSpell),
|
||
rankDescriptions,
|
||
rawRankDescriptions,
|
||
rankEffects: rankSpells.map((spell) => ({
|
||
spellId: spell.id,
|
||
rankText: cleanDescription(spell.rankText),
|
||
procChance: spell.procChance,
|
||
procCharges: spell.procCharges,
|
||
stackAmount: spell.stackAmount,
|
||
maxTargets: spell.maxTargets,
|
||
durationIndex: spell.durationIndex,
|
||
recoveryTimeMs: spell.recoveryTimeMs,
|
||
categoryRecoveryTimeMs: spell.categoryRecoveryTimeMs,
|
||
effects: spell.effects,
|
||
})),
|
||
});
|
||
}
|
||
|
||
if (talentDbcPath) {
|
||
const talentBuffer = fs.readFileSync(talentDbcPath);
|
||
if (talentBuffer.toString("ascii", 0, 4) !== "WDBC") throw new Error("Talent.dbc is not a WDBC file");
|
||
const recordCount = talentBuffer.readUInt32LE(4);
|
||
const fieldCount = talentBuffer.readUInt32LE(8);
|
||
const recordSize = talentBuffer.readUInt32LE(12);
|
||
if (fieldCount !== 23 || recordSize !== 92) throw new Error("Unexpected 3.3.5 Talent.dbc schema");
|
||
const treeByTabId = new Map(Object.values(treeMetadata).flat().map(([treeId, , tabId]) => [tabId, treeId]));
|
||
const pbmByTreeAndSpell = new Map(nodes.map((node) => [`${node.treeId}:${node.rankSpellIds[0]}`, node]));
|
||
const pbmByTreeAndCell = new Map(nodes.map((node) => [`${node.treeId}:${node.row}:${node.column}`, node]));
|
||
const baselineDefinitionByTalentId = new Map();
|
||
if (fs.existsSync(baselineTalentDbcPath)) {
|
||
const baselineBuffer = fs.readFileSync(baselineTalentDbcPath);
|
||
const baselineRecordCount = baselineBuffer.readUInt32LE(4);
|
||
const baselineRecordSize = baselineBuffer.readUInt32LE(12);
|
||
for (let index = 0; index < baselineRecordCount; index += 1) {
|
||
const base = 20 + index * baselineRecordSize;
|
||
const field = (fieldIndex) => baselineBuffer.readUInt32LE(base + fieldIndex * 4);
|
||
const treeId = treeByTabId.get(field(1));
|
||
if (!treeId) continue;
|
||
const firstRankSpellId = field(4);
|
||
const row = field(2) + 1;
|
||
const column = field(3) + 1;
|
||
const pbm = pbmByTreeAndSpell.get(`${treeId}:${firstRankSpellId}`)
|
||
?? pbmByTreeAndCell.get(`${treeId}:${row}:${column}`);
|
||
if (pbm) {
|
||
const prerequisites = Array.from({ length: 3 }, (_, prerequisite) => {
|
||
const talentId = field(13 + prerequisite);
|
||
if (!talentId) return null;
|
||
return { talentId: `talent-${talentId}`, requiredRank: field(16 + prerequisite) + 1 };
|
||
}).filter(Boolean);
|
||
baselineDefinitionByTalentId.set(field(0), { pbm, prerequisites });
|
||
}
|
||
}
|
||
}
|
||
const exactNodes = [];
|
||
for (let index = 0; index < recordCount; index += 1) {
|
||
const base = 20 + index * recordSize;
|
||
const field = (fieldIndex) => talentBuffer.readUInt32LE(base + fieldIndex * 4);
|
||
const dbcTalentId = field(0);
|
||
const treeId = treeByTabId.get(field(1));
|
||
if (!treeId) continue;
|
||
const rankSpellIds = Array.from({ length: 9 }, (_, rank) => field(4 + rank)).filter(Boolean);
|
||
const row = field(2) + 1;
|
||
const column = field(3) + 1;
|
||
// Ascension ships several additional talent systems in the same classic
|
||
// TalentTab ids. Join by the stock Talent.dbc id map so those other
|
||
// systems cannot be mixed into the default-class trees.
|
||
const baselineDefinition = baselineDefinitionByTalentId.get(dbcTalentId);
|
||
const pbm = baselineDefinitionByTalentId.size
|
||
? baselineDefinition?.pbm
|
||
: pbmByTreeAndSpell.get(`${treeId}:${rankSpellIds[0]}`);
|
||
if (!pbm) continue;
|
||
const currentPrerequisites = Array.from({ length: 3 }, (_, prerequisite) => {
|
||
const talentId = field(13 + prerequisite);
|
||
if (!talentId) return null;
|
||
return { talentId: `talent-${talentId}`, requiredRank: field(16 + prerequisite) + 1 };
|
||
}).filter(Boolean);
|
||
const prerequisites = baselineDefinition?.prerequisites ?? currentPrerequisites;
|
||
const firstSpell = spells.get(rankSpellIds[0]);
|
||
const lastSpell = spells.get(rankSpellIds.at(-1));
|
||
const rankSpells = rankSpellIds.map((spellId) => spells.get(spellId)).filter(Boolean);
|
||
const rawRankDescriptions = rankSpells.map((spell) => cleanDescription(spell.description || spell.auraDescription));
|
||
const rankDescriptions = rankSpells.map((spell) => resolveDescription(spell.description || spell.auraDescription, spell));
|
||
exactNodes.push({
|
||
id: `talent-${dbcTalentId}`,
|
||
dbcTalentId,
|
||
treeId,
|
||
index: pbm.index,
|
||
prerequisiteId: prerequisites[0]?.talentId ?? null,
|
||
prerequisiteRank: prerequisites[0]?.requiredRank ?? null,
|
||
prerequisites,
|
||
column: pbm.column,
|
||
row: pbm.row,
|
||
icon: pbm.icon,
|
||
iconBasename: pbm.iconBasename,
|
||
rankSpellIds,
|
||
name: firstSpell?.name || pbm.name,
|
||
description: rankDescriptions.at(-1) || resolveDescription(lastSpell?.description || firstSpell?.description || pbm.description, lastSpell || firstSpell),
|
||
rankDescriptions,
|
||
rawRankDescriptions,
|
||
rankEffects: rankSpells.map((spell) => ({
|
||
spellId: spell.id,
|
||
rankText: cleanDescription(spell.rankText),
|
||
procChance: spell.procChance,
|
||
procCharges: spell.procCharges,
|
||
stackAmount: spell.stackAmount,
|
||
maxTargets: spell.maxTargets,
|
||
durationIndex: spell.durationIndex,
|
||
recoveryTimeMs: spell.recoveryTimeMs,
|
||
categoryRecoveryTimeMs: spell.categoryRecoveryTimeMs,
|
||
effects: spell.effects,
|
||
})),
|
||
flags: field(19),
|
||
requiredSpellId: field(20),
|
||
categoryMask: [field(21), field(22)],
|
||
});
|
||
}
|
||
nodes = exactNodes;
|
||
}
|
||
|
||
const expectedClasses = Object.keys(treeMetadata).length;
|
||
const foundClasses = new Set(nodes.map((node) => node.treeId.split("-")[0]));
|
||
if (nodes.length < 820 || nodes.length > 829 || foundClasses.size < expectedClasses - 1) {
|
||
throw new Error(`Only parsed ${nodes.length} talent nodes; refusing to generate incomplete data`);
|
||
}
|
||
|
||
const treeLines = Object.entries(treeMetadata).flatMap(([className, trees]) => trees.map(([id, name, dbcTalentTabId], index) => ({
|
||
id,
|
||
name,
|
||
className,
|
||
dbcTalentTabId,
|
||
background: `/assets/ui/talents/backgrounds/talent_${className.toLowerCase()}${index + 1}.png`,
|
||
})));
|
||
|
||
const output = `/* Generated from the local client DBCs. Do not hand-edit. */\n` +
|
||
`export const WOW335_TALENT_TREE_ASSETS = ${JSON.stringify(treeLines, null, 2)} as const;\n\n` +
|
||
`export const WOW335_TALENT_NODES = ${JSON.stringify(nodes, null, 2)} as const;\n`;
|
||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||
fs.writeFileSync(outputPath, output, "utf8");
|
||
console.log(JSON.stringify({ nodes: nodes.length, trees: treeLines.length, outputPath }));
|