Files
healer-man/scripts/dungeon-pipeline/import-azerothcore-world.mjs
T
2026-08-14 15:56:39 -04:00

1369 lines
48 KiB
JavaScript

#!/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;
}
}