feat: implement Mass Sleep stupor

This commit is contained in:
Crome696 2026-07-11 23:49:40 +02:00
parent b5b1b566cd
commit ed19b8f4fd
8 changed files with 415 additions and 2 deletions

View file

@ -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<MassSleepSpell>(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<Type, int>)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();
}
}

View file

@ -1533,6 +1533,8 @@ public abstract partial class BaseWeapon
bonus -= EssenceOfWindSpell.GetSSIMalus(m);
}
bonus -= SleepSpell.GetSwingSpeedMalus(m);
if (bonus > 60)
{
bonus = 60;

View file

@ -1641,6 +1641,7 @@ namespace Server.Mobiles
Confidence.StopRegenerating(this);
StaminaSystem.FatigueOnDamage(this, amount);
Server.Spells.Mysticism.SleepSpell.OnMobileDamaged(this, amount);
var speechType = SpeechType;

View file

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

View file

@ -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)

View file

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

View file

@ -0,0 +1,79 @@
using System;
using Server.Collections;
using Server.Targeting;
namespace Server.Spells.Mysticism;
public class MassSleepSpell : MysticSpell, ITargetingSpell<IPoint3D>
{
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<Mobile>.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<IPoint3D>(this, allowGround: true);
}
}

View file

@ -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<Mobile, SleepContext> _effects = [];
private static readonly Dictionary<Mobile, TimerExecutionToken> _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<Mobile>.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;
}
}