Release Healer Man 0.1.14

This commit is contained in:
phenom committed 2026-09-05 11:40:50 -04:00
1 parent 67d7a8ebe7
commit 7b41b102fa
147 files changed
+169892 -1342

No files matched your search

@@ -47,6 +47,14 @@ const azerothCoreReferenceFile = path.join(
"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(
@@ -533,8 +541,194 @@ function fallbackSpecial(id, boss) {
};
}
function combatFor(entity, boss, spells, scriptCatalog = undefined) {
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 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);
@@ -565,20 +759,27 @@ function combatFor(entity, boss, spells, scriptCatalog = undefined) {
: 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 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,
attacks: [basicAttack(basicCooldown), ...spellAttacks],
...(boss ? { mechanicIds: mechanics.map((mechanic) => mechanic.id) } : {}),
attacks: [basicAttack(basicCooldown), ...mechanics],
};
}
@@ -596,6 +797,7 @@ function wailingServerCombatFor(entity, boss, reference) {
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],
@@ -664,7 +866,7 @@ function offsetPosition(position, index, radius = 3.2) {
];
}
function syntheticEntity(id, name, boss, level) {
function syntheticEntity(id, name, boss, level, bossMechanics = undefined) {
const entity = {
id,
entry: Number(id.match(/\d+/)?.[0] ?? stableHash(id)),
@@ -681,7 +883,7 @@ function syntheticEntity(id, name, boss, level) {
name,
...(boss ? { title: "Dungeon Boss", hasLoot: true } : { hasLoot: false }),
combat: {
...combatFor(entity, boss, new Map()),
...combatFor(entity, boss, new Map(), undefined, undefined, bossMechanics),
...(!boss ? { attacks: [basicAttack(), fallbackSpecial(id, false)] } : {}),
},
visual: visualFor(id, name, boss, entity.rank),
@@ -764,6 +966,9 @@ async function compile() {
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.");
@@ -798,6 +1003,14 @@ async function compile() {
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);
@@ -897,7 +1110,14 @@ async function compile() {
...(boss ? { title: "Dungeon Boss", hasLoot: true } : {}),
combat: recipe.slug === "wailing-caverns"
? wailingServerCombatFor(source, boss, azerothCoreReference)
: combatFor(source, boss, spells, scriptSpellCatalog),
: combatFor(
source,
boss,
spells,
scriptSpellCatalog,
azerothCoreWorldAi,
bossMechanics,
),
visual: visualFor(
source.id,
source.name,
@@ -920,7 +1140,14 @@ async function compile() {
hasLoot: true,
combat: recipe.slug === "wailing-caverns"
? wailingServerCombatFor(source, true, azerothCoreReference)
: combatFor(source, true, spells, scriptSpellCatalog),
: combatFor(
source,
true,
spells,
scriptSpellCatalog,
azerothCoreWorldAi,
bossMechanics,
),
visual: visualFor(
entityId,
encounter.name,
@@ -949,6 +1176,7 @@ async function compile() {
encounter.name,
true,
recipe.difficultyVariants[0]?.levelRange?.[1] ?? 60,
bossMechanics,
);
}
}