Release Healer Man 0.1.22
This commit is contained in:
1 parent
f6f2375145
commit
7f6001723a
144 files changed
+54107
-690
No files matched your search
@@ -10,6 +10,11 @@ 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 gameObjectAssetManifestFile = path.join(
|
||||
projectRoot,
|
||||
"dungeon-pipeline",
|
||||
"WOW_GAMEOBJECT_ASSET_MANIFEST.json",
|
||||
);
|
||||
const compressedOutputFile = path.join(
|
||||
projectRoot,
|
||||
"src",
|
||||
@@ -81,6 +86,19 @@ const epochSpellDbc = path.join(
|
||||
"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"],
|
||||
@@ -200,6 +218,100 @@ function slugPart(value) {
|
||||
.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 ?? []);
|
||||
@@ -214,6 +326,395 @@ const ADT_SOURCE_BASES = new Set([
|
||||
]);
|
||||
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") {
|
||||
@@ -371,13 +872,44 @@ function uniqueDraftEncounters(encounterRows, mapId) {
|
||||
...(resolvedEntry > 0 ? {} : { syntheticGroupEncounter: true }),
|
||||
};
|
||||
const key = `${creatureId}:${normalizedName(encounter.name)}`;
|
||||
if (!byIdentity.has(key)) byIdentity.set(key, encounter);
|
||||
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";
|
||||
@@ -1032,6 +1564,12 @@ async function compile() {
|
||||
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)
|
||||
@@ -1103,6 +1641,14 @@ async function compile() {
|
||||
?? 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)
|
||||
@@ -1230,6 +1776,11 @@ async function compile() {
|
||||
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
|
||||
@@ -1253,7 +1804,10 @@ async function compile() {
|
||||
name: encounter.name,
|
||||
position,
|
||||
yaw: candidate?.yaw ?? 0,
|
||||
...(spawnChance < 1 ? { optional: true } : {}),
|
||||
spawnMask,
|
||||
...(spawnChance < 1 || (spawnMask & declaredDifficultyMask) !== declaredDifficultyMask
|
||||
? { optional: true }
|
||||
: {}),
|
||||
...(encounter.eventOnly ? { eventOnly: true } : {}),
|
||||
};
|
||||
});
|
||||
@@ -1313,7 +1867,7 @@ async function compile() {
|
||||
entityId: boss.entityId,
|
||||
position: boss.position,
|
||||
yaw: boss.yaw,
|
||||
spawnMask: 1,
|
||||
spawnMask: boss.spawnMask,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1426,7 +1980,8 @@ async function compile() {
|
||||
bosses,
|
||||
objectiveOrder: bosses,
|
||||
navigationLinks: [],
|
||||
serverGameObjects: draft?.gameObjects ?? [],
|
||||
encounters: [compileBossProgressionEncounter(recipe, bosses, syntheticTrash)],
|
||||
serverGameObjects,
|
||||
provenance: {
|
||||
clientBuild: recipe.clientBuild,
|
||||
coordinateAlignment,
|
||||
@@ -1490,7 +2045,13 @@ async function compile() {
|
||||
};
|
||||
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`,
|
||||
@@ -1517,6 +2078,7 @@ async function compile() {
|
||||
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),
|
||||
@@ -1525,6 +2087,11 @@ async function compile() {
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user