Files
healer-man/src/game/romPriestHolyCandle.integration.test.ts
2026-10-02 10:38:12 -04:00

278 lines
12 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { abilityById, type AbilityDefinition } from "./abilityCatalog";
import { PLAYER_AGGRO_ID } from "./aggro";
import { channelPulseAt, channelPulseCount } from "./combatChannelAmounts";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { ROM_PRIEST_HOLY_CANDLE_TAG } from "./romPriestHolyCandleRuntime";
const candle = abilityById("rom-priest-primary-17")!;
const heal = abilityById("rom-priest-primary-2")!;
const groupHeal = abilityById("rom-priest-primary-8")!;
const divineHymn = abilityById("wow335-priest-divine-hymn")!;
function setPlayerMana(amount: number): void {
useCombatStore.setState((state) => ({
resource: amount,
maxResource: amount,
maxResourcePools: { ...state.maxResourcePools, mana: amount },
resourcePools: { ...state.resourcePools, mana: amount },
}));
}
function candleSelfDamageEvents(ownerActorId = PLAYER_AGGRO_ID) {
return useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage"
&& event.sourceActorId === ownerActorId && event.targetActorId === ownerActorId
&& [heal.id, groupHeal.id, divineHymn.id, "rom-priest-test-holy-candle-hot"].includes(event.abilityId ?? ""));
}
function partyMember(name: string, classId: "rom-priest" | "rom-warrior", role: "healer" | "tank", index: number) {
return createConfiguredPartyMember({
name,
classId,
raceId: "rom-human",
specialization: "Primary",
role,
}, 80, index, "rom-priest-holy-candle-integration");
}
describe("RoM Priest Holy Candle combat integration", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({
classId: "rom-priest",
raceId: "rom-human",
level: 80,
role: "healer",
});
usePartyStore.getState().initializeParty(
"rom-priest-holy-candle-integration", 80, useCombatStore.getState().health, "healer", 2, "rom",
);
usePartyStore.getState().setCommand("stop");
setPlayerMana(10_000);
});
it("charges once for an accepted direct heal even when it overheals its full-health target", () => {
const target = partyMember("Full-health heal target", "rom-warrior", "tank", 0);
usePartyStore.setState({ members: [target] });
registerPartyRuntimePosition(target.id, [2, 0, 0]);
useCombatStore.getState().synchronizeActors();
expect(useCombatStore.getState().castAbility(candle.id, undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.utilityTags?.includes(ROM_PRIEST_HOLY_CANDLE_TAG))).toBe(true);
const maxHealth = useCombatStore.getState().maxHealth;
useCombatStore.setState({ health: maxHealth });
const healCast = useCombatStore.getState().castAbility(heal.id, {
targetActorId: target.id,
now: 3_000,
});
expect(healCast).toMatchObject({ ok: true });
useCombatStore.getState().tick(2, 5_000);
expect(candleSelfDamageEvents()).toHaveLength(1);
expect(candleSelfDamageEvents()[0]).toMatchObject({
rawAmount: Math.round(maxHealth * 0.12),
effectiveAmount: Math.round(maxHealth * 0.12),
});
expect(useCombatStore.getState().health).toBe(maxHealth - Math.round(maxHealth * 0.12));
});
it("charges once before Group Heal fans out to multiple recipients", () => {
const first = partyMember("Candle ally one", "rom-warrior", "tank", 0);
const second = partyMember("Candle ally two", "rom-warrior", "tank", 1);
usePartyStore.setState({ members: [first, second].map((member) => ({
...member,
health: Math.max(1, Math.floor(member.maxHealth * 0.45)),
})) });
registerPartyRuntimePosition(first.id, [3, 0, 0]);
registerPartyRuntimePosition(second.id, [5, 0, 0]);
useCombatStore.getState().synchronizeActors();
expect(useCombatStore.getState().castAbility(candle.id, undefined, 1_000)).toMatchObject({ ok: true });
const before = usePartyStore.getState().members.map((member) => member.health);
expect(useCombatStore.getState().castAbility(groupHeal.id, undefined, 3_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(3, 6_000);
const after = usePartyStore.getState().members.map((member) => member.health);
expect(after[0]).toBeGreaterThan(before[0]!);
expect(after[1]).toBeGreaterThan(before[1]!);
expect(candleSelfDamageEvents()).toHaveLength(1);
expect(candleSelfDamageEvents()[0]?.abilityId).toBe(groupHeal.id);
});
it("does not charge while inactive and shares the one-second limit across heal spell IDs per owner", () => {
expect(useCombatStore.getState().triggerRomPriestHolyCandle(PLAYER_AGGRO_ID, heal.id, 1_000)).toBe(0);
expect(useCombatStore.getState().castAbility(candle.id, undefined, 2_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().triggerRomPriestHolyCandle(PLAYER_AGGRO_ID, heal.id, 3_000)).toBeGreaterThan(0);
expect(useCombatStore.getState().triggerRomPriestHolyCandle(PLAYER_AGGRO_ID, groupHeal.id, 3_999)).toBe(0);
expect(useCombatStore.getState().triggerRomPriestHolyCandle(PLAYER_AGGRO_ID, groupHeal.id, 4_000)).toBeGreaterThan(0);
expect(candleSelfDamageEvents()).toHaveLength(2);
const companion = partyMember("Candle Priest", "rom-priest", "healer", 0);
usePartyStore.setState({ members: [companion] });
registerPartyRuntimePosition(companion.id, [1, 0, 0]);
useCombatStore.getState().synchronizeActors();
const aura = candle.effects.find((effect) => effect.kind === "apply-aura");
if (!aura || aura.kind !== "apply-aura") throw new Error("Holy Candle aura effect is missing");
useCombatStore.getState().applyActorAura(companion.id, companion.id, candle, aura, 2_000);
// The throttle is per owner: this Priest can charge at the same timestamp as the player.
expect(useCombatStore.getState().triggerRomPriestHolyCandle(companion.id, heal.id, 4_000)).toBeGreaterThan(0);
expect(candleSelfDamageEvents(companion.id)).toHaveLength(1);
});
it("does not charge an invalid or interrupted heal cast", () => {
const target = partyMember("Interrupted heal target", "rom-warrior", "tank", 0);
usePartyStore.setState({ members: [target] });
registerPartyRuntimePosition(target.id, [2, 0, 0]);
useCombatStore.getState().synchronizeActors();
expect(useCombatStore.getState().castAbility(candle.id, undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().castAbility(heal.id, {
targetActorId: "missing-friendly-target",
now: 3_000,
})).toMatchObject({ ok: false, reason: "target-unavailable" });
expect(candleSelfDamageEvents()).toHaveLength(0);
expect(useCombatStore.getState().castAbility(heal.id, {
targetActorId: target.id,
now: 3_000,
})).toMatchObject({ ok: true });
expect(useCombatStore.getState().cancelCast("test interruption", 3_500)).toBe(true);
useCombatStore.getState().tick(2, 5_000);
expect(candleSelfDamageEvents()).toHaveLength(0);
});
it("charges once when a HoT is applied and never again on its periodic healing ticks", () => {
const target = partyMember("Periodic heal target", "rom-warrior", "tank", 0);
usePartyStore.setState({ members: [{ ...target, health: 1 }] });
registerPartyRuntimePosition(target.id, [2, 0, 0]);
useCombatStore.getState().synchronizeActors();
expect(useCombatStore.getState().castAbility(candle.id, undefined, 1_000)).toMatchObject({ ok: true });
const hotAbility: AbilityDefinition = {
...heal,
id: "rom-priest-test-holy-candle-hot",
dbcSpellId: 990_304,
name: "Holy Candle HoT regression fixture",
target: "friendly",
castMode: "instant",
castTimeMs: 0,
cooldownMs: 0,
costs: [],
cost: { resource: "mana", amount: 0 },
ranks: undefined,
effects: [{
kind: "hot",
coefficient: 0,
basePoints: 100,
ticks: 3,
intervalMs: 1_000,
}],
};
useCombatStore.setState((state) => ({ abilities: [...state.abilities, hotAbility] }));
expect(useCombatStore.getState().castAbility(hotAbility.id, {
targetActorId: target.id,
now: 3_000,
})).toMatchObject({ ok: true });
expect(candleSelfDamageEvents()).toHaveLength(1);
useCombatStore.getState().tick(1, 4_000);
useCombatStore.getState().tick(1, 5_000);
useCombatStore.getState().tick(1, 6_000);
expect(candleSelfDamageEvents()).toHaveLength(1);
});
it("charges a generated Priest companion once when its accepted heal cast resolves", () => {
const caster = partyMember("Holy Candle Priest", "rom-priest", "healer", 0);
const target = partyMember("Candle heal target", "rom-warrior", "tank", 1);
usePartyStore.setState({ members: [
{
...caster,
resource: 10_000,
maxResource: 10_000,
resourcePools: { ...caster.resourcePools, mana: 10_000 },
activeCast: {
abilityId: heal.id,
school: "holy",
mode: "cast",
startedAt: 1_000,
completesAt: 2_000,
originalDurationMs: 1_000,
pushbackHits: 0,
origin: [0, 0, 0],
targetId: target.id,
},
activeCastTargetId: target.id,
status: "healing",
},
{ ...target, health: 1 },
] });
registerPartyRuntimePosition(caster.id, [0, 0, 0]);
registerPartyRuntimePosition(target.id, [2, 0, 0]);
useCombatStore.getState().synchronizeActors();
const aura = candle.effects.find((effect) => effect.kind === "apply-aura");
if (!aura || aura.kind !== "apply-aura") throw new Error("Holy Candle aura effect is missing");
useCombatStore.getState().applyActorAura(caster.id, caster.id, candle, aura, 1_000);
const maxHealth = caster.maxHealth;
advanceDungeonPartyCombat(2_000);
expect(candleSelfDamageEvents(caster.id)).toHaveLength(1);
expect(candleSelfDamageEvents(caster.id)[0]).toMatchObject({
abilityId: heal.id,
rawAmount: Math.round(maxHealth * 0.12),
effectiveAmount: Math.round(maxHealth * 0.12),
});
expect(usePartyStore.getState().members.find((member) => member.id === caster.id)?.health)
.toBe(maxHealth - Math.round(maxHealth * 0.12));
expect(usePartyStore.getState().members.find((member) => member.id === target.id)?.health).toBeGreaterThan(1);
});
it("persists the channel-cast guard across pulses more than one second apart", () => {
const durationMs = divineHymn.castTimeMs;
const startedAt = 10_000;
const totalTicks = channelPulseCount(divineHymn);
const nextTickAt = channelPulseAt(divineHymn, startedAt, durationMs, 0);
expect(divineHymn.castMode).toBe("channel");
expect(useCombatStore.getState().castAbility(candle.id, undefined, 1_000)).toMatchObject({ ok: true });
useCombatStore.setState((state) => ({
health: Math.floor(state.maxHealth * 0.5),
activeCast: {
id: 9001,
abilityId: divineHymn.id,
targetId: null,
mode: "channel",
startedAt,
completesAt: startedAt + durationMs,
originalDurationMs: durationMs,
pushbackHits: 0,
school: "holy",
canCastWhileMoving: false,
origin: [0, 0, 0],
groundPosition: null,
totalTicks,
ticksCompleted: 0,
nextTickAt,
},
}));
useCombatStore.getState().tick(0, nextTickAt);
expect(useCombatStore.getState().activeCast?.romPriestHolyCandleCharged).toBe(true);
const secondPulseAt = channelPulseAt(divineHymn, startedAt, durationMs, 1);
expect(secondPulseAt - nextTickAt).toBeGreaterThan(1_000);
useCombatStore.getState().tick(0, secondPulseAt);
expect(candleSelfDamageEvents()).toHaveLength(1);
expect(useCombatStore.getState().activeCast?.romPriestHolyCandleCharged).toBe(true);
});
});