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