diff --git a/Projects/UOContent.Tests/Tests/Spells/Mysticism/MassSleepSpellTests.cs b/Projects/UOContent.Tests/Tests/Spells/Mysticism/MassSleepSpellTests.cs new file mode 100644 index 000000000..2d6d09d6f --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Spells/Mysticism/MassSleepSpellTests.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Server.Items; +using Server.Spells; +using Server.Spells.Mysticism; +using Xunit; + +namespace Server.Tests.Spells.Mysticism; + +[Collection("Sequential UOContent Tests")] +public class MassSleepSpellTests +{ + [Fact] + public void SpellMetadataAndScroll_MatchFifthCircleMysticismSources() + { + var caster = NewMysticCaster(); + var spell = new MassSleepSpell(caster); + var scroll = new MassSleepScroll(); + + try + { + Assert.Equal("Mass Sleep", spell.Name); + Assert.Equal("Vas Zu", spell.Mantra); + Assert.Equal(SpellCircle.Fifth, spell.Circle); + Assert.Equal(TimeSpan.FromSeconds(1.5), spell.CastDelayBase); + Assert.Equal(14, spell.GetMana()); + Assert.Equal(45.0, spell.RequiredSkill); + Assert.Equal([Reagent.Ginseng, Reagent.Nightshade, Reagent.SpidersSilk], spell.Reagents); + Assert.Equal(686, GetSpellScrollId(scroll)); + } + finally + { + caster.Delete(); + scroll.Delete(); + } + } + + [Fact] + public void RegisterMysticism_RegistersMassSleepOnlyAtSa() + { + var previousExpansion = Core.Expansion; + var caster = NewMysticCaster(); + + try + { + ResetSpellRegistry(); + Core.Expansion = Expansion.ML; + Initializer.Configure(); + Assert.Null(SpellRegistry.NewSpell(686, caster, null)); + + ResetSpellRegistry(); + Core.Expansion = Expansion.SA; + Initializer.Configure(); + Assert.Same(typeof(MassSleepSpell), SpellRegistry.Types[686]); + Assert.IsType(SpellRegistry.NewSpell(686, caster, null)); + } + finally + { + Core.Expansion = previousExpansion; + ResetSpellRegistry(); + Initializer.Configure(); + caster.Delete(); + } + } + + [Fact] + public void SleepDuration_UsesBetterSupportSkillAndTargetResist() + { + var caster = NewMysticCaster(); + var target = NewMobile(); + + try + { + caster.Skills.Mysticism.Base = 120.0; + caster.Skills.Focus.Base = 80.0; + caster.Skills.Imbuing.Base = 120.0; + target.Skills.MagicResist.Base = 50.0; + + Assert.Equal(TimeSpan.FromSeconds(10), SleepSpell.GetDuration(caster, target)); + + target.Skills.MagicResist.Base = 150.0; + Assert.Equal(TimeSpan.Zero, SleepSpell.GetDuration(caster, target)); + } + finally + { + caster.Delete(); + target.Delete(); + } + } + + [Fact] + public void DamageBreaksSleepAndAppliesResistScaledPlayerImmunity() + { + var caster = NewMysticCaster(); + var target = NewMobile(); + target.Player = true; + target.Skills.MagicResist.Base = 120.0; + + try + { + Assert.True(SleepSpell.Apply(caster, target, TimeSpan.FromSeconds(10))); + Assert.True(SleepSpell.IsUnderEffect(target)); + Assert.Equal(SleepSpell.CastSpeedMalus, SleepSpell.GetCastSpeedMalus(target)); + Assert.Equal(SleepSpell.CastRecoveryMalus, SleepSpell.GetCastRecoveryMalus(target)); + Assert.Equal(SleepSpell.SwingSpeedMalus, SleepSpell.GetSwingSpeedMalus(target)); + Assert.Equal(TimeSpan.FromSeconds(12), SleepSpell.GetImmunityDuration(target)); + + SleepSpell.OnMobileDamaged(target, 1); + + Assert.False(SleepSpell.IsUnderEffect(target)); + Assert.True(SleepSpell.IsImmune(target)); + Assert.False(SleepSpell.Apply(caster, target, TimeSpan.FromSeconds(10))); + } + finally + { + SleepSpell.ClearAllForTests(); + caster.Delete(); + target.Delete(); + } + } + + private static Mobile NewMobile() + { + var mobile = new Mobile(World.NewMobile); + mobile.DefaultMobileInit(); + mobile.InitStats(100, 100, 100); + mobile.Hits = mobile.HitsMax; + mobile.Mana = mobile.ManaMax; + return mobile; + } + + private static Mobile NewMysticCaster() + { + var caster = NewMobile(); + caster.Skills.Mysticism.Base = 120.0; + caster.Skills.Focus.Base = 120.0; + return caster; + } + + 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(); + } +} diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.cs index 297edc2c6..ec455fd3c 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.cs @@ -1533,6 +1533,8 @@ public abstract partial class BaseWeapon bonus -= EssenceOfWindSpell.GetSSIMalus(m); } + bonus -= SleepSpell.GetSwingSpeedMalus(m); + if (bonus > 60) { bonus = 60; diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 2d425ff88..733833a2d 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -1641,6 +1641,7 @@ namespace Server.Mobiles Confidence.StopRegenerating(this); StaminaSystem.FatigueOnDamage(this, amount); + Server.Spells.Mysticism.SleepSpell.OnMobileDamaged(this, amount); var speechType = SpeechType; diff --git a/Projects/UOContent/Mobiles/PlayerMobile.cs b/Projects/UOContent/Mobiles/PlayerMobile.cs index fb00baa88..d97dccaa1 100644 --- a/Projects/UOContent/Mobiles/PlayerMobile.cs +++ b/Projects/UOContent/Mobiles/PlayerMobile.cs @@ -2375,6 +2375,7 @@ namespace Server.Mobiles Confidence.StopRegenerating(this); StaminaSystem.FatigueOnDamage(this, amount); + Server.Spells.Mysticism.SleepSpell.OnMobileDamaged(this, amount); ReceivedHonorContext?.OnTargetDamaged(from, amount); SentHonorContext?.OnSourceDamaged(from, amount); diff --git a/Projects/UOContent/Spells/Base/Spell.cs b/Projects/UOContent/Spells/Base/Spell.cs index 86ac47058..9bf0e78c5 100644 --- a/Projects/UOContent/Spells/Base/Spell.cs +++ b/Projects/UOContent/Spells/Base/Spell.cs @@ -697,7 +697,8 @@ namespace Server.Spells } var fcr = AosAttributes.GetValue(Caster, AosAttribute.CastRecovery) - - ThunderstormSpell.GetCastRecoveryMalus(Caster); + ThunderstormSpell.GetCastRecoveryMalus(Caster) - + Mysticism.SleepSpell.GetCastRecoveryMalus(Caster); var fcrDelay = -(CastRecoveryFastScalar * fcr); @@ -742,6 +743,8 @@ namespace Server.Spells fc -= EssenceOfWindSpell.GetFCMalus(Caster); } + fc -= Mysticism.SleepSpell.GetCastSpeedMalus(Caster); + fc += Mysticism.EnchantSpell.GetFasterCasting(Caster); if (Core.SA) diff --git a/Projects/UOContent/Spells/Initializer.cs b/Projects/UOContent/Spells/Initializer.cs index 45e32bd66..febd424af 100644 --- a/Projects/UOContent/Spells/Initializer.cs +++ b/Projects/UOContent/Spells/Initializer.cs @@ -190,7 +190,7 @@ namespace Server.Spells Register(683, typeof(AnimatedWeaponSpell)); Register(684, typeof(StoneFormSpell)); Register(685, typeof(SpellTriggerSpell)); - // Register(686, typeof(MassSleepSpell)); + Register(686, typeof(MassSleepSpell)); Register(687, typeof(CleansingWindsSpell)); Register(688, typeof(BombardSpell)); Register(689, typeof(SpellPlagueSpell)); diff --git a/Projects/UOContent/Spells/Mysticism/MassSleepSpell.cs b/Projects/UOContent/Spells/Mysticism/MassSleepSpell.cs new file mode 100644 index 000000000..31cd5e6e5 --- /dev/null +++ b/Projects/UOContent/Spells/Mysticism/MassSleepSpell.cs @@ -0,0 +1,79 @@ +using System; +using Server.Collections; +using Server.Targeting; + +namespace Server.Spells.Mysticism; + +public class MassSleepSpell : MysticSpell, ITargetingSpell +{ + private const int Radius = 3; + + private static readonly SpellInfo _info = new( + "Mass Sleep", + "Vas Zu", + 230, + 9022, + Reagent.Ginseng, + Reagent.Nightshade, + Reagent.SpidersSilk + ); + + public MassSleepSpell(Mobile caster, Item scroll = null) : base(caster, scroll, _info) + { + } + + public override SpellCircle Circle => SpellCircle.Fifth; + + public void Target(IPoint3D point) + { + var location = (point as Item)?.GetWorldLocation() ?? new Point3D(point); + + if (!SpellHelper.CheckTown(location, Caster) || !CheckSequence()) + { + return; + } + + SpellHelper.Turn(Caster, point); + + var map = Caster.Map; + + if (map == null) + { + return; + } + + using var targets = PooledRefQueue.Create(); + + foreach (var target in map.GetMobilesInRange(location, Radius)) + { + // Sleep is explicitly allowed to affect hidden players. The location target still + // passes the normal target cursor, map, range, town, and LOS validation first. + if (target == Caster || !SpellHelper.ValidIndirectTarget(Caster, target) || !Caster.CanBeHarmful(target, false) || + !Caster.InLOS(target)) + { + continue; + } + + targets.Enqueue(target); + } + + while (targets.Count > 0) + { + var target = targets.Dequeue(); + var duration = SleepSpell.GetDuration(Caster, target); + + if (duration <= TimeSpan.Zero) + { + continue; + } + + Caster.DoHarmful(target); + SleepSpell.Apply(Caster, target, duration); + } + } + + public override void OnCast() + { + Caster.Target = new SpellTarget(this, allowGround: true); + } +} diff --git a/Projects/UOContent/Spells/Mysticism/SleepSpell.cs b/Projects/UOContent/Spells/Mysticism/SleepSpell.cs new file mode 100644 index 000000000..b78b1df93 --- /dev/null +++ b/Projects/UOContent/Spells/Mysticism/SleepSpell.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using ModernUO.CodeGeneratedEvents; +using Server.Collections; +using Server.Engines.BuffIcons; +using Server.Mobiles; +using Server.Network; + +namespace Server.Spells.Mysticism; + +public static class SleepSpell +{ + internal const int CastSpeedMalus = 4; + internal const int CastRecoveryMalus = 6; + internal const int SwingSpeedMalus = 60; + + private static readonly Dictionary _effects = []; + private static readonly Dictionary _immunities = []; + + public static void Configure() + { + EventSink.Logout += Clear; + EventSink.MapChanged += OnMapChanged; + } + + public static bool Apply(Mobile caster, Mobile target, TimeSpan duration) + { + if (caster == null || target is { Deleted: true } || !target.Alive || duration <= TimeSpan.Zero || + _effects.ContainsKey(target) || IsImmune(target)) + { + return false; + } + + var context = new SleepContext(); + _effects[target] = context; + Timer.StartTimer(duration, () => EndEffect(target, false), out context.TimerToken); + + target.FixedParticles(0x373A, 1, 15, 0x48F, 7, 0, EffectLayer.Head, 0); + target.PlaySound(0x1FB); + + if (target is PlayerMobile player) + { + player.NetState?.SendSpeedControl(SpeedControlSetting.Walk); + player.AddBuff(new BuffInfo(BuffIcon.MassSleep, 1075824, duration)); + } + + return true; + } + + public static bool IsUnderEffect(Mobile target) => target != null && _effects.ContainsKey(target); + + public static bool IsImmune(Mobile target) => target != null && _immunities.ContainsKey(target); + + public static void OnMobileDamaged(Mobile target, int damage) + { + if (damage > 0) + { + EndEffect(target, true); + } + } + + internal static int GetCastSpeedMalus(Mobile target) => IsUnderEffect(target) ? CastSpeedMalus : 0; + + internal static int GetCastRecoveryMalus(Mobile target) => IsUnderEffect(target) ? CastRecoveryMalus : 0; + + internal static int GetSwingSpeedMalus(Mobile target) => IsUnderEffect(target) ? SwingSpeedMalus : 0; + + internal static TimeSpan GetDuration(Mobile caster, Mobile target) + { + var supportSkill = Math.Max(caster.Skills.Focus.Value, caster.Skills.Imbuing.Value); + var seconds = (caster.Skills.Mysticism.Value + supportSkill) / 20.0 + 3.0 - target.Skills.MagicResist.Value / 10.0; + return TimeSpan.FromSeconds(Math.Max(seconds, 0.0)); + } + + internal static TimeSpan GetImmunityDuration(Mobile target) => + TimeSpan.FromSeconds(Math.Clamp((int)(target.Skills.MagicResist.Value / 10.0), 3, 12)); + + internal static void EndEffectForTests(Mobile target, bool grantImmunity) => EndEffect(target, grantImmunity); + + internal static void ClearAllForTests() + { + using var targets = PooledRefList.Create(); + + foreach (var target in _effects.Keys) + { + targets.Add(target); + } + + for (var i = 0; i < targets.Count; i++) + { + EndEffect(targets[i], false); + } + + foreach (var token in _immunities.Values) + { + token.Cancel(); + } + + _immunities.Clear(); + } + + [OnEvent(nameof(PlayerMobile.PlayerDeathEvent))] + [OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))] + [OnEvent(nameof(BaseCreature.CreatureDeathEvent))] + [OnEvent(nameof(BaseCreature.CreatureDeletedEvent))] + public static void Clear(Mobile target) + { + EndEffect(target, false); + + if (_immunities.Remove(target, out var token)) + { + token.Cancel(); + } + } + + private static void OnMapChanged(Mobile target, Map oldMap) => Clear(target); + + private static void EndEffect(Mobile target, bool grantImmunity) + { + if (target == null || !_effects.Remove(target, out var context)) + { + return; + } + + context.TimerToken.Cancel(); + + if (target is PlayerMobile player) + { + player.NetState?.SendSpeedControl(SpeedControlSetting.Disable); + player.RemoveBuff(BuffIcon.MassSleep); + } + + if (grantImmunity && target.Player && !target.Deleted && target.Alive) + { + StartImmunity(target); + } + } + + private static void StartImmunity(Mobile target) + { + if (_immunities.ContainsKey(target)) + { + return; + } + + Timer.StartTimer(GetImmunityDuration(target), () => RemoveImmunity(target), out var token); + _immunities[target] = token; + } + + private static void RemoveImmunity(Mobile target) + { + if (_immunities.Remove(target, out var token)) + { + token.Cancel(); + } + } + + private sealed class SleepContext + { + internal TimerExecutionToken TimerToken; + } +}