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