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); }); });