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

303 lines
14 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { resetCombatSpatialProvider, registerCombatSpatialProvider } from "./combatSpatial";
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import { setMobPosition, clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { commitPartyAbilityUse } from "./partyAbilityAi";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import {
ROM_PRIEST_SCOUT_CURING_SHOT_COOLDOWN_MS,
ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_AURA,
ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_SCHOOLS,
ROM_PRIEST_SCOUT_CURING_SHOT_ID,
} from "./romPriestScoutCuringShot";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
const CURING_SHOT = ROM_ALL_ABILITIES.find((ability) => (
ability.source === "runewaker"
&& ability.classId === "rom-priest"
&& ability.secondaryClassId === "rom-scout"
&& ability.dbcSpellId === ROM_PRIEST_SCOUT_CURING_SHOT_ID
));
const CURING_SHOT_ID = "rom-priest-rom-scout-elite-35";
const TEST_TARGET_ID = "curing-shot-elemental-target";
function priest(name: string, seed: string, index: number, secondaryClassId?: "rom-scout") {
return createConfiguredPartyMember({
name,
classId: "rom-priest",
...(secondaryClassId ? { secondaryClassId } : {}),
specialization: "Primary",
role: "damage",
}, 80, index, seed);
}
function initializePlayerParty(seed: string) {
useCombatStore.getState().initializeCharacter({
classId: "rom-priest",
secondaryClassId: "rom-scout",
raceId: "rom-human",
level: 80,
role: "healer",
});
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "healer", 5, "rom");
const roster = [
...usePartyStore.getState().members,
priest("Extra modeled ally A", seed, 4),
priest("Extra modeled ally B", seed, 5),
];
usePartyStore.setState({ members: roster });
roster.forEach((member, index) => {
// Five party actors plus the player are intentionally in range so the
// integration catches the old five-recipient fallback cap.
registerPartyRuntimePosition(member.id, index < 5
? [2 + index * 1.5, 0, 0]
: [10.01, 0, 0]);
usePartyStore.getState().damageMember(member.id, 2_000);
});
useCombatStore.getState().damagePlayer(2_000);
useCombatStore.getState().synchronizeActors();
registerCombatSpatialProvider({
actor: () => null,
hasLineOfSight: () => true,
projectGroundPoint: (position) => position,
});
useCombatStore.getState().registerMob(TEST_TARGET_ID, {
name: "Elemental damage test target",
level: 1,
maxHealth: 1_000_000,
xpReward: 0,
});
setMobPosition(TEST_TARGET_ID, [3, 0, 0]);
return { roster };
}
function applyCuringShotFromCompanion(casterId: string, now: number): void {
const caster = usePartyStore.getState().members.find((member) => member.id === casterId);
if (!caster || !CURING_SHOT) throw new Error("Missing Priest/Scout Curing Shot companion.");
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === casterId
? {
...member,
activeCast: {
abilityId: CURING_SHOT_ID,
school: "holy",
mode: "cast",
startedAt: now,
completesAt: now,
originalDurationMs: 0,
pushbackHits: 0,
origin: [0, 0, 0],
targetId: casterId,
},
activeCastTargetId: casterId,
status: "attacking",
}
: member),
}));
useCombatStore.getState().synchronizeActors();
const syncedCaster = usePartyStore.getState().members.find((member) => member.id === casterId);
const committed = syncedCaster ? commitPartyAbilityUse(syncedCaster, CURING_SHOT, now) : null;
if (!committed) throw new Error("Companion could not commit Curing Shot.");
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === casterId
? { ...committed, activeCast: member.activeCast, activeCastTargetId: member.activeCastTargetId }
: member),
}));
useCombatStore.getState().commitCompanionActors([committed]);
advanceDungeonPartyCombat(now);
}
function schoolDamage(sourceActorId: string, school: "physical" | "holy" | "fire" | "frost" | "nature" | "shadow" | "arcane", now: number) {
return useCombatStore.getState().damageMob(
TEST_TARGET_ID,
1_000,
now,
{ actorId: sourceActorId, role: "damage", abilityId: `curing-shot-school-test:${school}` },
school,
{
attackKind: school === "physical" ? "melee" : "spell",
seed: `curing-shot-school-test:${sourceActorId}:${school}:${now}`,
rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false },
},
).amount;
}
function curingShotAura(targetId: string) {
return useCombatStore.getState().auras.find((aura) => aura.targetId === targetId
&& aura.definition.id === ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_AURA.id);
}
describe("CoA Priest/Scout Curing Shot runtime", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
resetCombatSpatialProvider();
useDungeonSimulationStore.setState({ sessionMode: "play" });
});
afterEach(() => resetCombatSpatialProvider());
it("heals every nearby modeled party actor, schedules six HoT ticks, and buffs only the caster's Water/frost analog", () => {
if (!CURING_SHOT) throw new Error("Priest/Scout Curing Shot (492636) is missing from the generated catalog.");
const { roster } = initializePlayerParty("curing-shot-player-runtime");
const ally = roster[0]!;
usePartyStore.getState().selectMember(ally.id);
const baseline = new Map(ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_SCHOOLS.map((school) => (
[school, schoolDamage(PLAYER_AGGRO_ID, school, 500)] as const
)));
const baselineExcluded = new Map(([
"fire", "nature", "arcane", "physical", "holy", "shadow",
] as const).map((school) => [school, schoolDamage(PLAYER_AGGRO_ID, school, 700)] as const));
expect(useCombatStore.getState().castAbility(CURING_SHOT.id, undefined, 1_000)).toMatchObject({ ok: true });
const scheduledHots = useCombatStore.getState().effects.filter((effect) => (
effect.kind === "hot" && effect.sourceAbilityId === CURING_SHOT.id
));
expect(scheduledHots).toHaveLength(6);
expect(scheduledHots.map((effect) => effect.targetId)).toEqual(expect.arrayContaining([
null,
...roster.slice(0, 5).map((member) => member.id),
]));
const directEvents = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "healing" && event.abilityId === CURING_SHOT.id && event.occurredAt === 1_000
));
expect(directEvents.map((event) => event.targetActorId)).toHaveLength(6);
expect(directEvents.map((event) => event.targetActorId)).toEqual(expect.arrayContaining([
PLAYER_AGGRO_ID,
...roster.slice(0, 5).map((member) => member.id),
]));
expect(directEvents.map((event) => event.targetActorId)).not.toContain(roster[5]!.id);
expect(directEvents.every((event) => event.rawAmount > 0)).toBe(true);
expect(curingShotAura(PLAYER_AGGRO_ID)).toMatchObject({
expiresAt: 19_000,
definition: { modifiers: [
{ kind: "damage", direction: "dealt", school: "frost", value: 0.1 },
] },
});
expect(roster.map((member) => curingShotAura(member.id))).toEqual(Array.from({ length: roster.length }, () => undefined));
expect(useCombatStore.getState().cooldowns[CURING_SHOT.id]).toBe(1_000 + ROM_PRIEST_SCOUT_CURING_SHOT_COOLDOWN_MS);
const playerHotHealing = [];
const tickTimes = [4_000, 7_000, 10_000, 13_000, 16_000, 19_000];
for (const [tickIndex, tickAt] of tickTimes.slice(0, 5).entries()) {
const playerHealthBeforeTick = useCombatStore.getState().health;
const activeHotsBeforeTick = useCombatStore.getState().effects.filter((effect) => (
effect.kind === "hot" && effect.sourceAbilityId === CURING_SHOT.id
));
expect(activeHotsBeforeTick).toHaveLength(6);
expect(activeHotsBeforeTick.every((effect) => effect.remainingTicks === 6 - tickIndex)).toBe(true);
useCombatStore.getState().tick(3, tickAt);
playerHotHealing.push(useCombatStore.getState().health - playerHealthBeforeTick);
const activeHotsAfterTick = useCombatStore.getState().effects.filter((effect) => (
effect.kind === "hot" && effect.sourceAbilityId === CURING_SHOT.id
));
expect(activeHotsAfterTick).toHaveLength(6);
expect(activeHotsAfterTick.every((effect) => effect.remainingTicks === 5 - tickIndex)).toBe(true);
}
for (const school of ROM_PRIEST_SCOUT_CURING_SHOT_ELEMENTAL_SCHOOLS) {
expect(schoolDamage(PLAYER_AGGRO_ID, school, 18_999) / baseline.get(school)!).toBeCloseTo(1.1, 1);
}
for (const school of ["fire", "nature", "arcane", "physical", "holy", "shadow"] as const) {
expect(schoolDamage(PLAYER_AGGRO_ID, school, 18_999)).toBe(baselineExcluded.get(school));
}
expect(curingShotAura(PLAYER_AGGRO_ID)?.expiresAt).toBe(19_000);
const finalTickAt = tickTimes[5]!;
const finalTickHotEffects = useCombatStore.getState().effects.filter((effect) => (
effect.kind === "hot" && effect.sourceAbilityId === CURING_SHOT.id
));
expect(finalTickHotEffects).toHaveLength(6);
expect(finalTickHotEffects.every((effect) => effect.remainingTicks === 1)).toBe(true);
const playerHealthBeforeFinalTick = useCombatStore.getState().health;
useCombatStore.getState().tick(3, finalTickAt);
playerHotHealing.push(useCombatStore.getState().health - playerHealthBeforeFinalTick);
expect(useCombatStore.getState().effects.some((effect) => (
effect.kind === "hot" && effect.sourceAbilityId === CURING_SHOT.id
))).toBe(false);
const periodicEvents = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "healing" && event.abilityId === CURING_SHOT.id && event.occurredAt > 1_000
));
// Companion HoTs are emitted as healing events; the player's self-HoT
// updates health directly and is verified separately below.
expect(periodicEvents).toHaveLength(30);
expect(periodicEvents.every((event) => tickTimes.includes(event.occurredAt))).toBe(true);
expect(new Set(periodicEvents.map((event) => event.targetActorId))).toEqual(new Set(
roster.slice(0, 5).map((member) => member.id),
));
expect(periodicEvents.every((event) => event.rawAmount > 0)).toBe(true);
expect(playerHotHealing).toHaveLength(6);
expect(playerHotHealing.every((amount) => amount > 0)).toBe(true);
expect(curingShotAura(PLAYER_AGGRO_ID)).toBeUndefined();
expect(schoolDamage(PLAYER_AGGRO_ID, "frost", 19_000)).toBe(baseline.get("frost"));
});
it("lets a Priest/Scout companion heal nearby party actors and own the elemental bonus", () => {
if (!CURING_SHOT) throw new Error("Priest/Scout Curing Shot (492636) is missing from the generated catalog.");
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior", raceId: "rom-human", level: 80, role: "damage",
});
useCombatStore.getState().setPlayerPosition([1, 0, 0]);
usePartyStore.getState().initializeParty("curing-shot-companion-runtime", 80, useCombatStore.getState().health, "damage", 5, "rom");
const caster = {
...priest("Priest Scout caster", "curing-shot-companion-runtime", 0, "rom-scout"),
resource: 500,
maxResource: 500,
resourcePools: { mana: 500 },
};
const ally = priest("Priest Scout ally", "curing-shot-companion-runtime", 1);
usePartyStore.setState({ members: [caster, ally] });
registerPartyRuntimePosition(caster.id, [0, 0, 0]);
registerPartyRuntimePosition(ally.id, [5, 0, 0]);
usePartyStore.getState().damageMember(caster.id, 2_000);
usePartyStore.getState().damageMember(ally.id, 2_000);
useCombatStore.getState().damagePlayer(2_000);
useCombatStore.getState().synchronizeActors();
registerCombatSpatialProvider({
actor: () => null,
hasLineOfSight: () => true,
projectGroundPoint: (position) => position,
});
useCombatStore.getState().registerMob(TEST_TARGET_ID, {
name: "Elemental damage test target", level: 1, maxHealth: 1_000_000, xpReward: 0,
});
setMobPosition(TEST_TARGET_ID, [3, 0, 0]);
const companionFrostBaseline = schoolDamage(caster.id, "frost", 900);
const companionFireBaseline = schoolDamage(caster.id, "fire", 950);
applyCuringShotFromCompanion(caster.id, 1_000);
expect(useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "healing" && event.abilityId === CURING_SHOT_ID && event.occurredAt === 1_000
)).map((event) => event.targetActorId)).toEqual(expect.arrayContaining([
PLAYER_AGGRO_ID, caster.id, ally.id,
]));
expect(curingShotAura(caster.id)?.expiresAt).toBe(19_000);
expect(curingShotAura(PLAYER_AGGRO_ID)).toBeUndefined();
expect(curingShotAura(ally.id)).toBeUndefined();
expect(schoolDamage(caster.id, "frost", 1_100) / companionFrostBaseline).toBeCloseTo(1.1, 1);
expect(schoolDamage(caster.id, "fire", 1_150)).toBe(companionFireBaseline);
const playerHealthBeforeHotTick = useCombatStore.getState().health;
useCombatStore.getState().tick(3, 4_000);
const periodicEvents = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "healing" && event.abilityId === CURING_SHOT_ID && event.occurredAt === 4_000
));
expect(periodicEvents).toHaveLength(3);
expect(new Set(periodicEvents.map((event) => event.targetActorId))).toEqual(new Set([
PLAYER_AGGRO_ID, caster.id, ally.id,
]));
expect(useCombatStore.getState().health).toBeGreaterThan(playerHealthBeforeHotTick);
});
});