diff --git a/Projects/Server/Events/EventSink.cs b/Projects/Server/Events/EventSink.cs index 02cb0c2f0..70bb743fb 100644 --- a/Projects/Server/Events/EventSink.cs +++ b/Projects/Server/Events/EventSink.cs @@ -25,6 +25,9 @@ public static partial class EventSink public static event Action Logout; public static void InvokeLogout(Mobile m) => Logout?.Invoke(m); + public static event Action MapChanged; + public static void InvokeMapChanged(Mobile mobile, Map oldMap) => MapChanged?.Invoke(mobile, oldMap); + public static event Action Connected; public static void InvokeConnected(Mobile m) => Connected?.Invoke(m); diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index a3497f163..55013c5d6 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -2581,6 +2581,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro ns.SendMobileAttributes(this); OnMapChange(oldMap); + EventSink.InvokeMapChanged(this, oldMap); } public virtual void MoveToWorld(Point3D newLocation, Map map) diff --git a/Projects/UOContent.Tests/Tests/Items/Weapons/BoneBreakerPropertyTests.cs b/Projects/UOContent.Tests/Tests/Items/Weapons/BoneBreakerPropertyTests.cs new file mode 100644 index 000000000..68f88da92 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/Weapons/BoneBreakerPropertyTests.cs @@ -0,0 +1,490 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Server; +using Server.Items; +using Server.Mobiles; +using Server.Spells; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class BoneBreakerPropertyTests +{ + private const int BoneBreakerCliloc = 1157318; + private static readonly Point3D TestLocation = new(6200, 520, 0); + + [Fact] + public void ExtendedWeaponAttributes_StoresAndDupesBoneBreaker() + { + var weapon = new TestKatana(); + var dupe = new TestKatana(); + + try + { + weapon.ExtendedWeaponAttributes.BoneBreaker = 1; + + weapon.Dupe(dupe); + + Assert.Equal(1, weapon.ExtendedWeaponAttributes.BoneBreaker); + Assert.Equal(1, dupe.ExtendedWeaponAttributes.BoneBreaker); + } + finally + { + weapon.Delete(); + dupe.Delete(); + } + } + + [Fact] + public void ExtendedWeaponAttributes_GetProperties_GatesBoneBreakerTooltipToTimeOfLegends() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + + try + { + weapon.ExtendedWeaponAttributes.BoneBreaker = 1; + + Core.Expansion = Expansion.HS; + var highSeas = new RecordingPropertyList(); + weapon.ExtendedWeaponAttributes.GetProperties(highSeas); + Assert.DoesNotContain(highSeas.Entries, entry => entry.Number == BoneBreakerCliloc); + + Core.Expansion = Expansion.TOL; + var timeOfLegends = new RecordingPropertyList(); + weapon.ExtendedWeaponAttributes.GetProperties(timeOfLegends); + Assert.Contains(timeOfLegends.Entries, entry => entry.Number == BoneBreakerCliloc); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + } + } + + [Fact] + public void OnHit_NormalHitAppliesIndependentManaBonus() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + var attacker = CreateMobile(mana: BoneBreaker.ManaThreshold); + var defender = CreateMobile(location: new Point3D(6201, 520, 0)); + + try + { + Core.Expansion = Expansion.TOL; + weapon.ExtendedWeaponAttributes.BoneBreaker = 1; + + weapon.OnHit(attacker, defender); + + Assert.Equal(0, attacker.Mana); + Assert.Equal(149, defender.Hits); + } + finally + { + Core.Expansion = previousExpansion; + BoneBreaker.ClearAll(); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Theory] + [InlineData(0, 30)] + [InlineData(20, 24)] + [InlineData(40, 18)] + [InlineData(75, 18)] + public void TryProcOnNormalHit_ScalesManaCostWithLowerManaCost(int lowerManaCost, int expectedCost) + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + var attacker = CreateMobile(mana: BoneBreaker.ManaThreshold); + var defender = CreateMobile(location: new Point3D(6201, 520, 0)); + + try + { + Core.Expansion = Expansion.TOL; + weapon.Attributes.LowerManaCost = lowerManaCost; + Assert.True(attacker.EquipItem(weapon)); + + BoneBreaker.TryProcOnNormalHit(attacker, defender, 1, 0); + + Assert.Equal(BoneBreaker.ManaThreshold - expectedCost, attacker.Mana); + Assert.Equal(150, defender.Hits); + } + finally + { + Core.Expansion = previousExpansion; + BoneBreaker.ClearAll(); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void TryProcOnNormalHit_BelowManaThresholdCanDrainButDoesNotDamageOrSpendMana() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(mana: BoneBreaker.ManaThreshold - 1); + var defender = CreateMobile(location: new Point3D(6201, 520, 0)); + + try + { + Core.Expansion = Expansion.TOL; + + BoneBreaker.TryProcOnNormalHit(attacker, defender, 1, 100); + + Assert.Equal(BoneBreaker.ManaThreshold - 1, attacker.Mana); + Assert.Equal(200, defender.Hits); + Assert.True(BoneBreaker.HasActiveDrain(defender)); + } + finally + { + Core.Expansion = previousExpansion; + BoneBreaker.ClearAll(); + attacker.Delete(); + defender.Delete(); + } + } + + [Theory] + [InlineData(typeof(TestWeaponAbility))] + [InlineData(typeof(TestSpecialMove))] + public void OnHit_WeaponAbilitiesAndSpecialMovesDoNotTriggerBoneBreaker(Type actionType) + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + var attacker = CreateMobile(mana: BoneBreaker.ManaThreshold); + var defender = CreateMobile(location: new Point3D(6201, 520, 0)); + + try + { + Core.Expansion = Expansion.TOL; + weapon.ExtendedWeaponAttributes.BoneBreaker = 1; + + if (typeof(WeaponAbility).IsAssignableFrom(actionType)) + { + WeaponAbility.Table[attacker] = (WeaponAbility)Activator.CreateInstance(actionType); + } + else + { + SpecialMove.Table[attacker] = (SpecialMove)Activator.CreateInstance(actionType); + } + + weapon.OnHit(attacker, defender); + + Assert.Equal(BoneBreaker.ManaThreshold, attacker.Mana); + Assert.False(BoneBreaker.HasActiveDrain(defender)); + } + finally + { + Core.Expansion = previousExpansion; + WeaponAbility.Table.Remove(attacker); + SpecialMove.Table.Remove(attacker); + BoneBreaker.ClearAll(); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void Drain_BlocksOnlyRefreshPotionsUntilTheFourTicksComplete() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 520, 0)); + var refresh = new RefreshPotion(); + var totalRefresh = new TotalRefreshPotion(); + + try + { + Core.Expansion = Expansion.TOL; + defender.Stam = defender.StamMax - 1; + refresh.MoveToWorld(defender.Location, defender.Map); + totalRefresh.MoveToWorld(defender.Location, defender.Map); + BoneBreaker.TryProcOnNormalHit(attacker, defender, 1, 100); + + Assert.True(BoneBreaker.HasActiveDrain(defender)); + Assert.False(refresh.CanDrink(defender)); + Assert.False(totalRefresh.CanDrink(defender)); + + for (var i = 0; i < BoneBreaker.DrainTickCount; i++) + { + Assert.True(BoneBreaker.TickForTests(defender)); + } + + Assert.False(BoneBreaker.HasActiveDrain(defender)); + Assert.True(BoneBreaker.IsImmune(defender)); + Assert.True(refresh.CanDrink(defender)); + Assert.True(totalRefresh.CanDrink(defender)); + } + finally + { + Core.Expansion = previousExpansion; + BoneBreaker.ClearAll(); + refresh.Delete(); + totalRefresh.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void Drain_UsesFourOneSecondTicksAndPreservesOneStamina() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 520, 0)); + + try + { + Core.Expansion = Expansion.TOL; + defender.Stam = 2; + BoneBreaker.TryProcOnNormalHit(attacker, defender, 1, 100); + + Assert.Equal(TimeSpan.FromSeconds(1.0), BoneBreaker.DrainTickInterval); + + for (var i = 0; i < BoneBreaker.DrainTickCount; i++) + { + Assert.True(BoneBreaker.TickForTests(defender)); + Assert.Equal(1, defender.Stam); + } + + Assert.False(BoneBreaker.HasActiveDrain(defender)); + } + finally + { + Core.Expansion = previousExpansion; + BoneBreaker.ClearAll(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void ImmunityAfterDrainExpires_SuppressesBothBranchesWithoutManaCost() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(mana: 100); + var defender = CreateMobile(location: new Point3D(6201, 520, 0)); + + try + { + Core.Expansion = Expansion.TOL; + BoneBreaker.TryProcOnNormalHit(attacker, defender, 1, 100); + + for (var i = 0; i < BoneBreaker.DrainTickCount; i++) + { + BoneBreaker.TickForTests(defender); + } + + var hitsAfterFirstProc = defender.Hits; + attacker.Mana = 100; + + BoneBreaker.TryProcOnNormalHit(attacker, defender, 1, 100); + + Assert.True(BoneBreaker.IsImmune(defender)); + Assert.False(BoneBreaker.HasActiveDrain(defender)); + Assert.Equal(100, attacker.Mana); + Assert.Equal(hitsAfterFirstProc, defender.Hits); + } + finally + { + Core.Expansion = previousExpansion; + BoneBreaker.ClearAll(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void ExpiredImmunity_AllowsBoneBreakerToProcAgain() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(mana: 100); + var defender = CreateMobile(location: new Point3D(6201, 520, 0)); + + try + { + Core.Expansion = Expansion.TOL; + Assert.Equal(TimeSpan.FromSeconds(60), BoneBreaker.ImmunityDuration); + BoneBreaker.TryProcOnNormalHit(attacker, defender, 1, 100); + + for (var i = 0; i < BoneBreaker.DrainTickCount; i++) + { + BoneBreaker.TickForTests(defender); + } + + BoneBreaker.ExpireImmunityForTests(defender); + attacker.Mana = 100; + var hitsBeforeSecondProc = defender.Hits; + + BoneBreaker.TryProcOnNormalHit(attacker, defender, 1, 100); + + Assert.False(BoneBreaker.IsImmune(defender)); + Assert.True(BoneBreaker.HasActiveDrain(defender)); + Assert.Equal(70, attacker.Mana); + Assert.Equal(hitsBeforeSecondProc - BoneBreaker.Damage, defender.Hits); + } + finally + { + Core.Expansion = previousExpansion; + BoneBreaker.ClearAll(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void DrainCleanup_ReleasesStateWhenTargetLeavesTheWorld() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 520, 0)); + + try + { + BoneBreaker.Configure(); + Core.Expansion = Expansion.TOL; + var map = defender.Map; + BoneBreaker.TryProcOnNormalHit(attacker, defender, 1, 100); + defender.Internalize(); + defender.MoveToWorld(new Point3D(6201, 520, 0), map); + + Assert.False(BoneBreaker.HasActiveDrain(defender)); + Assert.Equal(0, BoneBreaker.ActiveDrainCount); + } + finally + { + Core.Expansion = previousExpansion; + BoneBreaker.ClearAll(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void DrainCleanup_ReleasesStateWhenAttackerDies() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 520, 0)); + + try + { + Core.Expansion = Expansion.TOL; + BoneBreaker.TryProcOnNormalHit(attacker, defender, 1, 100); + + BoneBreaker.Clear(attacker); + + Assert.False(BoneBreaker.HasActiveDrain(defender)); + Assert.Equal(0, BoneBreaker.ActiveDrainCount); + } + finally + { + Core.Expansion = previousExpansion; + BoneBreaker.ClearAll(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void RunicAttributeGeneration_DoesNotRollBoneBreaker() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + + try + { + Core.Expansion = Expansion.TOL; + + BaseRunicTool.ApplyAttributesTo(weapon, false, 0, 25, 100, 100); + + Assert.Equal(0, weapon.ExtendedWeaponAttributes.BoneBreaker); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + } + } + + private static Mobile CreateMobile(int mana = 0, Point3D location = default) + { + var mobile = new Mobile(World.NewMobile); + mobile.DefaultMobileInit(); + mobile.RawStr = 300; + mobile.RawDex = 100; + mobile.RawInt = 100; + mobile.Hits = mobile.HitsMax; + mobile.Stam = mobile.StamMax; + mobile.Mana = mana; + mobile.MoveToWorld(location == default ? TestLocation : location, Map.Felucca); + return mobile; + } + + private class TestKatana : Katana + { + public override bool CheckHit(Mobile attacker, Mobile defender) => true; + public override int ComputeDamage(Mobile attacker, Mobile defender) => 1; + public override int AbsorbDamage(Mobile attacker, Mobile defender, int damage) => damage; + public override void AddBlood(Mobile attacker, Mobile defender, int damage) + { + } + } + + private class TestWeaponAbility : WeaponAbility + { + } + + private class TestSpecialMove : SpecialMove + { + } + + private sealed class RecordingPropertyList : IPropertyList + { + private string _interpolated = string.Empty; + + public List<(int Number, string Argument)> Entries { get; } = []; + + public void Reset() + { + } + + public void Terminate() + { + } + + public void Add(int number) => Entries.Add((number, null)); + public void Add(int number, string argument) => Entries.Add((number, argument)); + public void Add(ReadOnlySpan argument) => Entries.Add((0, argument.ToString())); + public void Add(int number, ReadOnlySpan argument) => Entries.Add((number, argument.ToString())); + public void AddChunked(ReadOnlySpan text) => Entries.Add((0, text.ToString())); + public OplTextBlock TextBlock() => new(this); + public void Add(int number, int value) => Entries.Add((number, value.ToString(CultureInfo.InvariantCulture))); + public void AddLocalized(int value) => Entries.Add((0, value.ToString(CultureInfo.InvariantCulture))); + public void AddLocalized(int number, int value) => Entries.Add((number, value.ToString(CultureInfo.InvariantCulture))); + public void Add(ref IPropertyList.InterpolatedStringHandler handler) => Entries.Add((0, _interpolated)); + public void Add(int number, ref IPropertyList.InterpolatedStringHandler handler) => Entries.Add((number, _interpolated)); + public void InitializeInterpolation(int literalLength, int formattedCount) => _interpolated = string.Empty; + public void AppendLiteral(string value) => _interpolated += value; + public void AppendFormatted(T value) => _interpolated += value; + public void AppendFormatted(T value, string format) => + _interpolated += value is IFormattable formattable ? formattable.ToString(format, null) : value; + public void AppendFormatted(T value, int alignment) => _interpolated += value; + public void AppendFormatted(T value, int alignment, string format) => + _interpolated += value is IFormattable formattable ? formattable.ToString(format, null) : value; + public void AppendFormatted(ReadOnlySpan value) => _interpolated += value.ToString(); + public void AppendFormatted(ReadOnlySpan value, int alignment, string format = null) => + _interpolated += value.ToString(); + public void AppendFormatted(object value, int alignment = 0, string format = null) => _interpolated += value; + public void AppendFormatted(string value) => _interpolated += value; + public void AppendFormatted(string value, int alignment, string format = null) => _interpolated += value; + } +} diff --git a/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs b/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs index 9df8be24f..b98021b40 100644 --- a/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs @@ -29,6 +29,7 @@ public class ExtendedWeaponAttributesTests Assert.Equal(0x00000010, (int)ExtendedWeaponAttribute.HitSwarm); Assert.Equal(0x00000020, (int)ExtendedWeaponAttribute.SplinteringWeapon); Assert.Equal(0x00000040, (int)ExtendedWeaponAttribute.Focus); + Assert.Equal(0x00000080, (int)ExtendedWeaponAttribute.BoneBreaker); } [Fact] @@ -47,6 +48,7 @@ public class ExtendedWeaponAttributesTests Assert.Equal(0, weapon.ExtendedWeaponAttributes.HitSwarm); Assert.Equal(0, weapon.ExtendedWeaponAttributes.SplinteringWeapon); Assert.Equal(0, weapon.ExtendedWeaponAttributes.Focus); + Assert.Equal(0, weapon.ExtendedWeaponAttributes.BoneBreaker); } finally { @@ -85,6 +87,7 @@ public class ExtendedWeaponAttributesTests AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.HitSwarm)); AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.SplinteringWeapon)); AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.Focus)); + AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.BoneBreaker)); } [Fact] @@ -104,6 +107,7 @@ public class ExtendedWeaponAttributesTests weapon.ExtendedWeaponAttributes.HitSwarm = 30; weapon.ExtendedWeaponAttributes.SplinteringWeapon = 25; weapon.ExtendedWeaponAttributes.Focus = 1; + weapon.ExtendedWeaponAttributes.BoneBreaker = 1; var writer = new BufferWriter(true); weapon.Serialize(writer); @@ -121,6 +125,7 @@ public class ExtendedWeaponAttributesTests Assert.Equal(30, deserialized.ExtendedWeaponAttributes.HitSwarm); Assert.Equal(25, deserialized.ExtendedWeaponAttributes.SplinteringWeapon); Assert.Equal(1, deserialized.ExtendedWeaponAttributes.Focus); + Assert.Equal(1, deserialized.ExtendedWeaponAttributes.BoneBreaker); } finally { diff --git a/Projects/UOContent/Items/Skill Items/Magical/Potions/Refresh Potions/BaseRefreshPotion.cs b/Projects/UOContent/Items/Skill Items/Magical/Potions/Refresh Potions/BaseRefreshPotion.cs index ff24c4e4b..04c2537e6 100644 --- a/Projects/UOContent/Items/Skill Items/Magical/Potions/Refresh Potions/BaseRefreshPotion.cs +++ b/Projects/UOContent/Items/Skill Items/Magical/Potions/Refresh Potions/BaseRefreshPotion.cs @@ -18,6 +18,11 @@ public abstract partial class BaseRefreshPotion : BasePotion return false; } + if (BoneBreaker.HasActiveDrain(from)) + { + return false; + } + if (from.Stam >= from.StamMax) { from.SendMessage("You decide against drinking this potion, as you are already at full stamina."); diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.cs index 7867dce3e..bf499efbd 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.cs @@ -2390,6 +2390,7 @@ public abstract partial class BaseWeapon { Sparks.TryProcOnNormalHit(attacker, defender, ExtendedWeaponAttributes.HitSparks); Swarm.TryProcOnNormalHit(attacker, defender, ExtendedWeaponAttributes.HitSwarm); + BoneBreaker.TryProcOnNormalHit(attacker, defender, ExtendedWeaponAttributes.BoneBreaker); } } diff --git a/Projects/UOContent/Items/Weapons/BoneBreaker.cs b/Projects/UOContent/Items/Weapons/BoneBreaker.cs new file mode 100644 index 000000000..82e27730e --- /dev/null +++ b/Projects/UOContent/Items/Weapons/BoneBreaker.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using ModernUO.CodeGeneratedEvents; +using Server.Collections; +using Server.Mobiles; + +namespace Server.Items; + +public static class BoneBreaker +{ + internal const int Damage = 50; + internal const int DrainChance = 20; + internal const int ManaThreshold = 30; + internal const int DrainTickCount = 4; + + internal static readonly TimeSpan DrainTickInterval = TimeSpan.FromSeconds(1.0); + internal static readonly TimeSpan ImmunityDuration = TimeSpan.FromSeconds(60.0); + + private static readonly Dictionary _drains = []; + private static readonly Dictionary _immunities = []; + + public static void Configure() + { + EventSink.Logout += Clear; + EventSink.MapChanged += OnMapChanged; + } + + private static void OnMapChanged(Mobile mobile, Map oldMap) => Clear(mobile); + + public static void TryProcOnNormalHit(Mobile attacker, Mobile defender, int boneBreaker) + { + TryProcOnNormalHit(attacker, defender, boneBreaker, DrainChance); + } + + internal static void TryProcOnNormalHit(Mobile attacker, Mobile defender, int boneBreaker, int drainChance) + { + if (!Core.TOL || boneBreaker <= 0 || !IsValidCombatant(attacker) || !IsValidCombatant(defender) || + IsImmune(defender)) + { + return; + } + + if (attacker.Mana >= ManaThreshold) + { + var manaCost = GetManaCost(attacker); + attacker.Mana -= manaCost; + AOS.Damage(defender, attacker, Damage, false, 100, 0, 0, 0, 0); + } + + if (drainChance > Utility.Random(100) && IsValidCombatant(defender) && !_drains.ContainsKey(defender)) + { + _drains[defender] = new DrainContext(attacker, defender); + } + } + + public static bool HasActiveDrain(Mobile mobile) => mobile != null && _drains.ContainsKey(mobile); + + internal static bool IsImmune(Mobile mobile) => mobile != null && _immunities.ContainsKey(mobile); + + internal static int ActiveDrainCount => _drains.Count; + + internal static int GetManaCost(Mobile attacker) + { + var lowerManaCost = Math.Min(AosAttributes.GetValue(attacker, AosAttribute.LowerManaCost), 40); + return (int)(ManaThreshold * (1.0 - lowerManaCost / 100.0)); + } + + internal static bool TickForTests(Mobile defender) + { + if (!_drains.TryGetValue(defender, out var context)) + { + return false; + } + + context.Tick(); + return true; + } + + internal static void ExpireImmunityForTests(Mobile mobile) => RemoveImmunity(mobile); + + internal static void ClearAll() + { + foreach (var context in _drains.Values) + { + context.Stop(); + } + + _drains.Clear(); + + 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 mobile) + { + if (mobile == null) + { + return; + } + + using var defenders = PooledRefList.Create(); + + foreach (var (defender, context) in _drains) + { + if (defender == mobile || context.Attacker == mobile) + { + defenders.Add(defender); + } + } + + for (var i = 0; i < defenders.Count; i++) + { + StopDrain(defenders[i], false); + } + + RemoveImmunity(mobile); + } + + private static bool IsValidCombatant(Mobile mobile) => + mobile is { Deleted: false, Alive: true } && mobile.Map != null && mobile.Map != Map.Internal; + + private static void StopDrain(Mobile defender, bool grantImmunity) + { + if (!_drains.Remove(defender, out var context)) + { + return; + } + + context.Stop(); + + if (grantImmunity) + { + AddImmunity(defender); + } + } + + private static void AddImmunity(Mobile defender) + { + if (_immunities.ContainsKey(defender)) + { + return; + } + + Timer.StartTimer(ImmunityDuration, () => RemoveImmunity(defender), out var token); + _immunities[defender] = token; + } + + private static void RemoveImmunity(Mobile mobile) + { + if (mobile != null && _immunities.Remove(mobile, out var token)) + { + token.Cancel(); + } + } + + private sealed class DrainContext + { + private readonly Mobile _defender; + private TimerExecutionToken _tickTimerToken; + + public DrainContext(Mobile attacker, Mobile defender) + { + Attacker = attacker; + _defender = defender; + TicksRemaining = DrainTickCount; + Timer.StartTimer(DrainTickInterval, DrainTickInterval, Tick, out _tickTimerToken); + } + + public Mobile Attacker { get; } + + public int TicksRemaining { get; private set; } + + public void Stop() => _tickTimerToken.Cancel(); + + public void Tick() + { + if (!IsValidCombatant(Attacker) || !IsValidCombatant(_defender)) + { + StopDrain(_defender, false); + return; + } + + var staminaDrain = Math.Max(1, _defender.StamMax / 10); + _defender.Stam = Math.Max(1, _defender.Stam - staminaDrain); + TicksRemaining--; + + if (TicksRemaining <= 0) + { + StopDrain(_defender, true); + } + } + } +} diff --git a/Projects/UOContent/Misc/AOS.cs b/Projects/UOContent/Misc/AOS.cs index add34e7d6..7851299c6 100644 --- a/Projects/UOContent/Misc/AOS.cs +++ b/Projects/UOContent/Misc/AOS.cs @@ -1257,7 +1257,8 @@ namespace Server BloodDrinker = 0x00000008, HitSwarm = 0x00000010, SplinteringWeapon = 0x00000020, - Focus = 0x00000040 + Focus = 0x00000040, + BoneBreaker = 0x00000080 } public sealed class ExtendedWeaponAttributes : BaseAttributes @@ -1345,6 +1346,13 @@ namespace Server } } + [CommandProperty(AccessLevel.GameMaster)] + public int BoneBreaker + { + get => this[ExtendedWeaponAttribute.BoneBreaker]; + set => this[ExtendedWeaponAttribute.BoneBreaker] = value; + } + public void GetProperties(IPropertyList list) { if (Core.HS && Bane != 0) @@ -1381,6 +1389,11 @@ namespace Server { list.Add(1157325, HitSwarm); // Swarm ~1_val~% } + + if (Core.TOL && BoneBreaker != 0) + { + list.Add(1157318); // Bone Breaker + } } public override string ToString() => "...";