1369 lines
48 KiB
JavaScript
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;
|
|
}
|
|
}
|