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

2381 lines
89 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 runtimeOverridesRoot = path.join(projectRoot, "dungeon-pipeline", "runtime-overrides");
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 gameObjectAssetManifestFile = path.join(
projectRoot,
"dungeon-pipeline",
"WOW_GAMEOBJECT_ASSET_MANIFEST.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 azerothCoreWorldAiFile = path.join(
projectRoot,
"src",
"game",
"generated",
"azerothCoreWorldAi.json",
);
const bossMechanicsFile = path.join(projectRoot, "dungeon-pipeline", "boss-mechanics.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 defaultSpellSupportRoot = path.join(
projectRoot,
"..",
"LadiksMPQEditor",
"client-current",
"patch-S",
"DBFilesClient",
);
const standardGameObjectDisplayDbc = path.join(
workRoot,
"deadmines",
"client-dbc",
"DBFilesClient",
"GameObjectDisplayInfo.dbc",
);
const epochGameObjectDisplayDbc = path.join(
workRoot,
"epoch-client",
"DBFilesClient",
"GameObjectDisplayInfo.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 horizontalDistanceSquared(left, right) {
return (left[0] - right[0]) ** 2 + (left[2] - right[2]) ** 2;
}
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 compileBossProgressionEncounter(recipe, bosses, syntheticTrash) {
const runtimeBosses = bosses.filter((boss) => !boss.eventOnly);
const requiredBosses = runtimeBosses.filter((boss) => !boss.optional);
const rules = runtimeBosses.map((boss, index) => ({
id: `boss-${index + 1}-defeated`,
trigger: { kind: "entities-dead", entityIds: [boss.id] },
actions: [
{
id: `record-boss-${index + 1}`,
kind: "complete-objective",
objectiveId: boss.id,
persistence: "run",
},
...(!boss.optional ? [{
id: `checkpoint-boss-${index + 1}`,
kind: "set-checkpoint",
checkpointId: boss.id,
persistence: "checkpoint",
}] : []),
],
}));
const finalBoss = requiredBosses.at(-1);
// Event-only objectives belong to detailed instance orchestration. A
// baseline must not declare the dungeon complete before that event fires.
if (finalBoss && !bosses.some((boss) => boss.eventOnly)) {
rules.push({
id: "all-required-bosses-defeated",
// Re-evaluate persisted objectives independently of kill order. Tying
// completion to the catalog's final actor can strand a run when players
// defeat that actor early and finish another required boss afterward.
trigger: { kind: "tick" },
guards: requiredBosses.map((boss) => ({
kind: "objective-complete",
objectiveId: boss.id,
equals: true,
})),
actions: [
{
id: "mark-dungeon-complete",
kind: "set-flag",
flagId: "dungeon-complete",
value: true,
persistence: "run",
},
{
id: "enter-complete-phase",
kind: "set-phase",
phase: "complete",
persistence: "run",
},
],
});
}
const sourceBacked = !syntheticTrash;
return {
id: `${recipe.slug}:boss-progression`,
dungeonId: recipe.slug,
initialPhase: "progression",
initialState: { flags: { "dungeon-complete": false } },
resetPolicy: { bossWipePhase: "progression", checkpointPhase: "progression" },
source: {
fidelity: sourceBacked ? "source-backed-adapted" : "healer-man-authored",
scope: "boss-progression",
files: sourceBacked
? [
`dungeon-pipeline/work/${recipe.slug}/runtime-draft.json`,
"src/game/generated/azerothCoreScriptSpells.json",
"src/game/generated/azerothCoreWorldAi.json",
]
: [
`dungeon-pipeline/recipes/${recipe.slug}.json`,
"src/game/generated/manastormRuntimeCatalog.json",
],
reviewedBehavior: sourceBacked
? "Live source-backed boss runtime IDs record persistent objective completion and stable post-boss checkpoints; all required non-event bosses complete the baseline run."
: "Catalog boss identities record persistent objective completion and stable post-boss checkpoints; all required bosses complete the authored fallback run.",
adaptations: [
"Boss progression is intentionally order-independent unless a detailed encounter definition supplies an explicit gate.",
"Optional rare bosses record completion when present but do not gate the final completion state.",
"A checkpoint retains completed objectives and resets temporary combat state; it does not invent a source graveyard or teleport.",
],
remainingGaps: [
...(sourceBacked ? [] : [
"Replace the authored fallback with authoritative Ascension population and instance-script data when those sources become available.",
]),
"Port exact instance-script doors, waves, escorts, vehicles, dialogue, gossip, quests, area triggers, and event-only bosses where the source requires them.",
"Enable source game-object visuals, collision, and state transitions only after their client models and script bindings are reviewed.",
"Record an end-to-end traversal, wipe/reset, checkpoint reload, HUD, collision, and completion playtest before marking the dungeon validated.",
],
},
rules,
};
}
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"]);
const COORDINATE_ALIGNMENTS = new Set(["player-anchor", "source", "source-transform"]);
const SOURCE_GAME_OBJECT_TYPES = new Set(Array.from({ length: 36 }, (_, type) => type));
function rowValue(row, names, fallback = undefined) {
for (const name of names) {
if (row?.[name] !== undefined && row[name] !== null) return row[name];
}
return fallback;
}
function finiteNumber(value, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, value));
}
function sourceGameObjectPosition(row) {
const wowX = finiteNumber(rowValue(row, ["position_x", "positionX", "x"]));
const wowY = finiteNumber(rowValue(row, ["position_y", "positionY", "y"]));
const wowZ = finiteNumber(rowValue(row, ["position_z", "positionZ", "z"]));
return [-wowX, wowZ, wowY];
}
function sourceGameObjectKind(type, name) {
const normalized = normalizedName(name);
if (type === 0 || type === 35) {
if (normalized.includes("bridge") || normalized.includes("drawbridge")) return "bridge";
if (normalized.includes("gate") || normalized.includes("portcullis")) return "gate";
return "door";
}
if (type === 1 || type === 31) return "switch";
if (type === 2 || type === 10 || type === 23) return "quest-object";
if (type === 3) return /vein|deposit|herb|plant|school|node/i.test(name) ? "resource" : "chest";
if (type === 5) return "generic";
if (type === 6 || type === 12) return "trap";
if (type === 7 || type === 32) return "chair";
if (type === 8) return "spell-focus";
if (type === 9) return "text";
if (type === 11 || type === 15) return "transport";
if (type === 13) return "camera";
if (type === 18) return "ritual";
if (type === 19) return "mailbox";
if (type === 22) return "spellcaster";
if (type === 25) return "fishing-hole";
if (type === 33) return "destructible";
return type === 30 ? "aura-generator" : "generic";
}
function sourceGameObjectHalfExtents(kind, sourceSize) {
const size = clamp(finiteNumber(sourceSize, 1), 0.1, 6);
if (kind === "door" || kind === "gate") return vector([1.25 * size, 2 * size, 0.2 * size]);
if (kind === "bridge") return vector([2.5 * size, 0.2 * size, 1.25 * size]);
if (kind === "switch") return vector([0.35 * size, 0.5 * size, 0.35 * size]);
if (kind === "chest" || kind === "resource") return vector([0.6 * size, 0.45 * size, 0.45 * size]);
if (kind === "trap" || kind === "trigger") return vector([0.8 * size, 0.1 * size, 0.8 * size]);
if (kind === "transport") return vector([2 * size, 0.25 * size, 2 * size]);
if (kind === "destructible") return vector([1.5 * size, 1.5 * size, 1.5 * size]);
return vector([0.45 * size, 0.65 * size, 0.45 * size]);
}
function clientGameObjectHalfExtents(display, sourceSize) {
if (!display?.bounds) return null;
const size = clamp(finiteNumber(sourceSize, 1), 0.01, 100);
const spans = display.bounds.max.map((value, axis) => value - display.bounds.min[axis]);
if (!spans.every((value) => Number.isFinite(value) && value > 0 && value <= 1_000)) return null;
// Client bounds are x/y/z. Runtime draft space is -x/z/y.
return vector([
clamp(spans[0] * size / 2, 0.025, 500),
clamp(spans[2] * size / 2, 0.025, 500),
clamp(spans[1] * size / 2, 0.025, 500),
]);
}
function parseGameObjectDisplays(buffer) {
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 !== 19 || recordSize !== fieldCount * 4) return new Map();
const stringsOffset = 20 + recordCount * recordSize;
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 displays = new Map();
for (let index = 0; index < recordCount; index += 1) {
const base = 20 + index * recordSize;
const id = buffer.readUInt32LE(base);
const modelPath = readString(buffer.readUInt32LE(base + 4)).replace(/\\/g, "/");
const min = [12, 13, 14].map((field) => rounded(buffer.readFloatLE(base + field * 4)));
const max = [15, 16, 17].map((field) => rounded(buffer.readFloatLE(base + field * 4)));
displays.set(id, {
id,
modelPath,
bounds: { min, max },
});
}
return displays;
}
function sourceGameObjectStates(kind) {
if (kind === "door" || kind === "gate" || kind === "bridge") {
return [
// Adapted source objects remain non-blocking until an instance-script
// transition has been reviewed; otherwise a closed door with no opener
// would turn an incomplete import into an unavoidable soft lock.
{ id: "closed", label: "Closed", visible: true, blocksMovement: false, blocksSight: false },
{ id: "open", label: "Open", visible: false, blocksMovement: false, blocksSight: false },
];
}
if (kind === "trap") {
return [
{ id: "armed", label: "Armed", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "disabled", label: "Disabled", visible: false, blocksMovement: false, interactionEnabled: false },
];
}
if (kind === "transport") {
return [
{ id: "stationary", label: "Stationary", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "moving", label: "Moving", visible: true, blocksMovement: false, interactionEnabled: false },
];
}
if (kind === "destructible") {
return [
{ id: "intact", label: "Intact", visible: true, blocksMovement: false, blocksSight: false },
{ id: "destroyed", label: "Destroyed", visible: false, blocksMovement: false, blocksSight: false },
];
}
return [
{ id: "available", label: "Available", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "inactive", label: "Inactive", visible: false, blocksMovement: false, interactionEnabled: false },
];
}
function sourceGameObjectInitialState(kind, row) {
const sourceState = finiteNumber(rowValue(row, ["state", "State"]), 1);
if (kind === "door" || kind === "gate" || kind === "bridge") return sourceState === 0 ? "open" : "closed";
if (kind === "trap") return sourceState === 0 ? "disabled" : "armed";
if (kind === "transport") return sourceState === 0 ? "moving" : "stationary";
if (kind === "destructible") return sourceState === 2 ? "destroyed" : "intact";
return sourceState === 0 ? "inactive" : "available";
}
function sourceGameObjectVisual(kind) {
const colors = {
door: "#5f4932",
gate: "#4d4a46",
bridge: "#66543c",
switch: "#c38d35",
chest: "#8f682f",
resource: "#54704b",
trap: "#713b36",
transport: "#53616b",
camera: "#475f78",
ritual: "#74518f",
spellcaster: "#496e87",
"spell-focus": "#5f698d",
"aura-generator": "#586f80",
destructible: "#6b5849",
"quest-object": "#8a773b",
generic: "#65584b",
chair: "#725942",
text: "#806c48",
mailbox: "#596778",
"fishing-hole": "#3d6878",
trigger: "#58606a",
};
return { hidden: true, color: colors[kind] ?? "#65584b" };
}
function compileSourceGameObjects(draft, mapPosition, recipe, gameObjectDisplays) {
const templates = new Map((draft?.gameObjectTemplates ?? []).map((template) => [
finiteNumber(rowValue(template, ["entry", "id"]), -1),
template,
]));
return (draft?.gameObjects ?? [])
.map((row, index) => {
const templateId = finiteNumber(rowValue(row, ["id", "entry"]), -1);
const template = templates.get(templateId);
const type = finiteNumber(rowValue(template, ["type", "Type"]), -1);
if (!template || !SOURCE_GAME_OBJECT_TYPES.has(type)) return null;
const spawnId = finiteNumber(rowValue(row, ["guid", "spawnId"]), index + 1);
const name = String(rowValue(template, ["name", "Name"], `Game object ${templateId}`)).trim()
|| `Game object ${templateId}`;
const kind = sourceGameObjectKind(type, name);
const displayId = finiteNumber(rowValue(template, ["displayId", "displayID", "DisplayID"]), 0);
const display = gameObjectDisplays.get(displayId);
const sourceSize = rowValue(template, ["size", "Size"], 1);
const clientHalfExtents = clientGameObjectHalfExtents(display, sourceSize);
return {
id: `gameobject-${spawnId}`,
name,
kind,
position: mapPosition(sourceGameObjectPosition(row)),
yaw: rounded(-finiteNumber(rowValue(row, ["orientation", "yaw"]))),
halfExtents: clientHalfExtents ?? sourceGameObjectHalfExtents(kind, sourceSize),
initialState: sourceGameObjectInitialState(kind, row),
states: sourceGameObjectStates(kind),
visual: sourceGameObjectVisual(kind),
source: {
fidelity: "source-backed-adapted",
files: [
`dungeon-pipeline/work/${recipe.slug}/source-snapshot.json`,
`dungeon-pipeline/work/${recipe.slug}/runtime-draft.json`,
],
templateId,
spawnId,
gameObjectType: type,
...(displayId > 0 ? { clientDisplayId: displayId } : {}),
...(display?.modelPath ? { clientModelPath: display.modelPath } : {}),
...(clientHalfExtents ? { clientBounds: display.bounds } : {}),
note: clientHalfExtents
? "Source transform/state and exact client display bounds/model path are preserved. Visual assets and collision remain hidden until the model export and instance-script transition are reviewed; interactions remain disabled until ported."
: "Source position, orientation, type, initial state, and template scale are preserved. Visuals and collision remain hidden until client-display bounds/models are reviewed; encounter-script interactions remain disabled until ported.",
},
};
})
.filter(Boolean)
.sort((left, right) => left.id.localeCompare(right.id, undefined, { numeric: true }));
}
function increment(map, key, amount = 1) {
map.set(key, (map.get(key) ?? 0) + amount);
}
function sortedCountEntries(map, keyName) {
return [...map.entries()]
.sort(([left], [right]) => String(left).localeCompare(String(right), undefined, { numeric: true }))
.map(([key, instances]) => ({ [keyName]: key, instances }));
}
function gameObjectAssetManifestFor(definitions) {
const assets = new Map();
const nonVisualObjects = new Map();
const unresolvedModels = new Map();
const unresolvedBounds = new Map();
let typedInstances = 0;
let visualDisplayInstances = 0;
let resolvedModelInstances = 0;
let boundedInstances = 0;
for (const dungeon of definitions) {
for (const object of dungeon.serverGameObjects ?? []) {
if (object.source?.fidelity !== "source-backed-adapted") continue;
typedInstances += 1;
const displayId = Number(object.source.clientDisplayId);
const modelPath = String(object.source.clientModelPath ?? "").replace(/\\/g, "/");
const hasBounds = Boolean(object.source.clientBounds);
if (!Number.isFinite(displayId) || displayId <= 0) {
const templateId = Number(object.source.templateId) || 0;
let nonVisual = nonVisualObjects.get(templateId);
if (!nonVisual) {
nonVisual = {
templateId,
instances: 0,
dungeons: new Map(),
kinds: new Map(),
};
nonVisualObjects.set(templateId, nonVisual);
}
nonVisual.instances += 1;
increment(nonVisual.dungeons, dungeon.id);
increment(nonVisual.kinds, object.kind);
continue;
}
visualDisplayInstances += 1;
if (hasBounds) boundedInstances += 1;
if (!modelPath) {
const key = displayId;
let unresolved = unresolvedModels.get(key);
if (!unresolved) {
unresolved = {
displayId: key,
instances: 0,
dungeons: new Map(),
kinds: new Map(),
templateIds: new Set(),
};
unresolvedModels.set(key, unresolved);
}
unresolved.instances += 1;
increment(unresolved.dungeons, dungeon.id);
increment(unresolved.kinds, object.kind);
if (object.source.templateId) unresolved.templateIds.add(object.source.templateId);
} else {
resolvedModelInstances += 1;
const key = modelPath.toLowerCase();
let asset = assets.get(key);
if (!asset) {
const extension = path.extname(modelPath).toLowerCase().replace(/^\./, "") || "unknown";
const targetPath = modelPath.replace(/\.[^/.]+$/, ".glb");
asset = {
sourceModelPath: modelPath,
sourceType: extension,
targetUrl: `/assets/game/world-objects/${targetPath}`,
instances: 0,
boundedInstances: 0,
displayIds: new Set(),
dungeons: new Map(),
kinds: new Map(),
};
assets.set(key, asset);
}
asset.instances += 1;
if (hasBounds) asset.boundedInstances += 1;
asset.displayIds.add(displayId);
increment(asset.dungeons, dungeon.id);
increment(asset.kinds, object.kind);
}
if (!hasBounds) {
const key = displayId;
let unresolved = unresolvedBounds.get(key);
if (!unresolved) {
unresolved = {
displayId: key,
modelPath: modelPath || undefined,
instances: 0,
dungeons: new Map(),
kinds: new Map(),
templateIds: new Set(),
};
unresolvedBounds.set(key, unresolved);
}
unresolved.instances += 1;
increment(unresolved.dungeons, dungeon.id);
increment(unresolved.kinds, object.kind);
if (object.source.templateId) unresolved.templateIds.add(object.source.templateId);
}
}
}
const serializeUnresolved = (entry) => ({
displayId: entry.displayId,
...(entry.modelPath ? { modelPath: entry.modelPath } : {}),
instances: entry.instances,
templateIds: [...entry.templateIds].sort((left, right) => left - right),
dungeons: sortedCountEntries(entry.dungeons, "dungeonId"),
kinds: sortedCountEntries(entry.kinds, "kind"),
});
return {
schemaVersion: 1,
generatedBy: "scripts/dungeon-pipeline/compile-campaign-catalog.mjs",
summary: {
typedInstances,
visualDisplayInstances,
nonVisualInstances: typedInstances - visualDisplayInstances,
resolvedModelInstances,
boundedInstances,
uniqueAssets: assets.size,
unresolvedModelInstances: visualDisplayInstances - resolvedModelInstances,
unresolvedBoundInstances: visualDisplayInstances - boundedInstances,
unresolvedModelDisplayIds: unresolvedModels.size,
unresolvedBoundDisplayIds: unresolvedBounds.size,
},
assets: [...assets.values()]
.sort((left, right) => left.sourceModelPath.localeCompare(right.sourceModelPath))
.map((asset) => ({
sourceModelPath: asset.sourceModelPath,
sourceType: asset.sourceType,
targetUrl: asset.targetUrl,
instances: asset.instances,
boundedInstances: asset.boundedInstances,
displayIds: [...asset.displayIds].sort((left, right) => left - right),
dungeons: sortedCountEntries(asset.dungeons, "dungeonId"),
kinds: sortedCountEntries(asset.kinds, "kind"),
})),
nonVisualObjects: [...nonVisualObjects.values()]
.sort((left, right) => left.templateId - right.templateId)
.map((entry) => ({
templateId: entry.templateId,
instances: entry.instances,
dungeons: sortedCountEntries(entry.dungeons, "dungeonId"),
kinds: sortedCountEntries(entry.kinds, "kind"),
})),
unresolvedModels: [...unresolvedModels.values()]
.sort((left, right) => left.displayId - right.displayId)
.map(serializeUnresolved),
unresolvedBounds: [...unresolvedBounds.values()]
.sort((left, right) => left.displayId - right.displayId)
.map(serializeUnresolved),
};
}
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;
}
function coordinateAlignmentFor(recipe, environmentMode) {
const legacyGlobalWmoAlignment = environmentMode === "global-wmo"
? recipe.globalWmoAlignment
: undefined;
if (
recipe.coordinateAlignment
&& legacyGlobalWmoAlignment
&& recipe.coordinateAlignment !== legacyGlobalWmoAlignment
) {
throw new Error(`${recipe.slug}: coordinate alignment overrides disagree.`);
}
const alignment = recipe.coordinateAlignment ?? legacyGlobalWmoAlignment ?? "player-anchor";
if (!COORDINATE_ALIGNMENTS.has(alignment)) {
throw new Error(`${recipe.slug}: unsupported coordinate alignment ${alignment}.`);
}
return alignment;
}
function sourceTransformFor(recipe, alignment) {
const transform = recipe.coordinateTransform;
if (alignment !== "source-transform") {
if (transform) {
throw new Error(`${recipe.slug}: coordinateTransform requires source-transform alignment.`);
}
return undefined;
}
if (!transform || !Array.isArray(transform.origin) || transform.origin.length !== 3) {
throw new Error(`${recipe.slug}: source-transform alignment requires a three-value coordinateTransform.origin.`);
}
const origin = transform.origin.map(Number);
const yawDegrees = Number(transform.yawDegrees ?? 0);
if (!origin.every(Number.isFinite) || !Number.isFinite(yawDegrees)) {
throw new Error(`${recipe.slug}: coordinateTransform values must be finite numbers.`);
}
return { origin, yawDegrees };
}
/**
* 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 rotateAroundY(position, yawDegrees) {
const radians = yawDegrees * Math.PI / 180;
const cosine = Math.cos(radians);
const sine = Math.sin(radians);
return vector([
position[0] * cosine - position[2] * sine,
position[1],
position[0] * sine + position[2] * cosine,
]);
}
function coordinateMapper(
draft,
environmentMode,
playerAnchor,
sourceBasis,
alignment,
sourceTransform,
) {
if (alignment === "source") {
return (position) => sourceToPackageBasis(position, environmentMode, sourceBasis);
}
if (alignment === "source-transform") {
const basisOrigin = sourceToPackageBasis(
sourceTransform.origin,
environmentMode,
sourceBasis,
);
return (position) => rotateAroundY(
subtract(sourceToPackageBasis(position, environmentMode, sourceBasis), basisOrigin),
sourceTransform.yawDegrees,
);
}
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)}`;
const previous = byIdentity.get(key);
if (!previous) {
byIdentity.set(key, encounter);
continue;
}
byIdentity.set(key, {
...previous,
modeIds: [...new Set([...previous.modeIds, ...encounter.modeIds])].sort((left, right) => left - right),
sourceRowIds: [...new Set([
...(previous.sourceRowIds ?? []),
...(encounter.sourceRowIds ?? []),
])].sort((left, right) => left - right),
orderIndex: Math.min(previous.orderIndex, encounter.orderIndex),
});
}
return [...byIdentity.values()].sort((left, right) => (
left.orderIndex - right.orderIndex || left.encounterId - right.encounterId
));
}
function encounterSpawnMask(recipe, encounter, candidate) {
const declaredMask = recipe.difficultyVariants.reduce(
(mask, difficulty) => mask | Number(difficulty.spawnMask ?? 0),
0,
);
const candidateMask = Number(candidate?.spawnMask ?? 0) & declaredMask;
if (candidateMask > 0) return candidateMask;
const modeIds = new Set((encounter.modeIds ?? []).map(Number));
const rowMask = recipe.difficultyVariants.reduce((mask, difficulty) => (
difficulty.mapDifficultyId !== undefined
&& modeIds.has(Number(difficulty.mapDifficultyId))
? mask | Number(difficulty.spawnMask ?? 0)
: mask
), 0);
return rowMask || declaredMask || 1;
}
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 numberTable(buffer, field, floating = false) {
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);
if (fieldCount <= field || recordSize < fieldCount * 4) return new Map();
const result = new Map();
for (let index = 0; index < recordCount; index += 1) {
const base = 20 + index * recordSize;
result.set(buffer.readUInt32LE(base), floating
? buffer.readFloatLE(base + field * 4)
: buffer.readInt32LE(base + field * 4));
}
return result;
}
async function spellSupportTables(spellFile, fallbackRoot) {
const siblingRoot = path.dirname(spellFile);
const table = async (name, field, floating = false) => {
const sibling = path.join(siblingRoot, `${name}.dbc`);
const fallback = path.join(fallbackRoot, `${name}.dbc`);
const file = await exists(sibling) ? sibling : fallback;
return await exists(file) ? numberTable(await readFile(file), field, floating) : new Map();
};
return {
durations: await table("SpellDuration", 3),
radii: await table("SpellRadius", 3, true),
};
}
function parseSpells(buffer, wantedIds, durations = new Map(), radii = new Map()) {
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 shifted = (field) => field >= 77 ? field + localizedShift : field;
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);
const signed = (field) => buffer.readInt32LE(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)),
durationMs: durations.get(unsigned(40)) ?? 0,
effects: Array.from({ length: 3 }, (_, effectIndex) => ({
effectType: unsigned(71 + effectIndex),
auraType: unsigned(shifted(95 + effectIndex)),
basePoints: signed(shifted(80 + effectIndex)) + 1,
mechanic: unsigned(shifted(83 + effectIndex)),
implicitTargetA: unsigned(shifted(86 + effectIndex)),
implicitTargetB: unsigned(shifted(89 + effectIndex)),
radius: radii.get(unsigned(shifted(92 + effectIndex))) ?? 0,
miscValue: signed(shifted(110 + effectIndex)),
})).filter((effect) => effect.effectType || effect.auraType),
});
}
return spells;
}
const CONTROL_AURA_MECHANICS = Object.freeze(new Map([
[5, "confuse"],
[6, "confuse"],
[7, "fear"],
[12, "stun"],
[26, "root"],
[27, "silence"],
[33, "slow"],
[56, "confuse"],
[60, "silence"],
[92, "root"],
[193, "slow"],
]));
const AUXILIARY_NON_BOSS_CASTS = Object.freeze(new Set([
"26687:47669", // The stasis orb awakens Gortok; Gortok does not cast this spell.
"29307:54888", // The Colossus does not own the elemental's spawn/reset visual.
"29573:54888", // The elemental uses this during reset, not as a combat attack.
"13282:21708", // Noxxion's helper dummy is triggered by the actual summon spell.
]));
function spellSemantic(spell, boss, area) {
const durationMs = spell.durationMs < 0
? 0
: spell.durationMs || (boss ? 8_000 : 5_000);
const damageMultiplier = area ? (boss ? 0.82 : 0.62) : boss ? 1.35 : 1.12;
const hasDamage = spell.effects.some((effect) => [2, 31, 58, 121].includes(effect.effectType));
const controlEffect = spell.effects.find((effect) => CONTROL_AURA_MECHANICS.has(effect.auraType));
const controlMechanic = /\b(?:sleep|slumber)\b/i.test(spell.name)
? "sleep"
: controlEffect ? CONTROL_AURA_MECHANICS.get(controlEffect.auraType) : undefined;
const leechEffect = spell.effects.find((effect) => effect.effectType === 9 || effect.auraType === 53);
const summons = spell.effects.filter((effect) => (
[28, 41].includes(effect.effectType) && effect.miscValue > 0
));
if (spell.id === 52723) {
return {
kind: "aura",
aura: `spell-${spell.id}`,
durationMs: 30_000,
magnitude: 0.5,
lifeStealPercent: 0.5,
};
}
if (spell.effects.some((effect) => effect.effectType === 43)) {
return { kind: "pull", destination: "source" };
}
if (summons.length) {
const summonEffects = summons.map((effect) => ({
kind: "summon",
creatureEntry: effect.miscValue,
count: Math.max(1, Math.min(8, effect.basePoints)),
}));
const effects = hasDamage
? [{ kind: "damage", multiplier: damageMultiplier }, ...summonEffects]
: summonEffects;
return effects.length === 1 ? effects[0] : { kind: "composite", effects };
}
if (leechEffect) {
const leech = {
kind: "leech",
damageMultiplier,
healMultiplier: damageMultiplier,
};
if (!controlEffect) return leech;
return {
kind: "composite",
effects: [
leech,
{
kind: "control",
mechanic: controlMechanic,
durationMs,
},
],
};
}
if (controlEffect) {
return {
kind: "control",
mechanic: controlMechanic,
durationMs,
...(hasDamage ? { damageMultiplier } : {}),
};
}
const absorb = spell.effects.find((effect) => effect.auraType === 69);
const reflect = spell.effects.find((effect) => effect.auraType === 28);
const schoolDefense = spell.effects.find((effect) => effect.auraType === 87);
const armorBuff = spell.effects.find((effect) => effect.auraType === 22);
if (schoolDefense && schoolDefense.implicitTargetA !== 1) {
const targetAura = {
kind: "target-aura",
aura: `spell-${spell.id}`,
durationMs,
damageTakenMultiplier: Math.max(0, 1 + schoolDefense.basePoints / 100),
damageSchool: schoolFor(Math.max(0, schoolDefense.miscValue)),
};
return hasDamage
? {
kind: "composite",
effects: [{ kind: "damage", multiplier: damageMultiplier }, targetAura],
}
: targetAura;
}
if (absorb || reflect || schoolDefense || armorBuff) {
const defensive = absorb ?? reflect ?? schoolDefense ?? armorBuff;
const school = schoolFor(Math.max(0, defensive.miscValue));
const damageTakenMultiplier = schoolDefense
? Math.max(0, Math.min(1, 1 + schoolDefense.basePoints / 100))
: armorBuff ? 0.85 : undefined;
return {
kind: "aura",
aura: `spell-${spell.id}`,
durationMs,
...(damageTakenMultiplier === undefined ? {} : { damageTakenMultiplier }),
...((schoolDefense || armorBuff) ? { damageSchool: schoolDefense ? school : "physical" } : {}),
...(absorb ? { absorbMultiplier: 2.5 } : {}),
...(reflect ? { reflectSpellChance: Math.max(0, Math.min(1, reflect.basePoints / 100)) } : {}),
};
}
const armorReduction = spell.effects.find((effect) => effect.auraType === 101);
if (armorReduction) {
return {
kind: "target-aura",
aura: `spell-${spell.id}`,
durationMs,
damageTakenMultiplier: 1.2,
damageSchool: "physical",
};
}
return undefined;
}
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) || spell.id === 53030;
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(?:hex|polymorph|dominat\w*)\b/i.test(spell.name)
? "confuse"
: /\b(?:silence)\b/i.test(spell.name)
? "silence"
: /\b(?:slow|hamstring)\b/i.test(spell.name)
? "slow"
: /\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 semanticEffect = spellSemantic(spell, boss, area);
const semanticEffects = semanticEffect?.kind === "composite"
? semanticEffect.effects
: semanticEffect ? [semanticEffect] : [];
const semanticHas = (kind) => semanticEffects.some((entry) => entry.kind === kind);
const target = healing
? "lowest-health-friendly"
: semanticHas("aura") || aura
? "self"
: semanticEffects.length > 0 && semanticEffects.every((entry) => entry.kind === "summon")
? "self"
: semanticHas("control")
? area ? "nearby-party" : "random-party"
: control === "fear" && area
? "nearby-party"
: area
? "nearby-party"
: control
? "random-party"
: "primary";
const effect = semanticEffect ?? (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);
const dbcRadius = spell.effects.reduce((maximum, entry) => Math.max(maximum, entry.radius), 0);
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: dbcRadius || (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 validateBossMechanicsCatalog(catalog) {
if (catalog.schemaVersion !== 1) {
throw new Error(`Unsupported boss mechanic schema ${catalog.schemaVersion}.`);
}
const deliveries = new Set(["melee", "projectile", "area"]);
const targets = new Set(["primary", "random-party", "nearby-party", "self", "lowest-health-friendly"]);
const schools = new Set(["physical", "arcane", "fire", "frost", "nature", "shadow", "holy"]);
const animations = new Set(["attack", "cast"]);
for (const [entry, boss] of Object.entries(catalog.bosses ?? {})) {
if (!/^\d+$/.test(entry) && !/^rw-[a-z0-9-]+-\d+$/.test(entry)) {
throw new Error(`Boss mechanic key ${entry} is not a creature entry or RuneWaker entity id.`);
}
if (!["append", "replace"].includes(boss.mode)) {
throw new Error(`${entry}: mechanic mode must be append or replace.`);
}
if (!Array.isArray(boss.mechanics)) throw new Error(`${entry}: mechanics must be an array.`);
const ids = new Set();
for (const mechanic of boss.mechanics) {
if (!mechanic.id || ids.has(mechanic.id)) {
throw new Error(`${entry}: mechanic ids must be non-empty and unique (${mechanic.id}).`);
}
ids.add(mechanic.id);
if (!mechanic.name || !deliveries.has(mechanic.delivery) || !targets.has(mechanic.target)
|| !schools.has(mechanic.school) || !animations.has(mechanic.animation)
|| !Number.isFinite(mechanic.cooldownMs) || mechanic.cooldownMs <= 0
|| !Number.isFinite(mechanic.range) || mechanic.range < 0) {
throw new Error(`${entry}:${mechanic.id}: mechanic fields are incomplete or invalid.`);
}
if (mechanic.telegraph && !["circle", "cone", "line"].includes(mechanic.telegraph.shape)) {
throw new Error(`${entry}:${mechanic.id}: unsupported telegraph shape.`);
}
}
}
}
function smartAiEventProperties(row) {
if (row.event_type === 0) {
const initial = [Number(row.event_param1) || 0, Number(row.event_param2) || Number(row.event_param1) || 0];
const repeat = [Number(row.event_param3) || 1_000, Number(row.event_param4) || Number(row.event_param3) || 1_000];
return {
initialCooldownMs: initial,
repeatCooldownMs: repeat,
cooldownMs: Math.max(1_000, Math.round((repeat[0] + repeat[1]) / 2)),
};
}
if (row.event_type === 2) {
return {
initialCooldownMs: [0, 0],
repeatCooldownMs: [1_000, 1_000],
maximumCasts: 1,
cooldownMs: 1_000,
condition: {
kind: "self-health-percent",
minimum: Math.max(0, Number(row.event_param1) || 0),
maximum: Math.min(100, Number(row.event_param2) || 100),
},
};
}
if (row.event_type === 4) {
return {
initialCooldownMs: [0, 0],
repeatCooldownMs: [1_000, 1_000],
maximumCasts: 1,
cooldownMs: 1_000,
};
}
if (row.event_type === 14) {
return {
initialCooldownMs: [0, 0],
repeatCooldownMs: [1_000, 1_000],
cooldownMs: 1_000,
condition: {
kind: "friendly-missing-health",
amount: Math.max(1, Number(row.event_param1) || 1),
range: Math.max(1, Number(row.event_param2) || 30),
},
};
}
return null;
}
function smartAiMechanicsFor(entity, boss, spells, worldAi) {
if (!boss) return [];
const entry = Number(entity.entry);
const rows = worldAi?.smartScriptsByEntry?.[String(entry)] ?? [];
const rowsById = new Map(rows.map((row) => [Number(row.id), row]));
const mechanics = [];
for (const trigger of rows.filter((row) => Number(row.event_type) !== 61)) {
const event = smartAiEventProperties(trigger);
if (!event) continue;
const chain = [];
let row = trigger;
const visited = new Set();
while (row && !visited.has(Number(row.id))) {
visited.add(Number(row.id));
chain.push(row);
row = Number(row.link) ? rowsById.get(Number(row.link)) : undefined;
}
for (const action of chain) {
const actionType = Number(action.action_type);
const source = {
entry,
sourceType: Number(action.source_type) || 0,
rowId: Number(action.id) || 0,
eventType: Number(trigger.event_type) || 0,
actionType,
targetType: Number(action.target_type) || 0,
comment: String(action.comment ?? trigger.comment ?? ""),
};
if (actionType === 11) {
const spellId = Number(action.action_param1);
const spell = spells.get(spellId);
if (!spell) continue;
const hash = stableHash(`${entry}:${spellId}:${action.id}`);
const attack = spellAttack(spell, hash, true);
const target = [5, 6].includes(Number(action.target_type))
? "random-party"
: attack.target;
mechanics.push({
...attack,
...event,
id: `smart-${entry}-${action.id}-${slugPart(spell.name)}`,
target,
range: target === "self" ? attack.range : Math.max(attack.range, 28),
source,
});
} else if (actionType === 12) {
const creatureEntry = Number(action.action_param1);
if (!creatureEntry) continue;
mechanics.push({
id: `smart-${entry}-${action.id}-summon-${creatureEntry}`,
name: String(action.comment ?? "Summon Reinforcements")
.replace(/^.*? - (?:In Combat|Health Between [^-]+) - /, ""),
delivery: "area",
target: "self",
school: "physical",
animation: "cast",
castTimeMs: 1_200,
interruptible: false,
range: 0,
damageMultiplier: 0,
effect: { kind: "summon", creatureEntry, count: 1 },
...event,
source,
});
} else if (actionType === 39) {
const radius = Math.max(1, Number(action.action_param1) || 30);
mechanics.push({
id: `smart-${entry}-${action.id}-call-for-help`,
name: "Call for Help",
delivery: "area",
target: "self",
school: "physical",
animation: "cast",
castTimeMs: 800,
interruptible: false,
range: 0,
damageMultiplier: 0,
effect: { kind: "call-for-help", radius },
...event,
source,
});
}
}
}
return mechanics;
}
function initializationSpawnChance(entry, worldAi) {
const despawnRows = (worldAi?.smartScriptsByEntry?.[String(entry)] ?? []).filter((row) => (
Number(row.event_type) === 37
&& Number(row.action_type) === 41
&& Number(row.target_type) === 1
));
if (!despawnRows.length) return 1;
return rounded(despawnRows.reduce((chance, row) => (
chance * (1 - Math.max(0, Math.min(100, Number(row.event_chance) || 0)) / 100)
), 1));
}
function uniqueMechanics(mechanics) {
const seenIds = new Set();
const seenSpellIds = new Set();
return mechanics.filter((mechanic) => {
if (seenIds.has(mechanic.id)) return false;
if (mechanic.spellId && seenSpellIds.has(mechanic.spellId)) return false;
seenIds.add(mechanic.id);
if (mechanic.spellId) seenSpellIds.add(mechanic.spellId);
return true;
});
}
function combatFor(
entity,
boss,
spells,
scriptCatalog = undefined,
worldAi = undefined,
bossMechanics = undefined,
) {
const hash = stableHash(`${entity.id}:${entity.name}`);
const entry = Number(entity.entry);
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)
|| AUXILIARY_NON_BOSS_CASTS.has(`${entry}:${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)
.map((spell) => spellAttack(spell, hash, boss));
const importedMechanics = smartAiMechanicsFor(entity, boss, spells, worldAi);
const authored = boss ? bossMechanics?.bosses?.[String(entry)] : undefined;
let mechanics = authored?.mode === "replace"
? authored.mechanics ?? []
: [...(authored?.mechanics ?? []), ...importedMechanics, ...spellAttacks];
mechanics = mechanics.filter((mechanic) => (
!mechanic.spellId || !AUXILIARY_NON_BOSS_CASTS.has(`${entry}:${mechanic.spellId}`)
));
mechanics = uniqueMechanics(mechanics);
if (!mechanics.length && boss && !authored) mechanics.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 {
...(entry ? { serverEntry: entry } : {}),
...(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,
...(boss ? { mechanicIds: mechanics.map((mechanic) => mechanic.id) } : {}),
attacks: [basicAttack(basicCooldown), ...mechanics],
};
}
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,
...(boss ? { mechanicIds: scripted.map((attack) => attack.id) } : {}),
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, bossMechanics = undefined) {
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(), undefined, undefined, bossMechanics),
...(!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, override = {}) {
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],
);
const presentation = {
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",
],
},
};
return {
...presentation,
...override,
fog: { ...presentation.fog, ...override.fog },
ambientLight: { ...presentation.ambientLight, ...override.ambientLight },
hemisphereLight: { ...presentation.hemisphereLight, ...override.hemisphereLight },
directionalLight: { ...presentation.directionalLight, ...override.directionalLight },
materials: presentation.materials,
map: presentation.map,
};
}
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 azerothCoreWorldAi = await readJson(azerothCoreWorldAiFile);
const bossMechanics = await readJson(bossMechanicsFile);
validateBossMechanicsCatalog(bossMechanics);
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 runtimeOverrides = new Map();
for (const recipe of recipes) {
const file = path.join(runtimeOverridesRoot, `${recipe.slug}.json`);
if (!await exists(file)) continue;
const override = await readJson(file);
if (override.schemaVersion !== 1 || override.dungeonId !== recipe.slug) {
throw new Error(`${recipe.slug}: runtime override identity or schema is invalid.`);
}
if (override.validation?.navigation === "green" && !(override.navigationLinks?.length > 0)) {
throw new Error(`${recipe.slug}: green navigation requires at least one reviewed link.`);
}
runtimeOverrides.set(recipe.slug, override);
}
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));
}
for (const row of azerothCoreWorldAi.smartScriptsByEntry?.[String(entity.entry)] ?? []) {
if (Number(row.action_type) === 11) wantedSpellIds.add(Number(row.action_param1));
}
}
}
for (const boss of Object.values(bossMechanics.bosses ?? {})) {
for (const mechanic of boss.mechanics ?? []) {
if (mechanic.spellId) wantedSpellIds.add(Number(mechanic.spellId));
}
}
const spellDbc = path.resolve(process.env.ASCENSION_SPELL_DBC ?? defaultSpellDbc);
const ascensionSpellSupport = await spellSupportTables(spellDbc, defaultSpellSupportRoot);
const ascensionSpells = await exists(spellDbc)
? parseSpells(
await readFile(spellDbc),
wantedSpellIds,
ascensionSpellSupport.durations,
ascensionSpellSupport.radii,
)
: new Map();
const epochSpellSupport = await spellSupportTables(epochSpellDbc, defaultSpellSupportRoot);
const epochSpells = await exists(epochSpellDbc)
? parseSpells(
await readFile(epochSpellDbc),
wantedSpellIds,
epochSpellSupport.durations,
epochSpellSupport.radii,
)
: ascensionSpells;
const standardGameObjectDisplays = await exists(standardGameObjectDisplayDbc)
? parseGameObjectDisplays(await readFile(standardGameObjectDisplayDbc))
: new Map();
const epochGameObjectDisplays = await exists(epochGameObjectDisplayDbc)
? parseGameObjectDisplays(await readFile(epochGameObjectDisplayDbc))
: standardGameObjectDisplays;
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);
const runtimeOverride = runtimeOverrides.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 coordinateAlignment = coordinateAlignmentFor(recipe, environmentMode);
const sourceTransform = sourceTransformFor(recipe, coordinateAlignment);
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],
);
let 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,
coordinateAlignment,
sourceTransform,
);
const entrancePosition = mapPosition(
draft?.entrance
?? draft?.spawns?.[0]?.position
?? playerAnchor.position,
);
const serverGameObjects = compileSourceGameObjects(
draft,
mapPosition,
recipe,
recipe.clientBuild === "3.3.5a-Epoch"
? epochGameObjectDisplays
: standardGameObjectDisplays,
);
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,
azerothCoreWorldAi,
bossMechanics,
),
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,
azerothCoreWorldAi,
bossMechanics,
),
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,
bossMechanics,
);
}
}
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 mappedNativeRoutePositions = coordinateAlignment !== "player-anchor"
? (draft?.spawns ?? [])
.map((spawn) => mapPosition(spawn.position))
.filter((position) => position.every(Number.isFinite))
.sort((left, right) => (
horizontalDistanceSquared(left, entrancePosition)
- horizontalDistanceSquared(right, entrancePosition)
))
: [];
const entranceDirectionTarget = mappedNativeRoutePositions.find((position) => (
Math.abs(position[1] - entrancePosition[1]) <= 3
&& horizontalDistanceSquared(position, entrancePosition) >= 16
));
if (entranceDirectionTarget) {
entranceYaw = Math.atan2(
entranceDirectionTarget[0] - entrancePosition[0],
entranceDirectionTarget[2] - entrancePosition[2],
);
}
const entranceLevelRoutePositions = mappedNativeRoutePositions.filter(
(position) => Math.abs(position[1] - entrancePosition[1]) <= 2.5,
);
const nativeRoutePositions = (
bossCandidatesByEntry.size === 0
&& entranceLevelRoutePositions.length >= encounters.length
)
? entranceLevelRoutePositions
: mappedNativeRoutePositions;
const usedBossSpawnIds = new Set();
const bossObjectives = encounters.map((encounter, index) => {
const candidate = bossCandidatesByEntry.get(Number(encounter.creatureId));
const spawnMask = encounterSpawnMask(recipe, encounter, candidate);
const declaredDifficultyMask = recipe.difficultyVariants.reduce(
(mask, difficulty) => mask | Number(difficulty.spawnMask ?? 0),
0,
);
const alpha = encounters.length <= 1 ? 1 : index / (encounters.length - 1);
const routeAlpha = 0.18 + alpha * 0.82;
const nativeRoutePosition = nativeRoutePositions.length
? nativeRoutePositions[Math.round(routeAlpha * (nativeRoutePositions.length - 1))]
: undefined;
const routePosition = nativeRoutePosition
?? interpolate(trashAnchor.position, bossAnchor.position, routeAlpha);
const position = encounter.eventOnly
? [-151.27, -102.82, 252.26]
: candidate
? mapPosition(candidate.position)
: nativeRoutePosition ?? offsetPosition(routePosition, index % 5, 2.4);
const id = encounter.eventOnly
? "mutanus-spawn"
: candidate?.id ?? `encounter-${encounter.encounterId}`;
const spawnChance = initializationSpawnChance(encounter.creatureId, azerothCoreWorldAi);
usedBossSpawnIds.add(id);
return {
id,
entityId: `entry-${encounter.creatureId}`,
name: encounter.name,
position,
yaw: candidate?.yaw ?? 0,
spawnMask,
...(spawnChance < 1 || (spawnMask & declaredDifficultyMask) !== declaredDifficultyMask
? { optional: true }
: {}),
...(encounter.eventOnly ? { eventOnly: true } : {}),
};
});
const staticSpawns = [];
const roamingPacks = [];
for (const spawn of sourceSpawns) {
const entry = Number(spawn.entityId.replace("entry-", ""));
const spawnChance = initializationSpawnChance(entry, azerothCoreWorldAi);
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);
const origin = waypoints[0];
const hasDistinctWaypoint = origin && waypoints.some((point) => Math.hypot(
point[0] - origin[0],
point[1] - origin[1],
point[2] - origin[2],
) > 1e-6);
if (waypoints.length >= 2 && hasDistinctWaypoint) {
roamingPacks.push({
id: `patrol-${spawn.guid}`,
name: `${entities[spawn.entityId].name} Patrol`,
speed: 1.15,
pathId: spawn.pathId,
formationSource: "database-solo",
spawnMask: spawn.spawnMask,
...(spawnChance < 1 ? { spawnChance } : {}),
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,
...(spawnChance < 1 ? { spawnChance } : {}),
});
}
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: boss.spawnMask,
});
}
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(entrancePosition, 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 = [
entrancePosition,
...(coordinateAlignment === "player-anchor" || syntheticTrash
? [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, optional, eventOnly }) => ({
id,
name,
position,
...(optional ? { optional: true } : {}),
...(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."]
: coordinateAlignment === "source"
? [`Server positions use the packaged ${environmentMode} environment's native world coordinates.`]
: coordinateAlignment === "source-transform"
? [`Server positions use the packaged ${environmentMode} environment's authoritative WDT placement transform.`]
: [`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, runtimeOverride.presentation),
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(entrancePosition),
forward: [Math.sin(entranceYaw), 0, Math.cos(entranceYaw)],
yaw: entranceYaw,
},
areas: [
{ id: "entrance", name: `${recipe.title} Entrance`, center: vector(entrancePosition), 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: (runtimeOverride.navigationLinks ?? []).map((link) => ({
id: String(link.id),
start: vector(link.start),
end: vector(link.end),
bidirectional: link.bidirectional !== false,
authoredReason: String(link.authoredReason),
})),
...(runtimeOverride.traversalRamps?.length ? {
traversalRamps: runtimeOverride.traversalRamps.map((ramp) => ({
id: String(ramp.id),
start: vector(ramp.start),
end: vector(ramp.end),
width: Number(ramp.width),
navigationBridge: {
linkId: String(ramp.navigationBridge.linkId),
startPortal: ramp.navigationBridge.startPortal.map(vector),
endPortal: ramp.navigationBridge.endPortal.map(vector),
},
})),
} : {}),
encounters: [compileBossProgressionEncounter(recipe, bosses, syntheticTrash)],
serverGameObjects,
provenance: {
clientBuild: recipe.clientBuild,
coordinateAlignment,
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)"
: coordinateAlignment === "source-transform"
? `three(x,y,z)=rotate-y(${sourceBasis === "flip-x" ? "(-draft.x,draft.y,draft.z)" : "draft(x,y,z)"}-[${sourceTransform.origin.join(",")}],${sourceTransform.yawDegrees}deg)`
: coordinateAlignment === "source"
? sourceBasis === "flip-x"
? "three(x,y,z)=(-draft.x,draft.y,draft.z)"
: "three(x,y,z)=draft(x,y,z)"
: 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",
...(runtimeOverride.validation?.navigation
? { navigation: runtimeOverride.validation.navigation }
: {}),
...(runtimeOverride.validation?.report
? { report: runtimeOverride.validation.report }
: {}),
blockers: [],
warnings: [
...sourceWarnings,
...(runtimeOverride.validation?.warnings ?? []),
],
},
});
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 });
const gameObjectAssetManifest = gameObjectAssetManifestFor(definitions);
await writeFile(outputFile, `${serialized}\n`, "utf8");
await writeFile(
gameObjectAssetManifestFile,
`${JSON.stringify(gameObjectAssetManifest, null, 2)}\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, "/"),
gameObjectAssetManifest: path.relative(projectRoot, gameObjectAssetManifestFile).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,
gameObjectInstances: gameObjectAssetManifest.summary.typedInstances,
nonVisualGameObjectInstances: gameObjectAssetManifest.summary.nonVisualInstances,
gameObjectAssets: gameObjectAssetManifest.summary.uniqueAssets,
unresolvedGameObjectModelInstances: gameObjectAssetManifest.summary.unresolvedModelInstances,
unresolvedGameObjectBoundInstances: gameObjectAssetManifest.summary.unresolvedBoundInstances,
}, null, 2));
}
await compile();