303 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|