import { beforeEach, describe, expect, it } from "vitest"; import { PLAYER_AURA_ENTITY_ID, useCombatStore, resolvedAbilityCosts } from "./combatStore"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; import { usePartyStore } from "./partyStore"; import { abilitiesForClass, preloadAbilityCatalogsForClasses } from "./abilityCatalog"; import { applyTalentModifierValue, talentModifiersForRanks } from "./talentModifiers"; import { deriveCharacterStats } from "./wotlkStats"; function shamanAbility(name: string) { const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); expect(ability, `Shaman ${name}`).toBeDefined(); return ability!; } function setTalentRank(talentId: string, rank: number): void { useCombatStore.setState((state) => { const talentRanks = { ...state.talentRanks, [talentId]: rank }; const passiveModifiers = state.talentModifiers.filter((modifier) => ( modifier.sourceTalentId?.startsWith("class-passive:") )); return { talentRanks, talentModifiers: [...passiveModifiers, ...talentModifiersForRanks(talentRanks, "shaman")], }; }); } function castAndFinish(abilityId: string, targetActorId: string | null | undefined, now: number): void { const result = useCombatStore.getState().castAbility( abilityId, targetActorId !== undefined ? { targetActorId, now } : { now }, ); expect(result.ok, JSON.stringify(result)).toBe(true); const activeCast = useCombatStore.getState().activeCast; if (activeCast) useCombatStore.getState().tick(0.1, activeCast.completesAt); } async function setupShaman(): Promise { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); await preloadAbilityCatalogsForClasses(["shaman", "mage"]); useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0 }); useCombatStore.getState().resetSettings(); useCombatStore.getState().setPlayerPosition([0, 0, 0]); usePartyStore.getState().initializeParty("wow-shaman-conditional-talent-tests", 80, useCombatStore.getState().health); useCombatStore.getState().registerMob("shaman-conditional-target", { name: "Shaman conditional talent target", level: 80, maxHealth: 1_000_000, xpReward: 0, }); setMobPosition("shaman-conditional-target", [8, 0, 0]); useCombatStore.getState().selectMob("shaman-conditional-target"); } function setMagePartyCaster(): string { const member = usePartyStore.getState().members[0]!; usePartyStore.setState((state) => ({ members: state.members.map((candidate) => candidate.id === member.id ? { ...candidate, classId: "mage", talentRanks: { "talent-2049": 3 }, resourceType: "mana", resourceName: "Mana", } : candidate), })); useCombatStore.setState((state) => { const actors = { ...state.actors }; delete actors[member.id]; return { actors }; }); return member.id; } function damageWithShamanSpellCrit(sourceActorId?: string, attackKind: "spell" | "melee" | "ranged" = "spell") { const isParty = Boolean(sourceActorId); const sourceMember = sourceActorId ? usePartyStore.getState().members.find((member) => member.id === sourceActorId) : undefined; const ability = isParty ? abilitiesForClass("mage").find((candidate) => candidate.name === "Fireball")! : shamanAbility("Lightning Bolt"); return useCombatStore.getState().damageMob( "shaman-conditional-target", 100, 10_000 + useCombatStore.getState().combatEvents.length, sourceActorId && sourceMember ? { actorId: sourceActorId, role: sourceMember.role, abilityId: ability.id } : undefined, attackKind === "spell" ? "nature" : "physical", { attackKind, seed: `shaman-elemental-oath-${sourceActorId ?? "player"}-${attackKind}-${useCombatStore.getState().combatEvents.length}`, abilityId: ability.id, rules: { canMiss: false, canDodge: false, canParry: false, canBlock: false }, }, ); } function shamanHealRoll(abilityId: string, targetId: string, impactAt: number): number { let hash = 2166136261; for (const character of `${PLAYER_AURA_ENTITY_ID}:${abilityId}:${targetId}:${impactAt}:heal`) { hash ^= character.charCodeAt(0); hash = Math.imul(hash, 16777619); } return (hash >>> 0) / 0x1_0000_0000; } function findShamanHealImpactAt( abilityId: string, targetId: string, minimumChancePercent: number, maximumChancePercent: number, startAt: number, ): number { for (let impactAt = startAt; impactAt < startAt + 100_000; impactAt += 1) { const roll = shamanHealRoll(abilityId, targetId, impactAt); if (roll >= minimumChancePercent / 100 && roll < maximumChancePercent / 100) return impactAt; } throw new Error("Could not find a seeded Shaman heal crit roll in the requested interval."); } describe("WoW Shaman conditional talent mechanics", () => { beforeEach(setupShaman); it("applies each Convection rank to its complete generated spell list", () => { const eligibleNames = [ "Earth Shock", "Flame Shock", "Frost Shock", "Lightning Bolt", "Chain Lightning", "Lava Burst", "Wind Shear", ]; for (let rank = 1; rank <= 5; rank += 1) { setTalentRank("talent-564", rank); const state = useCombatStore.getState(); for (const name of eligibleNames) { const ability = shamanAbility(name); const baseCost = resolvedAbilityCosts(ability, [], { ...state, auras: [] }) .find((cost) => cost.resource === "mana")?.amount; const reducedCost = resolvedAbilityCosts(ability, state.talentModifiers, { ...state, auras: [] }) .find((cost) => cost.resource === "mana")?.amount; expect(baseCost, `${name} base mana cost`).toBeGreaterThan(0); expect(reducedCost, `${name} at Convection rank ${rank}`) .toBe(Math.round(baseCost! * (1 - rank * 0.03))); } const healingWave = shamanAbility("Healing Wave"); const baseHealingWaveCost = resolvedAbilityCosts(healingWave, [], { ...state, auras: [] }) .find((cost) => cost.resource === "mana")?.amount; const unchangedHealingWaveCost = resolvedAbilityCosts(healingWave, state.talentModifiers, { ...state, auras: [] }) .find((cost) => cost.resource === "mana")?.amount; expect(unchangedHealingWaveCost).toBe(baseHealingWaveCost); } }); it("adds Elemental Oath once to Shaman and party spell crit chance only", () => { const partyCasterId = setMagePartyCaster(); const generatedCritModifiers = (rank: number) => talentModifiersForRanks({ "talent-2049": rank }, "shaman") .filter((modifier) => modifier.kind === "critical-chance-percent"); const playerSpellChance: number[] = []; const partySpellChance: number[] = []; const playerMeleeChance: number[] = []; const playerRangedChance: number[] = []; let playerSpellBase = 0; let partySpellBase = 0; let playerMeleeBase = 0; let playerRangedBase = 0; for (let rank = 0; rank <= 3; rank += 1) { setTalentRank("talent-2049", rank); expect(generatedCritModifiers(rank)).toEqual([]); const playerSpell = damageWithShamanSpellCrit(); const partySpell = damageWithShamanSpellCrit(partyCasterId); const playerMelee = damageWithShamanSpellCrit(undefined, "melee"); const playerRanged = damageWithShamanSpellCrit(undefined, "ranged"); const playerSpellCrit = playerSpell.combatResult!.attackTable.criticalPercent; const partySpellCrit = partySpell.combatResult!.attackTable.criticalPercent; const playerMeleeCrit = playerMelee.combatResult!.attackTable.criticalPercent; const playerRangedCrit = playerRanged.combatResult!.attackTable.criticalPercent; if (rank === 0) { playerSpellBase = playerSpellCrit; partySpellBase = partySpellCrit; playerMeleeBase = playerMeleeCrit; playerRangedBase = playerRangedCrit; } playerSpellChance.push(playerSpellCrit); partySpellChance.push(partySpellCrit); playerMeleeChance.push(playerMeleeCrit); playerRangedChance.push(playerRangedCrit); } expect(playerSpellChance).toEqual([0, 1, 2, 3].map((rank) => playerSpellBase + rank)); expect(partySpellChance).toEqual([0, 1, 2, 3].map((rank) => partySpellBase + rank)); expect(playerMeleeChance).toEqual([playerMeleeBase, playerMeleeBase, playerMeleeBase, playerMeleeBase]); expect(playerRangedChance).toEqual([playerRangedBase, playerRangedBase, playerRangedBase, playerRangedBase]); }); it("applies the Elemental Oath rank once to Shaman healing spell crits", async () => { const probeHealingWave = shamanAbility("Healing Wave"); const probe = useCombatStore.getState().castAbility(probeHealingWave.id, { targetActorId: null, now: 1_000 }); expect(probe.ok, JSON.stringify(probe)).toBe(true); const castDurationMs = useCombatStore.getState().activeCast!.completesAt - 1_000; await setupShaman(); const healingWave = shamanAbility("Healing Wave"); const state = useCombatStore.getState(); useCombatStore.setState({ selectedFriendlyActorId: null }); usePartyStore.setState({ selectedMemberId: null }); const baseSpellCrit = deriveCharacterStats(state.classId, state.level, state.gearStats, state.raceId).spellCritPercent + applyTalentModifierValue(0, state.talentModifiers, "critical-chance-percent", { abilityName: healingWave.name, tags: ["cast-time", "healing", "nature"], }); const targetId = PLAYER_AURA_ENTITY_ID; const firstImpactAt = findShamanHealImpactAt( healingWave.id, targetId, baseSpellCrit, baseSpellCrit + 1, 10_000, ); const secondImpactAt = findShamanHealImpactAt( healingWave.id, targetId, baseSpellCrit + 1, baseSpellCrit + 2, firstImpactAt + 20_000, ); setTalentRank("talent-2049", 1); const castForImpact = (impactAt: number) => { useCombatStore.setState((current) => ({ health: Math.max(1, current.maxHealth - 1_000) })); castAndFinish(healingWave.id, null, impactAt - castDurationMs); const events = useCombatStore.getState().combatEvents; const event = [...events].reverse().find((candidate) => ( candidate.kind === "healing" && candidate.abilityId === healingWave.id )); expect(event, JSON.stringify(events)).toBeDefined(); expect(event?.occurredAt).toBe(impactAt); return event; }; expect(castForImpact(firstImpactAt)?.critical).toBe(true); expect(castForImpact(secondImpactAt)?.critical).toBe(false); }); it("grants two 30% mana-cost reductions after a critical elemental spell", () => { setTalentRank("talent-574", 2); useCombatStore.setState((state) => ({ gearStats: { ...state.gearStats, criticalStrikeRating: 1_000_000 }, })); const lightningBolt = shamanAbility("Lightning Bolt"); castAndFinish(lightningBolt.id, undefined, 1_000); const criticalBolt = useCombatStore.getState().combatEvents.find((event) => ( event.kind === "damage" && event.abilityId === lightningBolt.id && event.critical )); expect(criticalBolt).toBeDefined(); const clearcasting = () => useCombatStore.getState().auras.find((aura) => ( aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("wow335-shaman-elemental-focus-clearcasting") )); expect(clearcasting()?.stacks).toBe(2); expect(clearcasting()?.expiresAt).toBe(criticalBolt!.occurredAt + 15_000); const healingWave = shamanAbility("Healing Wave"); const before = useCombatStore.getState(); const fullCost = resolvedAbilityCosts(healingWave, before.talentModifiers, { ...before, auras: [] }) .find((cost) => cost.resource === "mana")?.amount; expect(fullCost).toBeGreaterThan(0); const discountedCost = Math.round(fullCost! * 0.7); expect(resolvedAbilityCosts(healingWave, before.talentModifiers, before) .find((cost) => cost.resource === "mana")?.amount).toBe(discountedCost); castAndFinish(healingWave.id, null, 10_000); expect(clearcasting()?.stacks).toBe(1); const oneChargeState = useCombatStore.getState(); expect(resolvedAbilityCosts(healingWave, oneChargeState.talentModifiers, oneChargeState) .find((cost) => cost.resource === "mana")?.amount).toBe(discountedCost); castAndFinish(healingWave.id, null, 20_000); expect(clearcasting()).toBeUndefined(); const expiredState = useCombatStore.getState(); expect(resolvedAbilityCosts(healingWave, expiredState.talentModifiers, expiredState) .find((cost) => cost.resource === "mana")?.amount).toBe(fullCost); }); it("uses Reverberation's rank chance and 20% bonus on the next different Shock", async () => { setTalentRank("talent-575", 3); const earthShock = shamanAbility("Earth Shock"); const frostShock = shamanAbility("Frost Shock"); castAndFinish(earthShock.id, undefined, 1_000); const initialReverberation = useCombatStore.getState().auras.find((aura) => ( aura.definition.utilityTags?.includes("wow335-shaman-reverberation") )); expect(initialReverberation?.expiresAt).toBe(9_000); expect(initialReverberation?.definition.utilityTags).toContain(`wow335-shaman-reverberation:from:${earthShock.id}`); castAndFinish(frostShock.id, undefined, 6_000); const frostShockHit = useCombatStore.getState().combatEvents.find((event) => ( event.kind === "damage" && event.abilityId === frostShock.id )); expect(frostShockHit?.effectiveAmount).toBeGreaterThan(0); const refreshedReverberation = useCombatStore.getState().auras.find((aura) => ( aura.definition.utilityTags?.includes("wow335-shaman-reverberation") )); expect(refreshedReverberation?.definition.utilityTags) .toContain(`wow335-shaman-reverberation:from:${frostShock.id}`); const buffedRawDamage = frostShockHit?.rawAmount; await setupShaman(); const baselineBolt = shamanAbility("Lightning Bolt"); const baselineFrostShock = shamanAbility("Frost Shock"); castAndFinish(baselineBolt.id, undefined, 1_000); castAndFinish(baselineFrostShock.id, undefined, 6_000); const baselineHit = useCombatStore.getState().combatEvents.find((event) => ( event.kind === "damage" && event.abilityId === baselineFrostShock.id )); expect(baselineHit?.rawAmount).toBeGreaterThan(0); expect(buffedRawDamage).toBe(Math.round((baselineHit?.rawAmount ?? 0) * 1.2)); }); });