import { beforeEach, describe, expect, it } from "vitest"; import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; import { preloadAbilityCatalogsForClasses } from "./abilityCatalog"; import { usePartyStore } from "./partyStore"; import { talentModifiersForRanks } from "./talentModifiers"; import { deriveCharacterStats } from "./wotlkStats"; import { WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG } from "./wow335ShamanTidalWavesRuntime"; function shamanAbility(name: string) { const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); expect(ability, `Shaman ${name}`).toBeDefined(); return ability!; } function setTidalWavesRank(rank: number): void { useCombatStore.setState((state) => { const talentRanks = { ...state.talentRanks, "talent-2063": rank }; const passiveModifiers = state.talentModifiers.filter((modifier) => ( modifier.sourceTalentId?.startsWith("class-passive:") )); return { talentRanks, talentModifiers: [...passiveModifiers, ...talentModifiersForRanks(talentRanks, "shaman")], }; }); } function setRiptideRank(rank = 1): void { useCombatStore.setState((state) => { const talentRanks = { ...state.talentRanks, "talent-2064": rank }; const passiveModifiers = state.talentModifiers.filter((modifier) => ( modifier.sourceTalentId?.startsWith("class-passive:") )); return { talentRanks, talentModifiers: [...passiveModifiers, ...talentModifiersForRanks(talentRanks, "shaman")], }; }); } async function setupShaman(): Promise { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); await preloadAbilityCatalogsForClasses(["shaman"]); useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0 }); useCombatStore.getState().resetSettings(); useCombatStore.getState().setPlayerPosition([0, 0, 0]); usePartyStore.getState().initializeParty("wow-shaman-tidal-waves", 80, useCombatStore.getState().health); usePartyStore.getState().members.forEach((member, index) => { registerPartyRuntimePosition(member.id, [index + 1, 0, 0]); }); useCombatStore.setState((state) => { const talentRanks = { "talent-2064": 1 }; const passiveModifiers = state.talentModifiers.filter((modifier) => ( modifier.sourceTalentId?.startsWith("class-passive:") )); return { health: Math.max(1, state.maxHealth - 5_000), talentRanks, talentModifiers: [...passiveModifiers, ...talentModifiersForRanks(talentRanks, "shaman")], auras: [], effects: [], activeCast: null, gearStats: { ...state.gearStats, criticalStrikeRating: 0, hasteRating: 0, spellPower: 0 }, resource: 8_000, maxResource: 10_000, resourcePools: { ...state.resourcePools, mana: 8_000 }, maxResourcePools: { ...state.maxResourcePools, mana: 10_000 }, }; }); } function cast(name: string, now: number, targetActorId: string | null = null) { const ability = shamanAbility(name); const result = useCombatStore.getState().castAbility(ability.id, { targetActorId, now }); expect(result, JSON.stringify(result)).toMatchObject({ ok: true }); return { ability, result, activeCast: useCombatStore.getState().activeCast }; } function finishCast(now: number): void { const activeCast = useCombatStore.getState().activeCast; if (activeCast) useCombatStore.getState().tick(0.1, now); } function tidalWavesAura() { return useCombatStore.getState().auras.find((aura) => ( aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes(WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG) )); } function healRoll(abilityId: string, targetId: string, impactAt: number): number { let hash = 2_166_136_261; for (const character of `${PLAYER_AURA_ENTITY_ID}:${abilityId}:${targetId}:${impactAt}:heal`) { hash ^= character.charCodeAt(0); hash = Math.imul(hash, 16_777_619); } return (hash >>> 0) / 0x1_0000_0000; } function findHealImpactAt( abilityId: string, minimumChancePercent: number, maximumChancePercent: number, startAt: number, endAt: number, targetId = PLAYER_AURA_ENTITY_ID, ): number { for (let impactAt = startAt; impactAt <= endAt; impactAt += 1) { const roll = healRoll(abilityId, targetId, impactAt); if (roll >= minimumChancePercent / 100 && roll < maximumChancePercent / 100) return impactAt; } throw new Error("Could not find a seeded Shaman healing roll in the requested interval."); } function lastHealingEvent(abilityId: string) { return [...useCombatStore.getState().combatEvents].reverse().find((event) => ( event.kind === "healing" && event.abilityId === abilityId )); } describe("WoW Shaman Tidal Waves", () => { beforeEach(setupShaman); it.each([ { rank: 1, now: 1_500, expectedCharges: 2 }, { rank: 1, now: 1_000, expectedCharges: 0 }, { rank: 2, now: 1_500, expectedCharges: 2 }, { rank: 2, now: 1_000, expectedCharges: 0 }, { rank: 3, now: 1_000, expectedCharges: 2 }, ])("uses rank $rank's actual Riptide proc chance at $now", ({ rank, now, expectedCharges }) => { setRiptideRank(); setTidalWavesRank(rank); expect(talentModifiersForRanks({ "talent-2063": rank }, "shaman")).toEqual([]); const { ability } = cast("Riptide", now); expect(lastHealingEvent(ability.id)).toBeDefined(); finishCast(now); expect(tidalWavesAura()?.stacks ?? 0).toBe(expectedCharges); if (expectedCharges > 0) expect(tidalWavesAura()?.expiresAt).toBe(now + 15_000); }); it("grants three Chain Heal charges and consumes one per Healing Wave cast", () => { setTidalWavesRank(3); const { ability: chainHeal, activeCast: chainCast } = cast("Chain Heal", 1_000); expect(chainCast?.completesAt).toBe(3_500); finishCast(3_500); expect(lastHealingEvent(chainHeal.id)).toBeDefined(); expect(tidalWavesAura()?.stacks).toBe(3); expect(tidalWavesAura()?.expiresAt).toBe(18_500); const expectedDurations = [2_100, 2_400, 2_700]; const startsAt = [4_000, 6_200, 8_700]; for (const [index, startAt] of startsAt.entries()) { const { activeCast } = cast("Healing Wave", startAt); expect(activeCast?.originalDurationMs).toBe(expectedDurations[index]); expect(tidalWavesAura()?.stacks ?? 0).toBe(2 - index); finishCast(activeCast!.completesAt); } expect(tidalWavesAura()).toBeUndefined(); }); it("uses Riptide charges for Lesser Healing Wave crit chance and spends one charge", () => { setRiptideRank(); setTidalWavesRank(3); cast("Riptide", 1_000); expect(tidalWavesAura()?.stacks).toBe(2); const lesserHealingWave = shamanAbility("Lesser Healing Wave"); const state = useCombatStore.getState(); const baseCritPercent = deriveCharacterStats( state.classId, state.level, state.gearStats, state.raceId, {}, state.secondaryClassId, ).spellCritPercent; const impactAt = findHealImpactAt( lesserHealingWave.id, baseCritPercent, baseCritPercent + 20, 8_000, 15_000, ); const { activeCast } = cast("Lesser Healing Wave", impactAt - 1_500); expect(activeCast?.completesAt).toBe(impactAt); expect(activeCast?.tidalWavesChargesConsumed).toBe(2); expect(tidalWavesAura()?.stacks).toBe(1); finishCast(impactAt); expect(lastHealingEvent(lesserHealingWave.id)?.critical).toBe(true); }); it("consumes only the primary target's Riptide HoT for its Chain Heal bonus", async () => { expect(talentModifiersForRanks({ "talent-2064": 1 }, "shaman")).toEqual([]); const chainHeal = shamanAbility("Chain Heal"); const primaryTarget = usePartyStore.getState().members[0]; expect(primaryTarget).toBeDefined(); const baselineState = useCombatStore.getState(); const baselineCrit = deriveCharacterStats( baselineState.classId, baselineState.level, baselineState.gearStats, baselineState.raceId, {}, baselineState.secondaryClassId, ).spellCritPercent; const impactAt = findHealImpactAt( chainHeal.id, baselineCrit, 100, 5_000, 10_000, primaryTarget.id, ); const { activeCast: baselineCast } = cast("Chain Heal", impactAt - 2_500, primaryTarget.id); expect(baselineCast?.completesAt).toBe(impactAt); finishCast(impactAt); const baselineEvent = [...useCombatStore.getState().combatEvents].reverse().find((event) => ( event.kind === "healing" && event.abilityId === chainHeal.id && event.targetActorId === primaryTarget.id )); expect(baselineEvent?.critical).toBe(false); expect(baselineEvent?.rawAmount).toBeGreaterThan(0); await setupShaman(); const sameTarget = usePartyStore.getState().members[0]; const sameTargetRiptide = cast("Riptide", 1_000, sameTarget.id); const riptideHot = useCombatStore.getState().effects.find((effect) => ( effect.kind === "hot" && effect.sourceAbilityId === sameTargetRiptide.ability.id && effect.targetId === sameTarget.id )); expect(riptideHot).toMatchObject({ remainingTicks: 5, intervalMs: 3_000 }); const { activeCast: empoweredCast } = cast("Chain Heal", impactAt - 2_500, sameTarget.id); expect(empoweredCast?.completesAt).toBe(impactAt); finishCast(impactAt); const empoweredEvent = [...useCombatStore.getState().combatEvents].reverse().find((event) => ( event.kind === "healing" && event.abilityId === chainHeal.id && event.targetActorId === sameTarget.id )); expect(empoweredEvent?.critical).toBe(false); expect(empoweredEvent?.rawAmount).toBe(Math.round((baselineEvent?.rawAmount ?? 0) * 1.25)); expect(useCombatStore.getState().effects).not.toContainEqual(expect.objectContaining({ kind: "hot", sourceAbilityId: sameTargetRiptide.ability.id, targetId: sameTarget.id, })); await setupShaman(); const riptideTarget = usePartyStore.getState().members[0]; const differentChainHealTarget = usePartyStore.getState().members[1]; const mismatchedRiptide = cast("Riptide", 1_000, riptideTarget.id); const { activeCast: mismatchedCast } = cast( "Chain Heal", impactAt - 2_500, differentChainHealTarget.id, ); expect(mismatchedCast?.completesAt).toBe(impactAt); finishCast(impactAt); const mismatchedEvent = [...useCombatStore.getState().combatEvents].reverse().find((event) => ( event.kind === "healing" && event.abilityId === chainHeal.id && event.targetActorId === differentChainHealTarget.id )); expect(mismatchedEvent?.rawAmount).toBe(baselineEvent?.rawAmount); expect(useCombatStore.getState().effects).toContainEqual(expect.objectContaining({ kind: "hot", sourceAbilityId: mismatchedRiptide.ability.id, targetId: riptideTarget.id, })); }); it("adds the rank's bonus-healing coefficient without passively granting cast or crit effects", async () => { const spellPower = 1_000; useCombatStore.setState((state) => ({ gearStats: { ...state.gearStats, spellPower }, })); const lesserHealingWave = shamanAbility("Lesser Healing Wave"); const baselineState = useCombatStore.getState(); const baselineCrit = deriveCharacterStats( baselineState.classId, baselineState.level, baselineState.gearStats, baselineState.raceId, {}, baselineState.secondaryClassId, ).spellCritPercent; const impactAt = findHealImpactAt( lesserHealingWave.id, baselineCrit, 100, 10_000, 15_000, ); const castStart = impactAt - 1_500; const { activeCast: baselineCast } = cast("Lesser Healing Wave", castStart); expect(baselineCast?.originalDurationMs).toBe(1_500); finishCast(impactAt); const baselineEvent = lastHealingEvent(lesserHealingWave.id); expect(baselineEvent?.critical).toBe(false); const baselineRawAmount = baselineEvent?.rawAmount ?? 0; await setupShaman(); useCombatStore.setState((state) => ({ gearStats: { ...state.gearStats, spellPower }, })); setTidalWavesRank(3); const rankThreeHealingWave = shamanAbility("Healing Wave"); const { activeCast: unchargedHealingWave } = cast("Healing Wave", 10_000); expect(unchargedHealingWave?.originalDurationMs).toBe(3_000); finishCast(13_000); const rankThreeHeal = lastHealingEvent(rankThreeHealingWave.id); expect(rankThreeHeal?.rawAmount).toBeGreaterThan(0); await setupShaman(); useCombatStore.setState((state) => ({ gearStats: { ...state.gearStats, spellPower }, })); setTidalWavesRank(3); const rankedLesserHealingWave = shamanAbility("Lesser Healing Wave"); const { activeCast: rankThreeCast } = cast("Lesser Healing Wave", castStart); expect(rankThreeCast?.originalDurationMs).toBe(1_500); finishCast(impactAt); const rankThreeEvent = lastHealingEvent(rankedLesserHealingWave.id); expect(rankThreeEvent?.critical).toBe(false); expect(rankThreeEvent?.rawAmount).toBe(baselineRawAmount + 120); }); });