import { beforeEach, describe, expect, it } from "vitest"; import { PLAYER_AGGRO_ID } from "./aggro"; import { abilitiesForClass, preloadAbilityCatalogsForClasses } from "./abilityCatalog"; import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility, } from "./partyRuntimeRegistry"; import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; import { EMPTY_ITEM_STATS } from "./itemStats"; import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; import { WOW335_SHAMAN_NATURES_GUARDIAN_COOLDOWN_TAG, WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG, } from "./wow335ShamanNaturesGuardianRuntime"; const TARGET_ID = "wow335-shaman-natures-guardian-target"; const ENEMY_HIT_AT = 1_550; const PLAYER_NEXT_ACTION_AT = 2_000; function natureGuardianAuras(actorId: string) { return useCombatStore.getState().auras.filter((aura) => aura.sourceId === actorId && aura.targetId === actorId); } function armShamanForHit(targetActorId: string): void { const actorId = targetActorId === PLAYER_AURA_ENTITY_ID ? PLAYER_AGGRO_ID : targetActorId; useCombatStore.getState().registerMob(TARGET_ID, { name: "Nature's Guardian target", level: 80, maxHealth: 1_000_000, xpReward: 0, attacks: [{ ...DEFAULT_ENEMY_ATTACK, id: "nature-guardian-incoming-hit", name: "Nature Guardian hit", delivery: "projectile", school: "physical", range: 30, damageMultiplier: 6, }], }); setMobPosition(TARGET_ID, [2, 0, 0]); useCombatStore.getState().damageMob( TARGET_ID, 1, 100, { actorId, role: "damage", abilityId: "nature-guardian-test-engage" }, "physical", ); } describe("WoW Shaman Nature's Guardian player and companion integration", () => { beforeEach(async () => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); await preloadAbilityCatalogsForClasses(["shaman", "mage"]); useCombatStore.getState().initializeCharacter({ classId: "shaman", role: "damage", level: 80, xp: 0 }); useCombatStore.setState({ auras: [], effects: [], combatEvents: [] }); usePartyStore.getState().initializeParty( "wow-shaman-natures-guardian-player", 80, useCombatStore.getState().health, "damage", 1, ); }); it("arms on the player's direct hit below 30%, then spends one charge on a Nature damage spell", () => { useCombatStore.setState((state) => ({ health: Math.round(state.maxHealth * 0.4), talentRanks: { ...state.talentRanks, "talent-1699": 3 }, })); armShamanForHit(PLAYER_AURA_ENTITY_ID); useCombatStore.getState().advanceMobCombat(ENEMY_HIT_AT); expect(useCombatStore.getState().health).toBeGreaterThan(0); expect(useCombatStore.getState().health).toBeLessThan(useCombatStore.getState().maxHealth * 0.3); expect(natureGuardianAuras(PLAYER_AURA_ENTITY_ID)).toEqual(expect.arrayContaining([ expect.objectContaining({ definition: expect.objectContaining({ utilityTags: expect.arrayContaining([WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG]), }) }), expect.objectContaining({ definition: expect.objectContaining({ utilityTags: expect.arrayContaining([WOW335_SHAMAN_NATURES_GUARDIAN_COOLDOWN_TAG]), }) }), ])); const lightningBolt = abilitiesForClass("shaman").find((ability) => ability.name === "Lightning Bolt"); expect(lightningBolt).toBeDefined(); const cast = useCombatStore.getState().castAbility(lightningBolt!.id, { targetActorId: TARGET_ID, now: PLAYER_NEXT_ACTION_AT }); expect(cast.ok, cast.reason).toBe(true); const activeCast = useCombatStore.getState().activeCast; if (activeCast) useCombatStore.getState().tick(0.1, activeCast.completesAt); const damageEvent = [...useCombatStore.getState().combatEvents].reverse().find((event) => ( event.kind === "damage" && event.sourceActorId === PLAYER_AGGRO_ID && event.abilityId === lightningBolt!.id )); expect(damageEvent?.critical).toBe(true); expect(natureGuardianAuras(PLAYER_AURA_ENTITY_ID).some((aura) => ( aura.definition.utilityTags?.includes(WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG) ))).toBe(false); expect(natureGuardianAuras(PLAYER_AURA_ENTITY_ID).some((aura) => ( aura.definition.utilityTags?.includes(WOW335_SHAMAN_NATURES_GUARDIAN_COOLDOWN_TAG) ))).toBe(true); }); it("spends the player's charge on an accepted Nature healing spell and guarantees its critical heal", () => { useCombatStore.setState((state) => ({ health: Math.round(state.maxHealth * 0.4), talentRanks: { ...state.talentRanks, "talent-1699": 3 }, })); armShamanForHit(PLAYER_AURA_ENTITY_ID); useCombatStore.getState().advanceMobCombat(ENEMY_HIT_AT); expect(natureGuardianAuras(PLAYER_AURA_ENTITY_ID)).toEqual(expect.arrayContaining([ expect.objectContaining({ definition: expect.objectContaining({ utilityTags: expect.arrayContaining([WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG]), }) }), ])); const healingWave = abilitiesForClass("shaman").find((ability) => ability.name === "Healing Wave"); expect(healingWave).toBeDefined(); const rejected = useCombatStore.getState().castAbility(healingWave!.id, { targetActorId: "missing-friendly-target", now: PLAYER_NEXT_ACTION_AT, }); expect(rejected.ok).toBe(false); expect(natureGuardianAuras(PLAYER_AURA_ENTITY_ID).some((aura) => ( aura.definition.utilityTags?.includes(WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG) ))).toBe(true); const cast = useCombatStore.getState().castAbility(healingWave!.id, { now: PLAYER_NEXT_ACTION_AT, }); expect(cast.ok, cast.reason).toBe(true); const activeCast = useCombatStore.getState().activeCast; expect(activeCast, "Healing Wave remains charged while casting").toBeDefined(); expect(natureGuardianAuras(PLAYER_AURA_ENTITY_ID).some((aura) => ( aura.definition.utilityTags?.includes(WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG) ))).toBe(true); expect(useCombatStore.getState().cancelCast("test interruption", PLAYER_NEXT_ACTION_AT + 500)).toBe(true); expect(natureGuardianAuras(PLAYER_AURA_ENTITY_ID).some((aura) => ( aura.definition.utilityTags?.includes(WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG) ))).toBe(true); const resolvedCast = useCombatStore.getState().castAbility(healingWave!.id, { now: PLAYER_NEXT_ACTION_AT + 2_000, }); expect(resolvedCast.ok, resolvedCast.reason).toBe(true); const resolvedActiveCast = useCombatStore.getState().activeCast; expect(resolvedActiveCast, "the retry begins a new Healing Wave cast").toBeDefined(); useCombatStore.getState().tick(0.1, resolvedActiveCast!.completesAt); const healingEvent = [...useCombatStore.getState().combatEvents].reverse().find((event) => ( event.kind === "healing" && event.sourceActorId === PLAYER_AGGRO_ID && event.targetActorId === PLAYER_AURA_ENTITY_ID && event.abilityId === healingWave!.id )); expect(healingEvent?.critical).toBe(true); expect(natureGuardianAuras(PLAYER_AURA_ENTITY_ID).some((aura) => ( aura.definition.utilityTags?.includes(WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG) ))).toBe(false); }); it("uses the companion's own rank for the incoming proc and consumes one charge after its accepted Nature spell", () => { useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80, xp: 0 }); usePartyStore.getState().initializeParty( "wow-shaman-natures-guardian-companion", 80, useCombatStore.getState().health, "damage", 1, ); const lightningBolt = abilitiesForClass("shaman").find((ability) => ability.name === "Lightning Bolt"); expect(lightningBolt).toBeDefined(); const shamanBase = createConfiguredPartyMember({ name: "Nature's Guardian Shaman", classId: "shaman", specialization: "Elemental", specializationId: "shaman:elemental", talentTreeId: "shaman-elemental", talentRanks: { "talent-1699": 3 }, role: "damage", }, 80, 0, "wow-shaman-natures-guardian-companion"); const disabledCooldowns = Object.fromEntries(abilitiesForClass("shaman").map((ability) => [ability.id, 1_000_000])); const member = { ...shamanBase, gearStats: EMPTY_ITEM_STATS, health: Math.round(shamanBase.maxHealth * 0.4), cooldowns: disabledCooldowns, nextActionAt: 1_000_000, activeCast: null, activeCastTargetId: null, status: "ready" as const, }; usePartyStore.setState({ members: [member], command: "attack", activeMobId: TARGET_ID }); useCombatStore.getState().commitCompanionActors([member]); registerPartyRuntimePosition(member.id, [0, 0, 0]); useCombatStore.setState((state) => ({ actors: { ...state.actors, [member.id]: { ...state.actors[member.id]!, health: member.health, alive: true }, }, })); armShamanForHit(member.id); updatePartyRuntimeTargetVisibility(member.id, TARGET_ID, true); useCombatStore.getState().advanceMobCombat(ENEMY_HIT_AT); const actorAfterHit = useCombatStore.getState().actors[member.id]; expect(actorAfterHit?.health).toBeGreaterThan(0); expect(actorAfterHit?.health).toBeLessThan(actorAfterHit!.maxHealth * 0.3); expect(natureGuardianAuras(member.id)).toEqual(expect.arrayContaining([ expect.objectContaining({ definition: expect.objectContaining({ utilityTags: expect.arrayContaining([WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG]), }) }), expect.objectContaining({ definition: expect.objectContaining({ utilityTags: expect.arrayContaining([WOW335_SHAMAN_NATURES_GUARDIAN_COOLDOWN_TAG]), }) }), ])); const readyForBolt = { ...usePartyStore.getState().members[0]!, cooldowns: Object.fromEntries(abilitiesForClass("shaman").map((ability) => [ ability.id, ability.id === lightningBolt!.id ? 0 : 1_000_000, ])), nextActionAt: PLAYER_NEXT_ACTION_AT, activeCast: null, activeCastTargetId: null, }; usePartyStore.setState({ members: [readyForBolt] }); useCombatStore.getState().commitCompanionActors([readyForBolt]); advanceDungeonPartyCombat(PLAYER_NEXT_ACTION_AT); const started = usePartyStore.getState().members[0]?.activeCast; expect(started, "the companion begins Lightning Bolt").toBeDefined(); advanceDungeonPartyCombat(started!.completesAt); const damageEvent = [...useCombatStore.getState().combatEvents].reverse().find((event) => ( event.kind === "damage" && event.sourceActorId === member.id && event.abilityId === lightningBolt!.id )); expect(damageEvent?.critical).toBe(true); expect(natureGuardianAuras(member.id).some((aura) => ( aura.definition.utilityTags?.includes(WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG) ))).toBe(false); expect(natureGuardianAuras(member.id).some((aura) => ( aura.definition.utilityTags?.includes(WOW335_SHAMAN_NATURES_GUARDIAN_COOLDOWN_TAG) ))).toBe(true); }); it("spends the companion's charge on its accepted Nature healing spell", () => { useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80, xp: 0 }); usePartyStore.getState().initializeParty( "wow-shaman-natures-guardian-companion-heal", 80, useCombatStore.getState().health, "damage", 1, ); const healingWave = abilitiesForClass("shaman").find((ability) => ability.name === "Healing Wave"); expect(healingWave).toBeDefined(); const shamanBase = createConfiguredPartyMember({ name: "Nature's Guardian Healing Shaman", classId: "shaman", specialization: "Restoration", specializationId: "shaman:restoration", talentTreeId: "shaman-restoration", talentRanks: { "talent-1699": 3 }, role: "healer", }, 80, 0, "wow-shaman-natures-guardian-companion-heal"); const disabledCooldowns = Object.fromEntries(abilitiesForClass("shaman").map((ability) => [ability.id, 1_000_000])); const member = { ...shamanBase, gearStats: EMPTY_ITEM_STATS, health: Math.round(shamanBase.maxHealth * 0.4), cooldowns: disabledCooldowns, nextActionAt: 1_000_000, activeCast: null, activeCastTargetId: null, status: "ready" as const, }; usePartyStore.setState({ members: [member], command: "attack", activeMobId: TARGET_ID }); useCombatStore.getState().commitCompanionActors([member]); registerPartyRuntimePosition(member.id, [0, 0, 0]); useCombatStore.setState((state) => ({ actors: { ...state.actors, [member.id]: { ...state.actors[member.id]!, health: member.health, alive: true }, }, })); armShamanForHit(member.id); updatePartyRuntimeTargetVisibility(member.id, TARGET_ID, true); useCombatStore.getState().advanceMobCombat(ENEMY_HIT_AT); const actorAfterHit = useCombatStore.getState().actors[member.id]; expect(actorAfterHit?.health).toBeLessThan(actorAfterHit!.maxHealth * 0.3); expect(natureGuardianAuras(member.id).some((aura) => ( aura.definition.utilityTags?.includes(WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG) ))).toBe(true); const readyForHeal = { ...usePartyStore.getState().members[0]!, health: actorAfterHit!.health, cooldowns: Object.fromEntries(abilitiesForClass("shaman").map((ability) => [ ability.id, ability.id === healingWave!.id ? 0 : 1_000_000, ])), nextActionAt: PLAYER_NEXT_ACTION_AT, activeCast: null, activeCastTargetId: null, }; usePartyStore.setState({ members: [readyForHeal] }); useCombatStore.getState().commitCompanionActors([readyForHeal]); advanceDungeonPartyCombat(PLAYER_NEXT_ACTION_AT); const activeCast = usePartyStore.getState().members[0]?.activeCast; expect(activeCast?.abilityId).toBe(healingWave!.id); expect(natureGuardianAuras(member.id).some((aura) => ( aura.definition.utilityTags?.includes(WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG) ))).toBe(true); advanceDungeonPartyCombat(activeCast!.completesAt); const healingEvent = [...useCombatStore.getState().combatEvents].reverse().find((event) => ( event.kind === "healing" && event.sourceActorId === member.id && event.targetActorId === member.id && event.abilityId === healingWave!.id )); expect(healingEvent?.critical).toBe(true); expect(natureGuardianAuras(member.id).some((aura) => ( aura.definition.utilityTags?.includes(WOW335_SHAMAN_NATURES_GUARDIAN_NEXT_NATURE_CRIT_TAG) ))).toBe(false); }); });