import { beforeEach, describe, expect, it } from "vitest"; import { abilityTalentNodeId, preloadAbilityCatalogsForClasses } from "./abilityCatalog"; import { PLAYER_AGGRO_ID } from "./aggro"; import { applyAura } from "./combatAuras"; import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; import { cooldownResetCandidates } from "./cooldownReset"; import { runtimeTalentNodeById } from "./talentRuntimeCatalog"; import { talentModifiersForRanks } from "./talentModifiers"; import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog"; import { lockAndLoadRankMechanics, WOW335_HUNTER_LOCK_AND_LOAD_ICD_TAG, WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG, } from "./wow335HunterRuntime"; import { usePartyStore } from "./partyStore"; import { wow335HunterPetBaseStatsForLevel, wow335HunterPetBiteDamage, } from "./wow335HunterPetRuntime"; describe("WoW 3.3.5 Hunter Readiness", () => { it("preserves Readiness's Bestial Wrath cooldown exception", () => { const hunterAbilities = WOW335_ABILITIES_BY_CLASS.hunter; const readiness = hunterAbilities.find((ability) => ability.name === "Readiness"); expect(readiness).toBeDefined(); const reset = readiness!.effects.find((effect) => effect.kind === "cooldown-reset"); expect(reset).toMatchObject({ kind: "cooldown-reset", mode: "reset", excludeAbilityNames: ["Bestial Wrath"], }); if (!reset || reset.kind !== "cooldown-reset") return; const candidates = cooldownResetCandidates( hunterAbilities, readiness!.id, reset.abilityName, reset.excludeAbilityNames, ); expect(candidates.some((ability) => ability.name === "Bestial Wrath")).toBe(false); expect(candidates.some((ability) => ability.name === "Readiness")).toBe(false); expect(candidates.some((ability) => ability.name === "Rapid Fire")).toBe(true); }); it("does not turn The Beast Within's conditional tooltip into a permanent modifier", () => { expect(talentModifiersForRanks({ "talent-1803": 1 }, "hunter")).toEqual([]); expect(talentModifiersForRanks({ "talent-1803": 2 }, "hunter")).toEqual([]); }); }); function hunterAbilityId(name: string): string { const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); expect(ability, name).toBeDefined(); return ability!.id; } function unlockHunterTalent(name: string): string { const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name); expect(ability, name).toBeDefined(); const nodeId = abilityTalentNodeId(ability!); expect(nodeId, name).toBeTruthy(); useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [nodeId!]: 1 }, })); return ability!.id; } function hunterProcRoll(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 hunterProcTime(abilityId: string, petId: string, shouldProc: boolean, after: number): number { for (let now = after; now < after + 60_000; now += 1) { const rolls = hunterProcRoll(`${PLAYER_AGGRO_ID}:${abilityId}:${petId}:${now}:the-beast-within`); if ((rolls < 0.5) === shouldProc) return now; } throw new Error("Could not find a deterministic rank-one proc test time."); } function registerHunterMob(id: string, position: readonly [number, number, number], maxHealth = 100_000): void { useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 }); setMobPosition(id, position); useCombatStore.getState().selectMob(id); } describe("WoW 3.3.5 Hunter pet mechanics", () => { beforeEach(async () => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); await preloadAbilityCatalogsForClasses(["hunter"]); useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80 }); useCombatStore.getState().resetSettings(); useCombatStore.getState().setPlayerPosition([0, 0, 0]); usePartyStore.getState().initializeParty( "wow335-hunter-mechanics-tests", 1, useCombatStore.getState().health, ); }); it("reads Lock and Load chance, charges, and cooldown from each generated rank description", () => { expect([1, 2, 3].map(lockAndLoadRankMechanics)).toEqual([ expect.objectContaining({ trapProcChance: 0.33, periodicProcChance: 0.1, freeCastCount: 2, durationMs: 15_000, internalCooldownMs: 15_000 }), expect.objectContaining({ trapProcChance: 0.66, periodicProcChance: 0.2, freeCastCount: 2, durationMs: 15_000, internalCooldownMs: 15_000 }), expect.objectContaining({ trapProcChance: 1, periodicProcChance: 0.3, freeCastCount: 2, durationMs: 15_000, internalCooldownMs: 15_000 }), ]); expect(lockAndLoadRankMechanics(0)).toBeNull(); }); it("resets Hunter cooldowns while leaving Bestial Wrath's cooldown untouched", () => { const readinessId = unlockHunterTalent("Readiness"); const bestialWrathId = unlockHunterTalent("Bestial Wrath"); const rapidFireId = hunterAbilityId("Rapid Fire"); useCombatStore.setState((state) => ({ cooldowns: { ...state.cooldowns, [readinessId]: 0, [bestialWrathId]: 90_000, [rapidFireId]: 30_000, }, })); const result = useCombatStore.getState().castAbility(readinessId, undefined, 1_000); expect(result, JSON.stringify(result)).toMatchObject({ ok: true }); expect(useCombatStore.getState().cooldowns[bestialWrathId]).toBe(90_000); expect(useCombatStore.getState().cooldowns[rapidFireId]).toBe(0); expect(useCombatStore.getState().cooldowns[readinessId]).toBeGreaterThan(1_000); }); it("rejects Bestial Wrath without an active pet and does not proc The Beast Within", () => { const bestialWrathId = unlockHunterTalent("Bestial Wrath"); useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-1803": 2 }, })); expect(useCombatStore.getState().auras.some((aura) => aura.definition.id.includes(":34471:"))).toBe(false); const startingMana = useCombatStore.getState().resourcePools.mana; const result = useCombatStore.getState().castAbility(bestialWrathId, undefined, 1_000); expect(result).toMatchObject({ ok: false, reason: "condition-not-met" }); expect(useCombatStore.getState().resourcePools.mana).toBe(startingMana); expect(useCombatStore.getState().cooldowns[bestialWrathId] ?? 0).toBe(0); expect(useCombatStore.getState().auras.some((aura) => aura.definition.id.includes(":34471:"))).toBe(false); }); it("requires a live pet before accepting pet-command and Mend Pet casts", () => { registerHunterMob("petless-target", [0, 0, 3]); const petDependentCasts = [ { id: unlockHunterTalent("Bestial Wrath"), targetActorId: undefined }, { id: unlockHunterTalent("Intimidation"), targetActorId: "petless-target" }, { id: hunterAbilityId("Kill Command"), targetActorId: "petless-target" }, { id: hunterAbilityId("Mend Pet"), targetActorId: undefined }, ]; const startingMana = useCombatStore.getState().resourcePools.mana; for (const [index, cast] of petDependentCasts.entries()) { const result = useCombatStore.getState().castAbility(cast.id, { ...(cast.targetActorId ? { targetActorId: cast.targetActorId } : {}), now: 1_000 + index * 2_000, }); expect(result, JSON.stringify(result)).toMatchObject({ ok: false, reason: "condition-not-met" }); expect(useCombatStore.getState().resourcePools.mana).toBe(startingMana); expect(useCombatStore.getState().cooldowns[cast.id] ?? 0).toBe(0); } }); it("places Bestial Wrath on the pet and applies the rank-two Beast Within payload to the hunter", () => { const callPetId = hunterAbilityId("Call Pet"); const bestialWrathId = unlockHunterTalent("Bestial Wrath"); const beastWithin = useCombatStore.getState().abilities.find((candidate) => candidate.name === "The Beast Within"); const beastWithinTalent = runtimeTalentNodeById("talent-1803"); expect(beastWithinTalent).toBeDefined(); expect(beastWithin).toMatchObject({ talentNodeId: "talent-1803", description: beastWithinTalent?.rankDescriptions[0], }); expect(beastWithin?.ranks?.map((rank) => rank.description)).toEqual(beastWithinTalent?.rankDescriptions); useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-1803": 2 }, })); const callResult = useCombatStore.getState().castAbility(callPetId, undefined, 1_000); expect(callResult, JSON.stringify(callResult)).toMatchObject({ ok: true }); const pet = useCombatStore.getState().summons.find((summon) => summon.kind === "pet"); const basePetStats = wow335HunterPetBaseStatsForLevel(80); expect(pet).toMatchObject({ hunterPet: { baseStats: { strength: 192, attackPower: 364 }, effectiveStats: { inheritedStamina: expect.any(Number), inheritedRangedAttackPower: expect.any(Number), }, }, }); expect(pet!.maxHealth).toBe(pet!.hunterPet!.effectiveStats.maxHealth); expect(pet!.attackDamage).toBe(pet!.hunterPet!.effectiveStats.averageAutoAttackDamage); expect(pet!.maxHealth).toBeGreaterThan(basePetStats.maxHealth); expect(pet!.attackDamage).toBeGreaterThan(basePetStats.averageAutoAttackDamage); const result = useCombatStore.getState().castAbility(bestialWrathId, undefined, 3_000); expect(result, JSON.stringify(result)).toMatchObject({ ok: true }); const auras = useCombatStore.getState().auras; const petAuras = auras.filter((aura) => aura.targetId === pet!.id && aura.definition.id.includes(":19574:")); expect(petAuras).toHaveLength(3); expect(auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.id.includes(":19574:"))).toBe(false); const beastWithinAuras = auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.id.includes(":34471:")); expect(beastWithinAuras).toHaveLength(3); expect(beastWithinAuras.every((aura) => aura.expiresAt === 9_000)).toBe(true); expect(beastWithinAuras.every((aura) => aura.definition.description === beastWithinTalent?.rankDescriptions[1])).toBe(true); expect(beastWithinAuras.flatMap((aura) => aura.definition.modifiers ?? [])).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: "resource", resource: "power", property: "cost", value: -0.5 }), expect.objectContaining({ kind: "damage", direction: "dealt", value: 0.15 }), ])); }); it("updates Hunter pet health and AP inheritance when the owner gains stamina and ranged AP", () => { const callPetId = hunterAbilityId("Call Pet"); expect(useCombatStore.getState().castAbility(callPetId, undefined, 1_000).ok).toBe(true); const initialPet = useCombatStore.getState().summons.find((summon) => summon.kind === "pet")!; const initialEffectiveStats = initialPet.hunterPet!.effectiveStats; useCombatStore.setState((state) => ({ auras: applyAura(state.auras, { definition: { id: "test:hunter-owner-pet-inheritance", name: "Test owner attributes", disposition: "buff", durationMs: 10_000, modifiers: [ { kind: "stat", stat: "stamina", operation: "flat", value: 100 }, { kind: "stat", stat: "ranged-attack-power", operation: "flat", value: 1_000 }, ], }, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now: 2_000, }).auras, })); useCombatStore.getState().tick(0.1, 2_000); const updatedPet = useCombatStore.getState().summons.find((summon) => summon.kind === "pet")!; expect(updatedPet.hunterPet!.effectiveStats.inheritedStamina - initialEffectiveStats.inheritedStamina).toBeCloseTo(45); expect(updatedPet.hunterPet!.effectiveStats.inheritedRangedAttackPower - initialEffectiveStats.inheritedRangedAttackPower).toBeCloseTo(220); expect(updatedPet.hunterPet!.effectiveStats.inheritedSpellDamage - initialEffectiveStats.inheritedSpellDamage).toBeCloseTo(128.7); expect(updatedPet.maxHealth - initialPet.maxHealth).toBe(450); expect(updatedPet.attackDamage).toBeGreaterThan(initialPet.attackDamage); expect(wow335HunterPetBiteDamage(updatedPet.hunterPet!) - wow335HunterPetBiteDamage(initialPet.hunterPet!)).toBeCloseTo(220 * 0.07 + 128.7 * 0.119658); }); it("uses The Beast Within's 50% rank-one proc chance", () => { const callPetId = hunterAbilityId("Call Pet"); const bestialWrathId = unlockHunterTalent("Bestial Wrath"); useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-1803": 1 }, })); expect(useCombatStore.getState().castAbility(callPetId, undefined, 1_000).ok).toBe(true); const pet = useCombatStore.getState().summons.find((summon) => summon.kind === "pet"); expect(pet).toBeDefined(); const noProcAt = hunterProcTime(bestialWrathId, pet!.id, false, 3_000); expect(useCombatStore.getState().castAbility(bestialWrathId, undefined, noProcAt).ok).toBe(true); expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.id.includes(":34471:"))).toBe(false); useCombatStore.setState((state) => ({ cooldowns: { ...state.cooldowns, [bestialWrathId]: 0 }, globalCooldownEndsAt: 0, })); const procAt = hunterProcTime(bestialWrathId, pet!.id, true, noProcAt + 5_000); expect(useCombatStore.getState().castAbility(bestialWrathId, undefined, procAt).ok).toBe(true); const beastWithinAuras = useCombatStore.getState().auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.id.includes(":34471:")); expect(beastWithinAuras).toHaveLength(3); expect(beastWithinAuras.every((aura) => aura.expiresAt === procAt + 6_000)).toBe(true); expect(beastWithinAuras.flatMap((aura) => aura.definition.modifiers ?? [])).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: "resource", resource: "power", property: "cost", value: -0.5 }), expect.objectContaining({ kind: "damage", direction: "dealt", value: 0.15 }), ])); }); it("stores Intimidation on the pet and spends it on the pet's next melee hit", () => { registerHunterMob("intimidation-target", [0, 0, 3]); const callPetId = hunterAbilityId("Call Pet"); const intimidationId = unlockHunterTalent("Intimidation"); expect(useCombatStore.getState().castAbility(callPetId, undefined, 1_000).ok).toBe(true); const pet = useCombatStore.getState().summons.find((summon) => summon.kind === "pet"); expect(pet).toBeDefined(); const cast = useCombatStore.getState().castAbility(intimidationId, { targetActorId: "intimidation-target", now: 3_000 }); expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true }); const intimidationAura = useCombatStore.getState().auras.find((aura) => ( aura.definition.id.includes(":19577:") )); expect(intimidationAura?.targetId).toBe(pet!.id); expect(useCombatStore.getState().mobs["intimidation-target"]?.statuses).toHaveLength(0); useCombatStore.getState().tick(1, 4_000); const target = useCombatStore.getState().mobs["intimidation-target"]; expect(target?.statuses).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: "stun", sourceActorId: pet!.id, endsAt: 7_000 }), ])); expect(target?.threatByActor[pet!.id]).toBe(580); expect(useCombatStore.getState().auras.some((aura) => aura.instanceId === intimidationAura?.instanceId)).toBe(false); }); it("only enables Counterattack after a parry and consumes that readiness on use", () => { const counterattackId = unlockHunterTalent("Counterattack"); useCombatStore.setState((state) => ({ gearStats: { ...state.gearStats, parryRating: 100_000 }, })); useCombatStore.getState().registerMob("counterattack-target", { name: "counterattack-target", maxHealth: 100_000, xpReward: 0, attacks: [DEFAULT_ENEMY_ATTACK], }); setMobPosition("counterattack-target", [2, 0, 0]); const unavailable = useCombatStore.getState().castAbility(counterattackId, { targetActorId: "counterattack-target", now: 1_000, }); expect(unavailable).toMatchObject({ ok: false, reason: "condition-not-met" }); useCombatStore.getState().damageMob("counterattack-target", 1, 1_100); let parryAt: number | null = null; for (let now = 1_450; now < 20_000; now += 100) { useCombatStore.getState().advanceMobCombat(now); if (useCombatStore.getState().lastCombatResult?.parried) { parryAt = now; break; } } expect(parryAt).not.toBeNull(); expect(useCombatStore.getState().auras.some((aura) => ( aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("wow335-hunter-counterattack-ready") ))).toBe(true); const available = useCombatStore.getState().castAbility(counterattackId, { targetActorId: "counterattack-target", now: parryAt! + 100, }); expect(available, JSON.stringify(available)).toMatchObject({ ok: true }); expect(useCombatStore.getState().auras.some((aura) => ( aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.utilityTags?.includes("wow335-hunter-counterattack-ready") ))).toBe(false); }); it("keeps Kill Command's pet aura inert on generic melee swings", () => { registerHunterMob("kill-command-target", [0, 0, 3]); const callPetId = hunterAbilityId("Call Pet"); const killCommandId = hunterAbilityId("Kill Command"); expect(useCombatStore.getState().castAbility(callPetId, undefined, 1_000).ok).toBe(true); const pet = useCombatStore.getState().summons.find((summon) => summon.kind === "pet"); expect(pet).toBeDefined(); const cast = useCombatStore.getState().castAbility(killCommandId, { targetActorId: "kill-command-target", now: 3_000 }); expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true }); const aura = useCombatStore.getState().auras.find((entry) => ( entry.definition.utilityTags?.includes("wow335-hunter-kill-command") )); expect(aura?.targetId).toBe(pet!.id); expect(aura?.expiresAt).toBe(33_000); expect(aura?.definition.maxStacks).toBe(3); expect(aura?.definition.initialStacks).toBe(3); expect(aura?.definition.modifiers?.some((modifier) => modifier.kind === "damage") ?? false).toBe(false); useCombatStore.setState((state) => ({ summons: state.summons.map((summon) => summon.id === pet!.id ? { ...summon, hunterPetSpecialReadyAt: 100_000 } : summon), })); useCombatStore.getState().tick(1, 4_000); expect(useCombatStore.getState().auras.find((entry) => entry.instanceId === aura?.instanceId)).toMatchObject({ expiresAt: 33_000, stacks: 3, }); }); it("arms Freezing Arrow at its ground point and freezes only an approaching target", () => { registerHunterMob("freezing-target", [0, 0, 10]); useCombatStore.getState().registerMob("freezing-later", { name: "freezing-later", maxHealth: 100_000, xpReward: 0 }); setMobPosition("freezing-later", [0, 0, 12]); const freezingArrowId = hunterAbilityId("Freezing Arrow"); const ability = useCombatStore.getState().abilities.find((candidate) => candidate.id === freezingArrowId)!; expect(ability.targeting).toMatchObject({ shape: "ground", groundRange: 40, radius: 2 }); const cast = useCombatStore.getState().castAbility(freezingArrowId, { origin: [0, 0, 0], groundPosition: [0, 0, 0], now: 1_000, }); expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true }); expect(useCombatStore.getState().mobs["freezing-target"]?.statuses).toHaveLength(0); expect(useCombatStore.getState().areaEffects[0]?.hunterTrap?.kind).toBe("freezing"); setMobPosition("freezing-target", [0, 0, 1]); useCombatStore.getState().tick(1, 2_000); expect(useCombatStore.getState().mobs["freezing-target"]?.statuses).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: "freeze", endsAt: 22_000, breakOnDamage: true }), ])); expect(useCombatStore.getState().mobs["freezing-later"]?.statuses).toHaveLength(0); expect(useCombatStore.getState().areaEffects.some((area) => area.hunterTrap?.kind === "freezing")).toBe(false); useCombatStore.getState().damageMob("freezing-target", 1, 2_001); expect(useCombatStore.getState().mobs["freezing-target"]?.statuses).toHaveLength(0); }); it("procs Lock and Load when Freezing Arrow successfully traps a target", () => { registerHunterMob("lock-and-load-trap-target", [0, 0, 10]); useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-1306": 3 }, })); const freezingArrowId = hunterAbilityId("Freezing Arrow"); expect(useCombatStore.getState().castAbility(freezingArrowId, { origin: [0, 0, 0], groundPosition: [0, 0, 0], now: 1_000, })).toMatchObject({ ok: true }); setMobPosition("lock-and-load-trap-target", [0, 0, 1]); useCombatStore.getState().tick(1, 2_000); expect(useCombatStore.getState().auras).toEqual(expect.arrayContaining([ expect.objectContaining({ targetId: PLAYER_AURA_ENTITY_ID, stacks: 2, expiresAt: 17_000, definition: expect.objectContaining({ utilityTags: expect.arrayContaining([WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG]), }), }), expect.objectContaining({ targetId: PLAYER_AURA_ENTITY_ID, expiresAt: 17_000, definition: expect.objectContaining({ utilityTags: expect.arrayContaining([WOW335_HUNTER_LOCK_AND_LOAD_ICD_TAG]), }), }), ])); }); it("uses the generated periodic-damage chance for Black Arrow ticks", () => { const targetId = "lock-and-load-black-arrow-target"; registerHunterMob(targetId, [0, 0, 12]); const blackArrowId = unlockHunterTalent("Black Arrow"); useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-1306": 3 }, })); const firstProcTickAt = Array.from({ length: 60_000 }, (_, index) => 4_000 + index) .find((tickAt) => hunterProcRoll( `${PLAYER_AGGRO_ID}:${blackArrowId}:periodic:${targetId}:${tickAt}:wow335-hunter-lock-and-load`, ) < 0.3); expect(firstProcTickAt).toBeDefined(); const castAt = firstProcTickAt! - 3_000; expect(useCombatStore.getState().castAbility(blackArrowId, { targetActorId: targetId, now: castAt })) .toMatchObject({ ok: true }); useCombatStore.getState().tick(1, firstProcTickAt!); expect(useCombatStore.getState().auras).toEqual(expect.arrayContaining([ expect.objectContaining({ targetId: PLAYER_AURA_ENTITY_ID, stacks: 2, expiresAt: firstProcTickAt! + 15_000, definition: expect.objectContaining({ utilityTags: expect.arrayContaining([WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG]), }), }), expect.objectContaining({ targetId: PLAYER_AURA_ENTITY_ID, expiresAt: firstProcTickAt! + 15_000, definition: expect.objectContaining({ utilityTags: expect.arrayContaining([WOW335_HUNTER_LOCK_AND_LOAD_ICD_TAG]), }), }), ])); }); it("makes the next two Arcane Shots free and cooldown-free, then restores normal costs", () => { registerHunterMob("lock-and-load-shot-target", [0, 0, 12]); useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-1306": 3 }, resource: 0, resourcePools: { ...state.resourcePools, mana: 0 }, })); const arcaneShotId = hunterAbilityId("Arcane Shot"); const arcaneShot = useCombatStore.getState().abilities.find((ability) => ability.id === arcaneShotId)!; const readyAura = { kind: "apply-aura" as const, recipient: "caster" as const, sourceEffectType: -1, sourceAuraType: -1, miscValue: 0, triggerSpellId: 0, aura: { id: "wow335:hunter:lock-and-load-ready", name: "Lock and Load", description: "Two Arcane Shot or Explosive Shot casts cost no mana and trigger no cooldown.", disposition: "buff" as const, durationMs: 15_000, maxStacks: 2, initialStacks: 2, stackBehavior: "replace" as const, utilityTags: [WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG], }, }; useCombatStore.setState((state) => ({ auras: applyAura(state.auras, { definition: readyAura.aura, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now: 1_000, }).auras, })); useCombatStore.setState((state) => ({ cooldowns: { ...state.cooldowns, [arcaneShotId]: 30_000 }, })); expect(useCombatStore.getState().castAbility(arcaneShotId, { targetActorId: "lock-and-load-shot-target", now: 1_000 })) .toMatchObject({ ok: true }); expect(useCombatStore.getState().resourcePools.mana).toBe(0); expect(useCombatStore.getState().cooldowns[arcaneShotId]).toBe(0); expect(useCombatStore.getState().auras.find((aura) => ( aura.definition.utilityTags?.includes(WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG) ))?.stacks).toBe(1); useCombatStore.setState((state) => ({ cooldowns: { ...state.cooldowns, [arcaneShotId]: 40_000 }, })); expect(useCombatStore.getState().castAbility(arcaneShotId, { targetActorId: "lock-and-load-shot-target", now: 3_000 })) .toMatchObject({ ok: true }); expect(useCombatStore.getState().resourcePools.mana).toBe(0); expect(useCombatStore.getState().cooldowns[arcaneShotId]).toBe(0); expect(useCombatStore.getState().auras.some((aura) => ( aura.definition.utilityTags?.includes(WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG) ))).toBe(false); useCombatStore.setState((state) => ({ cooldowns: { ...state.cooldowns, [arcaneShotId]: 0 }, })); expect(useCombatStore.getState().castAbility(arcaneShotId, { targetActorId: "lock-and-load-shot-target", now: 5_000 })) .toMatchObject({ ok: false, reason: "not-enough-resource" }); }); it("does not consume Lock and Load when an eligible shot cannot find a target", () => { useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-1306": 3 }, resource: 0, resourcePools: { ...state.resourcePools, mana: 0 }, })); const arcaneShotId = hunterAbilityId("Arcane Shot"); const arcaneShot = useCombatStore.getState().abilities.find((ability) => ability.id === arcaneShotId)!; useCombatStore.setState((state) => ({ auras: applyAura(state.auras, { definition: { id: "wow335:hunter:lock-and-load-ready", name: "Lock and Load", disposition: "buff", durationMs: 15_000, maxStacks: 2, initialStacks: 2, stackBehavior: "replace", utilityTags: [WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG], }, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now: 1_000, }).auras, })); useCombatStore.setState((state) => ({ cooldowns: { ...state.cooldowns, [arcaneShotId]: 30_000 }, })); expect(useCombatStore.getState().castAbility(arcaneShotId, { now: 1_000 })) .toMatchObject({ ok: false, reason: "no-target" }); expect(useCombatStore.getState().cooldowns[arcaneShotId]).toBe(30_000); expect(useCombatStore.getState().auras.find((aura) => ( aura.definition.utilityTags?.includes(WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG) ))?.stacks).toBe(2); }); it("spends a Lock and Load charge on a cast that misses", () => { const targetId = "lock-and-load-miss-target"; registerHunterMob(targetId, [0, 0, 12]); useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, "talent-1306": 3 }, })); const arcaneShotId = hunterAbilityId("Arcane Shot"); const arcaneShot = useCombatStore.getState().abilities.find((ability) => ability.id === arcaneShotId)!; useCombatStore.setState((state) => ({ auras: applyAura(state.auras, { definition: { id: "wow335:hunter:lock-and-load-ready", name: "Lock and Load", disposition: "buff", durationMs: 15_000, maxStacks: 2, initialStacks: 2, stackBehavior: "replace", utilityTags: [WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG], }, sourceId: PLAYER_AURA_ENTITY_ID, targetId: PLAYER_AURA_ENTITY_ID, now: 1_000, }).auras, })); const missAt = Array.from({ length: 13_000 }, (_, index) => 3_000 + index).find((now) => ( hunterProcRoll(`${PLAYER_AGGRO_ID}:${arcaneShot.id}:${targetId}:${now}:direct:outcome`) < 0.05 )); expect(missAt).toBeDefined(); expect(useCombatStore.getState().castAbility(arcaneShotId, { targetActorId: targetId, now: missAt! })) .toMatchObject({ ok: true }); expect(useCombatStore.getState().lastCombatResult?.outcome).toBe("miss"); expect(useCombatStore.getState().auras.find((aura) => ( aura.definition.utilityTags?.includes(WOW335_HUNTER_LOCK_AND_LOAD_READY_TAG) ))?.stacks).toBe(1); }); it("arms Explosive Trap, then deals its rank fire impact and periodic AoE ticks", () => { registerHunterMob("explosive-center", [0, 0, 0]); useCombatStore.getState().registerMob("explosive-edge", { name: "explosive-edge", maxHealth: 100_000, xpReward: 0 }); setMobPosition("explosive-edge", [0, 0, 8]); useCombatStore.getState().registerMob("explosive-outside", { name: "explosive-outside", maxHealth: 100_000, xpReward: 0 }); setMobPosition("explosive-outside", [0, 0, 11]); const abilityId = hunterAbilityId("Explosive Trap"); const cast = useCombatStore.getState().castAbility(abilityId, { origin: [0, 0, 0], groundPosition: [0, 0, 0], now: 1_000, }); expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true }); const beforeCenter = useCombatStore.getState().mobs["explosive-center"]!.health; const beforeEdge = useCombatStore.getState().mobs["explosive-edge"]!.health; expect(useCombatStore.getState().areaEffects[0]?.hunterTrap?.kind).toBe("explosive"); useCombatStore.getState().tick(1, 2_000); expect(useCombatStore.getState().mobs["explosive-center"]!.health).toBeLessThan(beforeCenter); expect(useCombatStore.getState().mobs["explosive-edge"]!.health).toBeLessThan(beforeEdge); expect(useCombatStore.getState().mobs["explosive-outside"]!.health).toBe(100_000); expect(useCombatStore.getState().combatEvents).toEqual(expect.arrayContaining([ expect.objectContaining({ occurredAt: 2_000, kind: "damage", abilityId, targetActorId: "explosive-center", school: "fire", }), ])); expect(useCombatStore.getState().areaEffects[0]).toMatchObject({ radius: 10, tickIntervalMs: 2_000 }); const afterImpact = useCombatStore.getState().mobs["explosive-center"]!.health; useCombatStore.getState().tick(1, 4_000); expect(useCombatStore.getState().mobs["explosive-center"]!.health).toBeLessThan(afterImpact); expect(useCombatStore.getState().combatEvents).toEqual(expect.arrayContaining([ expect.objectContaining({ occurredAt: 4_000, kind: "damage", abilityId, targetActorId: "explosive-center", school: "fire", }), ])); }); });