forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
4c768fbec6
commit
b16821e92e
330 files changed
+35406
-1760
No files matched your search
@@ -0,0 +1,277 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user