forked from phenom/healer-man
673 lines
31 KiB
TypeScript
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",
|
|
}),
|
|
]));
|
|
});
|
|
});
|