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,552 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry, updatePartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { abilityTalentNodeId, preloadAbilityCatalogsForClasses } from "./abilityCatalog";
|
||||
import { applyAura } from "./combatAuras";
|
||||
import { WOW335_TALENT_NODES } from "./wow335Talents.generated";
|
||||
|
||||
function abilityId(name: string): string {
|
||||
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
|
||||
expect(ability, name).toBeDefined();
|
||||
return ability!.id;
|
||||
}
|
||||
|
||||
function unlockTalentAbility(name: string): string {
|
||||
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.name === name);
|
||||
expect(ability, name).toBeDefined();
|
||||
const nodeId = abilityTalentNodeId(ability!);
|
||||
expect(nodeId, name).toBeTruthy();
|
||||
useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [nodeId!]: 1 } }));
|
||||
return ability!.id;
|
||||
}
|
||||
|
||||
function setTalentRank(name: string, rank: number): void {
|
||||
const talent = WOW335_TALENT_NODES.find((candidate) => candidate.name === name);
|
||||
expect(talent, name).toBeDefined();
|
||||
useCombatStore.setState((state) => ({
|
||||
talentRanks: { ...state.talentRanks, [talent!.id]: rank },
|
||||
}));
|
||||
}
|
||||
|
||||
describe("WoW 3.3.5 Paladin source-backed runtime mechanics", () => {
|
||||
beforeEach(async () => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
await preloadAbilityCatalogsForClasses(["paladin"]);
|
||||
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
|
||||
useCombatStore.getState().resetSettings();
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
usePartyStore.getState().initializeParty("wow335-paladin-runtime-tests", 80, useCombatStore.getState().health);
|
||||
useCombatStore.setState((state) => ({
|
||||
resource: state.maxResource,
|
||||
resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana },
|
||||
}));
|
||||
});
|
||||
|
||||
it("blocks silence with Aura Mastery for its sourced 10-second window, then allows silence after expiry", () => {
|
||||
const mobId = "paladin-aura-mastery-silence";
|
||||
useCombatStore.getState().registerMob(mobId, {
|
||||
name: "Silence attacker",
|
||||
maxHealth: 10_000,
|
||||
attacks: [{
|
||||
id: "silence",
|
||||
name: "Silence",
|
||||
delivery: "area",
|
||||
target: "primary",
|
||||
school: "shadow",
|
||||
animation: "cast",
|
||||
castTimeMs: 0,
|
||||
range: 30,
|
||||
cooldownMs: 10_000,
|
||||
initialCooldownMs: [0, 0],
|
||||
repeatCooldownMs: [10_000, 10_000],
|
||||
damageMultiplier: 0,
|
||||
effect: { kind: "control", mechanic: "silence", durationMs: 3_000 },
|
||||
}],
|
||||
});
|
||||
setMobPosition(mobId, [2, 0, 0]);
|
||||
useCombatStore.getState().selectMob(mobId);
|
||||
useCombatStore.getState().damageMob(mobId, 1, 1_000);
|
||||
|
||||
const activate = useCombatStore.getState().castAbility(unlockTalentAbility("Aura Mastery"), undefined, 1_000);
|
||||
expect(activate).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.controlImmunities?.includes("silence")
|
||||
&& aura.expiresAt === 11_000
|
||||
))).toBe(true);
|
||||
const partyMember = usePartyStore.getState().members[0]!;
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.targetId === partyMember.id
|
||||
&& aura.definition.controlImmunities?.includes("silence")
|
||||
&& aura.expiresAt === 11_000
|
||||
))).toBe(true);
|
||||
|
||||
useCombatStore.getState().advanceMobCombat(1_450);
|
||||
expect(useCombatStore.getState().controlMechanic).toBeNull();
|
||||
expect(useCombatStore.getState().schoolLockedUntil).toEqual({});
|
||||
|
||||
useCombatStore.getState().tick(0.1, 11_001);
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.definition.controlImmunities?.includes("silence")
|
||||
))).toBe(false);
|
||||
useCombatStore.getState().advanceMobCombat(11_450);
|
||||
expect(useCombatStore.getState().controlMechanic).toBe("silence");
|
||||
expect(useCombatStore.getState().controlledUntil).toBe(14_450);
|
||||
});
|
||||
|
||||
it("lets Redemption target and revive a defeated party member at the rank's source health", () => {
|
||||
const member = usePartyStore.getState().members[0]!;
|
||||
usePartyStore.setState((party) => ({
|
||||
members: party.members.map((candidate) => candidate.id === member.id
|
||||
? { ...candidate, health: 0, status: "down" as const }
|
||||
: candidate),
|
||||
}));
|
||||
updatePartyRuntimePosition(member.id, 4, 0, 0);
|
||||
usePartyStore.getState().selectMember(member.id);
|
||||
|
||||
const cast = useCombatStore.getState().castAbility(abilityId("Redemption"), undefined, 1_000);
|
||||
expect(cast).toMatchObject({ ok: true });
|
||||
const activeCast = useCombatStore.getState().activeCast;
|
||||
expect(activeCast?.targetId).toBe(member.id);
|
||||
useCombatStore.getState().tick(0.1, activeCast!.completesAt);
|
||||
|
||||
expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)?.health).toBe(1_760);
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => (
|
||||
event.kind === "resurrection" && event.targetActorId === member.id
|
||||
))).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks Redemption while any living enemy is engaged", () => {
|
||||
const member = usePartyStore.getState().members[0]!;
|
||||
usePartyStore.setState((party) => ({
|
||||
members: party.members.map((candidate) => candidate.id === member.id
|
||||
? { ...candidate, health: 0, status: "down" as const }
|
||||
: candidate),
|
||||
}));
|
||||
updatePartyRuntimePosition(member.id, 4, 0, 0);
|
||||
usePartyStore.getState().selectMember(member.id);
|
||||
|
||||
const targetId = "paladin-redemption-combat-gate";
|
||||
useCombatStore.getState().registerMob(targetId, { name: "Engaged enemy", maxHealth: 10_000 });
|
||||
setMobPosition(targetId, [4, 0, 0]);
|
||||
useCombatStore.setState((state) => ({
|
||||
mobs: { ...state.mobs, [targetId]: { ...state.mobs[targetId]!, engaged: true } },
|
||||
}));
|
||||
|
||||
const result = useCombatStore.getState().castAbility(abilityId("Redemption"), undefined, 1_000);
|
||||
expect(result).toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
expect(useCombatStore.getState().activeCast).toBeNull();
|
||||
expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)?.health).toBe(0);
|
||||
});
|
||||
|
||||
it("enforces Hammer of Wrath's 20% execute gate", () => {
|
||||
const targetId = "paladin-hammer-gate-target";
|
||||
useCombatStore.getState().registerMob(targetId, { name: "Target", maxHealth: 10_000 });
|
||||
setMobPosition(targetId, [4, 0, 0]);
|
||||
useCombatStore.setState((state) => ({
|
||||
mobs: { ...state.mobs, [targetId]: { ...state.mobs[targetId]!, health: 2_001 } },
|
||||
selectedTargetId: targetId,
|
||||
}));
|
||||
|
||||
const result = useCombatStore.getState().castAbility(abilityId("Hammer of Wrath"), undefined, 1_000);
|
||||
expect(result).toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
});
|
||||
|
||||
it("requires a shield before Holy Shield can be activated", () => {
|
||||
useCombatStore.setState({ equipment: {}, inventory: [] });
|
||||
expect(useCombatStore.getState().abilities.find((candidate) => candidate.name === "Holy Shield")?.target).toBe("self");
|
||||
const result = useCombatStore.getState().castAbility(unlockTalentAbility("Holy Shield"), undefined, 1_000);
|
||||
expect(result).toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
});
|
||||
|
||||
it("procs three 8-second Reckoning charges from melee hits and adds one landed autoattack per charge", () => {
|
||||
setTalentRank("Reckoning", 5);
|
||||
const mobId = "paladin-reckoning-trigger";
|
||||
useCombatStore.getState().registerMob(mobId, {
|
||||
name: "Reckoning attacker",
|
||||
level: 80,
|
||||
maxHealth: 100_000,
|
||||
attacks: [{
|
||||
id: "reckoning-melee",
|
||||
name: "Melee attack",
|
||||
delivery: "melee",
|
||||
target: "primary",
|
||||
school: "physical",
|
||||
animation: "attack",
|
||||
castTimeMs: 0,
|
||||
range: 8,
|
||||
cooldownMs: 500,
|
||||
initialCooldownMs: [0, 0],
|
||||
repeatCooldownMs: [500, 500],
|
||||
damageMultiplier: 0.1,
|
||||
}],
|
||||
});
|
||||
setMobPosition(mobId, [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob(mobId, 1, 1_000);
|
||||
const ensureMeleeHit = applyAura(useCombatStore.getState().auras, {
|
||||
definition: {
|
||||
id: "test:ensure-reckoning-melee-hit",
|
||||
name: "Ensure incoming melee hit",
|
||||
disposition: "buff",
|
||||
durationMs: null,
|
||||
modifiers: [{ kind: "stat", stat: "incoming-melee-hit", operation: "flat", value: 100 }],
|
||||
},
|
||||
sourceId: mobId,
|
||||
targetId: PLAYER_AURA_ENTITY_ID,
|
||||
now: 1_000,
|
||||
}).auras;
|
||||
useCombatStore.setState((state) => ({ auras: [...ensureMeleeHit] }));
|
||||
|
||||
let procAt: number | null = null;
|
||||
for (let now = 1_450; now <= 30_000; now += 250) {
|
||||
useCombatStore.getState().advanceMobCombat(now);
|
||||
if (useCombatStore.getState().auras.some((aura) => (
|
||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes("wow335-paladin-reckoning")
|
||||
))) {
|
||||
procAt = now;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(procAt).not.toBeNull();
|
||||
const reckoning = useCombatStore.getState().auras.find((aura) => (
|
||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes("wow335-paladin-reckoning")
|
||||
));
|
||||
expect(reckoning).toMatchObject({ stacks: 3, expiresAt: procAt! + 8_000 });
|
||||
|
||||
useCombatStore.setState((state) => ({
|
||||
autoAttackTargetId: mobId,
|
||||
nextAutoAttackAt: procAt! + 100,
|
||||
gearStats: { ...state.gearStats, hitRating: 1_000_000, expertiseRating: 1_000_000 },
|
||||
}));
|
||||
let autoAttackAt = procAt! + 100;
|
||||
for (let charge = 2; charge >= 0; charge -= 1) {
|
||||
useCombatStore.getState().tick(0.1, autoAttackAt);
|
||||
const swingEvents = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage"
|
||||
&& event.sourceActorId === PLAYER_AURA_ENTITY_ID
|
||||
&& event.targetActorId === mobId
|
||||
&& event.occurredAt === autoAttackAt
|
||||
));
|
||||
expect(swingEvents).toHaveLength(2);
|
||||
const remaining = useCombatStore.getState().auras.find((aura) => (
|
||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes("wow335-paladin-reckoning")
|
||||
));
|
||||
if (charge > 0) expect(remaining?.stacks).toBe(charge);
|
||||
else expect(remaining).toBeUndefined();
|
||||
autoAttackAt = useCombatStore.getState().nextAutoAttackAt;
|
||||
}
|
||||
});
|
||||
|
||||
it("reflects the sourced rank percentage of incoming critical damage with Eye for an Eye", () => {
|
||||
const mobId = "paladin-eye-for-an-eye-crit";
|
||||
useCombatStore.getState().registerMob(mobId, {
|
||||
name: "Critical attacker",
|
||||
level: 80,
|
||||
maxHealth: 100_000,
|
||||
damageMultiplier: 1,
|
||||
attacks: [{
|
||||
id: "eye-crit-18",
|
||||
name: "Critical attack",
|
||||
delivery: "area",
|
||||
target: "primary",
|
||||
school: "fire",
|
||||
animation: "cast",
|
||||
castTimeMs: 0,
|
||||
range: 30,
|
||||
cooldownMs: 10_000,
|
||||
initialCooldownMs: [0, 0],
|
||||
repeatCooldownMs: [10_000, 10_000],
|
||||
damageMultiplier: 1,
|
||||
}],
|
||||
});
|
||||
setMobPosition(mobId, [2, 0, 0]);
|
||||
useCombatStore.getState().selectMob(mobId);
|
||||
useCombatStore.getState().damageMob(mobId, 1, 1_000);
|
||||
|
||||
const ensureSpellHit = applyAura([], {
|
||||
definition: {
|
||||
id: "test:ensure-incoming-spell-hit",
|
||||
name: "Ensure incoming spell hit",
|
||||
disposition: "buff",
|
||||
durationMs: null,
|
||||
modifiers: [{ kind: "stat", stat: "incoming-spell-hit", operation: "flat", value: 100 }],
|
||||
},
|
||||
sourceId: mobId,
|
||||
targetId: PLAYER_AURA_ENTITY_ID,
|
||||
now: 1_000,
|
||||
}).auras;
|
||||
useCombatStore.setState((state) => ({
|
||||
talentRanks: { ...state.talentRanks, "talent-1632": 3 },
|
||||
auras: [...state.auras, ...ensureSpellHit],
|
||||
}));
|
||||
|
||||
// This seeded instant spell attack rolls inside the crit slice of the
|
||||
// equal-level attack table, so the test does not rely on chance.
|
||||
useCombatStore.getState().advanceMobCombat(1_450);
|
||||
useCombatStore.getState().advanceMobCombat(3_100);
|
||||
const events = useCombatStore.getState().combatEvents;
|
||||
const incoming = events.find((event) => event.kind === "damage"
|
||||
&& event.sourceActorId === mobId && event.targetActorId === PLAYER_AURA_ENTITY_ID);
|
||||
expect(incoming?.critical, JSON.stringify(incoming)).toBe(true);
|
||||
const reflected = events.find((event) => event.kind === "damage"
|
||||
&& event.targetActorId === mobId && event.abilityId === "talent-1632");
|
||||
expect(reflected?.rawAmount).toBe(Math.round((incoming?.effectiveAmount ?? 0) * 0.15));
|
||||
});
|
||||
|
||||
it("bounces Avenger's Shield to three total targets without damage falloff", () => {
|
||||
const shieldId = unlockTalentAbility("Avenger's Shield");
|
||||
const targetIds = [
|
||||
"paladin-avenger-target",
|
||||
"paladin-avenger-bounce-a",
|
||||
"paladin-avenger-bounce-b",
|
||||
"paladin-avenger-bounce-c",
|
||||
"paladin-avenger-out-of-range",
|
||||
];
|
||||
for (const id of targetIds) {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000 });
|
||||
}
|
||||
setMobPosition(targetIds[0]!, [4, 0, 0]);
|
||||
setMobPosition(targetIds[1]!, [10, 0, 0]);
|
||||
setMobPosition(targetIds[2]!, [16, 0, 0]);
|
||||
setMobPosition(targetIds[3]!, [22, 0, 0]);
|
||||
setMobPosition(targetIds[4]!, [10, 0, 12]);
|
||||
useCombatStore.getState().selectMob(targetIds[0]!);
|
||||
|
||||
const result = useCombatStore.getState().castAbility(shieldId, undefined, 1_000);
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
|
||||
expect(result.affectedMobIds).toEqual(targetIds.slice(0, 3));
|
||||
expect(result.affectedMobIds).not.toContain(targetIds[3]);
|
||||
expect(result.affectedMobIds).not.toContain(targetIds[4]);
|
||||
|
||||
const damageEvents = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage" && event.abilityId === shieldId
|
||||
&& result.affectedMobIds.includes(event.targetActorId)
|
||||
));
|
||||
expect(damageEvents).toHaveLength(3);
|
||||
const normalizedRawDamage = damageEvents.map((event) => Math.round(event.rawAmount / (event.critical ? 2 : 1)));
|
||||
expect(new Set(normalizedRawDamage).size).toBe(1);
|
||||
});
|
||||
|
||||
it("applies a Greater Blessing to the selected ally's class group", () => {
|
||||
const members = usePartyStore.getState().members;
|
||||
const target = members[0]!;
|
||||
usePartyStore.setState((party) => ({
|
||||
members: party.members.map((candidate, index) => ({
|
||||
...candidate,
|
||||
classId: index < 2 ? "mage" : "priest",
|
||||
})),
|
||||
}));
|
||||
for (const member of members) updatePartyRuntimePosition(member.id, 1, 0, 0);
|
||||
usePartyStore.getState().selectMember(target.id);
|
||||
|
||||
const result = useCombatStore.getState().castAbility(abilityId("Greater Blessing of Kings"), undefined, 1_000);
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
|
||||
const blessedIds = new Set(useCombatStore.getState().auras
|
||||
.filter((aura) => aura.definition.name.includes("Greater Blessing of Kings"))
|
||||
.map((aura) => aura.targetId));
|
||||
expect(blessedIds).toEqual(new Set([target.id, members[1]!.id]));
|
||||
expect(blessedIds.has(PLAYER_AURA_ENTITY_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it("heals at most three party actors from Divine Storm damage", () => {
|
||||
const members = usePartyStore.getState().members;
|
||||
usePartyStore.setState((party) => ({
|
||||
members: party.members.map((member) => ({ ...member, health: 1 })),
|
||||
}));
|
||||
members.forEach((member, index) => updatePartyRuntimePosition(member.id, index + 1, 0, 0));
|
||||
useCombatStore.setState((state) => ({ health: Math.max(1, Math.round(state.maxHealth * 0.1)) }));
|
||||
|
||||
const targetId = "paladin-divine-storm-target";
|
||||
const nearbyTargetId = "paladin-divine-storm-nearby-target";
|
||||
const distantTargetId = "paladin-divine-storm-distant-target";
|
||||
useCombatStore.getState().registerMob(targetId, { name: "Target", maxHealth: 100_000 });
|
||||
useCombatStore.getState().registerMob(nearbyTargetId, { name: "Nearby Target", maxHealth: 100_000 });
|
||||
useCombatStore.getState().registerMob(distantTargetId, { name: "Distant Target", maxHealth: 100_000 });
|
||||
setMobPosition(targetId, [4, 0, 0]);
|
||||
setMobPosition(nearbyTargetId, [7, 0, 0]);
|
||||
setMobPosition(distantTargetId, [13, 0, 0]);
|
||||
useCombatStore.setState((state) => ({ selectedTargetId: targetId }));
|
||||
const divineStorm = unlockTalentAbility("Divine Storm");
|
||||
const result = useCombatStore.getState().castAbility(divineStorm, { targetActorId: targetId, now: 1_000 });
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ ok: true });
|
||||
expect(result.affectedMobIds).toEqual(expect.arrayContaining([targetId, nearbyTargetId]));
|
||||
expect(result.affectedMobIds).not.toContain(distantTargetId);
|
||||
|
||||
const healEvents = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "healing" && event.abilityId === divineStorm && event.effectiveAmount > 0
|
||||
));
|
||||
expect(healEvents.length).toBeGreaterThan(0);
|
||||
expect(healEvents.length).toBeLessThanOrEqual(3);
|
||||
expect(healEvents.every((event) => event.rawAmount > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("transfers direct healing to the active Beacon of Light target", () => {
|
||||
const member = usePartyStore.getState().members[0]!;
|
||||
updatePartyRuntimePosition(member.id, 4, 0, 0);
|
||||
usePartyStore.setState((party) => ({
|
||||
members: party.members.map((candidate) => candidate.id === member.id
|
||||
? { ...candidate, health: 100 }
|
||||
: candidate),
|
||||
}));
|
||||
usePartyStore.getState().selectMember(member.id);
|
||||
|
||||
const beaconResult = useCombatStore.getState().castAbility(unlockTalentAbility("Beacon of Light"), undefined, 1_000);
|
||||
expect(beaconResult).toMatchObject({ ok: true });
|
||||
const beaconCast = useCombatStore.getState().activeCast;
|
||||
if (beaconCast) useCombatStore.getState().tick(0.1, beaconCast.completesAt);
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.targetId === member.id && aura.definition.utilityTags?.includes("beacon-of-light")
|
||||
))).toBe(true);
|
||||
|
||||
useCombatStore.getState().damagePlayer(5_000);
|
||||
const beforeTransfer = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health;
|
||||
const heal = useCombatStore.getState().castAbility(abilityId("Flash of Light"), undefined, 3_000);
|
||||
expect(heal).toMatchObject({ ok: true });
|
||||
const healCast = useCombatStore.getState().activeCast;
|
||||
if (healCast) useCombatStore.getState().tick(0.1, healCast.completesAt);
|
||||
|
||||
const afterTransfer = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health;
|
||||
expect(afterTransfer).toBeGreaterThan(beforeTransfer);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === member.id
|
||||
&& aura.definition.utilityTags?.includes("beacon-of-light"))).toBe(true);
|
||||
});
|
||||
|
||||
it("limits Divine Sacrifice to the remaining portion of its shared damage cap", () => {
|
||||
const member = usePartyStore.getState().members[0]!;
|
||||
updatePartyRuntimePosition(member.id, 0, 0, 0);
|
||||
usePartyStore.setState((party) => ({
|
||||
members: party.members.map((candidate) => candidate.id === member.id
|
||||
? { ...candidate, health: candidate.maxHealth }
|
||||
: candidate),
|
||||
}));
|
||||
usePartyStore.getState().selectMember(member.id);
|
||||
const sacrifice = unlockTalentAbility("Divine Sacrifice");
|
||||
expect(useCombatStore.getState().castAbility(sacrifice, undefined, 1_000).ok).toBe(true);
|
||||
|
||||
const cap = Math.round(useCombatStore.getState().maxHealth * 1.5);
|
||||
const anchor = useCombatStore.getState().auras.find((aura) => (
|
||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes("wow335-paladin-divine-sacrifice")
|
||||
));
|
||||
expect(anchor?.definition.redirectDamageCapPercent).toBe(1.5);
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.targetId === member.id && aura.definition.utilityTags?.includes("wow335-paladin-divine-sacrifice")
|
||||
))).toBe(true);
|
||||
useCombatStore.setState((state) => ({
|
||||
auras: state.auras.map((aura) => aura.instanceId === anchor!.instanceId
|
||||
? { ...aura, redirectedDamage: cap - 20 }
|
||||
: aura),
|
||||
}));
|
||||
|
||||
const mobId = "paladin-divine-sacrifice-partial";
|
||||
useCombatStore.getState().registerMob(mobId, { name: "Cap test attacker", maxHealth: 100_000 });
|
||||
setMobPosition(mobId, [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob(mobId, 1, 1_000, { actorId: member.id, role: "tank" });
|
||||
useCombatStore.setState((state) => ({
|
||||
mobs: { ...state.mobs, [mobId]: { ...state.mobs[mobId]!, attackDamage: 1_000 } },
|
||||
}));
|
||||
const playerBefore = useCombatStore.getState().health;
|
||||
const memberBefore = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health;
|
||||
useCombatStore.getState().advanceMobCombat(1_450);
|
||||
useCombatStore.getState().advanceMobCombat(3_350);
|
||||
|
||||
expect(playerBefore - useCombatStore.getState().health).toBe(20);
|
||||
expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health).toBeLessThan(memberBefore);
|
||||
expect(useCombatStore.getState().auras.find((aura) => aura.instanceId === anchor!.instanceId)?.redirectedDamage).toBe(cap);
|
||||
});
|
||||
|
||||
it("stops Divine Sacrifice redirects after the shared damage cap is exhausted", () => {
|
||||
const member = usePartyStore.getState().members[0]!;
|
||||
updatePartyRuntimePosition(member.id, 0, 0, 0);
|
||||
usePartyStore.setState((party) => ({
|
||||
members: party.members.map((candidate) => candidate.id === member.id
|
||||
? { ...candidate, health: candidate.maxHealth }
|
||||
: candidate),
|
||||
}));
|
||||
usePartyStore.getState().selectMember(member.id);
|
||||
const sacrifice = unlockTalentAbility("Divine Sacrifice");
|
||||
expect(useCombatStore.getState().castAbility(sacrifice, undefined, 1_000).ok).toBe(true);
|
||||
const anchor = useCombatStore.getState().auras.find((aura) => (
|
||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes("wow335-paladin-divine-sacrifice")
|
||||
));
|
||||
expect(anchor).toBeDefined();
|
||||
const cap = Math.round(useCombatStore.getState().maxHealth * 1.5);
|
||||
useCombatStore.setState((state) => ({
|
||||
auras: state.auras.map((aura) => aura.instanceId === anchor!.instanceId
|
||||
? { ...aura, redirectedDamage: cap }
|
||||
: aura),
|
||||
}));
|
||||
|
||||
const mobId = "paladin-divine-sacrifice-exhausted";
|
||||
useCombatStore.getState().registerMob(mobId, { name: "Cap test attacker", maxHealth: 100_000 });
|
||||
setMobPosition(mobId, [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob(mobId, 1, 1_000, { actorId: member.id, role: "tank" });
|
||||
useCombatStore.setState((state) => ({
|
||||
mobs: { ...state.mobs, [mobId]: { ...state.mobs[mobId]!, attackDamage: 1_000 } },
|
||||
}));
|
||||
const playerBefore = useCombatStore.getState().health;
|
||||
const memberBefore = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health;
|
||||
useCombatStore.getState().advanceMobCombat(1_450);
|
||||
const resolvedDamage = useCombatStore.getState().advanceMobCombat(3_350);
|
||||
expect(resolvedDamage, JSON.stringify(useCombatStore.getState().mobs[mobId])).toBeGreaterThan(0);
|
||||
|
||||
expect(useCombatStore.getState().health).toBe(playerBefore);
|
||||
expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!.health).toBeLessThan(memberBefore);
|
||||
expect(useCombatStore.getState().auras.find((aura) => aura.instanceId === anchor!.instanceId)?.redirectedDamage).toBe(cap);
|
||||
});
|
||||
|
||||
it("does not redirect damage from an attack that targets the Paladin", () => {
|
||||
const sacrifice = unlockTalentAbility("Divine Sacrifice");
|
||||
expect(useCombatStore.getState().castAbility(sacrifice, undefined, 1_000).ok).toBe(true);
|
||||
const anchor = useCombatStore.getState().auras.find((aura) => (
|
||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes("wow335-paladin-divine-sacrifice")
|
||||
));
|
||||
expect(anchor).toBeDefined();
|
||||
|
||||
const mobId = "paladin-divine-sacrifice-self-hit";
|
||||
useCombatStore.getState().registerMob(mobId, { name: "Self hit attacker", maxHealth: 100_000 });
|
||||
setMobPosition(mobId, [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob(mobId, 1, 1_000);
|
||||
useCombatStore.setState((state) => ({
|
||||
mobs: { ...state.mobs, [mobId]: { ...state.mobs[mobId]!, attackDamage: 1_000 } },
|
||||
}));
|
||||
const playerBefore = useCombatStore.getState().health;
|
||||
expect(useCombatStore.getState().advanceMobCombat(1_450)).toBeGreaterThan(0);
|
||||
|
||||
expect(useCombatStore.getState().health).toBeLessThan(playerBefore);
|
||||
expect(useCombatStore.getState().auras.find((aura) => aura.instanceId === anchor!.instanceId)?.redirectedDamage).toBe(0);
|
||||
});
|
||||
|
||||
it("makes the next eligible Divine Favor heal critical and consumes its aura", () => {
|
||||
const maxHealth = useCombatStore.getState().maxHealth;
|
||||
useCombatStore.getState().damagePlayer(Math.round(maxHealth * 0.4));
|
||||
|
||||
const activate = useCombatStore.getState().castAbility(unlockTalentAbility("Divine Favor"), undefined, 1_000);
|
||||
expect(activate).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.definition.utilityTags?.includes("wow335-divine-favor-next-holy-heal")
|
||||
))).toBe(true);
|
||||
|
||||
const flashOfLight = abilityId("Flash of Light");
|
||||
const heal = useCombatStore.getState().castAbility(flashOfLight, undefined, 3_000);
|
||||
expect(heal).toMatchObject({ ok: true });
|
||||
const activeCast = useCombatStore.getState().activeCast;
|
||||
if (activeCast) useCombatStore.getState().tick(0.1, activeCast.completesAt);
|
||||
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => (
|
||||
event.kind === "healing" && event.abilityId === flashOfLight && event.critical
|
||||
))).toBe(true);
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.definition.utilityTags?.includes("wow335-divine-favor-next-holy-heal")
|
||||
))).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user