forked from phenom/healer-man
1390 lines
63 KiB
TypeScript
1390 lines
63 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,
|
|
PLAYER_AURA_ENTITY_ID,
|
|
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";
|
|
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
|
|
|
|
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 - Math.round(startingResource * 0.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("identifies the player as the target of a self-cast aura presentation", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
|
const events: CombatPresentationEvent[] = [];
|
|
const unsubscribe = subscribeCombatPresentation((message) => {
|
|
if (message.kind === "event") events.push(message.event);
|
|
});
|
|
|
|
expect(useCombatStore.getState().castAbility(
|
|
"wow335-priest-power-word-shield",
|
|
undefined,
|
|
1_000,
|
|
).ok).toBe(true);
|
|
expect(events.find((event) => (
|
|
event.abilityId === "wow335-priest-power-word-shield" && event.phase === "aura-start"
|
|
))?.targetActorId).toBe(PLAYER_AURA_ENTITY_ID);
|
|
|
|
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("requires an explicit resurrection action to restore a defeated player", () => {
|
|
const maxHealth = useCombatStore.getState().maxHealth;
|
|
useCombatStore.getState().damagePlayer(maxHealth * 10);
|
|
|
|
expect(useCombatStore.getState().health).toBe(0);
|
|
expect(useCombatStore.getState().healPlayer(maxHealth)).toBe(0);
|
|
expect(useCombatStore.getState().health).toBe(0);
|
|
|
|
const restored = useCombatStore.getState().revivePlayer(0.35);
|
|
expect(restored).toBe(Math.max(1, Math.round(maxHealth * 0.35)));
|
|
expect(useCombatStore.getState().health).toBe(restored);
|
|
expect(useCombatStore.getState().revivePlayer(1)).toBe(0);
|
|
});
|
|
|
|
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 });
|
|
expect(useCombatStore.getState().settings.masterVolume).toBe(0);
|
|
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().updateSettings({
|
|
gameplayFrameRate: 30,
|
|
previewFrameRate: 15,
|
|
});
|
|
expect(useCombatStore.getState().settings).toMatchObject({
|
|
gameplayFrameRate: 30,
|
|
previewFrameRate: 15,
|
|
});
|
|
useCombatStore.getState().initializeCharacter({ classId: "priest" });
|
|
expect(useCombatStore.getState().settings.showThreatMeter).toBe(false);
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "priest",
|
|
settings: { masterVolume: 0.6 },
|
|
});
|
|
expect(useCombatStore.getState().settings.masterVolume).toBe(0.6);
|
|
useCombatStore.getState().resetSettings();
|
|
expect(useCombatStore.getState().settings).toEqual(DEFAULT_GAMEPLAY_SETTINGS);
|
|
});
|
|
|
|
it("does not notify subscribers for unchanged positions or settings", () => {
|
|
let notifications = 0;
|
|
const unsubscribe = useCombatStore.subscribe(() => {
|
|
notifications += 1;
|
|
});
|
|
|
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
|
useCombatStore.getState().updateSettings({
|
|
masterVolume: DEFAULT_GAMEPLAY_SETTINGS.masterVolume,
|
|
gameplayFrameRate: DEFAULT_GAMEPLAY_SETTINGS.gameplayFrameRate,
|
|
});
|
|
useCombatStore.getState().resetSettings();
|
|
expect(notifications).toBe(0);
|
|
|
|
useCombatStore.getState().updateSettings({ gameplayFrameRate: 30 });
|
|
expect(notifications).toBe(1);
|
|
unsubscribe();
|
|
});
|
|
|
|
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("breaks recursive hit-proc spell cycles in the canonical Felsworn tank build", async () => {
|
|
const build = (await loadCanonicalBenchmarkBuilds(60, ["felsworn"]))
|
|
.find((candidate) => candidate.role === "tank")!;
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: build.classId,
|
|
level: build.level,
|
|
talentRanks: build.talentRanks,
|
|
});
|
|
registerTarget("felsworn-proc-dummy", [10, 0, 0], 1_000_000, 0);
|
|
|
|
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
|
|
definition: expect.objectContaining({ name: "Doomstride (Aura 42)" }),
|
|
}));
|
|
const result = useCombatStore.getState().castAbility(
|
|
"felsworn-sargeron-smite",
|
|
[0, 0, 0],
|
|
1,
|
|
);
|
|
const state = useCombatStore.getState();
|
|
const damage = 1_000_000 - state.mobs["felsworn-proc-dummy"].health;
|
|
const damageAbilityIds = state.combatEvents
|
|
.filter((event) => event.kind === "damage")
|
|
.map((event) => event.abilityId);
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(damage).toBeGreaterThan(0);
|
|
expect(damage).toBeLessThan(100_000);
|
|
expect(damageAbilityIds.filter((abilityId) => abilityId === "coa-triggered-807962"))
|
|
.toHaveLength(1);
|
|
expect(state.auras).toContainEqual(expect.objectContaining({
|
|
definition: expect.objectContaining({ name: "Felwracked (Aura 4)" }),
|
|
}));
|
|
});
|
|
|
|
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 - Math.round(startingMana * 0.1 * 0.85));
|
|
});
|
|
|
|
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("fully heals the selected ally with Lay on Hands and starts its cooldown", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 10 });
|
|
usePartyStore.getState().initializeParty("lay-on-hands-test", 10, useCombatStore.getState().health);
|
|
const memberId = preparePartyMember(0, [10, 0, 0], 1, true);
|
|
const memberBefore = usePartyStore.getState().members.find((member) => member.id === memberId)!;
|
|
|
|
expect(useCombatStore.getState().castAbility(
|
|
"wow335-paladin-lay-on-hands",
|
|
[0, 0, 0],
|
|
1_000,
|
|
)).toMatchObject({
|
|
ok: true,
|
|
healing: memberBefore.maxHealth - 1,
|
|
});
|
|
expect(usePartyStore.getState().members.find((member) => member.id === memberId)?.health)
|
|
.toBe(memberBefore.maxHealth);
|
|
expect(useCombatStore.getState().cooldowns["wow335-paladin-lay-on-hands"])
|
|
.toBe(1_201_000);
|
|
expect(useCombatStore.getState().castAbility(
|
|
"wow335-paladin-lay-on-hands",
|
|
[0, 0, 0],
|
|
2_500,
|
|
)).toMatchObject({ ok: false, reason: "cooldown" });
|
|
});
|
|
|
|
it("makes the selected ally immune to physical attacks with Hand of Protection", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 10 });
|
|
usePartyStore.getState().initializeParty("hand-of-protection-test", 10, useCombatStore.getState().health);
|
|
const memberId = preparePartyMember(0, [0, 0, 0], 1, true);
|
|
const memberBefore = usePartyStore.getState().members.find((member) => member.id === memberId)!;
|
|
|
|
expect(useCombatStore.getState().castAbility(
|
|
"wow335-paladin-hand-of-protection",
|
|
[0, 0, 0],
|
|
1_000,
|
|
)).toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
|
|
targetId: memberId,
|
|
expiresAt: 7_000,
|
|
definition: expect.objectContaining({ name: expect.stringContaining("Hand of Protection") }),
|
|
}));
|
|
expect(useCombatStore.getState().cooldowns["wow335-paladin-hand-of-protection"])
|
|
.toBe(301_000);
|
|
|
|
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: memberId,
|
|
role: "tank",
|
|
});
|
|
|
|
expect(useCombatStore.getState().advanceMobCombat(1_450)).toBe(0);
|
|
expect(usePartyStore.getState().members.find((member) => member.id === memberId)?.health)
|
|
.toBe(memberBefore.health);
|
|
});
|
|
|
|
it("clears roots with Hand of Freedom and replaces mutually exclusive seals", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
|
|
usePartyStore.getState().initializeParty("paladin-aura-test", 80, useCombatStore.getState().health);
|
|
const memberId = preparePartyMember(0, [0, 0, 0], Number.MAX_SAFE_INTEGER, true);
|
|
usePartyStore.getState().controlMember(memberId, "root", 30_000);
|
|
|
|
expect(useCombatStore.getState().castAbility("wow335-paladin-hand-of-freedom", [0, 0, 0], 1_000).ok).toBe(true);
|
|
expect(usePartyStore.getState().members.find((member) => member.id === memberId)).toMatchObject({
|
|
controlledUntil: 0,
|
|
controlMechanic: null,
|
|
});
|
|
|
|
usePartyStore.getState().selectSelf();
|
|
expect(useCombatStore.getState().castAbility("wow335-paladin-seal-of-righteousness", [0, 0, 0], 3_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().castAbility("wow335-paladin-seal-of-wisdom", [0, 0, 0], 5_000).ok).toBe(true);
|
|
const seals = useCombatStore.getState().auras.filter((aura) => (
|
|
aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.exclusiveGroup === "paladin:seal"
|
|
));
|
|
expect(seals).toHaveLength(1);
|
|
expect(seals[0].definition.name).toContain("Seal of Wisdom");
|
|
});
|
|
|
|
it("redirects Hand of Sacrifice damage to the paladin", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
|
|
usePartyStore.getState().initializeParty("hand-of-sacrifice-test", 80, useCombatStore.getState().health);
|
|
const memberId = preparePartyMember(0, [0, 0, 0], Number.MAX_SAFE_INTEGER, true);
|
|
const memberBefore = usePartyStore.getState().members.find((member) => member.id === memberId)!;
|
|
const playerBefore = useCombatStore.getState().health;
|
|
|
|
expect(useCombatStore.getState().castAbility("wow335-paladin-hand-of-sacrifice", [0, 0, 0], 1_000).ok).toBe(true);
|
|
useCombatStore.getState().registerMob("sacrifice-attacker", { name: "Sacrifice attacker", maxHealth: 200, xpReward: 0 });
|
|
setMobPosition("sacrifice-attacker", [2, 0, 0]);
|
|
useCombatStore.getState().damageMob("sacrifice-attacker", 5, 1_000, { actorId: memberId, role: "tank" });
|
|
expect(useCombatStore.getState().advanceMobCombat(1_450)).toBeGreaterThan(0);
|
|
|
|
const memberLoss = memberBefore.health - usePartyStore.getState().members.find((member) => member.id === memberId)!.health;
|
|
const playerLoss = playerBefore - useCombatStore.getState().health;
|
|
expect(memberLoss).toBeGreaterThan(0);
|
|
expect(playerLoss).toBeGreaterThan(0);
|
|
expect(playerLoss).toBeLessThan(memberLoss);
|
|
});
|
|
|
|
it("taunts up to three attackers off an ally with Righteous Defense", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
|
|
usePartyStore.getState().initializeParty("righteous-defense-test", 80, useCombatStore.getState().health);
|
|
const memberId = preparePartyMember(0, [0, 0, 0], Number.MAX_SAFE_INTEGER, true);
|
|
for (const id of ["attacker-a", "attacker-b", "attacker-c", "attacker-d"]) {
|
|
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100, xpReward: 0 });
|
|
setMobPosition(id, [2, 0, 0]);
|
|
}
|
|
useCombatStore.setState((state) => ({
|
|
mobs: Object.fromEntries(Object.entries(state.mobs).map(([id, mob]) => [id, {
|
|
...mob,
|
|
engaged: true,
|
|
targetActorId: memberId,
|
|
threatByActor: { [memberId]: 10 },
|
|
}])),
|
|
}));
|
|
|
|
expect(useCombatStore.getState().castAbility("wow335-paladin-righteous-defense", [0, 0, 0], 1_000).ok).toBe(true);
|
|
expect(Object.values(useCombatStore.getState().mobs).filter((mob) => mob.forcedTarget?.actorId === PLAYER_AURA_ENTITY_ID)).toHaveLength(3);
|
|
});
|
|
|
|
it("channels party-wide classic healing without requiring an enemy target", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
|
usePartyStore.getState().initializeParty("divine-hymn-test", 80, useCombatStore.getState().health);
|
|
const memberId = preparePartyMember(0, [4, 0, 0], 1);
|
|
useCombatStore.getState().damagePlayer(500);
|
|
const playerBefore = useCombatStore.getState().health;
|
|
|
|
expect(useCombatStore.getState().castAbility("wow335-priest-divine-hymn", [0, 0, 0], 1_000).ok).toBe(true);
|
|
const completion = useCombatStore.getState().activeCast?.completesAt;
|
|
if (completion) useCombatStore.getState().tick(1, completion);
|
|
expect(useCombatStore.getState().activeCast).toBeNull();
|
|
expect(useCombatStore.getState().health).toBeGreaterThan(playerBefore);
|
|
expect(usePartyStore.getState().members.find((member) => member.id === memberId)!.health).toBeGreaterThan(1);
|
|
});
|
|
|
|
it("executes intentional classic utility spells instead of rejecting them", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
|
useCombatStore.getState().damagePlayer(500);
|
|
const before = useCombatStore.getState().health;
|
|
expect(useCombatStore.getState().castAbility("wow335-mage-conjure-food", [0, 0, 0], 1_000).ok).toBe(true);
|
|
const completion = useCombatStore.getState().activeCast?.completesAt;
|
|
if (completion) useCombatStore.getState().tick(1, completion);
|
|
expect(useCombatStore.getState().health).toBeGreaterThan(before);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|