Correct minion sacrifice and passive talent semantics (refs #19)

Coordinator Root /root, session 01a12399-cfab-7c72-8455-e0e4cb7e156c.
Independently reviewed by /root/combat_source_finish_luna (GPT-6 Luna xhigh).
31 focused DK tests, required release check and complete source type check pass.
This commit is contained in:
warren committed 2026-10-15 09:09:45 -04:00
1 parent a9322a3897
commit 3687c423e2
11 files changed
+284 -10

No files matched your search

+1
View File
@@ -196,6 +196,7 @@ export type AbilityEffect =
| ({ readonly kind: "dot"; readonly coefficient: number; readonly school?: DamageSchool; readonly ticks: number; readonly intervalMs: number; readonly startsImmediately?: boolean; readonly tag?: string } & AbilityAmountScaling & AbilityDamageResource)
| ({ readonly kind: "hot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number; readonly percentMaxHealth?: number } & AbilityAmountScaling & AbilityHealingRecipients)
| { readonly kind: "resurrection"; readonly percentMaxHealth: number; readonly flatHealth?: number }
| { readonly kind: "sacrifice-minion"; readonly creatureIds: readonly number[]; readonly radius: number; readonly percentMaxHealth: number }
| {
readonly kind: "summon";
readonly creatureId: number;
+1 -1
View File
@@ -96,7 +96,7 @@ export function aiHintsForAbility(ability: AbilityDefinition): AbilityAiHints {
for (const effect of ability.effects) {
if (["damage", "dot", "finisher-damage"].includes(effect.kind)) categories.add("damage");
if (effect.kind === "execute") categories.add("execute");
if (effect.kind === "heal" || effect.kind === "hot") categories.add("heal");
if (effect.kind === "heal" || effect.kind === "hot" || effect.kind === "sacrifice-minion") categories.add("heal");
if (effect.kind === "shield") categories.add("mitigation");
if (effect.kind === "taunt") categories.add("taunt");
if (effect.kind === "interrupt") categories.add("interrupt");
+6
View File
@@ -679,6 +679,12 @@ function scoreAbility(
score += injured.length ? -3_000 : 300;
}
} else {
if (ability.effects.some((effect) => effect.kind === "sacrifice-minion")) {
// Automated minion sacrifices are emergency self-heals; the cast gate
// still requires a living owned source minion before resources are spent.
if (selfHealth >= cooldownPolicy.generic.defensiveHealthPercent / 100) return null;
score += 140_000;
}
if (categories.has("mitigation")) {
const defensiveThreshold = cooldownPolicy.generic.defensiveHealthPercent / 100;
const emergencyThreshold = cooldownPolicy.generic.emergencyHealthPercent / 100;
+1 -1
View File
@@ -1536,7 +1536,7 @@ describe("combat store", () => {
registerTarget("pact-boss", [2, 0, 0], 1_000_000, 0);
useCombatStore.getState().registerMob("pact-boss", { name: "Pact boss", boss: true, maxHealth: 1_000_000, xpReward: 0 });
useCombatStore.setState({ resource: 100, resourcePools: { "runic-power": 100 } });
expect(useCombatStore.getState().castAbility("wow335-death-knight-death-pact", undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().castAbility("wow335-death-knight-death-pact", undefined, 1_000)).toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().mobs["pact-boss"]).toMatchObject({ dead: false, health: 1_000_000 });
});
+54 -1
View File
@@ -1,3 +1,4 @@
import { eligibleSacrificeMinion } from "./sacrificeMinion";
import { healerManaRecoveryAmount, synchronizeHealerManaRecovery } from "./healerManaRecovery";
import { nativeDamageCasterAmount, nativeHolyHealCasterAmount, nativeHolyHealTargetAmount, nativePeriodicHealSnapshot, nativePeriodicDamageCasterSnapshot, nativeTrainingAutoAttackProfile, nativeDisarmedMainHandAutoAttackProfile, matchesNativeTrainingGear, type RomNativeDamageTargetContext } from "./romNativeCombatAmount";
import { romNativeNpcCombatant, romNativeTrainingCombatant, romNativePlayerHitProfile, romNativeNpcHitProfile, resolveRomNativeCombatResult,
@@ -1712,6 +1713,7 @@ export interface CombatState {
controlMob: (id: string, sourceAbilityId: string, status: MobStatusKind, durationMs: number, now?: number, breakOnDamage?: boolean, sourceActorId?: string, magnitude?: number) => boolean;
dispelActor: (id: string, categories: readonly DispelCategory[], maxCount?: number, now?: number) => number;
healActor: (id: string, amount: number, request: ActorHealingRequest) => HealingResolution;
sacrificeActorMinion: (ownerActorId: string, abilityId: string, effect: Extract<AbilityEffect, { kind: "sacrifice-minion" }>, origin: CombatPosition, now?: number) => boolean;
/** Applies the CoA Holy Candle self-damage trigger to a companion or channel caster. */
triggerRomPriestHolyCandle: (ownerActorId: string, healingAbilityId: string, now?: number) => number;
reviveActor: (id: string, percentMaxHealth: number, sourceActorId: string, abilityId?: string, now?: number) => number;
@@ -1822,6 +1824,30 @@ let summonSequence = 0;
let areaEffectSequence = 0;
let combatEventSequence = 0;
function sacrificeMinionInWork(
work: Pick<MutableCastWork, "summons" | "actors" | "auras" | "effects" | "combatEvents">,
ownerActorId: string,
abilityId: string,
effect: Extract<AbilityEffect, { kind: "sacrifice-minion" }>,
origin: CombatPosition,
now: number,
): boolean {
const minion = eligibleSacrificeMinion(work.summons, ownerActorId, effect, origin, now);
if (!minion) return false;
work.summons = work.summons.map((summon) => summon.id === minion.id
? { ...summon, health: 0, targetId: null, cast: null } : summon);
const actor = work.actors[minion.id];
if (actor) work.actors[minion.id] = { ...actor, health: 0, alive: false, cast: null };
work.auras = work.auras.filter((aura) => aura.targetId !== minion.id);
work.effects = work.effects.filter((candidate) => candidate.targetId !== minion.id);
recordCombatEventInWork(work, {
occurredAt: now, kind: "death", sourceActorId: ownerActorId,
targetActorId: minion.id, abilityId, rawAmount: 0, effectiveAmount: 0,
overheal: 0, absorbed: 0, critical: false, outcome: "sacrificed",
});
return true;
}
function initialSummonCombatRuntime(): Pick<CombatSummon,
"abilityIds" | "resources" | "cooldowns" | "globalCooldownEndsAt" | "cast" | "controlledUntil" | "schoolLockedUntil"
> {
@@ -1908,7 +1934,7 @@ function recordCombatEvent(
return appendCombatEvent(events, input, combatEventSequence);
}
function recordCombatEventInWork(work: MutableCastWork, input: CombatEventInput): void {
function recordCombatEventInWork(work: Pick<MutableCastWork, "combatEvents">, input: CombatEventInput): void {
work.combatEvents = [...recordCombatEvent(work.combatEvents, input)];
}
@@ -13665,6 +13691,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
&& Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead)) failure = "condition-not-met";
else if (ability.name === "Enraged Regeneration"
&& !state.auras.some((aura) => consumableEnrage(aura, PLAYER_AURA_ENTITY_ID, now))) failure = "condition-not-met";
else if (ability.effects.some((effect) => effect.kind === "sacrifice-minion"
&& !eligibleSacrificeMinion(state.summons, PLAYER_AGGRO_ID, effect, currentOrigin(state, origin), now))) failure = "condition-not-met";
else if (state.classId === "hunter" && ability.name === "Revive Pet"
&& !state.summons.some((summon) => summon.health <= 0
&& (summon.id.startsWith("player-pet-")
@@ -16879,6 +16907,22 @@ export const useCombatStore = create<CombatState>((set, get) => ({
]);
}
}
} else if (effect.kind === "sacrifice-minion") {
if (!sacrificeMinionInWork(work, PLAYER_AGGRO_ID, ability.id, effect, playerOrigin, now)) continue;
const amount = Math.round(resolveAuraHealingValue(
resolveAuraHealingValue(Math.round(work.maxHealth * effect.percentMaxHealth),
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID), "done"),
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID), "taken",
));
const healed = healPlayerInWork(work, amount);
work.totalHealing += healed;
recordCombatEventInWork(work, {
occurredAt: now, kind: "healing", sourceActorId: PLAYER_AGGRO_ID,
targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: ability.id, school: spellSchool,
rawAmount: amount, effectiveAmount: healed, overheal: amount - healed,
absorbed: 0, critical: false,
});
addHealingThreatInWork(work, castThreatSource, healed, now);
} else if (effect.kind === "execute") {
for (const id of affectedMobIds) {
const mob = work.mobs[id];
@@ -18676,6 +18720,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return applied;
},
sacrificeActorMinion: (ownerActorId, abilityId, effect, origin, now = combatNow()) => {
const state = get();
const work = { summons: [...state.summons], actors: { ...state.actors },
auras: [...state.auras], effects: [...state.effects], combatEvents: [...state.combatEvents] };
if (!sacrificeMinionInWork(work, ownerActorId, abilityId, effect, origin, now)) return false;
set(work);
return true;
},
healActor: (id, amount, request) => {
const now = request.now ?? combatNow();
const state = get();
+16 -2
View File
@@ -1,3 +1,4 @@
import { eligibleSacrificeMinion } from "./sacrificeMinion";
import { healerManaRecoveryAmount } from "./healerManaRecovery";
import { nativeDisarmedMainHandAutoAttackProfile, nativeTrainingAutoAttackProfile } from "./romNativeCombatAmount";
import { romNativePlayerHitProfile } from "./romNativeCombatResolution";
@@ -2666,6 +2667,8 @@ export function partyAbilityTargetIsValid(
|| (metamorphosisActive && !warlockMetamorphosisCanCastAbilityWhileActive(ability))) return false;
}
const origin = getPartyRuntimePosition(member.id) ?? combat.playerPosition;
if (ability.effects.some((effect) => effect.kind === "sacrifice-minion"
&& !eligibleSacrificeMinion(combat.summons, member.id, effect, origin, now))) return false;
const meditationAllowsMovement = romMageWarriorMeditationPathAllowsMovement(
member.classId,
member.secondaryClassId,
@@ -3190,7 +3193,7 @@ function choosePartySupportIntent(
lowestHealthFraction: target.health / Math.max(1, target.maxHealth),
friendlyTargets,
maintainedAbilityIds,
canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield"].includes(effect.kind)
canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield", "sacrifice-minion"].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)
@@ -4587,6 +4590,17 @@ function executePartyAbility(
ability.id,
now,
);
} else if (effect.kind === "sacrifice-minion") {
if (!useCombatStore.getState().sacrificeActorMinion(member.id, ability.id, effect, origin, now)) continue;
const result = useCombatStore.getState().healActor(member.id,
Math.round(member.maxHealth * effect.percentMaxHealth), {
sourceActorId: member.id, abilityId: ability.id, school: spellSchool,
criticalChance: 0, now,
});
effectiveAmount += result.effectiveAmount;
directHealingRawAmount += result.rawAmount;
combat = useCombatStore.getState();
runtimeMember = memberFromCompanionActor(runtimeMember, combat.actors[member.id]);
} else if (effect.kind === "execute") {
for (const hostileTargetId of hostileTargets) {
const target = useCombatStore.getState().mobs[hostileTargetId];
@@ -5147,7 +5161,7 @@ function executePartyAbility(
}
emitPartyAbility(member, ability, targetId, "release", now, !castStartAlreadyEmitted);
emitPartyAbility(member, ability, targetId, "impact", now, !castStartAlreadyEmitted);
if (effectiveAmount > 0 && ability.effects.some((effect) => ["heal", "hot", "shield", "resurrection"].includes(effect.kind))) {
if (effectiveAmount > 0 && ability.effects.some((effect) => ["heal", "hot", "shield", "resurrection", "sacrifice-minion"].includes(effect.kind))) {
useCombatStore.getState().addHealingThreat({ actorId: member.id, role: member.role, abilityId: ability.id }, effectiveAmount);
}
if (suddenDoomTrigger && depth === 0) {
+18
View File
@@ -0,0 +1,18 @@
import type { AbilityEffect } from "./abilityCatalog";
import type { CombatSummon } from "./combatStore";
import type { CombatPosition } from "./mobRuntimeRegistry";
/** Select one living, owned, modeled undead minion; hostile actors never qualify. */
export function eligibleSacrificeMinion(
summons: readonly CombatSummon[],
ownerActorId: string,
effect: Extract<AbilityEffect, { kind: "sacrifice-minion" }>,
origin: CombatPosition,
now: number,
): CombatSummon | undefined {
return summons.find((summon) => summon.ownerActorId === ownerActorId
&& summon.health > 0 && summon.expiresAt > now
&& effect.creatureIds.includes(summon.creatureId)
&& Math.hypot(summon.position[0] - origin[0], summon.position[1] - origin[1],
summon.position[2] - origin[2]) <= effect.radius);
}
+20 -4
View File
@@ -1633,6 +1633,14 @@ function classScriptEffects(
triggerSpellId: 0,
durationMs: 0,
}];
case "death-knight:Death Pact": return [{
kind: "sacrifice-minion",
// These are the undead ghoul entries already modeled by Raise Dead and
// Army of the Dead. Unknown summon families are never assumed undead.
creatureIds: [26125, 24207],
radius: rank.effects.find((effect) => effect.effectType === 1)?.radius ?? 0,
percentMaxHealth: Math.abs(rank.effects.find((effect) => effect.effectType === 10)?.basePoints ?? 0) / 100,
}];
case "death-knight:Raise Dead": return [{ kind: "summon", creatureId: 26125, coefficient: 0.45, durationMs: 60_000 }];
case "death-knight:Army of the Dead": return Array.from({ length: 8 }, () => ({
kind: "summon" as const, creatureId: 24207, coefficient: 0.2, durationMs: 40_000,
@@ -1851,6 +1859,11 @@ export function hunterExplosiveTrapDirectScaling(
};
}
function learnOnlyRank(rank: Wow335Rank): boolean {
const effects = rank.effects.filter((effect) => effect.effectType !== 0);
return effects.length > 0 && effects.every((effect) => effect.effectType === 36);
}
function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: string): readonly AbilityEffect[] {
const effects: AbilityEffect[] = [];
for (const [effectIndex, effect] of rank.effects.entries()) {
@@ -2150,7 +2163,7 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
}
const auraOnlyStance = classId === "death-knight"
&& ["Blood Presence", "Frost Presence", "Unholy Presence"].includes(abilityName);
if (!auraOnlyStance && !(classId === "warrior" && abilityName === "Last Stand")
if (!learnOnlyRank(rank) && !auraOnlyStance && !(classId === "warrior" && abilityName === "Last Stand")
&& !(classId === "mage" && abilityName === "Molten Armor")
&& !effects.some((effect) => ["damage", "heal", "dot", "hot", "shield"].includes(effect.kind))) {
if (classId === "death-knight" && abilityName === "Death Coil") {
@@ -2167,6 +2180,7 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
.filter((effect) => effect.kind !== "scripted" && effect.kind !== "unsupported")
.filter((effect) => !(
(classId === "paladin" && abilityName === "Redemption" && effect.kind === "resurrection")
|| (classId === "death-knight" && abilityName === "Death Pact" && ["execute", "heal"].includes(effect.kind))
|| (classId === "paladin" && abilityName === "Divine Storm"
&& (effect.kind === "damage" || effect.kind === "heal"))
|| (classId === "paladin" && abilityName === "Holy Shield" && effect.kind === "damage")
@@ -2523,8 +2537,9 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
const effectiveSourceUnlockLevel = classId === "warrior" && chain.name === "Sweeping Strikes"
? Math.max(1, firstRaw.sourceLevel || firstRaw.level)
: chain.sourceUnlockLevel;
const target = abilityNameTarget(classId, chain.name) ?? targetForRank(firstRaw, first.effects);
const radius = Math.max(
const target = first.effects.some((effect) => effect.kind === "sacrifice-minion")
? "self" : abilityNameTarget(classId, chain.name) ?? targetForRank(firstRaw, first.effects);
const radius = first.effects.some((effect) => effect.kind === "sacrifice-minion") ? 0 : Math.max(
abilityNameRadius(chain.name),
...firstRaw.effects.map((effect) => effect.radius),
);
@@ -2579,7 +2594,8 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
gcdMs: first.gcdMs,
cost: first.cost,
effects: first.effects,
passive: chain.passive,
passive: chain.passive || (Boolean(chain.talentNodeId) && castRanks.every((rank) => learnOnlyRank(rank) && rank.castTimeMs === 0
&& rank.cooldownMs === 0 && rank.powerCost === 0 && rank.powerCostPercentage === 0)),
source: "wow335-progression",
// Some stock WotLK active talents remain in Spell.dbc after Ascension's
// patched Talent.dbc removes their nodes (Devastate, Shadowstep, Lava
@@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { abilitiesForCharacter, abilityAtLevel, type AbilityEffect } from "./abilityCatalog";
import { PLAYER_AGGRO_ID } from "./aggro";
import { installCombatClock } from "./combatClock";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { advanceDungeonPartyCombat, partyAbilityTargetIsValid, resetPartyRuntimeClock } from "./partyRuntime";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { EMPTY_ITEM_STATS } from "./itemStats";
const PACT = "wow335-death-knight-death-pact";
const RAISE = "wow335-death-knight-raise-dead";
const ability = (id: string) => abilityAtLevel(abilitiesForCharacter("death-knight", null).find(a => a.id === id)!, 80);
let restoreClock: (() => void) | undefined;
function hostile(id: string, boss = false) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, xpReward: 0, boss });
setMobPosition(id, [2, 0, 0]);
useCombatStore.getState().selectMob(id);
}
function summon(owner = PLAYER_AGGRO_ID, position: readonly [number, number, number] = [0, 0, 0], creatureId = 26125) {
const source = ability(RAISE);
const effect = source.effects.find(e => e.kind === "summon") as Extract<AbilityEffect, {kind: "summon"}>;
return useCombatStore.getState().summonActor(owner, source, { ...effect, creatureId }, position, null, 1_000)!;
}
describe("Death Pact source semantics and learned Blood Gorged", () => {
beforeEach(() => {
restoreClock = installCombatClock(() => 1_000);
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80 });
usePartyStore.setState({ members: [], command: "attack" });
useCombatStore.setState({ health: 200, maxHealth: 1_000, resource: 100,
resourcePools: { "runic-power": 100 }, gearStats: { ...EMPTY_ITEM_STATS, criticalStrikeRating: 100_000 } });
useCombatStore.getState().synchronizeActors();
});
afterEach(() => restoreClock?.());
it("does not spend resources, cooldown or GCD without a living owned minion", () => {
hostile("trash"); hostile("boss", true);
const result = useCombatStore.getState().castAbility(PACT, undefined, 2_000);
expect(result).toMatchObject({ ok: false, reason: "condition-not-met", damage: 0, healing: 0 });
const s = useCombatStore.getState();
expect(s.resource).toBe(100); expect(s.cooldowns[PACT]).toBeUndefined();
expect(s.globalCooldownEndsAt).toBe(0);
expect(s.mobs.trash.health).toBe(100_000); expect(s.mobs.boss.health).toBe(100_000);
});
it("consumes one actual Raise Dead minion, heals only its caster for 40%, and never crits", () => {
hostile("trash"); hostile("boss", true);
expect(useCombatStore.getState().castAbility(RAISE, undefined, 1_000).ok).toBe(true);
const first = useCombatStore.getState().summons.find(s => s.creatureId === 26125)!;
expect(first).toBeDefined();
const other = summon();
const before = useCombatStore.getState();
const result = before.castAbility(PACT, undefined, 3_000);
expect(result).toMatchObject({ ok: true, damage: 0, healing: 400 });
const s = useCombatStore.getState();
expect(s.health).toBe(600); expect(s.resource).toBe(60);
expect(s.cooldowns[PACT]).toBe(123_000); expect(s.globalCooldownEndsAt).toBe(4_500);
expect(s.summons.find(x => x.id === first.id)?.health).toBe(0);
expect(s.actors[first.id]).toMatchObject({ health: 0, alive: false });
expect(s.summons.find(x => x.id === other)?.health).toBeGreaterThan(0);
expect(s.mobs.trash.health).toBe(100_000); expect(s.mobs.boss.health).toBe(100_000);
const events = s.combatEvents.filter(e => e.abilityId === PACT);
expect(events.filter(e => e.kind === "damage")).toHaveLength(0);
expect(events.filter(e => e.kind === "death")).toHaveLength(1);
expect(events.filter(e => e.kind === "healing")).toEqual([expect.objectContaining({
targetActorId: PLAYER_AGGRO_ID, rawAmount: 400, effectiveAmount: 400, critical: false,
})]);
});
it.each(["dead", "expired", "distant", "living-family"])("rejects %s minions and does not heal", mode => {
const id = summon(PLAYER_AGGRO_ID, mode === "distant" ? [101, 0, 0] : [0, 0, 0], mode === "living-family" ? 29264 : 26125);
useCombatStore.setState(s => ({ summons: s.summons.map(x => x.id === id
? { ...x, ...(mode === "dead" ? {health: 0} : {}), ...(mode === "expired" ? {expiresAt: 2_000} : {}) } : x) }));
expect(useCombatStore.getState().castAbility(PACT, undefined, 2_000)).toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().health).toBe(200);
});
it("does not consume another caster's minion; companion automatic use heals only itself", () => {
const base = createConfiguredPartyMember({ name: "DK", classId: "death-knight", specialization: "Blood",
specializationId: "death-knight:blood", talentTreeId: "death-knight-blood", role: "damage" }, 80, 0, "pact-party");
const member = { ...base, health: 100, maxHealth: 1_000, resource: 100,
resourcePools: {"runic-power": 100}, gearStats: EMPTY_ITEM_STATS, nextActionAt: 0,
talentRanks: {}, cooldowns: Object.fromEntries(abilitiesForCharacter("death-knight", null)
.filter(a => a.id !== PACT).map(a => [a.id, 20_000])) };
usePartyStore.setState({ members: [member], command: "attack" });
registerPartyRuntimePosition(member.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
const id = summon(member.id);
expect(useCombatStore.getState().castAbility(PACT, undefined, 2_000)).toMatchObject({ ok: false, reason: "condition-not-met" });
expect(partyAbilityTargetIsValid(member, ability(PACT), member.id, undefined, 2_000)).toBe(true);
hostile("party-trash");
useCombatStore.setState(s => ({ mobs: { ...s.mobs, "party-trash": { ...s.mobs["party-trash"], engaged: true } } }));
advanceDungeonPartyCombat(2_000);
const s = useCombatStore.getState();
expect(s.summons.find(x => x.id === id)?.health).toBe(0);
expect(s.health).toBe(200);
expect(usePartyStore.getState().members[0].health).toBe(500);
expect(s.combatEvents.filter(e => e.kind === "healing" && e.abilityId === PACT)).toEqual([
expect.objectContaining({ sourceActorId: member.id, targetActorId: member.id, rawAmount: 400, critical: false }),
]);
expect(partyAbilityTargetIsValid(member, ability(PACT), member.id, undefined, 2_001)).toBe(false);
});
it("caps overhealing and accounts for it once", () => {
summon(); useCombatStore.setState({ health: 950 });
expect(useCombatStore.getState().castAbility(PACT, undefined, 2_000).healing).toBe(50);
expect(useCombatStore.getState().combatEvents.find(e => e.kind === "healing" && e.abilityId === PACT))
.toMatchObject({ rawAmount: 400, effectiveAmount: 50, overheal: 350, critical: false });
});
it.each([1, 2, 3, 4, 5])("keeps Blood Gorged rank %s passive without an invented direct attack", rank => {
const source = ability("wow335-death-knight-blood-gorged");
expect(source.passive).toBe(true);
expect(source.ranks?.[rank - 1].effects.some(e => ["damage", "heal", "execute"].includes(e.kind))).toBe(false);
useCombatStore.setState({ talentRanks: { "talent-2034": rank } });
expect(useCombatStore.getState().castAbility(source.id, undefined, 2_000))
.toMatchObject({ ok: false, reason: "passive", damage: 0 });
});
});