Files
healer-man/src/game/wow335HunterMechanics.test.ts
T
2026-10-02 10:38:12 -04:00

673 lines
31 KiB
TypeScript

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",
}),
]));
});
});