Files
healer-man/src/game/wow335HunterPetFamilySelection.integration.test.ts
T

446 lines
20 KiB
TypeScript

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);
}