forked from phenom/healer-man
Release Healer Man 0.1.14
This commit is contained in:
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user