import { beforeEach, describe, expect, it } from "vitest"; import { abilitiesForClass, abilityAtLevel } from "./abilityCatalog"; import { PLAYER_AGGRO_ID } from "./aggro"; import { applyAura } from "./combatAuras"; import { useCombatStore, equipmentAdjustedCombatAmount } from "./combatStore"; import { EMPTY_ITEM_STATS } from "./itemStats"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; import { usePartyStore } from "./partyStore"; const ability = (name: string, level = 8) => abilityAtLevel(abilitiesForClass("primalist").find(a => a.name === name)!, level); const gains = (id: string) => useCombatStore.getState().combatEvents.filter(e => e.kind === "resource" && e.abilityId === id); function beginBarrage(health = 100_000, absorb = 0): void { const combat = useCombatStore.getState(); combat.registerMob("target", { name: "Target", level: 8, maxHealth: health, xpReward: 0 }); setMobPosition("target", [25, 0, 0]); combat.selectMob("target"); if (absorb > 0) useCombatStore.setState(state => ({ auras: applyAura(state.auras, { definition: { id: "stone-absorb", name: "Stone Absorb", disposition: "buff", durationMs: 10_000, absorbs: [{ id: "pool", amount: absorb }] }, sourceId: "target", targetId: "target", now: 1_000, }).auras })); const rageBefore = useCombatStore.getState().resource; expect(combat.castAbility(ability("Geode Barrage").id, undefined, 1_000).ok).toBe(true); expect(useCombatStore.getState().resource).toBe(rageBefore); combat.tick(0, 3_000); // Finish the two-second cast. combat.tick(0, 3_000); // Resolve its initial stone. } describe("Primalist rage generation", () => { beforeEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); usePartyStore.setState({ members: [], selectedMemberId: null }); useCombatStore.getState().initializeCharacter({ classId: "primalist", role: "healer", level: 8 }); }); it("lands three stones over one second and grants 5 rage per hit without weapon attacks", () => { beginBarrage(); expect(useCombatStore.getState().resource).toBe(5); useCombatStore.getState().tick(0, 3_499); expect(useCombatStore.getState().resource).toBe(5); useCombatStore.getState().tick(0, 3_500); expect(useCombatStore.getState().resource).toBe(10); useCombatStore.getState().tick(0, 4_000); expect(useCombatStore.getState().resource).toBe(15); expect(useCombatStore.getState().resourcePools.rage).toBe(15); expect(useCombatStore.getState().autoAttackTargetId).toBeNull(); expect(gains(ability("Geode Barrage").id).map(e => [e.occurredAt, e.effectiveAmount])) .toEqual([[3_000, 5], [3_500, 5], [4_000, 5]]); expect(useCombatStore.getState().combatEvents.filter(e => e.kind === "damage" && e.abilityId === ability("Geode Barrage").id)).toHaveLength(3); useCombatStore.getState().tick(0, 5_000); expect(gains(ability("Geode Barrage").id)).toHaveLength(3); }); it("uses the ranked damage with nature spell power and attack power once per stone", () => { const spell = ability("Geode Barrage", 80); const stone = spell.effects.find(e => e.kind === "dot")!; const amount = (gear: typeof EMPTY_ITEM_STATS) => equipmentAdjustedCombatAmount(stone, 80, gear, "primalist", spell); const base = amount(EMPTY_ITEM_STATS); expect(amount({ ...EMPTY_ITEM_STATS, spellPower: 100 }) - base).toBe(23); expect(amount({ ...EMPTY_ITEM_STATS, attackPower: 100 }) - base).toBe(9); expect(base).toBeGreaterThan(equipmentAdjustedCombatAmount(ability("Geode Barrage").effects.find(e => e.kind === "dot")!, 8, EMPTY_ITEM_STATS, "primalist", ability("Geode Barrage"))); }); it("stops rage gains when the target dies and caps gains at 100", () => { beginBarrage(1); useCombatStore.getState().tick(0, 5_000); expect(gains(ability("Geode Barrage").id)).toHaveLength(1); expect(useCombatStore.getState().resource).toBe(5); useCombatStore.getState().initializeCharacter({ classId: "primalist", role: "healer", level: 8 }); useCombatStore.setState({ resource: 98, resourcePools: { ...useCombatStore.getState().resourcePools, rage: 98 } }); beginBarrage(); useCombatStore.getState().tick(0, 4_000); expect(useCombatStore.getState().resource).toBe(100); expect(gains(ability("Geode Barrage").id).map(e => e.effectiveAmount)).toEqual([2, 0, 0]); }); it("credits companion Barrage hits to their caster's rage pool", () => { usePartyStore.getState().initializeParty("primalist-source", 8, useCombatStore.getState().maxHealth); const original = usePartyStore.getState().members[0]; usePartyStore.setState({ members: [{ ...original, classId: "primalist", resourceType: "rage", resource: 0, maxResource: 100, resourcePools: { rage: 0 }, gearStats: EMPTY_ITEM_STATS }] }); registerPartyRuntimePosition(original.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); useCombatStore.getState().registerMob("target", { name: "Target", maxHealth: 100_000, xpReward: 0 }); setMobPosition("target", [25, 0, 0]); const spell = ability("Geode Barrage"); const stone = spell.effects.find(e => e.kind === "dot")!; if (stone.kind !== "dot") throw new Error("Missing stones"); expect(useCombatStore.getState().applyTimedActorEffect(original.id, "target", spell, stone, 1_000)).toBe(true); useCombatStore.getState().tick(0, 2_000); expect(useCombatStore.getState().actors[original.id].resources.rage.current).toBe(15); expect(usePartyStore.getState().members[0].resource).toBe(15); expect(useCombatStore.getState().resource).toBe(0); expect(gains(spell.id).every(e => e.sourceActorId === original.id && e.targetActorId === original.id)).toBe(true); }); it("does not grant rage for fully absorbed stones", () => { beginBarrage(100_000, 10_000); useCombatStore.getState().tick(0, 4_000); expect(useCombatStore.getState().resource).toBe(0); expect(gains(ability("Geode Barrage").id)).toHaveLength(0); }); it("normalizes the passive Protective Roar payload to 20 rage when triggered", () => { const spell = ability("Protective Roar"); expect(spell.passive).toBe(true); expect(spell.target).toBe("self"); expect(spell.effects.find(e => e.kind === "resource")).toMatchObject({ resource: "rage", amount: 20 }); useCombatStore.getState().registerMob("target", { name: "Target", maxHealth: 100_000, xpReward: 0 }); setMobPosition("target", [2, 0, 0]); useCombatStore.getState().selectMob("target"); useCombatStore.setState(state => ({ auras: applyAura(state.auras, { definition: { id: "roar-trigger", name: "Roar Trigger", disposition: "buff", durationMs: 10_000, procs: [{ id: "roar", triggers: "cast", chance: 1, charges: 1, action: { kind: "custom", id: "wow335-trigger-spell", data: { spellId: spell.dbcSpellId } } }] }, sourceId: PLAYER_AGGRO_ID, targetId: PLAYER_AGGRO_ID, now: 1_000, }).auras })); expect(useCombatStore.getState().castAbility(ability("Primal Strike").id, undefined, 1_000)).toMatchObject({ ok: true }); expect(useCombatStore.getState().resourcePools.rage).toBe(20); expect(useCombatStore.getState().resource).toBe(20); }); it("restores Seismic Crash's 20 rage once per cast, alongside its initial damage pulse", () => { const spell = ability("Seismic Crash"); useCombatStore.getState().registerMob("target", { name: "Target", maxHealth: 100_000, xpReward: 0 }); setMobPosition("target", [20, 0, 0]); useCombatStore.getState().selectMob("target"); expect(useCombatStore.getState().castAbility(spell.id, undefined, 1_000).ok).toBe(true); useCombatStore.getState().tick(0, 2_000); expect(gains(spell.id).map(e => e.effectiveAmount)).toEqual([20]); expect(useCombatStore.getState().combatEvents.filter(e => e.kind === "damage" && e.abilityId === spell.id)).toHaveLength(3); }); it("grants 4 rage when Seismic Spike lands", () => { useCombatStore.getState().initializeCharacter({ classId: "primalist", role: "healer", level: 20 }); const spell = ability("Seismic Spike", 20); useCombatStore.getState().registerMob("target", { name: "Target", level: 20, maxHealth: 100_000, xpReward: 0 }); setMobPosition("target", [15, 0, 0]); useCombatStore.getState().selectMob("target"); expect(useCombatStore.getState().castAbility(spell.id, undefined, 1_000)).toMatchObject({ ok: true }); expect(useCombatStore.getState().resource).toBe(4); expect(gains(spell.id).map(e => e.effectiveAmount)).toEqual([4]); }); });