169 lines
6.9 KiB
TypeScript
169 lines
6.9 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { targetingRulesForAbility } from "./abilityRules";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import {
|
|
clearPartyRuntimeRegistry,
|
|
registerPartyRuntimePosition,
|
|
updatePartyRuntimeTargetVisibility,
|
|
} from "./partyRuntimeRegistry";
|
|
import { useCombatStore } from "./combatStore";
|
|
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
|
import { commitPartyAbilityUse } from "./partyAbilityAi";
|
|
import { abilitiesForCharacter } from "./abilityCatalog";
|
|
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
|
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
|
|
|
const HEART_RUNE_ENERGY_ID = 498612;
|
|
const BLESSING_OF_HUMILITY_ID = 490301;
|
|
const TARGET_DISTANCES = [2, 15, 30, 45, 65, 80] as const;
|
|
const OUTSIDE_DISTANCE = 80.1;
|
|
const TEST_NOW = 1_000;
|
|
const heartRuneEnergy = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === HEART_RUNE_ENERGY_ID
|
|
&& ability.classId === "rom-priest" && ability.secondaryClassId === "rom-champion")!;
|
|
|
|
function registerTarget(id: string, distance: number): void {
|
|
useCombatStore.getState().registerMob(id, {
|
|
name: id,
|
|
maxHealth: 1_000_000,
|
|
level: 1,
|
|
xpReward: 0,
|
|
});
|
|
setMobPosition(id, [distance, 0, 0]);
|
|
useCombatStore.getState().damageMob(id, 1, 100);
|
|
}
|
|
|
|
function registerHeartRuneTargets(prefix: string): { insideIds: string[]; outsideId: string } {
|
|
const insideIds = TARGET_DISTANCES.map((distance, index) => {
|
|
const id = `${prefix}-inside-${index}`;
|
|
registerTarget(id, distance);
|
|
return id;
|
|
});
|
|
const outsideId = `${prefix}-outside`;
|
|
registerTarget(outsideId, OUTSIDE_DISTANCE);
|
|
return { insideIds, outsideId };
|
|
}
|
|
|
|
function abilityDamageTargets(abilityId: string, sourceActorId?: string): string[] {
|
|
return useCombatStore.getState().combatEvents
|
|
.filter((event) => event.kind === "damage"
|
|
&& event.abilityId === abilityId
|
|
&& (!sourceActorId || event.sourceActorId === sourceActorId)
|
|
&& event.school === "holy"
|
|
&& event.effectiveAmount > 0)
|
|
.map((event) => event.targetActorId)
|
|
.sort();
|
|
}
|
|
|
|
describe("RoM Priest/Champion Heart Rune Energy runtime", () => {
|
|
beforeEach(() => {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
resetPartyRuntimeClock();
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-priest",
|
|
secondaryClassId: "rom-champion",
|
|
raceId: "rom-human",
|
|
level: 80,
|
|
role: "damage",
|
|
});
|
|
usePartyStore.getState().initializeParty("rom-priest-heart-rune-energy", 80,
|
|
useCombatStore.getState().health, "damage", 2, "rom");
|
|
});
|
|
|
|
it("executes the caster-centered 80-radius Light hit for every catalog-eligible target and reduces Blessing cooldown", () => {
|
|
const { insideIds, outsideId } = registerHeartRuneTargets("heart-rune-player");
|
|
const blessing = useCombatStore.getState().abilities.find((ability) => (
|
|
ability.dbcSpellId === BLESSING_OF_HUMILITY_ID
|
|
))!;
|
|
useCombatStore.setState((state) => ({
|
|
resourcePools: { ...state.resourcePools, rage: 50 },
|
|
cooldowns: { ...state.cooldowns, [blessing.id]: TEST_NOW + 60_000 },
|
|
}));
|
|
useCombatStore.getState().selectMob(insideIds[0]!);
|
|
|
|
const result = useCombatStore.getState().castAbility(heartRuneEnergy.id, undefined, TEST_NOW);
|
|
|
|
expect(result).toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().resourcePools.rage).toBe(35);
|
|
expect(useCombatStore.getState().cooldowns[heartRuneEnergy.id]).toBe(TEST_NOW + 12_000);
|
|
expect(useCombatStore.getState().cooldowns[blessing.id]).toBe(TEST_NOW + 45_000);
|
|
expect(abilityDamageTargets(heartRuneEnergy.id)).toEqual(insideIds.sort());
|
|
expect(abilityDamageTargets(heartRuneEnergy.id)).not.toContain(outsideId);
|
|
});
|
|
|
|
it("executes for generated Priest/Champion companions with the same radius, no configured target cap, and cooldown rider", () => {
|
|
const { insideIds, outsideId } = registerHeartRuneTargets("heart-rune-companion");
|
|
const seed = "rom-priest-heart-rune-energy-companion";
|
|
const blessing = abilitiesForCharacter("rom-priest", "rom-champion")
|
|
.find((ability) => ability.dbcSpellId === BLESSING_OF_HUMILITY_ID)!;
|
|
const member = createConfiguredPartyMember({
|
|
name: "Priest Champion",
|
|
classId: "rom-priest",
|
|
secondaryClassId: "rom-champion",
|
|
specialization: "Primary",
|
|
role: "damage",
|
|
}, 80, 0, seed);
|
|
const fundedMember = {
|
|
...member,
|
|
resourcePools: { ...member.resourcePools, rage: 50 },
|
|
cooldowns: { ...member.cooldowns, [blessing.id]: TEST_NOW + 60_000 },
|
|
};
|
|
const committedMember = commitPartyAbilityUse(fundedMember, heartRuneEnergy, TEST_NOW);
|
|
expect(committedMember).not.toBeNull();
|
|
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2, "rom");
|
|
usePartyStore.getState().setCommand("attack");
|
|
usePartyStore.setState({ members: [{
|
|
...committedMember!,
|
|
activeCast: {
|
|
abilityId: heartRuneEnergy.id,
|
|
school: "holy",
|
|
mode: "cast",
|
|
startedAt: TEST_NOW,
|
|
completesAt: TEST_NOW,
|
|
originalDurationMs: 0,
|
|
pushbackHits: 0,
|
|
origin: [0, 0, 0],
|
|
targetId: insideIds[0]!,
|
|
},
|
|
activeCastTargetId: insideIds[0]!,
|
|
status: "attacking",
|
|
}] });
|
|
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
|
for (const id of [...insideIds, outsideId]) updatePartyRuntimeTargetVisibility(member.id, id, true);
|
|
useCombatStore.getState().synchronizeActors();
|
|
|
|
advanceDungeonPartyCombat(TEST_NOW);
|
|
|
|
const updatedMember = usePartyStore.getState().members.find((candidate) => candidate.id === member.id)!;
|
|
expect(updatedMember.resourcePools.rage).toBe(35);
|
|
expect(updatedMember.cooldowns[heartRuneEnergy.id]).toBe(TEST_NOW + 12_000);
|
|
expect(updatedMember.cooldowns[blessing.id]).toBe(TEST_NOW + 45_000);
|
|
expect(abilityDamageTargets(heartRuneEnergy.id, member.id)).toEqual(insideIds.sort());
|
|
expect(abilityDamageTargets(heartRuneEnergy.id, member.id)).not.toContain(outsideId);
|
|
});
|
|
|
|
it("keeps the current source radius and leaves target count uncapped because CoA provides no cap", () => {
|
|
const targeting = targetingRulesForAbility(heartRuneEnergy);
|
|
expect(heartRuneEnergy).toMatchObject({
|
|
target: "self",
|
|
range: { max: 150 },
|
|
radius: 80,
|
|
castMode: "instant",
|
|
castTimeMs: 0,
|
|
cooldownMs: 12_000,
|
|
cost: { resource: "rage", amount: 15 },
|
|
});
|
|
expect(targeting).toMatchObject({ shape: "circle", radius: 80 });
|
|
expect(targeting.maximumTargets).toBeUndefined();
|
|
expect(heartRuneEnergy.effects).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ kind: "damage", coefficient: 9.751, school: "holy" }),
|
|
expect.objectContaining({
|
|
kind: "cooldown-reset",
|
|
mode: "reduce",
|
|
abilityName: "Blessing of Humility",
|
|
amountMs: 15_000,
|
|
}),
|
|
]));
|
|
});
|
|
});
|