2080 lines
101 KiB
TypeScript
2080 lines
101 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,
|
|
resolvedAbilityCosts,
|
|
selectedMob,
|
|
useCombatStore,
|
|
} from "./combatStore";
|
|
import { TALENT_NODES, talentNodesForTree } from "./talentCatalog";
|
|
import { EMPTY_ITEM_STATS } from "./itemStats";
|
|
import { abilitiesForClass, abilityAtLevel, abilityTalentNodeId, defaultActionBarForClass } from "./abilityCatalog";
|
|
import { defaultActionBindings } from "./actionBindings";
|
|
import { deriveCharacterStats, MANA_POOL_MULTIPLIER } from "./wotlkStats";
|
|
import { applyAura as applyCombatAura } from "./combatAuras";
|
|
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
|
|
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
|
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
|
|
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
|
import { talentModifiersForRanks } from "./talentModifiers";
|
|
import {
|
|
ROGUE_DEADLY_POISON_DOSE_TAG,
|
|
ROGUE_ENVENOM_BUFF_TAG,
|
|
} from "./roguePoison";
|
|
|
|
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 unlockReadySpell(id: string): void {
|
|
const ability = useCombatStore.getState().abilities.find((entry) => entry.id === id);
|
|
expect(ability, id).toBeDefined();
|
|
const nodeId = abilityTalentNodeId(ability!);
|
|
if (nodeId) useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [nodeId]: 1 } }));
|
|
}
|
|
|
|
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 * MANA_POOL_MULTIPLIER);
|
|
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 - 17);
|
|
expect(useCombatStore.getState().activeCast).toBeNull();
|
|
expect(useCombatStore.getState().mobs.ravager.engaged).toBe(true);
|
|
});
|
|
|
|
it("uses the displayed primary mana and fixed rank cost for affordability", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
|
|
const initial = useCombatStore.getState();
|
|
const frostbolt = initial.abilities.find((ability) => ability.id === "mage-frostbolt");
|
|
expect(frostbolt).toBeDefined();
|
|
const [cost] = resolvedAbilityCosts(frostbolt!, initial.talentModifiers, initial);
|
|
expect(cost).toEqual({
|
|
resource: "mana",
|
|
amount: 17,
|
|
});
|
|
registerTarget("mana-target", [10, 0, 0]);
|
|
|
|
useCombatStore.setState({
|
|
resource: cost.amount,
|
|
resourcePools: { ...initial.resourcePools, mana: 0 },
|
|
});
|
|
expect(useCombatStore.getState().castAbility(frostbolt!.id, [0, 0, 0], 1_000)).toMatchObject({ ok: true });
|
|
useCombatStore.getState().cancelCast();
|
|
|
|
useCombatStore.setState({
|
|
resource: cost.amount - 1,
|
|
resourcePools: { ...initial.resourcePools, mana: initial.maxResource },
|
|
});
|
|
expect(useCombatStore.getState().castAbility(frostbolt!.id, [0, 0, 0], 3_000)).toMatchObject({
|
|
ok: false,
|
|
reason: "not-enough-resource",
|
|
});
|
|
});
|
|
|
|
it("does not increase a classic spell's mana cost until a new rank is learned", () => {
|
|
const pain = abilitiesForClass("priest").find((ability) => ability.id === "priest-shadow-word-pain");
|
|
expect(pain).toBeDefined();
|
|
|
|
const resolvedAtLevel = (level: number) => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level });
|
|
const state = useCombatStore.getState();
|
|
const ranked = abilityAtLevel(pain!, level);
|
|
return {
|
|
dbcSpellId: ranked.dbcSpellId,
|
|
maximumMana: state.maxResource,
|
|
cost: resolvedAbilityCosts(ranked, state.talentModifiers, state)[0],
|
|
};
|
|
};
|
|
|
|
const rankFourAt26 = resolvedAtLevel(26);
|
|
const rankFourAt32 = resolvedAtLevel(32);
|
|
const rankFiveAt34 = resolvedAtLevel(34);
|
|
|
|
expect(rankFourAt26).toMatchObject({ dbcSpellId: 992, cost: { resource: "mana", amount: 133 } });
|
|
expect(rankFourAt32).toMatchObject({ dbcSpellId: 992, cost: { resource: "mana", amount: 133 } });
|
|
expect(rankFourAt32.maximumMana).toBeGreaterThan(rankFourAt26.maximumMana);
|
|
expect(rankFiveAt34).toMatchObject({ dbcSpellId: 2767, cost: { resource: "mana", amount: 188 } });
|
|
});
|
|
|
|
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("marks an instant area resolution for its original source cast phase", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 2 });
|
|
registerTarget("instant-presentation-target", [3, 0, 0], 500, 0);
|
|
const events: CombatPresentationEvent[] = [];
|
|
const unsubscribe = subscribeCombatPresentation((message) => {
|
|
if (message.kind === "event" && message.event.abilityId === "mage-frost-nova") {
|
|
events.push(message.event);
|
|
}
|
|
});
|
|
|
|
expect(useCombatStore.getState().castAbility("mage-frost-nova", [0, 0, 0], 1_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().activeCast).toBeNull();
|
|
expect(events.map((event) => event.phase)).toEqual(["release", "impact"]);
|
|
expect(events.every((event) => event.occurredAt === 1_000)).toBe(true);
|
|
expect(events.every((event) => event.instantCast)).toBe(true);
|
|
expect(events.every((event) => event.durationMs === undefined)).toBe(true);
|
|
|
|
unsubscribe();
|
|
});
|
|
|
|
it("damages nearby enemies and heals nearby allies with one Holy Nova cast", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
|
registerTarget("nova-near", [3, 0, 0], 100_000, 0);
|
|
useCombatStore.getState().registerMob("nova-far", { name: "nova-far", maxHealth: 100_000, xpReward: 0 });
|
|
setMobPosition("nova-far", [15, 0, 0]);
|
|
const nearbyAlly = preparePartyMember(0, [3, 0, 0], 1);
|
|
const start = useCombatStore.getState();
|
|
useCombatStore.setState({ health: start.maxHealth - 1_000 });
|
|
const events: CombatPresentationEvent[] = [];
|
|
const unsubscribe = subscribeCombatPresentation((message) => {
|
|
if (message.kind === "event" && message.event.abilityId === "wow335-priest-holy-nova") {
|
|
events.push(message.event);
|
|
}
|
|
});
|
|
|
|
const result = useCombatStore.getState().castAbility("wow335-priest-holy-nova", [0, 0, 0], 1_000);
|
|
unsubscribe();
|
|
|
|
expect(result).toMatchObject({ ok: true, affectedMobIds: ["nova-near"] });
|
|
expect(result.damage).toBeGreaterThan(0);
|
|
expect(result.healing).toBeGreaterThan(0);
|
|
expect(useCombatStore.getState().mobs["nova-near"].health).toBeLessThan(100_000);
|
|
expect(useCombatStore.getState().mobs["nova-far"].health).toBe(100_000);
|
|
expect(useCombatStore.getState().health).toBeGreaterThan(start.maxHealth - 1_000);
|
|
expect(usePartyStore.getState().members.find((member) => member.id === nearbyAlly)?.health).toBeGreaterThan(1);
|
|
expect(events.map((event) => event.phase)).toEqual(["release", "impact"]);
|
|
});
|
|
|
|
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 / MANA_POOL_MULTIPLIER * 1.12) * MANA_POOL_MULTIPLIER,
|
|
maxResource: Math.round(chronomancerBase.mana / MANA_POOL_MULTIPLIER * 1.12) * MANA_POOL_MULTIPLIER,
|
|
});
|
|
|
|
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("gives a geared level-eight Priest the expanded live-game mana pool", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "priest",
|
|
specializationId: "priest:holy",
|
|
level: 8,
|
|
inventory: [],
|
|
equipment: {},
|
|
});
|
|
expect(useCombatStore.getState()).toMatchObject({
|
|
specializationId: "priest:holy",
|
|
gearRole: "healer",
|
|
resource: 3_285,
|
|
maxResource: 3_285,
|
|
});
|
|
});
|
|
|
|
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("shows learned classic talent actions and clears their bindings after a talent reset", () => {
|
|
const talentRanks = runtimePartyTalentRanksForLevel("druid", "Balance", 80);
|
|
const talentAction = abilitiesForClass("druid").find((ability) => ability.talentNodeId
|
|
&& talentRanks[ability.talentNodeId] && !ability.passive)!;
|
|
expect(talentAction).toBeDefined();
|
|
useCombatStore.getState().initializeCharacter({ classId: "druid", specializationId: "druid:balance", level: 80, talentRanks });
|
|
expect(useCombatStore.getState().setActionBinding("secondary", "r2", talentAction.id)).toBe(true);
|
|
expect(useCombatStore.getState().actionBindings["secondary:r2"]).toBe(talentAction.id);
|
|
useCombatStore.getState().resetTalents();
|
|
expect(useCombatStore.getState().actionBindings["secondary:r2"]).toBeNull();
|
|
expect(useCombatStore.getState().setActionBinding("secondary", "r2", talentAction.id)).toBe(false);
|
|
});
|
|
|
|
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("keeps Inner Focus as an on-use, one-heal effect", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
|
const talent = TALENT_NODES.find((node) => node.name === "Inner Focus")!;
|
|
const ranks = { [talent.id]: 1 };
|
|
useCombatStore.setState({
|
|
talentRanks: ranks,
|
|
talentModifiers: talentModifiersForRanks(ranks, "priest"),
|
|
});
|
|
const innerFocus = abilitiesForClass("priest").find((ability) => ability.name === "Inner Focus")!;
|
|
useCombatStore.getState().damagePlayer(500);
|
|
useCombatStore.setState((state) => ({
|
|
resource: 0,
|
|
resourcePools: { ...state.resourcePools, mana: 0 },
|
|
}));
|
|
|
|
expect(useCombatStore.getState().castAbility(innerFocus.id, undefined, 1_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().auras.some((aura) => (
|
|
aura.definition.utilityTags?.includes("next-heal-free-critical")
|
|
))).toBe(true);
|
|
|
|
const heal = useCombatStore.getState().castAbility("wow335-priest-lesser-heal", undefined, 3_000);
|
|
expect(heal.ok).toBe(true);
|
|
expect(useCombatStore.getState().activeCast?.originalDurationMs).toBeGreaterThan(0);
|
|
const completion = useCombatStore.getState().activeCast!.completesAt;
|
|
const spentAtBeforeCompletion = useCombatStore.getState().lastResourceSpentAt.mana;
|
|
useCombatStore.getState().tick(0.1, completion);
|
|
expect(useCombatStore.getState().lastResourceSpentAt.mana).toBe(spentAtBeforeCompletion);
|
|
expect(useCombatStore.getState().auras.some((aura) => (
|
|
aura.definition.utilityTags?.includes("next-heal-free-critical")
|
|
))).toBe(false);
|
|
useCombatStore.setState((state) => ({
|
|
resource: 0,
|
|
resourcePools: { ...state.resourcePools, mana: 0 },
|
|
}));
|
|
expect(useCombatStore.getState().castAbility("wow335-priest-lesser-heal", undefined, completion + 2_000))
|
|
.toMatchObject({ ok: false, reason: "not-enough-resource" });
|
|
});
|
|
|
|
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("drops or redirects companion threat and immediately retargets the enemy", () => {
|
|
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
|
|
const damage = usePartyStore.getState().members.find((member) => member.role === "damage")!;
|
|
registerPartyRuntimePosition(tank.id, [0, 0, 0]);
|
|
registerPartyRuntimePosition(damage.id, [0, 0, 0]);
|
|
useCombatStore.getState().registerMob("threat-effect-boss", {
|
|
name: "Threat effect boss", boss: true, maxHealth: 1_000, xpReward: 0,
|
|
});
|
|
setMobPosition("threat-effect-boss", [2, 0, 0]);
|
|
useCombatStore.getState().damageMob("threat-effect-boss", 10, 1_000,
|
|
{ actorId: tank.id, role: "tank" });
|
|
useCombatStore.getState().damageMob("threat-effect-boss", 100, 1_100,
|
|
{ actorId: damage.id, role: "damage" });
|
|
|
|
useCombatStore.getState().applyActorThreatEffect(damage.id,
|
|
{ kind: "threat", mode: "percent", amount: -100 }, null, 1_200);
|
|
expect(useCombatStore.getState().mobs["threat-effect-boss"]).toMatchObject({
|
|
targetActorId: tank.id,
|
|
threatByActor: { [damage.id]: 0 },
|
|
});
|
|
|
|
useCombatStore.getState().damageMob("threat-effect-boss", 80, 1_300,
|
|
{ actorId: damage.id, role: "damage" });
|
|
const tankThreat = useCombatStore.getState().mobs["threat-effect-boss"].threatByActor[tank.id];
|
|
useCombatStore.getState().applyActorThreatEffect(damage.id,
|
|
{ kind: "threat", mode: "redirect", amount: 100 }, tank.id, 1_400);
|
|
expect(useCombatStore.getState().mobs["threat-effect-boss"].threatByActor).toMatchObject({
|
|
[damage.id]: 0,
|
|
[tank.id]: tankThreat + 80,
|
|
});
|
|
});
|
|
|
|
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("grants companion Warriors Rage when enemy attacks land", () => {
|
|
const tank = usePartyStore.getState().members.find((member) => member.resourcePools.rage !== undefined)!;
|
|
usePartyStore.setState((state) => ({
|
|
members: state.members.map((member) => member.id === tank.id
|
|
? { ...member, resource: 0, resourcePools: { ...member.resourcePools, rage: 0 } }
|
|
: member),
|
|
}));
|
|
registerPartyRuntimePosition(tank.id, [0, 0, 0]);
|
|
useCombatStore.getState().registerMob("rage-attacker", {
|
|
name: "Rage attacker",
|
|
maxHealth: 200,
|
|
xpReward: 0,
|
|
});
|
|
setMobPosition("rage-attacker", [2, 0, 0]);
|
|
useCombatStore.getState().damageMob("rage-attacker", 5, 1_000, {
|
|
actorId: tank.id,
|
|
role: "tank",
|
|
});
|
|
|
|
expect(useCombatStore.getState().advanceMobCombat(1_450)).toBeGreaterThan(0);
|
|
expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.resourcePools.rage)
|
|
.toBe(7);
|
|
});
|
|
|
|
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("suppresses leveling equipment while granting boss cards 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).toEqual([]);
|
|
expect(corpse.bossCardDrop?.rarities).toContain("normal");
|
|
expect(useCombatStore.getState().bossCards.counts["wow:lady anacondra"]?.normal).toBe(1);
|
|
|
|
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(0);
|
|
expect(useCombatStore.getState().bossCards.counts["wow:lady anacondra"]?.normal).toBe(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().castAbility("rogue-sinister-strike", undefined, 1000).ok).toBe(true);
|
|
expect(useCombatStore.getState().comboPoints).toBe(1);
|
|
expect(useCombatStore.getState().castAbility("rogue-eviscerate", 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("prevents Death Pact from instantly killing an encounter boss", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80 });
|
|
registerTarget("pact-boss", [2, 0, 0], 1_000_000, 0);
|
|
useCombatStore.getState().registerMob("pact-boss", { name: "Pact boss", boss: true, maxHealth: 1_000_000, xpReward: 0 });
|
|
useCombatStore.setState({ resource: 100, resourcePools: { "runic-power": 100 } });
|
|
expect(useCombatStore.getState().castAbility("wow335-death-knight-death-pact", undefined, 1_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().mobs["pact-boss"]).toMatchObject({ dead: false, health: 1_000_000 });
|
|
});
|
|
|
|
it("prevents repeated Favor activations across branches of one Guardian hit", async () => {
|
|
const build = (await loadCanonicalBenchmarkBuilds(80, ["guardian"]))
|
|
.find((candidate) => candidate.role === "damage")!;
|
|
useCombatStore.getState().initializeCharacter({ classId: build.classId, level: build.level, talentRanks: build.talentRanks });
|
|
registerTarget("guardian-proc-dummy", [2, 0, 0], 1_000_000, 0);
|
|
expect(useCombatStore.getState().castAbility("coa-18-reprisal-69-800316", undefined, 1_000).ok).toBe(true);
|
|
const hits = useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage"
|
|
&& event.abilityId === "coa-triggered-712476");
|
|
expect(hits.length).toBeLessThanOrEqual(2);
|
|
});
|
|
|
|
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" }),
|
|
]));
|
|
const lightningBolt = abilityAtLevel(
|
|
abilitiesForClass("shaman").find((ability) => ability.id === "shaman-lightning-bolt")!,
|
|
80,
|
|
);
|
|
const expectedLightningBoltCost = Math.round(lightningBolt.cost.amount * 0.85);
|
|
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 - expectedLightningBoltCost);
|
|
});
|
|
|
|
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("builds, refreshes, and consumes source-specific Deadly Poison doses", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rogue",
|
|
level: 80,
|
|
specializationId: "rogue:assassination",
|
|
talentRanks: runtimePartyTalentRanksForLevel("rogue", "Assassination", 80),
|
|
});
|
|
useCombatStore.getState().synchronizeActors();
|
|
registerTarget("poison-target", [2, 0, 0], 1_000_000, 0);
|
|
for (const abilityId of ["rogue-instant-poison", "rogue-deadly-poison"]) {
|
|
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.id === abilityId)!;
|
|
const effect = ability.effects.find((candidate) => candidate.kind === "apply-aura")!;
|
|
expect(useCombatStore.getState().applyActorAura(
|
|
PLAYER_AURA_ENTITY_ID,
|
|
PLAYER_AURA_ENTITY_ID,
|
|
ability,
|
|
effect,
|
|
1_000,
|
|
)).toBe(true);
|
|
}
|
|
|
|
expect(useCombatStore.getState().triggerRogueWeaponPoisons(
|
|
PLAYER_AURA_ENTITY_ID,
|
|
"poison-target",
|
|
"wow335-rogue-shiv",
|
|
1,
|
|
1_001,
|
|
)).toMatchObject({ deadlyApplications: 1, deadlyStacks: 1 });
|
|
|
|
for (let hit = 0; hit < 200; hit += 1) {
|
|
useCombatStore.getState().triggerRogueWeaponPoisons(
|
|
PLAYER_AURA_ENTITY_ID,
|
|
"poison-target",
|
|
`test-hit-${hit}`,
|
|
1,
|
|
1_002 + hit,
|
|
);
|
|
}
|
|
const stacked = useCombatStore.getState();
|
|
expect(stacked.auras.find((aura) => (
|
|
aura.targetId === "poison-target"
|
|
&& aura.definition.utilityTags?.includes(ROGUE_DEADLY_POISON_DOSE_TAG)
|
|
))?.stacks).toBe(5);
|
|
expect(stacked.effects.find((effect) => (
|
|
effect.targetId === "poison-target" && effect.sourceAbilityId === "rogue-deadly-poison"
|
|
))).toMatchObject({ kind: "dot", remainingTicks: 4 });
|
|
expect(stacked.combatEvents.some((event) => (
|
|
event.abilityId === "rogue-instant-poison" && event.effectiveAmount > 0
|
|
))).toBe(true);
|
|
|
|
expect(useCombatStore.getState().consumeRogueDeadlyPoison(
|
|
PLAYER_AURA_ENTITY_ID,
|
|
"poison-target",
|
|
4,
|
|
2_000,
|
|
)).toBe(4);
|
|
expect(useCombatStore.getState().auras.find((aura) => (
|
|
aura.targetId === "poison-target"
|
|
&& aura.definition.utilityTags?.includes(ROGUE_DEADLY_POISON_DOSE_TAG)
|
|
))?.stacks).toBe(1);
|
|
expect(useCombatStore.getState().consumeRogueDeadlyPoison(
|
|
PLAYER_AURA_ENTITY_ID,
|
|
"poison-target",
|
|
5,
|
|
2_001,
|
|
)).toBe(1);
|
|
expect(useCombatStore.getState().effects.some((effect) => (
|
|
effect.targetId === "poison-target" && effect.sourceAbilityId === "rogue-deadly-poison"
|
|
))).toBe(false);
|
|
});
|
|
|
|
it("casts Envenom with dose-scaled Nature damage and a combo-scaled poison buff", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rogue",
|
|
level: 80,
|
|
specializationId: "rogue:assassination",
|
|
talentRanks: runtimePartyTalentRanksForLevel("rogue", "Assassination", 80),
|
|
});
|
|
useCombatStore.getState().synchronizeActors();
|
|
registerTarget("envenom-target", [2, 0, 0], 1_000_000, 0);
|
|
for (const abilityId of ["rogue-instant-poison", "rogue-deadly-poison"]) {
|
|
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.id === abilityId)!;
|
|
const effect = ability.effects.find((candidate) => candidate.kind === "apply-aura")!;
|
|
useCombatStore.getState().applyActorAura(
|
|
PLAYER_AURA_ENTITY_ID,
|
|
PLAYER_AURA_ENTITY_ID,
|
|
ability,
|
|
effect,
|
|
1_000,
|
|
);
|
|
}
|
|
for (let hit = 0; hit < 200; hit += 1) {
|
|
useCombatStore.getState().triggerRogueWeaponPoisons(
|
|
PLAYER_AURA_ENTITY_ID,
|
|
"envenom-target",
|
|
`envenom-setup-${hit}`,
|
|
1,
|
|
1_001 + hit,
|
|
);
|
|
}
|
|
useCombatStore.setState({ comboPoints: 5, comboTargetId: "envenom-target" });
|
|
const envenom = useCombatStore.getState().abilities.find((ability) => ability.name === "Envenom")!;
|
|
const result = useCombatStore.getState().castAbility(envenom.id, [0, 0, 0], 3_000);
|
|
|
|
expect(result).toMatchObject({ ok: true, abilityId: envenom.id });
|
|
expect(result.damage).toBeGreaterThan(0);
|
|
expect(useCombatStore.getState().auras.find((aura) => (
|
|
aura.targetId === "envenom-target"
|
|
&& aura.definition.utilityTags?.includes(ROGUE_DEADLY_POISON_DOSE_TAG)
|
|
))?.stacks).toBe(5);
|
|
expect(useCombatStore.getState().auras.find((aura) => (
|
|
aura.targetId === PLAYER_AURA_ENTITY_ID
|
|
&& aura.definition.utilityTags?.includes(ROGUE_ENVENOM_BUFF_TAG)
|
|
))?.expiresAt).toBe(9_000);
|
|
expect(useCombatStore.getState().combatEvents.some((event) => (
|
|
event.abilityId === envenom.id && event.school === "nature" && event.effectiveAmount > 0
|
|
))).toBe(true);
|
|
});
|
|
|
|
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(0);
|
|
expect(useCombatStore.getState().mobs["storm-boss"].bossCardDrop?.rarities).toContain("normal");
|
|
});
|
|
|
|
it("stacks Sunder through Devastate and grants Last Stand temporary maximum health", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 80 });
|
|
unlockReadySpell("wow335-warrior-devastate");
|
|
unlockReadySpell("wow335-warrior-last-stand");
|
|
useCombatStore.setState((state) => ({ resource: 100, resourcePools: { ...state.resourcePools, rage: 100 } }));
|
|
registerTarget("sunder-target", [2, 0, 0], 100_000, 0);
|
|
expect(useCombatStore.getState().castAbility("wow335-warrior-devastate", [0, 0, 0], 1_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().auras.find((aura) => aura.targetId === "sunder-target"
|
|
&& aura.definition.id === "wow335:warrior:sunder-armor")?.stacks).toBe(1);
|
|
expect(useCombatStore.getState().castAbility("wow335-warrior-devastate", [0, 0, 0], 3_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().auras.find((aura) => aura.targetId === "sunder-target"
|
|
&& aura.definition.id === "wow335:warrior:sunder-armor")?.stacks).toBe(2);
|
|
const before = useCombatStore.getState().maxHealth;
|
|
expect(useCombatStore.getState().castAbility("wow335-warrior-last-stand", [0, 0, 0], 5_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().maxHealth).toBeGreaterThan(before * 1.29);
|
|
useCombatStore.getState().tick(0, 24_999);
|
|
expect(useCombatStore.getState().maxHealth).toBeGreaterThan(before * 1.29);
|
|
useCombatStore.getState().tick(0, 26_000);
|
|
expect(useCombatStore.getState().maxHealth).toBe(before);
|
|
});
|
|
|
|
it("requires and consumes Enrage for Enraged Regeneration", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 80 });
|
|
unlockReadySpell("wow335-warrior-enraged-regeneration");
|
|
useCombatStore.setState((state) => ({ resource: 100, resourcePools: { ...state.resourcePools, rage: 100 } }));
|
|
expect(useCombatStore.getState().castAbility("wow335-warrior-enraged-regeneration", [0, 0, 0], 1_000))
|
|
.toMatchObject({ ok: false, reason: "condition-not-met" });
|
|
const enrage = applyCombatAura(useCombatStore.getState().auras, {
|
|
definition: { id: "test:enrage", name: "Enrage", disposition: "buff", dispelCategory: "enrage", durationMs: 12_000 },
|
|
sourceId: PLAYER_AURA_ENTITY_ID,
|
|
targetId: PLAYER_AURA_ENTITY_ID,
|
|
now: 1_000,
|
|
});
|
|
useCombatStore.setState({ auras: enrage.auras });
|
|
expect(useCombatStore.getState().castAbility("wow335-warrior-enraged-regeneration", [0, 0, 0], 2_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "test:enrage")).toBe(false);
|
|
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "hot"
|
|
&& effect.sourceAbilityId === "wow335-warrior-enraged-regeneration")).toBe(true);
|
|
});
|
|
|
|
it("prevents recasting Power Word: Shield until Weakened Soul expires", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
|
expect(useCombatStore.getState().castAbility("wow335-priest-power-word-shield", [0, 0, 0], 1_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().auras.find((aura) => aura.definition.id === "wow335:priest:weakened-soul")?.expiresAt)
|
|
.toBe(31_000);
|
|
expect(useCombatStore.getState().castAbility("wow335-priest-power-word-shield", [0, 0, 0], 10_000))
|
|
.toMatchObject({ ok: false, reason: "condition-not-met" });
|
|
useCombatStore.getState().tick(0, 31_000);
|
|
expect(useCombatStore.getState().castAbility("wow335-priest-power-word-shield", [0, 0, 0], 31_000).ok).toBe(true);
|
|
});
|
|
|
|
it("grants Inner Fire armor for 20 landed physical hits", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
|
expect(useCombatStore.getState().castAbility("wow335-priest-inner-fire", [0, 0, 0], 1_000).ok).toBe(true);
|
|
const charges = () => useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
|
&& aura.definition.name.startsWith("Inner Fire (DBC ") && aura.definition.modifiers?.some((modifier) => (
|
|
modifier.kind === "stat" && modifier.stat === "armor"
|
|
)))?.stacks;
|
|
expect(charges()).toBe(20);
|
|
registerTarget("inner-fire-attacker", [2, 0, 0], 100_000, 0);
|
|
useCombatStore.getState().damageMob("inner-fire-attacker", 1, 1_000);
|
|
for (let now = 1_450; now < 15_000 && charges() === 20; now += 1_900) {
|
|
useCombatStore.getState().advanceMobCombat(now);
|
|
}
|
|
expect(charges()).toBe(19);
|
|
expect(useCombatStore.getState().lastCombatResult?.effectiveArmor).toBeGreaterThan(2_000);
|
|
});
|
|
|
|
it("makes Evasion reduce incoming ranged hit chance", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 80 });
|
|
expect(useCombatStore.getState().castAbility("wow335-rogue-evasion", [0, 0, 0], 1_000).ok).toBe(true);
|
|
useCombatStore.getState().registerMob("evasion-attacker", {
|
|
name: "Evasion attacker", level: 80, maxHealth: 100_000, xpReward: 0,
|
|
attacks: [{ ...DEFAULT_ENEMY_ATTACK, id: "evasion-arrow", delivery: "projectile", range: 30 }],
|
|
});
|
|
setMobPosition("evasion-attacker", [8, 0, 0]);
|
|
useCombatStore.getState().damageMob("evasion-attacker", 1, 1_000);
|
|
useCombatStore.getState().advanceMobCombat(1_450);
|
|
expect(useCombatStore.getState().lastCombatResult?.attackTable.missPercent).toBe(30);
|
|
});
|
|
|
|
it("summons one Shadowfiend and restores mana only after its attacks", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
|
registerTarget("fiend-target", [3, 0, 0], 100_000, 0);
|
|
useCombatStore.setState((state) => ({
|
|
resource: Math.round(state.maxResource / 2),
|
|
resourcePools: { ...state.resourcePools, mana: Math.round(state.maxResource / 2) },
|
|
}));
|
|
expect(useCombatStore.getState().castAbility("wow335-priest-shadowfiend", [0, 0, 0], 1_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().summons.filter((summon) => summon.sourceAbilityId === "wow335-priest-shadowfiend"))
|
|
.toHaveLength(1);
|
|
const manaBeforeAttack = useCombatStore.getState().resource;
|
|
useCombatStore.getState().tick(1, 2_000);
|
|
expect(useCombatStore.getState().mobs["fiend-target"].health).toBeLessThan(100_000);
|
|
expect(useCombatStore.getState().resource).toBeGreaterThanOrEqual(manaBeforeAttack + Math.floor(useCombatStore.getState().maxResource * 0.01));
|
|
});
|
|
|
|
it("casts Kill Shot only below 20 percent health and resolves one direct hit", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80 });
|
|
registerTarget("kill-shot-target", [10, 0, 0], 100_000, 0);
|
|
expect(useCombatStore.getState().castAbility("wow335-hunter-kill-shot", [0, 0, 0], 1_000))
|
|
.toMatchObject({ ok: false, reason: "condition-not-met" });
|
|
useCombatStore.setState((state) => ({ mobs: {
|
|
...state.mobs,
|
|
"kill-shot-target": { ...state.mobs["kill-shot-target"], health: 20_000 },
|
|
} }));
|
|
expect(useCombatStore.getState().castAbility("wow335-hunter-kill-shot", [0, 0, 0], 3_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage"
|
|
&& event.abilityId === "wow335-hunter-kill-shot")).toHaveLength(1);
|
|
});
|
|
|
|
it("summons three Mirror Images and gives Combustion Fire hit stacks", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
|
unlockReadySpell("wow335-mage-combustion");
|
|
registerTarget("fire-target", [8, 0, 0], 100_000, 0);
|
|
expect(useCombatStore.getState().castAbility("wow335-mage-mirror-image", [0, 0, 0], 1_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().summons.filter((summon) => summon.sourceAbilityId === "wow335-mage-mirror-image"))
|
|
.toHaveLength(3);
|
|
expect(useCombatStore.getState().castAbility("wow335-mage-combustion", [0, 0, 0], 3_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().mobs["fire-target"].health).toBe(100_000);
|
|
const combustionId = "wow335:mage:combustion";
|
|
expect(useCombatStore.getState().auras.find((aura) => aura.definition.id === combustionId)?.stacks).toBe(1);
|
|
const fireBlast = useCombatStore.getState().abilities.find((ability) => ability.name === "Fire Blast")!;
|
|
expect(useCombatStore.getState().castAbility(fireBlast.id, [0, 0, 0], 5_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().auras.find((aura) => aura.definition.id === combustionId)?.stacks).toBe(2);
|
|
useCombatStore.setState((state) => ({ auras: state.auras.map((aura) => aura.definition.id === combustionId
|
|
? { ...aura, stacks: 100, combustionCriticalHits: 2 }
|
|
: aura) }));
|
|
expect(useCombatStore.getState().castAbility(fireBlast.id, [0, 0, 0], 14_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === combustionId)).toBe(false);
|
|
});
|
|
|
|
it("scales Rupture duration and ticks with combo points", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 80 });
|
|
registerTarget("rupture-target", [2, 0, 0], 100_000, 0);
|
|
useCombatStore.setState({ comboPoints: 1, comboTargetId: "rupture-target" });
|
|
expect(useCombatStore.getState().castAbility("wow335-rogue-rupture", [0, 0, 0], 1_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().effects.find((effect) => effect.sourceAbilityId === "wow335-rogue-rupture")?.remainingTicks)
|
|
.toBe(4);
|
|
const firstAura = useCombatStore.getState().auras.find((aura) => aura.targetId === "rupture-target"
|
|
&& aura.definition.name.startsWith("Rupture"));
|
|
expect(firstAura?.expiresAt).toBe(9_000);
|
|
useCombatStore.setState({ comboPoints: 5, comboTargetId: "rupture-target", resource: 100 });
|
|
expect(useCombatStore.getState().castAbility("wow335-rogue-rupture", [0, 0, 0], 3_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().effects.find((effect) => effect.sourceAbilityId === "wow335-rogue-rupture")?.remainingTicks)
|
|
.toBe(8);
|
|
expect(useCombatStore.getState().auras.find((aura) => aura.targetId === "rupture-target"
|
|
&& aura.definition.name.startsWith("Rupture"))?.expiresAt).toBe(19_000);
|
|
});
|
|
|
|
it("lets Penance damage an enemy and continue pulsing", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
|
unlockReadySpell("wow335-priest-penance");
|
|
registerTarget("penance-target", [8, 0, 0], 100_000, 0);
|
|
expect(useCombatStore.getState().castAbility("wow335-priest-penance", [0, 0, 0], 1_000).ok).toBe(true);
|
|
const first = useCombatStore.getState().mobs["penance-target"].health;
|
|
expect(first).toBeLessThan(100_000);
|
|
useCombatStore.getState().tick(0, 2_000);
|
|
expect(useCombatStore.getState().mobs["penance-target"].health).toBeLessThan(first);
|
|
useCombatStore.getState().tick(0, 3_000);
|
|
expect(useCombatStore.getState().effects.some((effect) => effect.sourceAbilityId === "wow335-priest-penance")).toBe(false);
|
|
});
|
|
|
|
it("lets Penance heal a friendly target immediately and twice afterward", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
|
unlockReadySpell("wow335-priest-penance");
|
|
usePartyStore.getState().selectSelf();
|
|
useCombatStore.getState().damagePlayer(2_000);
|
|
const before = useCombatStore.getState().health;
|
|
expect(useCombatStore.getState().castAbility("wow335-priest-penance", [0, 0, 0], 1_000).ok).toBe(true);
|
|
const first = useCombatStore.getState().health;
|
|
expect(first).toBeGreaterThan(before);
|
|
useCombatStore.getState().tick(0, 2_000);
|
|
expect(useCombatStore.getState().health).toBeGreaterThan(first);
|
|
useCombatStore.getState().tick(0, 3_000);
|
|
expect(useCombatStore.getState().effects.some((effect) => effect.sourceAbilityId === "wow335-priest-penance")).toBe(false);
|
|
});
|
|
|
|
it("selects only the three lowest-health nearby Divine Hymn targets per two-second pulse", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
|
usePartyStore.getState().initializeParty("hymn-cap-test", 80, useCombatStore.getState().health);
|
|
const ids = [0, 1, 2, 3].map((index) => preparePartyMember(index,
|
|
[4 + index * 2, 0, 0], index + 1));
|
|
const before = usePartyStore.getState().members
|
|
.filter((member) => ids.includes(member.id))
|
|
.map((member) => ({ id: member.id, health: member.health, fraction: member.health / member.maxHealth }))
|
|
.sort((left, right) => left.fraction - right.fraction || left.id.localeCompare(right.id));
|
|
expect(useCombatStore.getState().castAbility("wow335-priest-divine-hymn", [0, 0, 0], 1_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().activeCast?.totalTicks).toBe(4);
|
|
useCombatStore.getState().tick(0, 3_000);
|
|
const members = usePartyStore.getState().members;
|
|
expect(before.slice(0, 3).every((entry) => members.find((member) => member.id === entry.id)!.health > entry.health)).toBe(true);
|
|
expect(members.find((member) => member.id === before[3].id)!.health).toBe(before[3].health);
|
|
});
|
|
|
|
it("refreshes Serpent Sting and triggers its Chimera Shot damage", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80 });
|
|
unlockReadySpell("wow335-hunter-chimera-shot");
|
|
registerTarget("sting-target", [10, 0, 0], 100_000, 0);
|
|
const stingId = useCombatStore.getState().abilities.find((ability) => ability.name === "Serpent Sting")!.id;
|
|
expect(useCombatStore.getState().castAbility(stingId, [0, 0, 0], 1_000).ok).toBe(true);
|
|
const dotBefore = useCombatStore.getState().effects.find((effect) => effect.sourceAbilityId === stingId);
|
|
expect(dotBefore).toBeDefined();
|
|
expect(useCombatStore.getState().castAbility("wow335-hunter-chimera-shot", [0, 0, 0], 3_000).ok).toBe(true);
|
|
const dotAfter = useCombatStore.getState().effects.find((effect) => effect.sourceAbilityId === stingId);
|
|
expect(dotAfter?.nextTickAt).toBe(3_000 + dotBefore!.intervalMs);
|
|
expect(dotAfter?.remainingTicks).toBeGreaterThanOrEqual(dotBefore!.remainingTicks);
|
|
expect(useCombatStore.getState().combatEvents.filter((event) => event.abilityId === "wow335-hunter-chimera-shot"
|
|
&& event.kind === "damage")).toHaveLength(2);
|
|
});
|
|
|
|
it("restores mana from Viper Sting and limits Scorpid Sting disarm to once per minute", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80 });
|
|
registerTarget("sting-utility-target", [10, 0, 0], 100_000, 0);
|
|
const viperId = useCombatStore.getState().abilities.find((ability) => ability.name === "Viper Sting")!.id;
|
|
expect(useCombatStore.getState().castAbility(viperId, [0, 0, 0], 1_000).ok).toBe(true);
|
|
useCombatStore.setState((state) => ({
|
|
resource: state.maxResource / 2,
|
|
resourcePools: { ...state.resourcePools, mana: state.maxResource / 2 },
|
|
}));
|
|
const manaBefore = useCombatStore.getState().resource;
|
|
useCombatStore.getState().triggerChimeraShot(PLAYER_AURA_ENTITY_ID, "sting-utility-target", "wow335-hunter-chimera-shot", 2_000);
|
|
expect(useCombatStore.getState().resource).toBeGreaterThan(manaBefore);
|
|
|
|
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80 });
|
|
registerTarget("scorpid-target", [10, 0, 0], 100_000, 0);
|
|
const scorpidId = useCombatStore.getState().abilities.find((ability) => ability.name === "Scorpid Sting")!.id;
|
|
expect(useCombatStore.getState().castAbility(scorpidId, [0, 0, 0], 1_000).ok).toBe(true);
|
|
useCombatStore.getState().triggerChimeraShot(PLAYER_AURA_ENTITY_ID, "scorpid-target", "wow335-hunter-chimera-shot", 2_000);
|
|
const disarm = useCombatStore.getState().mobs["scorpid-target"].statuses.find((status) => status.kind === "disarm");
|
|
expect(disarm?.endsAt).toBe(12_000);
|
|
useCombatStore.getState().triggerChimeraShot(PLAYER_AURA_ENTITY_ID, "scorpid-target", "wow335-hunter-chimera-shot", 3_000);
|
|
expect(useCombatStore.getState().mobs["scorpid-target"].statuses.find((status) => status.kind === "disarm")?.endsAt).toBe(12_000);
|
|
});
|
|
|
|
it("explodes Living Bomb onto nearby enemies when its last tick completes", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
|
unlockReadySpell("wow335-mage-living-bomb");
|
|
registerTarget("bomb-target", [5, 0, 0], 100_000, 0);
|
|
useCombatStore.getState().registerMob("bomb-neighbor", { name: "bomb-neighbor", maxHealth: 100_000, xpReward: 0 });
|
|
setMobPosition("bomb-neighbor", [7, 0, 0]);
|
|
expect(useCombatStore.getState().castAbility("wow335-mage-living-bomb", [0, 0, 0], 1_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().mobs["bomb-neighbor"].health).toBe(100_000);
|
|
useCombatStore.getState().tick(0, 13_000);
|
|
expect(useCombatStore.getState().mobs["bomb-neighbor"].health).toBeLessThan(100_000);
|
|
});
|
|
|
|
it("detonates Living Bomb once when its magic aura is dispelled", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
|
unlockReadySpell("wow335-mage-living-bomb");
|
|
registerTarget("dispel-bomb", [5, 0, 0], 100_000, 0);
|
|
useCombatStore.getState().registerMob("dispel-neighbor", { name: "dispel-neighbor", maxHealth: 100_000, xpReward: 0 });
|
|
setMobPosition("dispel-neighbor", [7, 0, 0]);
|
|
expect(useCombatStore.getState().castAbility("wow335-mage-living-bomb", [0, 0, 0], 1_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().dispelActor("dispel-bomb", ["magic"], 1, 2_000)).toBe(1);
|
|
expect(useCombatStore.getState().mobs["dispel-neighbor"].health).toBeLessThan(100_000);
|
|
expect(useCombatStore.getState().effects.some((effect) => effect.targetId === "dispel-bomb"
|
|
&& effect.sourceAbilityId === "wow335-mage-living-bomb")).toBe(false);
|
|
});
|
|
|
|
it("casts Molten Armor on self without initial damage", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
|
registerTarget("armor-target", [8, 0, 0], 100_000, 0);
|
|
expect(useCombatStore.getState().castAbility("wow335-mage-molten-armor", [0, 0, 0], 1_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().mobs["armor-target"].health).toBe(100_000);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
|
|
&& aura.definition.name.startsWith("Molten Armor"))).toBe(true);
|
|
});
|
|
|
|
it("applies Molten Armor's Spirit crit conversion and incoming crit reduction", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
|
registerTarget("molten-crit-target", [8, 0, 0], 100_000, 0);
|
|
const fireBlast = useCombatStore.getState().abilities.find((ability) => ability.name === "Fire Blast")!;
|
|
expect(useCombatStore.getState().castAbility(fireBlast.id, [0, 0, 0], 3_000).ok).toBe(true);
|
|
const baseCrit = useCombatStore.getState().lastCombatResult!.attackTable.criticalPercent;
|
|
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
|
registerTarget("molten-crit-target", [8, 0, 0], 100_000, 0);
|
|
expect(useCombatStore.getState().castAbility("wow335-mage-molten-armor", [0, 0, 0], 1_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().castAbility(fireBlast.id, [0, 0, 0], 3_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().lastCombatResult!.attackTable.criticalPercent).toBeGreaterThan(baseCrit);
|
|
|
|
useCombatStore.getState().registerMob("molten-attacker", {
|
|
name: "Molten attacker", level: 80, maxHealth: 100_000, xpReward: 0,
|
|
attacks: [{ ...DEFAULT_ENEMY_ATTACK, id: "molten-arrow", delivery: "projectile", range: 30 }],
|
|
});
|
|
setMobPosition("molten-attacker", [8, 0, 0]);
|
|
useCombatStore.getState().damageMob("molten-attacker", 1, 3_000);
|
|
useCombatStore.getState().advanceMobCombat(3_450);
|
|
expect(useCombatStore.getState().lastCombatResult?.attackTable.criticalPercent).toBe(0);
|
|
});
|
|
|
|
it("awards Shiv's combo point and consumes Cold Blood on the next strike", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 80 });
|
|
unlockReadySpell("wow335-rogue-cold-blood");
|
|
registerTarget("rogue-target", [2, 0, 0], 100_000, 0);
|
|
expect(useCombatStore.getState().castAbility("wow335-rogue-shiv", [0, 0, 0], 1_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().comboPoints).toBe(1);
|
|
expect(useCombatStore.getState().castAbility("wow335-rogue-cold-blood", [0, 0, 0], 3_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("next-offensive-critical"))).toBe(true);
|
|
expect(useCombatStore.getState().castAbility("rogue-sinister-strike", [0, 0, 0], 5_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("next-offensive-critical"))).toBe(false);
|
|
expect(useCombatStore.getState().lastCombatResult?.outcome).toBe("crit");
|
|
});
|
|
});
|