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); }); });