feat: add Damage Eater armor properties

This commit is contained in:
Crome696 2026-07-10 16:49:33 +02:00
parent 7a40f7f0a7
commit a969ac0d45
4 changed files with 1031 additions and 6 deletions

View file

@ -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<CommandPropertyAttribute>();
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<PropertyEntry> 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<char> argument) => Entries.Add(new PropertyEntry(0, argument.ToString()));
public void Add(int number, ReadOnlySpan<char> argument) => Entries.Add(new PropertyEntry(number, argument.ToString()));
public void AddChunked(ReadOnlySpan<char> 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>(T value) => _interpolated += value;
public void AppendFormatted<T>(T value, string format) =>
_interpolated += value is IFormattable formattable ? formattable.ToString(format, null) : value;
public void AppendFormatted<T>(T value, int alignment) => _interpolated += value;
public void AppendFormatted<T>(T value, int alignment, string format) =>
_interpolated += value is IFormattable formattable ? formattable.ToString(format, null) : value;
public void AppendFormatted(ReadOnlySpan<char> value) => _interpolated += value.ToString();
public void AppendFormatted(ReadOnlySpan<char> 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;
}
}

View file

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

View file

@ -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<Mobile, DamageEaterContext> _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<int> _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;
}
}
}

View file

@ -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() => "...";