import { beforeEach, describe, expect, it } from "vitest"; import { PLAYER_AURA_ENTITY_ID, resolvedAbilityCosts, useCombatStore } from "./combatStore"; import { abilityAtLevel, abilityById } from "./abilityCatalog"; import { applyAura } from "./combatAuras"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; import { EMPTY_ITEM_STATS } from "./itemStats"; import { usePartyStore } from "./partyStore"; import { WOW335_SHAMAN_MAELSTROM_WEAPON_AURA_TAG } from "./wow335ShamanMaelstromWeaponRuntime"; const TARGET_ID = "maelstrom-target"; const OFF_HAND_WEAPON_ID = "maelstrom-test-off-hand"; function runtimeManaCost(abilityId: string, state: ReturnType, now: number): number { const catalogAbility = abilityById(abilityId); expect(catalogAbility).not.toBeNull(); const rankedAbility = abilityAtLevel(catalogAbility!, state.level); return resolvedAbilityCosts(rankedAbility, state.talentModifiers, { ...state, now }) .find((cost) => cost.resource === "mana")?.amount ?? 0; } function shamanAbility(name: string) { const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); expect(ability, `Shaman ${name}`).toBeDefined(); return ability!; } function setMaelstromRank(rank: number): void { useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-2057": rank }, })); } function maelstromAura() { return useCombatStore.getState().auras.find((aura) => ( aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes(WOW335_SHAMAN_MAELSTROM_WEAPON_AURA_TAG) )); } function stableCombatUnit(seed: string): number { let hash = 2_166_136_261; for (let index = 0; index < seed.length; index += 1) { hash ^= seed.charCodeAt(index); hash = Math.imul(hash, 16_777_619); } return (hash >>> 0) / 0x1_0000_0000; } function markTargetAsCharacterDefender(): void { useCombatStore.setState((state) => ({ mobs: { ...state.mobs, [TARGET_ID]: { ...state.mobs[TARGET_ID]!, characterDefender: { level: 80 } }, }, })); } function grantMaelstrom(stacks: number, now: number): void { const definition = { id: "wow335:shaman:maelstrom-weapon", name: "Maelstrom Weapon", description: "Your next listed spell has its cast time reduced by 20% per stack.", disposition: "buff" as const, durationMs: 30_000, maxStacks: 5, initialStacks: 1, stackBehavior: "add" as const, utilityTags: [WOW335_SHAMAN_MAELSTROM_WEAPON_AURA_TAG], }; useCombatStore.setState((state) => ({ auras: [...applyAura(state.auras, { definition, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, stacks, now, }).auras], })); } function firstPlayerLightningBoltDamage(): number { const event = useCombatStore.getState().combatEvents.find((candidate) => ( candidate.kind === "damage" && candidate.sourceActorId === PLAYER_AURA_ENTITY_ID && candidate.targetActorId === TARGET_ID && candidate.abilityId === shamanAbility("Lightning Bolt").id )); expect(event, "player Lightning Bolt damage event").toBeDefined(); return event!.effectiveAmount; } function setupShaman(): void { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); const offHandStats = { ...EMPTY_ITEM_STATS, meleeWeapon: { damageMin: 20, damageMax: 30, damageSchool: "physical" as const, speedMs: 5_000 }, }; useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80, xp: 0, inventory: [{ instanceId: OFF_HAND_WEAPON_ID, itemId: 99001, itemKey: OFF_HAND_WEAPON_ID, name: "Slow off-hand weapon", quality: "uncommon", inventorySlot: "off-hand", armorType: null, weaponType: "mace", itemLevel: 80, sourceItemLevel: 80, stats: offHandStats, quantity: 1, sourceBossKey: "test", sourceBossName: "Test", acquiredAt: 1, }], equipment: { player: { "off-hand": OFF_HAND_WEAPON_ID } }, }); useCombatStore.getState().resetSettings(); useCombatStore.getState().setPlayerPosition([0, 0, 0]); // Keep a distinct one-second main-hand value so the Lava Lash assertion // proves that the proc uses its equipped five-second off-hand weapon. useCombatStore.setState((state) => ({ gearStats: { ...state.gearStats, meleeWeapon: { damageMin: 20, damageMax: 30, damageSchool: "physical", speedMs: 1_000 }, }, })); usePartyStore.getState().initializeParty("wow-shaman-maelstrom-tests", 80, useCombatStore.getState().health); useCombatStore.getState().registerMob(TARGET_ID, { name: "Maelstrom target", level: 80, maxHealth: 1_000_000, xpReward: 0, }); setMobPosition(TARGET_ID, [3, 0, 0]); useCombatStore.getState().selectMob(TARGET_ID); } function cast(abilityId: string, now: number, targetActorId: string | null = TARGET_ID) { const result = useCombatStore.getState().castAbility(abilityId, { targetActorId, now }); expect(result.ok, JSON.stringify(result)).toBe(true); return result; } describe("WoW Shaman Maelstrom Weapon combat runtime", () => { beforeEach(setupShaman); it("procs on landed melee hits using Lava Lash's equipped off-hand speed", () => { setMaelstromRank(3); const lavaLash = shamanAbility("Lava Lash"); const seedUnitAtMainHandRate = (now: number) => stableCombatUnit( `${PLAYER_AURA_ENTITY_ID}:${lavaLash.id}:${TARGET_ID}:${now}:direct:wow335-shaman-maelstrom-weapon`, ); const now = Array.from({ length: 1_000 }, (_, index) => 1_000 + index) .find((candidate) => seedUnitAtMainHandRate(candidate) >= 0.2)!; expect(now).toBeDefined(); cast(lavaLash.id, now); expect(seedUnitAtMainHandRate(now)).toBeGreaterThanOrEqual(0.2); expect(maelstromAura()).toMatchObject({ stacks: 1, expiresAt: now + 30_000 }); expect(useCombatStore.getState().combatEvents.some((event) => ( event.kind === "damage" && event.abilityId === lavaLash.id && event.effectiveAmount > 0 ))).toBe(true); }); it("uses the off-hand speed for Shaman off-hand auto-attack Maelstrom rolls", () => { setMaelstromRank(3); useCombatStore.setState((state) => ({ inventory: state.inventory.map((item) => item.instanceId === OFF_HAND_WEAPON_ID ? { ...item, stats: { ...item.stats, meleeWeapon: { ...item.stats.meleeWeapon!, speedMs: 2_000 } } } : item), gearStats: { ...state.gearStats, hitRating: 10_000, expertiseRating: 10_000 }, autoAttackTargetId: TARGET_ID, nextAutoAttackAt: 1_000, nextOffhandAutoAttackAt: 1_000, })); const mainHandRoll = (now: number) => stableCombatUnit( `${PLAYER_AURA_ENTITY_ID}:auto:${TARGET_ID}:${now}:wow335-shaman-maelstrom-weapon`, ); const offHandRoll = (now: number) => stableCombatUnit( `${PLAYER_AURA_ENTITY_ID}:offhand-auto:${TARGET_ID}:${now}:wow335-shaman-maelstrom-weapon`, ); const now = Array.from({ length: 50_000 }, (_, index) => 1_000 + index) .find((candidate) => mainHandRoll(candidate) >= 0.2 && offHandRoll(candidate) >= 0.2 && offHandRoll(candidate) < 0.4)!; expect(now).toBeDefined(); useCombatStore.getState().tick(0.1, now); const offhandEvents = useCombatStore.getState().combatEvents.filter((event) => ( event.kind === "damage" && event.abilityId === "player-auto-attack-offhand" )); expect(offhandEvents, JSON.stringify(offhandEvents)).toHaveLength(1); expect(mainHandRoll(now)).toBeGreaterThanOrEqual(0.2); expect(offHandRoll(now)).toBeLessThan(0.4); expect(maelstromAura(), JSON.stringify({ now, roll: offHandRoll(now), rank: useCombatStore.getState().talentRanks["talent-2057"], weapon: useCombatStore.getState().inventory.find((item) => item.instanceId === OFF_HAND_WEAPON_ID)?.stats.meleeWeapon, offhandEvents, })).toMatchObject({ stacks: 1 }); }); it("resolves Stormstrike's hands independently and uses each weapon speed for Maelstrom", () => { setMaelstromRank(3); useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-901": 1 }, inventory: state.inventory.map((item) => item.instanceId === OFF_HAND_WEAPON_ID ? { ...item, stats: { ...item.stats, meleeWeapon: { ...item.stats.meleeWeapon!, speedMs: 2_000 } } } : item), gearStats: { ...state.gearStats, hitRating: 10_000, expertiseRating: 10_000 }, })); const stormstrike = shamanAbility("Stormstrike"); const mainHandRoll = (now: number) => stableCombatUnit( `${PLAYER_AURA_ENTITY_ID}:${stormstrike.id}:${TARGET_ID}:${now}:direct:main-hand:wow335-shaman-maelstrom-weapon`, ); const offHandRoll = (now: number) => stableCombatUnit( `${PLAYER_AURA_ENTITY_ID}:${stormstrike.id}:${TARGET_ID}:${now}:direct:off-hand:wow335-shaman-maelstrom-weapon`, ); const now = Array.from({ length: 50_000 }, (_, index) => 1_000 + index) .find((candidate) => mainHandRoll(candidate) >= 0.2 && offHandRoll(candidate) >= 0.2 && offHandRoll(candidate) < 0.4)!; expect(now).toBeDefined(); cast(stormstrike.id, now); const hitEvents = useCombatStore.getState().combatEvents.filter((event) => ( event.kind === "damage" && event.abilityId === stormstrike.id && event.effectiveAmount > 0 )); expect(hitEvents, JSON.stringify(useCombatStore.getState().combatEvents.filter((event) => ( event.kind === "damage" && event.abilityId === stormstrike.id )))).toHaveLength(2); expect(mainHandRoll(now)).toBeGreaterThanOrEqual(0.2); expect(offHandRoll(now)).toBeLessThan(0.4); expect(maelstromAura(), JSON.stringify({ now, roll: offHandRoll(now), rank: useCombatStore.getState().talentRanks["talent-2057"], weapon: useCombatStore.getState().inventory.find((item) => item.instanceId === OFF_HAND_WEAPON_ID)?.stats.meleeWeapon, hitEvents, })).toMatchObject({ stacks: 1 }); }); it("adds one stack per proc, refreshes the 30-second duration, and caps at five", () => { setMaelstromRank(3); const lavaLash = shamanAbility("Lava Lash"); const interval = Math.max(lavaLash.cooldownMs, lavaLash.gcdMs, 1_500) + 10; let now = 1_000; for (let hit = 1; hit <= 6; hit += 1) { cast(lavaLash.id, now); expect(maelstromAura()?.stacks).toBe(Math.min(hit, 5)); expect(maelstromAura()?.expiresAt).toBe(now + 30_000); now += interval; } }); it("reduces Lightning Bolt cast time per stack, consumes the whole bundle at cast start, and still charges Mana", () => { setMaelstromRank(3); const lavaLash = shamanAbility("Lava Lash"); const lightningBolt = shamanAbility("Lightning Bolt"); const interval = Math.max(lavaLash.cooldownMs, lavaLash.gcdMs, 1_500) + 10; let now = 1_000; for (let hit = 0; hit < 5; hit += 1) { cast(lavaLash.id, now); now += interval; } expect(maelstromAura()?.stacks).toBe(5); // Earth Shock is not listed by the generated Maelstrom tooltip and must // leave the active next-spell bundle alone. const earthShock = shamanAbility("Earth Shock"); cast(earthShock.id, now); expect(maelstromAura()?.stacks).toBe(5); now += Math.max(earthShock.cooldownMs, earthShock.gcdMs, 1_500) + 10; const before = useCombatStore.getState(); const manaBefore = before.resource; const expectedManaCost = runtimeManaCost(lightningBolt.id, before, now); cast(lightningBolt.id, now); expect(useCombatStore.getState().activeCast).toBeNull(); expect(maelstromAura()).toBeUndefined(); expect(useCombatStore.getState().resource).toBe(manaBefore - expectedManaCost); }); it("applies a partial cast-time reduction, consumes the bundle on start, and does not make the spell free", () => { const lightningBolt = shamanAbility("Lightning Bolt"); cast(lightningBolt.id, 500); const baselineCast = useCombatStore.getState().activeCast; expect(baselineCast).not.toBeNull(); const baselineDurationMs = baselineCast!.completesAt - baselineCast!.startedAt; setupShaman(); setMaelstromRank(3); const lavaLash = shamanAbility("Lava Lash"); const rankedLightningBolt = shamanAbility("Lightning Bolt"); cast(lavaLash.id, 1_000); expect(maelstromAura()?.stacks).toBe(1); const now = 3_000; const before = useCombatStore.getState(); const manaBefore = before.resource; const expectedManaCost = runtimeManaCost(rankedLightningBolt.id, before, now); cast(rankedLightningBolt.id, now); const activeCast = useCombatStore.getState().activeCast; expect(activeCast!.completesAt! - activeCast!.startedAt!) .toBe(Math.round(baselineDurationMs * 0.8)); expect(maelstromAura()).toBeUndefined(); expect(useCombatStore.getState().resource).toBe(manaBefore); useCombatStore.getState().tick(0.1, activeCast!.completesAt); expect(useCombatStore.getState().resource).toBeLessThan(manaBefore); expect(useCombatStore.getState().resource).toBeCloseTo(manaBefore - expectedManaCost, 0); }); it("does not proc from a spell hit or a melee hit without a trained rank", () => { const lavaLash = shamanAbility("Lava Lash"); const lightningBolt = shamanAbility("Lightning Bolt"); cast(lavaLash.id, 1_000); expect(maelstromAura()).toBeUndefined(); setMaelstromRank(3); cast(lightningBolt.id, 3_000); expect(maelstromAura()).toBeUndefined(); }); it("does not proc from a missed melee attack", () => { setMaelstromRank(3); const lavaLash = shamanAbility("Lava Lash"); useCombatStore.getState().registerMob(TARGET_ID, { name: "High level Maelstrom target", level: 200, maxHealth: 1_000_000, xpReward: 0, }); const now = 1_000; cast(lavaLash.id, now); const damageEvent = useCombatStore.getState().combatEvents.find((event) => ( event.kind === "damage" && event.abilityId === lavaLash.id )); expect(damageEvent?.effectiveAmount).toBe(0); expect(maelstromAura()).toBeUndefined(); }); it("ignores an expired Maelstrom aura when calculating cast time", () => { setMaelstromRank(3); const lavaLash = shamanAbility("Lava Lash"); const lightningBolt = shamanAbility("Lightning Bolt"); cast(lavaLash.id, 1_000); const generatedAura = maelstromAura(); expect(generatedAura).toBeDefined(); useCombatStore.setState((state) => ({ auras: state.auras.map((aura) => aura.instanceId === generatedAura!.instanceId ? { ...aura, expiresAt: 3_000 } : aura), })); cast(lightningBolt.id, 3_000); const activeCast = useCombatStore.getState().activeCast; expect(activeCast!.completesAt! - activeCast!.startedAt!).toBe(2_500); expect(maelstromAura()?.expiresAt).toBe(3_000); }); it("reduces instant Lightning Bolt damage only against a character defender, capped at five stacks", () => { const run = (stacks: number, characterDefender: boolean): number => { setupShaman(); setMaelstromRank(3); if (characterDefender) markTargetAsCharacterDefender(); const lightningBolt = shamanAbility("Lightning Bolt"); if (stacks > 0) grantMaelstrom(stacks, 5_000); cast(lightningBolt.id, 5_000); const activeCast = useCombatStore.getState().activeCast; if (activeCast) { useCombatStore.setState((state) => ({ activeCast: state.activeCast ? { ...state.activeCast, completesAt: 7_500 } : null, })); useCombatStore.getState().tick(0.1, 7_500); } return firstPlayerLightningBoltDamage(); }; const pvpBaseline = run(0, true); const pvpFiveStacks = run(5, true); expect(pvpBaseline).toBeGreaterThan(0); expect(pvpFiveStacks).toBe(Math.round(pvpBaseline * 0.9)); const npcBaseline = run(0, false); const npcFiveStacks = run(5, false); expect(npcFiveStacks).toBe(npcBaseline); }); it("retains consumed Maelstrom stacks through cast completion and channel pulses", () => { const runCast = (stacks: number): { readonly damage: number; readonly snapshot: unknown } => { setupShaman(); setMaelstromRank(3); markTargetAsCharacterDefender(); const lightningBolt = shamanAbility("Lightning Bolt"); if (stacks > 0) grantMaelstrom(stacks, 5_000); cast(lightningBolt.id, 5_000); const activeCast = useCombatStore.getState().activeCast; expect(activeCast).not.toBeNull(); const snapshot = { rank: activeCast!.maelstromWeaponRank, stacks: activeCast!.maelstromWeaponStacksConsumed, }; expect(maelstromAura()).toBeUndefined(); useCombatStore.setState((state) => ({ activeCast: state.activeCast ? { ...state.activeCast, completesAt: 20_000 } : null, ...(stacks > 0 ? { talentRanks: { ...state.talentRanks, "talent-2057": 0 } } : {}), })); useCombatStore.getState().tick(0.1, 20_000); return { damage: firstPlayerLightningBoltDamage(), snapshot }; }; const castBaseline = runCast(0); const castWithStacks = runCast(3); expect(castWithStacks.snapshot).toEqual({ rank: 3, stacks: 3 }); expect(castWithStacks.damage).toBe(Math.round(castBaseline.damage * 0.94)); const runChannel = (stacks: number): { readonly damage: number; readonly snapshot: unknown } => { setupShaman(); setMaelstromRank(3); markTargetAsCharacterDefender(); const lightningBolt = shamanAbility("Lightning Bolt"); if (stacks > 0) grantMaelstrom(stacks, 5_000); cast(lightningBolt.id, 5_000); const activeCast = useCombatStore.getState().activeCast; expect(activeCast).not.toBeNull(); const snapshot = { rank: activeCast!.maelstromWeaponRank, stacks: activeCast!.maelstromWeaponStacksConsumed, }; expect(maelstromAura()).toBeUndefined(); useCombatStore.setState((state) => ({ activeCast: state.activeCast ? { ...state.activeCast, mode: "channel", totalTicks: 2, ticksCompleted: 0, originalDurationMs: 2_000, completesAt: 7_000, nextTickAt: 6_000, } : null, ...(stacks > 0 ? { talentRanks: { ...state.talentRanks, "talent-2057": 0 } } : {}), })); useCombatStore.getState().tick(0.1, 6_000); return { damage: firstPlayerLightningBoltDamage(), snapshot }; }; const channelBaseline = runChannel(0); const channelWithStacks = runChannel(3); expect(channelWithStacks.snapshot).toEqual({ rank: 3, stacks: 3 }); expect(channelWithStacks.damage).toBe(Math.round(channelBaseline.damage * 0.94)); }); });