diff --git a/Projects/UOContent.Tests/Tests/Items/Armor/DamageEaterPropertyTests.cs b/Projects/UOContent.Tests/Tests/Items/Armor/DamageEaterPropertyTests.cs new file mode 100644 index 000000000..7f5685d1d --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/Armor/DamageEaterPropertyTests.cs @@ -0,0 +1,576 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Server; +using Server.Items; +using Server.Tests; +using Server.Text; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class DamageEaterPropertyTests +{ + private const int DamageEaterCliloc = 1113598; + private const int KineticEaterCliloc = 1113597; + private const int FireEaterCliloc = 1113593; + private const int ColdEaterCliloc = 1113594; + private const int PoisonEaterCliloc = 1113595; + private const int EnergyEaterCliloc = 1113596; + private static readonly DateTime TestNow = new(2035, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + [Fact] + public void AbsorptionAttributes_StoresDupesAndSerializesDamageEatersOnArmorAndShields() + { + var armor = new LeatherChest(); + var armorDupe = new LeatherChest(); + var deserialized = new LeatherChest(); + var shield = new BronzeShield(); + var shieldDupe = new BronzeShield(); + + try + { + armor.AbsorptionAttributes.DamageEater = 12; + armor.AbsorptionAttributes.KineticEater = 13; + armor.AbsorptionAttributes.FireEater = 14; + armor.AbsorptionAttributes.ColdEater = 15; + armor.AbsorptionAttributes.PoisonEater = 16; + armor.AbsorptionAttributes.EnergyEater = 17; + shield.AbsorptionAttributes.FireEater = 20; + + armor.Dupe(armorDupe); + shield.Dupe(shieldDupe); + + Assert.Equal(12, armorDupe.AbsorptionAttributes.DamageEater); + Assert.Equal(13, armorDupe.AbsorptionAttributes.KineticEater); + Assert.Equal(14, armorDupe.AbsorptionAttributes.FireEater); + Assert.Equal(15, armorDupe.AbsorptionAttributes.ColdEater); + Assert.Equal(16, armorDupe.AbsorptionAttributes.PoisonEater); + Assert.Equal(17, armorDupe.AbsorptionAttributes.EnergyEater); + Assert.Equal(20, shieldDupe.AbsorptionAttributes.FireEater); + + var writer = new BufferWriter(true); + armor.Serialize(writer); + var buffer = new byte[writer.Position]; + writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer); + + var reader = new BufferReader(buffer); + deserialized.Deserialize(reader); + + Assert.Equal(buffer.Length, reader.Position); + Assert.Equal(12, deserialized.AbsorptionAttributes.DamageEater); + Assert.Equal(13, deserialized.AbsorptionAttributes.KineticEater); + Assert.Equal(14, deserialized.AbsorptionAttributes.FireEater); + Assert.Equal(15, deserialized.AbsorptionAttributes.ColdEater); + Assert.Equal(16, deserialized.AbsorptionAttributes.PoisonEater); + Assert.Equal(17, deserialized.AbsorptionAttributes.EnergyEater); + } + finally + { + armor.Delete(); + armorDupe.Delete(); + deserialized.Delete(); + shield.Delete(); + shieldDupe.Delete(); + } + } + + [Fact] + public void AbsorptionAttributes_EaterPropertiesAreStaffEditable() + { + var names = new[] + { + nameof(AbsorptionAttributes.DamageEater), + nameof(AbsorptionAttributes.KineticEater), + nameof(AbsorptionAttributes.FireEater), + nameof(AbsorptionAttributes.ColdEater), + nameof(AbsorptionAttributes.PoisonEater), + nameof(AbsorptionAttributes.EnergyEater) + }; + + foreach (var name in names) + { + var property = typeof(AbsorptionAttributes).GetProperty(name); + Assert.NotNull(property); + + var attribute = property.GetCustomAttribute(); + Assert.NotNull(attribute); + Assert.Equal(AccessLevel.GameMaster, attribute.ReadLevel); + Assert.Equal(AccessLevel.GameMaster, attribute.WriteLevel); + } + } + + [Fact] + public void BaseArmor_GetProperties_GatesAllDamageEaterTooltipsToStygianAbyss() + { + var previousExpansion = Core.Expansion; + var armor = new LeatherChest(); + + try + { + armor.AbsorptionAttributes.DamageEater = 3; + armor.AbsorptionAttributes.KineticEater = 4; + armor.AbsorptionAttributes.FireEater = 5; + armor.AbsorptionAttributes.ColdEater = 6; + armor.AbsorptionAttributes.PoisonEater = 7; + armor.AbsorptionAttributes.EnergyEater = 8; + + Core.Expansion = Expansion.ML; + var preStygianAbyss = new RecordingPropertyList(); + armor.GetProperties(preStygianAbyss); + Assert.DoesNotContain( + preStygianAbyss.Entries, + entry => entry.Number is DamageEaterCliloc or KineticEaterCliloc or FireEaterCliloc or ColdEaterCliloc + or PoisonEaterCliloc or EnergyEaterCliloc + ); + + Core.Expansion = Expansion.SA; + var stygianAbyss = new RecordingPropertyList(); + armor.GetProperties(stygianAbyss); + + Assert.Contains(stygianAbyss.Entries, entry => entry.Number == DamageEaterCliloc && entry.Argument == "3"); + Assert.Contains(stygianAbyss.Entries, entry => entry.Number == KineticEaterCliloc && entry.Argument == "4"); + Assert.Contains(stygianAbyss.Entries, entry => entry.Number == FireEaterCliloc && entry.Argument == "5"); + Assert.Contains(stygianAbyss.Entries, entry => entry.Number == ColdEaterCliloc && entry.Argument == "6"); + Assert.Contains(stygianAbyss.Entries, entry => entry.Number == PoisonEaterCliloc && entry.Argument == "7"); + Assert.Contains(stygianAbyss.Entries, entry => entry.Number == EnergyEaterCliloc && entry.Argument == "8"); + } + finally + { + Core.Expansion = previousExpansion; + armor.Delete(); + } + } + + [Fact] + public void AbsorptionAttributes_GetEaterValue_AppliesSpecificAndAllTypeCaps() + { + var previousExpansion = Core.Expansion; + var mobile = CreateMobile(); + var first = new LeatherChest(); + var second = new LeatherLegs(); + + try + { + first.AbsorptionAttributes.FireEater = 20; + second.AbsorptionAttributes.FireEater = 20; + first.AbsorptionAttributes.DamageEater = 12; + second.AbsorptionAttributes.DamageEater = 12; + mobile.AddItem(first); + mobile.AddItem(second); + + Core.Expansion = Expansion.ML; + Assert.Equal(0, AbsorptionAttributes.GetEaterValue(mobile, AbsorptionAttribute.FireEater)); + + Core.Expansion = Expansion.SA; + Assert.Equal(DamageEater.SpecificCap, AbsorptionAttributes.GetEaterValue(mobile, AbsorptionAttribute.FireEater)); + Assert.Equal(DamageEater.AllTypesCap, AbsorptionAttributes.GetEaterValue(mobile, AbsorptionAttribute.DamageEater)); + } + finally + { + Core.Expansion = previousExpansion; + first.Delete(); + second.Delete(); + mobile.Delete(); + } + } + + [Fact] + public void DamagePipeline_PreStygianAbyssStoredValuesAreInert() + { + var previousExpansion = Core.Expansion; + var previousNow = Core._now; + var defender = CreateMobile(hitsMax: 1000, hits: 500); + var source = CreateMobile(); + var armor = new LeatherChest(); + + try + { + Core.Expansion = Expansion.ML; + Core._now = TestNow; + armor.AbsorptionAttributes.KineticEater = 20; + defender.AddItem(armor); + + var appliedDamage = ApplyDamage(defender, source, 100, 100, 0, 0, 0, 0); + Assert.True(appliedDamage > 0); + Assert.Equal(500 - appliedDamage, defender.Hits); + Assert.Equal(0, DamageEater.GetPendingChargesForTests(defender)); + Assert.False(DamageEater.TickForTests(defender)); + } + finally + { + DamageEater.ClearAll(); + Core._now = previousNow; + Core.Expansion = previousExpansion; + armor.Delete(); + defender.Delete(); + source.Delete(); + } + } + + [Fact] + public void DamagePipeline_UsesMatchingPostResistDamageAndNonAdditiveAllTypeEater() + { + var previousExpansion = Core.Expansion; + var previousNow = Core._now; + var defender = CreateMobile(hitsMax: 1000, hits: 500); + var source = CreateMobile(); + var armor = new LeatherChest(); + + try + { + Core.Expansion = Expansion.SA; + Core._now = TestNow; + armor.AbsorptionAttributes.FireEater = 20; + defender.AddItem(armor); + + var wrongTypeDamage = ApplyDamage(defender, source, 100, 100, 0, 0, 0, 0); + Assert.True(wrongTypeDamage > 0); + Assert.Equal(0, DamageEater.GetPendingChargesForTests(defender)); + + armor.AbsorptionAttributes.DamageEater = 10; + var appliedDamage = ApplyDamage(defender, source, 100, 0, 100, 0, 0, 0); + Assert.True(appliedDamage > 0); + Assert.Equal(1, DamageEater.GetPendingChargesForTests(defender)); + + Core._now = TestNow.Add(DamageEater.ConversionDelay); + Assert.True(DamageEater.TickForTests(defender)); + Assert.Equal(500 - wrongTypeDamage - appliedDamage + appliedDamage * 20 / 100, defender.Hits); + } + finally + { + DamageEater.ClearAll(); + Core._now = previousNow; + Core.Expansion = previousExpansion; + armor.Delete(); + defender.Delete(); + source.Delete(); + } + } + + [Fact] + public void DamagePipeline_ArmorIgnorePreservesTypedDamageForSpecificEaters() + { + var previousExpansion = Core.Expansion; + var previousNow = Core._now; + var defender = CreateMobile(hitsMax: 1000, hits: 500); + var source = CreateMobile(); + var armor = new LeatherChest(); + + try + { + Core.Expansion = Expansion.SA; + Core._now = TestNow; + armor.AbsorptionAttributes.KineticEater = 20; + defender.AddItem(armor); + + var appliedDamage = ApplyDamage(defender, source, 100, 100, 0, 0, 0, 0, ignoreArmor: true); + Assert.True(appliedDamage > 0); + Assert.Equal(1, DamageEater.GetPendingChargesForTests(defender)); + + Core._now = TestNow.Add(DamageEater.ConversionDelay); + DamageEater.TickForTests(defender); + Assert.Equal(500 - appliedDamage + appliedDamage * 20 / 100, defender.Hits); + } + finally + { + DamageEater.ClearAll(); + Core._now = previousNow; + Core.Expansion = previousExpansion; + armor.Delete(); + defender.Delete(); + source.Delete(); + } + } + + [Fact] + public void DamagePipeline_AllTypeEaterIsCappedAndHandlesDirectDamage() + { + var previousExpansion = Core.Expansion; + var previousNow = Core._now; + var defender = CreateMobile(hitsMax: 1000, hits: 500); + var source = CreateMobile(); + var armor = new LeatherChest(); + + try + { + Core.Expansion = Expansion.SA; + Core._now = TestNow; + armor.AbsorptionAttributes.DamageEater = 25; + armor.AbsorptionAttributes.KineticEater = 30; + defender.AddItem(armor); + + var appliedDamage = ApplyDamage(defender, source, 100, 0, 0, 0, 0, 0, 100); + Assert.True(appliedDamage > 0); + Assert.Equal(1, DamageEater.GetPendingChargesForTests(defender)); + + Core._now = TestNow.Add(DamageEater.ConversionDelay); + DamageEater.TickForTests(defender); + Assert.Equal(500 - appliedDamage + appliedDamage * DamageEater.AllTypesCap / 100, defender.Hits); + } + finally + { + DamageEater.ClearAll(); + Core._now = previousNow; + Core.Expansion = previousExpansion; + armor.Delete(); + defender.Delete(); + source.Delete(); + } + } + + [Fact] + public void DamagePipeline_CapsPendingHealingAtTwentyCharges() + { + var previousExpansion = Core.Expansion; + var previousNow = Core._now; + var defender = CreateMobile(hitsMax: 5000, hits: 5000); + var source = CreateMobile(); + var armor = new LeatherChest(); + + try + { + Core.Expansion = Expansion.SA; + Core._now = TestNow; + armor.AbsorptionAttributes.KineticEater = 10; + defender.AddItem(armor); + + for (var i = 0; i < DamageEater.MaxCharges + 5; i++) + { + ApplyDamage(defender, source, 100, 100, 0, 0, 0, 0); + } + + Assert.Equal(DamageEater.MaxCharges, DamageEater.GetPendingChargesForTests(defender)); + } + finally + { + DamageEater.ClearAll(); + Core._now = previousNow; + Core.Expansion = previousExpansion; + armor.Delete(); + defender.Delete(); + source.Delete(); + } + } + + [Fact] + public void DamagePipeline_FullQueueStillResetsConversionCadence() + { + var previousExpansion = Core.Expansion; + var previousNow = Core._now; + var defender = CreateMobile(hitsMax: 5000, hits: 5000); + var source = CreateMobile(); + var armor = new LeatherChest(); + + try + { + Core.Expansion = Expansion.SA; + Core._now = TestNow; + armor.AbsorptionAttributes.KineticEater = 10; + defender.AddItem(armor); + + var firstDamage = 0; + for (var i = 0; i < DamageEater.MaxCharges; i++) + { + var appliedDamage = ApplyDamage(defender, source, 100, 100, 0, 0, 0, 0); + firstDamage = i == 0 ? appliedDamage : firstDamage; + } + + Core._now = TestNow.AddSeconds(2); + ApplyDamage(defender, source, 100, 100, 0, 0, 0, 0); + var hitsAfterDamage = defender.Hits; + + Core._now = TestNow.Add(DamageEater.ConversionDelay); + DamageEater.TickForTests(defender); + Assert.Equal(hitsAfterDamage, defender.Hits); + Assert.Equal(DamageEater.MaxCharges, DamageEater.GetPendingChargesForTests(defender)); + + Core._now = TestNow.AddSeconds(5); + DamageEater.TickForTests(defender); + Assert.Equal(hitsAfterDamage + firstDamage * 10 / 100, defender.Hits); + Assert.Equal(DamageEater.MaxCharges - 1, DamageEater.GetPendingChargesForTests(defender)); + } + finally + { + DamageEater.ClearAll(); + Core._now = previousNow; + Core.Expansion = previousExpansion; + armor.Delete(); + defender.Delete(); + source.Delete(); + } + } + + [Fact] + public void DamagePipeline_ConvertsOneChargeEveryThreeSecondsAfterLastDamage() + { + var previousExpansion = Core.Expansion; + var previousNow = Core._now; + var defender = CreateMobile(hitsMax: 1000, hits: 500); + var source = CreateMobile(); + var armor = new LeatherChest(); + + try + { + Core.Expansion = Expansion.SA; + Core._now = TestNow; + armor.AbsorptionAttributes.KineticEater = 10; + defender.AddItem(armor); + + var firstDamage = ApplyDamage(defender, source, 100, 100, 0, 0, 0, 0); + var secondDamage = ApplyDamage(defender, source, 100, 100, 0, 0, 0, 0); + var hitsAfterDamage = defender.Hits; + + Core._now = TestNow.AddSeconds(2); + DamageEater.TickForTests(defender); + Assert.Equal(hitsAfterDamage, defender.Hits); + Assert.Equal(2, DamageEater.GetPendingChargesForTests(defender)); + + var interveningDamage = ApplyDamage(defender, source, 100, 0, 100, 0, 0, 0); + hitsAfterDamage -= interveningDamage; + + Core._now = TestNow.Add(DamageEater.ConversionDelay); + DamageEater.TickForTests(defender); + Assert.Equal(hitsAfterDamage, defender.Hits); + Assert.Equal(2, DamageEater.GetPendingChargesForTests(defender)); + + Core._now = TestNow.Add(DamageEater.ConversionDelay + DamageEater.ConversionDelay); + DamageEater.TickForTests(defender); + Assert.Equal(hitsAfterDamage + firstDamage * 10 / 100, defender.Hits); + Assert.Equal(1, DamageEater.GetPendingChargesForTests(defender)); + + Core._now = TestNow.Add(DamageEater.ConversionDelay + DamageEater.ConversionDelay + DamageEater.ConversionDelay); + DamageEater.TickForTests(defender); + Assert.Equal(hitsAfterDamage + firstDamage * 10 / 100 + secondDamage * 10 / 100, defender.Hits); + } + finally + { + DamageEater.ClearAll(); + Core._now = previousNow; + Core.Expansion = previousExpansion; + armor.Delete(); + defender.Delete(); + source.Delete(); + } + } + + [Fact] + public void DamagePipeline_CleansPendingHealingWhenArmorIsRemovedOrOwnerDies() + { + var previousExpansion = Core.Expansion; + var previousNow = Core._now; + var defender = CreateMobile(hitsMax: 1000, hits: 500); + var source = CreateMobile(); + var armor = new LeatherChest(); + + try + { + Core.Expansion = Expansion.SA; + Core._now = TestNow; + armor.AbsorptionAttributes.KineticEater = 10; + defender.AddItem(armor); + ApplyDamage(defender, source, 100, 100, 0, 0, 0, 0); + Assert.Equal(1, DamageEater.GetPendingChargesForTests(defender)); + + armor.Delete(); + Assert.Equal(0, DamageEater.GetPendingChargesForTests(defender)); + + var secondArmor = new LeatherChest(); + secondArmor.AbsorptionAttributes.KineticEater = 10; + defender.AddItem(secondArmor); + ApplyDamage(defender, source, 100, 100, 0, 0, 0, 0); + defender.Kill(); + + Core._now = TestNow.Add(DamageEater.ConversionDelay); + DamageEater.TickForTests(defender); + Assert.Equal(0, defender.Hits); + Assert.Equal(0, DamageEater.GetPendingChargesForTests(defender)); + secondArmor.Delete(); + } + finally + { + DamageEater.ClearAll(); + Core._now = previousNow; + Core.Expansion = previousExpansion; + armor.Delete(); + defender.Delete(); + source.Delete(); + } + } + + private static int ApplyDamage( + Mobile defender, + Mobile source, + int damage, + int physical, + int fire, + int cold, + int poison, + int energy, + int direct = 0, + bool ignoreArmor = false + ) + { + var oldHits = defender.Hits; + AOS.Damage(defender, source, damage, ignoreArmor, physical, fire, cold, poison, energy, 0, direct); + + return Math.Max(0, oldHits - defender.Hits); + } + + private static Mobile CreateMobile(int hitsMax = 1000, int hits = 1000) + { + var mobile = new Mobile(World.NewMobile); + mobile.DefaultMobileInit(); + mobile.RawStr = Math.Max(1, (hitsMax - 50) * 2); + Assert.Equal(hitsMax, mobile.HitsMax); + mobile.Hits = hits; + mobile.MoveToWorld(new Point3D(6200, 500, 0), Map.Felucca); + return mobile; + } + + private sealed record PropertyEntry(int Number, string Argument); + + private sealed class RecordingPropertyList : IPropertyList + { + private string _interpolated = string.Empty; + + public List Entries { get; } = []; + + public void Reset() + { + } + + public void Terminate() + { + } + + public void Add(int number) => Entries.Add(new PropertyEntry(number, string.Empty)); + public void Add(int number, string argument) => Entries.Add(new PropertyEntry(number, argument)); + public void Add(ReadOnlySpan argument) => Entries.Add(new PropertyEntry(0, argument.ToString())); + public void Add(int number, ReadOnlySpan argument) => Entries.Add(new PropertyEntry(number, argument.ToString())); + public void AddChunked(ReadOnlySpan text) => Entries.Add(new PropertyEntry(0, text.ToString())); + public OplTextBlock TextBlock() => new(this); + public void Add(int number, int value) => Entries.Add(new PropertyEntry(number, value.ToString())); + public void AddLocalized(int value) => Entries.Add(new PropertyEntry(0, value.ToString())); + public void AddLocalized(int number, int value) => Entries.Add(new PropertyEntry(number, value.ToString())); + public void Add(ref IPropertyList.InterpolatedStringHandler handler) => + Entries.Add(new PropertyEntry(0, _interpolated)); + public void Add(int number, ref IPropertyList.InterpolatedStringHandler handler) => + Entries.Add(new PropertyEntry(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/Armor/BaseArmor.cs b/Projects/UOContent/Items/Armor/BaseArmor.cs index 32fe6bdfd..ad9a04db6 100644 --- a/Projects/UOContent/Items/Armor/BaseArmor.cs +++ b/Projects/UOContent/Items/Armor/BaseArmor.cs @@ -1234,6 +1234,7 @@ namespace Server.Items { if (parent is Mobile m) { + var hadDamageEater = AbsorptionAttributes?.HasDamageEater == true; var serial = Serial; m.RemoveStatMod($"{serial}Str"); @@ -1247,6 +1248,11 @@ namespace Server.Items m.Delta(MobileDelta.Armor); // Tell them armor rating has changed m.CheckStatTimers(); + + if (hadDamageEater) + { + DamageEater.Clear(m); + } } base.OnRemoved(parent); diff --git a/Projects/UOContent/Items/Armor/DamageEater.cs b/Projects/UOContent/Items/Armor/DamageEater.cs new file mode 100644 index 000000000..81f9805e6 --- /dev/null +++ b/Projects/UOContent/Items/Armor/DamageEater.cs @@ -0,0 +1,289 @@ +using System; +using System.Collections.Generic; +using ModernUO.CodeGeneratedEvents; +using Server.Mobiles; + +namespace Server.Items; + +public static class DamageEater +{ + internal const int SpecificCap = 30; + internal const int AllTypesCap = 18; + internal const int MaxCharges = 20; + internal const int EffectCliloc = 1113617; + + internal static readonly TimeSpan ConversionDelay = TimeSpan.FromSeconds(3.0); + + // UO.com describes conversion every three seconds from the last received damage. + // Treat each queued type portion as one charge: after a quiet interval, one charge converts, + // and remaining charges continue at the same cadence. Any new damage resets that interval. + + private static readonly Dictionary _contexts = []; + + public static void OnDamageTaken( + Mobile defender, + int appliedDamage, + int physicalDamage, + int fireDamage, + int coldDamage, + int poisonDamage, + int energyDamage, + int directDamage + ) + { + if (!IsValidOwner(defender) || appliedDamage <= 0) + { + Clear(defender); + return; + } + + if (!HasValue(defender)) + { + Clear(defender); + return; + } + + if (!_contexts.TryGetValue(defender, out var context)) + { + context = new DamageEaterContext(defender); + _contexts[defender] = context; + } + + context.AddDamage( + appliedDamage, + physicalDamage, + fireDamage, + coldDamage, + poisonDamage, + energyDamage, + directDamage + ); + + if (context.PendingCharges == 0) + { + Clear(defender); + } + } + + internal static int GetPendingChargesForTests(Mobile mobile) => + _contexts.TryGetValue(mobile, out var context) ? context.PendingCharges : 0; + + internal static int GetPendingHealingForTests(Mobile mobile) => + _contexts.TryGetValue(mobile, out var context) ? context.PendingHealing : 0; + + internal static bool TickForTests(Mobile mobile) + { + if (!_contexts.TryGetValue(mobile, out var context)) + { + return false; + } + + context.Convert(); + return true; + } + + internal static void ClearAll() + { + foreach (var context in _contexts.Values) + { + context.Stop(); + } + + _contexts.Clear(); + } + + internal static void ClearIfInactive(Mobile mobile) + { + if (mobile != null && _contexts.ContainsKey(mobile) && !HasValue(mobile)) + { + Clear(mobile); + } + } + + [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 && _contexts.Remove(mobile, out var context)) + { + context.Stop(); + } + } + + private static bool HasValue(Mobile mobile) => AbsorptionAttributes.HasDamageEaterOn(mobile); + + private static bool IsValidOwner(Mobile mobile) => + Core.SA && mobile?.Deleted == false && mobile.Alive && mobile.Map != null && mobile.Map != Map.Internal; + + private static int ScaleDamagePortion(int appliedDamage, int portion, int totalPortion) + { + return (int)((long)appliedDamage * portion / totalPortion); + } + + private sealed class DamageEaterContext + { + private readonly Mobile _owner; + private readonly Queue _pending = []; + private TimerExecutionToken _conversionToken; + private DateTime _nextConversion; + private int _pendingHealing; + + public DamageEaterContext(Mobile owner) + { + _owner = owner; + } + + public int PendingCharges => _pending.Count; + public int PendingHealing => _pendingHealing; + + public void AddDamage( + int appliedDamage, + int physicalDamage, + int fireDamage, + int coldDamage, + int poisonDamage, + int energyDamage, + int directDamage + ) + { + var totalPortion = physicalDamage + fireDamage + coldDamage + poisonDamage + energyDamage + directDamage; + + if (totalPortion <= 0) + { + return; + } + + QueueHealing( + ScaleDamagePortion(appliedDamage, physicalDamage, totalPortion), + AbsorptionAttribute.KineticEater + ); + QueueHealing( + ScaleDamagePortion(appliedDamage, fireDamage, totalPortion), + AbsorptionAttribute.FireEater + ); + QueueHealing( + ScaleDamagePortion(appliedDamage, coldDamage, totalPortion), + AbsorptionAttribute.ColdEater + ); + QueueHealing( + ScaleDamagePortion(appliedDamage, poisonDamage, totalPortion), + AbsorptionAttribute.PoisonEater + ); + QueueHealing( + ScaleDamagePortion(appliedDamage, energyDamage, totalPortion), + AbsorptionAttribute.EnergyEater + ); + QueueDirectHealing(ScaleDamagePortion(appliedDamage, directDamage, totalPortion)); + + if (_pending.Count > 0) + { + _nextConversion = Core.Now + ConversionDelay; + _conversionToken.Cancel(); + Timer.StartTimer(ConversionDelay, Convert, out _conversionToken); + } + } + + public void Convert() + { + if (Core.Now < _nextConversion) + { + _conversionToken.Cancel(); + Timer.StartTimer(_nextConversion - Core.Now, Convert, out _conversionToken); + return; + } + + if (!IsValidOwner(_owner) || !HasValue(_owner)) + { + DamageEater.Clear(_owner); + return; + } + + if (_pending.Count == 0) + { + DamageEater.Clear(_owner); + return; + } + + var amount = _pending.Dequeue(); + _pendingHealing -= amount; + + var oldHits = _owner.Hits; + _owner.Heal(amount, _owner, false); + + if (_owner.Hits > oldHits) + { + _owner.SendLocalizedMessage(EffectCliloc); + } + + if (_pending.Count == 0) + { + DamageEater.Clear(_owner); + return; + } + + _nextConversion = Core.Now + ConversionDelay; + Timer.StartTimer(ConversionDelay, Convert, out _conversionToken); + } + + public void Stop() + { + _conversionToken.Cancel(); + _pending.Clear(); + _pendingHealing = 0; + } + + private void QueueHealing(int portionDamage, AbsorptionAttribute specificAttribute) + { + if (portionDamage <= 0 || _pending.Count >= MaxCharges) + { + return; + } + + var specific = AbsorptionAttributes.GetEaterValue(_owner, specificAttribute); + var allTypes = AbsorptionAttributes.GetEaterValue(_owner, AbsorptionAttribute.DamageEater); + var healing = GetHealing(portionDamage, specific, allTypes, specific >= allTypes); + + Enqueue(healing); + } + + private void QueueDirectHealing(int portionDamage) + { + if (portionDamage <= 0 || _pending.Count >= MaxCharges) + { + return; + } + + var allTypes = AbsorptionAttributes.GetEaterValue(_owner, AbsorptionAttribute.DamageEater); + Enqueue(GetHealing(portionDamage, 0, allTypes, false)); + } + + private static int GetHealing(int portionDamage, int specific, int allTypes, bool useSpecific) + { + var rate = useSpecific ? specific : allTypes; + var cap = useSpecific ? SpecificCap : AllTypesCap; + + if (rate <= 0) + { + return 0; + } + + var healing = portionDamage * (double)rate / 100.0; + var cappedHealing = portionDamage * (double)cap / 100.0; + + return (int)Math.Min(healing, cappedHealing); + } + + private void Enqueue(int healing) + { + if (healing <= 0 || _pending.Count >= MaxCharges) + { + return; + } + + _pending.Enqueue(healing); + _pendingHealing += healing; + } + } +} diff --git a/Projects/UOContent/Misc/AOS.cs b/Projects/UOContent/Misc/AOS.cs index e39b9fd9e..0b70885bc 100644 --- a/Projects/UOContent/Misc/AOS.cs +++ b/Projects/UOContent/Misc/AOS.cs @@ -77,7 +77,12 @@ namespace Server Fix(ref chaos); Fix(ref direct); - var firePostResistDamage = ignoreArmor ? damage * fire / 100 : 0; + var physicalPostResistDamage = 0; + var firePostResistDamage = 0; + var coldPostResistDamage = 0; + var poisonPostResistDamage = 0; + var energyPostResistDamage = 0; + var directPostResistDamage = 0; if (Core.ML && chaos > 0) { @@ -111,6 +116,16 @@ namespace Server } } + if (ignoreArmor) + { + physicalPostResistDamage = damage * phys / 100; + firePostResistDamage = damage * fire / 100; + coldPostResistDamage = damage * cold / 100; + poisonPostResistDamage = damage * pois / 100; + energyPostResistDamage = damage * nrgy / 100; + directPostResistDamage = damage * direct / 100; + } + BaseQuiver quiver = null; if (archer && from != null) @@ -131,18 +146,23 @@ namespace Server var fireDamageNumerator = damage * fire * (100 - resFire); + physicalPostResistDamage = damage * phys * (100 - resPhys) / 10000; + firePostResistDamage = fireDamageNumerator / 10000; + coldPostResistDamage = damage * cold * (100 - resCold) / 10000; + poisonPostResistDamage = damage * pois * (100 - resPois) / 10000; + energyPostResistDamage = damage * nrgy * (100 - resNrgy) / 10000; + totalDamage = damage * phys * (100 - resPhys); 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) { - totalDamage += damage * direct / 100; + directPostResistDamage = damage * direct / 100; + totalDamage += directPostResistDamage; if (quiver != null) { @@ -251,6 +271,17 @@ namespace Server PurgeMagicSpell.OnMobileDamaged(from, m, appliedDamage); + DamageEater.OnDamageTaken( + m, + appliedDamage, + physicalPostResistDamage, + firePostResistDamage, + coldPostResistDamage, + poisonPostResistDamage, + energyPostResistDamage, + directPostResistDamage + ); + if (firePostResistDamage > 0 && appliedDamage > 0) { Swarm.ClearDefender(m); @@ -1313,7 +1344,13 @@ namespace Server [Flags] public enum AbsorptionAttribute { - CastingFocus = 0x00000001 + CastingFocus = 0x00000001, + DamageEater = 0x00000002, + KineticEater = 0x00000004, + FireEater = 0x00000008, + ColdEater = 0x00000010, + PoisonEater = 0x00000020, + EnergyEater = 0x00000040 } public sealed class AbsorptionAttributes : BaseAttributes @@ -1339,9 +1376,64 @@ namespace Server set => this[AbsorptionAttribute.CastingFocus] = Owner is BaseArmor ? value : 0; } + [CommandProperty(AccessLevel.GameMaster)] + public int DamageEater + { + get => Owner is BaseArmor ? this[AbsorptionAttribute.DamageEater] : 0; + set => SetEaterValue(AbsorptionAttribute.DamageEater, value); + } + + [CommandProperty(AccessLevel.GameMaster)] + public int KineticEater + { + get => Owner is BaseArmor ? this[AbsorptionAttribute.KineticEater] : 0; + set => SetEaterValue(AbsorptionAttribute.KineticEater, value); + } + + [CommandProperty(AccessLevel.GameMaster)] + public int FireEater + { + get => Owner is BaseArmor ? this[AbsorptionAttribute.FireEater] : 0; + set => SetEaterValue(AbsorptionAttribute.FireEater, value); + } + + [CommandProperty(AccessLevel.GameMaster)] + public int ColdEater + { + get => Owner is BaseArmor ? this[AbsorptionAttribute.ColdEater] : 0; + set => SetEaterValue(AbsorptionAttribute.ColdEater, value); + } + + [CommandProperty(AccessLevel.GameMaster)] + public int PoisonEater + { + get => Owner is BaseArmor ? this[AbsorptionAttribute.PoisonEater] : 0; + set => SetEaterValue(AbsorptionAttribute.PoisonEater, value); + } + + [CommandProperty(AccessLevel.GameMaster)] + public int EnergyEater + { + get => Owner is BaseArmor ? this[AbsorptionAttribute.EnergyEater] : 0; + set => SetEaterValue(AbsorptionAttribute.EnergyEater, value); + } + + internal bool HasDamageEater => + DamageEater != 0 || KineticEater != 0 || FireEater != 0 || ColdEater != 0 || PoisonEater != 0 || EnergyEater != 0; + + private void SetEaterValue(AbsorptionAttribute attribute, int value) + { + this[attribute] = Owner is BaseArmor ? value : 0; + + if (Owner is Item { Parent: Mobile mobile }) + { + Server.Items.DamageEater.ClearIfInactive(mobile); + } + } + public static int GetValue(Mobile m, AbsorptionAttribute attribute) { - if (!Core.SA) + if (!Core.SA || m == null) { return 0; } @@ -1367,6 +1459,35 @@ namespace Server return value; } + internal static bool HasDamageEaterOn(Mobile m) + { + if (!Core.SA || m == null) + { + return false; + } + + var items = m.Items; + for (var i = 0; i < items.Count; ++i) + { + if (items[i] is BaseArmor armor && armor.AbsorptionAttributes?.HasDamageEater == true) + { + return true; + } + } + + return false; + } + + internal static int GetEaterValue(Mobile m, AbsorptionAttribute attribute) + { + var value = Math.Max(0, GetValue(m, attribute)); + var cap = attribute == AbsorptionAttribute.DamageEater + ? Server.Items.DamageEater.AllTypesCap + : Server.Items.DamageEater.SpecificCap; + + return Math.Min(value, cap); + } + public void GetProperties(IPropertyList list) { var castingFocus = CastingFocus; @@ -1374,6 +1495,39 @@ namespace Server { list.Add(1113696, castingFocus); // Casting Focus ~1_val~% } + + if (Core.SA) + { + if (DamageEater != 0) + { + list.Add(1113598, DamageEater); // Damage Eater ~1_val~% + } + + if (KineticEater != 0) + { + list.Add(1113597, KineticEater); // Kinetic Eater ~1_val~% + } + + if (FireEater != 0) + { + list.Add(1113593, FireEater); // Fire Eater ~1_val~% + } + + if (ColdEater != 0) + { + list.Add(1113594, ColdEater); // Cold Eater ~1_val~% + } + + if (PoisonEater != 0) + { + list.Add(1113595, PoisonEater); // Poison Eater ~1_val~% + } + + if (EnergyEater != 0) + { + list.Add(1113596, EnergyEater); // Energy Eater ~1_val~% + } + } } public override string ToString() => "...";