import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { abilitiesForCharacter } from "./abilityCatalog"; import { installCombatClock } from "./combatClock"; import { createDeathKnightRunes } from "./combatActors"; import { useCombatStore } from "./combatStore"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { advanceDungeonPartyCombat, resetPartyRuntimeClock, } from "./partyRuntime"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility, } from "./partyRuntimeRegistry"; import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; import { WOW335_DEATH_KNIGHT_REAPING_TALENT_ID } from "./wow335DeathKnightReapingRuntime"; const TARGET_ID = "reaping-target"; const PARTY_SEED = "wow335-death-knight-reaping"; let restoreClock: (() => void) | undefined; function bloodStrike() { const found = abilitiesForCharacter("death-knight", null).find((ability) => ability.name === "Blood Strike"); if (!found) throw new Error("Missing generated Death Knight Blood Strike."); return found; } function pestilence() { const found = abilitiesForCharacter("death-knight", null).find((ability) => ability.name === "Pestilence"); if (!found) throw new Error("Missing generated Death Knight Pestilence."); return found; } function registerTarget() { useCombatStore.getState().registerMob(TARGET_ID, { name: "Reaping target", maxHealth: 1_000_000, xpReward: 0, }); setMobPosition(TARGET_ID, [2, 0, 0]); useCombatStore.getState().selectMob(TARGET_ID); } describe("WoW Death Knight Reaping combat integration", () => { beforeEach(() => { restoreClock = installCombatClock(() => 1_000); clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80, xp: 0 }); usePartyStore.getState().initializeParty("wow335-death-knight-reaping", 80, useCombatStore.getState().health, "tank", 5); registerTarget(); }); afterEach(() => { restoreClock?.(); restoreClock = undefined; }); it("keeps the player's Reaping conversion after Blood Strike's second direct damage packet", () => { useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [WOW335_DEATH_KNIGHT_REAPING_TALENT_ID]: 3 }, deathKnightRunes: createDeathKnightRunes(1_000), })); const cast = useCombatStore.getState().castAbility(bloodStrike().id, { targetActorId: TARGET_ID, now: 1_000, }); expect(cast.ok).toBe(true); expect(useCombatStore.getState().combatEvents.filter((event) => ( event.sourceActorId === "__player__" && event.abilityId === bloodStrike().id && event.kind === "damage" ))).toHaveLength(2); expect(useCombatStore.getState().deathKnightRunes[0]).toMatchObject({ slot: 0, baseType: "blood", type: "death", readyAt: expect.any(Number), deathRuneExpiresAt: 31_000, deathRuneConversions: [{ source: "reaping", expiresAt: 31_000 }], }); }); it("spreads the player's disease through Pestilence before applying its single Reaping proc", () => { const abilities = abilitiesForCharacter("death-knight", null); const pest = pestilence(); const icyTouch = abilities.find((ability) => ability.name === "Icy Touch")!; const secondaryTargetId = "reaping-player-pestilence-secondary"; useCombatStore.getState().registerMob(secondaryTargetId, { name: "Pestilence secondary", maxHealth: 1_000_000, xpReward: 0, }); setMobPosition(secondaryTargetId, [4, 0, 0]); useCombatStore.getState().damageMob(secondaryTargetId, 1, 999); expect(useCombatStore.getState().castAbility(icyTouch.id, { targetActorId: TARGET_ID, now: 1_000, }).ok).toBe(true); useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [WOW335_DEATH_KNIGHT_REAPING_TALENT_ID]: 3 }, deathKnightRunes: createDeathKnightRunes(2_500), })); expect(useCombatStore.getState().castAbility(pest.id, { targetActorId: TARGET_ID, now: 2_500, }).ok).toBe(true); expect(useCombatStore.getState().effects).toContainEqual(expect.objectContaining({ kind: "dot", tag: "frost-fever", sourceActorId: "__player__", targetId: secondaryTargetId, })); expect(useCombatStore.getState().deathKnightRunes[0]).toMatchObject({ slot: 0, baseType: "blood", type: "death", deathRuneExpiresAt: 32_500, deathRuneConversions: [{ source: "reaping", expiresAt: 32_500 }], }); expect(useCombatStore.getState().deathKnightRunes[1]).toMatchObject({ slot: 1, baseType: "blood", type: "blood", }); }); it("converts the generated companion's just-spent Blood rune on its landed Blood Strike", () => { const strike = bloodStrike(); const abilities = abilitiesForCharacter("death-knight", null); const member = createConfiguredPartyMember({ name: "Reaping DK", classId: "death-knight", specialization: "Blood", specializationId: "death-knight:blood", talentTreeId: "death-knight-blood", talentRanks: { [WOW335_DEATH_KNIGHT_REAPING_TALENT_ID]: 3 }, role: "damage", }, 80, 0, PARTY_SEED); usePartyStore.setState({ members: [{ ...member, talentRanks: { [WOW335_DEATH_KNIGHT_REAPING_TALENT_ID]: 3 }, runes: createDeathKnightRunes(1_000), cooldowns: Object.fromEntries(abilities .filter((ability) => ability.id !== strike.id) .map((ability) => [ability.id, 60_000])), }] }); usePartyStore.getState().setCommand("attack"); useCombatStore.getState().synchronizeActors(); registerPartyRuntimePosition(member.id, [0, 0, 0]); useCombatStore.getState().damageMob(TARGET_ID, 1, 100); updatePartyRuntimeTargetVisibility(member.id, TARGET_ID, true); advanceDungeonPartyCombat(1_000); const updated = usePartyStore.getState().members.find((candidate) => candidate.id === member.id); expect(useCombatStore.getState().combatEvents.some((event) => ( event.sourceActorId === member.id && event.abilityId === strike.id && event.kind === "damage" ))).toBe(true); expect(updated?.runes[0]).toMatchObject({ slot: 0, baseType: "blood", type: "death", readyAt: expect.any(Number), deathRuneExpiresAt: 31_000, deathRuneConversions: [{ source: "reaping", expiresAt: 31_000 }], }); expect(useCombatStore.getState().actors[member.id]?.runes[0]).toMatchObject({ type: "death" }); }); it("spreads a companion-owned disease with Pestilence and procs Reaping once for the successful cast", () => { const pest = pestilence(); const abilities = abilitiesForCharacter("death-knight", null); const member = createConfiguredPartyMember({ name: "Reaping Pestilence DK", classId: "death-knight", specialization: "Blood", specializationId: "death-knight:blood", talentTreeId: "death-knight-blood", talentRanks: { [WOW335_DEATH_KNIGHT_REAPING_TALENT_ID]: 3 }, role: "damage", }, 80, 0, `${PARTY_SEED}-pestilence`); const secondaryTargetId = "reaping-pestilence-secondary"; useCombatStore.getState().registerMob(secondaryTargetId, { name: "Pestilence secondary", maxHealth: 1_000_000, xpReward: 0, }); setMobPosition(secondaryTargetId, [4, 0, 0]); usePartyStore.setState({ members: [{ ...member, talentRanks: { [WOW335_DEATH_KNIGHT_REAPING_TALENT_ID]: 3 }, runes: createDeathKnightRunes(1_000), cooldowns: Object.fromEntries(abilities .filter((ability) => ability.id !== pest.id) .map((ability) => [ability.id, 60_000])), }] }); usePartyStore.getState().setCommand("attack"); useCombatStore.getState().synchronizeActors(); registerPartyRuntimePosition(member.id, [0, 0, 0]); useCombatStore.getState().damageMob(TARGET_ID, 1, 100); useCombatStore.getState().damageMob(secondaryTargetId, 1, 100); updatePartyRuntimeTargetVisibility(member.id, TARGET_ID, true); const icyTouch = abilities.find((ability) => ability.name === "Icy Touch")!; const frostFever = icyTouch.effects.find((effect) => effect.kind === "dot")!; expect(useCombatStore.getState().applyTimedActorEffect( member.id, TARGET_ID, icyTouch, frostFever as Extract, 1_000, )).toBe(true); advanceDungeonPartyCombat(1_000); const updated = usePartyStore.getState().members.find((candidate) => candidate.id === member.id); expect(useCombatStore.getState().effects).toContainEqual(expect.objectContaining({ kind: "dot", tag: "frost-fever", sourceActorId: member.id, targetId: secondaryTargetId, })); expect(updated?.runes[0]).toMatchObject({ slot: 0, baseType: "blood", type: "death", deathRuneExpiresAt: 31_000, deathRuneConversions: [{ source: "reaping", expiresAt: 31_000 }], }); }); });