diff --git a/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs b/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs index 7427cf3a3..aba4db807 100644 --- a/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs @@ -27,6 +27,7 @@ public class ExtendedWeaponAttributesTests Assert.Equal(0x00000004, (int)ExtendedWeaponAttribute.HitSparks); Assert.Equal(0x00000008, (int)ExtendedWeaponAttribute.BloodDrinker); Assert.Equal(0x00000010, (int)ExtendedWeaponAttribute.HitSwarm); + Assert.Equal(0x00000020, (int)ExtendedWeaponAttribute.SplinteringWeapon); } [Fact] @@ -43,6 +44,7 @@ public class ExtendedWeaponAttributesTests Assert.Equal(0, weapon.ExtendedWeaponAttributes.HitSparks); Assert.Equal(0, weapon.ExtendedWeaponAttributes.BloodDrinker); Assert.Equal(0, weapon.ExtendedWeaponAttributes.HitSwarm); + Assert.Equal(0, weapon.ExtendedWeaponAttributes.SplinteringWeapon); } finally { @@ -79,6 +81,7 @@ public class ExtendedWeaponAttributesTests AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.HitSparks)); AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.BloodDrinker)); AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.HitSwarm)); + AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.SplinteringWeapon)); } [Fact] @@ -96,6 +99,7 @@ public class ExtendedWeaponAttributesTests weapon.ExtendedWeaponAttributes.HitSparks = 20; weapon.ExtendedWeaponAttributes.BloodDrinker = 1; weapon.ExtendedWeaponAttributes.HitSwarm = 30; + weapon.ExtendedWeaponAttributes.SplinteringWeapon = 25; var writer = new BufferWriter(true); weapon.Serialize(writer); @@ -111,6 +115,7 @@ public class ExtendedWeaponAttributesTests Assert.Equal(20, deserialized.ExtendedWeaponAttributes.HitSparks); Assert.Equal(1, deserialized.ExtendedWeaponAttributes.BloodDrinker); Assert.Equal(30, deserialized.ExtendedWeaponAttributes.HitSwarm); + Assert.Equal(25, deserialized.ExtendedWeaponAttributes.SplinteringWeapon); } finally { diff --git a/Projects/UOContent.Tests/Tests/Items/Weapons/SplinteringWeaponPropertyTests.cs b/Projects/UOContent.Tests/Tests/Items/Weapons/SplinteringWeaponPropertyTests.cs new file mode 100644 index 000000000..3516ddccc --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/Weapons/SplinteringWeaponPropertyTests.cs @@ -0,0 +1,474 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Items; +using Server.Mobiles; +using Server.Text; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class SplinteringWeaponPropertyTests +{ + private const int SplinteringWeaponCliloc = 1112857; + private static readonly Point3D TestLocation = new(6200, 540, 0); + + [Fact] + public void ExtendedWeaponAttributes_StoresAndDupesSplinteringWeapon() + { + var weapon = new TestKatana(); + var dupe = new TestKatana(); + + try + { + weapon.ExtendedWeaponAttributes.SplinteringWeapon = 30; + + weapon.Dupe(dupe); + + Assert.Equal(30, weapon.ExtendedWeaponAttributes.SplinteringWeapon); + Assert.Equal(30, dupe.ExtendedWeaponAttributes.SplinteringWeapon); + } + finally + { + weapon.Delete(); + dupe.Delete(); + } + } + + [Fact] + public void ExtendedWeaponAttributes_GetProperties_GatesSplinteringWeaponTooltipToStygianAbyss() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + + try + { + weapon.ExtendedWeaponAttributes.SplinteringWeapon = 30; + + Core.Expansion = Expansion.ML; + var preStygianAbyss = new RecordingPropertyList(); + weapon.ExtendedWeaponAttributes.GetProperties(preStygianAbyss); + Assert.DoesNotContain(preStygianAbyss.Entries, entry => entry.Number == SplinteringWeaponCliloc); + + Core.Expansion = Expansion.SA; + var stygianAbyss = new RecordingPropertyList(); + weapon.ExtendedWeaponAttributes.GetProperties(stygianAbyss); + Assert.Contains( + stygianAbyss.Entries, + entry => entry.Number == SplinteringWeaponCliloc && entry.Argument == "30" + ); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + } + } + + [Fact] + public void OnHit_PreStygianAbyss_DoesNotStartSplinteringContext() + { + var previousExpansion = Core.Expansion; + var weapon = EquipSplinteringWeapon(CreateMobile()); + var attacker = (Mobile)weapon.Parent; + var defender = CreateMobile(location: new Point3D(6201, 540, 0)); + + try + { + Core.Expansion = Expansion.ML; + + weapon.OnHit(attacker, defender); + + Assert.Equal(0, SplinteringWeapon.ActiveContextCount); + Assert.False(SplinteringWeapon.IsForceWalking(defender)); + } + finally + { + Core.Expansion = previousExpansion; + SplinteringWeapon.ClearAll(); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void OnHit_EligibleSuccessfulHit_StartsBleedForceWalkAndDamagesDurability() + { + var previousExpansion = Core.Expansion; + var weapon = EquipSplinteringWeapon(CreateMobile()); + var attacker = (Mobile)weapon.Parent; + var defender = CreateMobile(location: new Point3D(6201, 540, 0)); + + try + { + Core.Expansion = Expansion.SA; + weapon.HitPoints = 50; + weapon.MaxHitPoints = 50; + var defenderHits = defender.Hits; + + weapon.OnHit(attacker, defender); + + Assert.True(weapon.HitPoints <= 40); + Assert.True(SplinteringWeapon.IsForceWalking(defender)); + Assert.Equal(1, SplinteringWeapon.ActiveContextCount); + + Assert.True(SplinteringWeapon.TickForTests(attacker, defender)); + + Assert.True(defender.Hits < defenderHits); + } + finally + { + Core.Expansion = previousExpansion; + SplinteringWeapon.ClearAll(); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void ForceWalk_EndsAfterFourSecondsWhileSplinteringBleedCanContinue() + { + var previousExpansion = Core.Expansion; + var weapon = EquipSplinteringWeapon(CreateMobile()); + var attacker = (Mobile)weapon.Parent; + var defender = CreateMobile(location: new Point3D(6201, 540, 0)); + + try + { + Core.Expansion = Expansion.SA; + + weapon.OnHit(attacker, defender); + Assert.True(SplinteringWeapon.IsForceWalking(defender)); + + Assert.True(SplinteringWeapon.EndForceWalkForTests(attacker, defender)); + + Assert.False(SplinteringWeapon.IsForceWalking(defender)); + Assert.Equal(1, SplinteringWeapon.ActiveContextCount); + } + finally + { + Core.Expansion = previousExpansion; + SplinteringWeapon.ClearAll(); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void ForceWalk_BlocksRunningMovementUntilEffectEnds() + { + var previousExpansion = Core.Expansion; + var weapon = EquipSplinteringWeapon(CreateMobile()); + var attacker = (Mobile)weapon.Parent; + var defender = CreateMobile(location: new Point3D(6201, 540, 0)); + + try + { + Core.Expansion = Expansion.SA; + + weapon.OnHit(attacker, defender); + var run = MovementEventArgs.Create(defender, Direction.Running); + SplinteringWeapon.OnMovement(run); + Assert.True(run.Blocked); + run.Free(); + + var walk = MovementEventArgs.Create(defender, Direction.North); + SplinteringWeapon.OnMovement(walk); + Assert.False(walk.Blocked); + walk.Free(); + + Assert.True(SplinteringWeapon.EndForceWalkForTests(attacker, defender)); + var afterEffect = MovementEventArgs.Create(defender, Direction.Running); + SplinteringWeapon.OnMovement(afterEffect); + Assert.False(afterEffect.Blocked); + afterEffect.Free(); + } + finally + { + Core.Expansion = previousExpansion; + SplinteringWeapon.ClearAll(); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void LogoutClearsSplinteringEffectsAndPlayerImmunity() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + var attacker = CreateMobile(); + var defender = CreatePlayerMobile(location: new Point3D(6201, 540, 0)); + + try + { + Core.Expansion = Expansion.SA; + Assert.True(SplinteringWeapon.TryProcOnEligibleHit(attacker, defender, weapon, null, 100)); + Assert.True(SplinteringWeapon.IsForceWalking(defender)); + Assert.True(SplinteringWeapon.IsPlayerImmune(defender)); + + SplinteringWeapon.Clear(defender); + + Assert.False(SplinteringWeapon.IsForceWalking(defender)); + Assert.False(SplinteringWeapon.IsPlayerImmune(defender)); + Assert.Equal(0, SplinteringWeapon.ActiveContextCount); + } + finally + { + Core.Expansion = previousExpansion; + SplinteringWeapon.ClearAll(); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void PlayerVictim_ReceivesFifteenSecondImmunityToRepeatedSplinteringEffects() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + var attacker = CreateMobile(); + var defender = CreatePlayerMobile(location: new Point3D(6201, 540, 0)); + + try + { + Core.Expansion = Expansion.SA; + + Assert.True(SplinteringWeapon.TryProcOnEligibleHit(attacker, defender, weapon, null, 100)); + Assert.True(SplinteringWeapon.IsPlayerImmune(defender)); + + Assert.True(SplinteringWeapon.EndForceWalkForTests(attacker, defender)); + Assert.False(SplinteringWeapon.IsForceWalking(defender)); + + Assert.False(SplinteringWeapon.TryProcOnEligibleHit(attacker, defender, weapon, null, 100)); + + Assert.False(SplinteringWeapon.IsForceWalking(defender)); + + SplinteringWeapon.ExpirePlayerImmunityForTests(defender); + Assert.False(SplinteringWeapon.IsPlayerImmune(defender)); + } + finally + { + Core.Expansion = previousExpansion; + SplinteringWeapon.ClearAll(); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Theory] + [InlineData(5)] + [InlineData(8)] + public void NamedExcludedWeaponAbilities_DoNotTriggerSplinteringWeapon(int abilityIndex) + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 540, 0)); + + try + { + Core.Expansion = Expansion.SA; + var triggered = SplinteringWeapon.TryProcOnEligibleHit( + attacker, + defender, + weapon, + WeaponAbility.Abilities[abilityIndex], + 100 + ); + + Assert.False(triggered); + Assert.False(SplinteringWeapon.IsForceWalking(defender)); + Assert.Equal(0, SplinteringWeapon.ActiveContextCount); + } + finally + { + Core.Expansion = previousExpansion; + SplinteringWeapon.ClearAll(); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void NonExcludedWeaponAbility_CanTriggerSplinteringWeapon() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 540, 0)); + + try + { + Core.Expansion = Expansion.SA; + var triggered = SplinteringWeapon.TryProcOnEligibleHit( + attacker, + defender, + weapon, + WeaponAbility.ArmorIgnore, + 100 + ); + + Assert.True(triggered); + Assert.True(SplinteringWeapon.IsForceWalking(defender)); + } + finally + { + Core.Expansion = previousExpansion; + SplinteringWeapon.ClearAll(); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void SplinteringBleed_DoesNotReplaceRegularBleedAttackContext() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 540, 0)); + + try + { + Core.Expansion = Expansion.SA; + BleedAttack.BeginBleed(defender, attacker); + Assert.True(BleedAttack.IsBleeding(defender)); + + var triggered = SplinteringWeapon.TryProcOnEligibleHit(attacker, defender, weapon, null, 100); + + Assert.True(triggered); + Assert.True(BleedAttack.IsBleeding(defender)); + Assert.True(SplinteringWeapon.IsForceWalking(defender)); + } + finally + { + Core.Expansion = previousExpansion; + BleedAttack.EndBleed(defender, false); + SplinteringWeapon.ClearAll(); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void RunicAttributeGeneration_DoesNotRollSplinteringWeapon() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + + try + { + Core.Expansion = Expansion.SA; + + BaseRunicTool.ApplyAttributesTo(weapon, false, 0, 25, 100, 100); + + Assert.Equal(0, weapon.ExtendedWeaponAttributes.SplinteringWeapon); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + } + } + + private static TestKatana EquipSplinteringWeapon(Mobile wielder) + { + var weapon = new TestKatana(); + weapon.ExtendedWeaponAttributes.SplinteringWeapon = 100; + wielder.AddItem(weapon); + return weapon; + } + + private static Mobile CreateMobile(bool player = false, Point3D location = default) + { + var mobile = new Mobile(World.NewMobile) + { + Player = player + }; + + mobile.DefaultMobileInit(); + InitializeHits(mobile, 500, 500); + mobile.MoveToWorld(location == default ? TestLocation : location, Map.Felucca); + return mobile; + } + + private static PlayerMobile CreatePlayerMobile(Point3D location) + { + var mobile = new PlayerMobile(World.NewMobile); + + mobile.DefaultMobileInit(); + mobile.Player = true; + mobile.Hits = mobile.HitsMax; + mobile.MoveToWorld(location, Map.Felucca); + return mobile; + } + + private static void InitializeHits(Mobile mobile, int hitsMax, int hits) + { + mobile.RawStr = Math.Max(1, (hitsMax - 50) * 2); + Assert.Equal(hitsMax, mobile.HitsMax); + mobile.Hits = hits; + } + + private class TestKatana : Katana + { + public override int ComputeDamage(Mobile attacker, Mobile defender) => 100; + public override int AbsorbDamage(Mobile attacker, Mobile defender, int damage) => damage; + public override void AddBlood(Mobile attacker, Mobile defender, int damage) + { + } + } + + 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, string.Empty)); + 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())); + public void AddLocalized(int value) => Entries.Add((0, value.ToString())); + public void AddLocalized(int number, int value) => Entries.Add((number, value.ToString())); + 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/Items/Weapons/BaseWeapon.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.cs index 5170a7550..a05275e46 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.cs @@ -2087,6 +2087,13 @@ public abstract partial class BaseWeapon if (damageGiven > 0) { var propertyBonus = move?.GetPropertyBonus(attacker) ?? 1.0; + var splintering = SplinteringWeapon.TryProcOnEligibleHit( + attacker, + defender, + this, + a, + ExtendedWeaponAttributes.SplinteringWeapon + ); // Leech abilities if (Core.AOS) @@ -2144,14 +2151,18 @@ public abstract partial class BaseWeapon defender is Slime or AcidElemental; // Stratics says 50% chance, seems more like 4%.. - if (isAcidMonster || Utility.Random(25) == 0) + if (isAcidMonster || splintering || Utility.Random(25) == 0) { if (isAcidMonster) { attacker.LocalOverheadMessage(MessageType.Regular, 0x3B2, 500263); // *Acid blood scars your weapon!* } - if (Core.AOS && WeaponAttributes.SelfRepair > Utility.Random(10)) + if (splintering && _hitPoints > 0) + { + HitPoints = Math.Max(0, _hitPoints - SplinteringWeapon.DurabilityLoss); + } + else if (Core.AOS && WeaponAttributes.SelfRepair > Utility.Random(10)) { HitPoints += 2; } diff --git a/Projects/UOContent/Items/Weapons/SplinteringWeapon.cs b/Projects/UOContent/Items/Weapons/SplinteringWeapon.cs new file mode 100644 index 000000000..dd79c3b90 --- /dev/null +++ b/Projects/UOContent/Items/Weapons/SplinteringWeapon.cs @@ -0,0 +1,297 @@ +using System; +using System.Collections.Generic; +using ModernUO.CodeGeneratedEvents; +using Server.Collections; +using Server.Engines.BuffIcons; +using Server.Mobiles; +using Server.Network; + +namespace Server.Items; + +public static class SplinteringWeapon +{ + internal const int DurabilityLoss = 10; + internal const int TickCount = 5; + + internal static readonly TimeSpan ForcedWalkDuration = TimeSpan.FromSeconds(4.0); + internal static readonly TimeSpan BleedTickInterval = TimeSpan.FromSeconds(2.0); + internal static readonly TimeSpan PlayerImmunityDuration = TimeSpan.FromSeconds(15.0); + + private static readonly Dictionary<(Mobile Attacker, Mobile Defender), SplinteringContext> _contexts = []; + private static readonly Dictionary _playerImmunity = []; + + public static void Initialize() + { + EventSink.Logout += Clear; + EventSink.Movement += OnMovement; + } + + public static bool TryProcOnEligibleHit( + Mobile attacker, + Mobile defender, + BaseWeapon weapon, + WeaponAbility ability, + int splinteringChance + ) + { + if (!Core.SA || splinteringChance <= 0 || weapon == null || attacker == null || defender == null) + { + return false; + } + + if (!IsValidCombatant(attacker) || !IsValidCombatant(defender)) + { + return false; + } + + if (IsExcludedAbility(ability) || IsPlayerImmune(defender)) + { + return false; + } + + if (splinteringChance <= Utility.Random(100)) + { + return false; + } + + var key = (attacker, defender); + if (_contexts.ContainsKey(key)) + { + return false; + } + + _contexts[key] = new SplinteringContext(attacker, defender); + + if (defender is PlayerMobile) + { + AddPlayerImmunity(defender); + } + + return true; + } + + internal static bool IsForceWalking(Mobile defender) + { + if (defender == null) + { + return false; + } + + foreach (var context in _contexts.Values) + { + if (context.Defender == defender && context.ForceWalking) + { + return true; + } + } + + return false; + } + + internal static bool IsPlayerImmune(Mobile defender) => + defender is PlayerMobile && _playerImmunity.ContainsKey(defender); + + internal static int ActiveContextCount => _contexts.Count; + + internal static bool EndForceWalkForTests(Mobile attacker, Mobile defender) + { + if (attacker == null || defender == null || !_contexts.TryGetValue((attacker, defender), out var context)) + { + return false; + } + + context.EndForceWalk(); + return true; + } + + internal static bool TickForTests(Mobile attacker, Mobile defender) + { + if (attacker == null || defender == null || !_contexts.TryGetValue((attacker, defender), out var context)) + { + return false; + } + + context.OnBleedTick(); + return true; + } + + internal static void ExpirePlayerImmunityForTests(Mobile mobile) => RemovePlayerImmunity(mobile); + + internal static void ClearAll() + { + foreach (var context in _contexts.Values) + { + context.Stop(); + } + + _contexts.Clear(); + + foreach (var token in _playerImmunity.Values) + { + token.Cancel(); + } + + _playerImmunity.Clear(); + } + + internal static void ClearEffects(Mobile mobile) + { + if (mobile == null) + { + return; + } + + using var pairs = PooledRefList<(Mobile Attacker, Mobile Defender)>.Create(); + + foreach (var pair in _contexts.Keys) + { + if (pair.Attacker == mobile || pair.Defender == mobile) + { + pairs.Add(pair); + } + } + + for (var i = 0; i < pairs.Count; i++) + { + StopContext(pairs[i]); + } + } + + [OnEvent(nameof(PlayerMobile.PlayerDeathEvent))] + [OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))] + [OnEvent(nameof(BaseCreature.CreatureDeathEvent))] + [OnEvent(nameof(BaseCreature.CreatureDeletedEvent))] + public static void Clear(Mobile mobile) + { + ClearEffects(mobile); + RemovePlayerImmunity(mobile); + } + + internal static void OnMovement(MovementEventArgs e) + { + if ((e.Direction & Direction.Running) != 0 && IsForceWalking(e.Mobile) && e.Mobile.AccessLevel < AccessLevel.GameMaster) + { + e.Blocked = true; + } + } + + private static bool IsExcludedAbility(WeaponAbility ability) => ability is Disarm or InfectiousStrike; + + private static bool IsValidCombatant(Mobile mobile) => + mobile is { Deleted: false, Alive: true } && mobile.Map != null && mobile.Map != Map.Internal; + + private static void AddPlayerImmunity(Mobile defender) + { + if (defender is not PlayerMobile || _playerImmunity.ContainsKey(defender)) + { + return; + } + + Timer.StartTimer(PlayerImmunityDuration, () => RemovePlayerImmunity(defender), out var token); + _playerImmunity[defender] = token; + } + + private static void RemovePlayerImmunity(Mobile mobile) + { + if (mobile != null && _playerImmunity.Remove(mobile, out var token)) + { + token.Cancel(); + } + } + + private static void StopContext((Mobile Attacker, Mobile Defender) key) + { + if (_contexts.Remove(key, out var context)) + { + context.Stop(); + } + } + + private sealed class SplinteringContext + { + private readonly Mobile _attacker; + private readonly Mobile _defender; + private TimerExecutionToken _forcedWalkTimerToken; + private TimerExecutionToken _bleedTimerToken; + private int _bleedLevel = TickCount; + + public SplinteringContext(Mobile attacker, Mobile defender) + { + _attacker = attacker; + _defender = defender; + ForceWalking = true; + + StartForceWalk(); + + defender.SendLocalizedMessage(1112486); // A shard of the brittle weapon has become lodged in you! + attacker.SendLocalizedMessage(1113077); // A shard of your blade breaks off and sticks in your opponent! + Effects.PlaySound(defender.Location, defender.Map, 0x1DF); + + if (defender is PlayerMobile pm && BuffInfo.Enabled) + { + pm.AddBuff(new BuffInfo(BuffIcon.Unknown2, 1154670, 1152144, ForcedWalkDuration)); + } + + Timer.StartTimer(ForcedWalkDuration, EndForceWalk, out _forcedWalkTimerToken); + Timer.StartTimer(BleedTickInterval, BleedTickInterval, OnBleedTick, out _bleedTimerToken); + } + + public Mobile Defender => _defender; + + public bool ForceWalking { get; private set; } + + public void Stop() + { + _forcedWalkTimerToken.Cancel(); + _bleedTimerToken.Cancel(); + EndForceWalk(); + } + + private void StartForceWalk() + { + if (_defender.NetState != null && _defender.AccessLevel < AccessLevel.GameMaster) + { + _defender.NetState.SendSpeedControl(SpeedControlSetting.Walk); + } + } + + public void EndForceWalk() + { + if (!ForceWalking) + { + return; + } + + ForceWalking = false; + + if (_defender.NetState != null) + { + _defender.NetState.SendSpeedControl(SpeedControlSetting.Disable); + } + + if (_defender is PlayerMobile pm && BuffInfo.Enabled) + { + pm.RemoveBuff(BuffIcon.Unknown2); + } + + _defender.SendLocalizedMessage(1112487); // The shard is successfully removed. + } + + public void OnBleedTick() + { + if (!IsValidCombatant(_attacker) || !IsValidCombatant(_defender)) + { + StopContext((_attacker, _defender)); + return; + } + + BleedAttack.DoBleed(_defender, _attacker, _bleedLevel, bloodDrinker: false); + _bleedLevel--; + + if (_bleedLevel <= 0) + { + StopContext((_attacker, _defender)); + } + } + } +} diff --git a/Projects/UOContent/Misc/AOS.cs b/Projects/UOContent/Misc/AOS.cs index 9f86ddc7c..7f2ec7ecb 100644 --- a/Projects/UOContent/Misc/AOS.cs +++ b/Projects/UOContent/Misc/AOS.cs @@ -1132,7 +1132,8 @@ namespace Server BattleLust = 0x00000002, HitSparks = 0x00000004, BloodDrinker = 0x00000008, - HitSwarm = 0x00000010 + HitSwarm = 0x00000010, + SplinteringWeapon = 0x00000020 } public sealed class ExtendedWeaponAttributes : BaseAttributes @@ -1196,6 +1197,13 @@ namespace Server set => this[ExtendedWeaponAttribute.HitSwarm] = value; } + [CommandProperty(AccessLevel.GameMaster)] + public int SplinteringWeapon + { + get => this[ExtendedWeaponAttribute.SplinteringWeapon]; + set => this[ExtendedWeaponAttribute.SplinteringWeapon] = value; + } + public void GetProperties(IPropertyList list) { if (Core.HS && Bane != 0) @@ -1213,6 +1221,11 @@ namespace Server list.Add(1113591); // Blood Drinker } + if (Core.SA && SplinteringWeapon != 0) + { + list.Add(1112857, SplinteringWeapon); // splintering weapon ~1_val~% + } + if (Core.TOL && HitSparks != 0) { list.Add(1157326, HitSparks); // Sparks ~1_val~%