import { beforeEach, describe, expect, it } from "vitest"; import { PLAYER_AGGRO_ID } from "./aggro"; import { abilitiesForClass, abilityAtLevel, abilityTalentNodeId, preloadAbilityCatalogsForClasses } from "./abilityCatalog"; import { useCombatStore, PLAYER_AURA_ENTITY_ID } from "./combatStore"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; import { EMPTY_ITEM_STATS } from "./itemStats"; import { resetPartyRuntimeClock } from "./partyRuntime"; import { WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG } from "./wow335ShamanTidalWavesRuntime"; function shamanAbility(name: string) { const source = abilitiesForClass("shaman").find((candidate) => candidate.name === name); expect(source, `generated Shaman ${name}`).toBeDefined(); return abilityAtLevel(source!, 80); } function shieldAuraEffect(name: string) { const ability = shamanAbility(name); const effect = ability.effects.find((candidate) => candidate.kind === "apply-aura"); expect(effect, `${name} apply-aura`).toBeDefined(); if (!effect || effect.kind !== "apply-aura") throw new Error(`${name} aura is missing`); return { ability, effect }; } function registerAttacker(id: string, targetActorId: string, engagedAt = 100): void { useCombatStore.getState().registerMob(id, { name: id, level: 80, maxHealth: 100_000, xpReward: 0, attacks: [{ ...DEFAULT_ENEMY_ATTACK, id: `${id}:swing`, range: 30 }], }); setMobPosition(id, [2, 0, 0]); useCombatStore.getState().damageMob(id, 1, engagedAt, { actorId: targetActorId, role: "tank" }, "physical"); } describe("WoW Shaman shield proc integration", () => { beforeEach(async () => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); await preloadAbilityCatalogsForClasses(["shaman", "warrior"]); useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 }); useCombatStore.setState({ auras: [], effects: [], combatEvents: [] }); usePartyStore.getState().initializeParty("shaman-shields-test", 80, useCombatStore.getState().health, "damage", 1); }); it("scales player Lightning Shield damage and Earth Shield charges/healing, then gates Tidal Waves to its 5s ICD", () => { useCombatStore.getState().initializeCharacter({ classId: "shaman", role: "healer", level: 80 }); useCombatStore.setState((state) => ({ health: Math.round(state.maxHealth * 0.5), talentRanks: { ...state.talentRanks, "talent-607": 3, "talent-2059": 3, "talent-2063": 3, }, })); const lightning = shieldAuraEffect("Lightning Shield"); const earth = shieldAuraEffect("Earth Shield"); const earthTalentNodeId = abilityTalentNodeId(earth.ability); useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, ...(earthTalentNodeId ? { [earthTalentNodeId]: 1 } : {}) }, })); const lightningCast = useCombatStore.getState().castAbility(lightning.ability.id, { targetActorId: PLAYER_AURA_ENTITY_ID, now: 1_000, }); expect(lightningCast.ok, lightningCast.reason).toBe(true); const earthCast = useCombatStore.getState().castAbility(earth.ability.id, { targetActorId: null, now: 4_000, }); expect(earthCast.ok, earthCast.reason).toBe(true); const earthAura = useCombatStore.getState().auras.find((aura) => ( aura.sourceId === PLAYER_AGGRO_ID && aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.procs?.some((proc) => proc.id.endsWith(":earth-shield")) )); expect(earthAura, useCombatStore.getState().auras.map((aura) => `${aura.sourceId}:${aura.targetId}:${aura.definition.name}:${aura.definition.procs?.map((proc) => proc.id).join(",")}`).join(" | ")) .toBeDefined(); expect(earthAura?.definition.procs?.find((proc) => proc.id.endsWith(":earth-shield"))).toMatchObject({ charges: 12, internalCooldownMs: 3_000, }); registerAttacker("shaman-player-shield-attacker", PLAYER_AGGRO_ID, 4_000); useCombatStore.getState().advanceMobCombat(5_450); const lightningProc = [...useCombatStore.getState().combatEvents].reverse().find((event) => ( event.kind === "damage" && event.sourceActorId === PLAYER_AGGRO_ID && event.abilityId?.endsWith(":lightning-shield") )); const lightningBase = lightning.effect.aura.procs?.find((proc) => proc.id.endsWith(":lightning-shield"))?.action; expect(lightningBase?.kind).toBe("damage"); if (lightningBase?.kind !== "damage") throw new Error("Lightning Shield damage proc is missing"); expect(lightningProc?.rawAmount).toBe(Math.round(lightningBase.amount * 1.6)); expect(useCombatStore.getState().auras.find((aura) => aura.definition.exclusiveGroup === "shaman:elemental-shield") ?.procStates.find((proc) => proc.procId.endsWith(":lightning-shield"))?.remainingCharges).toBe(2); useCombatStore.getState().advanceMobCombat(5_450); const tidalAuraAtFirstHeal = useCombatStore.getState().auras.find((aura) => ( aura.sourceId === PLAYER_AGGRO_ID && aura.targetId === PLAYER_AGGRO_ID && aura.definition.utilityTags?.includes(WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG) )); const earthProcEvents = () => useCombatStore.getState().combatEvents.filter((event) => ( event.kind === "healing" && event.sourceActorId === PLAYER_AGGRO_ID && event.abilityId?.endsWith(":earth-shield") )); const earthBase = earthAura?.definition.procs?.find((proc) => proc.id.endsWith(":earth-shield"))?.action; expect(earthBase?.kind).toBe("heal"); if (earthBase?.kind !== "heal") throw new Error("Earth Shield heal proc is missing"); expect(earthProcEvents()[0]?.rawAmount).toBe(Math.round(earthBase.amount * 1.3)); expect(tidalAuraAtFirstHeal?.stacks).toBe(1); useCombatStore.getState().advanceMobCombat(7_350); useCombatStore.getState().advanceMobCombat(9_250); expect(useCombatStore.getState().auras.find((aura) => ( aura.sourceId === PLAYER_AGGRO_ID && aura.targetId === PLAYER_AGGRO_ID && aura.definition.utilityTags?.includes(WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG) ))?.stacks).toBe(1); let now = 9_250; while (earthProcEvents().length < 3 && now < 30_000) { now += 1_900; useCombatStore.getState().advanceMobCombat(now); } expect(earthProcEvents()).toHaveLength(3); expect(earthProcEvents()[2].occurredAt - earthProcEvents()[0].occurredAt).toBeGreaterThanOrEqual(5_000); expect(useCombatStore.getState().auras.find((aura) => ( aura.sourceId === PLAYER_AGGRO_ID && aura.targetId === PLAYER_AGGRO_ID && aura.definition.utilityTags?.includes(WOW335_SHAMAN_TIDAL_WAVES_AURA_TAG) ))?.stacks).toBe(2); expect(useCombatStore.getState().auras.find((aura) => aura.instanceId === earthAura?.instanceId) ?.procStates.find((proc) => proc.procId.endsWith(":earth-shield"))?.remainingCharges).toBe(9); }); it("applies Improved Earth Shield charge and healing bonuses to a generated companion's shield proc", () => { const shamanBase = createConfiguredPartyMember({ name: "Shield Shaman", classId: "shaman", specialization: "Restoration", specializationId: "shaman:restoration", talentTreeId: "shaman-restoration", talentRanks: { "talent-607": 3, "talent-2059": 3, "talent-2063": 3 }, role: "healer", }, 80, 0, "shaman-shields-test"); const tankBase = createConfiguredPartyMember({ name: "Shield Tank", classId: "warrior", specialization: "Protection", specializationId: "warrior:protection", talentTreeId: "warrior-protection", role: "tank", }, 80, 1, "shaman-shields-test"); const shaman = { ...shamanBase, gearStats: EMPTY_ITEM_STATS, health: shamanBase.maxHealth }; const tank = { ...tankBase, gearStats: EMPTY_ITEM_STATS, health: Math.round(tankBase.maxHealth * 0.35) }; usePartyStore.setState({ members: [shaman, tank] }); registerPartyRuntimePosition(shaman.id, [0, 0, 0]); registerPartyRuntimePosition(tank.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); const earth = shieldAuraEffect("Earth Shield"); for (const effect of earth.ability.effects.filter((candidate) => candidate.kind === "apply-aura")) { if (effect.kind === "apply-aura") { expect(useCombatStore.getState().applyActorAura(shaman.id, tank.id, earth.ability, effect, 500)).toBe(true); } } const aura = useCombatStore.getState().auras.find((candidate) => ( candidate.sourceId === shaman.id && candidate.targetId === tank.id && candidate.definition.procs?.some((proc) => proc.id.endsWith(":earth-shield")) )); expect(aura?.definition.procs?.find((proc) => proc.id.endsWith(":earth-shield"))).toMatchObject({ charges: 12, internalCooldownMs: 3_000, }); registerAttacker("shaman-party-shield-attacker", tank.id); useCombatStore.getState().advanceMobCombat(1_450); const procEvent = [...useCombatStore.getState().combatEvents].reverse().find((event) => ( event.kind === "healing" && event.sourceActorId === shaman.id && event.targetActorId === tank.id && event.abilityId?.endsWith(":earth-shield") )); const baseAction = aura?.definition.procs?.find((proc) => proc.id.endsWith(":earth-shield"))?.action; expect(baseAction?.kind).toBe("heal"); if (baseAction?.kind !== "heal") throw new Error("Earth Shield heal proc is missing"); expect(procEvent?.rawAmount).toBe(Math.round(baseAction.amount * 1.3)); expect(procEvent?.effectiveAmount).toBeGreaterThan(0); expect(useCombatStore.getState().auras.find((candidate) => candidate.instanceId === aura?.instanceId) ?.procStates.find((proc) => proc.procId.endsWith(":earth-shield"))?.remainingCharges).toBe(11); 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]) }), })); }); });