190 lines
8.5 KiB
TypeScript
190 lines
8.5 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { abilitiesForCharacter, abilityById } from "./abilityCatalog";
|
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
|
import { useCombatStore } from "./combatStore";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import {
|
|
advanceDungeonPartyCombat,
|
|
partyCastCanMoveDuringMeditationPath,
|
|
resetPartyRuntimeClock,
|
|
} from "./partyRuntime";
|
|
import {
|
|
clearPartyRuntimeRegistry,
|
|
registerPartyRuntimePosition,
|
|
updatePartyRuntimePosition,
|
|
updatePartyRuntimeTargetVisibility,
|
|
} from "./partyRuntimeRegistry";
|
|
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
|
import {
|
|
ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG,
|
|
ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG,
|
|
} from "./romMageWarriorRuntime";
|
|
|
|
const TARGET_ID = "meditation-path-target";
|
|
|
|
function initializeMageWarrior(): void {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-mage",
|
|
secondaryClassId: "rom-warrior",
|
|
raceId: "rom-human",
|
|
level: 80,
|
|
});
|
|
useCombatStore.setState((state) => ({
|
|
resource: 1_000,
|
|
maxResource: 1_000,
|
|
resourcePools: { ...state.resourcePools, mana: 1_000 },
|
|
maxResourcePools: { ...state.maxResourcePools, mana: 1_000 },
|
|
}));
|
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
|
}
|
|
|
|
function registerTarget(): void {
|
|
useCombatStore.getState().registerMob(TARGET_ID, { name: TARGET_ID, maxHealth: 1_000_000, xpReward: 0 });
|
|
setMobPosition(TARGET_ID, [3, 0, 0]);
|
|
useCombatStore.getState().selectMob(TARGET_ID);
|
|
}
|
|
|
|
function auraWithTag(tag: string, now: number) {
|
|
return useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AGGRO_ID
|
|
&& (aura.expiresAt === null || aura.expiresAt > now)
|
|
&& aura.definition.utilityTags?.includes(tag));
|
|
}
|
|
|
|
describe("RoM Mage/Warrior Meditation Path", () => {
|
|
beforeEach(() => {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
resetPartyRuntimeClock();
|
|
initializeMageWarrior();
|
|
});
|
|
|
|
it.each([
|
|
["rom-mage-general-0", 490204, "Fireball"],
|
|
["rom-mage-general-1", 490212, "Lightning"],
|
|
["rom-mage-primary-4", 490242, "Discharge"],
|
|
["rom-mage-primary-6", 490239, "Phoenix"],
|
|
] as const)("grants 7 seconds of mobile casting after %s (%i, %s)", (abilityId, spellId, _name) => {
|
|
registerTarget();
|
|
const state = useCombatStore.getState();
|
|
expect(state.abilities.some((ability) => ability.dbcSpellId === 499588 && ability.passive)).toBe(true);
|
|
expect(abilityById(abilityId)?.dbcSpellId).toBe(spellId);
|
|
|
|
expect(state.castAbility(abilityId, undefined, 1_000)).toMatchObject({ ok: true });
|
|
expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG, 1_000)?.expiresAt).toBe(8_000);
|
|
expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG, 1_000)?.expiresAt).toBe(16_000);
|
|
});
|
|
|
|
it("does not proc on Flame and does not retrigger during the 15-second lockout", () => {
|
|
registerTarget();
|
|
const flame = abilityById("rom-mage-primary-1");
|
|
const fireball = abilityById("rom-mage-general-0");
|
|
if (!flame || !fireball) throw new Error("Missing local Mage ability rows");
|
|
|
|
expect(useCombatStore.getState().castAbility(flame.id, undefined, 1_000)).toMatchObject({ ok: true });
|
|
useCombatStore.getState().tick(0.1, 4_100);
|
|
expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG, 4_100)).toBeUndefined();
|
|
expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG, 4_100)).toBeUndefined();
|
|
|
|
expect(useCombatStore.getState().castAbility(fireball.id, undefined, 6_000)).toMatchObject({ ok: true });
|
|
expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG, 6_000)?.expiresAt).toBe(13_000);
|
|
expect(useCombatStore.getState().castAbility(fireball.id, undefined, 14_000)).toMatchObject({ ok: true });
|
|
expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG, 14_000)).toBeUndefined();
|
|
expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG, 14_000)?.expiresAt).toBe(21_000);
|
|
|
|
expect(useCombatStore.getState().castAbility(fireball.id, undefined, 21_000)).toMatchObject({ ok: true });
|
|
expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG, 21_000)?.expiresAt).toBe(28_000);
|
|
});
|
|
|
|
it("lets a cast started during Meditation Path continue after movement", () => {
|
|
registerTarget();
|
|
const fireball = abilityById("rom-mage-general-0");
|
|
const flame = abilityById("rom-mage-primary-1");
|
|
if (!fireball || !flame) throw new Error("Missing local Mage ability rows");
|
|
|
|
expect(useCombatStore.getState().castAbility(fireball.id, undefined, 1_000)).toMatchObject({ ok: true });
|
|
expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG, 1_000)).toBeDefined();
|
|
expect(useCombatStore.getState().castAbility(flame.id, undefined, 2_600)).toMatchObject({ ok: true });
|
|
expect(useCombatStore.getState().activeCast).toMatchObject({ abilityId: flame.id, canCastWhileMoving: true });
|
|
|
|
useCombatStore.getState().setPlayerPosition([1, 0, 0]);
|
|
expect(useCombatStore.getState().activeCast).toMatchObject({ abilityId: flame.id, canCastWhileMoving: true });
|
|
useCombatStore.getState().tick(0.1, 5_700);
|
|
expect(useCombatStore.getState().activeCast).toBeNull();
|
|
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "cast"
|
|
&& event.abilityId === flame.id && event.outcome === "completed")).toBe(true);
|
|
expect(auraWithTag(ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG, 5_700)?.expiresAt).toBe(8_000);
|
|
});
|
|
|
|
it("grants the companion aura after a supported cast and preserves movement casting", () => {
|
|
const seed = "meditation-path-party";
|
|
const partyMember = createConfiguredPartyMember({
|
|
name: "Meditation Mage",
|
|
classId: "rom-mage",
|
|
secondaryClassId: "rom-warrior",
|
|
specialization: "Primary",
|
|
role: "damage",
|
|
}, 80, 0, seed);
|
|
const fireball = abilityById("rom-mage-general-0");
|
|
const flame = abilityById("rom-mage-primary-1");
|
|
if (!fireball || !flame) throw new Error("Missing local Mage ability rows");
|
|
|
|
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2, "rom");
|
|
usePartyStore.getState().setCommand("attack");
|
|
usePartyStore.setState({ members: [{
|
|
...partyMember,
|
|
resource: 10_000,
|
|
maxResource: 10_000,
|
|
resourcePools: { ...partyMember.resourcePools, mana: 10_000 },
|
|
activeCast: {
|
|
abilityId: fireball.id,
|
|
school: "fire",
|
|
mode: "cast",
|
|
startedAt: 500,
|
|
completesAt: 1_000,
|
|
originalDurationMs: 500,
|
|
pushbackHits: 0,
|
|
origin: [0, 0, 0],
|
|
targetId: TARGET_ID,
|
|
},
|
|
activeCastTargetId: TARGET_ID,
|
|
status: "attacking",
|
|
}] });
|
|
registerPartyRuntimePosition(partyMember.id, [0, 0, 0]);
|
|
registerTarget();
|
|
useCombatStore.getState().damageMob(TARGET_ID, 1, 900);
|
|
updatePartyRuntimeTargetVisibility(partyMember.id, TARGET_ID, true);
|
|
useCombatStore.getState().synchronizeActors();
|
|
|
|
advanceDungeonPartyCombat(1_000);
|
|
const partyAuras = useCombatStore.getState().auras;
|
|
expect(partyAuras.find((aura) => aura.targetId === partyMember.id
|
|
&& aura.definition.utilityTags?.includes(ROM_MAGE_WARRIOR_MEDITATION_PATH_ACTIVE_TAG))?.expiresAt).toBe(8_000);
|
|
expect(partyAuras.find((aura) => aura.targetId === partyMember.id
|
|
&& aura.definition.utilityTags?.includes(ROM_MAGE_WARRIOR_MEDITATION_PATH_LOCKOUT_TAG))?.expiresAt).toBe(16_000);
|
|
|
|
const canMoveDuringCast = partyCastCanMoveDuringMeditationPath(partyMember, flame, 7_000);
|
|
expect(canMoveDuringCast).toBe(true);
|
|
usePartyStore.setState({ members: usePartyStore.getState().members.map((member) => member.id === partyMember.id
|
|
? { ...member, activeCast: {
|
|
abilityId: flame.id,
|
|
school: "fire",
|
|
mode: "cast",
|
|
startedAt: 7_000,
|
|
completesAt: 10_000,
|
|
originalDurationMs: 3_000,
|
|
pushbackHits: 0,
|
|
canCastWhileMoving: canMoveDuringCast,
|
|
origin: [0, 0, 0],
|
|
targetId: TARGET_ID,
|
|
}, activeCastTargetId: TARGET_ID, status: "attacking" }
|
|
: member) });
|
|
useCombatStore.getState().synchronizeActors();
|
|
advanceDungeonPartyCombat(7_500);
|
|
updatePartyRuntimePosition(partyMember.id, 1, 0, 0);
|
|
advanceDungeonPartyCombat(10_000);
|
|
expect(usePartyStore.getState().members.find((member) => member.id === partyMember.id)?.activeCast).toBeNull();
|
|
expect(useCombatStore.getState().combatEvents.some((event) => event.sourceActorId === partyMember.id
|
|
&& event.abilityId === flame.id && event.outcome === "hit")).toBe(true);
|
|
});
|
|
});
|