From f38d564ad6df0d32f83ed68f6099e7b742478cbc Mon Sep 17 00:00:00 2001 From: Crome696 Date: Fri, 10 Jul 2026 14:45:38 +0200 Subject: [PATCH] feat: implement Soul Charge shield property --- .../Items/Armor/SoulChargePropertyTests.cs | 386 ++++++++++++++++++ Projects/UOContent/Items/Armor/SoulCharge.cs | 41 ++ Projects/UOContent/Misc/AOS.cs | 16 +- 3 files changed, 442 insertions(+), 1 deletion(-) create mode 100644 Projects/UOContent.Tests/Tests/Items/Armor/SoulChargePropertyTests.cs create mode 100644 Projects/UOContent/Items/Armor/SoulCharge.cs diff --git a/Projects/UOContent.Tests/Tests/Items/Armor/SoulChargePropertyTests.cs b/Projects/UOContent.Tests/Tests/Items/Armor/SoulChargePropertyTests.cs new file mode 100644 index 000000000..2ff7a79f2 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/Armor/SoulChargePropertyTests.cs @@ -0,0 +1,386 @@ +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 SoulChargePropertyTests +{ + private const int SoulChargeCliloc = 1113630; + private static readonly Point3D TestLocation = new(6200, 500, 0); + + [Fact] + public void AosArmorAttributes_StoresDupesAndSerializesSoulChargeOnShield() + { + var shield = new BronzeShield(); + var dupe = new BronzeShield(); + var deserialized = new BronzeShield(); + + try + { + shield.ArmorAttributes.SoulCharge = 30; + + shield.Dupe(dupe); + + var writer = new BufferWriter(true); + shield.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(30, shield.ArmorAttributes.SoulCharge); + Assert.Equal(30, dupe.ArmorAttributes.SoulCharge); + Assert.Equal(buffer.Length, reader.Position); + Assert.Equal(30, deserialized.ArmorAttributes.SoulCharge); + } + finally + { + shield.Delete(); + dupe.Delete(); + deserialized.Delete(); + } + } + + [Fact] + public void AosArmorAttributes_SoulChargeIsStaffEditable() + { + var property = typeof(AosArmorAttributes).GetProperty(nameof(AosArmorAttributes.SoulCharge)); + + 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 BaseShield_GetProperties_GatesSoulChargeTooltipToStygianAbyss() + { + var previousExpansion = Core.Expansion; + var shield = new BronzeShield(); + var armor = new LeatherChest(); + + try + { + shield.ArmorAttributes.SoulCharge = 30; + armor.ArmorAttributes[AosArmorAttribute.SoulCharge] = 30; + + Core.Expansion = Expansion.ML; + var preStygianAbyss = new RecordingPropertyList(); + shield.GetProperties(preStygianAbyss); + Assert.DoesNotContain(preStygianAbyss.Entries, entry => entry.Number == SoulChargeCliloc); + + Core.Expansion = Expansion.SA; + var stygianAbyss = new RecordingPropertyList(); + shield.GetProperties(stygianAbyss); + Assert.Contains( + stygianAbyss.Entries, + entry => entry.Number == SoulChargeCliloc && entry.Argument == "30" + ); + + var nonShield = new RecordingPropertyList(); + armor.GetProperties(nonShield); + Assert.DoesNotContain(nonShield.Entries, entry => entry.Number == SoulChargeCliloc); + } + finally + { + Core.Expansion = previousExpansion; + shield.Delete(); + armor.Delete(); + } + } + + [Fact] + public void DamageTaken_SoulChargeConvertsActualPostResistDamageToMana() + { + var previousExpansion = Core.Expansion; + using var random = new PredictableRandom(0); + var defender = CreateMobile(mana: 0); + var source = CreateMobile(location: new Point3D(6201, 500, 0)); + var shield = EquipSoulChargeShield(defender, 50); + + try + { + Core.Expansion = Expansion.SA; + AddResistance(defender, ResistanceType.Physical, 50); + + AOS.Damage(defender, source, 100, false, 100, 0, 0, 0, 0); + + Assert.Equal(450, defender.Hits); + Assert.Equal(15, defender.Mana); + } + finally + { + Core.Expansion = previousExpansion; + shield.Delete(); + defender.Delete(); + source.Delete(); + } + } + + [Fact] + public void DamageTaken_SoulChargeManaGainIsCappedAtMissingMana() + { + var previousExpansion = Core.Expansion; + using var random = new PredictableRandom(0); + var defender = CreateMobile(mana: 90); + var source = CreateMobile(location: new Point3D(6201, 500, 0)); + var shield = EquipSoulChargeShield(defender, 50); + + try + { + Core.Expansion = Expansion.SA; + + AOS.Damage(defender, source, 100, false, 100, 0, 0, 0, 0); + + Assert.Equal(100, defender.Mana); + } + finally + { + Core.Expansion = previousExpansion; + shield.Delete(); + defender.Delete(); + source.Delete(); + } + } + + [Fact] + public void DamageTaken_PreStygianAbyssSoulChargeIsInert() + { + var previousExpansion = Core.Expansion; + using var random = new PredictableRandom(0); + var defender = CreateMobile(); + var source = CreateMobile(location: new Point3D(6201, 500, 0)); + var shield = EquipSoulChargeShield(defender, 50); + + try + { + Core.Expansion = Expansion.ML; + + AOS.Damage(defender, source, 100, false, 100, 0, 0, 0, 0); + + Assert.Equal(0, defender.Mana); + } + finally + { + Core.Expansion = previousExpansion; + shield.Delete(); + defender.Delete(); + source.Delete(); + } + } + + [Fact] + public void DamageTaken_SoulChargeRequiresAnEquippedShieldAndLivingDefender() + { + var previousExpansion = Core.Expansion; + using var random = new PredictableRandom(0); + var nonShieldDefender = CreateMobile(); + var nonShield = new LeatherChest(); + var unequippedDefender = CreateMobile(); + var unequippedShield = EquipSoulChargeShield(unequippedDefender, 50); + var deadDefender = CreateMobile(); + var deadShield = EquipSoulChargeShield(deadDefender, 50); + var deletedDefender = CreateMobile(); + var deletedShield = EquipSoulChargeShield(deletedDefender, 50); + var source = CreateMobile(location: new Point3D(6201, 500, 0)); + + try + { + Core.Expansion = Expansion.SA; + nonShield.ArmorAttributes[AosArmorAttribute.SoulCharge] = 50; + nonShieldDefender.AddItem(nonShield); + + AOS.Damage(nonShieldDefender, source, 100, false, 100, 0, 0, 0, 0); + Assert.Equal(0, nonShieldDefender.Mana); + + unequippedDefender.RemoveItem(unequippedShield); + AOS.Damage(unequippedDefender, source, 100, false, 100, 0, 0, 0, 0); + Assert.Equal(0, unequippedDefender.Mana); + + deadDefender.Kill(); + AOS.Damage(deadDefender, source, 100, false, 100, 0, 0, 0, 0); + Assert.Equal(0, deadDefender.Mana); + + deletedDefender.Delete(); + AOS.Damage(deletedDefender, source, 100, false, 100, 0, 0, 0, 0); + Assert.Equal(0, deletedDefender.Mana); + } + finally + { + Core.Expansion = previousExpansion; + nonShield.Delete(); + unequippedShield.Delete(); + deadShield.Delete(); + deletedShield.Delete(); + nonShieldDefender.Delete(); + unequippedDefender.Delete(); + deadDefender.Delete(); + deletedDefender.Delete(); + source.Delete(); + } + } + + [Fact] + public void DamageTaken_SoulChargeDoesNotRestoreForZeroDamageOrFailedRoll() + { + var previousExpansion = Core.Expansion; + using var random = new PredictableRandom(99); + var defender = CreateMobile(); + var source = CreateMobile(location: new Point3D(6201, 500, 0)); + var shield = EquipSoulChargeShield(defender, 50); + + try + { + Core.Expansion = Expansion.SA; + + AOS.Damage(defender, source, 0, false, 100, 0, 0, 0, 0); + AOS.Damage(defender, source, 100, false, 100, 0, 0, 0, 0); + + Assert.Equal(0, defender.Mana); + } + finally + { + Core.Expansion = previousExpansion; + shield.Delete(); + defender.Delete(); + source.Delete(); + } + } + + [Fact] + public void DamageTaken_SoulChargeUsesPublishedChanceCap() + { + var previousExpansion = Core.Expansion; + using var random = new PredictableRandom(50); + var defender = CreateMobile(); + var source = CreateMobile(location: new Point3D(6201, 500, 0)); + var shield = EquipSoulChargeShield(defender, 100); + + try + { + Core.Expansion = Expansion.SA; + + AOS.Damage(defender, source, 100, false, 100, 0, 0, 0, 0); + + Assert.Equal(0, defender.Mana); + } + finally + { + Core.Expansion = previousExpansion; + shield.Delete(); + defender.Delete(); + source.Delete(); + } + } + + [Fact] + public void RunicAttributeGeneration_DoesNotRollSoulCharge() + { + var previousExpansion = Core.Expansion; + var armor = new LeatherChest(); + var shield = new BronzeShield(); + + try + { + Core.Expansion = Expansion.SA; + + BaseRunicTool.ApplyAttributesTo(armor, false, 0, 25, 100, 100); + BaseRunicTool.ApplyAttributesTo(shield, false, 0, 25, 100, 100); + + Assert.Equal(0, armor.ArmorAttributes.SoulCharge); + Assert.Equal(0, shield.ArmorAttributes.SoulCharge); + } + finally + { + Core.Expansion = previousExpansion; + armor.Delete(); + shield.Delete(); + } + } + + private static BronzeShield EquipSoulChargeShield(Mobile mobile, int value) + { + var shield = new BronzeShield(); + shield.ArmorAttributes.SoulCharge = value; + mobile.AddItem(shield); + Assert.Same(shield, mobile.FindItemOnLayer(Layer.TwoHanded)); + return shield; + } + + private static Mobile CreateMobile( + int hitsMax = 500, + int hits = 500, + 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, $"SoulChargeTest{type}{mobile.Serial}", offset, 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/SoulCharge.cs b/Projects/UOContent/Items/Armor/SoulCharge.cs new file mode 100644 index 000000000..31cf7c89d --- /dev/null +++ b/Projects/UOContent/Items/Armor/SoulCharge.cs @@ -0,0 +1,41 @@ +using System; + +namespace Server.Items; + +public static class SoulCharge +{ + internal const int ChanceCap = 50; + internal const int ConversionPercent = 30; + internal const int EffectCliloc = 1113636; + + public static void OnDamageTaken(Mobile defender, int damage) + { + if (!Core.SA || damage <= 0 || defender?.Deleted != false || !defender.Alive) + { + return; + } + + var shield = defender.FindItemOnLayer(Layer.TwoHanded); + var chance = Math.Min(shield?.ArmorAttributes.SoulCharge ?? 0, ChanceCap); + + if (chance <= 0) + { + return; + } + + if (chance <= Utility.Random(100)) + { + return; + } + + var manaGain = Math.Min(defender.ManaMax - defender.Mana, damage * ConversionPercent / 100); + + if (manaGain <= 0) + { + return; + } + + defender.Mana += manaGain; + defender.SendLocalizedMessage(EffectCliloc); + } +} diff --git a/Projects/UOContent/Misc/AOS.cs b/Projects/UOContent/Misc/AOS.cs index b7e1c704c..e39b9fd9e 100644 --- a/Projects/UOContent/Misc/AOS.cs +++ b/Projects/UOContent/Misc/AOS.cs @@ -257,6 +257,7 @@ namespace Server } BattleLust.OnDamageTaken(m, from, appliedDamage); + SoulCharge.OnDamageTaken(m, appliedDamage); return totalDamage; } @@ -1491,7 +1492,8 @@ namespace Server LowerStatReq = 0x00000001, SelfRepair = 0x00000002, MageArmor = 0x00000004, - DurabilityBonus = 0x00000008 + DurabilityBonus = 0x00000008, + SoulCharge = 0x00000010 } public sealed class AosArmorAttributes : BaseAttributes @@ -1538,6 +1540,13 @@ namespace Server set => this[AosArmorAttribute.DurabilityBonus] = value; } + [CommandProperty(AccessLevel.GameMaster)] + public int SoulCharge + { + get => Owner is BaseShield ? this[AosArmorAttribute.SoulCharge] : 0; + set => this[AosArmorAttribute.SoulCharge] = Owner is BaseShield ? value : 0; + } + public static int GetValue(Mobile m, AosArmorAttribute attribute) { if (!Core.AOS) @@ -1588,6 +1597,11 @@ namespace Server { list.Add(1060450, prop); // self repair ~1_val~ } + + if (Core.SA && Owner is BaseShield && (prop = SoulCharge) != 0) + { + list.Add(1113630, prop); // Soul Charge ~1_val~% + } } public override string ToString() => "...";