From 7e2be0c3240c74803e5130bd8fad64df89dffe8c Mon Sep 17 00:00:00 2001 From: Crome696 Date: Thu, 9 Jul 2026 20:29:15 +0200 Subject: [PATCH] feat: add last parry chance tooltip --- .../Weapons/LastParryChancePropertyTests.cs | 276 ++++++++++++++++++ .../UOContent/Items/Shields/BaseShield.cs | 41 +++ .../UOContent/Items/Weapons/BaseWeapon.cs | 59 +++- 3 files changed, 373 insertions(+), 3 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Items/Weapons/LastParryChancePropertyTests.cs diff --git a/Projects/UOContent.Tests/Tests/Items/Weapons/LastParryChancePropertyTests.cs b/Projects/UOContent.Tests/Tests/Items/Weapons/LastParryChancePropertyTests.cs new file mode 100644 index 000000000..1d5858758 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/Weapons/LastParryChancePropertyTests.cs @@ -0,0 +1,276 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Items; +using Server.Misc; +using Server.Tests; +using Server.Text; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class LastParryChancePropertyTests +{ + private const int LastParryChanceCliloc = 1158861; + + [Fact] + public void ShieldSuccessfulParry_SetsRuntimeTooltipValueAndClearsOnRemoval() + { + var previousExpansion = Core.Expansion; + using var random = new PredictableRandom(0); + var defender = CreateDefender(parry: 120.0, bushido: 0.0, dex: 100); + var shield = new Buckler(); + + try + { + EnsureSkillChecksConfigured(); + Core.Expansion = Expansion.EJ; + + Assert.True(defender.EquipItem(shield)); + Assert.True(BaseWeapon.CheckParry(defender)); + Assert.Equal(35, shield.LastParryChance); + AssertShowsLastParryChance(shield, 35); + + shield.Internalize(); + + Assert.Equal(0, shield.LastParryChance); + AssertDoesNotShowLastParryChance(shield); + } + finally + { + Core.Expansion = previousExpansion; + shield.Delete(); + defender.Delete(); + } + } + + [Fact] + public void WeaponSuccessfulParry_SetsOneHandedAndTwoHandedRuntimeTooltipValues() + { + var previousExpansion = Core.Expansion; + using var random = new PredictableRandom(0); + var oneHandedDefender = CreateDefender(parry: 120.0, bushido: 120.0, dex: 100); + var twoHandedDefender = CreateDefender(parry: 120.0, bushido: 120.0, dex: 100); + var katana = new Katana(); + var halberd = new Halberd(); + + try + { + EnsureSkillChecksConfigured(); + Core.Expansion = Expansion.EJ; + + Assert.True(oneHandedDefender.EquipItem(katana)); + Assert.True(BaseWeapon.CheckParry(oneHandedDefender)); + Assert.Equal(35, katana.LastParryChance); + AssertShowsLastParryChance(katana, 35); + + Assert.True(twoHandedDefender.EquipItem(halberd)); + Assert.True(BaseWeapon.CheckParry(twoHandedDefender)); + Assert.Equal(40, halberd.LastParryChance); + AssertShowsLastParryChance(halberd, 40); + } + finally + { + Core.Expansion = previousExpansion; + katana.Delete(); + halberd.Delete(); + oneHandedDefender.Delete(); + twoHandedDefender.Delete(); + } + } + + [Fact] + public void WeaponFallbackParry_DisplaysActualSuccessfulAosChanceBranch() + { + var previousExpansion = Core.Expansion; + using var random = new PredictableRandom(0); + var defender = CreateDefender(parry: 100.0, bushido: 0.0, dex: 100); + var katana = new Katana(); + + try + { + EnsureSkillChecksConfigured(); + Core.Expansion = Expansion.EJ; + + Assert.True(defender.EquipItem(katana)); + Assert.True(BaseWeapon.CheckParry(defender)); + + Assert.Equal(17, katana.LastParryChance); + AssertShowsLastParryChance(katana, 17); + } + finally + { + Core.Expansion = previousExpansion; + katana.Delete(); + defender.Delete(); + } + } + + [Fact] + public void TooltipDisplay_IsGatedToEndlessJourney() + { + var previousExpansion = Core.Expansion; + var weapon = new Katana { LastParryChance = 35 }; + var shield = new Buckler { LastParryChance = 35 }; + + try + { + Core.Expansion = Expansion.TOL; + AssertDoesNotShowLastParryChance(weapon); + AssertDoesNotShowLastParryChance(shield); + + Core.Expansion = Expansion.EJ; + AssertShowsLastParryChance(weapon, 35); + AssertShowsLastParryChance(shield, 35); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + shield.Delete(); + } + } + + [Fact] + public void FistsAndRangedWeapons_DoNotReceiveOrDisplayLastParryChance() + { + var previousExpansion = Core.Expansion; + using var random = new PredictableRandom(0); + var defender = CreateDefender(parry: 120.0, bushido: 120.0, dex: 100); + var bow = new Bow(); + var fists = new Fists { LastParryChance = 35 }; + + try + { + EnsureSkillChecksConfigured(); + Core.Expansion = Expansion.EJ; + + Assert.True(defender.EquipItem(bow)); + Assert.False(BaseWeapon.CheckParry(defender)); + Assert.Equal(0, bow.LastParryChance); + AssertDoesNotShowLastParryChance(bow); + AssertDoesNotShowLastParryChance(fists); + } + finally + { + Core.Expansion = previousExpansion; + bow.Delete(); + fists.Delete(); + defender.Delete(); + } + } + + [Fact] + public void RuntimeState_DoesNotUseRollablePropertyContainers() + { + var weapon = new Katana { LastParryChance = 35 }; + var shield = new Buckler { LastParryChance = 35 }; + + try + { + Assert.True(weapon.Attributes.IsEmpty); + Assert.True(weapon.WeaponAttributes.IsEmpty); + Assert.True(weapon.ExtendedWeaponAttributes.IsEmpty); + Assert.True(weapon.NegativeAttributes.IsEmpty); + Assert.True(weapon.AosElementDamages.IsEmpty); + + Assert.True(shield.Attributes.IsEmpty); + Assert.True(shield.ArmorAttributes.IsEmpty); + Assert.True(shield.NegativeAttributes.IsEmpty); + Assert.True(shield.AbsorptionAttributes.IsEmpty); + } + finally + { + weapon.Delete(); + shield.Delete(); + } + } + + private static Mobile CreateDefender(double parry, double bushido, int dex) + { + var mobile = new Mobile { Player = true }; + mobile.DefaultMobileInit(); + mobile.InitStats(100, dex, 100); + mobile.SkillsCap = 7200; + mobile.Skills[SkillName.Parry].Cap = 120.0; + mobile.Skills[SkillName.Bushido].Cap = 120.0; + mobile.Skills[SkillName.Parry].Base = parry; + mobile.Skills[SkillName.Bushido].Base = bushido; + return mobile; + } + + private static void EnsureSkillChecksConfigured() + { + if (AntiMacroSystem.Settings == null) + { + AntiMacroSystem.Configure(); + } + + SkillCheck.Configure(); + SkillCheck.Initialize(); + } + + private static void AssertShowsLastParryChance(Item item, int expectedValue) + { + var properties = new RecordingPropertyList(); + item.GetProperties(properties); + + Assert.Contains( + properties.Entries, + entry => entry.Number == LastParryChanceCliloc && entry.Argument == expectedValue.ToString() + ); + } + + private static void AssertDoesNotShowLastParryChance(Item item) + { + var properties = new RecordingPropertyList(); + item.GetProperties(properties); + + Assert.DoesNotContain(properties.Entries, entry => entry.Number == LastParryChanceCliloc); + } + + 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/Shields/BaseShield.cs b/Projects/UOContent/Items/Shields/BaseShield.cs index 1f9a229ce..e5c2619d3 100644 --- a/Projects/UOContent/Items/Shields/BaseShield.cs +++ b/Projects/UOContent/Items/Shields/BaseShield.cs @@ -6,6 +6,8 @@ namespace Server.Items; [SerializationGenerator(0, false)] public partial class BaseShield : BaseArmor { + private int _lastParryChance; + public BaseShield(int itemID) : base(itemID) { } @@ -28,6 +30,45 @@ public partial class BaseShield : BaseArmor } } + [CommandProperty(AccessLevel.GameMaster)] + public int LastParryChance + { + get => _lastParryChance; + set => SetLastParryChance(value); + } + + private void SetLastParryChance(int value) + { + value = Math.Max(value, 0); + + if (_lastParryChance == value) + { + return; + } + + _lastParryChance = value; + InvalidateProperties(); + } + + private void ClearLastParryChance() => SetLastParryChance(0); + + public override void OnRemoved(IEntity parent) + { + ClearLastParryChance(); + + base.OnRemoved(parent); + } + + public override void GetProperties(IPropertyList list) + { + base.GetProperties(list); + + if (Core.EJ && LastParryChance > 0) + { + list.Add(1158861, LastParryChance); // Last Parry Chance: ~1_val~% + } + } + public override int OnHit(BaseWeapon weapon, int damage) { if (Core.AOS) diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.cs index a05275e46..15c7d1884 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.cs @@ -211,6 +211,7 @@ public abstract partial class BaseWeapon private FactionItem m_FactionState; private SkillMod m_SkillMod, m_MageMod; + private int _lastParryChance; public BaseWeapon(int itemID) : base(itemID) { @@ -246,6 +247,30 @@ public abstract partial class BaseWeapon public static bool InDoubleStrike { get; set; } + [CommandProperty(AccessLevel.GameMaster)] + public int LastParryChance + { + get => _lastParryChance; + set => SetLastParryChance(value); + } + + private void SetLastParryChance(int value) + { + value = Math.Max(value, 0); + + if (_lastParryChance == value) + { + return; + } + + _lastParryChance = value; + InvalidateProperties(); + } + + private void ClearLastParryChance() => SetLastParryChance(0); + + private static int GetLastParryChancePercentage(double chance) => Math.Max((int)(chance * 100), 0); + public virtual int VirtualDamageBonus => 0; [Hue] @@ -1161,6 +1186,8 @@ public abstract partial class BaseWeapon public override void OnRemoved(IEntity parent) { + ClearLastParryChance(); + if (parent is not Mobile m) { return; @@ -1593,7 +1620,14 @@ public abstract partial class BaseWeapon chance = chance * (20 + defender.Dex) / 100; } - return defender.CheckSkill(SkillName.Parry, chance); + var success = defender.CheckSkill(SkillName.Parry, chance); + + if (success) + { + shield.LastParryChance = GetLastParryChancePercentage(chance); + } + + return success; } if (defender.Weapon is Fists or BaseRanged) @@ -1634,11 +1668,25 @@ public abstract partial class BaseWeapon if (chance > aosChance) { - return defender.CheckSkill(SkillName.Parry, chance); + var success = defender.CheckSkill(SkillName.Parry, chance); + + if (success && weapon != null) + { + weapon.LastParryChance = GetLastParryChancePercentage(chance); + } + + return success; } // Only skillcheck if wielding a shield & there's no effect from Bushido - return aosChance > Utility.RandomDouble(); + var fallbackSuccess = aosChance > Utility.RandomDouble(); + + if (fallbackSuccess && weapon != null) + { + weapon.LastParryChance = GetLastParryChancePercentage(aosChance); + } + + return fallbackSuccess; } public virtual int AbsorbDamageAOS(Mobile attacker, Mobile defender, int damage) @@ -3235,6 +3283,11 @@ public abstract partial class BaseWeapon { list.Add(1060639, $"{_hitPoints}\t{_maxHitPoints}"); // durability ~1_val~ / ~2_val~ } + + if (Core.EJ && LastParryChance > 0 && this is not Fists and not BaseRanged) + { + list.Add(1158861, LastParryChance); // Last Parry Chance: ~1_val~% + } } public override void OnSingleClick(Mobile from)