2826 lines
138 KiB
TypeScript
2826 lines
138 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
|
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 { armorReductionPercent, 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 { wow335HunterPetArmorForLevel } from "./wow335HunterPetInheritedDefenseRuntime";
|
|
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
|
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
|
|
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
|
import { talentModifiersForRanks } from "./talentModifiers";
|
|
import type { InventoryItem } from "./lootTypes";
|
|
import { resolvedEquipmentStats } from "./heirloomEquipment";
|
|
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 stableUnit(seed: string): number {
|
|
let hash = 2166136261;
|
|
for (const character of seed) {
|
|
hash ^= character.charCodeAt(0);
|
|
hash = Math.imul(hash, 16777619);
|
|
}
|
|
return (hash >>> 0) / 0x1_0000_0000;
|
|
}
|
|
|
|
function categoryGearFixture(
|
|
instanceId: string,
|
|
contentCategory: InventoryItem["contentCategory"],
|
|
romNative?: InventoryItem["stats"]["romNative"],
|
|
): InventoryItem {
|
|
return {
|
|
contentCategory,
|
|
instanceId,
|
|
itemId: 900001,
|
|
itemKey: instanceId,
|
|
name: instanceId,
|
|
quality: "rare",
|
|
inventorySlot: "back",
|
|
armorType: null,
|
|
weaponType: null,
|
|
itemLevel: 40,
|
|
sourceItemLevel: 40,
|
|
stats: { ...EMPTY_ITEM_STATS, stamina: 100, ...(romNative ? { romNative } : {}) },
|
|
quantity: 1,
|
|
sourceBossKey: "test-source",
|
|
sourceBossName: "Test Source",
|
|
acquiredAt: 1,
|
|
};
|
|
}
|
|
|
|
function paladinVindicationProcTime(
|
|
sourceActorId: string,
|
|
abilityId: string,
|
|
targetId: string,
|
|
chance: number,
|
|
): number {
|
|
for (let now = 1_000; now < 100_000; now += 1) {
|
|
if (stableUnit(`${sourceActorId}:${abilityId}:${targetId}:${now}:wow335-paladin-vindication`) < chance) return now;
|
|
}
|
|
throw new Error("Could not find a seeded Vindication proc time.");
|
|
}
|
|
|
|
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 registerHunterPetDefenseProbe(id: string, school: "physical" | "fire"): string {
|
|
const attackId = `${id}-attack`;
|
|
useCombatStore.getState().registerMob(id, {
|
|
name: id,
|
|
level: 80,
|
|
maxHealth: 100_000,
|
|
xpReward: 0,
|
|
attacks: [{
|
|
...DEFAULT_ENEMY_ATTACK,
|
|
id: attackId,
|
|
name: "Defense probe",
|
|
school,
|
|
range: 5,
|
|
}],
|
|
});
|
|
setMobPosition(id, [2, 0, 0]);
|
|
useCombatStore.setState((state) => ({
|
|
mobs: { ...state.mobs, [id]: { ...state.mobs[id]!, attackDamage: 1_000 } },
|
|
}));
|
|
return attackId;
|
|
}
|
|
|
|
function incomingDamageEventForSummon(mobId: string, attackId: string, summonId: string) {
|
|
useCombatStore.getState().damageMob(mobId, 1, 1_000, {
|
|
actorId: summonId,
|
|
role: "damage",
|
|
abilityId: "hunter-pet-defense-probe",
|
|
});
|
|
useCombatStore.getState().advanceMobCombat(1_450);
|
|
return [...useCombatStore.getState().combatEvents].reverse().find((event) => (
|
|
event.kind === "damage"
|
|
&& event.sourceActorId === mobId
|
|
&& event.targetActorId === summonId
|
|
&& event.abilityId === attackId
|
|
));
|
|
}
|
|
|
|
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("procs Vindication from a Paladin Holy hit with the generated rank chance, amount, duration, and owner", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80, xp: 0 });
|
|
useCombatStore.setState((state) => ({
|
|
talentRanks: { ...state.talentRanks, "talent-1633": 3 },
|
|
}));
|
|
registerTarget("vindication-target", [2, 0, 0], 10_000, 0);
|
|
const abilityId = "wow335-paladin-exorcism";
|
|
const now = paladinVindicationProcTime(PLAYER_AGGRO_ID, abilityId, "vindication-target", 0.65);
|
|
|
|
expect(useCombatStore.getState().damageMob(
|
|
"vindication-target",
|
|
25,
|
|
now,
|
|
{ actorId: PLAYER_AGGRO_ID, role: "damage", abilityId },
|
|
"holy",
|
|
).amount).toBeGreaterThan(0);
|
|
expect(useCombatStore.getState().mobs["vindication-target"].auras).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: "wow335:paladin:vindication",
|
|
sourceActorId: PLAYER_AGGRO_ID,
|
|
sourceAbilityId: abilityId,
|
|
endsAt: now + 10_000,
|
|
magnitude: 357,
|
|
}),
|
|
]));
|
|
});
|
|
|
|
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, next-spell 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);
|
|
|
|
expect(useCombatStore.getState().globalCooldownEndsAt).toBe(0);
|
|
const heal = useCombatStore.getState().castAbility("wow335-priest-greater-heal", undefined, 1_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("uses Inner Focus during another spell's global cooldown without clearing it", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
|
const talent = TALENT_NODES.find((node) => node.name === "Inner Focus")!;
|
|
useCombatStore.setState({ talentRanks: { [talent.id]: 1 } });
|
|
const innerFocus = abilitiesForClass("priest").find((ability) => ability.name === "Inner Focus")!;
|
|
|
|
expect(useCombatStore.getState().castAbility("wow335-priest-power-word-shield", undefined, 1_000).ok).toBe(true);
|
|
const gcdEnd = useCombatStore.getState().globalCooldownEndsAt;
|
|
expect(gcdEnd).toBeGreaterThan(1_000);
|
|
expect(useCombatStore.getState().castAbility(innerFocus.id, undefined, 1_100).ok).toBe(true);
|
|
expect(useCombatStore.getState().globalCooldownEndsAt).toBe(gcdEnd);
|
|
expect(useCombatStore.getState().castAbility("wow335-priest-renew", undefined, 1_100))
|
|
.toMatchObject({ ok: false, reason: "global-cooldown" });
|
|
});
|
|
|
|
it("reduces Inner Focus's active cooldown when Improved Flash Heal is used", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
|
const talent = TALENT_NODES.find((node) => node.name === "Improved Flash Heal")!;
|
|
const ranks = { [talent.id]: talent.maxRank };
|
|
const innerFocus = abilitiesForClass("priest").find((ability) => ability.name === "Inner Focus")!;
|
|
useCombatStore.setState((state) => ({
|
|
talentRanks: ranks,
|
|
talentModifiers: talentModifiersForRanks(ranks, "priest"),
|
|
cooldowns: { ...state.cooldowns, [innerFocus.id]: 10_000 },
|
|
}));
|
|
|
|
const cast = useCombatStore.getState().castAbility("priest-flash-heal", {
|
|
targetActorId: null,
|
|
now: 1_000,
|
|
});
|
|
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
|
|
const completion = useCombatStore.getState().activeCast!.completesAt;
|
|
useCombatStore.getState().tick(0.1, completion);
|
|
|
|
expect(useCombatStore.getState().cooldowns[innerFocus.id]).toBe(8_000);
|
|
});
|
|
|
|
it("converts health into mana with Life Tap", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 80, xp: 0 });
|
|
useCombatStore.setState((state) => ({ health: 5_000, resource: 0,
|
|
resourcePools: { ...state.resourcePools, mana: 0 } }));
|
|
const lifeTap = useCombatStore.getState().abilities.find((ability) => ability.name === "Life Tap")!;
|
|
|
|
const result = useCombatStore.getState().castAbility(lifeTap.id, undefined, 1_000);
|
|
|
|
expect(result).toMatchObject({ ok: true, healing: 0 });
|
|
expect(useCombatStore.getState()).toMatchObject({ health: 3_510, resource: 1_490 });
|
|
});
|
|
|
|
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("spreads only active Death Knight diseases with Pestilence and taunts with Death Grip", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80, xp: 0 });
|
|
registerTarget("disease-primary", [2, 0, 0], 5_000);
|
|
registerTarget("disease-neighbor", [5, 0, 0], 5_000);
|
|
useCombatStore.getState().selectMob("disease-primary");
|
|
const abilities = useCombatStore.getState().abilities;
|
|
const spellId = (name: string) => {
|
|
const ability = abilities.find((entry) => entry.name === name);
|
|
expect(ability, name).toBeDefined();
|
|
return ability!.id;
|
|
};
|
|
|
|
expect(useCombatStore.getState().castAbility(spellId("Icy Touch"), undefined, 1_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().castAbility(spellId("Plague Strike"), undefined, 3_000).ok).toBe(true);
|
|
const neighborHealth = useCombatStore.getState().mobs["disease-neighbor"].health;
|
|
expect(useCombatStore.getState().castAbility(spellId("Pestilence"), undefined, 5_000).ok).toBe(true);
|
|
|
|
expect(useCombatStore.getState().mobs["disease-neighbor"].health).toBe(neighborHealth);
|
|
expect(useCombatStore.getState().effects.filter((effect) => effect.targetId === "disease-neighbor")
|
|
.map((effect) => effect.tag).sort()).toEqual(["blood-plague", "frost-fever"]);
|
|
|
|
useCombatStore.getState().selectMob("disease-neighbor");
|
|
expect(useCombatStore.getState().castAbility(spellId("Death Grip"), undefined, 7_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().mobs["disease-neighbor"]).toMatchObject({
|
|
forcedTarget: { actorId: PLAYER_AGGRO_ID, endsAt: 10_000 },
|
|
});
|
|
});
|
|
|
|
it("mirrors a player Death Knight's landed spell during Dancing Rune Weapon's 12-second window", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "death-knight",
|
|
level: 80,
|
|
xp: 0,
|
|
});
|
|
useCombatStore.setState({
|
|
talentRanks: { "talent-1961": 1 },
|
|
resource: 100,
|
|
resourcePools: { "runic-power": 100 },
|
|
});
|
|
registerTarget("dancing-rune-weapon-player-target", [2, 0, 0], 1_000_000, 0);
|
|
|
|
expect(useCombatStore.getState().castAbility(
|
|
"wow335-death-knight-dancing-rune-weapon", undefined, 1_000,
|
|
).ok).toBe(true);
|
|
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
|
|
targetId: PLAYER_AURA_ENTITY_ID,
|
|
definition: expect.objectContaining({ name: "Dancing Rune Weapon", durationMs: 12_000 }),
|
|
appliedAt: 1_000,
|
|
expiresAt: 13_000,
|
|
}));
|
|
expect(useCombatStore.getState().summons.some((summon) => (
|
|
summon.sourceAbilityId === "wow335-death-knight-dancing-rune-weapon"
|
|
))).toBe(false);
|
|
|
|
const deathCoil = useCombatStore.getState().abilities.find((ability) => ability.name === "Death Coil");
|
|
expect(deathCoil).toBeDefined();
|
|
expect(useCombatStore.getState().castAbility(deathCoil!.id, undefined, 3_000).ok).toBe(true);
|
|
const activeHits = useCombatStore.getState().combatEvents.filter((event) => (
|
|
event.kind === "damage"
|
|
&& event.sourceActorId === PLAYER_AGGRO_ID
|
|
&& event.targetActorId === "dancing-rune-weapon-player-target"
|
|
&& event.abilityId === deathCoil!.id
|
|
&& event.occurredAt === 3_000
|
|
));
|
|
expect(activeHits).toHaveLength(2);
|
|
expect(activeHits[1].rawAmount).toBe(Math.floor(activeHits[0].rawAmount * 0.5));
|
|
|
|
useCombatStore.setState({ resource: 100, resourcePools: { "runic-power": 100 } });
|
|
expect(useCombatStore.getState().castAbility(deathCoil!.id, undefined, 14_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().combatEvents.filter((event) => (
|
|
event.kind === "damage"
|
|
&& event.sourceActorId === PLAYER_AGGRO_ID
|
|
&& event.targetActorId === "dancing-rune-weapon-player-target"
|
|
&& event.abilityId === deathCoil!.id
|
|
&& event.occurredAt === 14_000
|
|
))).toHaveLength(1);
|
|
});
|
|
|
|
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 boss items 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("derives the owner namespace from the source class and accepts only validated native gear rewards", () => {
|
|
const foreignWow = categoryGearFixture("foreign-wow", "wow");
|
|
const missingNative = categoryGearFixture("rom-without-native", "rom");
|
|
const missingCategory = categoryGearFixture("legacy-without-category", undefined);
|
|
const nullCategory = categoryGearFixture("explicit-null-category", null);
|
|
const nativeRom = categoryGearFixture("rom-native", "rom", { stamina: 4 });
|
|
const sourceProfile = {
|
|
classId: "rom-priest",
|
|
categoryId: "wow",
|
|
level: 40,
|
|
inventory: [foreignWow, missingNative, missingCategory, nullCategory, nativeRom],
|
|
equipment: { player: { back: foreignWow.instanceId, chest: missingNative.instanceId } },
|
|
} as const;
|
|
useCombatStore.getState().initializeCharacter(sourceProfile);
|
|
|
|
expect(useCombatStore.getState().contentCategoryId).toBe("rom");
|
|
expect(useCombatStore.getState().inventory.map((item) => item.instanceId)).toEqual([nativeRom.instanceId]);
|
|
expect(useCombatStore.getState().equipment).toEqual({});
|
|
expect(sourceProfile.inventory.map((item) => item.contentCategory)).toEqual(["wow", "rom", undefined, null, "rom"]);
|
|
const expectedTrainingOnlyStats = resolvedEquipmentStats({}, [], {
|
|
ownerId: "player",
|
|
ownerKind: "player",
|
|
classId: "rom-priest",
|
|
secondaryClassId: null,
|
|
specializationId: null,
|
|
role: "healer",
|
|
level: 40,
|
|
});
|
|
expect(useCombatStore.getState().gearStats).toEqual(expectedTrainingOnlyStats);
|
|
|
|
useCombatStore.getState().registerMob("gear-boundary", { name: "Gear Boundary", maxHealth: 1, hasLoot: true });
|
|
const nativeLoot = categoryGearFixture("rom-loot", "rom", { agility: 2 });
|
|
useCombatStore.setState((state) => {
|
|
const mob = state.mobs["gear-boundary"]!;
|
|
return { mobs: { ...state.mobs, "gear-boundary": {
|
|
...mob,
|
|
dead: true,
|
|
health: 0,
|
|
pendingLoot: [foreignWow, missingNative, missingCategory, nullCategory, nativeLoot],
|
|
} } };
|
|
});
|
|
expect(useCombatStore.getState().lootMob("gear-boundary", 10_000)).toBe(true);
|
|
expect(useCombatStore.getState().inventory.map((item) => item.instanceId))
|
|
.toEqual([nativeRom.instanceId, nativeLoot.instanceId]);
|
|
});
|
|
|
|
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",
|
|
sourceActorId: PLAYER_AGGRO_ID,
|
|
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);
|
|
expect(useCombatStore.getState().settings).toMatchObject({
|
|
showOwnDamageText: true,
|
|
showAllyDamageText: false,
|
|
showIncomingDamageText: false,
|
|
showFloatingCombatText: true,
|
|
});
|
|
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);
|
|
expect(useCombatStore.getState().settings).toMatchObject({
|
|
showOwnDamageText: true,
|
|
showAllyDamageText: false,
|
|
showIncomingDamageText: false,
|
|
});
|
|
useCombatStore.getState().updateSettings({ showOwnDamageText: false, showAllyDamageText: true, showIncomingDamageText: true });
|
|
expect(useCombatStore.getState().settings).toMatchObject({
|
|
showOwnDamageText: false,
|
|
showAllyDamageText: true,
|
|
showIncomingDamageText: true,
|
|
showFloatingCombatText: true,
|
|
});
|
|
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("applies Free Will Root and Slow immunity to the selected Knight/Priest ally", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-knight",
|
|
secondaryClassId: "rom-priest",
|
|
level: 80,
|
|
xp: 0,
|
|
});
|
|
usePartyStore.getState().initializeParty("free-will-test", 80, useCombatStore.getState().health);
|
|
const memberId = preparePartyMember(0, [2, 0, 0], Number.MAX_SAFE_INTEGER, true);
|
|
const freeWill = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 491351);
|
|
expect(freeWill).toBeDefined();
|
|
|
|
const result = useCombatStore.getState().castAbility(freeWill!.id, {
|
|
targetActorId: memberId,
|
|
now: 1_000,
|
|
});
|
|
|
|
expect(result).toMatchObject({ ok: true, abilityId: freeWill!.id });
|
|
const aura = useCombatStore.getState().auras.find((entry) => (
|
|
entry.targetId === memberId && entry.definition.id === "rom-free-will-immunity"
|
|
));
|
|
expect(aura).toMatchObject({
|
|
targetId: memberId,
|
|
definition: {
|
|
durationMs: 10_000,
|
|
controlImmunities: ["root", "slow"],
|
|
},
|
|
});
|
|
expect(useCombatStore.getState().auras.some((entry) => (
|
|
entry.targetId === PLAYER_AURA_ENTITY_ID && entry.definition.id === "rom-free-will-immunity"
|
|
))).toBe(false);
|
|
});
|
|
|
|
it("fans out Crystalline Protection and Acceleration only to party members in range", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "rom-warden", level: 80, xp: 0 });
|
|
usePartyStore.getState().initializeParty("warden-crystalline-test", 80, useCombatStore.getState().health);
|
|
const inRangeId = preparePartyMember(0, [6, 0, 0], Number.MAX_SAFE_INTEGER);
|
|
const outOfRangeId = preparePartyMember(1, [8, 0, 0], Number.MAX_SAFE_INTEGER);
|
|
const abilities = useCombatStore.getState().abilities;
|
|
const protection = abilities.find((ability) => ability.dbcSpellId === 498201)!;
|
|
const acceleration = abilities.find((ability) => ability.dbcSpellId === 498202)!;
|
|
|
|
expect(useCombatStore.getState().castAbility(protection.id, { now: 1_000 }).ok).toBe(true);
|
|
let auras = useCombatStore.getState().auras;
|
|
expect(auras.some((entry) => entry.targetId === inRangeId && entry.definition.name === "Crystalline Protection"
|
|
&& (entry.definition.absorbs?.length ?? 0) > 0)).toBe(true);
|
|
expect(auras.some((entry) => entry.targetId === outOfRangeId && entry.definition.name === "Crystalline Protection"))
|
|
.toBe(false);
|
|
|
|
expect(useCombatStore.getState().castAbility(acceleration.id, { now: 3_000 }).ok).toBe(true);
|
|
auras = useCombatStore.getState().auras;
|
|
const accelerationAuraFor = (targetId: string) => auras.filter((entry) => (
|
|
entry.targetId === targetId && entry.definition.name.startsWith("Crystalline Acceleration")
|
|
));
|
|
expect(accelerationAuraFor(inRangeId).flatMap((entry) => entry.definition.modifiers ?? []))
|
|
.toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ kind: "stat", stat: "attack-speed" }),
|
|
expect.objectContaining({ kind: "stat", stat: "spell-haste" }),
|
|
]));
|
|
expect(accelerationAuraFor(outOfRangeId)).toHaveLength(0);
|
|
});
|
|
|
|
it("applies Coat of Arms to its caster and the selected ally only", () => {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-warden",
|
|
secondaryClassId: "rom-warrior",
|
|
level: 80,
|
|
xp: 0,
|
|
});
|
|
usePartyStore.getState().initializeParty("warden-coat-of-arms-test", 80, useCombatStore.getState().health);
|
|
const selectedId = preparePartyMember(0, [2, 0, 0], Number.MAX_SAFE_INTEGER, true);
|
|
const otherId = preparePartyMember(1, [3, 0, 0], Number.MAX_SAFE_INTEGER);
|
|
const coatOfArms = useCombatStore.getState().abilities.find((ability) => ability.dbcSpellId === 494611)!;
|
|
|
|
const result = useCombatStore.getState().castAbility(coatOfArms.id, {
|
|
targetActorId: selectedId,
|
|
now: 1_000,
|
|
});
|
|
|
|
expect(result).toMatchObject({ ok: true, abilityId: coatOfArms.id });
|
|
const auras = useCombatStore.getState().auras.filter((entry) => entry.definition.name === "Coat of Arms");
|
|
const hasBothDefenses = (targetId: string) => auras.some((entry) => (
|
|
entry.targetId === targetId
|
|
&& entry.definition.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "armor")
|
|
&& entry.definition.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "magical-defense")
|
|
));
|
|
expect(hasBothDefenses(PLAYER_AURA_ENTITY_ID)).toBe(true);
|
|
expect(hasBothDefenses(selectedId)).toBe(true);
|
|
expect(hasBothDefenses(otherId)).toBe(false);
|
|
});
|
|
|
|
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("requires and consumes a Rejuvenation or Regrowth HoT for Swiftmend", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "druid", level: 80, xp: 0 });
|
|
useCombatStore.setState({ health: 1_000 });
|
|
const swiftmend = useCombatStore.getState().abilities.find((ability) => ability.name === "Swiftmend")!;
|
|
unlockReadySpell(swiftmend.id);
|
|
expect(useCombatStore.getState().castAbility(swiftmend.id, { targetActorId: null, now: 1_000 }))
|
|
.toMatchObject({ ok: false, reason: "condition-not-met" });
|
|
|
|
expect(useCombatStore.getState().castAbility("druid-rejuvenation", {
|
|
targetActorId: null,
|
|
now: 1_000,
|
|
}).ok).toBe(true);
|
|
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "hot"
|
|
&& effect.sourceAbilityId === "druid-rejuvenation")).toBe(true);
|
|
|
|
const result = useCombatStore.getState().castAbility(swiftmend.id, { targetActorId: null, now: 3_000 });
|
|
expect(result.ok).toBe(true);
|
|
expect(result.healing).toBeGreaterThan(0);
|
|
expect(useCombatStore.getState().health).toBeGreaterThan(1_000);
|
|
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "hot"
|
|
&& effect.sourceAbilityId === "druid-rejuvenation")).toBe(false);
|
|
});
|
|
|
|
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], 1_000, 0);
|
|
const memberId = preparePartyMember(0, [2, 0, 0], 1, true);
|
|
useCombatStore.getState().damagePlayer(40);
|
|
const playerHealthBefore = useCombatStore.getState().health;
|
|
expect(useCombatStore.getState().castAbility("death-knight-icy-touch", [0, 0, 0], 1_000).ok).toBe(true);
|
|
|
|
const result = useCombatStore.getState().castAbility("death-knight-death-strike", [0, 0, 0], 3_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);
|
|
const fiends = useCombatStore.getState().summons.filter((summon) => (
|
|
summon.sourceAbilityId === "wow335-priest-shadowfiend"
|
|
));
|
|
expect(fiends).toHaveLength(1);
|
|
expect(fiends[0]?.creatureId).toBe(19668);
|
|
expect(fiends[0]?.pulseIntervalMs).toBe(1_500);
|
|
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "resource"
|
|
&& event.sourceActorId === fiends[0]?.id)).toBe(false);
|
|
const manaBeforeAttack = useCombatStore.getState().resource;
|
|
useCombatStore.getState().tick(1, 2_000);
|
|
expect(useCombatStore.getState().mobs["fiend-target"].health).toBeLessThan(100_000);
|
|
expect(useCombatStore.getState().combatEvents).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ kind: "damage", sourceActorId: fiends[0]?.id,
|
|
targetActorId: "fiend-target", abilityId: "wow335-priest-shadowfiend", school: "shadow" }),
|
|
expect.objectContaining({ kind: "resource", sourceActorId: fiends[0]?.id,
|
|
targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: "wow335-priest-shadowfiend",
|
|
effectiveAmount: useCombatStore.getState().maxResource * 0.05 }),
|
|
]));
|
|
expect(useCombatStore.getState().resource).toBeGreaterThanOrEqual(
|
|
manaBeforeAttack + Math.floor(useCombatStore.getState().maxResource * 0.05),
|
|
);
|
|
});
|
|
|
|
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("uses player Hunter pet commands and owner attack events while Growl adds flat pet threat", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80, xp: 0 });
|
|
const callPet = useCombatStore.getState().abilities.find((ability) => ability.name === "Call Pet")!;
|
|
expect(useCombatStore.getState().castAbility(callPet.id, [0, 0, 0], 1_000).ok).toBe(true);
|
|
const pet = useCombatStore.getState().summons.find((summon) => summon.hunterPet)!;
|
|
expect(pet).toMatchObject({
|
|
hunterPetCommand: "follow",
|
|
hunterPetReactMode: "defensive",
|
|
hunterPetGrowlAutocastEnabled: false,
|
|
});
|
|
useCombatStore.getState().registerMob("hunter-pet-first-target", {
|
|
name: "Hunter pet target", maxHealth: 100_000, xpReward: 0,
|
|
});
|
|
setMobPosition("hunter-pet-first-target", [6, 0, 0]);
|
|
useCombatStore.getState().registerMob("hunter-pet-owner-action-target", {
|
|
name: "Owner action target", maxHealth: 100_000, xpReward: 0,
|
|
});
|
|
setMobPosition("hunter-pet-owner-action-target", [8, 0, 0]);
|
|
useCombatStore.getState().selectMob("hunter-pet-first-target");
|
|
const arcaneShot = useCombatStore.getState().abilities.find((ability) => ability.name === "Arcane Shot")!;
|
|
const firstShot = useCombatStore.getState().castAbility(arcaneShot.id, [0, 0, 0], 2_600);
|
|
expect(firstShot.ok, firstShot.reason).toBe(true);
|
|
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)
|
|
?.hunterPetPendingOwnerAttackTargetId).toBe("hunter-pet-first-target");
|
|
useCombatStore.getState().tick(0.1, 2_700);
|
|
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)?.targetId)
|
|
.toBe("hunter-pet-first-target");
|
|
expect(useCombatStore.getState().mobs["hunter-pet-first-target"]?.threatByActor[pet.id] ?? 0).toBe(0);
|
|
expect(useCombatStore.getState().setHunterPetGrowlAutocast(pet.id, true)).toBe(true);
|
|
useCombatStore.getState().tick(0.1, 2_800);
|
|
useCombatStore.getState().tick(0.1, 2_900);
|
|
const afterGrowl = useCombatStore.getState();
|
|
expect(afterGrowl.mobs["hunter-pet-first-target"]?.threatByActor[pet.id]).toBeGreaterThanOrEqual(1_234);
|
|
expect(afterGrowl.mobs["hunter-pet-first-target"]?.forcedTarget).toBeNull();
|
|
expect(afterGrowl.summons.find((summon) => summon.id === pet.id)?.hunterPetGrowlReadyAt).toBe(7_900);
|
|
|
|
useCombatStore.getState().selectMob("hunter-pet-owner-action-target");
|
|
expect(useCombatStore.getState().castAbility(arcaneShot.id, [0, 0, 0], 10_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)
|
|
?.hunterPetPendingOwnerAttackTargetId).toBe("hunter-pet-owner-action-target");
|
|
useCombatStore.getState().tick(0.1, 10_100);
|
|
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)?.targetId)
|
|
.toBe("hunter-pet-first-target");
|
|
|
|
useCombatStore.getState().selectMob("hunter-pet-first-target");
|
|
useCombatStore.getState().tick(0.1, 10_200);
|
|
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)?.targetId)
|
|
.toBe("hunter-pet-first-target");
|
|
expect(useCombatStore.getState().commandHunterPet(pet.id, "follow")).toBe(true);
|
|
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)).toMatchObject({
|
|
targetId: null,
|
|
hunterPetCommand: "follow",
|
|
});
|
|
expect(useCombatStore.getState().commandHunterPet(pet.id, "stay")).toBe(true);
|
|
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)?.hunterPetCommand)
|
|
.toBe("stay");
|
|
useCombatStore.setState((state) => ({ summons: state.summons.map((summon) => summon.id === pet.id
|
|
? { ...summon, position: [10, 0, 0] as const }
|
|
: summon) }));
|
|
expect(useCombatStore.getState().commandHunterPet(pet.id, "follow")).toBe(true);
|
|
useCombatStore.getState().tick(0.1, 10_300);
|
|
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)!.position[0]).toBeLessThan(10);
|
|
expect(useCombatStore.getState().commandHunterPet(pet.id, "stay")).toBe(true);
|
|
useCombatStore.setState((state) => ({ summons: state.summons.map((summon) => summon.id === pet.id
|
|
? { ...summon, position: [0, 0, 0] as const }
|
|
: summon) }));
|
|
useCombatStore.getState().tick(0.1, 10_400);
|
|
expect(useCombatStore.getState().summons.find((summon) => summon.id === pet.id)!.position[0]).toBeGreaterThan(0);
|
|
});
|
|
|
|
it.each(["physical", "fire"] as const)(
|
|
"applies inherited WoW 3.3.5 defenses to a player Hunter pet against %s damage",
|
|
(school) => {
|
|
const gearStats = { ...EMPTY_ITEM_STATS, armor: 3_000, fireResistance: 500 };
|
|
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80, xp: 0 });
|
|
useCombatStore.setState({ gearStats });
|
|
const callPet = useCombatStore.getState().abilities.find((ability) => ability.name === "Call Pet")!;
|
|
expect(useCombatStore.getState().castAbility(callPet.id, [0, 0, 0], 1_000).ok).toBe(true);
|
|
const pet = useCombatStore.getState().summons.find((summon) => summon.hunterPet)!;
|
|
const attackId = registerHunterPetDefenseProbe(`player-pet-defense-${school}`, school);
|
|
|
|
const event = incomingDamageEventForSummon(`player-pet-defense-${school}`, attackId, pet.id);
|
|
expect(event).toBeDefined();
|
|
expect(event?.rawAmount).toBe(1_000);
|
|
if (school === "physical") {
|
|
const ownerStats = deriveCharacterStats("hunter", 80, gearStats, "human");
|
|
const petArmor = wow335HunterPetArmorForLevel(80, ownerStats.armor);
|
|
const expected = 1_000 - Math.round(1_000 * armorReductionPercent(petArmor, 80) / 100);
|
|
expect(event?.effectiveAmount).toBe(expected);
|
|
expect(event?.effectiveAmount).toBeLessThan(1_000);
|
|
} else {
|
|
// 40% of the owner's 500 Fire Resistance yields a 200-resistance
|
|
// pet profile: the level-80 partial-resist table selects 20% or 30%.
|
|
expect(event?.effectiveAmount).toBeGreaterThanOrEqual(700);
|
|
expect(event?.effectiveAmount).toBeLessThanOrEqual(800);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(["physical", "fire"] as const)(
|
|
"applies inherited WoW 3.3.5 defenses to a generated companion Hunter pet against %s damage",
|
|
(school) => {
|
|
const gearStats = { ...EMPTY_ITEM_STATS, armor: 3_000, fireResistance: 500 };
|
|
usePartyStore.getState().initializeParty(
|
|
`hunter-pet-defense-companion-${school}`,
|
|
80,
|
|
useCombatStore.getState().health,
|
|
);
|
|
const memberId = usePartyStore.getState().members[0]!.id;
|
|
usePartyStore.setState((state) => ({
|
|
members: state.members.map((member) => member.id === memberId
|
|
? {
|
|
...member,
|
|
classId: "hunter",
|
|
secondaryClassId: null,
|
|
raceId: "human",
|
|
level: 80,
|
|
gearStats,
|
|
talentRanks: {},
|
|
health: member.maxHealth,
|
|
}
|
|
: member),
|
|
}));
|
|
registerPartyRuntimePosition(memberId, [0, 0, 0]);
|
|
useCombatStore.getState().synchronizeActors();
|
|
const callPet = abilitiesForClass("hunter").find((ability) => ability.name === "Call Pet")!;
|
|
const petId = useCombatStore.getState().summonActor(memberId, callPet, {
|
|
kind: "summon",
|
|
creatureId: 1,
|
|
coefficient: 1,
|
|
durationMs: 60_000,
|
|
}, [0, 0, 0], null, 1_000);
|
|
expect(petId).not.toBeNull();
|
|
const attackId = registerHunterPetDefenseProbe(`companion-pet-defense-${school}`, school);
|
|
|
|
const event = incomingDamageEventForSummon(`companion-pet-defense-${school}`, attackId, petId!);
|
|
expect(event).toBeDefined();
|
|
expect(event?.rawAmount).toBe(1_000);
|
|
if (school === "physical") {
|
|
const ownerStats = deriveCharacterStats("hunter", 80, gearStats, "human");
|
|
const petArmor = wow335HunterPetArmorForLevel(80, ownerStats.armor);
|
|
const expected = 1_000 - Math.round(1_000 * armorReductionPercent(petArmor, 80) / 100);
|
|
expect(event?.effectiveAmount).toBe(expected);
|
|
expect(event?.effectiveAmount).toBeLessThan(1_000);
|
|
} else {
|
|
expect(event?.effectiveAmount).toBeGreaterThanOrEqual(700);
|
|
expect(event?.effectiveAmount).toBeLessThanOrEqual(800);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("includes the player's active armor aura in inherited Hunter-pet physical mitigation", () => {
|
|
const gearStats = { ...EMPTY_ITEM_STATS, armor: 3_000 };
|
|
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80, xp: 0 });
|
|
useCombatStore.setState({ gearStats });
|
|
const callPet = useCombatStore.getState().abilities.find((ability) => ability.name === "Call Pet")!;
|
|
expect(useCombatStore.getState().castAbility(callPet.id, [0, 0, 0], 1_000).ok).toBe(true);
|
|
const pet = useCombatStore.getState().summons.find((summon) => summon.hunterPet)!;
|
|
const ownerArmorWithoutAura = deriveCharacterStats("hunter", 80, gearStats, "human").armor;
|
|
const aura = applyCombatAura(useCombatStore.getState().auras, {
|
|
definition: {
|
|
id: "test:hunter-owner-armor",
|
|
name: "Test Armor Bonus",
|
|
disposition: "buff",
|
|
durationMs: 30_000,
|
|
modifiers: [{ kind: "stat", stat: "armor", operation: "flat", value: 1_000 }],
|
|
},
|
|
sourceId: PLAYER_AURA_ENTITY_ID,
|
|
targetId: PLAYER_AURA_ENTITY_ID,
|
|
now: 1_000,
|
|
});
|
|
useCombatStore.setState({ auras: aura.auras });
|
|
const attackId = registerHunterPetDefenseProbe("player-pet-owner-armor-aura", "physical");
|
|
|
|
const event = incomingDamageEventForSummon("player-pet-owner-armor-aura", attackId, pet.id);
|
|
const petArmorWithAura = wow335HunterPetArmorForLevel(80, ownerArmorWithoutAura + 1_000);
|
|
const expectedWithAura = 1_000
|
|
- Math.round(1_000 * armorReductionPercent(petArmorWithAura, 80) / 100);
|
|
const petArmorWithoutAura = wow335HunterPetArmorForLevel(80, ownerArmorWithoutAura);
|
|
const expectedWithoutAura = 1_000
|
|
- Math.round(1_000 * armorReductionPercent(petArmorWithoutAura, 80) / 100);
|
|
expect(event?.effectiveAmount).toBe(expectedWithAura);
|
|
expect(event?.effectiveAmount).toBeLessThan(expectedWithoutAura);
|
|
});
|
|
|
|
it("leaves a non-Hunter summon on its existing incoming-damage path", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80, xp: 0 });
|
|
const mirrorImage = useCombatStore.getState().abilities.find((ability) => ability.name === "Mirror Image")!;
|
|
expect(useCombatStore.getState().castAbility(mirrorImage.id, [0, 0, 0], 1_000).ok).toBe(true);
|
|
const summon = useCombatStore.getState().summons.find((candidate) => !candidate.hunterPet)!;
|
|
const attackId = registerHunterPetDefenseProbe("non-hunter-summon-defense", "physical");
|
|
|
|
const event = incomingDamageEventForSummon("non-hunter-summon-defense", attackId, summon.id);
|
|
expect(event).toBeDefined();
|
|
expect(event?.rawAmount).toBe(1_000);
|
|
expect(event?.effectiveAmount).toBe(1_000);
|
|
});
|
|
|
|
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("channels Penance damage immediately and at one-second intervals without healing its caster", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
|
unlockReadySpell("wow335-priest-penance");
|
|
registerTarget("penance-target", [8, 0, 0], 100_000, 0);
|
|
useCombatStore.getState().damagePlayer(500);
|
|
const casterHealth = useCombatStore.getState().health;
|
|
const visuals: CombatPresentationEvent[] = [];
|
|
const stopVisuals = subscribeCombatPresentation((message) => {
|
|
if (message.kind === "event" && message.event.abilityId === "wow335-priest-penance") visuals.push(message.event);
|
|
});
|
|
expect(useCombatStore.getState().castAbility("wow335-priest-penance", [0, 0, 0], 1_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().activeCast).toMatchObject({ mode: "channel", totalTicks: 3,
|
|
completesAt: 3_000, nextTickAt: 1_000 });
|
|
expect(useCombatStore.getState().cooldowns["wow335-priest-penance"]).toBeGreaterThan(1_000);
|
|
expect(useCombatStore.getState().mobs["penance-target"].health).toBe(100_000);
|
|
useCombatStore.getState().tick(0, 1_000);
|
|
const first = useCombatStore.getState().mobs["penance-target"].health;
|
|
expect(first).toBeLessThan(100_000);
|
|
expect(useCombatStore.getState().health).toBe(casterHealth);
|
|
useCombatStore.getState().tick(0, 1_999);
|
|
expect(useCombatStore.getState().mobs["penance-target"].health).toBe(first);
|
|
useCombatStore.getState().tick(0, 2_000);
|
|
expect(useCombatStore.getState().mobs["penance-target"].health).toBeLessThan(first);
|
|
useCombatStore.getState().tick(0, 3_000);
|
|
expect(useCombatStore.getState().activeCast).toBeNull();
|
|
const after = useCombatStore.getState().mobs["penance-target"].health;
|
|
useCombatStore.getState().tick(0, 4_000);
|
|
expect(useCombatStore.getState().mobs["penance-target"].health).toBe(after);
|
|
stopVisuals();
|
|
expect(visuals.map((event) => [event.phase, event.sourceSpellId])).toEqual([
|
|
["cast-start", 47540],
|
|
...Array.from({ length: 3 }, () => [["tick", 47666], ["impact", 47666]]).flat(),
|
|
["release", 47540],
|
|
]);
|
|
});
|
|
|
|
it("channels Penance healing and stops future pulses when interrupted", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
|
unlockReadySpell("wow335-priest-penance");
|
|
usePartyStore.getState().selectSelf();
|
|
useCombatStore.getState().damagePlayer(2_000);
|
|
const before = useCombatStore.getState().health;
|
|
const visuals: CombatPresentationEvent[] = [];
|
|
const stopVisuals = subscribeCombatPresentation((message) => {
|
|
if (message.kind === "event" && message.event.abilityId === "wow335-priest-penance") visuals.push(message.event);
|
|
});
|
|
expect(useCombatStore.getState().castAbility("wow335-priest-penance", [0, 0, 0], 1_000).ok).toBe(true);
|
|
useCombatStore.getState().tick(0, 1_000);
|
|
const first = useCombatStore.getState().health;
|
|
expect(first).toBeGreaterThan(before);
|
|
useCombatStore.getState().tick(0, 2_000);
|
|
expect(useCombatStore.getState().health).toBeGreaterThan(first);
|
|
const interruptedAt = useCombatStore.getState().health;
|
|
expect(useCombatStore.getState().cancelCast()).toBe(true);
|
|
useCombatStore.getState().tick(0, 3_000);
|
|
expect(useCombatStore.getState().health).toBe(interruptedAt);
|
|
expect(useCombatStore.getState().activeCast).toBeNull();
|
|
stopVisuals();
|
|
expect(visuals.map((event) => [event.phase, event.sourceSpellId])).toEqual([
|
|
["cast-start", 47540], ["tick", 47750], ["impact", 47750],
|
|
["tick", 47750], ["impact", 47750], ["cast-cancel", 47540],
|
|
]);
|
|
});
|
|
|
|
it("channels ground-area Volley over its selected point without a target or post-channel damage", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80 });
|
|
registerTarget("volley-a", [10, 0, 0], 100_000, 0);
|
|
registerTarget("volley-b", [12, 0, 0], 100_000, 0);
|
|
registerTarget("volley-outside", [25, 0, 0], 100_000, 0);
|
|
expect(useCombatStore.getState().castAbility("wow335-hunter-volley", {
|
|
now: 1_000, origin: [0, 0, 0], targetActorId: null, groundPosition: [10, 0, 0],
|
|
}).ok).toBe(true);
|
|
expect(useCombatStore.getState().activeCast).toMatchObject({ mode: "channel", totalTicks: 6,
|
|
nextTickAt: 2_000, completesAt: 7_000 });
|
|
useCombatStore.getState().tick(0, 2_000);
|
|
expect(useCombatStore.getState().mobs["volley-a"].health).toBeLessThan(100_000);
|
|
expect(useCombatStore.getState().mobs["volley-b"].health).toBeLessThan(100_000);
|
|
expect(useCombatStore.getState().mobs["volley-outside"].health).toBe(100_000);
|
|
useCombatStore.getState().tick(0, 7_000);
|
|
expect(useCombatStore.getState().activeCast).toBeNull();
|
|
expect(useCombatStore.getState().areaEffects.some((effect) => effect.sourceAbilityId === "wow335-hunter-volley"))
|
|
.toBe(false);
|
|
const finalHealth = useCombatStore.getState().mobs["volley-a"].health;
|
|
useCombatStore.getState().tick(0, 9_000);
|
|
expect(useCombatStore.getState().mobs["volley-a"].health).toBe(finalHealth);
|
|
});
|
|
|
|
it("applies Blizzard damage during its eight source-timed channel pulses", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
|
registerTarget("blizzard-target", [10, 0, 0], 100_000, 0);
|
|
expect(useCombatStore.getState().castAbility("wow335-mage-blizzard", {
|
|
now: 1_000, origin: [0, 0, 0], targetActorId: null, groundPosition: [10, 0, 0],
|
|
}).ok).toBe(true);
|
|
expect(useCombatStore.getState().activeCast).toMatchObject({ mode: "channel", totalTicks: 8 });
|
|
useCombatStore.getState().tick(0, 2_000);
|
|
expect(useCombatStore.getState().mobs["blizzard-target"].health).toBeLessThan(100_000);
|
|
useCombatStore.getState().tick(0, 9_000);
|
|
expect(useCombatStore.getState().activeCast).toBeNull();
|
|
expect(useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage"
|
|
&& event.abilityId === "wow335-mage-blizzard" && event.targetActorId === "blizzard-target"))
|
|
.toHaveLength(8);
|
|
expect(useCombatStore.getState().areaEffects.some((effect) => effect.sourceAbilityId === "wow335-mage-blizzard"))
|
|
.toBe(false);
|
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.name === "Blizzard"))
|
|
.toBe(false);
|
|
const finalHealth = useCombatStore.getState().mobs["blizzard-target"].health;
|
|
useCombatStore.getState().tick(0, 11_000);
|
|
expect(useCombatStore.getState().mobs["blizzard-target"].health).toBe(finalHealth);
|
|
});
|
|
|
|
it.each([
|
|
["warlock", "wow335-warlock-rain-of-fire", 4, 2_000],
|
|
["druid", "wow335-druid-hurricane", 10, 1_000],
|
|
] as const)("applies %s ground-channel damage only during its pulses", (classId, abilityId, pulses, intervalMs) => {
|
|
useCombatStore.getState().initializeCharacter({ classId, level: 80 });
|
|
expect(useCombatStore.getState().abilities.find((ability) => ability.id === abilityId))
|
|
.toMatchObject({ castMode: "channel", castTimeMs: pulses * intervalMs, channelPulses: pulses });
|
|
registerTarget("channel-target", [10, 0, 0], 100_000, 0);
|
|
expect(abilityAtLevel(useCombatStore.getState().abilities.find((ability) => ability.id === abilityId)!, 80))
|
|
.toMatchObject({ castMode: "channel", castTimeMs: pulses * intervalMs, channelPulses: pulses });
|
|
expect(useCombatStore.getState().castAbility(abilityId, {
|
|
now: 1_000, origin: [0, 0, 0], targetActorId: null, groundPosition: [10, 0, 0],
|
|
}).ok).toBe(true);
|
|
expect(useCombatStore.getState().activeCast).toMatchObject({ mode: "channel", totalTicks: pulses,
|
|
nextTickAt: 1_000 + intervalMs });
|
|
useCombatStore.getState().tick(0, 1_000 + intervalMs);
|
|
expect(useCombatStore.getState().mobs["channel-target"].health).toBeLessThan(100_000);
|
|
useCombatStore.getState().tick(0, 1_000 + pulses * intervalMs);
|
|
expect(useCombatStore.getState().activeCast).toBeNull();
|
|
expect(useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage"
|
|
&& event.abilityId === abilityId && event.targetActorId === "channel-target"))
|
|
.toHaveLength(pulses);
|
|
expect(useCombatStore.getState().areaEffects.some((effect) => effect.sourceAbilityId === abilityId))
|
|
.toBe(false);
|
|
const finalHealth = useCombatStore.getState().mobs["channel-target"].health;
|
|
useCombatStore.getState().tick(0, 2_000 + pulses * intervalMs);
|
|
expect(useCombatStore.getState().mobs["channel-target"].health).toBe(finalHealth);
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|