import { beforeEach, describe, expect, it } from "vitest"; import { PLAYER_AGGRO_ID } from "./aggro"; import { abilityTalentNodeId, type AbilityDefinition } from "./abilityCatalog"; import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog"; import { talentModifiersForRanks } from "./talentModifiers"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; import { useCombatStore, type TimedCombatEffect } from "./combatStore"; function prepareSwiftmend(): AbilityDefinition { useCombatStore.getState().initializeCharacter({ classId: "druid", level: 80, xp: 0 }); useCombatStore.setState((state) => ({ health: 1_000, resource: state.maxResource })); const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === "Swiftmend"); if (!ability) throw new Error("Druid Swiftmend is missing from the learned ability catalog."); const talentId = abilityTalentNodeId(ability); if (!talentId) throw new Error("Druid Swiftmend has no active talent node."); useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [talentId]: 1 } })); return ability; } function hot( sourceName: "Rejuvenation" | "Regrowth", sourceActorId: string, tickHealing: number, remainingTicks: number, nextTickAt: number, id: string, fixedAmount?: number, ): TimedCombatEffect { const sourceAbility = useCombatStore.getState().abilities.find((ability) => ability.name === sourceName); if (!sourceAbility) throw new Error(`Druid ${sourceName} is missing from the learned ability catalog.`); return { id, kind: "hot", sourceAbilityId: sourceAbility.id, sourceActorId, targetId: null, coefficient: 0, basePoints: tickHealing, dieSides: 1, pointsPerLevel: 0, sourceLevel: 80, ...(fixedAmount === undefined ? {} : { fixedAmount }), remainingTicks, intervalMs: 3_000, nextTickAt, }; } function castSwiftmend(ability: AbilityDefinition, now = 1_000) { const result = useCombatStore.getState().castAbility(ability.id, { targetActorId: null, now }); expect(result.ok).toBe(true); const event = useCombatStore.getState().combatEvents.filter((candidate) => ( candidate.kind === "healing" && candidate.abilityId === ability.id )).at(-1); if (!event || event.kind !== "healing") throw new Error("Swiftmend did not record its healing event."); return event; } function expectCriticalAdjustedRawHealing(rawAmount: number, critical: boolean, baseAmount: number): void { expect(rawAmount).toBe(baseAmount * (critical ? 1.5 : 1)); } describe("WoW Druid active talent runtime", () => { beforeEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); }); it("lets Nature's Reach extend a cast-time damaging spell to its generated rank range", () => { useCombatStore.getState().initializeCharacter({ classId: "druid", level: 80, xp: 0 }); const wrath = useCombatStore.getState().abilities.find((candidate) => candidate.name === "Wrath"); if (!wrath) throw new Error("Druid Wrath is missing from the learned ability catalog."); useCombatStore.getState().registerMob("natures-reach-target", { name: "Nature's Reach target", maxHealth: 100_000 }); setMobPosition("natures-reach-target", [35, 0, 0]); useCombatStore.getState().selectMob("natures-reach-target"); expect(useCombatStore.getState().castAbility(wrath.id, { targetActorId: "natures-reach-target", now: 1_000, })).toMatchObject({ ok: false, reason: "out-of-range" }); const talentRanks = { ...useCombatStore.getState().talentRanks, "talent-764": 3 }; useCombatStore.setState({ talentRanks, talentModifiers: talentModifiersForRanks(talentRanks, "druid"), }); const extendedCast = useCombatStore.getState().castAbility(wrath.id, { targetActorId: "natures-reach-target", now: 2_000, }); expect(extendedCast.ok).toBe(true); }); it.each([ ["Rejuvenation", 100, 5, 400], ["Regrowth", 150, 7, 900], ] as const)("Swiftmend uses its source 12/18-second amount for %s", (sourceName, tickHealing, remainingTicks, expected) => { const swiftmend = prepareSwiftmend(); const sourceHot = hot(sourceName, PLAYER_AGGRO_ID, tickHealing, remainingTicks, 4_000, "swiftmend-source-hot"); useCombatStore.setState({ effects: [sourceHot] }); const event = castSwiftmend(swiftmend); expectCriticalAdjustedRawHealing(event.rawAmount, event.critical, expected); expect(useCombatStore.getState().effects).toEqual([]); }); it("prefers its own Hot over a shorter foreign Hot", () => { const swiftmend = prepareSwiftmend(); const foreignShort = hot("Rejuvenation", "party-druid-short", 25, 1, 1_500, "swiftmend-foreign-short", 25); const casterHot = hot("Regrowth", PLAYER_AGGRO_ID, 100, 7, 16_000, "swiftmend-caster-long"); useCombatStore.setState({ effects: [foreignShort, casterHot] }); const event = castSwiftmend(swiftmend); expectCriticalAdjustedRawHealing(event.rawAmount, event.critical, 600); expect(useCombatStore.getState().effects.map((effect) => effect.id)).toEqual([foreignShort.id]); }); it("uses the shortest foreign Rejuvenation or Regrowth when no caster Hot exists", () => { const swiftmend = prepareSwiftmend(); const longerRegrowth = hot("Regrowth", "party-druid-long", 50, 7, 4_000, "swiftmend-foreign-long", 50); const shorterRejuvenation = hot("Rejuvenation", "party-druid-short", 100, 5, 4_000, "swiftmend-foreign-short", 100); useCombatStore.setState({ effects: [longerRegrowth, shorterRejuvenation] }); const event = castSwiftmend(swiftmend); expectCriticalAdjustedRawHealing(event.rawAmount, event.critical, 400); expect(useCombatStore.getState().effects.map((effect) => effect.id)).toEqual([longerRegrowth.id]); }); it("preserves the selected HoT when Glyph of Swiftmend is active", () => { const swiftmend = prepareSwiftmend(); const sourceHot = hot("Rejuvenation", PLAYER_AGGRO_ID, 100, 5, 4_000, "swiftmend-glyph-hot"); useCombatStore.setState({ effects: [sourceHot] }); expect(useCombatStore.getState().setGlyphActive(WOW335_GLYPH_OF_SWIFTMEND_ID, true)).toBe(true); const event = castSwiftmend(swiftmend); expectCriticalAdjustedRawHealing(event.rawAmount, event.critical, 400); expect(useCombatStore.getState().glyphIds).toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]); expect(useCombatStore.getState().effects).toEqual([sourceHot]); }); });