diff --git a/Projects/UOContent.Tests/Tests/Spells/Mysticism/PurgeMagicSpellTests.cs b/Projects/UOContent.Tests/Tests/Spells/Mysticism/PurgeMagicSpellTests.cs new file mode 100644 index 000000000..7ee667ce0 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Spells/Mysticism/PurgeMagicSpellTests.cs @@ -0,0 +1,432 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Server.Engines.BuffIcons; +using Server.Items; +using Server.Mobiles; +using Server.Spells; +using Server.Spells.Fifth; +using Server.Spells.First; +using Server.Spells.Mysticism; +using Server.Spells.Second; +using Server.Spells.Third; +using Xunit; + +namespace Server.Tests.Spells.Mysticism; + +[Collection("Sequential UOContent Tests")] +public class PurgeMagicSpellTests +{ + private static Mobile NewMobile() + { + var m = new Mobile(World.NewMobile); + m.DefaultMobileInit(); + m.InitStats(100, 100, 100); + m.Hits = 100; + m.Mana = 100; + return m; + } + + private static Mobile NewMysticCaster() + { + var caster = NewMobile(); + caster.Skills.Mysticism.Base = 120.0; + caster.Skills.Focus.Base = 120.0; + caster.Skills.Imbuing.Base = 0.0; + return caster; + } + + [Fact] + public void SpellMetadata_MatchesSecondCircleMysticismSources() + { + var caster = NewMysticCaster(); + var spell = new PurgeMagicSpell(caster); + + Assert.Equal("Purge Magic", spell.Name); + Assert.Equal("An Ort Sanct", spell.Mantra); + Assert.Equal(SpellCircle.Second, spell.Circle); + Assert.Equal(TimeSpan.FromSeconds(0.75), spell.CastDelayBase); + Assert.Equal(6, spell.GetMana()); + Assert.Equal(8.0, spell.RequiredSkill); + Assert.Equal(SkillName.Mysticism, spell.CastSkill); + Assert.Equal( + [Reagent.FertileDirt, Reagent.Garlic, Reagent.MandrakeRoot, Reagent.SulfurousAsh], + spell.Reagents + ); + + caster.Delete(); + } + + [Fact] + public void MysticSpellbookAndScroll_UsePurgeMagicSlot() + { + var book = new MysticSpellbook(1UL << (679 - 677)); + var scroll = new PurgeMagicScroll(); + + Assert.Equal(677, book.BookOffset); + Assert.Equal(16, book.BookCount); + Assert.True(book.HasSpell(679)); + Assert.Equal(679, GetSpellScrollId(scroll)); + + book.Delete(); + scroll.Delete(); + } + + [Fact] + public void RegisterMysticism_PreSa_DoesNotExposePurgeMagic() + { + var previousExpansion = Core.Expansion; + var caster = NewMysticCaster(); + + try + { + ResetSpellRegistry(); + Core.Expansion = Expansion.ML; + + Initializer.Configure(); + + Assert.Null(SpellRegistry.NewSpell(679, caster, null)); + Assert.Equal(-1, SpellRegistry.GetRegistryNumber(typeof(PurgeMagicSpell))); + } + finally + { + Core.Expansion = previousExpansion; + ResetSpellRegistry(); + Initializer.Configure(); + caster.Delete(); + } + } + + [Fact] + public void RegisterMysticism_Sa_ExposesPurgeMagicAtSpellId679() + { + var previousExpansion = Core.Expansion; + var caster = NewMysticCaster(); + + try + { + ResetSpellRegistry(); + Core.Expansion = Expansion.SA; + + Initializer.Configure(); + + Assert.Same(typeof(PurgeMagicSpell), SpellRegistry.Types[679]); + Assert.Equal(679, SpellRegistry.GetRegistryNumber(typeof(PurgeMagicSpell))); + Assert.IsType(SpellRegistry.NewSpell(679, caster, null)); + Assert.IsType(SpellRegistry.NewSpell(682, caster, null)); + } + finally + { + Core.Expansion = previousExpansion; + ResetSpellRegistry(); + Initializer.Configure(); + caster.Delete(); + } + } + + [Theory] + [InlineData(PurgeMagicSpell.PurgeWardType.MagicReflection)] + [InlineData(PurgeMagicSpell.PurgeWardType.Protection)] + [InlineData(PurgeMagicSpell.PurgeWardType.ReactiveArmor)] + [InlineData(PurgeMagicSpell.PurgeWardType.Bless)] + public void ApplyPurge_RemovesSupportedWard(PurgeMagicSpell.PurgeWardType wardType) + { + var caster = NewMysticCaster(); + var target = NewMobile(); + target.Skills.MagicResist.Base = 0.0; + + try + { + ApplyWard(wardType, target); + Assert.True(HasWard(wardType, target)); + + using var random = new PredictableRandom(20); + + Assert.True(PurgeMagicSpell.ApplyPurge(caster, target)); + Assert.False(HasWard(wardType, target)); + } + finally + { + PurgeMagicSpell.ClearState(target); + caster.Delete(); + target.Delete(); + } + } + + [Fact] + public void ApplyPurge_RemovesExactlyOneSupportedWard() + { + var caster = NewMysticCaster(); + var target = NewMobile(); + target.Skills.MagicResist.Base = 0.0; + + try + { + ApplyWard(PurgeMagicSpell.PurgeWardType.Protection, target); + ApplyWard(PurgeMagicSpell.PurgeWardType.ReactiveArmor, target); + + using var random = new PredictableRandom(20); + + Assert.True(PurgeMagicSpell.ApplyPurge(caster, target)); + + var remaining = 0; + remaining += ProtectionSpell.HasEffect(target) ? 1 : 0; + remaining += ReactiveArmorSpell.HasAosEffect(target) ? 1 : 0; + Assert.Equal(1, remaining); + } + finally + { + PurgeMagicSpell.ClearState(target); + ProtectionSpell.EndProtection(target); + ReactiveArmorSpell.EndArmor(target); + caster.Delete(); + target.Delete(); + } + } + + [Fact] + public void ApplyPurge_ReappliedWardBypassesStandardImmunityForThatWard() + { + var caster = NewMysticCaster(); + var target = NewMobile(); + target.Skills.MagicResist.Base = 0.0; + + try + { + ApplyWard(PurgeMagicSpell.PurgeWardType.Protection, target); + + using (new PredictableRandom(20)) + { + Assert.True(PurgeMagicSpell.ApplyPurge(caster, target)); + } + + Assert.False(ProtectionSpell.HasEffect(target)); + Assert.True(PurgeMagicSpell.IsImmuneToPurge(target, null)); + + ApplyWard(PurgeMagicSpell.PurgeWardType.Protection, target); + + using (new PredictableRandom(20)) + { + Assert.True(PurgeMagicSpell.ApplyPurge(caster, target)); + } + + Assert.False(ProtectionSpell.HasEffect(target)); + } + finally + { + PurgeMagicSpell.ClearState(target); + ProtectionSpell.EndProtection(target); + caster.Delete(); + target.Delete(); + } + } + + [Fact] + public void ApplyPurge_StandardImmunityWithoutReappliedWardBlocksDisruption() + { + var caster = NewMysticCaster(); + var target = NewMobile(); + target.Skills.MagicResist.Base = 0.0; + + try + { + ApplyWard(PurgeMagicSpell.PurgeWardType.Protection, target); + + using (new PredictableRandom(20)) + { + Assert.True(PurgeMagicSpell.ApplyPurge(caster, target)); + } + + Assert.True(PurgeMagicSpell.IsImmuneToPurge(target, null)); + Assert.False(PurgeMagicSpell.ApplyPurge(caster, target)); + Assert.False(PurgeMagicSpell.IsManaDisrupted(target)); + } + finally + { + PurgeMagicSpell.ClearState(target); + caster.Delete(); + target.Delete(); + } + } + + [Fact] + public void ApplyPurge_WithoutWard_AppliesManaDisruptionAndOutgoingDamageClearsIt() + { + var caster = NewMysticCaster(); + var target = NewMobile(); + var defender = NewMobile(); + + try + { + Assert.True(PurgeMagicSpell.ApplyPurge(caster, target)); + Assert.True(PurgeMagicSpell.IsManaDisrupted(target)); + Assert.True(new NetherBoltSpell(target).ScaleMana(10) > 10); + + var hits = target.Hits; + PurgeMagicSpell.OnMobileDamaged(target, defender, 1); + + Assert.False(PurgeMagicSpell.IsManaDisrupted(target)); + Assert.True(target.Hits < hits); + } + finally + { + PurgeMagicSpell.ClearState(target); + caster.Delete(); + target.Delete(); + defender.Delete(); + } + } + + [Fact] + public void CheckCast_FailsWhileCasterManaIsDisrupted() + { + var caster = NewMysticCaster(); + var attacker = NewMysticCaster(); + + try + { + Assert.True(PurgeMagicSpell.ApplyManaDisruption(attacker, caster)); + + var spell = new PurgeMagicSpell(caster); + + Assert.False(spell.CheckCast()); + } + finally + { + PurgeMagicSpell.ClearState(caster); + caster.Delete(); + attacker.Delete(); + } + } + + [Fact] + public void ClearState_RemovesManaDisruptionAndImmunityWithoutDamage() + { + var caster = NewMysticCaster(); + var target = NewMobile(); + + try + { + Assert.True(PurgeMagicSpell.ApplyManaDisruption(caster, target)); + var hits = target.Hits; + + PurgeMagicSpell.ClearState(target); + + Assert.False(PurgeMagicSpell.IsManaDisrupted(target)); + Assert.Equal(hits, target.Hits); + } + finally + { + caster.Delete(); + target.Delete(); + } + } + + private static void ApplyWard(PurgeMagicSpell.PurgeWardType wardType, Mobile target) + { + switch (wardType) + { + case PurgeMagicSpell.PurgeWardType.MagicReflection: + ApplyMagicReflection(target); + break; + case PurgeMagicSpell.PurgeWardType.Protection: + ProtectionSpell.Toggle(target, target); + break; + case PurgeMagicSpell.PurgeWardType.ReactiveArmor: + new ReactiveArmorSpell(target).OnCastForTests(); + break; + case PurgeMagicSpell.PurgeWardType.Bless: + var duration = TimeSpan.FromMinutes(1); + SpellHelper.AddStatBonus(target, target, StatType.Str, 10, duration); + SpellHelper.AddStatBonus(target, target, StatType.Dex, 10, duration); + SpellHelper.AddStatBonus(target, target, StatType.Int, 10, duration); + (target as PlayerMobile)?.AddBuff(new BuffInfo(BuffIcon.Bless, 1075847, 1075848, duration, "10\t10\t10")); + break; + } + } + + private static bool HasWard(PurgeMagicSpell.PurgeWardType wardType, Mobile target) => wardType switch + { + PurgeMagicSpell.PurgeWardType.MagicReflection => MagicReflectSpell.HasEffect(target), + PurgeMagicSpell.PurgeWardType.Protection => ProtectionSpell.HasEffect(target), + PurgeMagicSpell.PurgeWardType.ReactiveArmor => ReactiveArmorSpell.HasAosEffect(target), + PurgeMagicSpell.PurgeWardType.Bless => target.GetStatMod("[Magic] Str Buff") != null && + target.GetStatMod("[Magic] Dex Buff") != null && + target.GetStatMod("[Magic] Int Buff") != null, + _ => false + }; + + private static void ApplyMagicReflection(Mobile target) + { + var mods = new[] + { + new ResistanceMod(ResistanceType.Physical, "PhysicalResistMagicResist", -20), + new ResistanceMod(ResistanceType.Fire, "FireResistMagicResist", 10), + new ResistanceMod(ResistanceType.Cold, "ColdResistMagicResist", 10), + new ResistanceMod(ResistanceType.Poison, "PoisonResistMagicResist", 10), + new ResistanceMod(ResistanceType.Energy, "EnergyResistMagicResist", 10) + }; + + foreach (var mod in mods) + { + target.AddResistanceMod(mod); + } + + var table = (Dictionary)typeof(MagicReflectSpell) + .GetField("_table", BindingFlags.Static | BindingFlags.NonPublic)! + .GetValue(null)!; + table[target] = mods; + } + + 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(); + } +} + +internal static class ReactiveArmorSpellTestExtensions +{ + public static void OnCastForTests(this ReactiveArmorSpell spell) + { + var caster = spell.Caster; + var mods = new[] + { + new ResistanceMod(ResistanceType.Physical, "PhysicalResistReactiveArmorSpell", 20), + new ResistanceMod(ResistanceType.Fire, "FireResistReactiveArmorSpell", -5), + new ResistanceMod(ResistanceType.Cold, "ColdResistReactiveArmorSpell", -5), + new ResistanceMod(ResistanceType.Poison, "PoisonResistReactiveArmorSpell", -5), + new ResistanceMod(ResistanceType.Energy, "EnergyResistReactiveArmorSpell", -5) + }; + + foreach (var mod in mods) + { + caster.AddResistanceMod(mod); + } + + var tableField = typeof(ReactiveArmorSpell).GetField("_table", BindingFlags.Static | BindingFlags.NonPublic)!; + var table = (Dictionary?)tableField.GetValue(null); + table ??= []; + table[caster] = mods; + tableField.SetValue(null, table); + } +} diff --git a/Projects/UOContent/Misc/AOS.cs b/Projects/UOContent/Misc/AOS.cs index 7f2ec7ecb..7f98f4447 100644 --- a/Projects/UOContent/Misc/AOS.cs +++ b/Projects/UOContent/Misc/AOS.cs @@ -5,6 +5,7 @@ using Server.Items; using Server.Mobiles; using Server.Spells; using Server.Spells.Fifth; +using Server.Spells.Mysticism; using Server.Spells.Ninjitsu; using Server.Spells.Seventh; @@ -248,6 +249,8 @@ namespace Server m.Damage(totalDamage, from); var appliedDamage = Math.Max(0, oldHits - m.Hits); + PurgeMagicSpell.OnMobileDamaged(from, m, appliedDamage); + if (firePostResistDamage > 0 && appliedDamage > 0) { Swarm.ClearDefender(m); diff --git a/Projects/UOContent/Spells/Base/Spell.cs b/Projects/UOContent/Spells/Base/Spell.cs index 387112988..088ba0d4f 100644 --- a/Projects/UOContent/Spells/Base/Spell.cs +++ b/Projects/UOContent/Spells/Base/Spell.cs @@ -652,6 +652,8 @@ namespace Server.Spells scalar = 1.0; } + Mysticism.PurgeMagicSpell.GetManaDisruptionScalar(Caster, ref scalar); + // Lower Mana Cost = 40% var lmc = AosAttributes.GetValue(Caster, AosAttribute.LowerManaCost); if (lmc > 40) diff --git a/Projects/UOContent/Spells/Fifth/MagicReflect.cs b/Projects/UOContent/Spells/Fifth/MagicReflect.cs index 576094e88..0eb1d93a0 100644 --- a/Projects/UOContent/Spells/Fifth/MagicReflect.cs +++ b/Projects/UOContent/Spells/Fifth/MagicReflect.cs @@ -25,6 +25,8 @@ namespace Server.Spells.Fifth public override SpellCircle Circle => SpellCircle.Fifth; + public static bool HasEffect(Mobile m) => _table.ContainsKey(m); + public override bool CheckCast() { if (Core.AOS) @@ -157,11 +159,11 @@ namespace Server.Spells.Fifth } [OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))] - public static void EndReflect(Mobile m) + public static bool EndReflect(Mobile m) { if (!_table.Remove(m, out var mods)) { - return; + return false; } for (var i = 0; i < mods?.Length; ++i) @@ -170,6 +172,7 @@ namespace Server.Spells.Fifth } (m as PlayerMobile)?.RemoveBuff(BuffIcon.MagicReflection); + return true; } } } diff --git a/Projects/UOContent/Spells/First/ReactiveArmor.cs b/Projects/UOContent/Spells/First/ReactiveArmor.cs index 6f88490db..690a0b8f8 100644 --- a/Projects/UOContent/Spells/First/ReactiveArmor.cs +++ b/Projects/UOContent/Spells/First/ReactiveArmor.cs @@ -28,6 +28,8 @@ namespace Server.Spells.First public override SpellCircle Circle => SpellCircle.First; + public static bool HasAosEffect(Mobile m) => _table?.ContainsKey(m) == true; + public static bool HasEffect(Mobile m) => _t2aTable?.ContainsKey(m) == true; public static void RemoveEffect(Mobile m) @@ -256,13 +258,13 @@ namespace Server.Spells.First } [OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))] - public static void EndArmor(Mobile m) + public static bool EndArmor(Mobile m) { RemoveEffect(m); if (_table?.Remove(m, out var mods) != true) { - return; + return false; } for (var i = 0; i < mods?.Length; ++i) @@ -271,6 +273,7 @@ namespace Server.Spells.First } (m as PlayerMobile)?.RemoveBuff(BuffIcon.ReactiveArmor); + return true; } } } diff --git a/Projects/UOContent/Spells/Initializer.cs b/Projects/UOContent/Spells/Initializer.cs index 4d16bd821..28d068080 100644 --- a/Projects/UOContent/Spells/Initializer.cs +++ b/Projects/UOContent/Spells/Initializer.cs @@ -183,7 +183,7 @@ namespace Server.Spells // Mysticism spells Register(677, typeof(NetherBoltSpell)); // Register(678, typeof(HealingStoneSpell)); - // Register(679, typeof(PurgeMagicSpell)); + Register(679, typeof(PurgeMagicSpell)); // Register(680, typeof(EnchantSpell)); // Register(681, typeof(SleepSpell)); Register(682, typeof(EagleStrikeSpell)); diff --git a/Projects/UOContent/Spells/Mysticism/PurgeMagicSpell.cs b/Projects/UOContent/Spells/Mysticism/PurgeMagicSpell.cs new file mode 100644 index 000000000..b3024e258 --- /dev/null +++ b/Projects/UOContent/Spells/Mysticism/PurgeMagicSpell.cs @@ -0,0 +1,490 @@ +using System; +using System.Collections.Generic; +using ModernUO.CodeGeneratedEvents; +using Server.Engines.BuffIcons; +using Server.Mobiles; +using Server.Spells.Fifth; +using Server.Spells.First; +using Server.Spells.Second; +using Server.Targeting; + +namespace Server.Spells.Mysticism; + +public class PurgeMagicSpell : MysticSpell, ITargetingSpell +{ + private const int StandardPurgeImmunitySeconds = 8; + private const int HistoricalSkillScaledImmunityMinSeconds = 1; + private const int HistoricalSkillScaledImmunityMaxSeconds = 6; + private const int ManaDisruptionSeconds = 8; + private const int ManaDisruptionAdditionalImmunitySeconds = 16; + private const double MinManaDisruptionScalar = 1.10; + private const double MaxManaDisruptionScalar = 1.50; + private const int MaxManaDisruptionDamage = 40; + + private static readonly SpellInfo _info = new( + "Purge Magic", + "An Ort Sanct", + -1, + 9002, + Reagent.FertileDirt, + Reagent.Garlic, + Reagent.MandrakeRoot, + Reagent.SulfurousAsh + ); + + private static readonly Dictionary _purgeImmunity = new(); + private static readonly Dictionary> _rePurgeableWards = new(); + private static readonly Dictionary _manaDisruptions = new(); + private static bool _applyingDisruptionDamage; + + public PurgeMagicSpell(Mobile caster, Item scroll = null) : base(caster, scroll, _info) + { + } + + public override SpellCircle Circle => SpellCircle.Second; + + public static void Configure() + { + EventSink.Logout += OnLogout; + } + + public override bool CheckCast() + { + if (IsManaDisrupted(Caster)) + { + Caster.SendMessage("Your disrupted mana flow prevents you from casting Purge Magic."); + return false; + } + + return base.CheckCast(); + } + + public override void OnCast() + { + Caster.Target = new SpellTarget(this, TargetFlags.Harmful); + } + + public void Target(Mobile m) + { + if (CheckHSequence(m)) + { + var source = Caster; + SpellHelper.Turn(source, m); + SpellHelper.CheckReflect((int)Circle, ref source, ref m); + + if (ApplyPurge(source, m, sendMessages: true)) + { + HarmfulSpell(m); + } + } + } + + public static bool ApplyPurge(Mobile caster, Mobile target, bool sendMessages = false) + { + if (caster == null || target == null || caster.Deleted || target.Deleted || !caster.Alive || !target.Alive) + { + return false; + } + + if (TryPurgeWard(caster, target, out var wardName)) + { + if (wardName.Length == 0) + { + return false; + } + + StartPurgeImmunity(target, TimeSpan.FromSeconds(StandardPurgeImmunitySeconds)); + + if (sendMessages) + { + caster.SendMessage($"You purge {wardName} from your target."); + target.SendMessage($"Your {wardName} ward has been purged."); + } + + target.FixedParticles(0x3728, 1, 13, 0x26B8, 0x834, 7, EffectLayer.Head, 0); + target.PlaySound(0x655); + return true; + } + + if (IsImmuneToPurge(target, null)) + { + if (sendMessages) + { + caster.SendMessage("That target is temporarily immune to purge effects."); + } + + return false; + } + + return ApplyManaDisruption(caster, target, sendMessages); + } + + public static bool TryPurgeWard(Mobile caster, Mobile target, out string wardName) + { + wardName = null; + + if (!TryGetRandomPurgeableWard(target, out var ward)) + { + return false; + } + + if (IsImmuneToPurge(target, ward.Type)) + { + wardName = string.Empty; + return true; + } + + if (CheckPurgeResisted(caster, target, ward.Circle)) + { + target.SendMessage("You resist the purge magic."); + wardName = string.Empty; + return true; + } + + if (!ward.Remove(target)) + { + return false; + } + + wardName = ward.Name; + MarkRePurgeable(target, ward.Type, TimeSpan.FromSeconds(StandardPurgeImmunitySeconds)); + return true; + } + + public static bool ApplyManaDisruption(Mobile caster, Mobile target, bool sendMessages = false) + { + if (_manaDisruptions.ContainsKey(target) || target.Deleted || !target.Alive) + { + return false; + } + + var skillTotal = caster.Skills.Mysticism.Value + Math.Max(caster.Skills.Focus.Value, caster.Skills.Imbuing.Value); + var scalar = Math.Clamp(1.0 + skillTotal / 240.0 * 0.5, MinManaDisruptionScalar, MaxManaDisruptionScalar); + var context = new ManaDisruptionContext(caster, target, scalar); + _manaDisruptions[target] = context; + + Timer.StartTimer(TimeSpan.FromSeconds(ManaDisruptionSeconds), () => EndManaDisruption(target, applyDamage: true), out context.TimerToken); + + if (sendMessages) + { + caster.SendMessage("You disrupt the target's mana flow."); + target.SendMessage("Your mana flow has been disrupted."); + } + + target.FixedParticles(0x3728, 1, 13, 0x26B8, 0x834, 7, EffectLayer.Head, 0); + target.PlaySound(0x655); + return true; + } + + public static bool EndManaDisruption(Mobile target, bool applyDamage) + { + if (!_manaDisruptions.Remove(target, out var context)) + { + return false; + } + + context.TimerToken.Cancel(); + StartPurgeImmunity(target, TimeSpan.FromSeconds(ManaDisruptionAdditionalImmunitySeconds)); + + if (applyDamage && target?.Deleted == false && target.Alive) + { + var elapsed = Core.Now - context.Started; + var damage = Math.Clamp((int)Math.Ceiling(elapsed.TotalSeconds / ManaDisruptionSeconds * MaxManaDisruptionDamage), 1, MaxManaDisruptionDamage); + _applyingDisruptionDamage = true; + + try + { + AOS.Damage(target, context.Caster, damage, 0, 0, 0, 0, 0, 100); + } + finally + { + _applyingDisruptionDamage = false; + } + + target.SendMessage("Chaotic energy rushes back through your disrupted mana flow."); + } + + return true; + } + + public static bool IsManaDisrupted(Mobile m) => m != null && _manaDisruptions.ContainsKey(m); + + public static bool GetManaDisruptionScalar(Mobile m, ref double scalar) + { + if (m != null && _manaDisruptions.TryGetValue(m, out var context)) + { + scalar = Math.Max(scalar, context.Scalar); + return true; + } + + return false; + } + + public static bool IsImmuneToPurge(Mobile target, PurgeWardType? wardType) + { + if (target == null) + { + return false; + } + + if (wardType.HasValue && IsRePurgeable(target, wardType.Value)) + { + return false; + } + + if (IsManaDisrupted(target)) + { + return true; + } + + if (_purgeImmunity.TryGetValue(target, out var immuneUntil)) + { + if (immuneUntil > Core.Now) + { + return true; + } + + _purgeImmunity.Remove(target); + } + + return false; + } + + public static void OnMobileDamaged(Mobile attacker, Mobile defender, int damage) + { + if (_applyingDisruptionDamage) + { + return; + } + + if (damage > 0 && attacker?.Deleted == false && defender?.Deleted == false && attacker != defender) + { + EndManaDisruption(attacker, applyDamage: true); + } + } + + public static void ClearState(Mobile m) + { + if (m == null) + { + return; + } + + if (_manaDisruptions.Remove(m, out var context)) + { + context.TimerToken.Cancel(); + } + + _purgeImmunity.Remove(m); + _rePurgeableWards.Remove(m); + } + + [OnEvent(nameof(PlayerMobile.PlayerDeathEvent))] + public static void OnPlayerDeath(PlayerMobile m) => ClearState(m); + + [OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))] + public static void OnPlayerDeleted(PlayerMobile m) => ClearState(m); + + private static void OnLogout(Mobile m) => ClearState(m); + + private static bool TryGetRandomPurgeableWard(Mobile target, out PurgeWard selectedWard) + { + var selected = default(PurgeWard); + var wardCount = 0; + + if (MagicReflectSpell.HasEffect(target)) + { + TrySelectWard(new PurgeWard(PurgeWardType.MagicReflection, "Magic Reflection", SpellCircle.Fifth, MagicReflectSpell.EndReflect)); + } + + if (ProtectionSpell.HasEffect(target)) + { + TrySelectWard(new PurgeWard(PurgeWardType.Protection, "Protection", SpellCircle.Second, ProtectionSpell.EndProtection)); + } + + if (ReactiveArmorSpell.HasAosEffect(target)) + { + TrySelectWard(new PurgeWard(PurgeWardType.ReactiveArmor, "Reactive Armor", SpellCircle.First, ReactiveArmorSpell.EndArmor)); + } + + if (HasBless(target)) + { + TrySelectWard(new PurgeWard(PurgeWardType.Bless, "Bless", SpellCircle.Third, RemoveBless)); + } + + selectedWard = selected; + return wardCount > 0; + + void TrySelectWard(PurgeWard ward) + { + wardCount++; + + if (Utility.Random(wardCount) == 0) + { + selected = ward; + } + } + } + + private static bool CheckPurgeResisted(Mobile caster, Mobile target, SpellCircle wardCircle) + { + var resistPercent = GetPurgeResistPercent(caster, target, wardCircle); + + if (resistPercent <= 0.0) + { + return false; + } + + if (resistPercent >= 100.0) + { + return true; + } + + if (target.Skills.MagicResist.Value < (1 + (int)wardCircle) * 10) + { + target.CheckSkill(SkillName.MagicResist, 0.0, target.Skills.MagicResist.Cap); + } + + return resistPercent >= Utility.RandomDouble() * 100.0; + } + + private static double GetPurgeResistPercent(Mobile caster, Mobile target, SpellCircle wardCircle) + { + var effectiveSkill = (caster.Skills.Mysticism.Value + Math.Max(caster.Skills.Focus.Value, caster.Skills.Imbuing.Value)) / 2.0; + var magicResist = target.Skills.MagicResist.Value; + var firstPercent = magicResist / 5.0; + var secondPercent = magicResist - ((effectiveSkill - 20.0) / 5.0 + (1 + (int)wardCircle) * 5.0); + + return Math.Max(firstPercent, secondPercent) / 2.0; + } + + private static bool HasBless(Mobile target) => + target.GetStatMod("[Magic] Str Buff") != null && + target.GetStatMod("[Magic] Dex Buff") != null && + target.GetStatMod("[Magic] Int Buff") != null; + + private static bool RemoveBless(Mobile target) + { + if (!HasBless(target)) + { + return false; + } + + target.RemoveStatMod("[Magic] Str Buff"); + target.RemoveStatMod("[Magic] Dex Buff"); + target.RemoveStatMod("[Magic] Int Buff"); + (target as PlayerMobile)?.RemoveBuff(BuffIcon.Bless); + return true; + } + + private static void StartPurgeImmunity(Mobile target, TimeSpan duration) + { + if (target == null || target.Deleted) + { + return; + } + + var until = Core.Now + duration; + + if (!_purgeImmunity.TryGetValue(target, out var current) || current < until) + { + _purgeImmunity[target] = until; + Timer.StartTimer(duration, () => ExpirePurgeImmunity(target, until)); + } + } + + private static void MarkRePurgeable(Mobile target, PurgeWardType wardType, TimeSpan duration) + { + if (target == null || target.Deleted) + { + return; + } + + if (!_rePurgeableWards.TryGetValue(target, out var wards)) + { + wards = new Dictionary(); + _rePurgeableWards[target] = wards; + } + + var until = Core.Now + duration; + wards[wardType] = until; + Timer.StartTimer(duration, () => ExpireRePurgeableWard(target, wardType, until)); + } + + private static void ExpirePurgeImmunity(Mobile target, DateTime until) + { + if (_purgeImmunity.TryGetValue(target, out var current) && current <= until) + { + _purgeImmunity.Remove(target); + } + } + + private static void ExpireRePurgeableWard(Mobile target, PurgeWardType wardType, DateTime until) + { + if (!_rePurgeableWards.TryGetValue(target, out var wards) || !wards.TryGetValue(wardType, out var current) || current > until) + { + return; + } + + wards.Remove(wardType); + + if (wards.Count == 0) + { + _rePurgeableWards.Remove(target); + } + } + + private static bool IsRePurgeable(Mobile target, PurgeWardType wardType) + { + if (!_rePurgeableWards.TryGetValue(target, out var wards) || !wards.TryGetValue(wardType, out var until)) + { + return false; + } + + if (until > Core.Now) + { + return true; + } + + wards.Remove(wardType); + + if (wards.Count == 0) + { + _rePurgeableWards.Remove(target); + } + + return false; + } + + public enum PurgeWardType + { + MagicReflection, + Protection, + ReactiveArmor, + Bless + } + + private readonly record struct PurgeWard( + PurgeWardType Type, + string Name, + SpellCircle Circle, + Func Remove + ); + + private sealed class ManaDisruptionContext + { + public ManaDisruptionContext(Mobile caster, Mobile target, double scalar) + { + Caster = caster; + Target = target; + Scalar = scalar; + Started = Core.Now; + } + + public Mobile Caster { get; } + public Mobile Target { get; } + public double Scalar { get; } + public DateTime Started { get; } + public TimerExecutionToken TimerToken; + } +} diff --git a/Projects/UOContent/Spells/Second/Protection.cs b/Projects/UOContent/Spells/Second/Protection.cs index dcbeb998d..ddb3fb34b 100644 --- a/Projects/UOContent/Spells/Second/Protection.cs +++ b/Projects/UOContent/Spells/Second/Protection.cs @@ -32,6 +32,8 @@ namespace Server.Spells.Second public override SpellCircle Circle => SpellCircle.Second; + public static bool HasEffect(Mobile m) => Registry.ContainsKey(m) || HasT2AProtection(m); + public static bool HasT2AProtection(Mobile m) => _t2aTable?.ContainsKey(m) ?? false; public static void RemoveT2AProtection(Mobile m) @@ -153,13 +155,13 @@ namespace Server.Spells.Second } [OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))] - public static void EndProtection(Mobile m) + public static bool EndProtection(Mobile m) { RemoveT2AProtection(m); if (_table?.Remove(m, out var mods) != true) { - return; + return false; } Registry.Remove(m); @@ -168,6 +170,7 @@ namespace Server.Spells.Second m.RemoveSkillMod(mods.Item2); (m as PlayerMobile)?.RemoveBuff(BuffIcon.Protection); + return true; } public override void OnCast()