Release Healer Man 0.1.55

This commit is contained in:
phenom committed 2026-09-19 12:23:33 -04:00
1 parent 2dbb0acfc1
commit 38b3d4d160
14 files changed
+279 -41

No files matched your search

+17
View File
@@ -7,6 +7,7 @@ import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
function registerTarget(id = "target", distance = 2): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0 });
@@ -150,20 +151,36 @@ describe("named aura combat runtime", () => {
const summonIds = prepared.summons.map((summon) => summon.id);
useCombatStore.getState().applyPrecombatPreparation(now + 1_000);
expect(useCombatStore.getState().summons.map((summon) => summon.id)).toEqual(summonIds);
registerTarget("pet-target");
useCombatStore.getState().damageMob("pet-target", 1, now + 1_100);
const beforePetAttack = useCombatStore.getState().mobs["pet-target"].health;
useCombatStore.getState().tick(0.1, now + 2_100);
expect(useCombatStore.getState().mobs["pet-target"].health).toBeLessThan(beforePetAttack);
});
it("consumes a named absorb before player health", () => {
const now = Date.now();
const events: StructuredCombatEvent[] = [];
const stop = subscribeCombatEvents((event) => events.push(event));
expect(useCombatStore.getState().castAbility(
"wow335-priest-power-word-shield",
undefined,
now,
).ok).toBe(true);
stop();
const beforeHealth = useCombatStore.getState().health;
const beforeAbsorb = useCombatStore.getState().auras
.flatMap((aura) => aura.absorbs)
.reduce((total, absorb) => total + absorb.remaining, 0);
expect(beforeAbsorb).toBeGreaterThan(0);
expect(events).toContainEqual(expect.objectContaining({
kind: "absorb",
sourceActorId: PLAYER_AGGRO_ID,
targetActorId: PLAYER_AGGRO_ID,
abilityId: "wow335-priest-power-word-shield",
effectiveAmount: beforeAbsorb,
}));
expect(useCombatStore.getState().auras.find((aura) => aura.absorbs.length)?.definition.icon).toBe(
useCombatStore.getState().abilities.find((ability) => (
ability.id === "wow335-priest-power-word-shield"
+26
View File
@@ -374,6 +374,32 @@ describe("shared combat rotations", () => {
})).toBeNull();
});
it("reserves off-profile utility for reactive needs instead of spending rotation GCDs", () => {
const demoralizingShout = ability("warrior", "Demoralizing Shout");
const pummel = ability("warrior", "Pummel");
const mortalStrike = ability("warrior", "Mortal Strike");
const arms = context({
classId: "warrior",
specializationId: "warrior:arms",
primaryResource: "rage",
resources: { rage: 100 },
maximumResources: { rage: 100 },
abilities: [demoralizingShout, pummel],
knownDamagingAbilityNames: new Set([mortalStrike.name]),
});
expect(evaluateCombatRotation(arms)).toBeNull();
expect(evaluateCombatRotation({
...arms,
hostileTarget: {
id: "caster",
health: 1_000,
maxHealth: 1_000,
activeCast: { interruptible: true },
},
})?.ability.id).toBe(pummel.id);
});
it("never falls a full-health friendly maintenance heal through to a hostile target", () => {
const strike = semanticAbility("strike", [{ kind: "damage", coefficient: 1 }]);
const prayer = semanticAbility("prayer", [
+10 -2
View File
@@ -7,7 +7,7 @@ import { spendDeathKnightRunes } from "./combatResources";
import { healingAbilityCanAidOthers } from "./healingAbilityRules";
import type { PartyRole } from "./partyRoles";
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v3";
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v4";
export interface RotationProfile {
readonly id: string;
@@ -90,7 +90,7 @@ const profile = (
/** Playerbots priorities translated to the executable Healer Man spell model. */
export const CLASSIC_ROTATION_PROFILES: readonly RotationProfile[] = Object.freeze([
profile("warrior:arms", "warrior", ["Battle Stance", "Bloodrage", "Rend", "Mortal Strike", "Overpower", "Execute", "Slam", "Heroic Strike"]),
profile("warrior:arms", "warrior", ["Battle Stance", "Bloodrage", "Rend", "Mortal Strike", "Bladestorm", "Overpower", "Execute", "Slam", "Heroic Strike"]),
profile("warrior:fury", "warrior", ["Berserker Stance", "Battle Stance", "Bloodrage", "Bloodthirst", "Whirlwind", "Execute", "Sunder Armor", "Heroic Strike"]),
profile("warrior:protection", "warrior", ["Defensive Stance", "Battle Stance", "Bloodrage", "Shield Slam", "Revenge", "Devastate", "Thunder Clap", "Sunder Armor", "Heroic Strike"]),
profile("paladin:holy", "paladin", ["Concentration Aura", "Devotion Aura", "Blessing of Might", "Seal of Wisdom", "Seal of Light", "Seal of Righteousness", "Holy Shock", "Flash of Light", "Holy Light", "Sacred Shield", "Judgement of Light"]),
@@ -430,6 +430,14 @@ function scoreAbility(
if (maintained && (categories.has("debuff") || categories.has("buff"))) return null;
if (activeSummon && (categories.has("summon") || categories.has("pet"))) return null;
if (rotationProfile && profileIndex < 0 && !damaging && (maintenanceBuff || petSummon || offensiveSummon)) return null;
// A specialization profile is also an allow-list for utility GCDs once the
// build has a real damage action. Reactive interrupts, taunts, threat drops,
// and encounter-requested control already returned above; letting any other
// imported debuff/control action through here makes characters spam buttons
// such as Expose Armor and Demoralizing Shout instead of their rotation.
if (rotationProfile && context.level >= 10 && hasKnownProfileDamageAction
&& context.role !== "healer" && profileIndex < 0
&& (categories.has("debuff") || categories.has("control") || categories.has("interrupt"))) return null;
if (rotationProfile && context.level >= 10 && hasKnownProfileDamageAction
&& context.role !== "healer" && profileIndex < 0 && damaging) return null;
if (isComboFinisher(ability) && (context.comboPoints < 4 || context.comboTargetId !== context.hostileTarget?.id)) return null;
+64 -22
View File
@@ -5286,12 +5286,29 @@ export const useCombatStore = create<CombatState>((set, get) => ({
for (const recipientId of recipients) {
const previousAuras = work.auras;
if (effect.control && work.mobs[recipientId]?.characterDefender && work.mobs[recipientId].mechanicImmunities.includes(effect.control.kind as AzerothCoreMechanic)) continue;
work.auras = [...applyCombatAura(previousAuras, {
const auraResult = applyCombatAura(previousAuras, {
definition: auraDefinition,
sourceId: PLAYER_AURA_ENTITY_ID,
targetId: recipientId,
now,
}).auras];
});
work.auras = [...auraResult.auras];
const grantedAbsorb = auraResult.aura.absorbs.reduce((total, absorb) => total + absorb.remaining, 0);
if (grantedAbsorb > 0) {
recordCombatEventInWork(work, {
occurredAt: now,
kind: "absorb",
sourceActorId: PLAYER_AURA_ENTITY_ID,
targetActorId: recipientId,
abilityId: ability.id,
school: presentationSchoolForAbility(ability),
rawAmount: grantedAbsorb,
effectiveAmount: grantedAbsorb,
overheal: 0,
absorbed: grantedAbsorb,
critical: false,
});
}
applyAuraHealthTransitionInWork(work, recipientId, previousAuras, work.auras);
if (auraDefinition.controlImmunities?.length) {
if (
@@ -6278,6 +6295,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
targetId: targetActorId,
now,
});
const grantedAbsorb = result.aura.absorbs.reduce((total, absorb) => total + absorb.remaining, 0);
let health = state.health;
let maxHealth = state.maxHealth;
const actors = { ...state.actors };
@@ -6316,24 +6334,40 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}
}
}
let combatEvents = recordCombatEvent(state.combatEvents, {
occurredAt: now,
kind: "aura",
sourceActorId,
targetActorId,
abilityId: ability.id,
school: presentationSchoolForAbility(ability),
rawAmount: 0,
effectiveAmount: 1,
overheal: 0,
absorbed: 0,
critical: false,
});
if (grantedAbsorb > 0) {
combatEvents = recordCombatEvent(combatEvents, {
occurredAt: now,
kind: "absorb",
sourceActorId,
targetActorId,
abilityId: ability.id,
school: presentationSchoolForAbility(ability),
rawAmount: grantedAbsorb,
effectiveAmount: grantedAbsorb,
overheal: 0,
absorbed: grantedAbsorb,
critical: false,
});
}
set({
auras: result.auras,
health,
maxHealth,
actors,
combatEvents: recordCombatEvent(state.combatEvents, {
occurredAt: now,
kind: "aura",
sourceActorId,
targetActorId,
abilityId: ability.id,
school: presentationSchoolForAbility(ability),
rawAmount: 0,
effectiveAmount: 1,
overheal: 0,
absorbed: 0,
critical: false,
}),
combatEvents,
});
if (companionActor) mirrorCompanionActor(companionActor);
return true;
@@ -8330,13 +8364,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
if (
summon.health <= 0
|| summon.controlledUntil > now
|| summon.nextActionAt > now
|| summon.globalCooldownEndsAt > now
|| summon.cast
|| summon.attackDamage <= 0
) return summon;
const readyToAct = summon.nextActionAt <= now && summon.globalCooldownEndsAt <= now;
const summonAbility = abilityById(summon.sourceAbilityId);
if (summon.kind === "totem" && summonAbility?.effects.some((effect) => effect.kind === "heal" || effect.kind === "hot")) {
if (!readyToAct) return summon;
const partyMembers = usePartyStore.getState().members;
const injured = [
{ id: PLAYER_AURA_ENTITY_ID, health: work.health, maxHealth: work.maxHealth, position: state.playerPosition },
@@ -8374,12 +8408,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}
return { ...summon, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) };
}
const targetId = summon.targetId && !work.mobs[summon.targetId]?.dead
const targetId = summon.targetId && work.mobs[summon.targetId] && !work.mobs[summon.targetId].dead
? summon.targetId
: state.selectedTargetId && !work.mobs[state.selectedTargetId]?.dead
: state.selectedTargetId && work.mobs[state.selectedTargetId] && !work.mobs[state.selectedTargetId].dead
? state.selectedTargetId
: Object.values(work.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null;
if (!targetId) return { ...summon, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) };
if (!targetId) return readyToAct
? { ...summon, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) }
: summon;
const targetPosition = getMobPosition(targetId);
let position = summon.position;
if (targetPosition) {
@@ -8392,7 +8428,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
position = projectCombatGroundPoint(requested, step, position) ?? position;
}
if (
Math.sqrt(distanceSquared(position, targetPosition)) <= (summon.kind === "totem" ? 30 : 5)
readyToAct
&& Math.sqrt(distanceSquared(position, targetPosition)) <= (summon.kind === "totem" ? 30 : 5)
&& combatHasLineOfSight(position, targetPosition)
) {
damageMobInWork(
@@ -8406,7 +8443,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
);
}
}
return { ...summon, targetId, position, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) };
return {
...summon,
targetId,
position,
nextActionAt: readyToAct ? now + Math.max(500, summon.pulseIntervalMs) : summon.nextActionAt,
};
});
for (const [id, mob] of Object.entries(work.mobs)) {
+97
View File
@@ -22,7 +22,9 @@ import {
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
import { abilitiesForClass } from "./abilityCatalog";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
function registerEngagedMob(id = "pack:a", maxHealth = 200): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
@@ -138,6 +140,101 @@ describe("party combat runtime", () => {
expect(useCombatStore.getState().summons.filter((summon) => (
summon.ownerActorId === shaman.id && summon.sourceAbilityId === feralSpirit.id
))).toHaveLength(2);
const beforeSummonAttack = useCombatStore.getState().mobs["feral-spirit-boss"].health;
useCombatStore.getState().tick(0.1, 10_000);
expect(useCombatStore.getState().mobs["feral-spirit-boss"].health).toBeLessThan(beforeSummonAttack);
});
it("gives companion Hunter and Warlock pets a hostile target when summoned from self", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
const hunter = createConfiguredPartyMember({
name: "Beast Mastery",
classId: "hunter",
specialization: "Beast Mastery",
specializationId: "hunter:beast-mastery",
talentTreeId: "hunter-beast-mastery",
role: "damage",
}, 80, 0, "pet-target-runtime");
const warlock = createConfiguredPartyMember({
name: "Demonology",
classId: "warlock",
specialization: "Demonology",
specializationId: "warlock:demonology",
talentTreeId: "warlock-demonology",
talentRanks: runtimePartyTalentRanksForLevel("warlock", "Demonology", 80),
role: "damage",
}, 80, 1, "pet-target-runtime");
usePartyStore.getState().initializeParty("pet-target-runtime", 80, useCombatStore.getState().health, "damage");
usePartyStore.setState({ members: [hunter, warlock] });
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [-14.5, 0, 0]);
useCombatStore.getState().synchronizeActors();
registerEngagedMob("pet-runtime-boss", 100_000);
const events: StructuredCombatEvent[] = [];
const stop = subscribeCombatEvents((event) => events.push(event));
for (let now = 1_000; now <= 30_000; now += 100) {
useCombatStore.getState().tick(0.1, now);
advanceDungeonPartyCombat(now);
}
stop();
const pets = useCombatStore.getState().summons.filter((summon) => (
summon.ownerActorId === hunter.id || summon.ownerActorId === warlock.id
));
expect(new Set(pets.map((summon) => summon.ownerActorId))).toEqual(new Set([hunter.id, warlock.id]));
expect(pets.every((summon) => summon.targetId === "pet-runtime-boss")).toBe(true);
expect(events.filter((event) => event.kind === "damage"
&& [hunter.id, warlock.id].includes(event.sourceActorId))).toEqual(expect.arrayContaining([
expect.objectContaining({ abilityId: "wow335-hunter-call-pet" }),
expect.objectContaining({ abilityId: "wow335-warlock-summon-felguard" }),
]));
});
it("casts Power Word: Shield on an injured ally and grants absorb capacity", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
const tank = createConfiguredPartyMember({
name: "Tank",
classId: "warrior",
specialization: "Protection",
specializationId: "warrior:protection",
talentTreeId: "warrior-protection",
role: "tank",
}, 80, 0, "discipline-shield-runtime");
const healer = createConfiguredPartyMember({
name: "Discipline",
classId: "priest",
specialization: "Discipline",
specializationId: "priest:discipline",
talentTreeId: "priest-discipline",
role: "healer",
}, 80, 1, "discipline-shield-runtime");
const priestAbilities = abilitiesForClass("priest");
const shield = priestAbilities.find((ability) => ability.name === "Power Word: Shield")!;
usePartyStore.getState().initializeParty("discipline-shield-runtime", 80, useCombatStore.getState().health, "damage");
usePartyStore.setState({
members: [
{ ...tank, health: Math.round(tank.maxHealth * 0.5) },
{
...healer,
resource: 100_000,
maxResource: 100_000,
resourcePools: { mana: 100_000 },
cooldowns: Object.fromEntries(priestAbilities.filter((ability) => ability.id !== shield.id)
.map((ability) => [ability.id, 60_000])),
},
],
});
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
registerEngagedMob("discipline-shield-boss", 100_000);
advanceDungeonPartyCombat(1_000);
const applied = useCombatStore.getState().auras.find((aura) => (
aura.sourceId === healer.id
&& aura.targetId === tank.id
&& aura.definition.name.includes("Power Word: Shield")
));
expect(applied?.absorbs.reduce((total, absorb) => total + absorb.remaining, 0)).toBeGreaterThan(0);
});
it("lets a resurrected tank rejoin independently instead of taunting or casting from its checkpoint", () => {
+14 -3
View File
@@ -972,7 +972,8 @@ function choosePartySupportIntent(
health: candidate.health,
maxHealth: candidate.maxHealth,
})),
canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield"].includes(effect.kind))
canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield"].includes(effect.kind)
|| (effect.kind === "apply-aura" && Boolean(effect.aura.absorbs?.length)))
&& (healingAbilityCanAidOthers(ability) || target.id === member.id)
&& !(healingAbilityHasPendingHot(ability, member.id, target.id, combat.effects)
&& (!ability.effects.some(effect => effect.kind === "heal") || target.health / target.maxHealth > 0.5))
@@ -1162,7 +1163,12 @@ function executePartyAbility(
comboTargetId: comboPoints > 0 ? targetId : null,
};
} else if (effect.kind === "summon") {
useCombatStore.getState().summonActor(member.id, ability, effect, origin, targetId, now);
const summonTargetId = combat.mobs[targetId] && !combat.mobs[targetId].dead
? targetId
: combat.selectedTargetId && combat.mobs[combat.selectedTargetId] && !combat.mobs[combat.selectedTargetId].dead
? combat.selectedTargetId
: Object.values(combat.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null;
useCombatStore.getState().summonActor(member.id, ability, effect, origin, summonTargetId, now);
} else if (effect.kind === "pet" && ["summon", "call", "resurrect", "tame"].includes(effect.action)) {
const summonEffect: Extract<AbilityEffect, { kind: "summon" }> = {
kind: "summon",
@@ -1170,7 +1176,12 @@ function executePartyAbility(
coefficient: 0.35,
durationMs: Number.MAX_SAFE_INTEGER - now,
};
useCombatStore.getState().summonActor(member.id, ability, summonEffect, origin, targetId, now);
const summonTargetId = combat.mobs[targetId] && !combat.mobs[targetId].dead
? targetId
: combat.selectedTargetId && combat.mobs[combat.selectedTargetId] && !combat.mobs[combat.selectedTargetId].dead
? combat.selectedTargetId
: Object.values(combat.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null;
useCombatStore.getState().summonActor(member.id, ability, summonEffect, origin, summonTargetId, now);
} else if (effect.kind === "movement") {
const targetPosition = combat.mobs[targetId] ? getMobPosition(targetId) : partyFriendlyPosition(targetId);
if (targetPosition && effect.movement !== "knockback" && effect.toward !== "away") {
+10
View File
@@ -117,6 +117,16 @@ describe("WoW 3.3.5 executable effect mapping", () => {
expect.objectContaining({ coefficient: 1.8 }),
);
expect(effectsOfKind(byId("wow335-warrior-execute"), "heal")).toEqual([]);
expect(effectsOfKind(byId("wow335-warrior-bladestorm"), "dot")).toEqual([
expect.objectContaining({ coefficient: 0.6, ticks: 6, intervalMs: 1_000 }),
]);
expect(byId("wow335-warrior-bladestorm")).toMatchObject({ target: "self", radius: 8 });
expect(effectsOfKind(byId("wow335-paladin-hammer-of-the-righteous"), "damage")).toEqual([
expect.objectContaining({ coefficient: 1.2 }),
]);
expect(effectsOfKind(byId("wow335-druid-swiftmend"), "heal")).toEqual([
expect.objectContaining({ coefficient: 2.2 }),
]);
expect(effectsOfKind(byId("wow335-rogue-mutilate"), "damage")).toContainEqual(
expect.objectContaining({ coefficient: 2 }),
);
+16 -1
View File
@@ -744,6 +744,11 @@ function classScriptEffects(
// The player spell delegates its weapon hit to a triggered child spell;
// retain the imported self-heal aura and restore the missing attack.
case "warrior:Bloodthirst": return [{ kind: "damage", coefficient: 1.2 }];
// The player-facing aura delegates each weapon strike to server-only child
// spells. Model the six one-second swings as a periodic damage payload.
case "warrior:Bladestorm": return [{
kind: "dot", coefficient: 0.6, ticks: 6, intervalMs: 1_000, tag: "bladestorm",
}];
case "warrior:Bloodrage": return [{ kind: "resource", resource: "rage", amount: 20 }];
case "warrior:Execute": return [{ kind: "execute", coefficient: 1.8 }];
case "warrior:Charge": return [{ kind: "status", status: "stun", durationMs: 1_500 }];
@@ -764,6 +769,9 @@ function classScriptEffects(
case "paladin:Judgement of Justice": return [{ kind: "status", status: "root", durationMs: 10_000 }];
case "paladin:Righteous Defense": return [{ kind: "taunt", durationMs: 3_000, maxTargets: 3 }];
case "paladin:Hand of Salvation": return [{ kind: "threat", mode: "percent", amount: -20, recipient: "ability-target" }];
// The DBC parent carries a one-point trigger marker; its weapon hit lives in
// a child spell that is not present in the player-facing progression rows.
case "paladin:Hammer of the Righteous": return [{ kind: "damage", coefficient: 1.2 }];
case "paladin:Divine Intervention": return [{
kind: "apply-aura",
aura: {
@@ -881,6 +889,9 @@ function classScriptEffects(
triggerSpellId: 0,
}];
case "warlock:Inferno": return [{ kind: "summon", creatureId: 89, coefficient: 0.8, durationMs: 60_000 }];
// Swiftmend's DBC row exposes only the one-point consume marker; supply the
// direct heal while leaving HoT consumption out until aura choice is modeled.
case "druid:Swiftmend": return [{ kind: "heal", coefficient: 2.2 }];
case "druid:Pounce": return [{ kind: "dot", coefficient: 0.18, ticks: 6, intervalMs: 3_000, tag: "pounce" }];
default: return [];
}
@@ -1178,6 +1189,9 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
|| (classId === "priest" && abilityName === "Penance"
&& (effect.kind === "damage" || effect.kind === "heal"))
|| (classId === "shaman" && abilityName === "Feral Spirit" && effect.kind === "summon")
|| (classId === "warrior" && abilityName === "Bladestorm" && effect.kind === "dot")
|| (classId === "paladin" && abilityName === "Hammer of the Righteous" && effect.kind === "damage")
|| (classId === "druid" && abilityName === "Swiftmend" && effect.kind === "heal")
|| (classId === "warrior" && abilityName === "Bloodrage"
&& (effect.kind === "resource" || effect.kind === "heal"))
))
@@ -1351,6 +1365,7 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
}
function abilityNameRadius(abilityName: string): number {
if (abilityName === "Bladestorm") return 8;
if (abilityName === "Hellfire") return 10;
if (abilityName === "Mind Sear") return 10;
if (abilityName === "Pestilence") return 10;
@@ -1359,7 +1374,7 @@ function abilityNameRadius(abilityName: string): number {
}
function abilityNameTarget(abilityName: string): AbilityTarget | undefined {
if (["Divine Hymn", "Tranquility", "Holy Nova", "Hellfire", "Army of the Dead"].includes(abilityName)) return "self";
if (["Bladestorm", "Divine Hymn", "Tranquility", "Holy Nova", "Hellfire", "Army of the Dead"].includes(abilityName)) return "self";
if (abilityName === "Penance") return "friendly";
if (abilityName === "Death Coil") return "hostile";
return undefined;