Files
healer-man/src/game/combatStore.test.ts
T
2026-08-18 12:06:04 -04:00

1140 lines
51 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import {
clearPartyRuntimeRegistry,
registerPartyRuntimePosition,
updatePartyRuntimePosition,
type PartyWorldPosition,
} from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import {
DEFAULT_GAMEPLAY_SETTINGS,
EMPTY_CORPSE_DESPAWN_MS,
MINIMUM_CORPSE_VISIBLE_MS,
MOB_WOUND_ANIMATION_COOLDOWN_MS,
selectedMob,
useCombatStore,
} from "./combatStore";
import { TALENT_NODES, talentNodesForTree } from "./talentCatalog";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { abilitiesForClass, defaultActionBarForClass } from "./abilityCatalog";
import { defaultActionBindings } from "./actionBindings";
import { deriveCharacterStats } from "./wotlkStats";
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
function allocateTalentRanks(name: string, ranks: number): void {
const talent = TALENT_NODES.find((node) => node.name === name);
if (!talent) throw new Error(`Missing test talent: ${name}`);
for (let rank = 0; rank < ranks; rank += 1) {
expect(useCombatStore.getState().allocateTalent(talent.id)).toBe(true);
}
}
function registerTarget(
id = "target",
position: readonly [number, number, number] = [10, 0, 0],
maxHealth = 100,
xpReward = 20,
) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward });
setMobPosition(id, position);
useCombatStore.getState().selectMob(id);
}
function setPartyMemberHealth(id: string, health: number): void {
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === id
? { ...member, health: Math.max(0, Math.min(member.maxHealth, health)) }
: member),
}));
}
function preparePartyMember(
index: number,
position: PartyWorldPosition,
health = 1,
select = false,
): string {
const member = usePartyStore.getState().members[index];
if (!member) throw new Error(`Missing party member ${index}`);
setPartyMemberHealth(member.id, health);
registerPartyRuntimePosition(member.id, position);
if (select) usePartyStore.getState().selectMember(member.id);
return member.id;
}
describe("combat store", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 1, xp: 0 });
useCombatStore.getState().resetSettings();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("combat-store-tests", 1, useCombatStore.getState().health);
});
it("completes a ranged cast after its exact WoW 3.3.5a cast time", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
const startingResource = useCombatStore.getState().maxResource;
registerTarget("ravager", [10, 0, 0]);
const first = useCombatStore.getState().castSlot(0, undefined, 1000);
expect(first).toMatchObject({ ok: true, abilityId: "mage-frostbolt", damage: 0 });
expect(useCombatStore.getState().mobs.ravager.health).toBe(100);
expect(startingResource).toBe(165);
expect(useCombatStore.getState().resource).toBe(startingResource);
expect(useCombatStore.getState().activeCast).toMatchObject({ completesAt: 2500, mode: "cast" });
const second = useCombatStore.getState().castSlot(1, undefined, 1001);
expect(second).toMatchObject({ ok: false, reason: "already-casting" });
useCombatStore.getState().tick(0.1, 2499);
expect(useCombatStore.getState().mobs.ravager.health).toBe(100);
useCombatStore.getState().tick(0.1, 2500);
expect(useCombatStore.getState().mobs.ravager.health).toBeLessThan(100);
expect(useCombatStore.getState().resource).toBe(startingResource - 14);
expect(useCombatStore.getState().activeCast).toBeNull();
expect(useCombatStore.getState().mobs.ravager.engaged).toBe(true);
});
it("emits exactly one cast and release event and cancels presentation with the cast", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
registerTarget("presentation-target", [10, 0, 0]);
const events: CombatPresentationEvent[] = [];
const unsubscribe = subscribeCombatPresentation((message) => {
if (message.kind === "event") events.push(message.event);
});
expect(useCombatStore.getState().castAbility("mage-frostbolt", [0, 0, 0], 1_000).ok).toBe(true);
useCombatStore.getState().tick(0.1, 2_500);
expect(events.filter((event) => event.abilityId === "mage-frostbolt").map((event) => event.phase))
.toEqual(["cast-start", "release", "impact"]);
expect(useCombatStore.getState().castAbility("mage-frostbolt", [0, 0, 0], 4_000).ok).toBe(true);
expect(useCombatStore.getState().cancelCast()).toBe(true);
expect(events.filter((event) => event.abilityId === "mage-frostbolt").map((event) => event.phase))
.toEqual(["cast-start", "release", "impact", "cast-start", "cast-cancel"]);
unsubscribe();
});
it("does not cast or bind Priest Holy Concentration", () => {
const abilityId = "wow335-priest-holy-concentration";
expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000)).toMatchObject({
ok: false,
reason: "passive",
});
expect(useCombatStore.getState().setActionBinding("primary", "face-bottom", abilityId)).toBe(false);
});
it("starts with attribute-derived pools including racial and automatic class passives", () => {
const gnomeMage = deriveCharacterStats("mage", 1, EMPTY_ITEM_STATS, "gnome");
useCombatStore.getState().initializeCharacter({ classId: "mage", raceId: "gnome", level: 1 });
expect(useCombatStore.getState()).toMatchObject({
health: gnomeMage.health,
maxHealth: gnomeMage.health,
resource: gnomeMage.mana,
maxResource: gnomeMage.mana,
});
const taurenWarrior = deriveCharacterStats("warrior", 1, EMPTY_ITEM_STATS, "tauren");
useCombatStore.getState().initializeCharacter({ classId: "warrior", raceId: "tauren", level: 1 });
expect(useCombatStore.getState()).toMatchObject({
health: taurenWarrior.health,
maxHealth: taurenWarrior.health,
resource: 0,
maxResource: 100,
});
const chronomancerBase = deriveCharacterStats("chronomancer", 1, EMPTY_ITEM_STATS, "human");
useCombatStore.getState().initializeCharacter({ classId: "chronomancer", level: 1 });
expect(useCombatStore.getState()).toMatchObject({
resource: Math.round(chronomancerBase.mana * 1.12),
maxResource: Math.round(chronomancerBase.mana * 1.12),
});
useCombatStore.getState().initializeCharacter({ classId: "barbarian", level: 10 });
expect(useCombatStore.getState()).toMatchObject({ resource: 200, maxResource: 200 });
const primalistBase = deriveCharacterStats("primalist", 1, EMPTY_ITEM_STATS, "human");
useCombatStore.getState().initializeCharacter({ classId: "primalist", level: 1 });
expect(useCombatStore.getState()).toMatchObject({
health: Math.round(primalistBase.health * 1.08),
maxHealth: Math.round(primalistBase.health * 1.08),
resource: 0,
});
const sunClericBase = deriveCharacterStats("sun-cleric", 1, EMPTY_ITEM_STATS, "human");
useCombatStore.getState().initializeCharacter({ classId: "sun-cleric", level: 1 });
expect(useCombatStore.getState().maxResource).toBe(sunClericBase.mana);
const necromancerBase = deriveCharacterStats("necromancer", 20, EMPTY_ITEM_STATS, "human");
useCombatStore.getState().initializeCharacter({ classId: "necromancer", level: 20 });
expect(useCombatStore.getState().maxResource).toBe(necromancerBase.mana);
});
it("uses equipped spell power, stamina, and armor in player combat", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
const baseMaxHealth = useCombatStore.getState().maxHealth;
const staff = {
instanceId: "stat-staff",
itemId: 6631,
itemKey: "stat-staff",
name: "Stat Staff",
quality: "rare" as const,
inventorySlot: "two-hand",
armorType: null,
weaponType: "staff",
itemLevel: 1,
sourceItemLevel: 25,
stats: { ...EMPTY_ITEM_STATS, spellPower: 7, stamina: 3, armor: 100 },
quantity: 1,
sourceBossKey: "test-boss",
sourceBossName: "Test Boss",
acquiredAt: 1,
};
useCombatStore.getState().initializeCharacter({
classId: "mage",
level: 1,
inventory: [staff],
equipment: { player: { "main-hand": staff.instanceId } },
});
expect(useCombatStore.getState()).toMatchObject({
maxHealth: baseMaxHealth + 30,
gearStats: staff.stats,
});
registerTarget("geared-target", [10, 0, 0]);
useCombatStore.getState().castSlot(0, undefined, 1_000);
useCombatStore.getState().tick(0.1, 2_500);
expect(useCombatStore.getState().mobs["geared-target"].health).toBeLessThan(100);
expect(useCombatStore.getState().damagePlayer(100)).toBeLessThan(100);
});
it("uses spell penetration against a mob's matching magical resistance", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 20 });
useCombatStore.getState().registerMob("resisted-fire", {
name: "Resisted Fire Target",
level: 20,
maxHealth: 1_000,
xpReward: 0,
resistances: { fire: 100 },
});
const resisted = useCombatStore.getState().damageMob(
"resisted-fire",
100,
1_000,
undefined,
"fire",
).amount;
useCombatStore.setState({
gearStats: { ...EMPTY_ITEM_STATS, spellPenetration: 100 },
});
useCombatStore.getState().registerMob("penetrated-fire", {
name: "Penetrated Fire Target",
level: 20,
maxHealth: 1_000,
xpReward: 0,
resistances: { fire: 100 },
});
const penetrated = useCombatStore.getState().damageMob(
"penetrated-fire",
100,
2_000,
undefined,
"fire",
).amount;
expect(resisted).toBeLessThan(100);
expect(penetrated).toBe(100);
});
it("casts customized controller chords by stable ability id and holds on empty bindings", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
registerTarget("binding-target", [10, 0, 0]);
expect(useCombatStore.getState().actionBindings["primary:face-bottom"]).toBe("mage-frostbolt");
expect(useCombatStore.getState().actionBindings["primary:face-right"])
.toBe(defaultActionBarForClass("mage")[3]);
expect(useCombatStore.getState().setActionBinding("secondary", "r2", "mage-fire-blast")).toBe(true);
expect(useCombatStore.getState().setActionBinding("secondary", "r1", "priest-flash-heal")).toBe(false);
expect(useCombatStore.getState().castActionBinding("secondary", "r2", [0, 0, 0], 1_000)).toMatchObject({
ok: true,
abilityId: "mage-fire-blast",
});
expect(useCombatStore.getState().setActionBinding("tertiary", "r2", null)).toBe(true);
expect(useCombatStore.getState().castActionBinding("tertiary", "r2", [0, 0, 0], 2_000)).toMatchObject({
ok: false,
reason: "unknown-ability",
});
expect(useCombatStore.getState().feedback?.message).toMatch(/binding is empty/i);
});
it("normalizes saved bindings and can restore one layer or every layer", () => {
useCombatStore.getState().initializeCharacter({
classId: "mage",
actionBindings: {
"primary:face-bottom": null,
"secondary:r1": "mage-fire-blast",
"secondary:r2": "priest-flash-heal",
},
});
expect(useCombatStore.getState().actionBindings["primary:face-bottom"]).toBeNull();
expect(useCombatStore.getState().actionBindings["secondary:r1"]).toBe("mage-fire-blast");
expect(useCombatStore.getState().actionBindings["secondary:r2"]).toBeNull();
useCombatStore.getState().resetActionBindingLayer("secondary");
const mageDefaults = defaultActionBindings(defaultActionBarForClass("mage"));
expect(useCombatStore.getState().actionBindings["secondary:r1"]).toBe(mageDefaults["secondary:r1"]);
expect(useCombatStore.getState().actionBindings["primary:face-bottom"]).toBeNull();
useCombatStore.getState().resetActionBindings();
expect(useCombatStore.getState().actionBindings["primary:face-bottom"]).toBe("mage-frostbolt");
});
it("grants the level-ten starter kit while retaining later ability gates", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1, xp: 0 });
expect(useCombatStore.getState().castAbility("mage-frost-nova", [0, 0, 0], 1_000).ok).toBe(true);
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 19, xp: 0 });
expect(useCombatStore.getState().castAbility("mage-mana-shield", undefined, 2_000)).toMatchObject({
ok: false,
reason: "level-locked",
abilityId: "mage-mana-shield",
});
expect(useCombatStore.getState().feedback?.message).toBe("That ability unlocks at level 20.");
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 20, xp: 0 });
expect(useCombatStore.getState().castAbility("mage-mana-shield", undefined, 3_000).ok).toBe(true);
});
it("rejects unavailable and out-of-range targets without spending resources", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest" });
useCombatStore.getState().registerMob("hidden", { name: "Hidden" });
useCombatStore.getState().selectMob("hidden");
expect(useCombatStore.getState().castSlot(0, undefined, 1000).reason).toBe("target-unavailable");
expect(useCombatStore.getState().resource).toBe(useCombatStore.getState().maxResource);
setMobPosition("hidden", [60, 0, 0]);
expect(useCombatStore.getState().castSlot(0, undefined, 1000).reason).toBe("out-of-range");
expect(useCombatStore.getState().resource).toBe(useCombatStore.getState().maxResource);
});
it("heals, shields, and applies shield absorption before health damage", () => {
useCombatStore.getState().damagePlayer(40);
const before = useCombatStore.getState().health;
const heal = useCombatStore.getState().castAbility("wow335-priest-lesser-heal", undefined, 1000);
expect(heal.ok).toBe(true);
expect(useCombatStore.getState().health).toBe(before);
useCombatStore.getState().tick(0.1, useCombatStore.getState().activeCast!.completesAt);
expect(useCombatStore.getState().health).toBeGreaterThan(before);
expect(useCombatStore.getState().health - before).toBeGreaterThanOrEqual(30);
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 20 });
expect(useCombatStore.getState().castAbility("mage-mana-shield", undefined, 5000).ok).toBe(true);
const shield = useCombatStore.getState().shield;
expect(shield).toBeGreaterThan(0);
const healthBefore = useCombatStore.getState().health;
const healthDamage = useCombatStore.getState().damagePlayer(shield + 7);
expect(healthDamage).toBe(7);
expect(useCombatStore.getState().health).toBe(healthBefore - 7);
expect(useCombatStore.getState().shield).toBe(0);
});
it("ticks damage over time and awards kill experience exactly once", () => {
useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 1, xp: 0 });
registerTarget("ooze", [10, 0, 0], 5, 100);
expect(useCombatStore.getState().castSlot(1, undefined, 1000).ok).toBe(true);
useCombatStore.getState().tick(1, 4000);
expect(useCombatStore.getState().mobs.ooze.health).toBeLessThan(5);
useCombatStore.getState().tick(1, 7000);
expect(useCombatStore.getState()).toMatchObject({ level: 2, xp: 0 });
expect(useCombatStore.getState().mobs.ooze.dead).toBe(true);
useCombatStore.getState().tick(1, 30_000);
expect(useCombatStore.getState()).toMatchObject({ level: 2, xp: 0 });
expect(useCombatStore.getState().effects).toHaveLength(0);
});
it("supports multi-level rewards without duplicate death credit", () => {
registerTarget("boss", [2, 0, 0], 1, 400);
const first = useCombatStore.getState().damageMob("boss", 10, 1000);
expect(first).toMatchObject({ killed: true, xpAwarded: 1_600, levelsGained: 2 });
expect(useCombatStore.getState()).toMatchObject({ level: 3, xp: 300, xpToNext: 1_400 });
const second = useCombatStore.getState().damageMob("boss", 10, 2000);
expect(second).toMatchObject({ amount: 0, killed: false, xpAwarded: 0 });
expect(useCombatStore.getState().level).toBe(3);
});
it("damages every live mob inside a self-centered area ability", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 2 });
for (const [id, position] of [
["near-a", [3, 0, 0]],
["near-b", [8, 0, 0]],
["far", [18, 0, 0]],
] as const) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 50 });
setMobPosition(id, position);
}
const result = useCombatStore.getState().castSlot(2, [0, 0, 0], 1000);
expect(result.affectedMobIds).toEqual(["near-a", "near-b"]);
expect(useCombatStore.getState().mobs["near-a"].health).toBeLessThan(50);
expect(useCombatStore.getState().mobs["near-b"].health).toBe(
useCombatStore.getState().mobs["near-a"].health,
);
expect(useCombatStore.getState().mobs.far.health).toBe(50);
});
it("engages an attacked pack and gives every mob an independent melee swing", () => {
for (const [id, position] of [["pack:a", [2, 0, 0]], ["pack:b", [3, 0, 0]]] as const) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100 });
setMobPosition(id, position);
}
useCombatStore.getState().selectMob("pack:a");
const healthBefore = useCombatStore.getState().health;
expect(useCombatStore.getState().advanceMobCombat(1000, [0, 0, 0])).toBe(0);
expect(useCombatStore.getState().health).toBe(healthBefore);
useCombatStore.getState().damageMob("pack:a", 1, 1000);
expect(useCombatStore.getState().mobs["pack:a"].engaged).toBe(true);
expect(useCombatStore.getState().mobs["pack:b"].engaged).toBe(true);
expect(useCombatStore.getState().mobs["pack:a"].woundRevision).toBe(1);
expect(useCombatStore.getState().mobs["pack:a"].attackRevision).toBe(0);
expect(useCombatStore.getState().advanceMobCombat(1449, [0, 0, 0])).toBe(0);
expect(useCombatStore.getState().mobs["pack:a"].attackRevision).toBe(0);
expect(useCombatStore.getState().advanceMobCombat(1450, [0, 0, 0])).toBe(12);
expect(useCombatStore.getState().mobs["pack:a"].attackRevision).toBe(1);
expect(useCombatStore.getState().mobs["pack:b"].attackRevision).toBe(1);
expect(useCombatStore.getState().health).toBe(healthBefore - 12);
});
it("automatically engages a dungeon pack when a living party actor enters its aggro range", () => {
const partyMemberId = preparePartyMember(0, [40, 0, 0]);
for (const [id, position] of [["proximity-pack:a", [20, 0, 0]], ["proximity-pack:b", [22, 0, 0]]] as const) {
setMobPosition(id, position);
useCombatStore.getState().registerMob(id, {
name: id,
maxHealth: 100,
});
}
setMobPosition("proximity-boss", [100, 0, 0]);
useCombatStore.getState().registerMob("proximity-boss", {
name: "Proximity Boss",
boss: true,
maxHealth: 100,
});
expect(useCombatStore.getState().mobs["proximity-pack:a"].aggroRange).toBe(16);
expect(useCombatStore.getState().mobs["proximity-boss"].aggroRange).toBe(20);
expect(useCombatStore.getState().engageNearbyMobs(1_000, [0, 0, 0])).toBe(0);
expect(useCombatStore.getState().mobs["proximity-pack:a"].engaged).toBe(false);
updatePartyRuntimePosition(partyMemberId, 27, 0, 0);
expect(useCombatStore.getState().engageNearbyMobs(1_100, [0, 0, 0])).toBe(1);
expect(useCombatStore.getState().mobs["proximity-pack:a"]).toMatchObject({
engaged: true,
targetActorId: partyMemberId,
homePosition: [20, 0, 0],
});
expect(useCombatStore.getState().mobs["proximity-pack:b"]).toMatchObject({
engaged: true,
targetActorId: partyMemberId,
homePosition: [22, 0, 0],
});
});
it("uses 3D distance for proximity aggro so stacked dungeon floors do not pull", () => {
setMobPosition("upper-floor-mob", [0, 20, 0]);
useCombatStore.getState().registerMob("upper-floor-mob", {
name: "Upper Floor Mob",
maxHealth: 100,
});
expect(useCombatStore.getState().engageNearbyMobs(1_000, [0, 0, 0])).toBe(0);
expect(useCombatStore.getState().mobs["upper-floor-mob"].engaged).toBe(false);
expect(useCombatStore.getState().engageNearbyMobs(1_100, [0, 8, 0])).toBe(1);
expect(useCombatStore.getState().mobs["upper-floor-mob"].engaged).toBe(true);
});
it("anchors a roaming mob at the pull location and keeps aggro across vertical terrain", () => {
const id = "kresh-patrol:kresh-roaming";
setMobPosition(id, [0, -70, 0]);
useCombatStore.getState().registerMob(id, {
name: "Kresh",
boss: true,
maxHealth: 100,
leashRange: 55,
xpReward: 0,
});
// Kresh can travel far from the position where his model first mounted.
// His leash must begin at the patrol position where combat actually starts.
setMobPosition(id, [100, -106, 0]);
useCombatStore.getState().damageMob(id, 10, 1_000);
expect(useCombatStore.getState().mobs[id]).toMatchObject({
health: 90,
engaged: true,
homePosition: [100, -106, 0],
});
// A player falling from the ledge can be vertically farther than the leash
// while remaining beside the encounter in the dungeon's horizontal plane.
useCombatStore.getState().advanceMobCombat(1_450, [102, -30, 0]);
expect(useCombatStore.getState().mobs[id]).toMatchObject({
health: 90,
engaged: true,
combatPhase: "chasing",
});
// Horizontal distance still enforces the normal evade/reset boundary.
useCombatStore.getState().advanceMobCombat(1_500, [156, -106, 0]);
expect(useCombatStore.getState().mobs[id]).toMatchObject({
health: 100,
engaged: false,
combatPhase: "returning",
});
});
it("tracks damage and effective-healing threat independently for every engaged mob", () => {
for (const id of ["threat-pack:a", "threat-pack:b"]) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 500, xpReward: 0 });
setMobPosition(id, [2, 0, 0]);
}
useCombatStore.getState().damageMob(
"threat-pack:a",
10,
1_000,
{ actorId: "tank", role: "tank" },
);
useCombatStore.getState().damageMob(
"threat-pack:a",
20,
1_100,
{ actorId: "dps", role: "damage" },
);
useCombatStore.getState().addHealingThreat(
{ actorId: "healer", role: "healer" },
80,
);
expect(useCombatStore.getState().mobs["threat-pack:a"].threatByActor).toEqual({
tank: 51,
dps: 20,
healer: 10,
});
expect(useCombatStore.getState().mobs["threat-pack:b"].threatByActor).toEqual({
tank: 1,
healer: 10,
});
});
it("taunts bosses at max plus one, forces immediately, and resumes normal threat after expiry", () => {
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
registerPartyRuntimePosition(tank.id, [0, 0, 0]);
useCombatStore.getState().registerMob("taunt-boss", {
name: "Taunt boss",
boss: true,
maxHealth: 500,
xpReward: 0,
});
setMobPosition("taunt-boss", [2, 0, 0]);
useCombatStore.getState().damageMob("taunt-boss", 40, 1_000, {
actorId: "__player__",
role: "damage",
});
expect(useCombatStore.getState().tauntMob(
"taunt-boss",
{ actorId: tank.id, role: "tank" },
3_000,
2_000,
)).toBe(true);
expect(useCombatStore.getState().mobs["taunt-boss"]).toMatchObject({
targetActorId: tank.id,
forcedTarget: { actorId: tank.id, endsAt: 5_000 },
threatByActor: {
"__player__": 41,
[tank.id]: 42,
},
});
useCombatStore.getState().damageMob("taunt-boss", 100, 3_000, {
actorId: "__player__",
role: "damage",
});
useCombatStore.getState().advanceMobCombat(4_999);
expect(useCombatStore.getState().mobs["taunt-boss"].targetActorId).toBe(tank.id);
useCombatStore.getState().advanceMobCombat(5_000);
expect(useCombatStore.getState().mobs["taunt-boss"]).toMatchObject({
targetActorId: "__player__",
forcedTarget: null,
});
});
it("swings at the resolved companion without redirecting player damage", () => {
const target = usePartyStore.getState().members.find((member) => member.role === "damage")!;
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === target.id
? { ...member, gearStats: { ...EMPTY_ITEM_STATS, armor: 1_000 } }
: member),
}));
registerPartyRuntimePosition(target.id, [0, 0, 0]);
useCombatStore.getState().registerMob("party-victim", {
name: "Party victim",
maxHealth: 200,
xpReward: 0,
});
setMobPosition("party-victim", [2, 0, 0]);
useCombatStore.getState().damageMob("party-victim", 5, 1_000, {
actorId: target.id,
role: "damage",
});
const playerBefore = useCombatStore.getState().health;
const memberBefore = target.health;
const resolvedDamage = useCombatStore.getState().advanceMobCombat(1_450);
expect(resolvedDamage).toBeGreaterThan(0);
expect(useCombatStore.getState().health).toBe(playerBefore);
expect(usePartyStore.getState().members.find((member) => member.id === target.id)?.health)
.toBe(memberBefore - resolvedDamage);
expect(useCombatStore.getState().lastCombatResult?.finalDamage).toBe(resolvedDamage);
expect(useCombatStore.getState().mobs["party-victim"].targetActorId).toBe(target.id);
});
it("clears all aggro state on encounter reset", () => {
registerTarget("reset-threat", [2, 0, 0], 100, 0);
useCombatStore.getState().damageMob("reset-threat", 1, 1_000);
useCombatStore.getState().tauntMob(
"reset-threat",
{ actorId: "__player__", role: "healer" },
3_000,
1_100,
);
useCombatStore.getState().resetEncounter();
expect(useCombatStore.getState().mobs["reset-threat"]).toMatchObject({
threatByActor: {},
targetActorId: null,
forcedTarget: null,
engaged: false,
});
});
it("throttles wound animation revisions during simultaneous party damage", () => {
useCombatStore.getState().registerMob("focus-target", { name: "focus-target", maxHealth: 100 });
useCombatStore.getState().damageMob("focus-target", 1, 1_000);
useCombatStore.getState().damageMob("focus-target", 1, 1_000);
useCombatStore.getState().damageMob("focus-target", 1, 1_100);
expect(useCombatStore.getState().mobs["focus-target"].woundRevision).toBe(1);
useCombatStore.getState().damageMob(
"focus-target",
1,
1_000 + MOB_WOUND_ANIMATION_COOLDOWN_MS,
);
expect(useCombatStore.getState().mobs["focus-target"].woundRevision).toBe(2);
});
it("holds lootable corpses until looted and expires empty corpses after ten seconds", () => {
useCombatStore.getState().registerMob("empty", { name: "empty", maxHealth: 1 });
useCombatStore.getState().damageMob("empty", 1, 1_000);
expect(useCombatStore.getState().mobs.empty).toMatchObject({
dead: true,
despawned: false,
corpseDespawnAt: 1_000 + EMPTY_CORPSE_DESPAWN_MS,
});
useCombatStore.getState().tick(0, 1_000 + EMPTY_CORPSE_DESPAWN_MS - 1);
expect(useCombatStore.getState().mobs.empty.despawned).toBe(false);
useCombatStore.getState().tick(0, 1_000 + EMPTY_CORPSE_DESPAWN_MS);
expect(useCombatStore.getState().mobs.empty.despawned).toBe(true);
useCombatStore.getState().registerMob("lootable", {
name: "lootable",
maxHealth: 1,
hasLoot: true,
});
useCombatStore.getState().damageMob("lootable", 1, 20_000);
useCombatStore.getState().tick(0, 60_000);
expect(useCombatStore.getState().mobs.lootable).toMatchObject({
dead: true,
looted: false,
despawned: false,
corpseDespawnAt: null,
});
expect(useCombatStore.getState().lootMob("lootable", 20_100)).toBe(true);
expect(useCombatStore.getState().mobs.lootable).toMatchObject({
looted: true,
despawned: false,
corpseDespawnAt: 20_000 + MINIMUM_CORPSE_VISIBLE_MS,
});
useCombatStore.getState().tick(0, 20_000 + MINIMUM_CORPSE_VISIBLE_MS);
expect(useCombatStore.getState().mobs.lootable.despawned).toBe(true);
expect(useCombatStore.getState().lootMob("lootable", 30_000)).toBe(false);
});
it("rolls SQL boss loot at the killing player's level and grants it automatically exactly once", () => {
useCombatStore.getState().initializeCharacter({
classId: "priest",
level: 1,
xp: 360,
inventory: [],
});
useCombatStore.getState().registerMob("anacondra-spawn", {
name: "Lady Anacondra",
boss: true,
maxHealth: 1,
xpReward: 20,
lootSourceId: "lady-anacondra",
});
expect(useCombatStore.getState().damageMob("anacondra-spawn", 1, 5_000)).toMatchObject({
killed: true,
levelsGained: 1,
});
const corpse = useCombatStore.getState().mobs["anacondra-spawn"];
expect(useCombatStore.getState().level).toBe(2);
expect(corpse).toMatchObject({
dead: true,
hasLoot: true,
looted: true,
corpseDespawnAt: 5_000 + MINIMUM_CORPSE_VISIBLE_MS,
});
expect(corpse.pendingLoot).toHaveLength(1);
expect(corpse.pendingLoot[0]).toMatchObject({
itemLevel: 1,
sourceBossKey: "lady-anacondra",
});
expect(useCombatStore.getState().inventory).toEqual(corpse.pendingLoot);
expect(useCombatStore.getState().bossLootRewards).toEqual([
expect.objectContaining({
bossId: "anacondra-spawn",
bossName: "Lady Anacondra",
items: corpse.pendingLoot,
acquiredAt: 5_000,
}),
]);
useCombatStore.getState().resetEncounter();
expect(useCombatStore.getState().bossLootRewards).toHaveLength(1);
useCombatStore.getState().dismissBossLootReward();
expect(useCombatStore.getState().bossLootRewards).toEqual([]);
expect(useCombatStore.getState().lootMob("anacondra-spawn", 5_100)).toBe(false);
expect(useCombatStore.getState().lootMob("anacondra-spawn", 5_200)).toBe(false);
expect(useCombatStore.getState().inventory).toHaveLength(1);
});
it("aggros every surviving packmate even when the pulled mob dies to the opening hit", () => {
for (const [id, maxHealth] of [["one-shot:a", 1], ["one-shot:b", 100], ["outsider", 100]] as const) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
setMobPosition(id, [2, 0, 0]);
}
expect(useCombatStore.getState().damageMob("one-shot:a", 99, 1000)).toMatchObject({ killed: true });
expect(useCombatStore.getState().mobs["one-shot:a"]).toMatchObject({ dead: true, engaged: false });
expect(useCombatStore.getState().mobs["one-shot:b"]).toMatchObject({ dead: false, engaged: true });
expect(useCombatStore.getState().mobs.outsider.engaged).toBe(false);
});
it("fears every living non-boss enemy within Psychic Scream's eight-yard radius for eight seconds", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 14 });
const nearby = ["scream-pack:a", "near-b", "near-c", "near-d"] as const;
nearby.forEach((id, index) => {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100 });
setMobPosition(id, [index + 1, 0, 0]);
});
useCombatStore.getState().registerMob("scream-pack:b", { name: "outside packmate", maxHealth: 100 });
setMobPosition("scream-pack:b", [12, 0, 0]);
useCombatStore.getState().registerMob("near-boss", {
name: "immune boss",
boss: true,
maxHealth: 100,
mechanicImmunities: ["fear"],
});
setMobPosition("near-boss", [7, 0, 0]);
useCombatStore.getState().registerMob("fallen", { name: "fallen", maxHealth: 1, xpReward: 0 });
setMobPosition("fallen", [5, 0, 0]);
useCombatStore.getState().damageMob("fallen", 1, 500);
const result = useCombatStore.getState().castAbility("priest-psychic-scream", [0, 0, 0], 1000);
expect(result.ok).toBe(true);
expect(result.affectedMobIds).toHaveLength(5);
for (const id of nearby) {
expect(useCombatStore.getState().mobs[id].statuses).toContainEqual({
kind: "fear",
sourceAbilityId: "priest-psychic-scream",
endsAt: 9000,
});
}
expect(useCombatStore.getState().mobs["scream-pack:b"]).toMatchObject({ engaged: true, statuses: [] });
expect(useCombatStore.getState().mobs["near-boss"].statuses).toEqual([]);
expect(useCombatStore.getState().mobs.fallen.statuses).toEqual([]);
expect(useCombatStore.getState().advanceMobCombat(8999, [0, 0, 0])).toBe(0);
useCombatStore.getState().tick(0.001, 9000);
expect(useCombatStore.getState().mobs["scream-pack:a"].statuses).toEqual([]);
expect(useCombatStore.getState().advanceMobCombat(9000, [0, 0, 0])).toBeGreaterThan(0);
});
it("interrupts a hard cast when the player moves", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest" });
registerTarget("fang", [10, 0, 0]);
expect(useCombatStore.getState().castSlot(0, [0, 0, 0], 1000).ok).toBe(true);
expect(useCombatStore.getState().playerAnimationEvent).toMatchObject({
kind: "ability-start",
abilityId: "priest-smite",
});
useCombatStore.getState().setPlayerPosition([0.5, 0, 0]);
expect(useCombatStore.getState().activeCast).toBeNull();
expect(useCombatStore.getState().playerAnimationEvent).toMatchObject({
kind: "ability-cancel",
abilityId: "priest-smite",
});
useCombatStore.getState().tick(0.1, 3000);
expect(useCombatStore.getState().mobs.fang.health).toBe(100);
expect(useCombatStore.getState().resource).toBe(useCombatStore.getState().maxResource);
});
it("cycles nearest living targets in both directions", () => {
for (const [id, distance] of [["one", 2], ["two", 4], ["three", 6]] as const) {
useCombatStore.getState().registerMob(id, { name: id });
setMobPosition(id, [distance, 0, 0]);
}
expect(useCombatStore.getState().cycleTarget("next")).toBe("one");
expect(useCombatStore.getState().cycleTarget("next")).toBe("two");
expect(useCombatStore.getState().cycleTarget("previous")).toBe("one");
expect(selectedMob(useCombatStore.getState())?.id).toBe("one");
});
it("tracks rogue combo points and consumes them with Eviscerate", () => {
useCombatStore.getState().initializeCharacter({ classId: "rogue" });
registerTarget("fang", [2, 0, 0], 100);
expect(useCombatStore.getState().castSlot(0, undefined, 1000).ok).toBe(true);
expect(useCombatStore.getState().comboPoints).toBe(1);
expect(useCombatStore.getState().castSlot(1, undefined, 2000).ok).toBe(true);
expect(useCombatStore.getState().comboPoints).toBe(0);
expect(useCombatStore.getState().mobs.fang.health).toBeLessThan(100);
expect(useCombatStore.getState().lastCombatResult).not.toBeNull();
});
it("shares the shaman Shock cooldown family", () => {
useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 4 });
registerTarget("druid", [10, 0, 0]);
expect(useCombatStore.getState().castSlot(1, undefined, 1000).ok).toBe(true);
expect(useCombatStore.getState().cooldowns["shaman-flame-shock"]).toBe(7000);
expect(useCombatStore.getState().cooldowns["shaman-earth-shock"]).toBe(7000);
});
it("allocates the first WotLK talent point at level 10 and clamps settings", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 10 });
const holy = talentNodesForTree("priest-holy")[0];
const shadow = talentNodesForTree("priest-shadow")[0];
expect(useCombatStore.getState().allocateTalent(holy.id)).toBe(true);
expect(useCombatStore.getState().allocateTalent(shadow.id)).toBe(false);
expect(useCombatStore.getState().talentRanks).toEqual({ [holy.id]: 1 });
useCombatStore.getState().resetTalents();
expect(useCombatStore.getState().talentRanks).toEqual({});
expect(useCombatStore.getState().settings.showThreatMeter).toBe(false);
expect(useCombatStore.getState().settings.showMobAggroRanges).toBe(false);
useCombatStore.getState().updateSettings({
masterVolume: 9,
uiScale: 0.1,
minimapRotation: "north-up",
showThreatMeter: true,
showMobAggroRanges: true,
threatMeterPosition: { x: -2, y: 4 },
});
expect(useCombatStore.getState().settings).toMatchObject({
masterVolume: 1,
uiScale: 0.75,
minimapRotation: "north-up",
showThreatMeter: true,
showMobAggroRanges: true,
threatMeterPosition: { x: 0, y: 1 },
});
useCombatStore.getState().initializeCharacter({ classId: "priest" });
expect(useCombatStore.getState().settings.showThreatMeter).toBe(false);
useCombatStore.getState().resetSettings();
expect(useCombatStore.getState().settings).toEqual(DEFAULT_GAMEPLAY_SETTINGS);
});
it("keeps a CoA tree ability uncastable until its exact node is purchased", () => {
useCombatStore.getState().initializeCharacter({ classId: "stormbringer", level: 60 });
const ability = abilitiesForClass("stormbringer").find((candidate) => (
candidate.source === "coa-tree"
&& !candidate.passive
&& candidate.talentEntryId
&& TALENT_NODES.some((node) => (
node.id === `coa-entry-${candidate.talentEntryId}`
&& node.prerequisites.length === 0
&& (node.requiredClassPoints ?? 0) === 0
&& (node.requiredSpecPoints ?? 0) === 0
))
))!;
expect(ability).toBeDefined();
expect(useCombatStore.getState().castAbility(ability.id, [0, 0, 0], 1_000)).toMatchObject({
ok: false,
reason: "not-learned",
});
expect(useCombatStore.getState().allocateTalent(`coa-entry-${ability.talentEntryId}`)).toBe(true);
expect(useCombatStore.getState().castAbility(ability.id, [0, 0, 0], 3_000).reason).not.toBe("not-learned");
});
it("resets encounter health without erasing earned progression", () => {
registerTarget("mob", [2, 0, 0], 1, 100);
useCombatStore.getState().damageMob("mob", 5, 1000);
expect(useCombatStore.getState().level).toBe(2);
useCombatStore.getState().resetEncounter();
expect(useCombatStore.getState()).toMatchObject({ level: 2, selectedTargetId: null });
expect(useCombatStore.getState().mobs.mob).toMatchObject({ dead: false, health: 1, experienceGranted: false });
});
it("applies learned flat cast-time and cooldown claims to their named mage spells", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
allocateTalentRanks("Improved Frostbolt", 1);
allocateTalentRanks("Improved Fire Blast", 1);
registerTarget("talent-dummy", [10, 0, 0], 10_000, 0);
expect(useCombatStore.getState().castSlot(0, undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().activeCast?.completesAt).toBe(4_000);
useCombatStore.getState().tick(0.1, 4_000);
expect(useCombatStore.getState().castSlot(1, undefined, 4_100).ok).toBe(true);
expect(useCombatStore.getState().cooldowns["mage-fire-blast"]).toBeGreaterThan(4_100);
});
it("uses WoW 3.3.5a Shadow Word: Pain periodic damage", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
allocateTalentRanks("Twin Disciplines", 5);
registerTarget("pain-dummy", [10, 0, 0], 1_000, 0);
expect(useCombatStore.getState().castSlot(1, undefined, 1_000).ok).toBe(true);
useCombatStore.getState().tick(0.1, 4_000);
// Twin Disciplines is Holy-only in the current talent data, so it does
// not modify this Shadow spell.
expect(useCombatStore.getState().mobs["pain-dummy"].health).toBe(770);
});
it("applies learned healing and resource-cost percentages to exact outcomes", () => {
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
allocateTalentRanks("Divinity", 3);
useCombatStore.getState().damagePlayer(1_000);
const beforeHeal = useCombatStore.getState().health;
expect(useCombatStore.getState().castSlot(1, undefined, 1_000).ok).toBe(true);
const healCompletion = useCombatStore.getState().activeCast!.completesAt;
useCombatStore.getState().tick(0.1, healCompletion);
expect(useCombatStore.getState().health - beforeHeal).toBeGreaterThan(719);
useCombatStore.getState().initializeCharacter({ classId: "shaman", level: 80 });
allocateTalentRanks("Convection", 5);
const startingMana = useCombatStore.getState().resource;
expect(useCombatStore.getState().talentModifiers).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "resource-cost-percent", amount: -15, sourceTalentName: "Convection" }),
]));
registerTarget("mana-dummy", [10, 0, 0], 10_000, 0);
expect(useCombatStore.getState().castSlot(0, undefined, 10_000).ok).toBe(true);
const boltCompletion = useCombatStore.getState().activeCast!.completesAt;
useCombatStore.getState().tick(0.1, boltCompletion);
expect(useCombatStore.getState().resource).toBe(startingMana - 9);
});
it("routes a cast-time direct heal to the selected living party member", () => {
registerTarget("hostile-selection", [10, 0, 0], 100, 0);
const memberId = preparePartyMember(0, [10, 0, 0], 1, true);
useCombatStore.getState().damagePlayer(25);
const playerHealthBefore = useCombatStore.getState().health;
expect(useCombatStore.getState().castAbility("wow335-priest-lesser-heal", [0, 0, 0], 1_000)).toMatchObject({
ok: true,
healing: 0,
});
expect(useCombatStore.getState().activeCast?.targetId).toBe(memberId);
expect(useCombatStore.getState().selectedTargetId).toBe("hostile-selection");
useCombatStore.getState().tick(0.1, useCombatStore.getState().activeCast!.completesAt);
expect(usePartyStore.getState().members.find((member) => member.id === memberId)!.health).toBeGreaterThan(1);
expect(useCombatStore.getState().health).toBe(playerHealthBefore);
expect(useCombatStore.getState().selectedTargetId).toBe("hostile-selection");
});
it("falls back to the player and snapshots an explicit null friendly target", () => {
registerTarget("enemy", [10, 0, 0], 100, 0);
const memberId = preparePartyMember(0, [8, 0, 0], 1);
useCombatStore.getState().damagePlayer(40);
const playerHealthBefore = useCombatStore.getState().health;
expect(useCombatStore.getState().castAbility("wow335-priest-lesser-heal", [0, 0, 0], 1_000).ok).toBe(true);
expect(useCombatStore.getState().activeCast?.targetId).toBeNull();
usePartyStore.getState().selectMember(memberId);
useCombatStore.getState().tick(0.1, useCombatStore.getState().activeCast!.completesAt);
expect(useCombatStore.getState().health).toBeGreaterThan(playerHealthBefore);
expect(usePartyStore.getState().members.find((member) => member.id === memberId)!.health).toBe(1);
expect(useCombatStore.getState().selectedTargetId).toBe("enemy");
});
it("routes an explicit self selection to the player", () => {
const memberId = preparePartyMember(0, [8, 0, 0], 1, true);
useCombatStore.getState().damagePlayer(40);
const playerHealthBefore = useCombatStore.getState().health;
usePartyStore.getState().selectSelf();
expect(usePartyStore.getState()).toMatchObject({
selectedMemberId: null,
selfSelected: true,
});
expect(useCombatStore.getState().castAbility("wow335-priest-lesser-heal", [0, 0, 0], 1_000).ok).toBe(true);
expect(useCombatStore.getState().activeCast?.targetId).toBeNull();
useCombatStore.getState().tick(0.1, useCombatStore.getState().activeCast!.completesAt);
expect(useCombatStore.getState().health).toBeGreaterThan(playerHealthBefore);
expect(usePartyStore.getState().members.find((member) => member.id === memberId)!.health).toBe(1);
});
it("rejects unavailable, out-of-range, and down friendly targets before spending resources", () => {
const unavailableId = preparePartyMember(0, [10, 0, 0], 1, true);
clearPartyRuntimeRegistry();
expect(useCombatStore.getState().castAbility("wow335-priest-lesser-heal", [0, 0, 0], 1_000)).toMatchObject({
ok: false,
reason: "target-unavailable",
});
expect(useCombatStore.getState().resource).toBe(useCombatStore.getState().maxResource);
expect(usePartyStore.getState().selectedMemberId).toBe(unavailableId);
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 1 });
usePartyStore.getState().initializeParty("range-test", 1, useCombatStore.getState().health);
clearPartyRuntimeRegistry();
preparePartyMember(0, [40.01, 0, 0], 1, true);
expect(useCombatStore.getState().castAbility("wow335-priest-lesser-heal", [0, 0, 0], 2_000)).toMatchObject({
ok: false,
reason: "out-of-range",
});
expect(useCombatStore.getState().resource).toBe(useCombatStore.getState().maxResource);
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 1 });
usePartyStore.getState().initializeParty("down-test", 1, useCombatStore.getState().health);
clearPartyRuntimeRegistry();
const downId = preparePartyMember(0, [10, 0, 0], 1, true);
setPartyMemberHealth(downId, 0);
expect(useCombatStore.getState().castAbility("wow335-priest-lesser-heal", [0, 0, 0], 3_000)).toMatchObject({
ok: false,
reason: "target-dead",
});
expect(useCombatStore.getState().resource).toBe(useCombatStore.getState().maxResource);
});
it("revalidates the snapshotted friendly target at cast completion", () => {
const memberId = preparePartyMember(0, [40, 0, 0], 1, true);
expect(useCombatStore.getState().castAbility("wow335-priest-lesser-heal", [0, 0, 0], 1_000).ok).toBe(true);
const outOfRangeCompletion = useCombatStore.getState().activeCast!.completesAt;
updatePartyRuntimePosition(memberId, 40.01, 0, 0);
useCombatStore.getState().tick(0.1, outOfRangeCompletion);
expect(usePartyStore.getState().members.find((member) => member.id === memberId)!.health).toBe(1);
expect(useCombatStore.getState().resource).toBe(useCombatStore.getState().maxResource);
expect(useCombatStore.getState().feedback?.message).toBe("That target is out of range.");
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 1 });
usePartyStore.getState().initializeParty("completion-death", 1, useCombatStore.getState().health);
clearPartyRuntimeRegistry();
const downId = preparePartyMember(0, [10, 0, 0], 1, true);
expect(useCombatStore.getState().castAbility("wow335-priest-lesser-heal", [0, 0, 0], 3_000).ok).toBe(true);
const defeatedCompletion = useCombatStore.getState().activeCast!.completesAt;
setPartyMemberHealth(downId, 0);
useCombatStore.getState().tick(0.1, defeatedCompletion);
expect(usePartyStore.getState().members.find((member) => member.id === downId)!.health).toBe(0);
expect(useCombatStore.getState().resource).toBe(useCombatStore.getState().maxResource);
expect(useCombatStore.getState().feedback?.message).toBe("That target is already defeated.");
});
it("keeps a cast-time friendly heal on its original ally after selection changes", () => {
const firstId = preparePartyMember(0, [8, 0, 0], 1, true);
const secondId = preparePartyMember(1, [9, 0, 0], 1);
expect(useCombatStore.getState().castAbility("wow335-priest-lesser-heal", [0, 0, 0], 1_000).ok).toBe(true);
usePartyStore.getState().selectMember(secondId);
useCombatStore.getState().tick(0.1, useCombatStore.getState().activeCast!.completesAt);
expect(usePartyStore.getState().members.find((member) => member.id === firstId)!.health).toBeGreaterThan(1);
expect(usePartyStore.getState().members.find((member) => member.id === secondId)!.health).toBe(1);
});
it("tracks independent Rejuvenation HoTs per ally and refreshes only the same target", () => {
useCombatStore.getState().initializeCharacter({ classId: "druid", level: 1 });
const firstId = preparePartyMember(0, [8, 0, 0], 1, true);
const secondId = preparePartyMember(1, [9, 0, 0], 1);
useCombatStore.getState().damagePlayer(30);
const playerHealthBefore = useCombatStore.getState().health;
expect(useCombatStore.getState().castAbility("druid-rejuvenation", [0, 0, 0], 1_000).ok).toBe(true);
usePartyStore.getState().selectMember(secondId);
expect(useCombatStore.getState().castAbility("druid-rejuvenation", [0, 0, 0], 2_500).ok).toBe(true);
expect(useCombatStore.getState().effects.map((effect) => effect.targetId)).toEqual(
expect.arrayContaining([firstId, secondId]),
);
expect(useCombatStore.getState().effects).toHaveLength(2);
useCombatStore.getState().tick(0.1, 4_000);
expect(usePartyStore.getState().members.find((member) => member.id === firstId)!.health).toBeGreaterThan(1);
expect(usePartyStore.getState().members.find((member) => member.id === secondId)!.health).toBe(1);
useCombatStore.getState().tick(0.1, 5_500);
expect(usePartyStore.getState().members.find((member) => member.id === secondId)!.health).toBeGreaterThan(1);
expect(useCombatStore.getState().health).toBe(playerHealthBefore);
usePartyStore.getState().selectMember(firstId);
expect(useCombatStore.getState().castAbility("druid-rejuvenation", [0, 0, 0], 5_500).ok).toBe(true);
expect(useCombatStore.getState().effects.filter((effect) => effect.targetId === firstId)).toHaveLength(1);
expect(useCombatStore.getState().effects.filter((effect) => effect.targetId === secondId)).toHaveLength(1);
});
it("keeps healing components of hostile abilities on the player", () => {
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 2 });
useCombatStore.setState({ resource: 100 });
registerTarget("death-strike-target", [2, 0, 0], 100, 0);
const memberId = preparePartyMember(0, [2, 0, 0], 1, true);
useCombatStore.getState().damagePlayer(40);
const playerHealthBefore = useCombatStore.getState().health;
const result = useCombatStore.getState().castAbility("death-knight-death-strike", [0, 0, 0], 1_000);
expect(result.ok).toBe(true);
expect(result.damage).toBeGreaterThan(0);
expect(result.healing).toBeGreaterThan(0);
expect(useCombatStore.getState().health).toBeGreaterThan(playerHealthBefore);
expect(usePartyStore.getState().members.find((member) => member.id === memberId)!.health).toBe(1);
expect(useCombatStore.getState().selectedTargetId).toBe("death-strike-target");
});
it("does not misapply pet-only Fel Vitality to player health or mana", () => {
useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80 });
allocateTalentRanks("Improved Healthstone", 2);
allocateTalentRanks("Improved Imp", 3);
const beforeFelVitality = {
maxHealth: useCombatStore.getState().maxHealth,
maxResource: useCombatStore.getState().maxResource,
};
allocateTalentRanks("Fel Vitality", 1);
expect(useCombatStore.getState()).toMatchObject(beforeFelVitality);
});
it("applies Manastorm boss multipliers and a single bonus-loot chance at registration", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 10 });
useCombatStore.getState().registerMob("storm-boss", {
name: "Lady Anacondra",
boss: true,
lootSourceId: "lady-anacondra",
healthMultiplier: 2.02,
damageMultiplier: 2.02,
bonusLootChance: 1,
});
expect(useCombatStore.getState().mobs["storm-boss"]).toMatchObject({
maxHealth: 1_838,
attackDamage: 65,
healthMultiplier: 2.02,
damageMultiplier: 2.02,
bonusLootChance: 1,
});
useCombatStore.getState().damageMob("storm-boss", 10_000, 30_000);
expect(useCombatStore.getState().mobs["storm-boss"].pendingLoot).toHaveLength(2);
});
});