2152 lines
81 KiB
JavaScript
2152 lines
81 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 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 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 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);
|
|
mechanics.push({
|
|
...attack,
|
|
...event,
|
|
id: `smart-${entry}-${action.id}-${slugPart(spell.name)}`,
|
|
target: [5, 6].includes(Number(action.target_type))
|
|
? "random-party"
|
|
: attack.target,
|
|
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)) 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 = 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 ascensionSpells = await exists(spellDbc)
|
|
? parseSpells(await readFile(spellDbc), wantedSpellIds)
|
|
: new Map();
|
|
const epochSpells = await exists(epochSpellDbc)
|
|
? parseSpells(await readFile(epochSpellDbc), wantedSpellIds)
|
|
: 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();
|