From 238c593ec4e33b85341514ae20afd468ddb865ac Mon Sep 17 00:00:00 2001 From: Crome696 Date: Thu, 9 Jul 2026 14:07:07 +0200 Subject: [PATCH] feat(items): add Casting Focus property --- .../Items/Armor/CastingFocusPropertyTests.cs | 402 ++++++++++++++++++ .../Items/Armor/BaseArmor.Migrations.cs | 35 ++ Projects/UOContent/Items/Armor/BaseArmor.cs | 17 +- .../Server.Items.BaseArmor.v11.json | 234 ++++++++++ Projects/UOContent/Misc/AOS.cs | 70 +++ Projects/UOContent/Spells/Base/Spell.cs | 17 +- 6 files changed, 772 insertions(+), 3 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Items/Armor/CastingFocusPropertyTests.cs create mode 100644 Projects/UOContent/Migrations/Server.Items.BaseArmor.v11.json diff --git a/Projects/UOContent.Tests/Tests/Items/Armor/CastingFocusPropertyTests.cs b/Projects/UOContent.Tests/Tests/Items/Armor/CastingFocusPropertyTests.cs new file mode 100644 index 000000000..c0ef963d2 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/Armor/CastingFocusPropertyTests.cs @@ -0,0 +1,402 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Server; +using Server.Items; +using Server.Mobiles; +using Server.Spells; +using Server.Spells.Second; +using Server.Tests; +using Server.Text; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class CastingFocusPropertyTests +{ + private const int CastingFocusCliloc = 1113696; + + [Fact] + public void AbsorptionAttributes_StoresDupesAndSerializesCastingFocusOnArmor() + { + var armor = new LeatherChest(); + var dupe = new LeatherChest(); + var deserialized = new LeatherChest(); + + try + { + armor.AbsorptionAttributes.CastingFocus = 3; + + armor.Dupe(dupe); + Assert.Equal(3, dupe.AbsorptionAttributes.CastingFocus); + + 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(3, deserialized.AbsorptionAttributes.CastingFocus); + } + finally + { + armor.Delete(); + dupe.Delete(); + deserialized.Delete(); + } + } + + [Fact] + public void AbsorptionAttributes_AreStaffEditableThroughCommandProperties() + { + var containerProperty = typeof(BaseArmor).GetProperty(nameof(BaseArmor.AbsorptionAttributes)); + Assert.NotNull(containerProperty); + var containerCommandProperty = containerProperty.GetCustomAttribute(); + Assert.NotNull(containerCommandProperty); + Assert.True(containerCommandProperty.CanModify); + + var property = typeof(AbsorptionAttributes).GetProperty(nameof(AbsorptionAttributes.CastingFocus)); + 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 AbsorptionAttributes_GetValue_SumsEquippedArmorAndRespectsStygianAbyssGate() + { + var previousExpansion = Core.Expansion; + var caster = CreateMobile(player: true); + var chest = new LeatherChest(); + var legs = new LeatherLegs(); + + try + { + chest.AbsorptionAttributes.CastingFocus = 5; + legs.AbsorptionAttributes.CastingFocus = 10; + caster.AddItem(chest); + caster.AddItem(legs); + + Core.Expansion = Expansion.ML; + Assert.Equal(0, AbsorptionAttributes.GetValue(caster, AbsorptionAttribute.CastingFocus)); + + Core.Expansion = Expansion.SA; + Assert.Equal(15, AbsorptionAttributes.GetValue(caster, AbsorptionAttribute.CastingFocus)); + Assert.Equal( + AOS.CastingFocusChanceCap, + Math.Min( + AOS.CastingFocusChanceCap, + AbsorptionAttributes.GetValue(caster, AbsorptionAttribute.CastingFocus) + ) + ); + } + finally + { + Core.Expansion = previousExpansion; + chest.Delete(); + legs.Delete(); + caster.Delete(); + } + } + + [Fact] + public void BaseArmor_GetProperties_GatesCastingFocusTooltipToStygianAbyss() + { + var previousExpansion = Core.Expansion; + var armor = new LeatherChest(); + + try + { + armor.AbsorptionAttributes.CastingFocus = 3; + + Core.Expansion = Expansion.ML; + var preStygianAbyss = new RecordingPropertyList(); + armor.GetProperties(preStygianAbyss); + Assert.DoesNotContain(preStygianAbyss.Entries, entry => entry.Number == CastingFocusCliloc); + + Core.Expansion = Expansion.SA; + var stygianAbyss = new RecordingPropertyList(); + armor.GetProperties(stygianAbyss); + Assert.Contains(stygianAbyss.Entries, entry => + entry.Number == CastingFocusCliloc && entry.Argument == "3" + ); + } + finally + { + Core.Expansion = previousExpansion; + armor.Delete(); + } + } + + [Fact] + public void OnCasterHurt_CastingFocusSuccessPreservesCastWithoutPreventingDamage() + { + var previousExpansion = Core.Expansion; + using var random = new PredictableRandom(0); + var caster = CreateMobile(player: true); + var armor = EquipCastingFocusArmor(caster, 1); + var spell = SetCastingSpell(caster); + + try + { + Core.Expansion = Expansion.SA; + var hits = caster.Hits; + + caster.Damage(10); + + Assert.Equal(hits - 10, caster.Hits); + Assert.Equal(SpellState.Casting, spell.State); + Assert.Same(spell, caster.Spell); + Assert.Equal(0, spell.DisturbCount); + } + finally + { + Core.Expansion = previousExpansion; + armor.Delete(); + caster.Delete(); + } + } + + [Fact] + public void OnCasterHurt_CastingFocusFailedRollDisturbsNormally() + { + var previousExpansion = Core.Expansion; + using var random = new PredictableRandom(99); + var caster = CreateMobile(player: true); + var armor = EquipCastingFocusArmor(caster, 12); + var spell = SetCastingSpell(caster); + + try + { + Core.Expansion = Expansion.SA; + + caster.Damage(10); + + Assert.Equal(SpellState.None, spell.State); + Assert.Null(caster.Spell); + Assert.Equal(1, spell.DisturbCount); + Assert.Equal(DisturbType.Hurt, spell.LastDisturbType); + } + finally + { + Core.Expansion = previousExpansion; + armor.Delete(); + caster.Delete(); + } + } + + [Fact] + public void OnCasterHurt_PreStygianAbyssCastingFocusDoesNotPreserveCast() + { + var previousExpansion = Core.Expansion; + using var random = new PredictableRandom(0); + var caster = CreateMobile(player: true); + var armor = EquipCastingFocusArmor(caster, 12); + var spell = SetCastingSpell(caster); + + try + { + Core.Expansion = Expansion.ML; + + caster.Damage(10); + + Assert.Equal(SpellState.None, spell.State); + Assert.Null(caster.Spell); + Assert.Equal(1, spell.DisturbCount); + } + finally + { + Core.Expansion = previousExpansion; + armor.Delete(); + caster.Delete(); + } + } + + [Fact] + public void OnCasterHurt_ProtectionSuccessStillPreservesBeforeCastingFocus() + { + var previousExpansion = Core.Expansion; + using var random = new PredictableRandom(99); + var caster = CreateMobile(player: true); + var armor = EquipCastingFocusArmor(caster, 0); + var spell = SetCastingSpell(caster); + + try + { + Core.Expansion = Expansion.SA; + ProtectionSpell.Registry[caster] = 1000; + + caster.Damage(10); + + Assert.Equal(SpellState.Casting, spell.State); + Assert.Same(spell, caster.Spell); + Assert.Equal(0, spell.DisturbCount); + } + finally + { + ProtectionSpell.Registry.Remove(caster); + Core.Expansion = previousExpansion; + armor.Delete(); + caster.Delete(); + } + } + + [Fact] + public void OnCasterHurt_CastingFocusCanPreserveAfterProtectionFails() + { + var previousExpansion = Core.Expansion; + using var random = new PredictableRandom(1); + var caster = CreateMobile(player: true); + var armor = EquipCastingFocusArmor(caster, 12); + var spell = SetCastingSpell(caster); + + try + { + Core.Expansion = Expansion.SA; + ProtectionSpell.Registry[caster] = 0; + + caster.Damage(10); + + Assert.Equal(SpellState.Casting, spell.State); + Assert.Same(spell, caster.Spell); + Assert.Equal(0, spell.DisturbCount); + } + finally + { + ProtectionSpell.Registry.Remove(caster); + Core.Expansion = previousExpansion; + armor.Delete(); + caster.Delete(); + } + } + + [Fact] + public void OnCasterHurt_NonPlayerCasterRemainsUnchanged() + { + var previousExpansion = Core.Expansion; + using var random = new PredictableRandom(99); + var caster = CreateMobile(player: false); + var armor = EquipCastingFocusArmor(caster, 12); + var spell = SetCastingSpell(caster); + + try + { + Core.Expansion = Expansion.SA; + + caster.Damage(10); + + Assert.Equal(SpellState.Casting, spell.State); + Assert.Same(spell, caster.Spell); + Assert.Equal(0, spell.DisturbCount); + } + finally + { + Core.Expansion = previousExpansion; + armor.Delete(); + caster.Delete(); + } + } + + private static Mobile CreateMobile(bool player) + { + var mobile = new Mobile(World.NewMobile); + mobile.DefaultMobileInit(); + mobile.Player = player; + mobile.RawStr = 100; + mobile.Hits = mobile.HitsMax; + return mobile; + } + + private static LeatherChest EquipCastingFocusArmor(Mobile mobile, int value) + { + var armor = new LeatherChest(); + armor.AbsorptionAttributes.CastingFocus = value; + mobile.AddItem(armor); + return armor; + } + + private static TestSpell SetCastingSpell(Mobile caster) + { + var spell = new TestSpell(caster) + { + State = SpellState.Casting + }; + + caster.Spell = spell; + return spell; + } + + private sealed class TestSpell : Spell + { + private static readonly SpellInfo TestInfo = new("Test Spell", "test"); + + public TestSpell(Mobile caster) : base(caster, null, TestInfo) + { + } + + public int DisturbCount { get; private set; } + public DisturbType LastDisturbType { get; private set; } + public override TimeSpan CastDelayBase => TimeSpan.Zero; + public override void OnCast() + { + } + + public override int GetMana() => 0; + + public override void OnDisturb(DisturbType type, bool message) + { + DisturbCount++; + LastDisturbType = type; + } + } + + 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.Migrations.cs b/Projects/UOContent/Items/Armor/BaseArmor.Migrations.cs index 42cf1b13c..4968dbf87 100644 --- a/Projects/UOContent/Items/Armor/BaseArmor.Migrations.cs +++ b/Projects/UOContent/Items/Armor/BaseArmor.Migrations.cs @@ -31,6 +31,7 @@ public partial class BaseArmor _skillBonuses = content.SkillBonuses ?? SkillBonusesDefaultValue(); _playerConstructed = content.PlayerConstructed; _negativeAttributes = NegativeAttributesDefaultValue(); + _absorptionAttributes = AbsorptionAttributesDefaultValue(); } private void MigrateFrom(V9Content content) @@ -61,6 +62,38 @@ public partial class BaseArmor _skillBonuses = content.SkillBonuses ?? SkillBonusesDefaultValue(); _playerConstructed = content.PlayerConstructed; _negativeAttributes = NegativeAttributesDefaultValue(); + _absorptionAttributes = AbsorptionAttributesDefaultValue(); + } + + private void MigrateFrom(V10Content content) + { + _attributes = content.Attributes ?? AttributesDefaultValue(); + _armorAttributes = content.ArmorAttributes ?? ArmorAttributesDefaultValue(); + _physicalBonus = content.PhysicalBonus ?? 0; + _fireBonus = content.FireBonus ?? 0; + _coldBonus = content.ColdBonus ?? 0; + _poisonBonus = content.PoisonBonus ?? 0; + _energyBonus = content.EnergyBonus ?? 0; + _identified = content.Identified; + _maxHitPoints = content.MaxHitPoints ?? 0; + _hitPoints = content.HitPoints ?? 0; + _crafter = content.Crafter; + _quality = content.Quality ?? ArmorQuality.Regular; + _durability = content.Durability ?? ArmorDurabilityLevel.Regular; + _protectionLevel = content.ProtectionLevel ?? ArmorProtectionLevel.Regular; + _resource = content.Resource ?? DefaultResource; + _armorBase = content.BaseArmorRating ?? -1; + _strBonus = content.StrBonus ?? -1; + _dexBonus = content.DexBonus ?? -1; + _intBonus = content.IntBonus ?? -1; + _strReq = content.StrRequirement ?? -1; + _dexReq = content.DexRequirement ?? -1; + _intReq = content.IntRequirement ?? -1; + _meditate = content.MeditationAllowance ?? (AMA)(-1); + _skillBonuses = content.SkillBonuses ?? SkillBonusesDefaultValue(); + _playerConstructed = content.PlayerConstructed; + _negativeAttributes = content.NegativeAttributes ?? NegativeAttributesDefaultValue(); + _absorptionAttributes = AbsorptionAttributesDefaultValue(); } // Version 7 (pre-codegen) @@ -192,5 +225,7 @@ public partial class BaseArmor } PlayerConstructed = GetSaveFlag(flags, OldSaveFlag.PlayerConstructed); + NegativeAttributes = new NegativeAttributes(this); + AbsorptionAttributes = new AbsorptionAttributes(this); } } diff --git a/Projects/UOContent/Items/Armor/BaseArmor.cs b/Projects/UOContent/Items/Armor/BaseArmor.cs index 1e6197756..32fe6bdfd 100644 --- a/Projects/UOContent/Items/Armor/BaseArmor.cs +++ b/Projects/UOContent/Items/Armor/BaseArmor.cs @@ -12,7 +12,7 @@ using AMT = Server.Items.ArmorMaterialType; namespace Server.Items { - [SerializationGenerator(10, false)] + [SerializationGenerator(11, false)] public abstract partial class BaseArmor : Item, IScissorable, IFactionItem, ICraftable, IWearableDurability, IAosItem, IIdentifiable { @@ -162,6 +162,17 @@ namespace Server.Items [SerializableFieldDefault(25)] private NegativeAttributes NegativeAttributesDefaultValue() => new(this); + [SerializedIgnoreDupe] + [SerializableField(26, setter: "private")] + [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] + private AbsorptionAttributes _absorptionAttributes; + + [SerializableFieldSaveFlag(26)] + private bool ShouldSerializeAbsorptionAttributes() => !_absorptionAttributes.IsEmpty; + + [SerializableFieldDefault(26)] + private AbsorptionAttributes AbsorptionAttributesDefaultValue() => new(this); + private FactionItem m_FactionState; public BaseArmor(int itemID) : base(itemID) @@ -179,6 +190,7 @@ namespace Server.Items Attributes = new AosAttributes(this); ArmorAttributes = new AosArmorAttributes(this); NegativeAttributes = new NegativeAttributes(this); + AbsorptionAttributes = new AbsorptionAttributes(this); SkillBonuses = new AosSkillBonuses(this); } @@ -827,6 +839,7 @@ namespace Server.Items armor.Attributes = new AosAttributes(newItem, Attributes); armor.ArmorAttributes = new AosArmorAttributes(newItem, ArmorAttributes); armor.NegativeAttributes = new NegativeAttributes(newItem, NegativeAttributes); + armor.AbsorptionAttributes = new AbsorptionAttributes(newItem, AbsorptionAttributes); armor.SkillBonuses = new AosSkillBonuses(newItem, SkillBonuses); // Set hue again because of resource @@ -1050,6 +1063,7 @@ namespace Server.Items private void AfterDeserialization() { _negativeAttributes ??= NegativeAttributesDefaultValue(); + _absorptionAttributes ??= AbsorptionAttributesDefaultValue(); var m = Parent as Mobile; @@ -1344,6 +1358,7 @@ namespace Server.Items } NegativeAttributes.GetProperties(list); + AbsorptionAttributes.GetProperties(list); ArmorAttributes.GetProperties(list); Attributes.GetProperties(list, luckBonus: GetLuckBonus()); diff --git a/Projects/UOContent/Migrations/Server.Items.BaseArmor.v11.json b/Projects/UOContent/Migrations/Server.Items.BaseArmor.v11.json new file mode 100644 index 000000000..4cc65c417 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.BaseArmor.v11.json @@ -0,0 +1,234 @@ +{ + "version": 11, + "type": "Server.Items.BaseArmor", + "properties": [ + { + "name": "Attributes", + "type": "Server.AosAttributes", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "ArmorAttributes", + "type": "Server.AosArmorAttributes", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "PhysicalBonus", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "FireBonus", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "ColdBonus", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "PoisonBonus", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "EnergyBonus", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "Identified", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "MaxHitPoints", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "HitPoints", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "Crafter", + "type": "string", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Quality", + "type": "Server.Items.ArmorQuality", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "Durability", + "type": "Server.Items.ArmorDurabilityLevel", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "ProtectionLevel", + "type": "Server.Items.ArmorProtectionLevel", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "Resource", + "type": "Server.Items.CraftResource", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "BaseArmorRating", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "StrBonus", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "DexBonus", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "IntBonus", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "StrRequirement", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "DexRequirement", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "IntRequirement", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "MeditationAllowance", + "type": "Server.Items.ArmorMeditationAllowance", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "SkillBonuses", + "type": "Server.AosSkillBonuses", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "PlayerConstructed", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "NegativeAttributes", + "type": "Server.NegativeAttributes", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "AbsorptionAttributes", + "type": "Server.AbsorptionAttributes", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Misc/AOS.cs b/Projects/UOContent/Misc/AOS.cs index 17563e995..5107cb03b 100644 --- a/Projects/UOContent/Misc/AOS.cs +++ b/Projects/UOContent/Misc/AOS.cs @@ -12,6 +12,7 @@ namespace Server { public static class AOS { + public const int CastingFocusChanceCap = 12; public const int MassiveStrengthRequirement = 125; public static void DisableStatInfluences() @@ -1201,6 +1202,75 @@ namespace Server public override string ToString() => "..."; } + [Flags] + public enum AbsorptionAttribute + { + CastingFocus = 0x00000001 + } + + public sealed class AbsorptionAttributes : BaseAttributes + { + public AbsorptionAttributes(Item owner) : base(owner) + { + } + + public AbsorptionAttributes(Item owner, AbsorptionAttributes other) : base(owner, other) + { + } + + public int this[AbsorptionAttribute attribute] + { + get => GetValue((int)attribute); + set => SetValue((int)attribute, value); + } + + [CommandProperty(AccessLevel.GameMaster)] + public int CastingFocus + { + get => Owner is BaseArmor ? this[AbsorptionAttribute.CastingFocus] : 0; + set => this[AbsorptionAttribute.CastingFocus] = Owner is BaseArmor ? value : 0; + } + + public static int GetValue(Mobile m, AbsorptionAttribute attribute) + { + if (!Core.SA) + { + return 0; + } + + var items = m.Items; + var value = 0; + + for (var i = 0; i < items.Count; ++i) + { + var obj = items[i]; + + if (obj is BaseArmor armor) + { + var attrs = armor.AbsorptionAttributes; + + if (attrs != null) + { + value += attrs[attribute]; + } + } + } + + return value; + } + + public void GetProperties(IPropertyList list) + { + var castingFocus = CastingFocus; + if (Core.SA && castingFocus != 0) + { + list.Add(1113696, castingFocus); // Casting Focus ~1_val~% + } + } + + public override string ToString() => "..."; + } + [Flags] public enum NegativeAttribute { diff --git a/Projects/UOContent/Spells/Base/Spell.cs b/Projects/UOContent/Spells/Base/Spell.cs index d3f666530..387112988 100644 --- a/Projects/UOContent/Spells/Base/Spell.cs +++ b/Projects/UOContent/Spells/Base/Spell.cs @@ -88,10 +88,23 @@ namespace Server.Spells if (Caster.Player && IsCasting) { var hasProtection = ProtectionSpell.Registry.TryGetValue(Caster, out var d); - if (!hasProtection || d < 1000 && d < Utility.Random(1000)) + if (hasProtection && (d >= 1000 || d >= Utility.Random(1000))) { - Disturb(DisturbType.Hurt, false, true); + return; } + + var castingFocus = Math.Min( + AOS.CastingFocusChanceCap, + AbsorptionAttributes.GetValue(Caster, AbsorptionAttribute.CastingFocus) + ); + + if (castingFocus > Utility.Random(100)) + { + Caster.SendLocalizedMessage(1113690); // You regain your focus and continue casting the spell. + return; + } + + Disturb(DisturbType.Hurt, false, true); } }