import { beforeEach, describe, expect, it } from "vitest"; import { abilityById, resourceProfileForClass } from "./abilityCatalog"; import { PLAYER_AGGRO_ID } from "./aggro"; import { applyAura, resolveResourceValue } from "./combatAuras"; import { useCombatStore } from "./combatStore"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { ROM_MAGE_WARLOCK_PASSIVE_TAGS, romMageWarlockCastEffects, } from "./romMageWarlockOverrides"; import { applyTalentModifierValue } from "./talentModifiers"; function initializeMage(secondaryClassId?: "rom-druid" | "rom-warden" | "rom-champion", level = 80): void { useCombatStore.getState().initializeCharacter({ classId: "rom-mage", secondaryClassId, raceId: "rom-human", level, }); } function addTarget(id: string, x: number, z = 0): void { useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000_000 }); setMobPosition(id, [x, 0, z]); } describe("RoM Mage mechanics", () => { beforeEach(() => { clearMobRuntimeRegistry(); useCombatStore.setState({ playerPosition: [0, 0, 0] }); }); it("maps Elemental Catalysis mana percentage regeneration against maximum mana", () => { const catalysis = abilityById("rom-mage-primary-0")!; expect(catalysis.effects).toContainEqual(expect.objectContaining({ kind: "apply-aura", aura: expect.objectContaining({ durationMs: 20_000, modifiers: expect.arrayContaining([ expect.objectContaining({ kind: "stat", stat: "mana-regeneration-max-percent-per-second", operation: "flat", value: 0.01, }), ]), }), })); initializeMage(undefined, 60); addTarget("catalysis-combat", 3); useCombatStore.setState((state) => ({ ...state, resource: state.maxResource / 2, resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana! / 2 }, lastResourceSpentAt: { mana: 1_000 }, mobs: { ...state.mobs, "catalysis-combat": { ...state.mobs["catalysis-combat"]!, engaged: true } }, })); const before = useCombatStore.getState().resourcePools.mana!; expect(useCombatStore.getState().castAbility(catalysis.id, undefined, 1_000)).toMatchObject({ ok: true }); expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID && aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "mana-regeneration-max-percent-per-second" && modifier.value === 0.01))).toBe(true); useCombatStore.getState().tick(1, 2_000); useCombatStore.getState().tick(1, 3_000); const restored = useCombatStore.getState().resourcePools.mana! - before; expect(restored).toBeGreaterThanOrEqual(useCombatStore.getState().maxResourcePools.mana! * 0.02); }); it("uses rank-two Catalysis's explicit 20-second damage and spell-speed payload without adding a mana tick", () => { initializeMage(undefined, 65); const catalysis = abilityById("rom-mage-primary-0")!; const rankTwo = catalysis.ranks?.find((rank) => rank.spellId === 497971); expect(rankTwo).toBeDefined(); expect(rankTwo?.effects).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: "apply-aura", aura: expect.objectContaining({ durationMs: 20_000, modifiers: [expect.objectContaining({ kind: "damage", direction: "dealt", attackKind: "spell", value: 0.2 })], }), }), expect.objectContaining({ kind: "apply-aura", aura: expect.objectContaining({ durationMs: 20_000, modifiers: [expect.objectContaining({ kind: "stat", stat: "spell-haste", value: 50 })], }), }), ])); expect(rankTwo?.effects.some((effect) => effect.kind === "apply-aura" && effect.aura.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "mana-regeneration-max-percent-per-second"))).toBe(false); addTarget("catalysis-rank-two-combat", 3); useCombatStore.setState((state) => ({ ...state, lastResourceSpentAt: { mana: 1_000 }, mobs: { ...state.mobs, "catalysis-rank-two-combat": { ...state.mobs["catalysis-rank-two-combat"]!, engaged: true } }, })); const result = useCombatStore.getState().castAbility(catalysis.id, undefined, 1_000); expect(result, JSON.stringify(result)).toMatchObject({ ok: true }); expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID && aura.definition.id.includes("497971") && aura.definition.modifiers?.some((modifier) => modifier.kind === "damage" && modifier.attackKind === "spell" && modifier.value === 0.2))).toBe(true); }); it("applies rank-two Catalysis spell haste to Mage cast time while rank one stays unchanged", () => { const flame = abilityById("rom-mage-primary-1")!; expect(flame).toMatchObject({ name: "Flame", castTimeMs: 3_000 }); initializeMage(undefined, 65); addTarget("catalysis-haste-rank-two-baseline", 3); useCombatStore.getState().selectMob("catalysis-haste-rank-two-baseline"); expect(useCombatStore.getState().castAbility(flame.id, undefined, 1_000)).toMatchObject({ ok: true }); const rankTwoBaselineMs = useCombatStore.getState().activeCast!.originalDurationMs; initializeMage(undefined, 65); clearMobRuntimeRegistry(); addTarget("catalysis-haste-rank-two-target", 3); useCombatStore.getState().selectMob("catalysis-haste-rank-two-target"); expect(useCombatStore.getState().castAbility("rom-mage-primary-0", undefined, 3_000)).toMatchObject({ ok: true }); expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID && aura.definition.id.includes("497971") && aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "spell-haste" && modifier.value === 50))).toBe(true); expect(useCombatStore.getState().castAbility(flame.id, undefined, 5_000)).toMatchObject({ ok: true }); expect(useCombatStore.getState().activeCast).toMatchObject({ abilityId: flame.id, startedAt: 5_000, originalDurationMs: Math.round(rankTwoBaselineMs / 1.5), completesAt: 5_000 + Math.round(rankTwoBaselineMs / 1.5), }); initializeMage(undefined, 60); clearMobRuntimeRegistry(); addTarget("catalysis-haste-rank-one-baseline", 3); useCombatStore.getState().selectMob("catalysis-haste-rank-one-baseline"); expect(useCombatStore.getState().castAbility(flame.id, undefined, 7_000)).toMatchObject({ ok: true }); const rankOneBaselineMs = useCombatStore.getState().activeCast!.originalDurationMs; initializeMage(undefined, 60); clearMobRuntimeRegistry(); addTarget("catalysis-haste-rank-one-target", 3); useCombatStore.getState().selectMob("catalysis-haste-rank-one-target"); expect(useCombatStore.getState().castAbility("rom-mage-primary-0", undefined, 9_000)).toMatchObject({ ok: true }); expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID && aura.definition.modifiers?.some((modifier) => modifier.kind === "stat" && modifier.stat === "spell-haste"))).toBe(false); expect(useCombatStore.getState().castAbility(flame.id, undefined, 11_000)).toMatchObject({ ok: true }); expect(useCombatStore.getState().activeCast).toMatchObject({ abilityId: flame.id, startedAt: 11_000, originalDurationMs: rankOneBaselineMs, completesAt: 11_000 + rankOneBaselineMs, }); }); it("does not apply a RoM spell-haste aura to another class's cast time", () => { useCombatStore.getState().initializeCharacter({ classId: "priest", raceId: "human", level: 80 }); addTarget("non-mage-haste-baseline", 3); useCombatStore.getState().selectMob("non-mage-haste-baseline"); expect(useCombatStore.getState().castAbility("priest-smite", undefined, 1_000)).toMatchObject({ ok: true }); const priestBaselineMs = useCombatStore.getState().activeCast!.originalDurationMs; useCombatStore.getState().initializeCharacter({ classId: "priest", raceId: "human", level: 80 }); clearMobRuntimeRegistry(); addTarget("non-mage-haste-target", 3); useCombatStore.getState().selectMob("non-mage-haste-target"); useCombatStore.setState((state) => ({ auras: applyAura(state.auras, { definition: { id: "rom-catalysis-non-mage-test", name: "Test Catalysis haste", disposition: "buff", durationMs: 20_000, modifiers: [{ kind: "stat", stat: "spell-haste", operation: "flat", value: 50 }], }, sourceId: PLAYER_AGGRO_ID, targetId: PLAYER_AGGRO_ID, now: 3_000, }).auras, })); expect(useCombatStore.getState().castAbility("priest-smite", undefined, 3_000)).toMatchObject({ ok: true }); expect(useCombatStore.getState().activeCast).toMatchObject({ abilityId: "priest-smite", originalDurationMs: priestBaselineMs, completesAt: 3_000 + priestBaselineMs, }); }); it("leaves ordinary mana regeneration unchanged without the Catalysis aura", () => { useCombatStore.getState().initializeCharacter({ classId: "priest", raceId: "human", level: 80 }); addTarget("ordinary-mana-combat", 3); useCombatStore.setState((state) => ({ ...state, resource: state.maxResource / 2, resourcePools: { ...state.resourcePools, mana: state.maxResourcePools.mana! / 2 }, lastResourceSpentAt: { mana: 1_000 }, mobs: { ...state.mobs, "ordinary-mana-combat": { ...state.mobs["ordinary-mana-combat"]!, engaged: true } }, })); const beforeState = useCombatStore.getState(); const before = beforeState.resourcePools.mana!; const playerAuras = beforeState.auras.filter((aura) => aura.targetId === PLAYER_AGGRO_ID); const baseRegeneration = applyTalentModifierValue( resourceProfileForClass("priest").regenerationPerSecond + beforeState.gearStats.mp5 / 5 + beforeState.gearStats.spirit * 0.02, beforeState.talentModifiers, "resource-regeneration-percent", ); const expectedPerSecond = resolveResourceValue( resolveResourceValue(baseRegeneration, playerAuras, "mana", "regeneration"), playerAuras, "power", "regeneration", ); expect(beforeState.auras.some((aura) => aura.definition.modifiers?.some((modifier) => ( modifier.kind === "stat" && modifier.stat === "mana-regeneration-max-percent-per-second" )))).toBe(false); useCombatStore.getState().tick(1, 2_000); useCombatStore.getState().tick(1, 3_000); expect(useCombatStore.getState().resourcePools.mana! - before).toBeCloseTo(expectedPerSecond * 2); }); it("casts Phoenix down a capped line and leaves other line spells' extension intact", () => { initializeMage(); for (const [id, x, z] of [ ["phoenix-target", 4, 0], ["phoenix-path", 8, 0], ["phoenix-off-line", 7, 2], ["phoenix-beyond", 12.1, 0], ] as const) addTarget(id, x, z); useCombatStore.getState().selectMob("phoenix-target"); expect(abilityById("rom-mage-primary-6")).toMatchObject({ target: "hostile", range: { max: 12 }, targeting: { shape: "line", radius: 12 }, }); expect(useCombatStore.getState().castAbility("rom-mage-primary-6", undefined, 1_000)) .toMatchObject({ ok: true, affectedMobIds: ["phoenix-target", "phoenix-path"] }); useCombatStore.getState().initializeCharacter({ classId: "rom-scout", raceId: "rom-elf", level: 60 }); clearMobRuntimeRegistry(); for (const [id, x] of [["arrow-target", 3], ["arrow-path", 7], ["arrow-beyond", 7.1]] as const) { addTarget(id, x); } useCombatStore.getState().selectMob("arrow-target"); expect(useCombatStore.getState().castAbility("rom-scout-primary-5", undefined, 4_000)) .toMatchObject({ ok: true, affectedMobIds: ["arrow-target", "arrow-path"] }); }); it("doubles only Fireball and Flame on the 10% Eruption proc and runs Control Flame once", () => { const fireball = abilityById("rom-mage-general-0")!; const tags = new Set([ ROM_MAGE_WARLOCK_PASSIVE_TAGS.eruption, ROM_MAGE_WARLOCK_PASSIVE_TAGS.controlFlame, ]); const procced = romMageWarlockCastEffects(fireball, fireball.effects, tags, 0.099); const ordinary = romMageWarlockCastEffects(fireball, fireball.effects, tags, 0.1); expect(procced.filter((effect) => effect.kind === "damage")).toEqual( fireball.effects.filter((effect) => effect.kind === "damage").map((effect) => effect.kind === "damage" ? expect.objectContaining({ coefficient: effect.coefficient * 2, basePoints: (effect.basePoints ?? 0) * 2, pointsPerLevel: (effect.pointsPerLevel ?? 0) * 2, }) : effect), ); expect(ordinary.filter((effect) => effect.kind === "damage")).toEqual( fireball.effects.filter((effect) => effect.kind === "damage"), ); expect(procced).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: "apply-aura", aura: expect.objectContaining({ id: "rom-mage-druid-control-flame-buff", durationMs: 12_000, modifiers: [expect.objectContaining({ stat: "magical-attack", value: 0.08 })], }), }), expect.objectContaining({ kind: "apply-aura", aura: expect.objectContaining({ id: "rom-mage-druid-control-flame-cooldown", durationMs: 20_000, }), }), ])); expect(romMageWarlockCastEffects(fireball, fireball.effects, new Set([ ...tags, ROM_MAGE_WARLOCK_PASSIVE_TAGS.controlFlameCooldown, ]), 0.099).some((effect) => effect.kind === "apply-aura" && effect.aura.id === "rom-mage-druid-control-flame-buff")).toBe(false); }); it("applies Eruption and Control Flame when a Fireball proc succeeds", () => { initializeMage("rom-druid"); addTarget("eruption-target", 3); useCombatStore.getState().selectMob("eruption-target"); const before = useCombatStore.getState().mobs["eruption-target"]!.health; expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID && aura.definition.utilityTags?.includes(ROM_MAGE_WARLOCK_PASSIVE_TAGS.eruption))).toBe(true); expect(useCombatStore.getState().castAbility("rom-mage-general-0", undefined, 1_002)).toMatchObject({ ok: true }); expect(useCombatStore.getState().mobs["eruption-target"]!.health).toBeLessThan(before); expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "rom-mage-druid-control-flame-buff")) .toBe(true); const flameBuff = useCombatStore.getState().auras.find((aura) => aura.definition.id === "rom-mage-druid-control-flame-buff"); expect(flameBuff?.definition.modifiers).toEqual([ expect.objectContaining({ kind: "stat", stat: "magical-attack", operation: "percent", value: 0.08 }), ]); }); it("reduces Fireball cooldown by one second with Flame Follower", () => { initializeMage("rom-warden"); addTarget("follower-target", 3); useCombatStore.getState().selectMob("follower-target"); expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID && aura.definition.utilityTags?.includes(ROM_MAGE_WARLOCK_PASSIVE_TAGS.flameFollower))).toBe(true); expect(useCombatStore.getState().castAbility("rom-mage-general-0", undefined, 1_000)).toMatchObject({ ok: true }); expect(useCombatStore.getState().cooldowns["rom-mage-general-0"]).toBe(6_000); }); it("resets only Lightning and Discharge with Brain Shock", () => { initializeMage("rom-champion"); useCombatStore.setState((state) => ({ cooldowns: { ...state.cooldowns, "rom-mage-general-1": 8_000, "rom-mage-primary-4": 10_000, "rom-mage-general-0": 9_000, }, resourcePools: { ...state.resourcePools, rage: 100 }, })); expect(useCombatStore.getState().castAbility("rom-mage-rom-champion-elite-20", undefined, 1_000)) .toMatchObject({ ok: true }); expect(useCombatStore.getState().cooldowns).toMatchObject({ "rom-mage-general-1": 0, "rom-mage-primary-4": 0, "rom-mage-general-0": 9_000, }); }); it("reduces Electrostatic Charge cooldown by 30 seconds with Mage/Champion expertise", () => { initializeMage(undefined, 80); expect(useCombatStore.getState().castAbility("rom-mage-primary-2", undefined, 1_000)).toMatchObject({ ok: true }); expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(121_000); initializeMage("rom-champion", 40); expect(useCombatStore.getState().castAbility("rom-mage-primary-2", undefined, 1_000)).toMatchObject({ ok: true }); expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(121_000); initializeMage("rom-champion", 80); expect(useCombatStore.getState().castAbility("rom-mage-primary-2", undefined, 1_000)).toMatchObject({ ok: true }); expect(useCombatStore.getState().cooldowns["rom-mage-primary-2"]).toBe(91_000); }); it("keeps Mage Fire Ward party-scoped", () => { const fireWard = abilityById("rom-mage-general-5")!; expect(fireWard.effects.some((effect) => effect.kind === "apply-aura" && effect.scope === "party")) .toBe(true); }); });