forked from phenom/healer-man
278 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|