import { beforeEach, describe, expect, it } from "vitest"; import { applyAura } from "./combatAuras"; import { abilitiesForClass } from "./abilityCatalog"; import { useCombatStore } from "./combatStore"; import { damageAfterArmor } from "./itemStats"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, } from "./partyRuntimeRegistry"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { usePartyStore } from "./partyStore"; import { wow335HunterPetProwlAura, wow335HunterPetRakeDamage } from "./wow335HunterPetRuntime"; function callPet(now = 1_000) { const ability = useCombatStore.getState().abilities.find((entry) => entry.name === "Call Pet"); if (!ability) throw new Error("Hunter Call Pet is missing from the active build."); const result = useCombatStore.getState().castAbility(ability.id, [0, 0, 0], now); if (!result.ok) throw new Error(`Call Pet failed: ${result.reason ?? "unknown"}`); const pet = useCombatStore.getState().summons.find((summon) => summon.hunterPet); if (!pet) throw new Error("Call Pet did not create a Hunter pet."); return pet; } describe("selectable Hunter pet families", () => { beforeEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80, xp: 0 }); }); it("keeps Wolf as the legacy default, then calls the explicitly selected Turtle profile", () => { expect(useCombatStore.getState().hunterPetFamily).toBe("wolf"); expect(useCombatStore.getState().setHunterPetFamily("cat")).toBe(true); expect(useCombatStore.getState().setHunterPetFamily("turtle")).toBe(true); const pet = callPet(); expect(pet).toMatchObject({ name: "Turtle", hunterPet: { family: "turtle", shellShield: { spellId: 26_064, focusCost: 0, cooldownMs: 60_000, durationMs: 12_000 }, }, }); expect(pet.hunterPet?.furiousHowl).toBeUndefined(); expect(useCombatStore.getState().setHunterPetFamily("wolf")).toBe(true); expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)).toMatchObject({ name: "Wolf", hunterPet: { family: "wolf", furiousHowl: { spellId: 64_495 } }, }); }); it("autocasts source-backed Shell Shield in combat and prevents a family swap until combat ends", () => { expect(useCombatStore.getState().setHunterPetFamily("turtle")).toBe(true); const pet = callPet(); useCombatStore.getState().registerMob("turtle-shell-target", { name: "Shell Shield target", maxHealth: 100_000, xpReward: 0, }); setMobPosition("turtle-shell-target", [2, 0, 0]); useCombatStore.getState().selectMob("turtle-shell-target"); expect(useCombatStore.getState().commandHunterPet(pet.id, "attack")).toBe(true); useCombatStore.getState().tick(0.1, 1_500); const activePet = useCombatStore.getState().summons.find((summon) => summon.id === pet.id)!; expect(activePet.hunterPetShellShieldReadyAt).toBe(61_500); expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({ targetId: pet.id, definition: expect.objectContaining({ id: "wow335-hunter-pet-shell-shield", durationMs: 12_000, modifiers: [expect.objectContaining({ kind: "damage", direction: "taken", value: -0.5 })], }), })); expect(useCombatStore.getState().mobs["turtle-shell-target"]?.engaged).toBe(true); expect(useCombatStore.getState().setHunterPetFamily("wolf")).toBe(false); expect(useCombatStore.getState().hunterPetFamily).toBe("turtle"); expect(useCombatStore.getState().damageSummon(pet.id, 1_000)).toBe(500); }); it("revives the player's persisted Cat while generated companion pets remain Wolf", () => { useCombatStore.getState().initializeCharacter({ classId: "hunter", specializationId: "hunter:marksmanship", level: 80, xp: 0, hunterPetFamily: "cat", }); const calledPet = callPet(); const petId = "__player__:summon-1"; useCombatStore.setState((state) => ({ summons: state.summons.map((summon) => summon.id === calledPet.id ? { ...summon, id: petId } : summon), })); const pet = { ...calledPet, id: petId }; useCombatStore.setState((state) => ({ summons: state.summons.map((summon) => summon.id === pet.id ? { ...summon, health: 0 } : summon), })); const revive = useCombatStore.getState().abilities.find((ability) => ability.name === "Revive Pet"); if (!revive) throw new Error("Hunter Revive Pet is missing from the active build."); const startAt = 10_000; const result = useCombatStore.getState().castAbility(revive.id, [0, 0, 0], startAt); expect(result.ok, result.reason).toBe(true); const cast = useCombatStore.getState().activeCast; if (cast) useCombatStore.getState().tick((cast.completesAt - startAt) / 1_000 + 0.1, cast.completesAt + 1); expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)).toMatchObject({ name: "Cat", hunterPet: { family: "cat", spellId: 52_472, rake: { spellId: 59_886 }, prowl: { spellId: 24_453 } }, }); usePartyStore.getState().initializeParty("turtle-family-player-only", 80, useCombatStore.getState().health); const companionId = usePartyStore.getState().members[0]!.id; usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === companionId ? { ...member, classId: "hunter", secondaryClassId: null, level: 80, raceId: "human", talentRanks: {} } : member), })); registerPartyRuntimePosition(companionId, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); const companionCall = abilitiesForClass("hunter").find((ability) => ability.name === "Call Pet")!; const companionPetId = useCombatStore.getState().summonActor(companionId, companionCall, { kind: "summon", creatureId: 1, coefficient: 1, durationMs: 60_000, }, [0, 0, 0], null, 20_000); expect(useCombatStore.getState().summons.find((summon) => summon.id === companionPetId)).toMatchObject({ name: "Wolf", hunterPet: { family: "wolf", furiousHowl: { spellId: 64_495 } }, }); }); it("clears player Cat Prowl on family swaps and dismissal without stripping companion auras", () => { expect(useCombatStore.getState().setHunterPetFamily("cat")).toBe(true); const calledPet = callPet(); const playerPetId = "__player__:summon-1"; const companionPetId = "companion-cat-pet"; const prowl = calledPet.hunterPet?.prowl; if (!prowl) throw new Error("Cat Prowl profile is missing."); const playerProwl = applyAura([], { definition: wow335HunterPetProwlAura(prowl), sourceId: playerPetId, targetId: playerPetId, now: 1_000, }).aura; const companionProwl = applyAura([], { definition: wow335HunterPetProwlAura(prowl), sourceId: companionPetId, targetId: companionPetId, now: 1_000, }).aura; useCombatStore.setState((state) => ({ summons: state.summons.map((summon) => summon.id === calledPet.id ? { ...summon, id: playerPetId } : summon).concat({ ...calledPet, id: companionPetId, ownerActorId: "party:companion", }), auras: [...state.auras, playerProwl, companionProwl], })); expect(useCombatStore.getState().setHunterPetFamily("wolf")).toBe(true); expect(useCombatStore.getState().auras.some((aura) => aura.targetId === playerPetId && aura.definition.utilityTags?.includes("wow335-hunter-pet-prowl"))).toBe(false); expect(useCombatStore.getState().auras.some((aura) => aura.targetId === companionPetId && aura.definition.utilityTags?.includes("wow335-hunter-pet-prowl"))).toBe(true); expect(useCombatStore.getState().setHunterPetFamily("cat")).toBe(true); const refreshedPlayerProwl = applyAura([], { definition: wow335HunterPetProwlAura(prowl), sourceId: playerPetId, targetId: playerPetId, now: 2_000, }).aura; useCombatStore.setState((state) => ({ auras: [...state.auras, refreshedPlayerProwl] })); expect(useCombatStore.getState().setHunterPetFamily("turtle")).toBe(true); expect(useCombatStore.getState().auras.some((aura) => aura.targetId === playerPetId && aura.definition.utilityTags?.includes("wow335-hunter-pet-prowl"))).toBe(false); const dismiss = useCombatStore.getState().abilities.find((ability) => ability.name === "Dismiss Pet"); if (!dismiss) throw new Error("Hunter Dismiss Pet is missing from the active build."); const turtlePet = useCombatStore.getState().summons.find((summon) => summon.id === playerPetId); if (!turtlePet?.hunterPet?.shellShield) throw new Error("Turtle Shell Shield profile is missing."); const shellAura = applyAura([], { definition: { id: "test:turtle-shell", name: "Shell Shield test aura", disposition: "buff", durationMs: 12_000, maxStacks: 1, stackBehavior: "refresh", utilityTags: ["wow335-hunter-pet-shell-shield"], }, sourceId: playerPetId, targetId: playerPetId, now: 2_000, }).aura; useCombatStore.setState((state) => ({ auras: [...state.auras, shellAura] })); const dismissResult = useCombatStore.getState().castAbility(dismiss.id, [0, 0, 0], 5_000); expect(dismissResult.ok, dismissResult.reason).toBe(true); useCombatStore.getState().tick(5, 10_000); expect(useCombatStore.getState().summons.some((summon) => summon.id === playerPetId)).toBe(false); expect(useCombatStore.getState().auras.some((aura) => aura.targetId === playerPetId && aura.definition.utilityTags?.some((tag) => ( tag === "wow335-hunter-pet-prowl" || tag === "wow335-hunter-pet-shell-shield" )))).toBe(false); }); it("selects and calls a level-10 Cat from its learned Claw, Rake, and Prowl ranks", () => { useCombatStore.getState().initializeCharacter({ classId: "hunter", specializationId: "hunter:marksmanship", level: 10, xp: 0, hunterPetFamily: "cat", }); expect(useCombatStore.getState().hunterPetFamily).toBe("cat"); const pet = callPet(1_000); expect(pet).toMatchObject({ name: "Cat", hunterPet: { family: "cat", abilityName: "Claw", spellId: 16_828, rake: { spellId: 59_881 }, prowl: { spellId: 24_450 }, }, }); expect(pet.hunterPet?.furiousHowl).toBeUndefined(); expect(pet.hunterPet?.shellShield).toBeUndefined(); }); it("applies Prowl's source movement and incoming break, then buffs the earlier auto swing", () => { expect(useCombatStore.getState().setHunterPetFamily("cat")).toBe(true); const pet = callPet(); useCombatStore.setState((state) => ({ summons: state.summons.map((summon) => summon.id === pet.id ? { ...summon, position: [-10, 0, 0], nextActionAt: 2_000, hunterPetProwlReadyAt: 2_000 } : summon), })); useCombatStore.getState().tick(1, 2_000); expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({ targetId: pet.id, definition: expect.objectContaining({ id: "wow335-hunter-pet-prowl", modifiers: expect.arrayContaining([ expect.objectContaining({ kind: "damage", direction: "dealt", school: "physical", value: 0.5 }), expect.objectContaining({ kind: "damage", direction: "dealt", school: "arcane", value: 0.5 }), ]), }), })); expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)?.position[0]).toBeCloseTo(-7.6); expect(useCombatStore.getState().damageSummon(pet.id, 1)).toBe(1); expect(useCombatStore.getState().auras.some((aura) => ( aura.targetId === pet.id && aura.definition.utilityTags?.includes("wow335-hunter-pet-prowl") ))).toBe(false); registerCatTarget("cat-prowl-opener", [2, 0, 0]); useCombatStore.getState().commandHunterPet(pet.id, "attack"); useCombatStore.setState((state) => ({ mobs: { ...state.mobs, "cat-prowl-opener": { ...state.mobs["cat-prowl-opener"]!, characterShield: 100_000 }, }, summons: state.summons.map((summon) => summon.id === pet.id ? { ...summon, position: [1, 0, 0], nextActionAt: 3_000, hunterPetSpecialReadyAt: 3_000, hunterPetRakeReadyAt: 3_000, hunterPetProwlReadyAt: 3_000, } : summon), })); useCombatStore.getState().tick(1, 3_000); const events = useCombatStore.getState().combatEvents; const openingAutoAttack = events.find((event) => ( event.targetActorId === "cat-prowl-opener" && event.abilityId === pet.sourceAbilityId )); const openingRakeDamage = events.find((event) => ( event.targetActorId === "cat-prowl-opener" && event.abilityId === "wow335-hunter-pet-rake-rank-6" )); expect(openingAutoAttack?.rawAmount).toBe(Math.round(pet.attackDamage * 1.5)); expect(openingRakeDamage?.rawAmount).toBe(Math.round( wow335HunterPetRakeDamage(pet.hunterPet!, pet.hunterPet!.rake!), )); expect(openingAutoAttack?.effectiveAmount).toBe(0); expect(openingAutoAttack?.absorbed).toBeGreaterThan(0); expect(events.indexOf(openingAutoAttack!)).toBeLessThan(events.indexOf(openingRakeDamage!)); expect(useCombatStore.getState().auras.some((aura) => ( aura.targetId === pet.id && aura.definition.utilityTags?.includes("wow335-hunter-pet-prowl") ))).toBe(false); }); it("applies armor once to Prowl's boosted Cat auto swing", () => { expect(useCombatStore.getState().setHunterPetFamily("cat")).toBe(true); const pet = callPet(); registerCatTarget("cat-armored-swing", [2, 0, 0]); const targetArmor = 100_000; const prowl = pet.hunterPet?.prowl; if (!prowl) throw new Error("Cat Prowl profile is missing."); const prowlAura = applyAura([], { definition: wow335HunterPetProwlAura(prowl), sourceId: pet.id, targetId: pet.id, now: 1_000, }).aura; useCombatStore.setState((state) => ({ mobs: { ...state.mobs, "cat-armored-swing": { ...state.mobs["cat-armored-swing"]!, armor: targetArmor }, }, auras: [...state.auras, prowlAura], })); useCombatStore.getState().commandHunterPet(pet.id, "attack"); useCombatStore.setState((state) => ({ summons: state.summons.map((summon) => summon.id === pet.id ? { ...summon, position: [1, 0, 0], nextActionAt: 2_000 } : summon), })); useCombatStore.getState().tick(1, 2_000); const autoAttack = useCombatStore.getState().combatEvents.find((event) => ( event.targetActorId === "cat-armored-swing" && event.abilityId === pet.sourceAbilityId )); expect(autoAttack?.rawAmount).toBe(Math.round(pet.attackDamage * 1.5)); expect(autoAttack?.effectiveAmount).toBe(damageAfterArmor( autoAttack!.rawAmount, targetArmor, pet.hunterPet!.baseStats.level, )); expect(useCombatStore.getState().auras.some((aura) => ( aura.targetId === pet.id && aura.definition.utilityTags?.includes("wow335-hunter-pet-prowl") ))).toBe(false); }); it("keeps Rake ticks pet-owned through pet death, targets only its application target, and expires after three ticks", () => { expect(useCombatStore.getState().setHunterPetFamily("cat")).toBe(true); const pet = callPet(1_000); registerCatTarget("cat-rake-target", [2, 0, 0]); registerCatTarget("cat-rake-other-target", [3, 0, 0]); useCombatStore.getState().selectMob("cat-rake-target"); const targetArmor = 100_000; useCombatStore.setState((state) => ({ mobs: { ...state.mobs, "cat-rake-target": { ...state.mobs["cat-rake-target"]!, armor: targetArmor }, }, auras: [...state.auras, applyAura([], { definition: { id: "test:cat-rake-target-damage-taken", name: "Test physical vulnerability", disposition: "debuff", durationMs: 30_000, maxStacks: 1, stackBehavior: "refresh", modifiers: [{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: 0.2 }], }, sourceId: "test-source", targetId: "cat-rake-target", now: 1_000, }).aura], })); useCombatStore.setState((state) => ({ summons: state.summons.map((summon) => summon.id === pet.id ? { ...summon, position: [1, 0, 0], nextActionAt: 2_000, hunterPetSpecialReadyAt: 2_000, hunterPetRakeReadyAt: 2_000 } : summon), })); useCombatStore.getState().commandHunterPet(pet.id, "attack"); const killCommand = useCombatStore.getState().abilities.find((ability) => ability.name === "Kill Command"); if (!killCommand) throw new Error("Hunter Kill Command is missing from the active build."); expect(useCombatStore.getState().castAbility(killCommand.id, { targetActorId: "cat-rake-target", now: 3_000, }).ok).toBe(true); useCombatStore.setState((state) => ({ summons: state.summons.map((summon) => summon.id === pet.id ? { ...summon, nextActionAt: 4_000, hunterPetSpecialReadyAt: 4_000, hunterPetRakeReadyAt: 4_000, hunterPetProwlReadyAt: 4_000 } : summon), })); useCombatStore.getState().tick(1, 4_000); const rake = useCombatStore.getState().effects.find((effect) => effect.tag === "wow335-hunter-pet-rake"); expect(rake).toMatchObject({ sourceActorId: pet.id, sourceAbilityId: "wow335-hunter-pet-rake-rank-6", targetId: "cat-rake-target", kind: "dot", remainingTicks: 3, intervalMs: 3_000, nextTickAt: 7_000, fixedAmount: Math.round(wow335HunterPetRakeDamage(pet.hunterPet!, pet.hunterPet!.rake!, true) * 1.6), }); const killCommandAura = useCombatStore.getState().auras.find((aura) => ( aura.targetId === pet.id && aura.definition.utilityTags?.includes("wow335-hunter-kill-command") )); expect(killCommandAura?.stacks).toBe(2); useCombatStore.getState().damageSummon(pet.id, pet.health); expect(useCombatStore.getState().summons.some((summon) => summon.id === pet.id)).toBe(false); for (const now of [7_000, 10_000, 13_000]) { useCombatStore.getState().tick(3, now); } const rakeTicks = useCombatStore.getState().combatEvents.filter((event) => ( event.targetActorId === "cat-rake-target" && event.abilityId === "wow335-hunter-pet-rake-rank-6" )); expect(rakeTicks).toHaveLength(4); expect(rakeTicks.slice(1).map((event) => event.occurredAt)).toEqual([7_000, 10_000, 13_000]); expect(rakeTicks[0]).toMatchObject({ rawAmount: expect.any(Number), effectiveAmount: Math.round(damageAfterArmor( rakeTicks[0]!.rawAmount, targetArmor, pet.hunterPet!.baseStats.level, ) * 1.2), }); expect(rakeTicks[0]?.rawAmount).toBe(Math.round( wow335HunterPetRakeDamage(pet.hunterPet!, pet.hunterPet!.rake!) * 1.6, )); expect(rakeTicks.slice(1).every((event) => event.sourceActorId === pet.id)).toBe(true); expect(rakeTicks.slice(1).every((event) => event.rawAmount === rake?.fixedAmount)).toBe(true); expect(rakeTicks.slice(1).every((event) => ( event.effectiveAmount === Math.round(event.rawAmount * 1.2) ))).toBe(true); expect(useCombatStore.getState().auras.find((aura) => aura.instanceId === killCommandAura?.instanceId)?.stacks).toBe(2); expect(useCombatStore.getState().effects.some((effect) => effect.tag === "wow335-hunter-pet-rake")).toBe(false); expect(useCombatStore.getState().mobs["cat-rake-target"]?.health).toBeLessThan(100_000); expect(useCombatStore.getState().mobs["cat-rake-other-target"]?.health).toBe(100_000); expect(useCombatStore.getState().combatEvents.some((event) => ( event.targetActorId === "cat-rake-other-target" && event.abilityId === "wow335-hunter-pet-rake-rank-6" ))).toBe(false); }); }); function registerCatTarget(id: string, position: readonly [number, number, number]): void { useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, xpReward: 0 }); setMobPosition(id, position); useCombatStore.getState().selectMob(id); }