import { describe, expect, it } from "vitest"; import { abilityAtLevel, abilitiesForClass, preloadAbilityCatalogsForClasses } from "./abilityCatalog"; import { useCombatStore } from "./combatStore"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility, } from "./partyRuntimeRegistry"; import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; import { createConfiguredPartyMember, usePartyStore, type PartyMember } from "./partyStore"; import { EMPTY_ITEM_STATS } from "./itemStats"; import { partyAbilityAmount, partyAbilityCosts } from "./partyAbilityAi"; import { WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG } from "./wow335ShamanTidalWavesRuntime"; function stableUnit(seed: string): number { let hash = 2_166_136_261; for (const character of seed) { hash ^= character.charCodeAt(0); hash = Math.imul(hash, 16_777_619); } return (hash >>> 0) / 0x1_0000_0000; } function shamanAbility(name: string) { const source = abilitiesForClass("shaman").find((candidate) => candidate.name === name); expect(source, `generated Shaman ${name}`).toBeDefined(); return abilityAtLevel(source!, 80); } function onlyReady(member: PartyMember, abilityName: string): PartyMember { const readyAbility = shamanAbility(abilityName); return { ...member, cooldowns: Object.fromEntries(abilitiesForClass("shaman").map((candidate) => [ candidate.id, candidate.id === readyAbility.id ? 0 : 1_000_000, ])), activeCast: null, activeCastTargetId: null, nextActionAt: 0, }; } function noAbilitiesReady(member: PartyMember): PartyMember { return { ...member, cooldowns: Object.fromEntries(abilitiesForClass("shaman").map((candidate) => [candidate.id, 1_000_000])), activeCast: null, activeCastTargetId: null, nextActionAt: 0, }; } async function setupShamanParty(options: { readonly tidalRank?: number; readonly riptideRank?: number; readonly waterShieldRank?: number; readonly improvedShieldsRank?: number; readonly elementalFocusRank?: number; readonly secondShaman?: boolean; readonly secondShamanTidalRank?: number; readonly spellPower?: number; readonly criticalStrikeRating?: number; } = {}) { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); await preloadAbilityCatalogsForClasses(["shaman", "warrior"]); useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 }); useCombatStore.setState({ auras: [], effects: [], combatEvents: [] }); usePartyStore.getState().initializeParty("shaman-party-runtime", 80, useCombatStore.getState().health, "damage", 3); const talentRanks = { "talent-2063": options.tidalRank ?? 0, "talent-2064": options.riptideRank ?? 0, "talent-583": options.waterShieldRank ?? 0, "talent-607": options.improvedShieldsRank ?? 0, "talent-574": options.elementalFocusRank ?? 0, }; const firstShamanBase = createConfiguredPartyMember({ name: "Party Shaman", classId: "shaman", specialization: "Restoration", specializationId: "shaman:restoration", talentTreeId: "shaman-restoration", talentRanks, role: "healer", }, 80, 0, "shaman-party-runtime:first"); const spellPower = options.spellPower ?? 0; const criticalStrikeRating = options.criticalStrikeRating ?? 0; const firstShaman: PartyMember = { ...onlyReady(firstShamanBase, "Healing Wave"), gearStats: { ...EMPTY_ITEM_STATS, spellPower, criticalStrikeRating }, resource: 10_000, maxResource: 20_000, resourcePools: { ...firstShamanBase.resourcePools, mana: 10_000 }, health: firstShamanBase.maxHealth, }; const tankBase = createConfiguredPartyMember({ name: "Party Tank", classId: "warrior", specialization: "Protection", specializationId: "warrior:protection", talentTreeId: "warrior-protection", role: "tank", }, 80, 1, "shaman-party-runtime:tank"); const tank: PartyMember = { ...tankBase, health: Math.round(tankBase.maxHealth * 0.25), activeCast: null, activeCastTargetId: null, nextActionAt: 0, }; const members: PartyMember[] = [firstShaman, tank]; if (options.secondShaman) { const secondBase = createConfiguredPartyMember({ name: "Second Party Shaman", classId: "shaman", specialization: "Restoration", specializationId: "shaman:restoration", talentTreeId: "shaman-restoration", talentRanks: { "talent-2063": options.secondShamanTidalRank ?? 0, "talent-2064": 1, "talent-583": 0, }, role: "healer", }, 80, 2, "shaman-party-runtime:second"); members.push({ ...onlyReady(secondBase, "Healing Wave"), gearStats: { ...EMPTY_ITEM_STATS }, resource: 10_000, maxResource: 20_000, resourcePools: { ...secondBase.resourcePools, mana: 10_000 }, health: secondBase.maxHealth, }); } usePartyStore.setState({ members }); useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]); return usePartyStore.getState().members; } function findPartyHealTime( memberId: string, abilityId: string, targetId: string, predicate: (roll: number, now: number) => boolean, from = 20_000, ): number { for (let now = from; now < from + 100_000; now += 1) { if (predicate(stableUnit(`${memberId}:${abilityId}:${targetId}:${now}:wow335-shaman-improved-water-shield`), now)) { return now; } } throw new Error("Could not find a seeded Shaman party healing proc timestamp."); } describe("party WoW Shaman healing talents", () => { it("grants and consumes party Elemental Focus Clearcasting after a critical elemental spell", async () => { const members = await setupShamanParty({ elementalFocusRank: 2, criticalStrikeRating: 1_000_000 }); const shaman = members[0]; const bolt = shamanAbility("Lightning Bolt"); const targetId = "shaman-elemental-focus-party-target"; useCombatStore.getState().registerMob(targetId, { name: "Elemental Focus target", level: 80, maxHealth: 100_000, xpReward: 0, }); setMobPosition(targetId, [2, 0, 0]); useCombatStore.getState().damageMob(targetId, 1, 500); updatePartyRuntimeTargetVisibility(shaman.id, targetId, true); const damageShaman: PartyMember = { ...onlyReady(shaman, "Lightning Bolt"), role: "damage", health: shaman.maxHealth, globalCooldownEndsAt: 0, nextActionAt: 0, }; const fullManaCost = partyAbilityCosts(damageShaman, bolt, 1_000) .find((cost) => cost.resource === "mana")?.amount; usePartyStore.setState((state) => ({ command: "attack", activeMobId: targetId, members: state.members.map((member) => member.id === shaman.id ? damageShaman : { ...member, cooldowns: Object.fromEntries(abilitiesForClass("warrior").map((ability) => [ability.id, 1_000_000])) }), })); useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); advanceDungeonPartyCombat(1_000); const firstCast = usePartyStore.getState().members.find((member) => member.id === shaman.id)?.activeCast; expect(firstCast?.abilityId).toBe(bolt.id); advanceDungeonPartyCombat(firstCast!.completesAt); const hit = [...useCombatStore.getState().combatEvents].reverse().find((event) => ( event.kind === "damage" && event.sourceActorId === shaman.id && event.abilityId === bolt.id )); expect(hit).toMatchObject({ critical: true }); const clearcasting = () => useCombatStore.getState().auras.find((aura) => ( aura.sourceId === shaman.id && aura.targetId === shaman.id && aura.definition.utilityTags?.includes("wow335-shaman-elemental-focus-clearcasting") )); expect(clearcasting()).toMatchObject({ stacks: 2, expiresAt: hit!.occurredAt + 15_000 }); expect(fullManaCost).toBeGreaterThan(0); expect(partyAbilityCosts(shaman, bolt, hit!.occurredAt) .find((cost) => cost.resource === "mana")?.amount).toBe(Math.round(fullManaCost! * 0.7)); const currentShaman = usePartyStore.getState().members.find((member) => member.id === shaman.id)!; usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === shaman.id ? { ...onlyReady(currentShaman, "Lightning Bolt"), globalCooldownEndsAt: 0, nextActionAt: 0 } : member), })); useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); advanceDungeonPartyCombat(hit!.occurredAt + 1_000); expect(usePartyStore.getState().members.find((member) => member.id === shaman.id)?.activeCast?.abilityId) .toBe(bolt.id); expect(clearcasting()?.stacks).toBe(1); }); it("procs rank-owned Improved Water Shield from its own critical Healing Wave and restores only its mana", async () => { const members = await setupShamanParty({ tidalRank: 3, riptideRank: 1, waterShieldRank: 3, improvedShieldsRank: 3, spellPower: 1_000, criticalStrikeRating: 100_000, secondShaman: true, }); const shaman = members[0]; const tank = members[1]; const secondShaman = members[2]; const playerManaBefore = useCombatStore.getState().resourcePools.mana; const riptide = shamanAbility("Riptide"); const healingWave = shamanAbility("Healing Wave"); const riptideAt = findPartyHealTime( shaman.id, riptide.id, tank.id, (roll, now) => roll >= 0.3 && stableUnit( `${shaman.id}:${healingWave.id}:${tank.id}:${now + 3_500}:wow335-shaman-improved-water-shield`, ) < 0.6, ); usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === shaman.id ? onlyReady(member, "Riptide") : member.id === secondShaman.id ? noAbilitiesReady(member) : member), })); useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); advanceDungeonPartyCombat(riptideAt); const tidalAura = useCombatStore.getState().auras.find((aura) => ( aura.sourceId === shaman.id && aura.targetId === shaman.id && aura.definition.utilityTags?.includes(WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG) )); expect(tidalAura).toMatchObject({ stacks: 2, expiresAt: riptideAt + 15_000 }); expect(useCombatStore.getState().effects).toContainEqual(expect.objectContaining({ kind: "hot", sourceActorId: shaman.id, sourceAbilityId: riptide.id, targetId: tank.id, })); usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === shaman.id ? onlyReady(member, "Healing Wave") : member.id === secondShaman.id ? noAbilitiesReady(member) : member), })); useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); advanceDungeonPartyCombat(riptideAt + 1_500); const activeCast = usePartyStore.getState().members.find((member) => member.id === shaman.id)?.activeCast; expect(activeCast).toMatchObject({ abilityId: healingWave.id, originalDurationMs: Math.round(healingWave.castTimeMs * 0.8), tidalWavesChargesConsumed: 2, }); expect(useCombatStore.getState().auras.find((aura) => aura.instanceId === tidalAura?.instanceId)?.stacks).toBe(1); advanceDungeonPartyCombat(activeCast!.completesAt); const combat = useCombatStore.getState(); const healingEvent = [...combat.combatEvents].reverse().find((event) => ( event.kind === "healing" && event.sourceActorId === shaman.id && event.abilityId === healingWave.id )); const waterShieldEvent = [...combat.combatEvents].reverse().find((event) => ( event.kind === "resource" && event.sourceActorId === shaman.id && event.abilityId === "wow335-shaman-improved-water-shield" )); const effect = healingWave.effects.find((candidate) => candidate.kind === "heal"); expect(effect).toBeDefined(); const ordinaryHeal = partyAbilityAmount(shaman, healingWave, effect!); expect(healingEvent).toMatchObject({ targetActorId: tank.id, school: "nature", critical: true }); expect(healingEvent?.rawAmount).toBe(Math.round((ordinaryHeal + 120) * 1.5)); expect(waterShieldEvent).toMatchObject({ targetActorId: shaman.id, rawAmount: 460, effectiveAmount: 460, overheal: 0, }); expect(combat.combatEvents).not.toContainEqual(expect.objectContaining({ kind: "resource", sourceActorId: secondShaman.id, abilityId: "wow335-shaman-improved-water-shield", })); expect(combat.actors[shaman.id]?.resources.mana.current).toBe( usePartyStore.getState().members.find((member) => member.id === shaman.id)?.resourcePools.mana, ); expect(combat.resourcePools.mana).toBe(playerManaBefore); expect(useCombatStore.getState().effects).toContainEqual(expect.objectContaining({ kind: "hot", sourceActorId: shaman.id, sourceAbilityId: riptide.id, targetId: tank.id, })); }); it("adds party Tidal Waves charges to Lesser Healing Wave crit chance and consumes one charge", async () => { const members = await setupShamanParty({ tidalRank: 3, riptideRank: 1 }); const shaman = members[0]; const tank = members[1]; const riptide = shamanAbility("Riptide"); const lesserHealingWave = shamanAbility("Lesser Healing Wave"); let riptideAt = 1_000; for (; riptideAt < 100_000; riptideAt += 1) { if (stableUnit(`${shaman.id}:${lesserHealingWave.id}:${tank.id}:${riptideAt + 3_000}:heal`) < 0.2) break; } expect(riptideAt).toBeLessThan(100_000); usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === shaman.id ? onlyReady(member, "Riptide") : member), })); useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); advanceDungeonPartyCombat(riptideAt); expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({ sourceId: shaman.id, targetId: shaman.id, stacks: 2, definition: expect.objectContaining({ utilityTags: expect.arrayContaining([WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG]) }), })); usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === shaman.id ? onlyReady(member, "Lesser Healing Wave") : member), })); useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); advanceDungeonPartyCombat(riptideAt + 1_500); const activeCast = usePartyStore.getState().members.find((member) => member.id === shaman.id)?.activeCast; expect(activeCast).toMatchObject({ abilityId: lesserHealingWave.id, originalDurationMs: lesserHealingWave.castTimeMs, tidalWavesChargesConsumed: 2, }); expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({ sourceId: shaman.id, targetId: shaman.id, stacks: 1, definition: expect.objectContaining({ utilityTags: expect.arrayContaining([WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG]) }), })); advanceDungeonPartyCombat(activeCast!.completesAt); const healingEvent = [...useCombatStore.getState().combatEvents].reverse().find((event) => ( event.kind === "healing" && event.sourceActorId === shaman.id && event.abilityId === lesserHealingWave.id )); expect(healingEvent).toMatchObject({ targetActorId: tank.id, critical: true }); }); it("expires party Tidal Waves after 15 seconds and ignores another Shaman's Riptide HoT", async () => { const members = await setupShamanParty({ tidalRank: 3, riptideRank: 1, secondShaman: true }); const shaman = members[0]; const tank = members[1]; const secondShaman = members[2]; const riptide = shamanAbility("Riptide"); const chainHeal = shamanAbility("Chain Heal"); usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === shaman.id ? onlyReady(member, "Riptide") : member.id === secondShaman.id ? { ...onlyReady(member, "Healing Wave"), cooldowns: Object.fromEntries( abilitiesForClass("shaman").map((ability) => [ability.id, 1_000_000]), ) } : member), })); useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); const riptideAt = 1_000; advanceDungeonPartyCombat(riptideAt); const aura = useCombatStore.getState().auras.find((candidate) => ( candidate.sourceId === shaman.id && candidate.targetId === shaman.id && candidate.definition.utilityTags?.includes(WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG) )); expect(aura).toMatchObject({ stacks: 2, expiresAt: 16_000 }); const initialHot = useCombatStore.getState().effects.find((effect) => ( effect.kind === "hot" && effect.sourceActorId === shaman.id && effect.targetId === tank.id )); expect(initialHot?.sourceAbilityId).toBe(riptide.id); const chainEffect = chainHeal.effects.find((effect) => effect.kind === "heal"); expect(chainEffect).toBeDefined(); const baseAmount = partyAbilityAmount(secondShaman, chainHeal, chainEffect!); usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === secondShaman.id ? onlyReady(member, "Chain Heal") : member.id === shaman.id ? { ...member, cooldowns: Object.fromEntries(abilitiesForClass("shaman").map((ability) => [ability.id, 1_000_000])) } : member), })); useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); advanceDungeonPartyCombat(riptideAt + 1_000); const secondCast = usePartyStore.getState().members.find((member) => member.id === secondShaman.id)?.activeCast; expect(secondCast?.abilityId).toBe(chainHeal.id); advanceDungeonPartyCombat(secondCast!.completesAt); const secondHeal = [...useCombatStore.getState().combatEvents].reverse().find((event) => ( event.kind === "healing" && event.sourceActorId === secondShaman.id && event.abilityId === chainHeal.id && event.targetActorId === tank.id )); expect(secondHeal?.rawAmount).toBe(baseAmount); expect(useCombatStore.getState().auras).not.toContainEqual(expect.objectContaining({ sourceId: secondShaman.id, targetId: secondShaman.id, definition: expect.objectContaining({ utilityTags: expect.arrayContaining([WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG]) }), })); expect(useCombatStore.getState().effects).toContainEqual(expect.objectContaining({ id: initialHot?.id, sourceActorId: shaman.id, targetId: tank.id, })); usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === shaman.id ? onlyReady(member, "Healing Wave") : member.id === secondShaman.id ? { ...member, cooldowns: Object.fromEntries(abilitiesForClass("shaman").map((ability) => [ability.id, 1_000_000])) } : member), })); useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); advanceDungeonPartyCombat(16_000); const expiredCast = usePartyStore.getState().members.find((member) => member.id === shaman.id)?.activeCast; expect(expiredCast?.abilityId).toBe(shamanAbility("Healing Wave").id); expect(expiredCast?.originalDurationMs).toBe(shamanAbility("Healing Wave").castTimeMs); expect(useCombatStore.getState().auras.find((candidate) => candidate.instanceId === aura?.instanceId)?.stacks).toBe(2); }); it("consumes only the Riptide caster's matching HoT for the Chain Heal primary target", async () => { const members = await setupShamanParty({ riptideRank: 1, secondShaman: true, secondShamanTidalRank: 3, }); const shaman = members[0]; const tank = members[1]; const secondShaman = members[2]; const riptide = shamanAbility("Riptide"); const chainHeal = shamanAbility("Chain Heal"); usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === shaman.id ? onlyReady(member, "Riptide") : member.id === secondShaman.id ? { ...onlyReady(member, "Healing Wave"), cooldowns: Object.fromEntries( abilitiesForClass("shaman").map((ability) => [ability.id, 1_000_000]), ) } : member), })); useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); advanceDungeonPartyCombat(1_000); const riptideHot = useCombatStore.getState().effects.find((effect) => ( effect.kind === "hot" && effect.sourceActorId === shaman.id && effect.sourceAbilityId === riptide.id && effect.targetId === tank.id )); expect(riptideHot).toBeDefined(); const baseAmount = partyAbilityAmount(secondShaman, chainHeal, chainHeal.effects.find((effect) => effect.kind === "heal")!); usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === secondShaman.id ? onlyReady(member, "Chain Heal") : member.id === shaman.id ? { ...member, cooldowns: Object.fromEntries(abilitiesForClass("shaman").map((ability) => [ability.id, 1_000_000])) } : member), })); useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); advanceDungeonPartyCombat(2_500); const otherCasterCast = usePartyStore.getState().members.find((member) => member.id === secondShaman.id)?.activeCast; advanceDungeonPartyCombat(otherCasterCast!.completesAt); const otherCasterHeal = [...useCombatStore.getState().combatEvents].reverse().find((event) => ( event.kind === "healing" && event.sourceActorId === secondShaman.id && event.abilityId === chainHeal.id && event.targetActorId === tank.id )); expect(otherCasterHeal?.rawAmount).toBe(baseAmount); expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({ sourceId: secondShaman.id, targetId: secondShaman.id, stacks: 3, definition: expect.objectContaining({ utilityTags: expect.arrayContaining([WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG]), }), })); expect(useCombatStore.getState().effects).toContainEqual(expect.objectContaining({ id: riptideHot?.id })); usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === shaman.id ? onlyReady(member, "Chain Heal") : member.id === secondShaman.id ? { ...member, cooldowns: Object.fromEntries(abilitiesForClass("shaman").map((ability) => [ability.id, 1_000_000])) } : member), })); useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); advanceDungeonPartyCombat(otherCasterCast!.completesAt + 1_000); const ownCasterCast = usePartyStore.getState().members.find((member) => member.id === shaman.id)?.activeCast; advanceDungeonPartyCombat(ownCasterCast!.completesAt); const ownCasterHeal = [...useCombatStore.getState().combatEvents].reverse().find((event) => ( event.kind === "healing" && event.sourceActorId === shaman.id && event.abilityId === chainHeal.id && event.targetActorId === tank.id )); expect(ownCasterHeal?.rawAmount).toBe(Math.round(baseAmount * 1.25)); expect(useCombatStore.getState().effects).not.toContainEqual(expect.objectContaining({ id: riptideHot?.id })); }); });