From d4091e940deda7453ba1cd9e75fddfd9e7298a70 Mon Sep 17 00:00:00 2001 From: Crome696 Date: Thu, 9 Jul 2026 10:24:19 +0200 Subject: [PATCH] feat(items): add Brittle negative property --- .../Tests/Items/BrittlePropertyTests.cs | 455 ++++++++++++++++++ .../Blacksmithy/PowderOfTemperament.cs | 6 + Projects/UOContent/Misc/AOS.cs | 30 +- 3 files changed, 490 insertions(+), 1 deletion(-) create mode 100644 Projects/UOContent.Tests/Tests/Items/BrittlePropertyTests.cs diff --git a/Projects/UOContent.Tests/Tests/Items/BrittlePropertyTests.cs b/Projects/UOContent.Tests/Tests/Items/BrittlePropertyTests.cs new file mode 100644 index 000000000..829d8b6a7 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/BrittlePropertyTests.cs @@ -0,0 +1,455 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Engines.Craft; +using Server.Items; +using Server.Misc; +using Server.Mobiles; +using Server.Text; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class BrittlePropertyTests +{ + private const int BrittleCliloc = 1116209; + + [Fact] + public void NegativeAttributes_StoresDupesAndSerializesBrittleOnSupportedFamilies() + { + AssertStoresAndDupesBrittle(new TestKatana(), new TestKatana()); + AssertStoresAndDupesBrittle(new LeatherChest(), new LeatherChest()); + AssertStoresAndDupesBrittle(new Buckler(), new Buckler()); + + AssertSerializesBrittle(new TestKatana(), new TestKatana()); + AssertSerializesBrittle(new LeatherChest(), new LeatherChest()); + AssertSerializesBrittle(new Buckler(), new Buckler()); + } + + [Fact] + public void NegativeAttributes_DoesNotStoreBrittleOnUnsupportedJewelry() + { + var ring = new GoldRing(); + + try + { + ring.NegativeAttributes.Brittle = 1; + + Assert.Equal(0, ring.NegativeAttributes.Brittle); + } + finally + { + ring.Delete(); + } + } + + [Fact] + public void GetProperties_GatesBrittleTooltipToHighSeasOnSupportedFamilies() + { + var previousExpansion = Core.Expansion; + var items = CreateSupportedItems(); + + try + { + foreach (var item in items) + { + GetNegativeAttributes(item).Brittle = 1; + + Core.Expansion = Expansion.SA; + var preHighSeas = new RecordingPropertyList(); + item.GetProperties(preHighSeas); + Assert.DoesNotContain(preHighSeas.Entries, entry => entry.Number == BrittleCliloc); + + Core.Expansion = Expansion.HS; + var highSeas = new RecordingPropertyList(); + item.GetProperties(highSeas); + Assert.Contains(highSeas.Entries, entry => entry.Number == BrittleCliloc); + } + } + finally + { + Core.Expansion = previousExpansion; + DeleteItems(items); + } + } + + [Fact] + public void PowderOfTemperament_FortifiesNonBrittleRepairableItem() + { + var previousExpansion = Core.Expansion; + var player = CreatePlayerMobile(new Point3D(6400, 500, 0)); + var weapon = CreateDamagedWeapon(); + var powder = new PowderOfTemperament(); + + try + { + Core.Expansion = Expansion.HS; + player.Backpack.AddItem(weapon); + player.Backpack.AddItem(powder); + + UsePowder(player, powder, weapon); + + Assert.Equal(9, powder.UsesRemaining); + Assert.True(weapon.MaxHitPoints > 100); + Assert.True(weapon.HitPoints > 50); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + powder.Delete(); + player.Delete(); + } + } + + [Fact] + public void PowderOfTemperament_RejectsBrittleWeaponAndArmorWithoutMutatingDurabilityOrCharges() + { + var previousExpansion = Core.Expansion; + var player = CreatePlayerMobile(new Point3D(6420, 500, 0)); + var weapon = CreateDamagedWeapon(); + var armor = CreateDamagedArmor(); + var shield = CreateDamagedShield(); + var powder = new PowderOfTemperament(); + + try + { + Core.Expansion = Expansion.HS; + player.Backpack.AddItem(weapon); + player.Backpack.AddItem(armor); + player.Backpack.AddItem(shield); + player.Backpack.AddItem(powder); + + weapon.NegativeAttributes.Brittle = 1; + armor.NegativeAttributes.Brittle = 1; + shield.NegativeAttributes.Brittle = 1; + + AssertPowderRejectsBrittle(player, powder, weapon); + AssertPowderRejectsBrittle(player, powder, armor); + AssertPowderRejectsBrittle(player, powder, shield); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + armor.Delete(); + shield.Delete(); + powder.Delete(); + player.Delete(); + } + } + + [Fact] + public void Brittle_IsNoOpBeforeHighSeasForPowderUse() + { + var previousExpansion = Core.Expansion; + var player = CreatePlayerMobile(new Point3D(6440, 500, 0)); + var weapon = CreateDamagedWeapon(); + var powder = new PowderOfTemperament(); + + try + { + Core.Expansion = Expansion.SA; + player.Backpack.AddItem(weapon); + player.Backpack.AddItem(powder); + weapon.NegativeAttributes.Brittle = 1; + + UsePowder(player, powder, weapon); + + Assert.False(NegativeAttributes.IsBrittle(weapon)); + Assert.Equal(9, powder.UsesRemaining); + Assert.True(weapon.MaxHitPoints > 100); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + powder.Delete(); + player.Delete(); + } + } + + [Fact] + public void RepairFlow_StillRepairsBrittleWeaponsArmorAndShields() + { + var previousExpansion = Core.Expansion; + using var random = new Server.Tests.PredictableRandom(0); + var player = CreatePlayerMobile(new Point3D(6460, 500, 0)); + var weapon = CreateDamagedWeapon(); + var armor = CreateDamagedArmor(); + var shield = CreateDamagedShield(); + var smithHammer = new SmithHammer(); + var sewingKit = new SewingKit(); + var anvil = new Anvil(); + var forge = new Forge(); + + try + { + Core.Expansion = Expansion.HS; + SkillCheck.Configure(); + SkillCheck.Initialize(); + player.SkillsCap = 7200; + player.Skills[SkillName.Blacksmith].Cap = 120.0; + player.Skills[SkillName.Tailoring].Cap = 120.0; + player.Skills[SkillName.Blacksmith].Base = 120.0; + player.Skills[SkillName.Tailoring].Base = 120.0; + player.Backpack.AddItem(weapon); + player.Backpack.AddItem(armor); + player.Backpack.AddItem(shield); + player.Backpack.AddItem(smithHammer); + player.Backpack.AddItem(sewingKit); + anvil.MoveToWorld(player.Location, player.Map); + forge.MoveToWorld(player.Location, player.Map); + + weapon.NegativeAttributes.Brittle = 1; + armor.NegativeAttributes.Brittle = 1; + shield.NegativeAttributes.Brittle = 1; + + RepairWithTool(player, GetOrInitBlacksmithySystem(), smithHammer, weapon); + RepairWithTool(player, GetOrInitTailoringSystem(), sewingKit, armor); + RepairWithTool(player, GetOrInitBlacksmithySystem(), smithHammer, shield); + + Assert.Equal(weapon.MaxHitPoints, weapon.HitPoints); + Assert.Equal(armor.MaxHitPoints, armor.HitPoints); + Assert.Equal(shield.MaxHitPoints, shield.HitPoints); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + armor.Delete(); + shield.Delete(); + smithHammer.Delete(); + sewingKit.Delete(); + anvil.Delete(); + forge.Delete(); + player.Delete(); + } + } + + [Fact] + public void RunicAttributeGeneration_DoesNotRollBrittle() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + var armor = new LeatherChest(); + var shield = new Buckler(); + + try + { + Core.Expansion = Expansion.HS; + + BaseRunicTool.ApplyAttributesTo(weapon, false, 0, 25, 100, 100); + BaseRunicTool.ApplyAttributesTo(armor, false, 0, 25, 100, 100); + BaseRunicTool.ApplyAttributesTo(shield, false, 0, 25, 100, 100); + + Assert.Equal(0, weapon.NegativeAttributes.Brittle); + Assert.Equal(0, armor.NegativeAttributes.Brittle); + Assert.Equal(0, shield.NegativeAttributes.Brittle); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + armor.Delete(); + shield.Delete(); + } + } + + private static void AssertStoresAndDupesBrittle(TItem source, TItem dupe) where TItem : Item + { + try + { + GetNegativeAttributes(source).Brittle = 1; + + source.Dupe(dupe); + + Assert.Equal(1, GetNegativeAttributes(source).Brittle); + Assert.Equal(1, GetNegativeAttributes(dupe).Brittle); + } + finally + { + source.Delete(); + dupe.Delete(); + } + } + + private static void AssertSerializesBrittle(TItem source, TItem deserialized) where TItem : Item + { + try + { + GetNegativeAttributes(source).Brittle = 1; + + var writer = new BufferWriter(true); + source.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(1, GetNegativeAttributes(deserialized).Brittle); + } + finally + { + source.Delete(); + deserialized.Delete(); + } + } + + private static void AssertPowderRejectsBrittle(Mobile player, PowderOfTemperament powder, TItem item) + where TItem : Item, IDurability + { + var originalUses = powder.UsesRemaining; + var originalMaxHitPoints = item.MaxHitPoints; + var originalHitPoints = item.HitPoints; + + UsePowder(player, powder, item); + + Assert.Equal(originalUses, powder.UsesRemaining); + Assert.Equal(originalMaxHitPoints, item.MaxHitPoints); + Assert.Equal(originalHitPoints, item.HitPoints); + } + + private static Katana CreateDamagedWeapon() + { + return new Katana + { + MaxHitPoints = 100, + HitPoints = 50 + }; + } + + private static LeatherChest CreateDamagedArmor() + { + return new LeatherChest + { + MaxHitPoints = 100, + HitPoints = 50 + }; + } + + private static Buckler CreateDamagedShield() + { + return new Buckler + { + MaxHitPoints = 100, + HitPoints = 50 + }; + } + + private static void UsePowder(Mobile player, PowderOfTemperament powder, Item item) + { + powder.OnDoubleClick(player); + Assert.NotNull(player.Target); + player.Target.Invoke(player, item); + } + + private static void RepairWithTool(Mobile player, CraftSystem craftSystem, BaseTool tool, Item item) + { + Repair.Do(player, craftSystem, tool); + Assert.NotNull(player.Target); + player.Target.Invoke(player, item); + } + + private static CraftSystem GetOrInitBlacksmithySystem() + { + if (DefBlacksmithy.CraftSystem == null) + { + DefBlacksmithy.Initialize(); + } + + return DefBlacksmithy.CraftSystem; + } + + private static CraftSystem GetOrInitTailoringSystem() + { + if (DefTailoring.CraftSystem == null) + { + DefTailoring.Initialize(); + } + + return DefTailoring.CraftSystem; + } + + private static PlayerMobile CreatePlayerMobile(Point3D location) + { + var mobile = new PlayerMobile(World.NewMobile); + mobile.DefaultMobileInit(); + mobile.MoveToWorld(location, Map.Felucca); + mobile.AddItem(new Backpack()); + return mobile; + } + + private static Item[] CreateSupportedItems() => + [ + new TestKatana(), + new LeatherChest(), + new Buckler() + ]; + + private static NegativeAttributes GetNegativeAttributes(Item item) => item switch + { + BaseWeapon weapon => weapon.NegativeAttributes, + BaseArmor armor => armor.NegativeAttributes, + _ => throw new ArgumentException($"Unsupported Brittle host: {item.GetType().FullName}", nameof(item)) + }; + + private static void DeleteItems(IEnumerable items) + { + foreach (var item in items) + { + item.Delete(); + } + } + + private class TestKatana : Katana + { + } + + 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/Special/Bulk Order Rewards/Blacksmithy/PowderOfTemperament.cs b/Projects/UOContent/Items/Special/Bulk Order Rewards/Blacksmithy/PowderOfTemperament.cs index 5a474ebe1..4f99ea957 100644 --- a/Projects/UOContent/Items/Special/Bulk Order Rewards/Blacksmithy/PowderOfTemperament.cs +++ b/Projects/UOContent/Items/Special/Bulk Order Rewards/Blacksmithy/PowderOfTemperament.cs @@ -86,6 +86,12 @@ public partial class PowderOfTemperament : Item, IUsesRemaining return; } + if (NegativeAttributes.IsBrittle(item)) + { + from.SendLocalizedMessage(1149799); // That cannot be used on brittle items. + return; + } + if (!item.IsChildOf(from.Backpack) && (!Core.ML || item.Parent != from) || !_powder.IsChildOf(from.Backpack)) { diff --git a/Projects/UOContent/Misc/AOS.cs b/Projects/UOContent/Misc/AOS.cs index ab5574025..17563e995 100644 --- a/Projects/UOContent/Misc/AOS.cs +++ b/Projects/UOContent/Misc/AOS.cs @@ -1205,7 +1205,8 @@ namespace Server public enum NegativeAttribute { Prized = 0x00000001, - Massive = 0x00000002 + Massive = 0x00000002, + Brittle = 0x00000004 } public sealed class NegativeAttributes : BaseAttributes @@ -1238,6 +1239,28 @@ namespace Server set => this[NegativeAttribute.Massive] = Owner is BaseWeapon or BaseArmor ? value : 0; } + [CommandProperty(AccessLevel.GameMaster)] + public int Brittle + { + get => Owner is BaseWeapon or BaseArmor ? this[NegativeAttribute.Brittle] : 0; + set => this[NegativeAttribute.Brittle] = Owner is BaseWeapon or BaseArmor ? value : 0; + } + + public static bool IsBrittle(Item item) + { + if (!Core.HS) + { + return false; + } + + return item switch + { + BaseWeapon weapon => weapon.NegativeAttributes.Brittle != 0, + BaseArmor armor => armor.NegativeAttributes.Brittle != 0, + _ => false + }; + } + public static bool IsMassive(Item item) { if (!Core.HS) @@ -1275,6 +1298,11 @@ namespace Server { list.Add(1154910); // Prized } + + if (Core.HS && Brittle != 0) + { + list.Add(1116209); // Brittle + } } public override string ToString() => "...";