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; 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 }); }); });