Files
healer-man/scripts/dungeon-pipeline/compile-campaign-catalog.mjs
T
2026-08-14 15:56:39 -04:00

1152 lines
41 KiB
JavaScript

#!/usr/bin/env node
import { access, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { gzipSync } from "node:zlib";
import { mergeCampaignAvailability } from "./availability.mjs";
import { DEFAULT_AZEROTHCORE_COMMIT } from "./import-azerothcore-world.mjs";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const recipesRoot = path.join(projectRoot, "dungeon-pipeline", "recipes");
const workRoot = path.resolve(process.env.DUNGEON_PIPELINE_WORK ?? path.join(projectRoot, "..", "HealerMan-Storage", "pipeline-work", "dungeons"));
const outputFile = path.join(projectRoot, "src", "game", "generated", "dungeonCampaignCatalog.json");
const compressedOutputFile = path.join(
projectRoot,
"src",
"game",
"generated",
"dungeonCampaignCatalog.compressed.ts",
);
const availabilityFile = path.join(projectRoot, "src", "game", "generated", "dungeonAvailability.json");
const packageFile = path.join(projectRoot, "src", "game", "generated", "manastormAssetPackages.json");
const fivePlayerImportsFile = path.join(
projectRoot,
"src",
"game",
"generated",
"fivePlayerDungeonImports.json",
);
const epochCreatureCatalogFile = path.join(
projectRoot,
"src",
"game",
"generated",
"epochCreatureModels.json",
);
const scriptSpellCatalogFile = path.join(
projectRoot,
"src",
"game",
"generated",
"azerothCoreScriptSpells.json",
);
const azerothCoreReferenceFile = path.join(
projectRoot,
"src",
"game",
"generated",
"azerothCoreReference.json",
);
const encounterFile = path.join(projectRoot, "src", "game", "generated", "manastormRuntimeCatalog.json");
const sourceCatalogFile = path.join(projectRoot, "src", "game", "generated", "manastormCatalog.json");
const epochCatalogFile = path.join(
projectRoot,
"src",
"game",
"generated",
"epochDungeonClientCatalog.json",
);
const campaignFile = path.join(projectRoot, "dungeon-pipeline", "dungeon-campaign.json");
const defaultSpellDbc = path.join(
projectRoot,
"..",
"LadiksMPQEditor",
"client-current",
"area-52",
"patch-D",
"DBFilesClient",
"Spell.dbc",
);
const epochSpellDbc = path.join(
workRoot,
"epoch-client",
"DBFilesClient",
"Spell.dbc",
);
const WAILING_SERVER_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(([creatureId, name], orderIndex) => ({
encounterId: 43_000 + orderIndex,
mapIds: [43],
modeIds: [0],
difficultyId: 0,
orderIndex,
creatureId,
name,
sourceRowIds: [],
source: "AzerothCore SmartAI/instance_wailing_caverns",
...(creatureId === 3654 ? { eventOnly: true } : {}),
})));
const PALETTES = Object.freeze([
["#435b50", "#a8d7b1"],
["#584568", "#d2aef0"],
["#61473b", "#efbd83"],
["#3d5368", "#9bcff1"],
["#633f49", "#f0aaa0"],
["#535b35", "#d2dc7f"],
["#4e4c6d", "#b9b8f2"],
]);
const EXPANSION_PRESENTATION = Object.freeze({
classic: {
background: "#080d0c",
fog: "#0a1210",
ambient: "#a8b49b",
sky: "#879c8a",
ground: "#090b09",
light: "#d2c79d",
},
"burning-crusade": {
background: "#090b12",
fog: "#0b0e18",
ambient: "#a9a1c1",
sky: "#8588a7",
ground: "#090910",
light: "#c8b7df",
},
wrath: {
background: "#081016",
fog: "#0a141c",
ambient: "#9eb8c2",
sky: "#7896a5",
ground: "#070b0e",
light: "#c4dce3",
},
});
async function exists(file) {
try {
await access(file);
return true;
} catch {
return false;
}
}
async function readJson(file) {
return JSON.parse(await readFile(file, "utf8"));
}
function stableHash(value) {
let hash = 2_166_136_261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16_777_619);
}
return hash >>> 0;
}
function rounded(value) {
return Number(value.toFixed(4));
}
function vector(values) {
return values.map((value) => rounded(Number(value) || 0));
}
function add(left, right) {
return left.map((value, axis) => rounded(value + right[axis]));
}
function subtract(left, right) {
return left.map((value, axis) => rounded(value - right[axis]));
}
function interpolate(left, right, alpha) {
return left.map((value, axis) => rounded(value + (right[axis] - value) * alpha));
}
function normalizedName(value) {
return String(value ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function slugPart(value) {
return String(value ?? "")
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "unknown";
}
function inferEnvironmentMode(recipe, assetPackage) {
if (recipe.environmentMode !== "detect") return recipe.environmentMode;
const sourceFiles = assetPackage.visual.flatMap((chunk) => chunk.provenance?.sourceFiles ?? []);
return sourceFiles.some((file) => /(^|[/\\])adt_\d+_\d+\./i.test(String(file)))
? "adt-hybrid"
: "global-wmo";
}
const ADT_SOURCE_BASES = new Set([
"negative-z-y-negative-x",
"z-y-negative-x",
]);
const GLOBAL_WMO_SOURCE_BASES = new Set(["draft", "flip-x"]);
function sourceBasisFor(recipe, environmentMode) {
if (environmentMode !== "adt-hybrid") {
const basis = recipe.globalWmoSourceBasis ?? "draft";
if (!GLOBAL_WMO_SOURCE_BASES.has(basis)) {
throw new Error(`${recipe.slug}: unsupported global WMO source basis ${basis}.`);
}
return basis;
}
const basis = recipe.adtSourceBasis
?? (recipe.clientBuild === "3.3.5a-Epoch"
? "z-y-negative-x"
: "negative-z-y-negative-x");
if (!ADT_SOURCE_BASES.has(basis)) {
throw new Error(`${recipe.slug}: unsupported ADT source basis ${basis}.`);
}
return basis;
}
/**
* Runtime drafts retain the original pipeline convention. wow.export ADT
* packages have an additional horizontal basis change baked into their GLBs.
*/
function sourceToPackageBasis(position, environmentMode, sourceBasis) {
const point = vector(position);
if (environmentMode === "adt-hybrid") {
return [
sourceBasis === "z-y-negative-x" ? point[2] : -point[2],
point[1],
-point[0],
];
}
return sourceBasis === "flip-x"
? [-point[0], point[1], point[2]]
: point;
}
function coordinateMapper(draft, environmentMode, playerAnchor, sourceBasis) {
const sourceEntrance = draft?.entrance
?? draft?.spawns?.[0]?.position
?? playerAnchor.position;
const basisEntrance = sourceToPackageBasis(sourceEntrance, environmentMode, sourceBasis);
const translation = subtract(playerAnchor.position, basisEntrance);
return (position) => add(
sourceToPackageBasis(position, environmentMode, sourceBasis),
translation,
);
}
function uniqueEncounters(encounters, mapId) {
const byIdentity = new Map();
for (const encounter of encounters
.filter((candidate) => candidate.mapIds.includes(mapId) && candidate.creatureId > 0)
.sort((left, right) => (
Number(!left.modeIds.includes(0)) - Number(!right.modeIds.includes(0))
|| left.encounterId - right.encounterId
))) {
const key = `${encounter.creatureId}:${normalizedName(encounter.name)}`;
if (!byIdentity.has(key)) byIdentity.set(key, encounter);
}
return [...byIdentity.values()];
}
function uniqueDraftEncounters(encounterRows, mapId) {
const byIdentity = new Map();
for (const row of [...(encounterRows ?? [])].sort((left, right) => (
Number(left.difficultyId ?? 0) - Number(right.difficultyId ?? 0)
|| Number(left.orderIndex ?? 0) - Number(right.orderIndex ?? 0)
|| Number(left.encounterId ?? 0) - Number(right.encounterId ?? 0)
))) {
if (Number(row.map ?? row.mapId) !== mapId) continue;
if (/^escaped from /i.test(String(row.name ?? ""))) continue;
const resolvedEntry = Number(row.creatureEntry ?? 0);
const creatureId = resolvedEntry > 0
? resolvedEntry
: 800_000_000 + stableHash(normalizedName(row.name)) % 100_000_000;
const encounter = {
encounterId: Number(row.encounterId),
mapIds: [mapId],
modeIds: [Number(row.difficultyId ?? 0)],
difficultyId: Number(row.difficultyId ?? 0),
orderIndex: Number(row.orderIndex ?? row.encounterId ?? 0),
creatureId,
name: String(row.name || row.comment || `Encounter ${row.encounterId}`),
sourceRowIds: row.sourceRowIds ?? [Number(row.encounterId)],
...(resolvedEntry > 0 ? {} : { syntheticGroupEncounter: true }),
};
const key = `${creatureId}:${normalizedName(encounter.name)}`;
if (!byIdentity.has(key)) byIdentity.set(key, encounter);
}
return [...byIdentity.values()].sort((left, right) => (
left.orderIndex - right.orderIndex || left.encounterId - right.encounterId
));
}
function archetypeFor(name) {
const value = normalizedName(name);
if (/murloc|murk|glub|fin/.test(value)) return "murloc";
if (/ooze|slime|glob|ectoplasm|viscid/.test(value)) return "ooze";
if (/tree|plant|lash|briar|fung|mushroom|spore|bog/.test(value)) return "plant";
if (/turtle|tortoise|kresh/.test(value)) return "turtle";
if (/crocolisk|crocodile|gator/.test(value)) return "crocolisk";
if (/raptor|talon/.test(value)) return "raptor";
if (/serpent|snake|worm|wyrm/.test(value)) return "serpent";
if (/lizard|basilisk|salamander/.test(value)) return "lizard";
if (/dragon|drake|bat|harpy|gryphon|wing|phoenix|eagle/.test(value)) return "winged";
return "humanoid";
}
function schoolFor(mask) {
if (mask & 64) return "arcane";
if (mask & 32) return "shadow";
if (mask & 16) return "frost";
if (mask & 8) return "nature";
if (mask & 4) return "fire";
if (mask & 2) return "holy";
return "physical";
}
function parseSpells(buffer, wantedIds) {
if (!buffer || buffer.toString("ascii", 0, 4) !== "WDBC") return new Map();
const recordCount = buffer.readUInt32LE(4);
const fieldCount = buffer.readUInt32LE(8);
const recordSize = buffer.readUInt32LE(12);
const stringSize = buffer.readUInt32LE(16);
if (fieldCount < 226 || recordSize < fieldCount * 4) return new Map();
const stringsOffset = 20 + recordCount * recordSize;
const localizedShift = fieldCount >= 239 ? 6 : 0;
const readString = (offset) => {
if (!offset || offset >= stringSize) return "";
const start = stringsOffset + offset;
const end = buffer.indexOf(0, start);
return buffer.toString("utf8", start, end < 0 ? buffer.length : end);
};
const spells = new Map();
for (let index = 0; index < recordCount; index += 1) {
const base = 20 + index * recordSize;
const id = buffer.readUInt32LE(base);
if (!wantedIds.has(id)) continue;
const unsigned = (field) => buffer.readUInt32LE(base + field * 4);
let name = "";
for (let locale = 0; locale < 16 && !name; locale += 1) {
name = readString(unsigned(136 + localizedShift + locale));
}
spells.set(id, {
id,
name: name.replace(/\|c[0-9a-f]{8}|\|r/gi, "").trim() || `Spell ${id}`,
recoveryTimeMs: unsigned(29),
school: schoolFor(unsigned(225)),
});
}
return spells;
}
function basicAttack(cooldownMs = 1_900) {
return {
id: "basic-attack",
name: "Basic Attack",
delivery: "melee",
target: "primary",
school: "physical",
animation: "attack",
range: 4.5,
cooldownMs,
damageMultiplier: 1,
};
}
function spellAttack(spell, hash, boss) {
const area = /nova|roar|stomp|whirl|rain|volley|blizzard|explosion|quake|storm|breath|cleave|fear|horror|terror/i
.test(spell.name);
const melee = spell.school === "physical"
&& /strike|slash|cleave|bash|smash|rend|bite|claw|pummel/i.test(spell.name);
const healing = /\b(?:heal|healing|mend|renew|rejuvenat\w*|restor\w*|holy light|lay on hands)\b/i
.test(spell.name);
const control = /\b(?:fear|horror|terror|terrify)\b/i.test(spell.name)
? "fear"
: /\b(?:sleep|slumber)\b/i.test(spell.name)
? "sleep"
: /\b(?:root|entangl\w*|web wrap|web spray|freeze|frozen prison)\b/i.test(spell.name)
? "root"
: /\b(?:stun|hammer of justice|time stop)\b/i.test(spell.name)
? "stun"
: null;
const aura = /\b(?:enrage|frenzy|shield|reflection|reflect|defensive aura|battle aura|berserker aura|haste|hasten)\b/i
.test(spell.name);
const target = healing
? "lowest-health-friendly"
: aura
? "self"
: control === "fear" && area
? "nearby-party"
: area
? "nearby-party"
: control
? "random-party"
: "primary";
const effect = healing
? { kind: "heal", multiplier: boss ? 1.2 : 0.9 }
: control
? {
kind: "control",
mechanic: control,
durationMs: control === "stun"
? boss ? 2_500 : 1_500
: boss ? 4_500 : 3_000,
}
: aura
? {
kind: "aura",
aura: `spell-${spell.id}`,
durationMs: boss ? 12_000 : 8_000,
magnitude: boss ? 0.25 : 0.15,
}
: undefined;
return {
id: `spell-${spell.id}`,
name: spell.name,
spellId: spell.id,
delivery: target === "self" ? "area" : area ? "area" : melee ? "melee" : "projectile",
target,
school: spell.school,
animation: melee ? "attack" : "cast",
range: target === "self" ? 0 : melee ? 5.5 : area ? 22 : 28,
cooldownMs: Math.max(2_500, Math.min(12_000, spell.recoveryTimeMs || 4_000 + hash % 3_500)),
damageMultiplier: effect ? 0 : area ? (boss ? 0.82 : 0.62) : boss ? 1.35 : 1.12,
...(area ? { radius: boss ? 11 : 8 } : {}),
...(effect ? { effect } : {}),
...(healing
? {
condition: {
kind: "friendly-missing-health",
amount: 1,
range: 28,
},
}
: {}),
};
}
function fallbackSpecial(id, boss) {
const hash = stableHash(id);
const schools = ["arcane", "fire", "frost", "nature", "shadow", "holy"];
const school = schools[hash % schools.length];
return {
id: `${school}-surge`,
name: `${school[0].toUpperCase()}${school.slice(1)} Surge`,
delivery: boss ? "area" : "projectile",
target: boss ? "nearby-party" : "primary",
school,
animation: "cast",
range: boss ? 22 : 26,
cooldownMs: 5_000 + hash % 3_000,
damageMultiplier: boss ? 0.82 : 1.08,
...(boss ? { radius: 10 } : {}),
};
}
function combatFor(entity, boss, spells, scriptCatalog = undefined) {
const hash = stableHash(`${entity.id}:${entity.name}`);
const basicCooldown = Math.max(1_250, Math.min(3_200, Number(entity.baseAttackTimeMs) || 1_900));
const scriptDetails = (scriptCatalog?.scripts?.[entity.scriptName]?.spells ?? [])
.filter((spell) => spell.combat);
const scriptByIdentity = new Map();
for (const detail of scriptDetails) {
const identity = detail.symbol.replace(/_H$/, "");
const existing = scriptByIdentity.get(identity);
if (!existing || existing.symbol.endsWith("_H")) scriptByIdentity.set(identity, detail);
}
const orderedSpellDetails = [
...scriptByIdentity.values(),
...(entity.spells ?? []).map((spellId) => ({ spellId: Number(spellId) })),
];
const seenSpellIds = new Set();
const spellAttacks = orderedSpellDetails
.filter((detail) => {
if (!detail.spellId || seenSpellIds.has(detail.spellId)) return false;
seenSpellIds.add(detail.spellId);
return true;
})
.map((detail) => {
const spell = spells.get(Number(detail.spellId));
return spell
? {
...spell,
...(detail.cooldownMs ? { recoveryTimeMs: detail.cooldownMs } : {}),
}
: null;
})
.filter(Boolean)
.slice(0, boss ? 4 : 2)
.map((spell) => spellAttack(spell, hash, boss));
if (!spellAttacks.length && boss) spellAttacks.push(fallbackSpecial(entity.id, true));
const levels = (entity.level ?? []).map(Number).filter(Number.isFinite);
const level = levels.length
? Math.max(1, Math.round(levels.reduce((sum, value) => sum + value, 0) / levels.length))
: undefined;
return {
...(level ? { level } : {}),
healthMultiplier: boss ? 1.15 + Math.min(1.5, Number(entity.rank || 0) * 0.18) : 1,
damageMultiplier: boss ? 1.12 : 1,
moveSpeed: Math.max(2.4, Math.min(5.2, 3.2 * (Number(entity.runSpeed) || 1))),
leashRange: boss ? 70 : 52,
attacks: [basicAttack(basicCooldown), ...spellAttacks],
};
}
function wailingServerCombatFor(entity, boss, reference) {
const entry = Number(entity.entry);
const template = reference.creature.templates[String(entry)];
const basicCooldown = Math.max(
1_000,
Math.min(4_000, Number(template?.baseAttackTimeMs) || 1_900),
);
const scripted = reference.wailingCaverns.abilitiesByEntry[String(entry)] ?? [];
return {
serverEntry: entry,
healthMultiplier: boss ? 0.86 : 1,
damageMultiplier: 1,
moveSpeed: Math.max(2.4, Math.min(5.2, 3.2 * (Number(entity.runSpeed) || 1))),
leashRange: boss ? 55 : 42,
attacks: [{
...basicAttack(basicCooldown),
initialCooldownMs: [450, 450],
repeatCooldownMs: [basicCooldown, basicCooldown],
range: boss ? 5.2 : 4.3,
}, ...scripted],
};
}
function visualFor(id, name, boss, rank = 0, model = undefined) {
const hash = stableHash(`${id}:${name}`);
const palette = PALETTES[hash % PALETTES.length];
return {
primaryColor: palette[0],
accentColor: palette[1],
scale: boss
? rounded(1.08 + Math.min(0.62, Number(rank || 0) * 0.08) + ((hash >>> 8) % 4) * 0.05)
: rounded(0.88 + ((hash >>> 8) % 6) * 0.045),
archetype: archetypeFor(name),
...(model ? { model } : {}),
};
}
function creatureModelFor(catalog, source, dungeonImport) {
const model = dungeonImport?.creatureModels?.[String(source?.entry)]
?? catalog?.models?.[Number(source?.displayId)];
if (!model) return undefined;
return {
url: model.url,
rotationY: model.rotationY,
groundOffset: model.groundOffset,
labelHeight: model.labelHeight,
markerRadius: model.markerRadius,
};
}
function excludedAmbientEntity(entity, bossEntries) {
if (bossEntries.has(Number(entity.entry))) return false;
if ((Number(entity.unitFlags) & 0x02000002) !== 0) return true;
if ([31, 35].includes(Number(entity.faction))) return true;
const maximumLevel = Math.max(...(entity.level ?? []).map(Number).filter(Number.isFinite), 0);
if (maximumLevel <= 1) return true;
return /\b(?:bunny|trigger|invisible|camera|helper|marker|rat|snake|frog|roach|critter|maggot|beetle)\b/i
.test(entity.name);
}
function assetChunk(chunk) {
return {
id: chunk.id,
fileName: path.posix.basename(chunk.url),
url: chunk.url,
checksum: chunk.checksum,
...(chunk.byteLength ? { size: chunk.byteLength } : {}),
...(chunk.triangleCount ? { triangleCount: chunk.triangleCount } : {}),
};
}
function offsetPosition(position, index, radius = 3.2) {
if (index === 0) return position;
const angle = index * 2.399963229728653;
const distance = radius * (0.55 + (index % 3) * 0.2);
return [
rounded(position[0] + Math.cos(angle) * distance),
position[1],
rounded(position[2] + Math.sin(angle) * distance),
];
}
function syntheticEntity(id, name, boss, level) {
const entity = {
id,
entry: Number(id.match(/\d+/)?.[0] ?? stableHash(id)),
name,
level: [level, level],
rank: boss ? 3 : 0,
spells: [],
baseAttackTimeMs: boss ? 2_300 : 1_900,
runSpeed: boss ? 0.95 : 1,
};
return {
id,
kind: boss ? "boss" : "mob",
name,
...(boss ? { title: "Dungeon Boss", hasLoot: true } : { hasLoot: false }),
combat: {
...combatFor(entity, boss, new Map()),
...(!boss ? { attacks: [basicAttack(), fallbackSpecial(id, false)] } : {}),
},
visual: visualFor(id, name, boss, entity.rank),
};
}
function finiteBounds(points) {
const valid = points.filter((point) => point?.length === 3 && point.every(Number.isFinite));
const minimum = [0, 1, 2].map((axis) => Math.min(...valid.map((point) => point[axis])));
const maximum = [0, 1, 2].map((axis) => Math.max(...valid.map((point) => point[axis])));
const padding = [45, 20, 45];
return {
min: minimum.map((value, axis) => rounded(value - padding[axis])),
max: maximum.map((value, axis) => rounded(value + padding[axis])),
};
}
function presentationFor(recipe, assetPackage, environmentMode, bounds) {
const palette = EXPANSION_PRESENTATION[recipe.expansion] ?? EXPANSION_PRESENTATION.classic;
const span = Math.max(
bounds.max[0] - bounds.min[0],
bounds.max[2] - bounds.min[2],
);
return {
location: assetPackage.location || recipe.title,
theme: environmentMode === "adt-hybrid" ? "Open-world expedition" : "Instanced dungeon",
summary: `Fight through ${recipe.title}, defeat its encounter roster, and keep the party alive.`,
loadingMessage: `Preparing ${recipe.title}...`,
unchartedAreaName: `Uncharted ${recipe.title}`,
background: palette.background,
fog: { color: palette.fog, near: 36, far: Math.max(260, Math.min(1_800, rounded(span * 0.72))) },
ambientLight: { color: palette.ambient, intensity: 0.22 },
hemisphereLight: { skyColor: palette.sky, groundColor: palette.ground, intensity: 0.64 },
directionalLight: { color: palette.light, intensity: 1, offset: [12, 20, -7] },
materials: {
cutoutPatterns: ["_B1$"],
cutoutNames: [],
blendPatterns: ["_B2$"],
blendNames: [],
additivePatterns: ["_B4$"],
additiveNames: [],
},
map: {
width: 430,
height: 300,
padding: 24,
contours: [
"M24 205 C86 118 142 247 208 171 S334 71 406 126",
"M38 239 C109 183 172 268 240 208 S344 96 397 77",
],
},
};
}
async function compile() {
const campaign = await readJson(campaignFile);
if (campaign.scope !== "instanced-dungeons" || campaign.partySize !== 5) {
throw new Error("Dungeon campaign must explicitly target five-player instanced dungeons.");
}
const campaignSlugs = campaign.dungeons.map((entry) => entry.slug);
if (new Set(campaignSlugs).size !== campaignSlugs.length) {
throw new Error("Dungeon campaign contains duplicate slugs.");
}
const recipes = await Promise.all(
campaign.dungeons.map(async (entry) => {
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}.`);
}
return recipe;
}),
);
const packageRegistry = await readJson(packageFile);
const fivePlayerImports = await exists(fivePlayerImportsFile)
? await readJson(fivePlayerImportsFile)
: { imports: [] };
const epochCreatureCatalog = await exists(epochCreatureCatalogFile)
? await readJson(epochCreatureCatalogFile)
: { models: {} };
const scriptSpellCatalog = await exists(scriptSpellCatalogFile)
? await readJson(scriptSpellCatalogFile)
: { scripts: {} };
const azerothCoreReference = await readJson(azerothCoreReferenceFile);
if (azerothCoreReference.wailingCaverns.unsupportedRequiredBossActions.length) {
throw new Error("Wailing Caverns has unsupported required-boss SmartAI actions.");
}
const runtimeCatalog = await readJson(encounterFile);
const sourceCatalog = await readJson(sourceCatalogFile);
const epochCatalog = await readJson(epochCatalogFile);
const sourceMapsById = new Map(sourceCatalog.maps.map((entry) => [entry.mapId, entry]));
for (const entry of epochCatalog.maps ?? []) {
if (!sourceMapsById.has(entry.mapId)) sourceMapsById.set(entry.mapId, entry);
}
for (const recipe of recipes) {
const sourceMap = sourceMapsById.get(recipe.mapId);
if (!sourceMap) throw new Error(`${recipe.slug}: map ${recipe.mapId} is absent from Map.dbc.`);
if (sourceMap.instanceType !== 1) {
throw new Error(`${recipe.slug}: map ${recipe.mapId} is not a party-instance Map.dbc row.`);
}
}
const packagesByMap = new Map(packageRegistry.packages.map((entry) => [entry.mapId, entry]));
const importsBySlug = new Map(
(fivePlayerImports.imports ?? []).map((entry) => [entry.slug, entry]),
);
const drafts = new Map();
const wantedSpellIds = new Set();
for (const recipe of recipes) {
const file = path.join(workRoot, recipe.slug, "runtime-draft.json");
if (!await exists(file)) continue;
const draft = await readJson(file);
drafts.set(recipe.slug, draft);
for (const entity of draft.entities ?? []) {
for (const spellId of entity.spells ?? []) wantedSpellIds.add(Number(spellId));
for (const detail of scriptSpellCatalog.scripts?.[entity.scriptName]?.spells ?? []) {
if (detail.combat) wantedSpellIds.add(Number(detail.spellId));
}
}
}
const spellDbc = path.resolve(process.env.ASCENSION_SPELL_DBC ?? defaultSpellDbc);
const ascensionSpells = await exists(spellDbc)
? parseSpells(await readFile(spellDbc), wantedSpellIds)
: new Map();
const epochSpells = await exists(epochSpellDbc)
? parseSpells(await readFile(epochSpellDbc), wantedSpellIds)
: ascensionSpells;
const previousOutput = await exists(outputFile)
? await readJson(outputFile)
: { definitions: [], coverage: [] };
const previousDefinitions = new Map(
(previousOutput.definitions ?? []).map((definition) => [definition.id, definition]),
);
const previousCoverage = new Map(
(previousOutput.coverage ?? []).map((entry) => [entry.dungeonId, entry]),
);
const definitions = [];
const coverage = [];
for (const recipe of recipes) {
const dungeonImport = importsBySlug.get(recipe.slug);
// Manastorm packages contain the complete optimized dungeon geometry.
// Reuse them for full dungeons so the install ships one map package.
const assetPackage = packagesByMap.get(recipe.mapId) ?? dungeonImport?.environment;
if (!assetPackage) {
const previousDefinition = previousDefinitions.get(recipe.slug);
const previousCoverageEntry = previousCoverage.get(recipe.slug);
if (previousDefinition && previousCoverageEntry) {
definitions.push(previousDefinition);
coverage.push(previousCoverageEntry);
console.warn(`${recipe.slug}: retained the previous catalog entry because map ${recipe.mapId} has no environment package.`);
} else {
console.warn(`${recipe.slug}: skipped because map ${recipe.mapId} has no environment package.`);
}
continue;
}
const environmentMode = inferEnvironmentMode(recipe, assetPackage);
const sourceBasis = sourceBasisFor(recipe, environmentMode);
const playerAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "player");
const trashAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "trash");
const bossAnchor = assetPackage.anchors.find((anchor) => anchor.kind === "boss");
if (!playerAnchor || !trashAnchor || !bossAnchor) {
throw new Error(`${recipe.slug}: package is missing player, trash, or boss anchors.`);
}
const inferredEntranceYaw = Math.atan2(
trashAnchor.position[0] - playerAnchor.position[0],
trashAnchor.position[2] - playerAnchor.position[2],
);
const entranceYaw = Number.isFinite(playerAnchor.yaw)
? playerAnchor.yaw
: inferredEntranceYaw;
const draft = drafts.get(recipe.slug);
const encounters = recipe.slug === "wailing-caverns"
? WAILING_SERVER_ENCOUNTERS
: recipe.clientBuild === "3.3.5a-Epoch"
? uniqueDraftEncounters(draft?.encounterRows, recipe.mapId)
: uniqueEncounters(runtimeCatalog.encounters, recipe.mapId);
if (!encounters.length) {
throw new Error(`${recipe.slug}: instanced dungeon has no resolved boss encounters.`);
}
const bossEntries = new Set(encounters.map((encounter) => Number(encounter.creatureId)));
const spells = recipe.clientBuild === "3.3.5a-Epoch" ? epochSpells : ascensionSpells;
const mapPosition = coordinateMapper(
draft,
environmentMode,
playerAnchor,
sourceBasis,
);
const sourceEntities = (draft?.entities ?? [])
.filter((entity) => (
!(recipe.slug === "wailing-caverns" && Number(entity.entry) === 5912)
&& !excludedAmbientEntity(entity, bossEntries)
));
const sourceEntitiesByEntry = new Map(sourceEntities.map((entity) => [Number(entity.entry), entity]));
const creatureModelCatalog = recipe.clientBuild === "3.3.5a-Epoch"
? epochCreatureCatalog
: undefined;
const entities = {};
for (const source of sourceEntities) {
const boss = bossEntries.has(Number(source.entry));
entities[source.id] = {
id: source.id,
kind: boss ? "boss" : "mob",
name: source.name,
...(boss ? { title: "Dungeon Boss", hasLoot: true } : {}),
combat: recipe.slug === "wailing-caverns"
? wailingServerCombatFor(source, boss, azerothCoreReference)
: combatFor(source, boss, spells, scriptSpellCatalog),
visual: visualFor(
source.id,
source.name,
boss,
source.rank,
creatureModelFor(creatureModelCatalog, source, dungeonImport),
),
};
}
for (const encounter of encounters) {
const entityId = `entry-${encounter.creatureId}`;
if (!entities[entityId]) {
const source = sourceEntitiesByEntry.get(Number(encounter.creatureId));
entities[entityId] = source
? {
id: entityId,
kind: "boss",
name: encounter.name,
title: "Dungeon Boss",
hasLoot: true,
combat: recipe.slug === "wailing-caverns"
? wailingServerCombatFor(source, true, azerothCoreReference)
: combatFor(source, true, spells, scriptSpellCatalog),
visual: visualFor(
entityId,
encounter.name,
true,
source.rank,
creatureModelFor(creatureModelCatalog, source, dungeonImport),
),
}
: recipe.slug === "wailing-caverns"
? {
...syntheticEntity(
entityId,
encounter.name,
true,
recipe.difficultyVariants[0]?.levelRange?.[1] ?? 60,
),
combat: wailingServerCombatFor({
id: entityId,
entry: encounter.creatureId,
name: encounter.name,
runSpeed: 1,
}, true, azerothCoreReference),
}
: syntheticEntity(
entityId,
encounter.name,
true,
recipe.difficultyVariants[0]?.levelRange?.[1] ?? 60,
);
}
}
const sourceSpawns = (draft?.spawns ?? []).filter((spawn) => entities[spawn.entityId]);
const bossCandidatesByEntry = new Map();
for (const spawn of draft?.bossCandidates ?? []) {
const entry = Number(spawn.entityId.replace("entry-", ""));
if (!bossCandidatesByEntry.has(entry)) bossCandidatesByEntry.set(entry, spawn);
}
const usedBossSpawnIds = new Set();
const bossObjectives = encounters.map((encounter, index) => {
const candidate = bossCandidatesByEntry.get(Number(encounter.creatureId));
const alpha = encounters.length <= 1 ? 1 : index / (encounters.length - 1);
const routePosition = interpolate(trashAnchor.position, bossAnchor.position, 0.18 + alpha * 0.82);
const position = encounter.eventOnly
? [-151.27, -102.82, 252.26]
: candidate
? mapPosition(candidate.position)
: offsetPosition(routePosition, index % 5, 2.4);
const id = encounter.eventOnly
? "mutanus-spawn"
: candidate?.id ?? `encounter-${encounter.encounterId}`;
usedBossSpawnIds.add(id);
return {
id,
entityId: `entry-${encounter.creatureId}`,
name: encounter.name,
position,
yaw: candidate?.yaw ?? 0,
...(encounter.eventOnly ? { eventOnly: true } : {}),
};
});
const staticSpawns = [];
const roamingPacks = [];
for (const spawn of sourceSpawns) {
const entry = Number(spawn.entityId.replace("entry-", ""));
const isBoss = bossEntries.has(entry);
const transformedPosition = mapPosition(spawn.position);
if (!isBoss && spawn.waypoints?.length >= 2) {
const stride = Math.max(1, Math.ceil(spawn.waypoints.length / 32));
const waypoints = spawn.waypoints
.filter((_, index) => index % stride === 0)
.map(mapPosition);
if (waypoints.length >= 2) {
roamingPacks.push({
id: `patrol-${spawn.guid}`,
name: `${entities[spawn.entityId].name} Patrol`,
speed: 1.15,
pathId: spawn.pathId,
formationSource: "database-solo",
spawnMask: spawn.spawnMask,
waypoints,
members: [{
id: "member",
entityId: spawn.entityId,
role: "leader",
formationOffset: [0, 0, 0],
}],
});
continue;
}
}
staticSpawns.push({
id: spawn.id,
entityId: spawn.entityId,
position: transformedPosition,
yaw: spawn.yaw,
spawnMask: spawn.spawnMask,
});
}
for (const boss of bossObjectives) {
if (boss.eventOnly) continue;
if (staticSpawns.some((spawn) => spawn.id === boss.id)) continue;
staticSpawns.push({
id: boss.id,
entityId: boss.entityId,
position: boss.position,
yaw: boss.yaw,
spawnMask: 1,
});
}
const highestLevel = recipe.difficultyVariants[0]?.levelRange?.[1] ?? 60;
// A few custom Ascension maps have encounter identities but no world
// population and intentionally use the procedural fallback. Epoch
// instances are different: an arena such as Trial of the Champion can
// legitimately contain only its scripted encounter roster. Do not invent
// unmodeled trash for those authoritative instance snapshots.
const syntheticTrash = !sourceSpawns.length && recipe.clientBuild !== "3.3.5a-Epoch";
if (syntheticTrash) {
const guardianIds = ["guardian", "caster", "brute"].map((role, index) => {
const id = `${recipe.slug}-${role}`;
const names = ["Guardian", "Invoker", "Brute"];
entities[id] = syntheticEntity(id, `${assetPackage.location} ${names[index]}`, false, highestLevel);
return id;
});
for (let index = 0; index < 15; index += 1) {
const segment = index < 6
? interpolate(playerAnchor.position, trashAnchor.position, 0.25 + index * 0.1)
: interpolate(trashAnchor.position, bossAnchor.position, 0.08 + (index - 6) * 0.085);
staticSpawns.push({
id: `generated-trash-${index + 1}`,
entityId: guardianIds[index % guardianIds.length],
position: offsetPosition(segment, index % 4, 2.8),
yaw: rounded((index * 1.618) % (Math.PI * 2)),
spawnMask: 1,
});
}
}
const allPoints = [
playerAnchor.position,
trashAnchor.position,
bossAnchor.position,
...staticSpawns.map((spawn) => spawn.position),
...roamingPacks.flatMap((pack) => pack.waypoints),
];
const bounds = finiteBounds(allPoints);
const bosses = bossObjectives.map(({ id, name, position, eventOnly }) => ({
id,
name,
position,
...(eventOnly ? { eventOnly: true } : {}),
}));
const databaseKind = recipe.clientBuild === "3.3.5a-Epoch" || draft?.spawns?.length
? `azerothcore-${(
recipe.slug === "wailing-caverns"
? azerothCoreReference.source.commit
: DEFAULT_AZEROTHCORE_COMMIT
).slice(0, 12)}`
: "ascension-encounter-identities-with-procedural-population";
const entityValues = Object.values(entities);
const modeledEntities = entityValues.filter((entity) => entity.visual?.model).length;
const modelCoverageWarning = dungeonImport
? `${modeledEntities}/${entityValues.length} combat entity templates use native animated client models from the reviewed five-player import.`
: recipe.clientBuild === "3.3.5a-Epoch"
? `${modeledEntities}/${entityValues.length} combat entity templates use native animated Epoch client models; the remainder use the explicit procedural fallback.`
: "Creature bodies use explicit procedural fallbacks until the corresponding client display composites are exported.";
const sourceWarnings = [
modelCoverageWarning,
...(syntheticTrash
? ["Trash identities and placements are procedural; boss identities come from the installed Ascension encounter catalog."]
: [`Server positions were aligned to the packaged ${environmentMode} environment at its reviewed player anchor.`]),
];
definitions.push({
schemaVersion: 1,
id: recipe.slug,
title: recipe.title,
mapId: recipe.mapId,
lfgDungeonIds: recipe.lfgDungeonIds,
expansion: recipe.expansion,
environmentMode,
difficultyVariants: recipe.difficultyVariants,
defaultDifficultyId: recipe.difficultyVariants[0]?.id ?? "normal",
availability: "available",
presentation: presentationFor(recipe, assetPackage, environmentMode, bounds),
assets: {
visual: assetPackage.visual.map(assetChunk),
collision: assetPackage.collision.map(assetChunk),
navigation: assetChunk(assetPackage.navigation),
},
transform: assetPackage.transform,
bounds,
entrance: {
name: `${recipe.title} Entrance`,
footPosition: vector(playerAnchor.position),
forward: [Math.sin(entranceYaw), 0, Math.cos(entranceYaw)],
yaw: entranceYaw,
},
areas: [
{ id: "entrance", name: `${recipe.title} Entrance`, center: vector(playerAnchor.position), radius: 35 },
...bosses.map((boss) => ({
id: `area-${slugPart(boss.name)}`,
name: `${boss.name}'s Encounter`,
center: boss.position,
radius: 42,
})),
],
entities,
staticSpawns,
roamingPacks,
bosses,
objectiveOrder: bosses,
navigationLinks: [],
serverGameObjects: draft?.gameObjects ?? [],
provenance: {
clientBuild: recipe.clientBuild,
clientArchiveHashes: assetPackage.source.checksums ?? {},
databaseAdapter: databaseKind,
databaseRevision: draft?.spawns?.length
? recipe.slug === "wailing-caverns"
? `${azerothCoreReference.source.commit}+dirty-file-lock`
: DEFAULT_AZEROTHCORE_COMMIT
: undefined,
sourceSnapshot: draft ? `dungeon-pipeline/work/${recipe.slug}/source-snapshot.json` : undefined,
coordinateTransform: environmentMode === "adt-hybrid"
? sourceBasis === "z-y-negative-x"
? "three(x,y,z)=(wow.y,wow.z,wow.x)"
: "three(x,y,z)=(-wow.y,wow.z,wow.x)"
: sourceBasis === "flip-x"
? "three(x,y,z)=(-draft.x,draft.y,draft.z)+package-anchor-translation"
: "three(x,y,z)=draft(x,y,z)+package-anchor-translation",
},
validation: {
status: "unvalidated",
blockers: [],
warnings: sourceWarnings,
},
});
coverage.push({
dungeonId: recipe.slug,
mapId: recipe.mapId,
encounters: encounters.length,
entities: Object.keys(entities).length,
staticSpawns: staticSpawns.length,
roamingPacks: roamingPacks.length,
source: syntheticTrash ? "procedural-fallback" : "azerothcore",
});
}
const output = {
schemaVersion: 1,
source: {
campaignManifest: "dungeon-pipeline/dungeon-campaign.json",
environmentPackages: [
"src/game/generated/fivePlayerDungeonImports.json",
"src/game/generated/manastormAssetPackages.json (temporary fallback)",
],
encounterCatalog: "src/game/generated/manastormRuntimeCatalog.json",
azerothCoreCommit: azerothCoreReference.source.commit,
azerothCoreSourceLock: "src/game/generated/azerothCoreSourceLock.json",
spellCatalog: {
ascension: path.relative(projectRoot, spellDbc).replace(/\\/g, "/"),
epoch: path.relative(projectRoot, epochSpellDbc).replace(/\\/g, "/"),
},
},
coverage,
definitions,
};
const serialized = JSON.stringify(output);
const compressed = gzipSync(Buffer.from(serialized), { level: 9 });
await writeFile(outputFile, `${serialized}\n`, "utf8");
await writeFile(
compressedOutputFile,
`// Generated by npm run dungeon:catalog. Do not edit.\nexport default "${compressed.toString("base64")}";\n`,
"utf8",
);
let existingAvailability;
try {
existingAvailability = JSON.parse(await readFile(availabilityFile, "utf8"));
} catch (error) {
if (error?.code !== "ENOENT") {
throw error;
}
}
const mergedAvailability = mergeCampaignAvailability(
existingAvailability,
definitions.map((definition) => definition.id),
);
await writeFile(
availabilityFile,
`${JSON.stringify(mergedAvailability, null, 2)}\n`,
"utf8",
);
console.log(JSON.stringify({
status: "complete",
output: path.relative(projectRoot, outputFile).replace(/\\/g, "/"),
compressedOutput: path.relative(projectRoot, compressedOutputFile).replace(/\\/g, "/"),
compressedBytes: compressed.length,
dungeons: definitions.length,
encounters: coverage.reduce((total, row) => total + row.encounters, 0),
entities: coverage.reduce((total, row) => total + row.entities, 0),
staticSpawns: coverage.reduce((total, row) => total + row.staticSpawns, 0),
roamingPacks: coverage.reduce((total, row) => total + row.roamingPacks, 0),
authoritativeDungeons: coverage.filter((row) => row.source === "azerothcore").length,
fallbackDungeons: coverage.filter((row) => row.source === "procedural-fallback").length,
}, null, 2));
}
await compile();