Update Healer Man server
This commit is contained in:
1 parent
9d7e0e2f9e
commit
35dfdaed61
94 files changed
+17034
-418
No files matched your search
@@ -0,0 +1,672 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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("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).toBeGreaterThan(0);
|
||||
expect(useCombatStore.getState().health).toBe(healthBefore);
|
||||
expect(useCombatStore.getState().effects).toContainEqual(expect.objectContaining({
|
||||
kind: "hot",
|
||||
sourceAbilityId: ability.id,
|
||||
remainingTicks: 6,
|
||||
intervalMs: 1_000,
|
||||
fixedAmount: expect.any(Number),
|
||||
nextTickAt: completion! + 1_001,
|
||||
}));
|
||||
useCombatStore.getState().tick(6, completion! + 6_001);
|
||||
expect(useCombatStore.getState().health).toBeGreaterThan(healthBefore);
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user