1140 lines
51 KiB
TypeScript
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);
|
|
});
|
|
});
|