diff --git a/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs b/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs index 0e1cf0b5d..7427cf3a3 100644 --- a/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs @@ -26,6 +26,7 @@ public class ExtendedWeaponAttributesTests Assert.Equal(0x00000002, (int)ExtendedWeaponAttribute.BattleLust); Assert.Equal(0x00000004, (int)ExtendedWeaponAttribute.HitSparks); Assert.Equal(0x00000008, (int)ExtendedWeaponAttribute.BloodDrinker); + Assert.Equal(0x00000010, (int)ExtendedWeaponAttribute.HitSwarm); } [Fact] @@ -41,6 +42,7 @@ public class ExtendedWeaponAttributesTests Assert.Equal(0, weapon.ExtendedWeaponAttributes.BattleLust); Assert.Equal(0, weapon.ExtendedWeaponAttributes.HitSparks); Assert.Equal(0, weapon.ExtendedWeaponAttributes.BloodDrinker); + Assert.Equal(0, weapon.ExtendedWeaponAttributes.HitSwarm); } finally { @@ -76,6 +78,7 @@ public class ExtendedWeaponAttributesTests AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.BattleLust)); AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.HitSparks)); AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.BloodDrinker)); + AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.HitSwarm)); } [Fact] @@ -92,6 +95,7 @@ public class ExtendedWeaponAttributesTests weapon.ExtendedWeaponAttributes.BattleLust = 1; weapon.ExtendedWeaponAttributes.HitSparks = 20; weapon.ExtendedWeaponAttributes.BloodDrinker = 1; + weapon.ExtendedWeaponAttributes.HitSwarm = 30; var writer = new BufferWriter(true); weapon.Serialize(writer); @@ -106,6 +110,7 @@ public class ExtendedWeaponAttributesTests Assert.Equal(1, deserialized.ExtendedWeaponAttributes.BattleLust); Assert.Equal(20, deserialized.ExtendedWeaponAttributes.HitSparks); Assert.Equal(1, deserialized.ExtendedWeaponAttributes.BloodDrinker); + Assert.Equal(30, deserialized.ExtendedWeaponAttributes.HitSwarm); } finally { diff --git a/Projects/UOContent.Tests/Tests/Items/Weapons/SwarmPropertyTests.cs b/Projects/UOContent.Tests/Tests/Items/Weapons/SwarmPropertyTests.cs new file mode 100644 index 000000000..3c54fce3f --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/Weapons/SwarmPropertyTests.cs @@ -0,0 +1,558 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Server; +using Server.Items; +using Server.Mobiles; +using Server.Spells; +using Server.Spells.Bushido; +using Server.Spells.Ninjitsu; +using Server.Text; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class SwarmPropertyTests +{ + private const int SwarmCliloc = 1157325; + private static readonly Point3D TestLocation = new(6200, 520, 0); + + [Fact] + public void ExtendedWeaponAttributes_StoresAndDupesSwarm() + { + var weapon = new TestKatana(); + var dupe = new TestKatana(); + + try + { + weapon.ExtendedWeaponAttributes.HitSwarm = 30; + + weapon.Dupe(dupe); + + Assert.Equal(30, weapon.ExtendedWeaponAttributes.HitSwarm); + Assert.Equal(30, dupe.ExtendedWeaponAttributes.HitSwarm); + } + finally + { + weapon.Delete(); + dupe.Delete(); + } + } + + [Fact] + public void ExtendedWeaponAttributes_GetProperties_GatesSwarmTooltipToTimeOfLegends() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + + try + { + weapon.ExtendedWeaponAttributes.HitSwarm = 30; + + Core.Expansion = Expansion.HS; + var preTimeOfLegends = new RecordingPropertyList(); + weapon.ExtendedWeaponAttributes.GetProperties(preTimeOfLegends); + Assert.DoesNotContain(preTimeOfLegends.Entries, entry => entry.Number == SwarmCliloc); + + Core.Expansion = Expansion.TOL; + var timeOfLegends = new RecordingPropertyList(); + weapon.ExtendedWeaponAttributes.GetProperties(timeOfLegends); + Assert.Contains( + timeOfLegends.Entries, + entry => entry.Number == SwarmCliloc && entry.Argument == "30" + ); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + } + } + + [Fact] + public void OnHit_PreTimeOfLegends_DoesNotStartSwarmContext() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 520, 0)); + + try + { + Core.Expansion = Expansion.HS; + weapon.ExtendedWeaponAttributes.HitSwarm = 100; + + weapon.OnHit(attacker, defender); + + Assert.False(Swarm.HasActiveContext(attacker, defender)); + Assert.Equal(0, Swarm.ActiveContextCount); + } + finally + { + Core.Expansion = previousExpansion; + Swarm.ClearAll(); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void OnHit_NormalSuccessfulHit_StartsOneSwarmContextAndRejectsDuplicateDefenderProc() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + var attacker = CreateMobile(); + var secondAttacker = CreateMobile(location: new Point3D(6199, 520, 0)); + var defender = CreateMobile(location: new Point3D(6201, 520, 0)); + + try + { + Core.Expansion = Expansion.TOL; + weapon.ExtendedWeaponAttributes.HitSwarm = 100; + + weapon.OnHit(attacker, defender); + + Assert.True(Swarm.HasActiveContext(attacker, defender)); + Assert.True(Swarm.HasActiveContext(defender)); + Assert.Equal(1, Swarm.ActiveContextCount); + Assert.Equal(Swarm.TickCount, Swarm.GetTicksRemainingForTests(attacker, defender)); + + Swarm.TryProcOnNormalHit(secondAttacker, defender, 100); + + Assert.True(Swarm.HasActiveContext(attacker, defender)); + Assert.False(Swarm.HasActiveContext(secondAttacker, defender)); + Assert.Equal(1, Swarm.ActiveContextCount); + } + finally + { + Core.Expansion = previousExpansion; + Swarm.ClearAll(); + weapon.Delete(); + attacker.Delete(); + secondAttacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void OnHit_CurrentWeaponAbility_DoesNotStartSwarmContext() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 520, 0)); + + try + { + Core.Expansion = Expansion.TOL; + weapon.ExtendedWeaponAttributes.HitSwarm = 100; + WeaponAbility.Table[attacker] = new TestWeaponAbility(); + + weapon.OnHit(attacker, defender); + + Assert.False(Swarm.HasActiveContext(attacker, defender)); + } + finally + { + Core.Expansion = previousExpansion; + WeaponAbility.Table.Remove(attacker); + Swarm.ClearAll(); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Theory] + [InlineData(typeof(TestSpecialMove))] + [InlineData(typeof(LightningStrike))] + [InlineData(typeof(DeathStrike))] + public void OnHit_SpecialMoves_DoNotStartSwarmContext(Type moveType) + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 520, 0)); + + try + { + Core.Expansion = Expansion.TOL; + weapon.ExtendedWeaponAttributes.HitSwarm = 100; + SpecialMove.Table[attacker] = (SpecialMove)Activator.CreateInstance(moveType); + + weapon.OnHit(attacker, defender); + + Assert.False(Swarm.HasActiveContext(attacker, defender)); + } + finally + { + Core.Expansion = previousExpansion; + SpecialMove.Table.Remove(attacker); + Swarm.ClearAll(); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void ApplySwarmTick_UsesPhysicalResistanceAndReturnsAppliedDamage() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(); + var defender = CreateMobile(hitsMax: 200, hits: 200, location: new Point3D(6201, 520, 0)); + + try + { + Core.Expansion = Expansion.TOL; + AddResistance(defender, ResistanceType.Physical, 70); + + var damageApplied = Swarm.ApplySwarmTick(attacker, defender, Swarm.RawDamage); + + Assert.Equal(3, damageApplied); + Assert.Equal(197, defender.Hits); + } + finally + { + Core.Expansion = previousExpansion; + Swarm.ClearAll(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void SwarmContext_TicksThreeTimesAtFiveSecondIntervalAndThenCleansUp() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(); + var defender = CreateMobile(hitsMax: 200, hits: 200, location: new Point3D(6201, 520, 0)); + + try + { + Core.Expansion = Expansion.TOL; + + Swarm.TryProcOnNormalHit(attacker, defender, 100); + + Assert.True(Swarm.HasActiveContext(attacker, defender)); + Assert.Equal(TimeSpan.FromSeconds(5.0), Swarm.TickInterval); + Assert.Equal(TimeSpan.FromSeconds(15.0), Swarm.EffectDuration); + + for (var i = 0; i < Swarm.TickCount; i++) + { + Assert.True(Swarm.TickForTests(attacker, defender)); + } + + Assert.False(Swarm.HasActiveContext(attacker, defender)); + Assert.Equal(170, defender.Hits); + } + finally + { + Core.Expansion = previousExpansion; + Swarm.ClearAll(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void PositivePostResistFireDamageClearsSwarmContext() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 520, 0)); + + try + { + Core.Expansion = Expansion.TOL; + Swarm.TryProcOnNormalHit(attacker, defender, 100); + + AOS.Damage(defender, attacker, 10, false, 0, 100, 0, 0, 0); + + Assert.False(Swarm.HasActiveContext(attacker, defender)); + Assert.Equal(0, Swarm.ActiveContextCount); + } + finally + { + Core.Expansion = previousExpansion; + Swarm.ClearAll(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void FullyResistedFireDamageDoesNotClearSwarmContext() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 520, 0)); + + try + { + Core.Expansion = Expansion.TOL; + AddResistance(defender, ResistanceType.Fire, 100); + Swarm.TryProcOnNormalHit(attacker, defender, 100); + + AOS.Damage(defender, attacker, 10, false, 0, 100, 0, 0, 0); + + Assert.True(Swarm.HasActiveContext(attacker, defender)); + Assert.Equal(1, Swarm.ActiveContextCount); + } + finally + { + Core.Expansion = previousExpansion; + Swarm.ClearAll(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void BurningTorchEquipClearsSwarmContextImmediately() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 520, 0)); + var torch = new Torch(); + + try + { + Core.Expansion = Expansion.TOL; + Swarm.TryProcOnNormalHit(attacker, defender, 100); + torch.Burning = true; + + Assert.True(defender.EquipItem(torch)); + + Assert.False(Swarm.HasActiveContext(attacker, defender)); + Assert.Equal(0, Swarm.ActiveContextCount); + } + finally + { + Core.Expansion = previousExpansion; + Swarm.ClearAll(); + torch.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void BurningTorchIgniteClearsSwarmContextImmediately() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 520, 0)); + var torch = new Torch(); + + try + { + Core.Expansion = Expansion.TOL; + Swarm.TryProcOnNormalHit(attacker, defender, 100); + Assert.True(defender.EquipItem(torch)); + + torch.Ignite(); + + Assert.False(Swarm.HasActiveContext(attacker, defender)); + Assert.Equal(0, Swarm.ActiveContextCount); + } + finally + { + Core.Expansion = previousExpansion; + Swarm.ClearAll(); + torch.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void SwarmTickWithBurningTorchClearsContextWithoutDamage() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 520, 0)); + var torch = new Torch(); + + try + { + Core.Expansion = Expansion.TOL; + Assert.True(defender.EquipItem(torch)); + Swarm.TryProcOnNormalHit(attacker, defender, 100); + torch.Burning = true; + + var damageApplied = Swarm.ApplySwarmTick(attacker, defender, Swarm.RawDamage); + + Assert.Equal(0, damageApplied); + Assert.Equal(200, defender.Hits); + Assert.False(Swarm.HasActiveContext(attacker, defender)); + } + finally + { + Core.Expansion = previousExpansion; + Swarm.ClearAll(); + torch.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void SwarmContext_TickCleansUpInternalizedCombatant() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 520, 0)); + + try + { + Core.Expansion = Expansion.TOL; + + Swarm.TryProcOnNormalHit(attacker, defender, 100); + defender.Internalize(); + + Assert.True(Swarm.TickForTests(attacker, defender)); + Assert.False(Swarm.HasActiveContext(attacker, defender)); + Assert.Equal(0, Swarm.ActiveContextCount); + } + finally + { + Core.Expansion = previousExpansion; + Swarm.ClearAll(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void SwarmClear_RemovesContextsForDeletedOrDeadEventMobile() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 520, 0)); + + try + { + Core.Expansion = Expansion.TOL; + + Swarm.TryProcOnNormalHit(attacker, defender, 100); + Swarm.Clear(defender); + + Assert.False(Swarm.HasActiveContext(attacker, defender)); + Assert.Equal(0, Swarm.ActiveContextCount); + } + finally + { + Core.Expansion = previousExpansion; + Swarm.ClearAll(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void RunicAttributeGeneration_DoesNotRollSwarm() + { + 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.HitSwarm); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + } + } + + private static Mobile CreateMobile( + int hitsMax = 200, + int hits = 200, + int manaMax = 100, + int mana = 0, + Point3D location = default + ) + { + var mobile = new Mobile(World.NewMobile); + + mobile.DefaultMobileInit(); + mobile.RawStr = Math.Max(1, (hitsMax - 50) * 2); + mobile.RawInt = manaMax; + Assert.Equal(hitsMax, mobile.HitsMax); + Assert.Equal(manaMax, mobile.ManaMax); + mobile.Hits = hits; + mobile.Mana = mana; + mobile.MoveToWorld(location == default ? TestLocation : location, Map.Felucca); + return mobile; + } + + private static void AddResistance(Mobile mobile, ResistanceType type, int offset) => + mobile.AddResistanceMod(new ResistanceMod(type, $"SwarmTest{type}{mobile.Serial}", offset, 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/Items/Lights/Torch.cs b/Projects/UOContent/Items/Lights/Torch.cs index a2f527070..e596cbe77 100644 --- a/Projects/UOContent/Items/Lights/Torch.cs +++ b/Projects/UOContent/Items/Lights/Torch.cs @@ -32,6 +32,7 @@ public partial class Torch : BaseEquipableLight if (parent is Mobile mobile && Burning) { MeerMage.StopEffect(mobile, true); + Swarm.ClearDefender(mobile); } } @@ -42,6 +43,7 @@ public partial class Torch : BaseEquipableLight if (Parent is Mobile mobile && Burning) { MeerMage.StopEffect(mobile, true); + Swarm.ClearDefender(mobile); } } } diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.cs index 62842762f..5170a7550 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.cs @@ -2286,6 +2286,7 @@ public abstract partial class BaseWeapon if (a == null && move == null) { Sparks.TryProcOnNormalHit(attacker, defender, ExtendedWeaponAttributes.HitSparks); + Swarm.TryProcOnNormalHit(attacker, defender, ExtendedWeaponAttributes.HitSwarm); } } diff --git a/Projects/UOContent/Items/Weapons/Swarm.cs b/Projects/UOContent/Items/Weapons/Swarm.cs new file mode 100644 index 000000000..cdc37f006 --- /dev/null +++ b/Projects/UOContent/Items/Weapons/Swarm.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using ModernUO.CodeGeneratedEvents; +using Server.Collections; +using Server.Engines.BuffIcons; +using Server.Mobiles; + +namespace Server.Items; + +public static class Swarm +{ + internal const int TickCount = 3; + internal const int RawDamage = 10; + + internal static readonly TimeSpan TickInterval = TimeSpan.FromSeconds(5.0); + internal static readonly TimeSpan EffectDuration = TimeSpan.FromSeconds(15.0); + + private static readonly Dictionary _contexts = []; + + public static void TryProcOnNormalHit(Mobile attacker, Mobile defender, int hitSwarmChance) + { + if (!Core.TOL || hitSwarmChance <= 0 || attacker == null || defender == null) + { + return; + } + + if (!IsValidCombatant(attacker) || !IsValidCombatant(defender)) + { + return; + } + + if (hitSwarmChance <= Utility.Random(100)) + { + return; + } + + if (_contexts.ContainsKey(defender)) + { + return; + } + + _contexts[defender] = new SwarmContext(attacker, defender); + } + + internal static int ApplySwarmTick(Mobile attacker, Mobile defender, int rawDamage) + { + if (!Core.TOL || !IsValidCombatant(attacker) || !IsValidCombatant(defender)) + { + return 0; + } + + if (HasBurningTorchEquipped(defender)) + { + ClearDefender(defender); + return 0; + } + + var defenderHits = defender.Hits; + AOS.Damage(defender, attacker, rawDamage, false, 100, 0, 0, 0, 0); + return Math.Max(0, defenderHits - defender.Hits); + } + + internal static bool HasActiveContext(Mobile attacker, Mobile defender) => + attacker != null && defender != null && _contexts.TryGetValue(defender, out var context) && context.Attacker == attacker; + + internal static bool HasActiveContext(Mobile defender) => defender != null && _contexts.ContainsKey(defender); + + internal static int ActiveContextCount => _contexts.Count; + + internal static int GetTicksRemainingForTests(Mobile attacker, Mobile defender) => + HasActiveContext(attacker, defender) ? _contexts[defender].TicksRemaining : 0; + + internal static bool TickForTests(Mobile attacker, Mobile defender) + { + if (!HasActiveContext(attacker, defender)) + { + return false; + } + + _contexts[defender].Tick(); + return true; + } + + internal static void ClearAll() + { + foreach (var context in _contexts.Values) + { + context.Stop(); + } + + _contexts.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 _contexts) + { + if (context.Attacker == mobile || defender == mobile) + { + defenders.Add(defender); + } + } + + for (var i = 0; i < defenders.Count; i++) + { + StopContext(defenders[i]); + } + } + + public static void ClearDefender(Mobile defender) + { + if (defender == null) + { + return; + } + + StopContext(defender); + } + + private static void StopContext(Mobile defender) + { + if (_contexts.Remove(defender, out var context)) + { + context.Stop(); + } + } + + private static bool HasBurningTorchEquipped(Mobile mobile) => + mobile.FindItemOnLayer(Layer.TwoHanded) is { Burning: true }; + + private static bool IsValidCombatant(Mobile mobile) => + mobile is { Deleted: false, Alive: true } && mobile.Map != null && mobile.Map != Map.Internal; + + private sealed class SwarmContext + { + private readonly Mobile _defender; + private TimerExecutionToken _tickTimerToken; + + public SwarmContext(Mobile attacker, Mobile defender) + { + Attacker = attacker; + _defender = defender; + TicksRemaining = TickCount; + + if (_defender is PlayerMobile pm && BuffInfo.Enabled) + { + pm.AddBuff(new BuffInfo(BuffIcon.Swarm, 1157328, 1157362, EffectDuration)); + } + + Attacker.PlaySound(0x00E); + Timer.StartTimer(TickInterval, TickInterval, Tick, out _tickTimerToken); + } + + public Mobile Attacker { get; } + + public int TicksRemaining { get; private set; } + + public void Stop() + { + _tickTimerToken.Cancel(); + + if (_defender is PlayerMobile pm && BuffInfo.Enabled) + { + pm.RemoveBuff(BuffIcon.Swarm); + } + } + + public void Tick() + { + if (!IsValidCombatant(Attacker) || !IsValidCombatant(_defender) || HasBurningTorchEquipped(_defender)) + { + StopContext(_defender); + return; + } + + ApplySwarmTick(Attacker, _defender, RawDamage); + TicksRemaining--; + + if (TicksRemaining <= 0) + { + StopContext(_defender); + } + } + } +} diff --git a/Projects/UOContent/Misc/AOS.cs b/Projects/UOContent/Misc/AOS.cs index 5107cb03b..9f86ddc7c 100644 --- a/Projects/UOContent/Misc/AOS.cs +++ b/Projects/UOContent/Misc/AOS.cs @@ -76,6 +76,8 @@ namespace Server Fix(ref chaos); Fix(ref direct); + var firePostResistDamage = ignoreArmor ? damage * fire / 100 : 0; + if (Core.ML && chaos > 0) { switch (Utility.Random(5)) @@ -126,13 +128,16 @@ namespace Server var resPois = m.PoisonResistance; var resNrgy = m.EnergyResistance; + var fireDamageNumerator = damage * fire * (100 - resFire); + totalDamage = damage * phys * (100 - resPhys); - totalDamage += damage * fire * (100 - resFire); + totalDamage += fireDamageNumerator; totalDamage += damage * cold * (100 - resCold); totalDamage += damage * pois * (100 - resPois); totalDamage += damage * nrgy * (100 - resNrgy); totalDamage /= 10000; + firePostResistDamage = fireDamageNumerator / 10000; if (Core.ML) { @@ -241,7 +246,14 @@ namespace Server var oldHits = m.Hits; m.Damage(totalDamage, from); - BattleLust.OnDamageTaken(m, from, Math.Max(0, oldHits - m.Hits)); + var appliedDamage = Math.Max(0, oldHits - m.Hits); + + if (firePostResistDamage > 0 && appliedDamage > 0) + { + Swarm.ClearDefender(m); + } + + BattleLust.OnDamageTaken(m, from, appliedDamage); return totalDamage; } @@ -1119,7 +1131,8 @@ namespace Server Bane = 0x00000001, BattleLust = 0x00000002, HitSparks = 0x00000004, - BloodDrinker = 0x00000008 + BloodDrinker = 0x00000008, + HitSwarm = 0x00000010 } public sealed class ExtendedWeaponAttributes : BaseAttributes @@ -1176,6 +1189,13 @@ namespace Server set => this[ExtendedWeaponAttribute.BloodDrinker] = value; } + [CommandProperty(AccessLevel.GameMaster)] + public int HitSwarm + { + get => this[ExtendedWeaponAttribute.HitSwarm]; + set => this[ExtendedWeaponAttribute.HitSwarm] = value; + } + public void GetProperties(IPropertyList list) { if (Core.HS && Bane != 0) @@ -1197,6 +1217,11 @@ namespace Server { list.Add(1157326, HitSparks); // Sparks ~1_val~% } + + if (Core.TOL && HitSwarm != 0) + { + list.Add(1157325, HitSwarm); // Swarm ~1_val~% + } } public override string ToString() => "...";