diff --git a/Projects/Server/Interfaces.cs b/Projects/Server/Interfaces.cs index 8638e4fc7..7ffbd9596 100644 --- a/Projects/Server/Interfaces.cs +++ b/Projects/Server/Interfaces.cs @@ -35,7 +35,7 @@ public interface ISpell { bool BlocksMovement { get; } bool IsCasting { get; } - void OnCasterHurt(); + void OnCasterHurt(DamageType damageType = DamageType.None); void OnCasterKilled(); void OnConnectionChanged(); bool OnCasterMoving(Direction d); diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index 778d5b5a7..a3497f163 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -151,6 +151,16 @@ public enum VisibleDamageType Selective } +public enum DamageType +{ + None, + Physical, + Fire, + Cold, + Poison, + Energy +} + public enum ResistanceType { Physical, @@ -339,6 +349,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro private int m_SolidHueOverride = -1; private ISpell m_Spell; + private DamageType _damageType; private int m_StatCap; private int m_Str, m_Dex, m_Int; @@ -5919,6 +5930,21 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public virtual bool CanBeDamaged() => !m_Blessed; + public void Damage(int amount, Mobile from, DamageType damageType) + { + var previousDamageType = _damageType; + _damageType = damageType; + + try + { + Damage(amount, from); + } + finally + { + _damageType = previousDamageType; + } + } + public virtual void Damage(int amount, Mobile from = null, bool informMount = true, bool ignoreEvilOmen = false) { if (amount <= 0) @@ -5939,7 +5965,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro var oldHits = Hits; var newHits = oldHits - amount; - m_Spell?.OnCasterHurt(); + m_Spell?.OnCasterHurt(_damageType); // if (m_Spell != null && m_Spell.State == SpellState.Casting) // m_Spell.Disturb( DisturbType.Hurt, false, true ); diff --git a/Projects/UOContent.Tests/Tests/Items/Weapons/ResonancePropertyTests.cs b/Projects/UOContent.Tests/Tests/Items/Weapons/ResonancePropertyTests.cs new file mode 100644 index 000000000..7c6eeaf4c --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/Weapons/ResonancePropertyTests.cs @@ -0,0 +1,559 @@ +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 ResonancePropertyTests +{ + private const int FireResonanceCliloc = 1154655; + private const int ColdResonanceCliloc = 1154656; + private const int PoisonResonanceCliloc = 1154657; + private const int EnergyResonanceCliloc = 1154658; + private const int KineticResonanceCliloc = 1154659; + + [Fact] + public void AbsorptionAttributes_StoresDupesAndSerializesResonanceOnValidHostsOnly() + { + var shield = new BronzeShield { Layer = Layer.TwoHanded }; + var shieldDupe = new BronzeShield { Layer = Layer.TwoHanded }; + var shieldDeserialized = new BronzeShield { Layer = Layer.TwoHanded }; + var weapon = new WarHammer(); + var weaponDupe = new WarHammer(); + var deserialized = new WarHammer(); + var armor = new LeatherChest(); + var oneHandedWeapon = new Katana(); + + try + { + SetAllResonance(shield.AbsorptionAttributes, 1); + SetAllResonance(weapon.AbsorptionAttributes, 6); + SetAllResonance(armor.AbsorptionAttributes, 11); + SetAllResonance(oneHandedWeapon.AbsorptionAttributes, 16); + + shield.Dupe(shieldDupe); + weapon.Dupe(weaponDupe); + + AssertResonance(shieldDupe.AbsorptionAttributes, 1); + AssertResonance(weaponDupe.AbsorptionAttributes, 6); + AssertResonance(armor.AbsorptionAttributes, 0); + AssertResonance(oneHandedWeapon.AbsorptionAttributes, 0); + + var shieldWriter = new BufferWriter(true); + shield.Serialize(shieldWriter); + var shieldBuffer = new byte[shieldWriter.Position]; + shieldWriter.Buffer.AsSpan(0, (int)shieldWriter.Position).CopyTo(shieldBuffer); + + var shieldReader = new BufferReader(shieldBuffer); + shieldDeserialized.Deserialize(shieldReader); + + Assert.Equal(shieldBuffer.Length, shieldReader.Position); + AssertResonance(shieldDeserialized.AbsorptionAttributes, 1); + + var writer = new BufferWriter(true); + weapon.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); + AssertResonance(deserialized.AbsorptionAttributes, 6); + } + finally + { + shield.Delete(); + shieldDupe.Delete(); + shieldDeserialized.Delete(); + weapon.Delete(); + weaponDupe.Delete(); + deserialized.Delete(); + armor.Delete(); + oneHandedWeapon.Delete(); + } + } + + [Fact] + public void AbsorptionAttributes_ResonancePropertiesAreStaffEditable() + { + var weaponContainer = typeof(BaseWeapon).GetProperty(nameof(BaseWeapon.AbsorptionAttributes)); + Assert.NotNull(weaponContainer); + Assert.True(weaponContainer.GetCustomAttribute().CanModify); + + var properties = new[] + { + nameof(AbsorptionAttributes.FireResonance), + nameof(AbsorptionAttributes.ColdResonance), + nameof(AbsorptionAttributes.PoisonResonance), + nameof(AbsorptionAttributes.EnergyResonance), + nameof(AbsorptionAttributes.KineticResonance) + }; + + foreach (var name in properties) + { + 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 ResonanceTooltips_AreVisibleOnlyAtStygianAbyssOnShieldsAndTwoHandedWeapons() + { + var previousExpansion = Core.Expansion; + var shield = new BronzeShield { Layer = Layer.TwoHanded }; + var weapon = new WarHammer(); + var armor = new LeatherChest(); + + try + { + SetAllResonance(shield.AbsorptionAttributes, 1); + SetAllResonance(weapon.AbsorptionAttributes, 6); + SetAllResonance(armor.AbsorptionAttributes, 11); + + Core.Expansion = Expansion.ML; + AssertNoResonanceTooltips(shield); + AssertNoResonanceTooltips(weapon); + + Core.Expansion = Expansion.SA; + AssertResonanceTooltips(shield, 1); + AssertResonanceTooltips(weapon, 6); + AssertNoResonanceTooltips(armor); + } + finally + { + Core.Expansion = previousExpansion; + shield.Delete(); + weapon.Delete(); + armor.Delete(); + } + } + + [Fact] + public void ResonanceAggregation_UsesOnlyMatchingEligibleEquipmentAndCapsAtFortyPercent() + { + var previousExpansion = Core.Expansion; + var mobile = CreateMobile(player: true); + var shield = new BronzeShield { Layer = Layer.TwoHanded }; + var weapon = new WarHammer(); + var oneHandedWeapon = new Katana(); + + try + { + shield.AbsorptionAttributes.FireResonance = 25; + shield.AbsorptionAttributes.ColdResonance = 16; + weapon.AbsorptionAttributes.FireResonance = 25; + weapon.AbsorptionAttributes.PoisonResonance = 19; + oneHandedWeapon.AbsorptionAttributes.FireResonance = 99; + + mobile.AddItem(shield); + mobile.AddItem(weapon); + mobile.AddItem(oneHandedWeapon); + + Assert.Contains(shield, mobile.Items); + Assert.Contains(weapon, mobile.Items); + Assert.Contains(oneHandedWeapon, mobile.Items); + Assert.Equal(Layer.TwoHanded, shield.Layer); + Assert.Equal(Layer.TwoHanded, weapon.Layer); + + Core.Expansion = Expansion.ML; + Assert.Equal(0, AbsorptionAttributes.GetResonanceValue(mobile, DamageType.Fire)); + + Core.Expansion = Expansion.SA; + Assert.Equal(AOS.ResonanceChanceCap, AbsorptionAttributes.GetResonanceValue(mobile, DamageType.Fire)); + Assert.Equal(16, AbsorptionAttributes.GetResonanceValue(mobile, DamageType.Cold)); + Assert.Equal(19, AbsorptionAttributes.GetResonanceValue(mobile, DamageType.Poison)); + Assert.Equal(0, AbsorptionAttributes.GetResonanceValue(mobile, DamageType.Energy)); + Assert.Equal(0, oneHandedWeapon.AbsorptionAttributes.FireResonance); + } + finally + { + Core.Expansion = previousExpansion; + shield.Delete(); + weapon.Delete(); + oneHandedWeapon.Delete(); + mobile.Delete(); + } + } + + [Theory] + [InlineData(DamageType.Physical)] + [InlineData(DamageType.Fire)] + [InlineData(DamageType.Cold)] + [InlineData(DamageType.Poison)] + [InlineData(DamageType.Energy)] + public void TypedDamage_MatchingResonancePreservesCastWithoutChangingDamage(DamageType damageType) + { + var previousExpansion = Core.Expansion; + using var random = new PredictableRandom(0); + var caster = CreateMobile(player: true); + var source = CreateMobile(player: true); + var weapon = new WarHammer(); + + try + { + Core.Expansion = Expansion.SA; + SetResonance(weapon.AbsorptionAttributes, damageType, 1); + caster.AddItem(weapon); + var spell = SetCastingSpell(caster); + var hits = caster.Hits; + + ApplyTypedDamage(caster, source, damageType); + + 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; + weapon.Delete(); + caster.Delete(); + source.Delete(); + } + } + + [Fact] + public void NonMatchingAndUntypedDamage_DisturbsNormallyAndTypedContextDoesNotLeak() + { + var previousExpansion = Core.Expansion; + using var random = new PredictableRandom(0); + var caster = CreateMobile(player: true); + var source = CreateMobile(player: true); + var weapon = new WarHammer(); + + try + { + Core.Expansion = Expansion.SA; + weapon.AbsorptionAttributes.FireResonance = 1; + caster.AddItem(weapon); + + AssertDisturbsAfter(() => ApplyDamage(caster, source, physical: 50, fire: 50)); + AssertDisturbsAfter(() => ApplyDamage(caster, source, fire: 100, chaos: 1)); + AssertDisturbsAfter(() => ApplyDamage(caster, source, fire: 100, direct: 1)); + AssertDisturbsAfter(() => caster.Damage(10)); + + var preservedSpell = SetCastingSpell(caster); + ApplyDamage(caster, source, fire: 100); + Assert.Equal(SpellState.Casting, preservedSpell.State); + + AssertDisturbsAfter(() => caster.Damage(10)); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + caster.Delete(); + source.Delete(); + } + + void AssertDisturbsAfter(Action action) + { + var spell = SetCastingSpell(caster); + action(); + Assert.Equal(SpellState.None, spell.State); + Assert.Null(caster.Spell); + Assert.Equal(1, spell.DisturbCount); + Assert.Equal(DisturbType.Hurt, spell.LastDisturbType); + } + } + + [Fact] + public void HurtPreservation_OrderIsProtectionThenCastingFocusThenResonance() + { + var previousExpansion = Core.Expansion; + var caster = CreateMobile(player: true); + var source = CreateMobile(player: true); + var weapon = new WarHammer(); + var armor = new LeatherChest(); + + try + { + Core.Expansion = Expansion.SA; + weapon.AbsorptionAttributes.FireResonance = 1; + caster.AddItem(weapon); + caster.AddItem(armor); + + using (new PredictableRandom(0)) + { + ProtectionSpell.Registry[caster] = 1000; + var protectionSpell = SetCastingSpell(caster); + ApplyDamage(caster, source, fire: 100); + Assert.Equal(SpellState.Casting, protectionSpell.State); + Assert.Equal(0, protectionSpell.DisturbCount); + } + + ProtectionSpell.Registry[caster] = -1; + armor.AbsorptionAttributes.CastingFocus = 1; + using (new PredictableRandom(0)) + { + var focusSpell = SetCastingSpell(caster); + ApplyDamage(caster, source, fire: 100); + Assert.Equal(SpellState.Casting, focusSpell.State); + Assert.Equal(0, focusSpell.DisturbCount); + } + + armor.AbsorptionAttributes.CastingFocus = 0; + using (new PredictableRandom(0)) + { + var resonanceSpell = SetCastingSpell(caster); + ApplyDamage(caster, source, fire: 100); + Assert.Equal(SpellState.Casting, resonanceSpell.State); + Assert.Equal(0, resonanceSpell.DisturbCount); + } + + ProtectionSpell.Registry.Remove(caster); + using (new PredictableRandom(99)) + { + var failedSpell = SetCastingSpell(caster); + ApplyDamage(caster, source, fire: 100); + Assert.Equal(SpellState.None, failedSpell.State); + Assert.Equal(1, failedSpell.DisturbCount); + } + } + finally + { + ProtectionSpell.Registry.Remove(caster); + Core.Expansion = previousExpansion; + weapon.Delete(); + armor.Delete(); + caster.Delete(); + source.Delete(); + } + } + + [Fact] + public void Resonance_DoesNotChangeNonPlayerOrNonHurtDisturbanceBehavior() + { + var previousExpansion = Core.Expansion; + using var random = new PredictableRandom(0); + var creature = CreateMobile(player: false); + var weapon = new WarHammer(); + + try + { + Core.Expansion = Expansion.SA; + weapon.AbsorptionAttributes.FireResonance = 40; + creature.AddItem(weapon); + var spell = SetCastingSpell(creature); + + ApplyDamage(creature, null, fire: 100); + Assert.Equal(SpellState.Casting, spell.State); + Assert.Equal(0, spell.DisturbCount); + + creature.Player = true; + spell.Disturb(DisturbType.EquipRequest, false, true); + Assert.Equal(SpellState.None, spell.State); + Assert.Equal(1, spell.DisturbCount); + Assert.Equal(DisturbType.EquipRequest, spell.LastDisturbType); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + creature.Delete(); + } + } + + private static void SetAllResonance(AbsorptionAttributes attributes, int value) + { + attributes.FireResonance = value; + attributes.ColdResonance = value + 1; + attributes.PoisonResonance = value + 2; + attributes.EnergyResonance = value + 3; + attributes.KineticResonance = value + 4; + } + + private static void AssertResonance(AbsorptionAttributes attributes, int value) + { + Assert.Equal(value, attributes.FireResonance); + Assert.Equal(value + (value == 0 ? 0 : 1), attributes.ColdResonance); + Assert.Equal(value + (value == 0 ? 0 : 2), attributes.PoisonResonance); + Assert.Equal(value + (value == 0 ? 0 : 3), attributes.EnergyResonance); + Assert.Equal(value + (value == 0 ? 0 : 4), attributes.KineticResonance); + } + + private static void SetResonance(AbsorptionAttributes attributes, DamageType damageType, int value) + { + switch (damageType) + { + case DamageType.Physical: + attributes.KineticResonance = value; + break; + case DamageType.Fire: + attributes.FireResonance = value; + break; + case DamageType.Cold: + attributes.ColdResonance = value; + break; + case DamageType.Poison: + attributes.PoisonResonance = value; + break; + case DamageType.Energy: + attributes.EnergyResonance = value; + break; + default: + throw new ArgumentOutOfRangeException(nameof(damageType)); + } + } + + private static void AssertNoResonanceTooltips(Item item) + { + var list = new RecordingPropertyList(); + item.GetProperties(list); + Assert.DoesNotContain(list.Entries, entry => IsResonanceCliloc(entry.Number)); + } + + private static void AssertResonanceTooltips(Item item, int firstValue) + { + var list = new RecordingPropertyList(); + item.GetProperties(list); + Assert.Contains(list.Entries, entry => entry.Number == FireResonanceCliloc && entry.Argument == firstValue.ToString()); + Assert.Contains(list.Entries, entry => entry.Number == ColdResonanceCliloc && entry.Argument == (firstValue + 1).ToString()); + Assert.Contains(list.Entries, entry => entry.Number == PoisonResonanceCliloc && entry.Argument == (firstValue + 2).ToString()); + Assert.Contains(list.Entries, entry => entry.Number == EnergyResonanceCliloc && entry.Argument == (firstValue + 3).ToString()); + Assert.Contains(list.Entries, entry => entry.Number == KineticResonanceCliloc && entry.Argument == (firstValue + 4).ToString()); + } + + private static bool IsResonanceCliloc(int number) => number is FireResonanceCliloc or ColdResonanceCliloc or PoisonResonanceCliloc or EnergyResonanceCliloc or KineticResonanceCliloc; + + private static void ApplyTypedDamage(Mobile defender, Mobile source, DamageType type) + { + switch (type) + { + case DamageType.Physical: + ApplyDamage(defender, source, physical: 100); + break; + case DamageType.Fire: + ApplyDamage(defender, source, fire: 100); + break; + case DamageType.Cold: + ApplyDamage(defender, source, cold: 100); + break; + case DamageType.Poison: + ApplyDamage(defender, source, poison: 100); + break; + case DamageType.Energy: + ApplyDamage(defender, source, energy: 100); + break; + default: + throw new ArgumentOutOfRangeException(nameof(type)); + } + } + + private static void ApplyDamage( + Mobile defender, + Mobile source, + int physical = 0, + int fire = 0, + int cold = 0, + int poison = 0, + int energy = 0, + int chaos = 0, + int direct = 0 + ) => AOS.Damage(defender, source, 10, true, physical, fire, cold, poison, energy, chaos, direct); + + private static Mobile CreateMobile(bool player) + { + var mobile = new Mobile(World.NewMobile); + mobile.DefaultMobileInit(); + mobile.Player = player; + mobile.RawStr = 100; + mobile.Hits = mobile.HitsMax; + mobile.MoveToWorld(new Point3D(6200, 500, 0), Map.Felucca); + return mobile; + } + + 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/Weapons/BaseWeapon.Migrations.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs index 019a79aa2..750f4cd49 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs @@ -113,4 +113,43 @@ public abstract partial class BaseWeapon _negativeAttributes = content.NegativeAttributes ?? NegativeAttributesDefaultValue(); _unwieldyOriginalWeight = -1; } + + private void MigrateFrom(V13Content content) + { + _damageLevel = content.DamageLevel ?? WeaponDamageLevel.Regular; + _accuracyLevel = content.AccuracyLevel ?? WeaponAccuracyLevel.Regular; + _durabilityLevel = content.DurabilityLevel ?? WeaponDurabilityLevel.Regular; + _quality = content.Quality ?? WeaponQuality.Regular; + _hitPoints = content.HitPoints ?? 0; + _maxHitPoints = content.MaxHitPoints ?? 0; + _slayer = content.Slayer ?? SlayerName.None; + _poison = content.Poison; + _poisonCharges = content.PoisonCharges ?? 0; + _crafter = content.Crafter; + _identified = content.Identified; + _strRequirement = content.StrRequirement ?? -1; + _dexRequirement = content.DexRequirement ?? -1; + _intRequirement = content.IntRequirement ?? -1; + _minDamage = content.MinDamage ?? -1; + _maxDamage = content.MaxDamage ?? -1; + _hitSound = content.HitSound ?? -1; + _missSound = content.MissSound ?? -1; + _speed = content.Speed ?? -1; + _maxRange = content.MaxRange ?? -1; + _skill = content.Skill ?? (SkillName)(-1); + _type = content.Type ?? (WeaponType)(-1); + _animation = content.Animation ?? (WeaponAnimation)(-1); + _resource = content.Resource ?? CraftResource.Iron; + _attributes = content.Attributes ?? AttributesDefaultValue(); + _weaponAttributes = content.WeaponAttributes ?? WeaponAttributesDefaultValue(); + _playerConstructed = content.PlayerConstructed; + _skillBonuses = content.SkillBonuses ?? SkillBonusesDefaultValue(); + _slayer2 = content.Slayer2 ?? SlayerName.None; + _aosElementDamages = content.AosElementDamages ?? AosElementAttributesDefaultValue(); + _engravedText = content.EngravedText; + _extendedWeaponAttributes = content.ExtendedWeaponAttributes ?? ExtendedWeaponAttributesDefaultValue(); + _negativeAttributes = content.NegativeAttributes ?? NegativeAttributesDefaultValue(); + _unwieldyOriginalWeight = content.UnwieldyOriginalWeight ?? -1; + _absorptionAttributes = AbsorptionAttributesDefaultValue(); + } } diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.cs index 946a579ce..ec85a241e 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.cs @@ -28,7 +28,7 @@ public interface ISlayer SlayerName Slayer2 { get; set; } } -[SerializationGenerator(13, false)] +[SerializationGenerator(14, false)] public abstract partial class BaseWeapon : Item, IWeapon, IFactionItem, ICraftable, ISlayer, IDurability, IAosItem, IIdentifiable { @@ -214,6 +214,15 @@ public abstract partial class BaseWeapon [SerializableField(33)] private double _unwieldyOriginalWeight = -1; + // Field 34 intentionally has no save flag; BaseWeapon's legacy save-flag mask is already at the high bit. + [SerializedIgnoreDupe] + [SerializableField(34, setter: "private")] + [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] + private AbsorptionAttributes _absorptionAttributes; + + [SerializableFieldDefault(34)] + private AbsorptionAttributes AbsorptionAttributesDefaultValue() => new(this); + private FactionItem m_FactionState; private SkillMod m_SkillMod, m_MageMod; private int _lastParryChance; @@ -244,6 +253,7 @@ public abstract partial class BaseWeapon WeaponAttributes = new AosWeaponAttributes(this); ExtendedWeaponAttributes = new ExtendedWeaponAttributes(this); NegativeAttributes = new NegativeAttributes(this); + AbsorptionAttributes = new AbsorptionAttributes(this); SkillBonuses = new AosSkillBonuses(this); AosElementDamages = new AosElementAttributes(this); } @@ -987,6 +997,7 @@ public abstract partial class BaseWeapon weap.AosElementDamages = new AosElementAttributes(newItem, AosElementDamages); weap.ExtendedWeaponAttributes = new ExtendedWeaponAttributes(newItem, ExtendedWeaponAttributes); weap.NegativeAttributes = new NegativeAttributes(newItem, NegativeAttributes); + weap.AbsorptionAttributes = new AbsorptionAttributes(newItem, AbsorptionAttributes); weap.SkillBonuses = new AosSkillBonuses(newItem, SkillBonuses); weap.WeaponAttributes = new AosWeaponAttributes(newItem, WeaponAttributes); weap._unwieldyOriginalWeight = _unwieldyOriginalWeight; @@ -3986,6 +3997,7 @@ public abstract partial class BaseWeapon { _extendedWeaponAttributes ??= ExtendedWeaponAttributesDefaultValue(); _negativeAttributes ??= NegativeAttributesDefaultValue(); + _absorptionAttributes ??= AbsorptionAttributesDefaultValue(); ReconcileUnwieldyWeight(); var parentMobile = Parent as Mobile; diff --git a/Projects/UOContent/Misc/AOS.cs b/Projects/UOContent/Misc/AOS.cs index 1ee77ee4e..173397f08 100644 --- a/Projects/UOContent/Misc/AOS.cs +++ b/Projects/UOContent/Misc/AOS.cs @@ -15,6 +15,7 @@ namespace Server public static class AOS { public const int CastingFocusChanceCap = 12; + public const int ResonanceChanceCap = 40; public const int MassiveStrengthRequirement = 125; public static void DisableStatInfluences() @@ -78,6 +79,8 @@ namespace Server Fix(ref chaos); Fix(ref direct); + var damageType = GetDamageType(phys, fire, cold, pois, nrgy, chaos, direct); + var physicalPostResistDamage = 0; var firePostResistDamage = 0; var coldPostResistDamage = 0; @@ -267,7 +270,7 @@ namespace Server } var oldHits = m.Hits; - m.Damage(totalDamage, from); + m.Damage(totalDamage, from, damageType); var appliedDamage = Math.Max(0, oldHits - m.Hits); PurgeMagicSpell.OnMobileDamaged(from, m, appliedDamage); @@ -301,6 +304,24 @@ namespace Server } } + private static DamageType GetDamageType(int phys, int fire, int cold, int pois, int nrgy, int chaos, int direct) + { + if (chaos != 0 || direct != 0) + { + return DamageType.None; + } + + return (phys, fire, cold, pois, nrgy) switch + { + (100, 0, 0, 0, 0) => DamageType.Physical, + (0, 100, 0, 0, 0) => DamageType.Fire, + (0, 0, 100, 0, 0) => DamageType.Cold, + (0, 0, 0, 100, 0) => DamageType.Poison, + (0, 0, 0, 0, 100) => DamageType.Energy, + _ => DamageType.None + }; + } + public static int Scale(int input, int percent) => input * percent / 100; public static int GetStatus(Mobile from, int index) @@ -1374,7 +1395,12 @@ namespace Server FireEater = 0x00000008, ColdEater = 0x00000010, PoisonEater = 0x00000020, - EnergyEater = 0x00000040 + EnergyEater = 0x00000040, + FireResonance = 0x00000080, + ColdResonance = 0x00000100, + PoisonResonance = 0x00000200, + EnergyResonance = 0x00000400, + KineticResonance = 0x00000800 } public sealed class AbsorptionAttributes : BaseAttributes @@ -1442,6 +1468,41 @@ namespace Server set => SetEaterValue(AbsorptionAttribute.EnergyEater, value); } + [CommandProperty(AccessLevel.GameMaster)] + public int FireResonance + { + get => CanHaveResonance ? this[AbsorptionAttribute.FireResonance] : 0; + set => SetResonanceValue(AbsorptionAttribute.FireResonance, value); + } + + [CommandProperty(AccessLevel.GameMaster)] + public int ColdResonance + { + get => CanHaveResonance ? this[AbsorptionAttribute.ColdResonance] : 0; + set => SetResonanceValue(AbsorptionAttribute.ColdResonance, value); + } + + [CommandProperty(AccessLevel.GameMaster)] + public int PoisonResonance + { + get => CanHaveResonance ? this[AbsorptionAttribute.PoisonResonance] : 0; + set => SetResonanceValue(AbsorptionAttribute.PoisonResonance, value); + } + + [CommandProperty(AccessLevel.GameMaster)] + public int EnergyResonance + { + get => CanHaveResonance ? this[AbsorptionAttribute.EnergyResonance] : 0; + set => SetResonanceValue(AbsorptionAttribute.EnergyResonance, value); + } + + [CommandProperty(AccessLevel.GameMaster)] + public int KineticResonance + { + get => CanHaveResonance ? this[AbsorptionAttribute.KineticResonance] : 0; + set => SetResonanceValue(AbsorptionAttribute.KineticResonance, value); + } + internal bool HasDamageEater => DamageEater != 0 || KineticEater != 0 || FireEater != 0 || ColdEater != 0 || PoisonEater != 0 || EnergyEater != 0; @@ -1455,6 +1516,11 @@ namespace Server } } + private bool CanHaveResonance => Owner is BaseShield or BaseWeapon { Layer: Layer.TwoHanded }; + + private void SetResonanceValue(AbsorptionAttribute attribute, int value) => + this[attribute] = CanHaveResonance ? value : 0; + public static int GetValue(Mobile m, AbsorptionAttribute attribute) { if (!Core.SA || m == null) @@ -1473,6 +1539,15 @@ namespace Server { var attrs = armor.AbsorptionAttributes; + if (attrs != null) + { + value += attrs[attribute]; + } + } + else if (IsResonance(attribute) && obj is BaseWeapon weapon) + { + var attrs = weapon.AbsorptionAttributes; + if (attrs != null) { value += attrs[attribute]; @@ -1483,6 +1558,26 @@ namespace Server return value; } + internal static int GetResonanceValue(Mobile m, DamageType damageType) + { + AbsorptionAttribute attribute = damageType switch + { + DamageType.Physical => AbsorptionAttribute.KineticResonance, + DamageType.Fire => AbsorptionAttribute.FireResonance, + DamageType.Cold => AbsorptionAttribute.ColdResonance, + DamageType.Poison => AbsorptionAttribute.PoisonResonance, + DamageType.Energy => AbsorptionAttribute.EnergyResonance, + _ => (AbsorptionAttribute)0 + }; + + return attribute == 0 ? 0 : Math.Min(AOS.ResonanceChanceCap, Math.Max(0, GetValue(m, attribute))); + } + + private static bool IsResonance(AbsorptionAttribute attribute) => attribute is + AbsorptionAttribute.FireResonance or AbsorptionAttribute.ColdResonance or + AbsorptionAttribute.PoisonResonance or AbsorptionAttribute.EnergyResonance or + AbsorptionAttribute.KineticResonance; + internal static bool HasDamageEaterOn(Mobile m) { if (!Core.SA || m == null) @@ -1551,6 +1646,31 @@ namespace Server { list.Add(1113596, EnergyEater); // Energy Eater ~1_val~% } + + if (FireResonance != 0) + { + list.Add(1154655, FireResonance); // Fire Resonance ~1_val~% + } + + if (ColdResonance != 0) + { + list.Add(1154656, ColdResonance); // Cold Resonance ~1_val~% + } + + if (PoisonResonance != 0) + { + list.Add(1154657, PoisonResonance); // Poison Resonance ~1_val~% + } + + if (EnergyResonance != 0) + { + list.Add(1154658, EnergyResonance); // Energy Resonance ~1_val~% + } + + if (KineticResonance != 0) + { + list.Add(1154659, KineticResonance); // Kinetic Resonance ~1_val~% + } } } diff --git a/Projects/UOContent/Skills/SpiritSpeak.cs b/Projects/UOContent/Skills/SpiritSpeak.cs index 53590c0df..755d53c14 100644 --- a/Projects/UOContent/Skills/SpiritSpeak.cs +++ b/Projects/UOContent/Skills/SpiritSpeak.cs @@ -93,7 +93,7 @@ namespace Server.SkillHandlers public override int GetMana() => 0; - public override void OnCasterHurt() + public override void OnCasterHurt(DamageType damageType) { if (IsCasting) { diff --git a/Projects/UOContent/Spells/Base/Spell.cs b/Projects/UOContent/Spells/Base/Spell.cs index 2c96d932c..86ac47058 100644 --- a/Projects/UOContent/Spells/Base/Spell.cs +++ b/Projects/UOContent/Spells/Base/Spell.cs @@ -84,7 +84,7 @@ namespace Server.Spells public virtual bool IsCasting => State == SpellState.Casting; - public virtual void OnCasterHurt() + public virtual void OnCasterHurt(DamageType damageType = DamageType.None) { // Confirm: Monsters and pets cannot be disturbed. if (Caster.Player && IsCasting) @@ -106,6 +106,12 @@ namespace Server.Spells return; } + var resonance = AbsorptionAttributes.GetResonanceValue(Caster, damageType); + if (resonance > 0 && resonance > Utility.Random(100)) + { + return; + } + Disturb(DisturbType.Hurt, false, true); } }