#!/usr/bin/env node import { createHash } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const recipesRoot = path.join(projectRoot, "dungeon-pipeline", "recipes"); const defaultOutputRoot = path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons", "server-data"); const defaultSourceRoot = path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons", "azerothcore-world"); const defaultReferenceOutput = path.join( projectRoot, "src", "game", "generated", "azerothCoreReference.json", ); const defaultBaseStatsOutput = path.join( projectRoot, "src", "game", "generated", "wow335BaseStats.json", ); const epochClientCatalogFile = path.join( projectRoot, "src", "game", "generated", "epochDungeonClientCatalog.json", ); const epochDungeonSourceFile = path.join( projectRoot, "dungeon-pipeline", "epoch-five-player-instances.json", ); /** * Pin the database inputs. Updating this value is an explicit provenance change * and never silently changes a generated dungeon. */ export const DEFAULT_AZEROTHCORE_COMMIT = "4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3"; const SOURCE_TABLES = Object.freeze([ "creature", "creature_template", "creature_template_model", "creature_template_spell", "creature_classlevelstats", "creature_summon_groups", "creature_addon", "creature_formations", "waypoint_data", "waypoints", "smart_scripts", "gameobject", "gameobject_template", "instance_encounters", "areatrigger_teleport", "player_class_stats", "player_race_stats", "player_xp_for_level", ]); const WAILING_CAVERNS_REFERENCE_ENTRIES = Object.freeze(new Set([ 3636, 3637, 3640, 3653, 3654, 3669, 3670, 3671, 3673, 3674, 3678, 3679, 3840, 5048, 5053, 5055, 5056, 5755, 5756, 5761, 5763, 5775, 5912, 8886, ])); const WAILING_CAVERNS_REQUIRED_ENCOUNTERS = Object.freeze([ [3671, "Lady Anacondra"], [3669, "Lord Cobrahn"], [3653, "Kresh"], [3670, "Lord Pythas"], [3674, "Skum"], [3673, "Lord Serpentis"], [5775, "Verdan the Everliving"], [3654, "Mutanus the Devourer"], ].map(([creatureEntry, name], orderIndex) => ({ map: 43, encounterId: 43_000 + orderIndex, creditType: 0, creditEntry: creatureEntry, creatureEntry, name, orderIndex, difficultyId: 0, sourceRowIds: [], source: "AzerothCore SmartAI/instance_wailing_caverns", ...(creatureEntry === 3654 ? { eventOnly: true } : {}), }))); const MECHANIC_NAMES = Object.freeze([ "none", "charm", "disorient", "disarm", "distract", "fear", "grip", "root", "pacify", "silence", "sleep", "snare", "stun", "freeze", "knockout", "bleed", "bandage", "polymorph", "banish", "shield", "shackle", "mount", "infected", "turn", "horror", "invulnerability", "interrupt", "daze", "discovery", "immune-shield", "sap", "enrage", ]); const SPELL_SCHOOL_NAMES = Object.freeze([ "physical", "holy", "fire", "nature", "frost", "shadow", "arcane", ]); const WAILING_SPELL_BEHAVIOR = Object.freeze({ 3604: { name: "Tendon Rip", delivery: "melee", target: "primary", school: "physical", range: 4.5, effect: { kind: "damage", multiplier: 0.95 }, }, 3616: { name: "Poison Proc", delivery: "melee", target: "self", school: "nature", range: 0, effect: { kind: "aura", aura: "poison", durationMs: 0 }, }, 5187: { name: "Healing Touch", delivery: "projectile", target: "lowest-health-friendly", school: "nature", range: 40, effect: { kind: "heal", multiplier: 1.25 }, }, 6254: { name: "Chained Bolt", delivery: "projectile", target: "primary", school: "nature", range: 30, effect: { kind: "damage", multiplier: 1.1 }, }, 6778: { name: "Healing Touch", delivery: "projectile", target: "lowest-health-friendly", school: "nature", range: 40, effect: { kind: "heal", multiplier: 1.45 }, }, 7399: { name: "Terrify", delivery: "projectile", target: "primary", school: "shadow", range: 30, effect: { kind: "control", mechanic: "fear", durationMs: 6_000 }, }, 7947: { name: "Localized Toxin", delivery: "projectile", target: "primary", school: "nature", range: 30, effect: { kind: "damage", multiplier: 0.9 }, }, 7342: { name: "Wide Slash", delivery: "area", target: "nearby-party", school: "physical", range: 8, radius: 8, effect: { kind: "damage", multiplier: 0.9 }, }, 7948: { name: "Wild Regeneration", delivery: "melee", target: "lowest-health-friendly", school: "nature", range: 30, effect: { kind: "heal", multiplier: 1.05 }, }, 7951: { name: "Toxic Spit", delivery: "projectile", target: "primary", school: "nature", range: 30, effect: { kind: "damage", multiplier: 1 }, }, 7965: { name: "Cobrahn Serpent Form", delivery: "melee", target: "self", school: "nature", range: 0, effect: { kind: "transform", form: "serpent" }, }, 7967: { name: "Naralex's Nightmare", delivery: "projectile", target: "random-party", school: "shadow", range: 30, effect: { kind: "control", mechanic: "sleep", durationMs: 8_000 }, }, 8040: { name: "Druid's Slumber", delivery: "projectile", target: "random-party", school: "nature", range: 30, effect: { kind: "control", mechanic: "sleep", durationMs: 6_000 }, }, 8041: { name: "Serpent Form", delivery: "melee", target: "self", school: "nature", range: 0, effect: { kind: "transform", form: "serpent" }, }, 8142: { name: "Grasping Vines", delivery: "projectile", target: "primary", school: "nature", range: 30, effect: { kind: "control", mechanic: "root", durationMs: 8_000 }, }, 8147: { name: "Thunder Clap", delivery: "area", target: "nearby-party", school: "physical", range: 10, radius: 10, effect: { kind: "damage", multiplier: 0.9 }, }, 8148: { name: "Thorns", delivery: "melee", target: "self", school: "nature", range: 0, effect: { kind: "aura", aura: "thorns", durationMs: 40_000, magnitude: 0.08 }, }, 8150: { name: "Thundercrack", delivery: "area", target: "nearby-party", school: "nature", range: 10, radius: 10, effect: { kind: "damage", multiplier: 1.05 }, }, 9532: { name: "Lightning Bolt", delivery: "projectile", target: "primary", school: "nature", range: 30, effect: { kind: "damage", multiplier: 1.12 }, }, }); function stableValue(value) { if (Array.isArray(value)) return value.map(stableValue); if (!value || typeof value !== "object") return value; return Object.fromEntries( Object.keys(value).sort().map((key) => [key, stableValue(value[key])]), ); } function stableJson(value) { return `${JSON.stringify(stableValue(value), null, 2)}\n`; } function sha256(value) { return createHash("sha256").update(value).digest("hex"); } async function readJson(file) { return JSON.parse(await readFile(file, "utf8")); } async function writeJson(file, value) { await mkdir(path.dirname(file), { recursive: true }); await writeFile(file, stableJson(value), "utf8"); } function numeric(value) { const number = Number(value); return Number.isFinite(number) ? number : 0; } function firstValue(row, names) { for (const name of names) { if (row[name] !== undefined && row[name] !== null) return row[name]; } return undefined; } function bitMaskNames(maskValue, names, bitOffset = 0) { const mask = numeric(maskValue) >>> 0; return names.filter((name, index) => ( name !== "none" && (mask & (1 << Math.max(0, index - bitOffset))) !== 0 )); } function creatureTemplateReference(row) { const entry = numeric(firstValue(row, ["entry", "CreatureID", "creatureId"])); const mechanicImmuneMask = numeric(firstValue( row, ["mechanic_immune_mask", "mechanicImmuneMask", "CreatureImmunitiesId"], )); const spellSchoolImmuneMask = numeric(firstValue( row, ["spell_school_immune_mask", "spellSchoolImmuneMask"], )); return { entry, name: String(firstValue(row, ["name", "Name"]) ?? `Creature ${entry}`), minLevel: numeric(firstValue(row, ["minlevel", "minLevel"])), maxLevel: numeric(firstValue(row, ["maxlevel", "maxLevel"])), expansion: numeric(firstValue(row, ["exp", "expansion"])), rank: numeric(firstValue(row, ["rank", "Rank"])), damageSchool: numeric(firstValue(row, ["dmgschool", "damageSchool"])), damageModifier: numeric(firstValue(row, ["DamageModifier", "damageModifier"])) || 1, baseAttackTimeMs: numeric(firstValue(row, ["BaseAttackTime", "baseAttackTime"])) || 2_000, baseVariance: numeric(firstValue(row, ["BaseVariance", "baseVariance"])) || 1, unitClass: numeric(firstValue(row, ["unit_class", "unitClass"])) || 1, healthModifier: numeric(firstValue(row, ["HealthModifier", "ModHealth"])) || 1, manaModifier: numeric(firstValue(row, ["ManaModifier", "ModMana"])) || 1, armorModifier: numeric(firstValue(row, ["ArmorModifier", "ModArmor"])) || 1, experienceModifier: numeric(firstValue(row, ["ExperienceModifier", "ModExperience"])) || 1, mechanicImmuneMask, mechanicImmunities: bitMaskNames(mechanicImmuneMask, MECHANIC_NAMES, 1), spellSchoolImmuneMask, schoolImmunities: bitMaskNames(spellSchoolImmuneMask, SPELL_SCHOOL_NAMES), aiName: String(firstValue(row, ["AIName", "aiName"]) ?? ""), scriptName: String(firstValue(row, ["ScriptName", "scriptName"]) ?? ""), }; } function creatureClassLevelReference(row) { return { level: numeric(firstValue(row, ["level", "Level"])), unitClass: numeric(firstValue(row, ["class", "Class"])), baseHealth: [ numeric(firstValue(row, ["basehp0"])), numeric(firstValue(row, ["basehp1"])), numeric(firstValue(row, ["basehp2"])), ], baseMana: numeric(firstValue(row, ["basemana"])), baseArmor: numeric(firstValue(row, ["basearmor"])), attackPower: numeric(firstValue(row, ["attackpower"])), rangedAttackPower: numeric(firstValue(row, ["rangedattackpower"])), baseDamage: [ numeric(firstValue(row, ["damage_base"])), numeric(firstValue(row, ["damage_exp1"])), numeric(firstValue(row, ["damage_exp2"])), ], attributes: [ numeric(firstValue(row, ["Strength", "strength"])), numeric(firstValue(row, ["Agility", "agility"])), numeric(firstValue(row, ["Stamina", "stamina"])), numeric(firstValue(row, ["Intellect", "intellect"])), numeric(firstValue(row, ["Spirit", "spirit"])), ], }; } function smartCondition(row) { const eventType = numeric(firstValue(row, ["event_type", "eventType"])); if (eventType === 14) { return { kind: "friendly-missing-health", amount: numeric(firstValue(row, ["event_param1"])), range: numeric(firstValue(row, ["event_param2"])), }; } if (eventType === 2) { return { kind: "self-health-percent", minimum: numeric(firstValue(row, ["event_param1"])), maximum: numeric(firstValue(row, ["event_param2"])), }; } return { kind: "in-combat" }; } function wailingAbility(row) { const eventType = numeric(firstValue(row, ["event_type", "eventType"])); if (![0, 2, 14].includes(eventType)) return null; const actionType = numeric(firstValue(row, ["action_type", "actionType"])); const eventFlags = numeric(firstValue(row, ["event_flags", "eventFlags"])); const maximumCasts = (eventFlags & 1) !== 0 ? 1 : undefined; if (actionType === 39) { const radius = numeric(firstValue(row, ["action_param1"])) || 40; return { id: "call-for-help", name: "Call for Help", delivery: "area", target: "self", school: "physical", range: radius, radius, animation: "cast", initialCooldownMs: [0, 0], repeatCooldownMs: [60_000, 60_000], cooldownMs: 60_000, ...(maximumCasts ? { maximumCasts } : {}), damageMultiplier: 0, effect: { kind: "call-for-help", radius }, condition: smartCondition(row), source: { entry: numeric(firstValue(row, ["entryorguid"])), sourceType: numeric(firstValue(row, ["source_type"])), rowId: numeric(firstValue(row, ["id"])), eventType, actionType, targetType: numeric(firstValue(row, ["target_type"])), comment: String(firstValue(row, ["comment"]) ?? ""), }, }; } if (actionType !== 11) return null; const spellId = numeric(firstValue(row, ["action_param1"])); const behavior = WAILING_SPELL_BEHAVIOR[spellId]; if (!behavior) return null; const initialMinimum = numeric(firstValue(row, ["event_param1"])); const initialMaximum = numeric(firstValue(row, ["event_param2"])); const repeatMinimum = numeric(firstValue(row, ["event_param3"])); const repeatMaximum = numeric(firstValue(row, ["event_param4"])); const initialCooldown = eventType === 0 ? [ Math.max(0, initialMinimum), Math.max(initialMinimum, initialMaximum), ] : [0, 0]; const repeatFallback = eventType === 0 ? initialMinimum : 1; return { id: `spell-${spellId}`, spellId, ...behavior, animation: "cast", initialCooldownMs: initialCooldown, repeatCooldownMs: [ Math.max(1, repeatMinimum || repeatFallback), Math.max(repeatMinimum || repeatFallback, repeatMaximum || repeatMinimum || repeatFallback), ], cooldownMs: Math.max(1, repeatMinimum || initialMinimum || 1), ...(maximumCasts ? { maximumCasts } : {}), damageMultiplier: behavior.effect.kind === "damage" ? behavior.effect.multiplier : 0, condition: smartCondition(row), source: { entry: numeric(firstValue(row, ["entryorguid"])), sourceType: numeric(firstValue(row, ["source_type"])), rowId: numeric(firstValue(row, ["id"])), eventType, actionType, targetType: numeric(firstValue(row, ["target_type"])), comment: String(firstValue(row, ["comment"]) ?? ""), }, }; } function unescapeSqlString(value) { let output = ""; for (let index = 0; index < value.length; index += 1) { const character = value[index]; if (character === "'" && value[index + 1] === "'") { output += "'"; index += 1; continue; } if (character !== "\\") { output += character; continue; } const next = value[index + 1]; index += 1; switch (next) { case "0": output += "\0"; break; case "b": output += "\b"; break; case "n": output += "\n"; break; case "r": output += "\r"; break; case "t": output += "\t"; break; case "Z": output += "\x1a"; break; case undefined: output += "\\"; break; default: output += next; break; } } return output; } function sqlScalar(raw) { const value = raw.trim(); if (/^NULL$/i.test(value)) return null; if (value.startsWith("'") && value.endsWith("'")) { return unescapeSqlString(value.slice(1, -1)); } if (/^0x[0-9a-f]+$/i.test(value)) return value; const number = Number(value); return Number.isFinite(number) ? number : value; } /** * Streams logical INSERT tuples from a SQL string without evaluating SQL. * AzerothCore base files use scalar VALUES records, so nested expressions are * intentionally rejected instead of being interpreted. */ function visitInsertRows(sql, tableName, columns, visitor) { const marker = `INSERT INTO \`${tableName}\` VALUES`; let statement = sql.indexOf(marker); let visited = 0; while (statement >= 0) { let cursor = statement + marker.length; while (cursor < sql.length) { while (/[\s,]/.test(sql[cursor] ?? "")) cursor += 1; if (sql[cursor] === ";") { cursor += 1; break; } if (sql[cursor] !== "(") { throw new Error(`${tableName}: expected an INSERT tuple at byte ${cursor}.`); } cursor += 1; const values = []; let tokenStart = cursor; let quoted = false; let escaped = false; while (cursor < sql.length) { const character = sql[cursor]; if (quoted) { if (escaped) { escaped = false; } else if (character === "\\") { escaped = true; } else if (character === "'" && sql[cursor + 1] === "'") { cursor += 1; } else if (character === "'") { quoted = false; } cursor += 1; continue; } if (character === "'") { quoted = true; cursor += 1; continue; } if (character === ",") { values.push(sqlScalar(sql.slice(tokenStart, cursor))); cursor += 1; tokenStart = cursor; continue; } if (character === ")") { values.push(sqlScalar(sql.slice(tokenStart, cursor))); cursor += 1; break; } if (character === "(") { throw new Error(`${tableName}: nested INSERT expressions are not supported.`); } cursor += 1; } if (values.length !== columns.length) { throw new Error( `${tableName}: INSERT row has ${values.length} values for ${columns.length} columns.`, ); } const row = Object.fromEntries(columns.map((column, index) => [column, values[index]])); visitor(row); visited += 1; } statement = sql.indexOf(marker, cursor); } return visited; } function parseColumns(sql, tableName) { const marker = `CREATE TABLE \`${tableName}\` (`; const start = sql.indexOf(marker); if (start < 0) throw new Error(`${tableName}.sql has no CREATE TABLE statement.`); const end = sql.indexOf("\n) ENGINE=", start); if (end < 0) throw new Error(`${tableName}.sql has an unsupported CREATE TABLE terminator.`); const body = sql.slice(start + marker.length, end); const columns = []; for (const match of body.matchAll(/^\s*`([^`]+)`\s+/gm)) columns.push(match[1]); if (!columns.length) throw new Error(`${tableName}.sql declares no columns.`); return columns; } async function parseTable(sourceRoot, tableName, visitor) { const file = path.join(sourceRoot, `${tableName}.sql`); const buffer = await readFile(file); const sql = buffer.toString("utf8"); const columns = parseColumns(sql, tableName); const rows = visitInsertRows(sql, tableName, columns, visitor); return { file, columns, rows, byteLength: buffer.length, sha256: sha256(buffer), }; } async function fetchSources(sourceRoot, commit) { await mkdir(sourceRoot, { recursive: true }); const base = `https://raw.githubusercontent.com/azerothcore/azerothcore-wotlk/${commit}/data/sql/base/db_world`; const reports = []; for (const tableName of SOURCE_TABLES) { const url = `${base}/${tableName}.sql`; const response = await fetch(url, { headers: { "User-Agent": "healer-man-dungeon-pipeline" }, }); if (!response.ok) { throw new Error(`${tableName}: ${response.status} ${response.statusText} while fetching ${url}`); } const buffer = Buffer.from(await response.arrayBuffer()); const file = path.join(sourceRoot, `${tableName}.sql`); await writeFile(file, buffer); reports.push({ table: tableName, url, file: path.relative(projectRoot, file).replace(/\\/g, "/"), byteLength: buffer.length, sha256: sha256(buffer), }); } await writeJson(path.join(sourceRoot, "source.lock.json"), { schemaVersion: 1, repository: "https://github.com/azerothcore/azerothcore-wotlk", commit, files: reports, }); return reports; } async function loadDungeonRecipes() { const campaign = await readJson(path.join(projectRoot, "dungeon-pipeline", "dungeon-campaign.json")); const recipes = []; for (const entry of campaign.dungeons) { const recipe = await readJson(path.join(recipesRoot, `${entry.slug}.json`)); if (recipe.mapId !== entry.mapId) { throw new Error( `${entry.slug}: campaign map ${entry.mapId} does not match recipe map ${recipe.mapId}.`, ); } recipes.push({ ...recipe, campaignSource: entry.source }); } return recipes; } function groupByMap(recipes) { const grouped = new Map(); for (const recipe of recipes) { const rows = grouped.get(recipe.mapId) ?? []; rows.push(recipe); grouped.set(recipe.mapId, rows); } return grouped; } function groupedPush(map, key, row) { const rows = map.get(key) ?? []; rows.push(row); map.set(key, rows); } function normalizedName(value) { return String(value ?? "") .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") .toLowerCase() .replace(/[^a-z0-9]+/g, ""); } function encounterCreatureEntry(encounter, sourceRow, creatureRows, templateByEntry) { const creditType = numeric(firstValue(sourceRow, ["creditType"])); const creditEntry = numeric(firstValue(sourceRow, ["creditEntry"])); if (creditType === 0 && creditEntry > 0) return creditEntry; const wantedNames = [ encounter.name, firstValue(sourceRow, ["comment", "name"]), ].map(normalizedName).filter(Boolean); const candidates = [...new Set([ ...creatureRows.map((row) => numeric(firstValue(row, ["id1", "entry", "creatureId"]))), ...templateByEntry.keys(), ])].map((entry) => { const template = templateByEntry.get(entry) ?? {}; return { entry, name: normalizedName(firstValue(template, ["name", "Name"])), rank: numeric(firstValue(template, ["rank", "Rank"])), level: numeric(firstValue(template, ["maxlevel", "maxLevel"])), }; }); const exact = candidates.filter((candidate) => wantedNames.includes(candidate.name)); const partial = candidates.filter((candidate) => ( candidate.name.length >= 4 && wantedNames.some((name) => name.includes(candidate.name) || candidate.name.includes(name)) )); return [...exact, ...partial] .sort((left, right) => right.rank - left.rank || right.level - left.level || left.entry - right.entry)[0] ?.entry ?? 0; } function ascensionEncounterRowsForMap(runtimeCatalog, mapId) { return runtimeCatalog.encounters .filter((encounter) => ( encounter.mapIds.includes(mapId) && Number.isInteger(encounter.creatureId) && encounter.creatureId > 0 )) .map((encounter) => ({ map: mapId, encounterId: encounter.encounterId, creditType: 0, creditEntry: encounter.creatureId, creatureEntry: encounter.creatureId, name: encounter.name, orderIndex: encounter.orderIndex ?? encounter.encounterId, difficultyId: encounter.difficultyId ?? 0, sourceRowIds: encounter.sourceRowIds, source: "Ascension DungeonEncounterExtra.dbc", })) .sort((left, right) => left.orderIndex - right.orderIndex || left.encounterId - right.encounterId); } function epochEncounterRowsForMap( epochClientCatalog, sourceRowsByEncounterId, mapId, creatureRows, templateByEntry, scriptedEntries, ) { return epochClientCatalog.encounters .filter((encounter) => encounter.mapId === mapId) .flatMap((encounter) => { const sourceRow = sourceRowsByEncounterId.get(encounter.encounterId); if (!sourceRow) { throw new Error( `${encounter.name}: AzerothCore instance_encounters has no row for encounter ${encounter.encounterId}.`, ); } const creditType = numeric(firstValue(sourceRow, ["creditType"])); const creditEntry = numeric(firstValue(sourceRow, ["creditEntry"])); const base = { map: mapId, encounterId: encounter.encounterId, creditType, creditEntry, creatureEntry: encounterCreatureEntry( encounter, sourceRow, creatureRows, templateByEntry, ), lastEncounterDungeon: numeric(firstValue(sourceRow, ["lastEncounterDungeon"])), name: encounter.name, comment: String(firstValue(sourceRow, ["comment"]) ?? encounter.name), orderIndex: encounter.orderIndex, difficultyId: encounter.difficultyId, sourceRowIds: [encounter.encounterId], source: "Epoch DungeonEncounter.dbc + AzerothCore instance_encounters", }; const scriptedGroup = (scriptedEntries ?? []).filter( (entry) => normalizedName(entry.group) === normalizedName(encounter.name), ); return scriptedGroup.length ? scriptedGroup.map((entry, index) => ({ ...base, encounterId: encounter.encounterId * 100 + index, creatureEntry: Number(entry.entry), name: entry.name, orderIndex: encounter.orderIndex + index, sourceRowIds: [encounter.encounterId, Number(entry.entry)], source: `${base.source} + pinned scripted encounter roster`, })) : [base]; }) .sort((left, right) => ( left.difficultyId - right.difficultyId || left.orderIndex - right.orderIndex || left.encounterId - right.encounterId )); } export async function importAzerothCoreWorld({ sourceRoot = defaultSourceRoot, outputRoot = defaultOutputRoot, referenceOutput = defaultReferenceOutput, baseStatsOutput = defaultBaseStatsOutput, commit = DEFAULT_AZEROTHCORE_COMMIT, branch = "master", dirty = false, sourceLabel = "AzerothCore world SQL snapshot", fetch = false, } = {}) { const resolvedSourceRoot = path.resolve(sourceRoot); const resolvedOutputRoot = path.resolve(outputRoot); const resolvedReferenceOutput = path.resolve(referenceOutput); if (fetch) await fetchSources(resolvedSourceRoot, commit); const recipes = await loadDungeonRecipes(); const recipesByMap = groupByMap(recipes); const wantedMapIds = new Set(recipesByMap.keys()); const runtimeCatalog = await readJson( path.join(projectRoot, "src", "game", "generated", "manastormRuntimeCatalog.json"), ); const epochClientCatalog = await readJson(epochClientCatalogFile); const epochDungeonSource = await readJson(epochDungeonSourceFile); const epochDungeonByMap = new Map( epochDungeonSource.dungeons.map((dungeon) => [dungeon.mapId, dungeon]), ); const epochEncounterIds = new Set( epochClientCatalog.encounters.map((encounter) => encounter.encounterId), ); const epochEncounterNames = new Set( epochClientCatalog.encounters.map((encounter) => normalizedName(encounter.name)), ); const creaturesByMap = new Map(); const creatureEntries = new Set(); const creatureGuids = new Set(); const tableReports = []; tableReports.push(await parseTable(resolvedSourceRoot, "creature", (row) => { const mapId = numeric(firstValue(row, ["map", "mapId"])); if (!wantedMapIds.has(mapId)) return; groupedPush(creaturesByMap, mapId, row); creatureEntries.add(numeric(firstValue(row, ["id1", "entry", "creatureId"]))); creatureGuids.add(numeric(firstValue(row, ["guid"]))); })); for (const dungeon of epochDungeonSource.dungeons) { for (const entry of dungeon.scriptedEncounterEntries ?? []) { creatureEntries.add(Number(entry.entry)); } } for (const encounter of runtimeCatalog.encounters) { if ( Number.isInteger(encounter.creatureId) && encounter.creatureId > 0 && encounter.mapIds.some((mapId) => wantedMapIds.has(Number(mapId))) ) { creatureEntries.add(encounter.creatureId); } } const instanceEncountersById = new Map(); tableReports.push(await parseTable(resolvedSourceRoot, "instance_encounters", (row) => { const encounterId = numeric(firstValue(row, ["entry", "encounterId"])); if (!epochEncounterIds.has(encounterId)) return; instanceEncountersById.set(encounterId, row); if (numeric(firstValue(row, ["creditType"])) === 0) { creatureEntries.add(numeric(firstValue(row, ["creditEntry"]))); } })); const templateByEntry = new Map(); tableReports.push(await parseTable(resolvedSourceRoot, "creature_template", (row) => { const entry = numeric(firstValue(row, ["entry", "CreatureID", "creatureId"])); const name = normalizedName(firstValue(row, ["name", "Name"])); if ( !creatureEntries.has(entry) && !epochEncounterNames.has(name) && !WAILING_CAVERNS_REFERENCE_ENTRIES.has(entry) ) return; templateByEntry.set(entry, row); creatureEntries.add(entry); })); const modelsByEntry = new Map(); tableReports.push(await parseTable(resolvedSourceRoot, "creature_template_model", (row) => { const entry = numeric(firstValue(row, ["CreatureID", "entry", "creatureId"])); if (creatureEntries.has(entry)) groupedPush(modelsByEntry, entry, row); })); const spellsByEntry = new Map(); tableReports.push(await parseTable(resolvedSourceRoot, "creature_template_spell", (row) => { const entry = numeric(firstValue(row, ["CreatureID", "entry", "creatureId"])); if (creatureEntries.has(entry)) groupedPush(spellsByEntry, entry, row); })); for (const [entry, rows] of spellsByEntry) { const template = templateByEntry.get(entry); if (!template) continue; for (const row of rows.sort((left, right) => ( numeric(firstValue(left, ["Index", "index"])) - numeric(firstValue(right, ["Index", "index"])) ))) { const index = numeric(firstValue(row, ["Index", "index"])); const spellId = numeric(firstValue(row, ["Spell", "spell", "spellId"])); if (spellId > 0 && index >= 0 && index < 8) template[`spell${index + 1}`] = spellId; } } const creatureClassLevelStats = []; tableReports.push(await parseTable(resolvedSourceRoot, "creature_classlevelstats", (row) => { creatureClassLevelStats.push(creatureClassLevelReference(row)); })); const smartScriptsByEntry = new Map(); tableReports.push(await parseTable(resolvedSourceRoot, "smart_scripts", (row) => { const entry = numeric(firstValue(row, ["entryorguid"])); const sourceType = numeric(firstValue(row, ["source_type", "sourceType"])); if (entry <= 0 || sourceType !== 0 || !creatureEntries.has(entry)) return; groupedPush(smartScriptsByEntry, entry, row); })); const wailingEscortWaypoints = []; tableReports.push(await parseTable(resolvedSourceRoot, "waypoints", (row) => { const entry = numeric(firstValue(row, ["entry", "Entry"])); if (entry !== 3678) return; wailingEscortWaypoints.push({ point: numeric(firstValue(row, ["pointid", "pointId", "point"])), position: [ -numeric(firstValue(row, ["position_x", "positionX"])), numeric(firstValue(row, ["position_z", "positionZ"])), numeric(firstValue(row, ["position_y", "positionY"])), ], }); })); const wailingSummonGroups = []; tableReports.push(await parseTable(resolvedSourceRoot, "creature_summon_groups", (row) => { const summonerId = numeric(firstValue(row, ["summonerId", "summonerid", "summoner"])); if (summonerId !== 3678) return; wailingSummonGroups.push({ group: numeric(firstValue(row, ["groupId", "groupid", "group"])), entry: numeric(firstValue(row, ["entry", "creatureId"])), position: [ -numeric(firstValue(row, ["position_x", "positionX"])), numeric(firstValue(row, ["position_z", "positionZ"])), numeric(firstValue(row, ["position_y", "positionY"])), ], orientation: -numeric(firstValue(row, ["orientation", "orientationY", "o"])), }); })); const playerClassStats = new Map(); tableReports.push(await parseTable(resolvedSourceRoot, "player_class_stats", (row) => { const characterClass = numeric(firstValue(row, ["Class", "class"])); const level = numeric(firstValue(row, ["Level", "level"])); playerClassStats.set(`${characterClass}:${level}`, { resources: [ numeric(firstValue(row, ["BaseHP", "basehp"])), numeric(firstValue(row, ["BaseMana", "basemana"])), ], attributes: [ numeric(firstValue(row, ["Strength", "strength"])), numeric(firstValue(row, ["Agility", "agility"])), numeric(firstValue(row, ["Stamina", "stamina"])), numeric(firstValue(row, ["Intellect", "intellect"])), numeric(firstValue(row, ["Spirit", "spirit"])), ], }); })); const playerRaceStats = new Map(); tableReports.push(await parseTable(resolvedSourceRoot, "player_race_stats", (row) => { const race = numeric(firstValue(row, ["Race", "race"])); playerRaceStats.set(race, [ numeric(firstValue(row, ["Strength", "strength"])), numeric(firstValue(row, ["Agility", "agility"])), numeric(firstValue(row, ["Stamina", "stamina"])), numeric(firstValue(row, ["Intellect", "intellect"])), numeric(firstValue(row, ["Spirit", "spirit"])), ]); })); const xpForLevel = []; tableReports.push(await parseTable(resolvedSourceRoot, "player_xp_for_level", (row) => { const level = numeric(firstValue(row, ["Level", "level"])); if (level > 0) { xpForLevel[level - 1] = numeric(firstValue(row, ["Experience", "experience"])); } })); const addonsByGuid = new Map(); const pathIds = new Set(); tableReports.push(await parseTable(resolvedSourceRoot, "creature_addon", (row) => { const guid = numeric(firstValue(row, ["guid"])); if (!creatureGuids.has(guid)) return; addonsByGuid.set(guid, row); const pathId = numeric(firstValue(row, ["path_id", "pathId"])); if (pathId > 0) pathIds.add(pathId); })); const formations = []; tableReports.push(await parseTable(resolvedSourceRoot, "creature_formations", (row) => { const leader = numeric(firstValue(row, ["leaderGUID", "leaderGuid", "leader"])); const member = numeric(firstValue(row, ["memberGUID", "memberGuid", "member"])); if (creatureGuids.has(leader) || creatureGuids.has(member)) formations.push(row); })); const waypoints = []; tableReports.push(await parseTable(resolvedSourceRoot, "waypoint_data", (row) => { const id = numeric(firstValue(row, ["id", "pathId", "path_id"])); if (pathIds.has(id)) waypoints.push(row); })); const gameObjectsByMap = new Map(); const gameObjectEntries = new Set(); tableReports.push(await parseTable(resolvedSourceRoot, "gameobject", (row) => { const mapId = numeric(firstValue(row, ["map", "mapId"])); if (!wantedMapIds.has(mapId)) return; groupedPush(gameObjectsByMap, mapId, row); gameObjectEntries.add(numeric(firstValue(row, ["id", "entry", "gameObjectId"]))); })); const gameObjectTemplates = new Map(); tableReports.push(await parseTable(resolvedSourceRoot, "gameobject_template", (row) => { const entry = numeric(firstValue(row, ["entry", "gameObjectId"])); if (gameObjectEntries.has(entry)) gameObjectTemplates.set(entry, row); })); const entrancesByMap = new Map(); tableReports.push(await parseTable(resolvedSourceRoot, "areatrigger_teleport", (row) => { const mapId = numeric(firstValue(row, ["target_map", "targetMap"])); if (wantedMapIds.has(mapId)) groupedPush(entrancesByMap, mapId, row); })); await mkdir(resolvedOutputRoot, { recursive: true }); const outputs = []; const encountersByMap = new Map(); for (const recipe of recipes) { const creatureRows = creaturesByMap.get(recipe.mapId) ?? []; const sourceEncounterRows = recipe.campaignSource === "epoch" ? epochEncounterRowsForMap( epochClientCatalog, instanceEncountersById, recipe.mapId, creatureRows, templateByEntry, epochDungeonByMap.get(recipe.mapId)?.scriptedEncounterEntries, ) : ascensionEncounterRowsForMap(runtimeCatalog, recipe.mapId); const encounterRows = recipe.slug === "wailing-caverns" ? WAILING_CAVERNS_REQUIRED_ENCOUNTERS : sourceEncounterRows; encountersByMap.set(recipe.mapId, encounterRows); const entries = new Set([ ...creatureRows.map((row) => numeric(firstValue(row, ["id1", "entry", "creatureId"]))), ...encounterRows.map((row) => numeric(row.creatureEntry)).filter(Boolean), ]); const guids = new Set(creatureRows.map((row) => numeric(firstValue(row, ["guid"])))); const mapAddons = [...addonsByGuid.values()].filter( (row) => guids.has(numeric(firstValue(row, ["guid"]))), ); const mapPathIds = new Set( mapAddons.map((row) => numeric(firstValue(row, ["path_id", "pathId"]))).filter(Boolean), ); const gameObjectRows = gameObjectsByMap.get(recipe.mapId) ?? []; const objectEntries = new Set( gameObjectRows.map((row) => numeric(firstValue(row, ["id", "entry", "gameObjectId"]))), ); const output = { schemaVersion: 2, revision: `azerothcore-wotlk@${commit}`, schemaHash: sha256(stableJson(tableReports.map(({ columns, sha256: hash }) => ({ columns, sha256: hash, })))), provenance: { repository: "https://github.com/azerothcore/azerothcore-wotlk", sourceLabel, branch, commit, dirty, encounterSource: recipe.campaignSource === "epoch" ? "Epoch DungeonEncounter.dbc joined to AzerothCore instance_encounters" : "Installed Ascension DungeonEncounterExtra.dbc runtime catalog", }, tables: { creature: creatureRows, creature_template: [...entries].flatMap((entry) => templateByEntry.get(entry) ?? []), creature_template_model: [...entries].flatMap((entry) => modelsByEntry.get(entry) ?? []), creature_template_spell: [...entries].flatMap((entry) => spellsByEntry.get(entry) ?? []), smart_scripts: [...entries].flatMap((entry) => smartScriptsByEntry.get(entry) ?? []), creature_addon: mapAddons, creature_formations: formations.filter((row) => ( guids.has(numeric(firstValue(row, ["leaderGUID", "leaderGuid", "leader"]))) || guids.has(numeric(firstValue(row, ["memberGUID", "memberGuid", "member"]))) )), waypoint_data: waypoints.filter( (row) => mapPathIds.has(numeric(firstValue(row, ["id", "pathId", "path_id"]))), ), gameobject: gameObjectRows, gameobject_template: [...objectEntries].flatMap( (entry) => gameObjectTemplates.get(entry) ?? [], ), instance_encounters: encounterRows, areatrigger_teleport: entrancesByMap.get(recipe.mapId) ?? [], }, }; const file = path.join(resolvedOutputRoot, `${recipe.slug}.json`); await writeJson(file, output); outputs.push({ slug: recipe.slug, mapId: recipe.mapId, campaignSource: recipe.campaignSource, file: path.relative(projectRoot, file).replace(/\\/g, "/"), creatures: output.tables.creature.length, creatureTemplates: output.tables.creature_template.length, creatureModels: output.tables.creature_template_model.length, patrolPoints: output.tables.waypoint_data.length, formations: output.tables.creature_formations.length, gameObjects: output.tables.gameobject.length, encounters: output.tables.instance_encounters.length, entrances: output.tables.areatrigger_teleport.length, }); } const previousBaseStats = await readJson( path.join(projectRoot, "src", "game", "generated", "wow335BaseStats.json"), ); const attributes = {}; for (const key of Object.keys(previousBaseStats.attributes)) { const [race, characterClass] = key.split(":").map(Number); const raceOffsets = playerRaceStats.get(race) ?? [0, 0, 0, 0, 0]; attributes[key] = Array.from({ length: 80 }, (_, index) => { const row = playerClassStats.get(`${characterClass}:${index + 1}`); return row ? row.attributes.map((value, attributeIndex) => value + raceOffsets[attributeIndex]) : null; }); } const resources = {}; for (const characterClass of new Set( [...playerClassStats.keys()].map((key) => Number(key.split(":")[0])), )) { resources[characterClass] = Array.from({ length: 80 }, (_, index) => ( playerClassStats.get(`${characterClass}:${index + 1}`)?.resources ?? null )); } const normalizedTemplates = Object.fromEntries( [...templateByEntry.entries()] .sort(([left], [right]) => left - right) .map(([entry, row]) => [entry, creatureTemplateReference(row)]), ); const normalizedCreatureClassStats = Object.fromEntries( creatureClassLevelStats .sort((left, right) => left.level - right.level || left.unitClass - right.unitClass) .map((row) => [`${row.level}:${row.unitClass}`, row]), ); const wailingAbilitiesByEntry = {}; const unsupportedWailingBossActions = []; const requiredWailingBosses = new Set( WAILING_CAVERNS_REQUIRED_ENCOUNTERS.map((encounter) => encounter.creatureEntry), ); for (const entry of WAILING_CAVERNS_REFERENCE_ENTRIES) { const rows = smartScriptsByEntry.get(entry) ?? []; const abilities = rows.map(wailingAbility).filter(Boolean); if (abilities.length) wailingAbilitiesByEntry[entry] = abilities; if (requiredWailingBosses.has(entry)) { for (const row of rows) { if (numeric(firstValue(row, ["action_type", "actionType"])) !== 11) continue; const spellId = numeric(firstValue(row, ["action_param1"])); const eventType = numeric(firstValue(row, ["event_type", "eventType"])); if ([0, 2, 14].includes(eventType) && !WAILING_SPELL_BEHAVIOR[spellId]) { unsupportedWailingBossActions.push({ entry, rowId: numeric(firstValue(row, ["id"])), spellId, comment: String(firstValue(row, ["comment"]) ?? ""), }); } } } } const sourceFiles = tableReports.map(({ file, columns, rows, byteLength, sha256: hash }) => ({ table: path.basename(file, ".sql"), file: path.basename(file), columns, rows, byteLength, sha256: hash, })); const reference = { schemaVersion: 2, source: { label: sourceLabel, repository: "https://github.com/azerothcore/azerothcore-wotlk", branch, commit, dirty, }, player: { classStats: Object.fromEntries( [...playerClassStats.entries()] .sort(([left], [right]) => { const [leftClass, leftLevel] = left.split(":").map(Number); const [rightClass, rightLevel] = right.split(":").map(Number); return leftClass - rightClass || leftLevel - rightLevel; }), ), raceOffsets: Object.fromEntries( [...playerRaceStats.entries()].sort(([left], [right]) => left - right), ), attributes, resources, xpForLevel, }, creature: { classLevelStats: normalizedCreatureClassStats, templates: normalizedTemplates, }, wailingCaverns: { requiredBossEntries: WAILING_CAVERNS_REQUIRED_ENCOUNTERS .map((encounter) => encounter.creatureEntry), despawnOnInitializeEntries: [5912], abilitiesByEntry: wailingAbilitiesByEntry, unsupportedRequiredBossActions: unsupportedWailingBossActions, disciple: { entry: 3678, position: [134.965, -78.0945, 125.402], escortWaypoints: wailingEscortWaypoints.sort((left, right) => left.point - right.point), summonGroups: wailingSummonGroups.sort( (left, right) => left.group - right.group || left.entry - right.entry, ), }, mutanus: { entry: 3654, position: [-151.27, -102.82, 252.26], eventOnly: true, }, smartScripts: Object.fromEntries( [...WAILING_CAVERNS_REFERENCE_ENTRIES] .filter((entry) => smartScriptsByEntry.has(entry)) .map((entry) => [entry, smartScriptsByEntry.get(entry)]), ), }, }; await writeJson(resolvedReferenceOutput, reference); await writeJson( path.join(path.dirname(resolvedReferenceOutput), "azerothCoreWorldAi.json"), { schemaVersion: 1, source: reference.source, creatureSpellsByEntry: Object.fromEntries( [...spellsByEntry.entries()] .sort(([left], [right]) => left - right) .map(([entry, rows]) => [ entry, rows .map((row) => ({ index: numeric(firstValue(row, ["Index", "index"])), spellId: numeric(firstValue(row, ["Spell", "spell", "spellId"])), verifiedBuild: numeric(firstValue(row, ["VerifiedBuild", "verifiedBuild"])), })) .sort((left, right) => left.index - right.index || left.spellId - right.spellId), ]), ), smartScriptsByEntry: Object.fromEntries( [...smartScriptsByEntry.entries()] .sort(([left], [right]) => left - right) .map(([entry, rows]) => [ entry, [...rows].sort((left, right) => ( numeric(firstValue(left, ["source_type", "sourceType"])) - numeric(firstValue(right, ["source_type", "sourceType"])) || numeric(firstValue(left, ["id"])) - numeric(firstValue(right, ["id"])) )), ]), ), encountersByMap: Object.fromEntries( [...encountersByMap.entries()].sort(([left], [right]) => left - right), ), }, ); await writeJson( path.join(path.dirname(resolvedReferenceOutput), "azerothCoreSourceLock.json"), { schemaVersion: 1, source: { ...reference.source, dirtyStateWarning: dirty ? "The source checkout contained uncommitted changes; individual SQL hashes are authoritative." : null, }, schemas: { normalizedReference: reference.schemaVersion, normalizedWorldAi: 1, sourceLock: 1, dungeonSnapshot: 2, playerBaseStats: 2, }, files: sourceFiles, }, ); if (baseStatsOutput) { await writeJson(path.resolve(baseStatsOutput), { schemaVersion: 2, source: `${sourceLabel} player_class_stats/player_race_stats`, attributes, resources, }); } const unexpectedMissingPopulation = outputs.filter( (output) => output.creatures === 0 && output.campaignSource !== "ascension", ); const report = { schemaVersion: 2, status: unexpectedMissingPopulation.length ? "review-required" : "green", source: { repository: "https://github.com/azerothcore/azerothcore-wotlk", label: sourceLabel, branch, commit, dirty, root: sourceLabel, }, outputRoot: path.relative(projectRoot, resolvedOutputRoot).replace(/\\/g, "/"), referenceOutput: path.relative(projectRoot, resolvedReferenceOutput).replace(/\\/g, "/"), tables: sourceFiles, dungeons: outputs, }; await writeJson(path.join(resolvedOutputRoot, "import-report.json"), report); return report; } function commandOptions(argv) { const options = {}; for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]; if (argument === "--fetch") options.fetch = true; else if (argument === "--source") options.sourceRoot = argv[++index]; else if (argument === "--output") options.outputRoot = argv[++index]; else if (argument === "--reference-output") options.referenceOutput = argv[++index]; else if (argument === "--base-stats-output") options.baseStatsOutput = argv[++index]; else if (argument === "--commit") options.commit = argv[++index]; else if (argument === "--branch") options.branch = argv[++index]; else if (argument === "--dirty") options.dirty = true; else if (argument === "--source-label") options.sourceLabel = argv[++index]; else throw new Error(`Unknown option: ${argument}`); } return options; } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { try { const report = await importAzerothCoreWorld(commandOptions(process.argv.slice(2))); console.log(JSON.stringify({ status: report.status, source: report.source, outputRoot: report.outputRoot, dungeons: report.dungeons.length, creatureSpawns: report.dungeons.reduce((sum, dungeon) => sum + dungeon.creatures, 0), encounters: report.dungeons.reduce((sum, dungeon) => sum + dungeon.encounters, 0), missingCreatureMaps: report.dungeons .filter((dungeon) => dungeon.creatures === 0 && dungeon.campaignSource !== "ascension") .map((dungeon) => dungeon.slug), expectedFallbackMaps: report.dungeons .filter((dungeon) => dungeon.creatures === 0 && dungeon.campaignSource === "ascension") .map((dungeon) => dungeon.slug), }, null, 2)); if (report.status !== "green") process.exitCode = 1; } catch (error) { console.error(JSON.stringify({ status: "error", error: error instanceof Error ? error.message : String(error), }, null, 2)); process.exitCode = 1; } }