Files
healer-man/src/game/wow335PriestMechanics.test.ts
T
2026-09-30 09:57:13 -04:00

1059 lines
53 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { abilityAtLevel, abilitiesForClass, abilityTalentNodeId } from "./abilityCatalog";
import { resolvedAbilityCosts, useCombatStore, PLAYER_AURA_ENTITY_ID } from "./combatStore";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import { PLAYER_AGGRO_ID } from "./aggro";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { TALENT_NODES } from "./talentCatalog";
import { talentModifiersForRanks } from "./talentModifiers";
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
function priestAbility(name: string) {
const ability = abilitiesForClass("priest").find((candidate) => candidate.name === name);
expect(ability, name).toBeDefined();
return ability!;
}
function unlock(name: string): void {
const nodeId = abilityTalentNodeId(priestAbility(name));
expect(nodeId, `${name} talent node`).toBeTruthy();
useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [nodeId!]: 1 } }));
}
function setPriestTalentRanks(ranks: Record<string, number>, criticalChance?: number): void {
const modifiers = talentModifiersForRanks(ranks, "priest");
useCombatStore.setState({
talentRanks: ranks,
talentModifiers: criticalChance === undefined ? modifiers : [...modifiers, {
kind: "critical-chance-percent",
amount: criticalChance,
unit: "percent",
scope: "your spells",
sourceTalentId: "test:priest-critical-chance",
sourceTalentName: "Test critical chance",
rank: 1,
description: "Test-only critical chance modifier.",
}],
});
}
function setMemberHealth(memberId: string, health: number, maxHealth: number): void {
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === memberId
? { ...member, health, maxHealth }
: member),
}));
useCombatStore.setState((state) => ({
actors: {
...state.actors,
[memberId]: { ...state.actors[memberId]!, health, maxHealth, alive: health > 0 },
},
}));
}
function castFriendly(name: string, targetId: string, now: number) {
useCombatStore.getState().selectFriendlyActor(targetId);
const result = useCombatStore.getState().castAbility(priestAbility(name).id, { targetActorId: targetId, now });
const activeCast = useCombatStore.getState().activeCast;
if (result.ok && activeCast?.abilityId === priestAbility(name).id) {
useCombatStore.getState().tick((activeCast.completesAt - now) / 1_000, activeCast.completesAt);
}
return result;
}
function castHostile(name: string, targetId: string, now: number) {
useCombatStore.getState().selectMob(targetId);
const result = useCombatStore.getState().castAbility(priestAbility(name).id, { targetActorId: targetId, now });
const activeCast = useCombatStore.getState().activeCast;
if (result.ok && activeCast?.abilityId === priestAbility(name).id) {
useCombatStore.getState().tick((activeCast.completesAt - now) / 1_000, activeCast.completesAt);
}
return result;
}
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 painAndSufferingMindBlastStart(targetId: string, rank: number, shouldProc: boolean, earliest: number): number {
const mindBlast = priestAbility("Mind Blast");
const talent = TALENT_NODES.find((node) => node.id === "talent-1909")!;
const chance = rank > 0 ? (talent.rankEffects[rank - 1]?.procChance ?? 0) / 100 : 0;
for (let start = earliest; start < earliest + 12_000; start += 100) {
const completesAt = start + mindBlast.castTimeMs;
const proc = stableUnit(
`${PLAYER_AGGRO_ID}:${mindBlast.id}:${targetId}:${completesAt}:wow335-priest-pain-and-suffering`,
) < chance;
if (proc === shouldProc) return start;
}
throw new Error(`Could not find a seeded Pain and Suffering ${shouldProc ? "proc" : "failure"}.`);
}
function shadowWordPainEffect(targetId: string, sourceActorId = PLAYER_AGGRO_ID) {
return useCombatStore.getState().effects.find((effect) => effect.kind === "dot"
&& effect.sourceAbilityId === priestAbility("Shadow Word: Pain").id
&& effect.sourceActorId === sourceActorId && effect.targetId === targetId);
}
describe("WoW Priest mechanics", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow-priest-mechanics", 80, useCombatStore.getState().health);
usePartyStore.getState().selectMember(null);
useCombatStore.getState().synchronizeActors();
usePartyStore.getState().members.forEach((member, index) => {
registerPartyRuntimePosition(member.id, [index + 2, 0, 0]);
});
});
it("uses self targeting for Desperate Prayer and dual relationship for Dispel Magic", () => {
const desperatePrayer = priestAbility("Desperate Prayer");
const dispelMagic = priestAbility("Dispel Magic");
const rank988 = abilityAtLevel(dispelMagic, 38);
expect(desperatePrayer.target).toBe("self");
expect(dispelMagic.target).toBe("any");
expect(rank988.dbcSpellId).toBe(988);
expect(rank988.effects).toContainEqual(expect.objectContaining({
kind: "dispel", mode: "cleanse", relationship: "target", categories: ["magic"], maxCount: 2,
}));
});
it("lets Dispel Magic rank 988 cleanse a party member's magic debuff", () => {
const member = usePartyStore.getState().members[0]!;
const carrier = priestAbility("Prayer of Fortitude");
expect(useCombatStore.getState().applyActorAura(PLAYER_AGGRO_ID, member.id, carrier, {
kind: "apply-aura",
aura: {
id: "test:priest-magic-debuff", name: "Test magic debuff", disposition: "debuff",
dispelCategory: "magic", durationMs: 30_000, maxStacks: 1, stackBehavior: "refresh",
},
sourceEffectType: 6, sourceAuraType: 4, miscValue: 0, triggerSpellId: 0,
}, 1_000)).toBe(true);
expect(useCombatStore.getState().selectFriendlyActor(member.id)).toBe(true);
const result = useCombatStore.getState().castAbility(priestAbility("Dispel Magic").id, {
targetActorId: member.id, now: 2_000,
});
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "test:priest-magic-debuff")).toBe(false);
});
it("casts Desperate Prayer on the priest and applies Pain Suppression's threat reduction to its ally", () => {
unlock("Desperate Prayer");
useCombatStore.getState().damagePlayer(500);
const playerBefore = useCombatStore.getState().health;
const desperatePrayer = useCombatStore.getState().castAbility(priestAbility("Desperate Prayer").id, [0, 0, 0], 1_000);
expect(desperatePrayer.ok, JSON.stringify(desperatePrayer)).toBe(true);
expect(useCombatStore.getState().health).toBeGreaterThan(playerBefore);
const member = usePartyStore.getState().members[0]!;
unlock("Pain Suppression");
useCombatStore.getState().registerMob("priest-threat-test", { name: "Threat test", maxHealth: 1_000, xpReward: 0 });
setMobPosition("priest-threat-test", [4, 0, 0]);
useCombatStore.setState((state) => ({
mobs: { ...state.mobs, "priest-threat-test": {
...state.mobs["priest-threat-test"]!, engaged: true,
threatByActor: { [member.id]: 100 }, targetActorId: member.id,
} },
}));
useCombatStore.getState().selectFriendlyActor(member.id);
const result = useCombatStore.getState().castAbility(priestAbility("Pain Suppression").id, {
targetActorId: member.id, now: 3_000,
});
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(useCombatStore.getState().mobs["priest-threat-test"]?.threatByActor[member.id]).toBe(60);
});
it("applies Prayer of Fortitude to the priest and all party members", () => {
const result = useCombatStore.getState().castAbility(priestAbility("Prayer of Fortitude").id, [0, 0, 0], 1_000);
expect(result.ok, JSON.stringify(result)).toBe(true);
const recipients = useCombatStore.getState().auras
.filter((aura) => aura.definition.id.includes(":48162:"))
.map((aura) => aura.targetId);
expect(recipients).toContain(PLAYER_AURA_ENTITY_ID);
for (const member of usePartyStore.getState().members) expect(recipients).toContain(member.id);
});
it("reduces only magic damage while an eligible Test of Faith cast is active", () => {
const talent = TALENT_NODES.find((node) => node.name === "Test of Faith");
expect(talent).toBeDefined();
const ranks = { [talent!.id]: 1 };
let castId = 0;
const damageTaken = (rank: number, school: "physical" | "shadow", abilityName: string, expired = false): number => {
const now = Date.now();
const maxHealth = useCombatStore.getState().maxHealth;
const selectedRanks = rank > 0 ? { [talent!.id]: rank } : {};
const completesAt = expired ? now - 1 : now + 2_000;
useCombatStore.setState({
health: maxHealth,
shield: 0,
auras: [],
talentRanks: selectedRanks,
talentModifiers: talentModifiersForRanks(selectedRanks, "priest"),
activeCast: {
id: ++castId,
abilityId: priestAbility(abilityName).id,
targetId: null,
mode: "cast",
startedAt: now - 500,
completesAt,
originalDurationMs: 2_500,
pushbackHits: 0,
school: "holy",
canCastWhileMoving: false,
origin: [0, 0, 0],
groundPosition: null,
totalTicks: 1,
ticksCompleted: 0,
nextTickAt: completesAt,
},
});
useCombatStore.getState().damagePlayer(1_000, school);
return maxHealth - useCombatStore.getState().health;
};
const baselineMagic = damageTaken(0, "shadow", "Flash Heal");
expect(damageTaken(1, "shadow", "Flash Heal")).toBe(Math.round(baselineMagic * 0.9));
expect(damageTaken(1, "physical", "Flash Heal")).toBe(damageTaken(0, "physical", "Flash Heal"));
expect(damageTaken(1, "shadow", "Prayer of Fortitude")).toBe(baselineMagic);
expect(damageTaken(1, "shadow", "Flash Heal", true)).toBe(baselineMagic);
expect(ranks[talent!.id]).toBe(1);
});
it("applies Test of Faith to magic hits resolved through enemy combat", () => {
const talent = TALENT_NODES.find((node) => node.name === "Test of Faith");
expect(talent).toBeDefined();
const incomingHit = (rank: number): number => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
const selectedRanks = rank > 0 ? { [talent!.id]: rank } : {};
useCombatStore.setState({
talentRanks: selectedRanks,
talentModifiers: talentModifiersForRanks(selectedRanks, "priest"),
activeCast: {
id: 1,
abilityId: priestAbility("Flash Heal").id,
targetId: null,
mode: "cast",
startedAt: 1_000,
completesAt: 8_000,
originalDurationMs: 7_000,
pushbackHits: 0,
school: "holy",
canCastWhileMoving: false,
origin: [0, 0, 0],
groundPosition: null,
totalTicks: 1,
ticksCompleted: 0,
nextTickAt: 8_000,
},
});
const mobId = "priest-test-faith-magic-hit";
useCombatStore.getState().registerMob(mobId, {
name: "Test of Faith magic hit",
maxHealth: 100_000,
xpReward: 0,
attacks: [{
...DEFAULT_ENEMY_ATTACK,
id: "priest-test-shadow-cast",
name: "Shadow cast",
delivery: "projectile",
animation: "cast",
school: "shadow",
range: 30,
castTimeMs: 0,
}],
});
setMobPosition(mobId, [8, 0, 0]);
useCombatStore.getState().damageMob(mobId, 1, 1_000);
const before = useCombatStore.getState().health;
useCombatStore.getState().advanceMobCombat(1_450, [0, 0, 0]);
return before - useCombatStore.getState().health;
};
const baseline = incomingHit(0);
expect(baseline).toBeGreaterThan(0);
expect(incomingHit(1)).toBe(Math.round(baseline * 0.9));
});
it("applies Test of Faith only to spells whose target is at or below half health", () => {
const talent = TALENT_NODES.find((node) => node.name === "Test of Faith");
expect(talent).toBeDefined();
const ranks = { [talent!.id]: 3 };
expect(talentModifiersForRanks(ranks, "priest")).not.toContainEqual(expect.objectContaining({
kind: "healing-percent", sourceTalentName: "Test of Faith",
}));
expect(talentModifiersForRanks(ranks, "priest")).not.toContainEqual(expect.objectContaining({
kind: "damage-percent", sourceTalentName: "Test of Faith",
}));
const member = usePartyStore.getState().members[0]!;
const prepareTarget = (health: number) => {
usePartyStore.setState((state) => ({
members: state.members.map((candidate) => candidate.id === member.id
? { ...candidate, health, maxHealth: 20_000 }
: candidate),
}));
useCombatStore.setState((state) => ({
actors: {
...state.actors,
[member.id]: { ...state.actors[member.id]!, health, maxHealth: 20_000, alive: true },
},
}));
};
const castFlashHeal = (health: number, now: number, withTalent: boolean) => {
prepareTarget(health);
useCombatStore.setState({
talentRanks: withTalent ? ranks : {},
talentModifiers: talentModifiersForRanks(withTalent ? ranks : {}, "priest"),
});
useCombatStore.getState().selectFriendlyActor(member.id);
const result = useCombatStore.getState().castAbility(priestAbility("Flash Heal").id, {
targetActorId: member.id, now,
});
expect(result.ok, JSON.stringify(result)).toBe(true);
const completesAt = useCombatStore.getState().activeCast?.completesAt;
if (completesAt) useCombatStore.getState().tick((completesAt - now) / 1_000, completesAt);
const event = useCombatStore.getState().combatEvents.filter((candidate) => (
candidate.kind === "healing"
&& candidate.abilityId === priestAbility("Flash Heal").id
&& candidate.targetActorId === member.id
)).at(-1);
expect(event).toBeDefined();
return event!.rawAmount / (event!.critical ? 1.5 : 1);
};
const base = castFlashHeal(9_000, 1_000, false);
const boosted = castFlashHeal(9_000, 4_000, true);
const aboveThreshold = castFlashHeal(11_000, 7_000, true);
expect(boosted).toBeCloseTo(base * 1.12, 0);
expect(aboveThreshold).toBeCloseTo(base, 0);
const smite = priestAbility("Smite");
useCombatStore.getState().registerMob("priest-test-faith-damage", {
name: "Test of Faith target", maxHealth: 20_000, xpReward: 0,
});
setMobPosition("priest-test-faith-damage", [4, 0, 0]);
const castSmite = (health: number, now: number, withTalent: boolean) => {
useCombatStore.setState((state) => ({
talentRanks: withTalent ? ranks : {},
talentModifiers: talentModifiersForRanks(withTalent ? ranks : {}, "priest"),
mobs: {
...state.mobs,
"priest-test-faith-damage": {
...state.mobs["priest-test-faith-damage"]!, health, maxHealth: 20_000, dead: false,
},
},
}));
useCombatStore.getState().selectMob("priest-test-faith-damage");
const result = useCombatStore.getState().castAbility(smite.id, [0, 0, 0], now);
expect(result.ok, JSON.stringify(result)).toBe(true);
const completesAt = useCombatStore.getState().activeCast?.completesAt;
if (completesAt) useCombatStore.getState().tick((completesAt - now) / 1_000, completesAt);
const event = useCombatStore.getState().combatEvents.filter((candidate) => (
candidate.kind === "damage"
&& candidate.abilityId === smite.id
&& candidate.targetActorId === "priest-test-faith-damage"
)).at(-1);
expect(event).toBeDefined();
return event!.rawAmount / (event!.critical ? 1.5 : 1);
};
const baseDamage = castSmite(9_000, 10_000, false);
const lowHealthDamage = castSmite(9_000, 13_000, true);
const healthyDamage = castSmite(11_000, 16_000, true);
expect(lowHealthDamage).toBeCloseTo(baseDamage * 1.12, 0);
expect(healthyDamage).toBeCloseTo(baseDamage, 0);
});
it("keeps proc-based Priest talent values out of passive talent modifiers", () => {
const procTalentNames = ["Holy Concentration", "Serendipity", "Shadow Weaving"];
const ranks = Object.fromEntries(procTalentNames.map((name) => {
const talent = TALENT_NODES.find((node) => node.name === name);
expect(talent, name).toBeDefined();
return [talent!.id, talent!.maxRank];
}));
const modifiers = talentModifiersForRanks(ranks, "priest");
for (const name of procTalentNames) {
expect(modifiers.filter((modifier) => modifier.sourceTalentName === name), name).toEqual([]);
}
});
it("limits Inner Focus to its three named spells and empowers Greater Heal", () => {
const talent = TALENT_NODES.find((node) => node.name === "Inner Focus");
expect(talent).toBeDefined();
setPriestTalentRanks({ [talent!.id]: 1 }, 75);
useCombatStore.setState((state) => ({
resource: 0,
resourcePools: { ...state.resourcePools, mana: 0 },
}));
useCombatStore.getState().registerMob("priest-inner-focus-damage", {
name: "Inner Focus target", maxHealth: 100_000, xpReward: 0,
});
setMobPosition("priest-inner-focus-damage", [4, 0, 0]);
expect(useCombatStore.getState().castAbility(priestAbility("Inner Focus").id, undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().castAbility(priestAbility("Smite").id, {
targetActorId: "priest-inner-focus-damage", now: 1_001,
})).toMatchObject({ ok: false, reason: "not-enough-resource" });
expect(useCombatStore.getState().auras.some((aura) => (
aura.definition.utilityTags?.includes("next-heal-free-critical")
))).toBe(true);
const manaSpentAt = useCombatStore.getState().lastResourceSpentAt.mana;
const member = usePartyStore.getState().members[0]!;
setMemberHealth(member.id, 1, member.maxHealth);
useCombatStore.getState().selectFriendlyActor(member.id);
const greaterHeal = priestAbility("Greater Heal");
const cast = useCombatStore.getState().castAbility(greaterHeal.id, {
targetActorId: member.id, now: 1_002,
});
expect(cast.ok, JSON.stringify(cast)).toBe(true);
const activeCast = useCombatStore.getState().activeCast!;
expect(activeCast.completesAt - activeCast.startedAt).toBe(Math.round(greaterHeal.castTimeMs * 0.5));
useCombatStore.getState().tick((activeCast.completesAt - activeCast.startedAt) / 1_000, activeCast.completesAt);
expect(useCombatStore.getState().lastResourceSpentAt.mana).toBe(manaSpentAt);
expect(useCombatStore.getState().auras.some((aura) => (
aura.definition.utilityTags?.includes("next-heal-free-critical")
))).toBe(false);
const heal = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "healing" && event.abilityId === greaterHeal.id && event.targetActorId === member.id
)).at(-1)!;
expect(heal.critical).toBe(true);
});
it("gives Inner Focus's Mind Blast rider and reduces Power Word: Shield's Weakened Soul", () => {
const talent = TALENT_NODES.find((node) => node.name === "Inner Focus")!;
setPriestTalentRanks({ [talent.id]: 1 });
useCombatStore.setState((state) => ({
resource: 0,
resourcePools: { ...state.resourcePools, mana: 0 },
}));
useCombatStore.getState().registerMob("priest-inner-focus-mind-blast", {
name: "Inner Focus Mind Blast target", maxHealth: 100_000, xpReward: 0,
});
setMobPosition("priest-inner-focus-mind-blast", [4, 0, 0]);
expect(useCombatStore.getState().castAbility(priestAbility("Inner Focus").id, undefined, 1_000).ok).toBe(true);
const mindBlast = priestAbility("Mind Blast");
expect(useCombatStore.getState().castAbility(mindBlast.id, {
targetActorId: "priest-inner-focus-mind-blast", now: 1_001,
}).ok).toBe(true);
expect(useCombatStore.getState().activeCast?.completesAt).toBe(1_001 + mindBlast.castTimeMs / 2);
useCombatStore.getState().tick(mindBlast.castTimeMs / 2_000, 1_001 + mindBlast.castTimeMs / 2);
expect(useCombatStore.getState().mobs["priest-inner-focus-mind-blast"]?.statuses).toContainEqual(expect.objectContaining({
kind: "slow",
magnitude: 0.7,
endsAt: 1_001 + mindBlast.castTimeMs / 2 + 5_000,
}));
const member = usePartyStore.getState().members[0]!;
useCombatStore.setState((state) => ({
cooldowns: { ...state.cooldowns, [priestAbility("Inner Focus").id]: 0 },
}));
expect(useCombatStore.getState().castAbility(priestAbility("Inner Focus").id, undefined, 8_000).ok).toBe(true);
expect(castFriendly("Power Word: Shield", member.id, 8_001).ok).toBe(true);
const weakenedSoul = useCombatStore.getState().auras.find((aura) => (
aura.targetId === member.id && aura.definition.id === "wow335:priest:weakened-soul"
));
expect(weakenedSoul?.expiresAt).toBe(28_001);
});
it.each([
{ rank: 1, percent: 0.04 },
{ rank: 2, percent: 0.07 },
{ rank: 3, percent: 0.1 },
])("grants Divine Aegis rank $rank from a critical Holy heal, including reduced overheal", ({ rank, percent }) => {
const talent = TALENT_NODES.find((node) => node.name === "Divine Aegis")!;
const ranks = { [talent.id]: rank };
setPriestTalentRanks(ranks, 100);
const member = usePartyStore.getState().members[0]!;
setMemberHealth(member.id, 1, 20_000);
const result = castFriendly("Flash Heal", member.id, 1_000);
expect(result.ok, JSON.stringify(result)).toBe(true);
const heal = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "healing" && event.abilityId === priestAbility("Flash Heal").id && event.targetActorId === member.id
)).at(-1)!;
expect(heal.critical).toBe(true);
const expected = Math.min(2_000, Math.round((heal.effectiveAmount + heal.overheal * 0.25) * percent * 2));
const aegis = useCombatStore.getState().auras.find((aura) => aura.targetId === member.id
&& aura.definition.utilityTags?.includes("wow335-priest-divine-aegis"));
expect(aegis?.absorbs.reduce((total, absorb) => total + absorb.remaining, 0)).toBe(expected);
expect(aegis?.expiresAt).toBe(heal.occurredAt + 6_000);
});
it("caps a target's Divine Aegis absorb at ten percent maximum health", () => {
const talent = TALENT_NODES.find((node) => node.name === "Divine Aegis")!;
setPriestTalentRanks({ [talent.id]: 3 }, 100);
const member = usePartyStore.getState().members[0]!;
setMemberHealth(member.id, 1, 500);
expect(castFriendly("Flash Heal", member.id, 1_000).ok).toBe(true);
const aegis = useCombatStore.getState().auras.find((aura) => aura.targetId === member.id
&& aura.definition.utilityTags?.includes("wow335-priest-divine-aegis"));
expect(aegis?.absorbs.reduce((total, absorb) => total + absorb.remaining, 0)).toBeLessThanOrEqual(50);
});
it("makes Surge of Light's next Smite instant, free, stronger, and unable to crit", () => {
const talent = TALENT_NODES.find((node) => node.name === "Surge of Light")!;
useCombatStore.getState().registerMob("priest-surge-test", { name: "Surge target", maxHealth: 200_000, xpReward: 0 });
setMobPosition("priest-surge-test", [4, 0, 0]);
setPriestTalentRanks({}, -100);
expect(castHostile("Smite", "priest-surge-test", 1_000).ok).toBe(true);
const baseline = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.abilityId === priestAbility("Smite").id && event.targetActorId === "priest-surge-test"
)).at(-1)!;
expect(baseline.critical).toBe(false);
setPriestTalentRanks({ [talent.id]: 2 }, 100);
const member = usePartyStore.getState().members[0]!;
let procAt = 0;
for (let attempt = 0; attempt < 16; attempt += 1) {
const now = 6_000 + attempt * 4_000;
setMemberHealth(member.id, 1, 20_000);
expect(castFriendly("Flash Heal", member.id, now).ok).toBe(true);
if (useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("wow335-priest-surge-of-light"))) {
procAt = now + priestAbility("Flash Heal").castTimeMs;
break;
}
}
expect(procAt).toBeGreaterThan(0);
const manaBefore = useCombatStore.getState().resource;
expect(castHostile("Smite", "priest-surge-test", procAt + 4_000).ok).toBe(true);
expect(useCombatStore.getState().activeCast).toBeNull();
expect(useCombatStore.getState().resource).toBe(manaBefore);
const surged = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.abilityId === priestAbility("Smite").id && event.targetActorId === "priest-surge-test"
)).at(-1)!;
expect(surged.critical).toBe(false);
expect(surged.rawAmount).toBe(Math.round(baseline.rawAmount * 1.6));
});
it("also triggers Surge of Light from critical direct damage and spends it on Flash Heal", () => {
const talent = TALENT_NODES.find((node) => node.name === "Surge of Light")!;
useCombatStore.getState().registerMob("priest-surge-crit-test", {
name: "Surge critical target", maxHealth: 1_000_000, xpReward: 0,
});
setMobPosition("priest-surge-crit-test", [4, 0, 0]);
setPriestTalentRanks({ [talent.id]: 2 }, 100);
let procAt = 0;
for (let attempt = 0; attempt < 16; attempt += 1) {
const now = 1_000 + attempt * 4_000;
expect(castHostile("Smite", "priest-surge-crit-test", now).ok).toBe(true);
if (useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("wow335-priest-surge-of-light"))) {
procAt = now + priestAbility("Smite").castTimeMs;
break;
}
}
expect(procAt).toBeGreaterThan(0);
const member = usePartyStore.getState().members[0]!;
setMemberHealth(member.id, 1, 20_000);
setPriestTalentRanks({}, 100);
const manaBefore = useCombatStore.getState().resource;
expect(castFriendly("Flash Heal", member.id, procAt + 4_000).ok).toBe(true);
expect(useCombatStore.getState().activeCast).toBeNull();
expect(useCombatStore.getState().resource).toBe(manaBefore);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("wow335-priest-surge-of-light"))).toBe(false);
});
it("stacks Serendipity to three charges and consumes all charges on Greater Heal", () => {
const talent = TALENT_NODES.find((node) => node.name === "Serendipity")!;
const ranks = { [talent.id]: 3 };
setPriestTalentRanks(ranks);
const member = usePartyStore.getState().members[0]!;
for (const now of [1_000, 5_000, 9_000]) {
setMemberHealth(member.id, 1, 20_000);
expect(castFriendly("Flash Heal", member.id, now).ok).toBe(true);
}
const procAura = useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes("wow335-priest-serendipity"));
expect(procAura?.stacks).toBe(3);
const greaterHeal = abilityAtLevel(priestAbility("Greater Heal"), 80);
useCombatStore.setState((state) => ({
resource: 5_000,
maxResource: 5_000,
resourcePools: { ...state.resourcePools, mana: 5_000 },
maxResourcePools: { ...state.maxResourcePools, mana: 5_000 },
gearStats: { ...state.gearStats, spirit: 0, mp5: 0 },
}));
const baseCost = resolvedAbilityCosts(greaterHeal, [], useCombatStore.getState())
.find((cost) => cost.resource === "mana")!.amount;
const manaBefore = useCombatStore.getState().resource;
useCombatStore.getState().selectFriendlyActor(member.id);
const result = useCombatStore.getState().castAbility(greaterHeal.id, { targetActorId: member.id, now: 14_000 });
expect(result.ok, JSON.stringify(result)).toBe(true);
const cast = useCombatStore.getState().activeCast!;
expect(cast.completesAt - cast.startedAt).toBe(Math.round(greaterHeal.castTimeMs * 0.55));
expect(useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes("wow335-priest-serendipity"))?.stacks).toBe(3);
useCombatStore.getState().castAbility(greaterHeal.id, {
targetActorId: member.id, now: cast.completesAt, completingCast: cast,
});
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("wow335-priest-serendipity"))).toBe(false);
expect(manaBefore - useCombatStore.getState().resource).toBe(Math.round(baseCost * 0.55));
});
it("adds Shadow Weaving on Shadow damage and increases later ticks up to five stacks", () => {
const talent = TALENT_NODES.find((node) => node.name === "Shadow Weaving")!;
useCombatStore.getState().registerMob("priest-shadow-weaving-test", {
name: "Shadow Weaving target", maxHealth: 200_000, xpReward: 0,
});
setMobPosition("priest-shadow-weaving-test", [4, 0, 0]);
setPriestTalentRanks({ [talent.id]: 3 }, -100);
expect(castHostile("Shadow Word: Pain", "priest-shadow-weaving-test", 1_000).ok).toBe(true);
useCombatStore.getState().tick(18, 19_000);
const ticks = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.abilityId === priestAbility("Shadow Word: Pain").id
&& event.targetActorId === "priest-shadow-weaving-test"
));
expect(ticks.length).toBeGreaterThanOrEqual(2);
expect(ticks[1]!.rawAmount).toBe(Math.round(ticks[0]!.rawAmount * 1.02));
expect(useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes("wow335-priest-shadow-weaving"))?.stacks).toBe(5);
});
it("uses generated Pain and Suffering proc chances to refresh Shadow Word: Pain", () => {
const talent = TALENT_NODES.find((node) => node.id === "talent-1909")!;
expect(talent.rankEffects.map((rank) => rank.procChance)).toEqual([33, 66, 100]);
const cases = [
{ rank: 0, shouldProc: false },
{ rank: 1, shouldProc: false },
{ rank: 1, shouldProc: true },
{ rank: 2, shouldProc: false },
{ rank: 2, shouldProc: true },
{ rank: 3, shouldProc: true },
];
const shadowWordPain = abilityAtLevel(priestAbility("Shadow Word: Pain"), 80);
const dotDefinition = shadowWordPain.effects.find((effect) => effect.kind === "dot")!;
expect(dotDefinition.kind).toBe("dot");
cases.forEach(({ rank, shouldProc }, index) => {
const targetId = `priest-pain-and-suffering-rank-${rank}-${index}`;
useCombatStore.getState().registerMob(targetId, {
name: "Pain and Suffering target", maxHealth: 1_000_000, xpReward: 0,
});
setMobPosition(targetId, [4, 0, 0]);
setPriestTalentRanks(rank ? { [talent.id]: rank } : {});
const appliedAt = 1_000 + index * 25_000;
expect(castHostile("Shadow Word: Pain", targetId, appliedAt).ok).toBe(true);
const before = shadowWordPainEffect(targetId)!;
expect(before).toBeDefined();
const expirationBefore = before.nextTickAt + (before.remainingTicks - 1) * before.intervalMs;
const mindBlast = priestAbility("Mind Blast");
const castAt = painAndSufferingMindBlastStart(targetId, rank, shouldProc, appliedAt + 2_000);
expect(castHostile("Mind Blast", targetId, castAt).ok).toBe(true);
const completedCast = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "cast" && event.abilityId === mindBlast.id && event.targetActorId === targetId
)).at(-1)!;
const after = shadowWordPainEffect(targetId)!;
const expirationAfter = after.nextTickAt + (after.remainingTicks - 1) * after.intervalMs;
if (shouldProc) {
expect(after.remainingTicks).toBe(dotDefinition.ticks);
expect(after.nextTickAt).toBe(completedCast.occurredAt + after.intervalMs);
expect(expirationAfter).toBe(completedCast.occurredAt + dotDefinition.ticks * dotDefinition.intervalMs);
} else {
expect(expirationAfter).toBe(expirationBefore);
}
});
});
it("refreshes only its own Shadow Word: Pain on the eligible cast target", () => {
const talent = TALENT_NODES.find((node) => node.id === "talent-1909")!;
setPriestTalentRanks({ [talent.id]: 3 });
for (const targetId of ["priest-pain-scope-primary", "priest-pain-scope-other"]) {
useCombatStore.getState().registerMob(targetId, {
name: "Pain scope target", maxHealth: 1_000_000, xpReward: 0,
});
setMobPosition(targetId, [4, 0, 0]);
}
expect(castHostile("Shadow Word: Pain", "priest-pain-scope-primary", 1_000).ok).toBe(true);
expect(castHostile("Shadow Word: Pain", "priest-pain-scope-other", 5_000).ok).toBe(true);
const ownedPrimary = shadowWordPainEffect("priest-pain-scope-primary")!;
const ownedOther = shadowWordPainEffect("priest-pain-scope-other")!;
const foreignPrimary = { ...ownedPrimary, id: "foreign-priest-shadow-word-pain", sourceActorId: "other-priest" };
useCombatStore.setState((state) => ({ effects: [...state.effects, foreignPrimary] }));
const endOtherBefore = ownedOther.nextTickAt + (ownedOther.remainingTicks - 1) * ownedOther.intervalMs;
const endForeignBefore = foreignPrimary.nextTickAt + (foreignPrimary.remainingTicks - 1) * foreignPrimary.intervalMs;
expect(castHostile("Mind Blast", "priest-pain-scope-primary", 10_000).ok).toBe(true);
const ownAfter = shadowWordPainEffect("priest-pain-scope-primary")!;
const otherAfter = shadowWordPainEffect("priest-pain-scope-other")!;
const foreignAfter = useCombatStore.getState().effects.find((effect) => effect.id === foreignPrimary.id)!;
expect(ownAfter.remainingTicks).toBe(6);
expect(otherAfter.nextTickAt + (otherAfter.remainingTicks - 1) * otherAfter.intervalMs).toBe(endOtherBefore);
expect(foreignAfter.nextTickAt + (foreignAfter.remainingTicks - 1) * foreignAfter.intervalMs).toBe(endForeignBefore);
});
it("does not refresh for unrelated casts or when the target has no Shadow Word: Pain", () => {
const talent = TALENT_NODES.find((node) => node.id === "talent-1909")!;
setPriestTalentRanks({ [talent.id]: 3 });
for (const targetId of ["priest-pain-unrelated", "priest-pain-absent"]) {
useCombatStore.getState().registerMob(targetId, {
name: "Pain trigger guard target", maxHealth: 1_000_000, xpReward: 0,
});
setMobPosition(targetId, [4, 0, 0]);
}
expect(castHostile("Shadow Word: Pain", "priest-pain-unrelated", 1_000).ok).toBe(true);
const dot = shadowWordPainEffect("priest-pain-unrelated")!;
const endBefore = dot.nextTickAt + (dot.remainingTicks - 1) * dot.intervalMs;
expect(castHostile("Smite", "priest-pain-unrelated", 5_000).ok).toBe(true);
const afterSmite = shadowWordPainEffect("priest-pain-unrelated")!;
expect(afterSmite.nextTickAt + (afterSmite.remainingTicks - 1) * afterSmite.intervalMs).toBe(endBefore);
expect(castHostile("Mind Blast", "priest-pain-absent", 10_000).ok).toBe(true);
expect(shadowWordPainEffect("priest-pain-absent")).toBeUndefined();
});
it.each(["Mind Flay", "Mind Sear", "Mind Blast"])("refreshes Shadow Word: Pain after a %s cast", (castName) => {
const talent = TALENT_NODES.find((node) => node.id === "talent-1909")!;
const castAbilityNodeId = abilityTalentNodeId(priestAbility(castName));
setPriestTalentRanks({
[talent.id]: 3,
...(castAbilityNodeId ? { [castAbilityNodeId]: 1 } : {}),
});
const targetId = `priest-pain-and-suffering-${castName.toLowerCase().replaceAll(" ", "-")}`;
useCombatStore.getState().registerMob(targetId, {
name: "Pain and Suffering eligible cast target", maxHealth: 1_000_000, xpReward: 0,
});
setMobPosition(targetId, [4, 0, 0]);
expect(castHostile("Shadow Word: Pain", targetId, 1_000).ok).toBe(true);
useCombatStore.getState().tick(9, 10_000);
const before = shadowWordPainEffect(targetId)!;
expect(before.remainingTicks).toBeLessThan(6);
const castResult = castHostile(castName, targetId, 11_000);
expect(castResult.ok, JSON.stringify(castResult)).toBe(true);
const cast = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "cast" && event.abilityId === priestAbility(castName).id && event.targetActorId === targetId
)).at(-1)!;
const after = shadowWordPainEffect(targetId)!;
expect(after.remainingTicks).toBe(6);
expect(after.nextTickAt).toBe(cast.occurredAt + after.intervalMs);
expect(after.tickAmounts).toEqual(before.tickAmounts);
expect(after.basePoints).toBe(before.basePoints);
expect(after.intervalMs).toBe(before.intervalMs);
expect(after.damageSchool).toBe(before.damageSchool);
expect(after.id).toBe(before.id);
});
it("procs Holy Concentration on a critical heal and extends nearby Renew by at most six seconds", () => {
const talent = TALENT_NODES.find((node) => node.name === "Holy Concentration")!;
setPriestTalentRanks({ [talent.id]: 3 }, 100);
const [first, second] = usePartyStore.getState().members;
expect(castFriendly("Renew", first!.id, 1_000).ok).toBe(true);
expect(castFriendly("Renew", second!.id, 4_000).ok).toBe(true);
const beforeExtensions = new Map(useCombatStore.getState().effects.filter((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === priestAbility("Renew").id)
.map((effect) => [effect.targetId ?? PLAYER_AURA_ENTITY_ID,
effect.nextTickAt + (effect.remainingTicks - 1) * effect.intervalMs]));
setMemberHealth(second!.id, 1, second!.maxHealth);
expect(castFriendly("Flash Heal", second!.id, 7_000).ok).toBe(true);
const concentration = useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes("wow335-priest-holy-concentration"));
expect(concentration?.expiresAt).toBeGreaterThan(0);
expect(concentration?.definition.modifiers).toContainEqual(expect.objectContaining({
kind: "stat", stat: "spirit", operation: "percent", value: 0.3,
}));
let renewEffects = useCombatStore.getState().effects.filter((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === priestAbility("Renew").id);
for (const effect of renewEffects) {
const target = effect.targetId ?? PLAYER_AURA_ENTITY_ID;
expect(effect.extensionTicks).toBe(1);
expect(effect.nextTickAt + (effect.remainingTicks - 1) * effect.intervalMs).toBe(beforeExtensions.get(target)! + 3_000);
}
setMemberHealth(second!.id, 1, second!.maxHealth);
expect(castFriendly("Flash Heal", second!.id, 11_000).ok).toBe(true);
renewEffects = useCombatStore.getState().effects.filter((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === priestAbility("Renew").id);
for (const effect of renewEffects) expect(effect.extensionTicks).toBe(2);
setMemberHealth(second!.id, 1, second!.maxHealth);
expect(castFriendly("Flash Heal", second!.id, 15_000).ok).toBe(true);
renewEffects = useCombatStore.getState().effects.filter((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === priestAbility("Renew").id);
for (const effect of renewEffects) expect(effect.extensionTicks).toBe(2);
});
it("gives the three Prayer buffs party scope and Pain Suppression its target threat reduction", () => {
for (const name of ["Prayer of Fortitude", "Prayer of Shadow Protection", "Prayer of Spirit"]) {
const rank = abilityAtLevel(priestAbility(name), 80);
expect(rank.effects.some((effect) => effect.kind === "apply-aura" && effect.scope === "party"), name).toBe(true);
}
const painSuppression = abilityAtLevel(priestAbility("Pain Suppression"), 80);
expect(painSuppression.effects).toContainEqual({
kind: "threat", mode: "percent", amount: -40, recipient: "ability-target",
});
});
it("summons a nonattacking Lightwell and heals for six seconds per click", () => {
unlock("Lightwell");
const ability = priestAbility("Lightwell");
expect(useCombatStore.getState().castAbility(ability.id, [0, 0, 0], 1_000).ok).toBe(true);
const completion = useCombatStore.getState().activeCast?.completesAt;
expect(completion).toBeDefined();
useCombatStore.getState().tick(1, completion!);
const well = useCombatStore.getState().summons.find((summon) => summon.sourceAbilityId === ability.id);
expect(well).toMatchObject({ clickCharges: 10, expiresAt: completion! + 180_000, attackDamage: 0 });
useCombatStore.getState().damagePlayer(500);
const healthBefore = useCombatStore.getState().health;
const clickAmount = useCombatStore.getState().clickLightwell(well!.id, PLAYER_AURA_ENTITY_ID, completion! + 1);
expect(clickAmount).toBe(1_540 * 3);
expect(useCombatStore.getState().health).toBe(healthBefore);
expect(useCombatStore.getState().effects).toContainEqual(expect.objectContaining({
kind: "hot",
sourceAbilityId: ability.id,
remainingTicks: 3,
intervalMs: 2_000,
coefficient: 0,
basePoints: 1_540,
nextTickAt: completion! + 2_001,
}));
useCombatStore.getState().tick(1, completion! + 1_001);
expect(useCombatStore.getState().health).toBe(healthBefore);
expect(useCombatStore.getState().effects.find((effect) => effect.sourceAbilityId === ability.id)?.remainingTicks).toBe(3);
useCombatStore.getState().tick(1, completion! + 2_001);
expect(useCombatStore.getState().health).toBeGreaterThan(healthBefore);
expect(useCombatStore.getState().effects.find((effect) => effect.sourceAbilityId === ability.id)?.remainingTicks).toBe(2);
useCombatStore.getState().tick(4, completion! + 6_001);
expect(useCombatStore.getState().summons.find((summon) => summon.id === well!.id)?.clickCharges).toBe(9);
for (let click = 1; click < 10; click += 1) {
useCombatStore.getState().clickLightwell(well!.id, PLAYER_AURA_ENTITY_ID, completion! + 1 + click);
}
expect(useCombatStore.getState().summons.some((summon) => summon.id === well!.id)).toBe(false);
expect(useCombatStore.getState().clickLightwell(well!.id, PLAYER_AURA_ENTITY_ID, completion! + 20)).toBe(0);
});
it("uses the sourced per-tick spell-power coefficient for Lightwell ranks with a bonus row", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 75 });
useCombatStore.setState((state) => ({ gearStats: { ...state.gearStats, spellPower: 300 } }));
usePartyStore.getState().initializeParty("wow-priest-lightwell-coefficient", 75, useCombatStore.getState().health);
useCombatStore.getState().synchronizeActors();
unlock("Lightwell");
const ability = priestAbility("Lightwell");
expect(useCombatStore.getState().castAbility(ability.id, [0, 0, 0], 1_000).ok).toBe(true);
const completion = useCombatStore.getState().activeCast?.completesAt;
expect(completion).toBeDefined();
useCombatStore.getState().tick(1, completion!);
const well = useCombatStore.getState().summons.find((summon) => summon.sourceAbilityId === ability.id);
expect(well?.creatureId).toBe(31893);
expect(useCombatStore.getState().clickLightwell(well!.id, PLAYER_AURA_ENTITY_ID, completion! + 1)).toBe(1_405 * 3);
expect(useCombatStore.getState().effects).toContainEqual(expect.objectContaining({
sourceAbilityId: ability.id,
basePoints: 1_305,
coefficient: 0.3333,
remainingTicks: 3,
intervalMs: 2_000,
}));
});
it("cancels a Lightwell HoT after cumulative effective damage reaches 30% of recipient max health", () => {
unlock("Lightwell");
const ability = priestAbility("Lightwell");
expect(useCombatStore.getState().castAbility(ability.id, [0, 0, 0], 1_000).ok).toBe(true);
const completion = useCombatStore.getState().activeCast?.completesAt;
expect(completion).toBeDefined();
useCombatStore.getState().tick(1, completion!);
const well = useCombatStore.getState().summons.find((summon) => summon.sourceAbilityId === ability.id);
expect(well).toBeDefined();
const maxHealth = useCombatStore.getState().maxHealth;
useCombatStore.setState({ health: maxHealth, shield: 0 });
useCombatStore.getState().clickLightwell(well!.id, PLAYER_AURA_ENTITY_ID, completion! + 1);
const threshold = maxHealth * 0.3;
const firstDamage = useCombatStore.getState().damagePlayer(Math.floor(maxHealth * 0.2), "shadow", undefined, completion! + 2);
const afterFirstHit = useCombatStore.getState().effects.find((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === ability.id);
expect(firstDamage).toBeGreaterThan(0);
expect(firstDamage).toBeLessThan(threshold);
expect(afterFirstHit?.lightwellDamageCancellation).toEqual({ threshold, accumulatedDamage: firstDamage });
const secondDamage = useCombatStore.getState().damagePlayer(Math.floor(maxHealth * 0.2), "shadow", undefined, completion! + 3);
expect(firstDamage + secondDamage).toBeGreaterThanOrEqual(threshold);
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === ability.id)).toBe(false);
useCombatStore.getState().clickLightwell(well!.id, PLAYER_AURA_ENTITY_ID, completion! + 4);
expect(useCombatStore.getState().effects.find((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === ability.id)?.lightwellDamageCancellation).toEqual({
threshold,
accumulatedDamage: 0,
});
});
it("does not count fully absorbed damage and starts a fresh threshold after the HoT expires", () => {
unlock("Lightwell");
const ability = priestAbility("Lightwell");
expect(useCombatStore.getState().castAbility(ability.id, [0, 0, 0], 1_000).ok).toBe(true);
const completion = useCombatStore.getState().activeCast?.completesAt;
expect(completion).toBeDefined();
useCombatStore.getState().tick(1, completion!);
const well = useCombatStore.getState().summons.find((summon) => summon.sourceAbilityId === ability.id);
expect(well).toBeDefined();
useCombatStore.getState().clickLightwell(well!.id, PLAYER_AURA_ENTITY_ID, completion! + 1);
useCombatStore.setState({ shield: 2_000 });
expect(useCombatStore.getState().damagePlayer(1_000, "shadow", undefined, completion! + 2)).toBe(0);
expect(useCombatStore.getState().effects.find((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === ability.id)?.lightwellDamageCancellation?.accumulatedDamage).toBe(0);
useCombatStore.getState().tick(1, completion! + 6_001);
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === ability.id)).toBe(false);
useCombatStore.getState().clickLightwell(well!.id, PLAYER_AURA_ENTITY_ID, completion! + 6_002);
expect(useCombatStore.getState().effects.find((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === ability.id)?.lightwellDamageCancellation?.accumulatedDamage).toBe(0);
});
it("tracks Lightwell damage separately for each recipient and on resolved enemy hits", () => {
unlock("Lightwell");
const ability = priestAbility("Lightwell");
expect(useCombatStore.getState().castAbility(ability.id, [0, 0, 0], 1_000).ok).toBe(true);
const completion = useCombatStore.getState().activeCast?.completesAt;
expect(completion).toBeDefined();
useCombatStore.getState().tick(1, completion!);
const well = useCombatStore.getState().summons.find((summon) => summon.sourceAbilityId === ability.id);
expect(well).toBeDefined();
const member = usePartyStore.getState().members[0]!;
useCombatStore.getState().clickLightwell(well!.id, PLAYER_AURA_ENTITY_ID, completion! + 1);
useCombatStore.getState().clickLightwell(well!.id, member.id, completion! + 1);
useCombatStore.getState().registerMob("priest-lightwell-hit", {
name: "Lightwell damage test",
maxHealth: 100_000,
xpReward: 0,
attacks: [{
...DEFAULT_ENEMY_ATTACK,
id: "priest-lightwell-shadow-cast",
name: "Shadow cast",
delivery: "projectile",
animation: "cast",
school: "shadow",
range: 30,
castTimeMs: 0,
}],
});
setMobPosition("priest-lightwell-hit", [8, 0, 0]);
useCombatStore.getState().damageMob("priest-lightwell-hit", 1, completion! + 1);
const beforeHit = useCombatStore.getState().health;
useCombatStore.getState().advanceMobCombat(completion! + 451, [0, 0, 0]);
const appliedDamage = beforeHit - useCombatStore.getState().health;
const effects = useCombatStore.getState().effects.filter((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === ability.id);
expect(appliedDamage).toBeGreaterThan(0);
expect(effects.find((effect) => effect.targetId === null)?.lightwellDamageCancellation?.accumulatedDamage)
.toBe(appliedDamage);
expect(effects.find((effect) => effect.targetId === member.id)?.lightwellDamageCancellation?.accumulatedDamage)
.toBe(0);
});
it("counts resolved enemy attacks against a party member toward that member's Lightwell cancellation", () => {
unlock("Lightwell");
const ability = priestAbility("Lightwell");
expect(useCombatStore.getState().castAbility(ability.id, [0, 0, 0], 1_000).ok).toBe(true);
const completion = useCombatStore.getState().activeCast?.completesAt;
expect(completion).toBeDefined();
useCombatStore.getState().tick(1, completion!);
const well = useCombatStore.getState().summons.find((summon) => summon.sourceAbilityId === ability.id);
expect(well).toBeDefined();
const member = usePartyStore.getState().members[0]!;
setMemberHealth(member.id, 100_000, 100_000);
useCombatStore.getState().clickLightwell(well!.id, member.id, completion! + 1);
useCombatStore.getState().registerMob("priest-lightwell-party-hit", {
name: "Lightwell party damage test",
maxHealth: 100_000,
xpReward: 0,
attacks: [{
...DEFAULT_ENEMY_ATTACK,
id: "priest-lightwell-party-strike",
name: "Party strike",
range: 30,
cooldownMs: 1_900,
}],
});
setMobPosition("priest-lightwell-party-hit", [4, 0, 0]);
useCombatStore.getState().damageMob("priest-lightwell-party-hit", 1, completion! + 1, {
actorId: member.id,
role: member.role,
});
useCombatStore.getState().advanceMobCombat(completion! + 451, [0, 0, 0]);
const damagedMember = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!;
const effectiveDamage = 100_000 - damagedMember.health;
const memberHot = useCombatStore.getState().effects.find((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === ability.id && effect.targetId === member.id);
expect(effectiveDamage).toBeGreaterThan(0);
expect(memberHot?.lightwellDamageCancellation?.accumulatedDamage).toBe(effectiveDamage);
});
it("applies Spell Warding to incoming magic damage by rank and leaves physical damage unchanged", () => {
const incomingDamageAtRank = (rank: number, school: "physical" | "shadow"): number => {
setPriestTalentRanks(rank > 0 ? { "talent-411": rank } : {});
useCombatStore.setState({ health: 1_000_000, maxHealth: 1_000_000, shield: 0 });
const before = useCombatStore.getState().health;
useCombatStore.getState().damagePlayer(10_000, school, 80, 1_000 + rank);
return before - useCombatStore.getState().health;
};
const magicDamage = [0, 1, 2, 3].map((rank) => incomingDamageAtRank(rank, "shadow"));
expect(magicDamage[0]).toBeGreaterThan(magicDamage[1]!);
expect(magicDamage[1]).toBeGreaterThan(magicDamage[2]!);
expect(magicDamage[2]).toBeGreaterThan(magicDamage[3]!);
expect(magicDamage[3]).toBeCloseTo(magicDamage[0]! * 0.97, -1);
const physicalDamage = [0, 1, 2, 3].map((rank) => incomingDamageAtRank(rank, "physical"));
expect(physicalDamage).toEqual([physicalDamage[0], physicalDamage[0], physicalDamage[0], physicalDamage[0]]);
});
it("applies Spell Warding to resolved enemy spell attacks without reducing enemy melee hits", () => {
const resolvedEnemyDamageAtRank = (rank: number, school: "physical" | "shadow"): number => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("wow-priest-spell-warding", 80, useCombatStore.getState().health);
usePartyStore.getState().selectMember(null);
useCombatStore.getState().synchronizeActors();
setPriestTalentRanks(rank > 0 ? { "talent-411": rank } : {});
const mobId = "priest-spell-warding-attacker";
const attackId = "priest-spell-warding-attack";
useCombatStore.getState().registerMob(mobId, {
name: "Spell Warding attacker",
maxHealth: 100_000,
xpReward: 0,
attacks: [{
...DEFAULT_ENEMY_ATTACK,
id: attackId,
delivery: school === "physical" ? "melee" : "projectile",
animation: school === "physical" ? "attack" : "cast",
school,
range: 30,
castTimeMs: 0,
}],
});
setMobPosition(mobId, [8, 0, 0]);
useCombatStore.getState().damageMob(mobId, 1, 1_000);
const before = useCombatStore.getState().health;
useCombatStore.getState().advanceMobCombat(1_450, [0, 0, 0]);
return before - useCombatStore.getState().health;
};
const baseSpellHit = resolvedEnemyDamageAtRank(0, "shadow");
const wardedSpellHit = resolvedEnemyDamageAtRank(3, "shadow");
expect(baseSpellHit).toBeGreaterThan(0);
expect(wardedSpellHit).toBeLessThan(baseSpellHit);
const baseMeleeHit = resolvedEnemyDamageAtRank(0, "physical");
const wardedMeleeHit = resolvedEnemyDamageAtRank(3, "physical");
expect(baseMeleeHit).toBeGreaterThan(0);
expect(wardedMeleeHit).toBe(baseMeleeHit);
});
});