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