From 5c3ea8b598dec3147d37ee07ed4d57d0184f533f Mon Sep 17 00:00:00 2001 From: Crome696 Date: Thu, 9 Jul 2026 19:44:40 +0200 Subject: [PATCH] feat: add Nether Bolt mysticism spell --- .../Spells/Mysticism/NetherBoltSpellTests.cs | 205 ++++++++++++++++++ Projects/UOContent/Spells/First/MagicArrow.cs | 5 +- Projects/UOContent/Spells/Initializer.cs | 2 +- .../Spells/Mysticism/NetherBoltSpell.cs | 71 ++++++ 4 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Spells/Mysticism/NetherBoltSpellTests.cs create mode 100644 Projects/UOContent/Spells/Mysticism/NetherBoltSpell.cs diff --git a/Projects/UOContent.Tests/Tests/Spells/Mysticism/NetherBoltSpellTests.cs b/Projects/UOContent.Tests/Tests/Spells/Mysticism/NetherBoltSpellTests.cs new file mode 100644 index 000000000..d7c0545dd --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Spells/Mysticism/NetherBoltSpellTests.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Server.Items; +using Server.Spells; +using Server.Spells.First; +using Server.Spells.Mysticism; +using Xunit; + +namespace Server.Tests.Spells.Mysticism; + +[Collection("Sequential UOContent Tests")] +public class NetherBoltSpellTests +{ + private static Mobile NewMobile() + { + var m = new Mobile(World.NewMobile); + m.DefaultMobileInit(); + m.InitStats(100, 100, 100); + m.Mana = 100; + return m; + } + + [Fact] + public void SpellMetadata_MatchesFirstCircleMysticismSources() + { + var caster = NewMobile(); + var spell = new NetherBoltSpell(caster); + + Assert.Equal("Nether Bolt", spell.Name); + Assert.Equal("In Corp Ylem", spell.Mantra); + Assert.Equal(SpellCircle.First, spell.Circle); + Assert.Equal(TimeSpan.FromSeconds(0.5), spell.CastDelayBase); + Assert.Equal(4, spell.GetMana()); + Assert.Equal(0.0, spell.RequiredSkill); + Assert.Equal(SkillName.Mysticism, spell.CastSkill); + Assert.Equal([Reagent.BlackPearl, Reagent.SulfurousAsh], spell.Reagents); + Assert.True(spell.DelayedDamage); + + caster.Delete(); + } + + [Fact] + public void MysticSpellbookAndScroll_UseNetherBoltSlot() + { + var book = new MysticSpellbook(1); + var scroll = new NetherBoltScroll(); + + Assert.Equal(677, book.BookOffset); + Assert.Equal(16, book.BookCount); + Assert.True(book.HasSpell(677)); + Assert.Equal(677, GetSpellScrollId(scroll)); + + book.Delete(); + scroll.Delete(); + } + + [Fact] + public void RegisterMysticism_PreSa_DoesNotExposeNetherBolt() + { + var previousExpansion = Core.Expansion; + var caster = NewMobile(); + + try + { + ResetSpellRegistry(); + Core.Expansion = Expansion.ML; + + Initializer.Configure(); + + Assert.Null(SpellRegistry.NewSpell(677, caster, null)); + Assert.Equal(-1, SpellRegistry.GetRegistryNumber(typeof(NetherBoltSpell))); + Assert.IsType(SpellRegistry.NewSpell(4, caster, null)); + } + finally + { + Core.Expansion = previousExpansion; + ResetSpellRegistry(); + Initializer.Configure(); + caster.Delete(); + } + } + + [Fact] + public void RegisterMysticism_Sa_ExposesNetherBoltAtSpellId677() + { + var previousExpansion = Core.Expansion; + var caster = NewMobile(); + + try + { + ResetSpellRegistry(); + Core.Expansion = Expansion.SA; + + Initializer.Configure(); + + Assert.Same(typeof(NetherBoltSpell), SpellRegistry.Types[677]); + Assert.Equal(677, SpellRegistry.GetRegistryNumber(typeof(NetherBoltSpell))); + Assert.IsType(SpellRegistry.NewSpell(677, caster, null)); + Assert.IsType(SpellRegistry.NewSpell(682, caster, null)); + } + finally + { + Core.Expansion = previousExpansion; + ResetSpellRegistry(); + Initializer.Configure(); + caster.Delete(); + } + } + + [Fact] + public void DelayedDamageFamily_PreventsNetherBoltAndMagicArrowStackingInEitherOrder() + { + var caster = NewMobile(); + var target = NewMobile(); + var netherBolt = new NetherBoltSpell(caster); + var magicArrow = new MagicArrowSpell(caster); + + netherBolt.StartDelayedDamageContext(target, new TestTimer()); + + Assert.True(netherBolt.HasDelayedDamageContext(target)); + Assert.True(magicArrow.HasDelayedDamageContext(target)); + + magicArrow.RemoveDelayedDamageContext(target); + magicArrow.StartDelayedDamageContext(target, new TestTimer()); + + Assert.True(magicArrow.HasDelayedDamageContext(target)); + Assert.True(netherBolt.HasDelayedDamageContext(target)); + + netherBolt.RemoveDelayedDamageContext(target); + caster.Delete(); + target.Delete(); + } + + [Fact] + public void GetNewAosDamage_UsesHigherFocusOrImbuingSupportSkill() + { + var focusCaster = NewMobile(); + var imbuingCaster = NewMobile(); + var lowSupportCaster = NewMobile(); + var target = NewMobile(); + + focusCaster.Skills.Mysticism.Base = 120.0; + focusCaster.Skills.Focus.Base = 120.0; + focusCaster.Skills.Imbuing.Base = 0.0; + + imbuingCaster.Skills.Mysticism.Base = 120.0; + imbuingCaster.Skills.Focus.Base = 0.0; + imbuingCaster.Skills.Imbuing.Base = 120.0; + + lowSupportCaster.Skills.Mysticism.Base = 120.0; + lowSupportCaster.Skills.Focus.Base = 0.0; + lowSupportCaster.Skills.Imbuing.Base = 0.0; + + using var random = new PredictableRandom(0); + + var focusDamage = new NetherBoltSpell(focusCaster).GetNewAosDamage(10, 1, 4, target); + var imbuingDamage = new NetherBoltSpell(imbuingCaster).GetNewAosDamage(10, 1, 4, target); + var lowSupportDamage = new NetherBoltSpell(lowSupportCaster).GetNewAosDamage(10, 1, 4, target); + + Assert.Equal(focusDamage, imbuingDamage); + Assert.True(focusDamage > lowSupportDamage); + + focusCaster.Delete(); + imbuingCaster.Delete(); + lowSupportCaster.Delete(); + target.Delete(); + } + + private static int GetSpellScrollId(SpellScroll scroll) + { + var field = typeof(SpellScroll).GetField("_spellID", BindingFlags.Instance | BindingFlags.NonPublic); + return (int)field!.GetValue(scroll)!; + } + + private static void ResetSpellRegistry() + { + var types = (Type[])typeof(SpellRegistry) + .GetField("m_Types", BindingFlags.Static | BindingFlags.NonPublic)! + .GetValue(null)!; + Array.Clear(types); + + var idsFromTypes = (Dictionary)typeof(SpellRegistry) + .GetField("m_IDsFromTypes", BindingFlags.Static | BindingFlags.NonPublic)! + .GetValue(null)!; + idsFromTypes.Clear(); + + typeof(SpellRegistry) + .GetField("m_Count", BindingFlags.Static | BindingFlags.NonPublic)! + .SetValue(null, 0); + + SpellRegistry.SpecialMoves.Clear(); + } + + private sealed class TestTimer : Timer + { + public TestTimer() : base(TimeSpan.FromMinutes(5)) + { + } + + protected override void OnTick() + { + } + } +} diff --git a/Projects/UOContent/Spells/First/MagicArrow.cs b/Projects/UOContent/Spells/First/MagicArrow.cs index ad22a5d10..d8425a3ac 100644 --- a/Projects/UOContent/Spells/First/MagicArrow.cs +++ b/Projects/UOContent/Spells/First/MagicArrow.cs @@ -1,4 +1,5 @@ using System; +using Server.Spells.Mysticism; using Server.Targeting; namespace Server.Spells.First @@ -19,7 +20,9 @@ namespace Server.Spells.First public override SpellCircle Circle => SpellCircle.First; - public override Type[] DelayedDamageSpellFamilyStacking => AOSNoDelayedDamageStackingSelf; + private static readonly Type[] _delayedDamageSpellFamilyStacking = Core.AOS ? [typeof(NetherBoltSpell)] : null; + + public override Type[] DelayedDamageSpellFamilyStacking => _delayedDamageSpellFamilyStacking; public override bool DelayedDamage => true; diff --git a/Projects/UOContent/Spells/Initializer.cs b/Projects/UOContent/Spells/Initializer.cs index a7043036a..8639b3597 100644 --- a/Projects/UOContent/Spells/Initializer.cs +++ b/Projects/UOContent/Spells/Initializer.cs @@ -181,7 +181,7 @@ namespace Server.Spells if (Core.SA) { // Mysticism spells - // Register(677, typeof(NetherBoltSpell)); + Register(677, typeof(NetherBoltSpell)); // Register(678, typeof(HealingStoneSpell)); // Register(679, typeof(PurgeMagicSpell)); // Register(680, typeof(EnchantSpell)); diff --git a/Projects/UOContent/Spells/Mysticism/NetherBoltSpell.cs b/Projects/UOContent/Spells/Mysticism/NetherBoltSpell.cs new file mode 100644 index 000000000..1397e7d9e --- /dev/null +++ b/Projects/UOContent/Spells/Mysticism/NetherBoltSpell.cs @@ -0,0 +1,71 @@ +using System; +using Server.Spells.First; +using Server.Targeting; + +namespace Server.Spells.Mysticism; + +public class NetherBoltSpell : MysticSpell, ITargetingSpell +{ + private static readonly SpellInfo _info = new( + "Nether Bolt", + "In Corp Ylem", + -1, + 9002, + Reagent.BlackPearl, + Reagent.SulfurousAsh + ); + + private static readonly Type[] _delayedDamageSpellFamilyStacking = Core.AOS ? [typeof(MagicArrowSpell)] : null; + + public NetherBoltSpell(Mobile caster, Item scroll = null) : base(caster, scroll, _info) + { + } + + public override SpellCircle Circle => SpellCircle.First; + + public override bool DelayedDamage => true; + + public override Type[] DelayedDamageSpellFamilyStacking => _delayedDamageSpellFamilyStacking; + + public void Target(Mobile m) + { + if (CheckHSequence(m)) + { + var source = Caster; + + SpellHelper.Turn(source, m); + + if (Core.SA && HasDelayedDamageContext(m)) + { + DoHurtFizzle(); + return; + } + + SpellHelper.CheckReflect((int)Circle, ref source, ref m); + + source.MovingParticles( + m, + 0x36D4, + 5, + 0, + false, + true, + 0x49A, + 0, + 9502, + 1, + 0, + EffectLayer.Head, + 0x100 + ); + source.PlaySound(0x211); + + SpellHelper.Damage(this, m, GetNewAosDamage(10, 1, 4, m), 0, 0, 0, 0, 0, 100); + } + } + + public override void OnCast() + { + Caster.Target = new SpellTarget(this, TargetFlags.Harmful); + } +}