1059 lines
53 KiB
TypeScript
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);
|
|
});
|
|
});
|