From e9d0d8538565b8f6c515ada1843ed0f86952bf2c Mon Sep 17 00:00:00 2001 From: Crome696 Date: Sat, 11 Jul 2026 09:05:59 +0200 Subject: [PATCH] feat(items): implement antique property --- .../Tests/Items/AntiquePropertyTests.cs | 210 ++++++++++++++++++ .../UOContent/Engines/Craft/Core/Enhance.cs | 5 + .../UOContent/Engines/Craft/Core/Repair.cs | 44 ++++ Projects/UOContent/Items/Jewels/BaseJewel.cs | 11 +- .../Blacksmithy/PowderOfTemperament.cs | 60 ++++- .../UOContent/Items/Weapons/BaseWeapon.cs | 1 + Projects/UOContent/Misc/AOS.cs | 78 ++++++- 7 files changed, 401 insertions(+), 8 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Items/AntiquePropertyTests.cs diff --git a/Projects/UOContent.Tests/Tests/Items/AntiquePropertyTests.cs b/Projects/UOContent.Tests/Tests/Items/AntiquePropertyTests.cs new file mode 100644 index 000000000..b4cb25851 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/AntiquePropertyTests.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Items; +using Server.Mobiles; +using Server.Text; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class AntiquePropertyTests +{ + [Fact] + public void NegativeAttribute_ReservesTheNextNonCollidingBitForAntique() + { + Assert.True(Enum.TryParse("Antique", out var antique)); + Assert.Equal(0x00000008, (int)antique); + } + + [Fact] + public void Antique_IsStoredAndDisplayedOnlyFromHighSeasOnEverySupportedHost() + { + var previousExpansion = Core.Expansion; + Item[] items = [new Katana(), new LeatherChest(), new Buckler(), new GoldRing()]; + + try + { + foreach (var item in items) + { + GetNegativeAttributes(item).Antique = 1; + + Core.Expansion = Expansion.SA; + var beforeHighSeas = new RecordingPropertyList(); + item.GetProperties(beforeHighSeas); + Assert.False(NegativeAttributes.IsAntique(item)); + Assert.DoesNotContain(beforeHighSeas.Entries, entry => entry.Number == 1076187); + + Core.Expansion = Expansion.HS; + var highSeas = new RecordingPropertyList(); + item.GetProperties(highSeas); + Assert.True(NegativeAttributes.IsAntique(item)); + Assert.Single(highSeas.Entries, entry => entry.Number == 1076187 && entry.Argument == string.Empty); + } + } + finally + { + Core.Expansion = previousExpansion; + + foreach (var item in items) + { + item.Delete(); + } + } + } + + [Fact] + public void Antique_EquippedHostsLoseOneDurabilityOnAnAcceptedMissButBackpackItemsDoNot() + { + var previousExpansion = Core.Expansion; + using var random = new Server.Tests.PredictableRandom(0); + var attacker = CreateMobile(new Point3D(6200, 500, 0)); + var defender = CreateMobile(new Point3D(6201, 500, 0)); + var weapon = new TestKatana { MaxHitPoints = 10, HitPoints = 5 }; + var armor = new LeatherChest { MaxHitPoints = 10, HitPoints = 5 }; + var shield = new Buckler { Layer = Layer.TwoHanded, MaxHitPoints = 10, HitPoints = 5 }; + var jewel = new GoldRing { MaxHitPoints = 10, HitPoints = 5 }; + var backpackWeapon = new Katana { MaxHitPoints = 10, HitPoints = 5 }; + + try + { + Core.Expansion = Expansion.HS; + weapon.NegativeAttributes.Antique = 1; + armor.NegativeAttributes.Antique = 1; + shield.NegativeAttributes.Antique = 1; + jewel.NegativeAttributes.Antique = 1; + backpackWeapon.NegativeAttributes.Antique = 1; + attacker.AddItem(weapon); + attacker.AddItem(armor); + attacker.AddItem(shield); + attacker.AddItem(jewel); + attacker.AddItem(new Backpack()); + attacker.Backpack.AddItem(backpackWeapon); + + weapon.OnSwing(attacker, defender); + + Assert.Equal(4, weapon.HitPoints); + Assert.Equal(4, armor.HitPoints); + Assert.Equal(4, shield.HitPoints); + Assert.Equal(4, jewel.HitPoints); + Assert.Equal(5, backpackWeapon.HitPoints); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + armor.Delete(); + shield.Delete(); + jewel.Delete(); + backpackWeapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void PowderOfTemperament_AntiqueWeaponAppliesTheFirstCapWithoutFreeRepair() + { + var previousExpansion = Core.Expansion; + var player = new PlayerMobile(World.NewMobile); + player.DefaultMobileInit(); + player.MoveToWorld(new Point3D(6200, 500, 0), Map.Felucca); + player.AddItem(new Backpack()); + var weapon = new Katana { MaxHitPoints = 245, HitPoints = 200 }; + var powder = new PowderOfTemperament(); + + try + { + Core.Expansion = Expansion.HS; + weapon.NegativeAttributes.Antique = 1; + Assert.True(NegativeAttributes.IsAntique(weapon)); + player.Backpack.AddItem(weapon); + player.Backpack.AddItem(powder); + + powder.OnDoubleClick(player); + player.Target.Invoke(player, weapon); + + Assert.Equal(250, weapon.MaxHitPoints); + Assert.Equal(200, weapon.HitPoints); + Assert.Equal(2, weapon.NegativeAttributes.Antique); + Assert.Equal(9, powder.UsesRemaining); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + powder.Delete(); + player.Delete(); + } + } + + private static Mobile CreateMobile(Point3D location) + { + var mobile = new Mobile(World.NewMobile); + mobile.DefaultMobileInit(); + mobile.MoveToWorld(location, Map.Felucca); + return mobile; + } + + private sealed class TestKatana : Katana + { + public override bool CheckHit(Mobile attacker, Mobile defender) => false; + public override int ComputeDamage(Mobile attacker, Mobile defender) => 1; + public override void AddBlood(Mobile attacker, Mobile defender, int damage) + { + } + } + + private static NegativeAttributes GetNegativeAttributes(Item item) => item switch + { + BaseWeapon weapon => weapon.NegativeAttributes, + BaseArmor armor => armor.NegativeAttributes, + BaseJewel jewel => jewel.NegativeAttributes, + _ => throw new ArgumentException($"Unsupported Antique host: {item.GetType().FullName}", nameof(item)) + }; + + 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/Engines/Craft/Core/Enhance.cs b/Projects/UOContent/Engines/Craft/Core/Enhance.cs index 44a78c854..0014c0e6e 100644 --- a/Projects/UOContent/Engines/Craft/Core/Enhance.cs +++ b/Projects/UOContent/Engines/Craft/Core/Enhance.cs @@ -40,6 +40,11 @@ namespace Server.Engines.Craft return EnhanceResult.BadItem; } + if (NegativeAttributes.IsAntique(item)) + { + return EnhanceResult.BadItem; + } + if (item is IArcaneEquip eq && eq.IsArcane) { return EnhanceResult.BadItem; diff --git a/Projects/UOContent/Engines/Craft/Core/Repair.cs b/Projects/UOContent/Engines/Craft/Core/Repair.cs index ed988c459..7e27ab2ba 100644 --- a/Projects/UOContent/Engines/Craft/Core/Repair.cs +++ b/Projects/UOContent/Engines/Craft/Core/Repair.cs @@ -377,6 +377,50 @@ namespace Server.Engines.Craft toDelete = true; } } + else if (targeted is BaseJewel jewel) + { + var skill = m_CraftSystem.MainSkill; + const int toWeaken = 1; + + if (!NegativeAttributes.IsAntique(jewel) || m_CraftSystem.CraftItems.SearchForSubclass(jewel.GetType()) == null) + { + number = usingDeed ? 1061136 : 1044277; // That item cannot be repaired. + } + else if (!jewel.IsChildOf(from.Backpack) && (!Core.ML || jewel.Parent != from)) + { + number = 1044275; // The item must be in your backpack to repair it. + } + else if (jewel.MaxHitPoints <= 0 || jewel.HitPoints == jewel.MaxHitPoints) + { + number = 1044281; // That item is in full repair + } + else if (jewel.MaxHitPoints <= toWeaken) + { + number = 1044278; // That item has been repaired many times, and will break if repairs are attempted again. + } + else + { + if (CheckWeaken(from, skill, jewel.HitPoints, jewel.MaxHitPoints)) + { + jewel.MaxHitPoints -= toWeaken; + jewel.HitPoints = Math.Max(0, jewel.HitPoints - toWeaken); + } + + if (CheckRepairDifficulty(from, skill, jewel.HitPoints, jewel.MaxHitPoints)) + { + number = 1044279; // You repair the item. + m_CraftSystem.PlayCraftEffect(from); + jewel.HitPoints = jewel.MaxHitPoints; + } + else + { + number = usingDeed ? 1061137 : 1044280; // You fail to repair the item. + m_CraftSystem.PlayCraftEffect(from); + } + + toDelete = true; + } + } else if (targeted is BaseClothing clothing) { var skill = m_CraftSystem.MainSkill; diff --git a/Projects/UOContent/Items/Jewels/BaseJewel.cs b/Projects/UOContent/Items/Jewels/BaseJewel.cs index cff85e125..9ff9d2d05 100644 --- a/Projects/UOContent/Items/Jewels/BaseJewel.cs +++ b/Projects/UOContent/Items/Jewels/BaseJewel.cs @@ -19,7 +19,7 @@ public enum GemType } [SerializationGenerator(6, false)] -public abstract partial class BaseJewel : Item, ICraftable, IAosItem +public abstract partial class BaseJewel : Item, ICraftable, IAosItem, IDurability { [EncodedInt] [InvalidateProperties] @@ -123,6 +123,15 @@ public abstract partial class BaseJewel : Item, ICraftable, IAosItem public virtual int InitMinHits => 0; public virtual int InitMaxHits => 0; + public virtual bool CanFortify => false; + + public void UnscaleDurability() + { + } + + public void ScaleDurability() + { + } public override int LabelNumber { 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 4f99ea957..d694d9e67 100644 --- a/Projects/UOContent/Items/Special/Bulk Order Rewards/Blacksmithy/PowderOfTemperament.cs +++ b/Projects/UOContent/Items/Special/Bulk Order Rewards/Blacksmithy/PowderOfTemperament.cs @@ -1,3 +1,4 @@ +using System; using ModernUO.Serialization; using Server.Network; using Server.Targeting; @@ -80,18 +81,49 @@ public partial class PowderOfTemperament : Item, IUsesRemaining return; } - if (!wearable.CanFortify) - { - from.SendLocalizedMessage(1049083); // You cannot use the powder on that item. - return; - } - if (NegativeAttributes.IsBrittle(item)) { from.SendLocalizedMessage(1149799); // That cannot be used on brittle items. return; } + if (NegativeAttributes.IsAntique(item)) + { + if (item is BaseJewel || GetAntiqueMaximumDurability(item) is not { } maximumDurability) + { + from.SendLocalizedMessage(1049083); // You cannot use the powder on that item. + return; + } + + if (!item.IsChildOf(from.Backpack) && (!Core.ML || item.Parent != from) || + !_powder.IsChildOf(from.Backpack)) + { + from.SendLocalizedMessage(1042001); // That must be in your pack for you to use it. + return; + } + + wearable.MaxHitPoints = maximumDurability; + wearable.HitPoints = Math.Min(wearable.HitPoints, maximumDurability); + GetNegativeAttributes(item).Antique++; + from.SendLocalizedMessage(1049084); // You successfully use the powder on the item. + from.PlaySound(0x247); + --_powder.UsesRemaining; + + if (_powder.UsesRemaining <= 0) + { + from.SendLocalizedMessage(1049086); // You have used up your powder of fortifying. + _powder.Delete(); + } + + return; + } + + if (!wearable.CanFortify) + { + from.SendLocalizedMessage(1049083); // You cannot use the powder on that item. + return; + } + if (!item.IsChildOf(from.Backpack) && (!Core.ML || item.Parent != from) || !_powder.IsChildOf(from.Backpack)) { @@ -161,5 +193,21 @@ public partial class PowderOfTemperament : Item, IUsesRemaining from.SendLocalizedMessage(1049085); // The item cannot be improved any further. } } + + private static int? GetAntiqueMaximumDurability(Item item) => GetNegativeAttributes(item).Antique switch + { + 1 => 250, + 2 => 200, + 3 => 150, + _ => null + }; + + private static NegativeAttributes GetNegativeAttributes(Item item) => item switch + { + BaseWeapon weapon => weapon.NegativeAttributes, + BaseArmor armor => armor.NegativeAttributes, + BaseJewel jewel => jewel.NegativeAttributes, + _ => throw new ArgumentException($"Unsupported Antique host: {item.GetType().FullName}", nameof(item)) + }; } } diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.cs index 453ce5dc3..8e78db4c8 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.cs @@ -933,6 +933,7 @@ public abstract partial class BaseWeapon attacker.DisruptiveAction(); attacker.NetState?.SendSwing(attacker.Serial, defender.Serial); + NegativeAttributes.ApplyAntiqueWear(attacker); if (attacker is BaseCreature bc) { diff --git a/Projects/UOContent/Misc/AOS.cs b/Projects/UOContent/Misc/AOS.cs index 165106294..fd5d8e6d8 100644 --- a/Projects/UOContent/Misc/AOS.cs +++ b/Projects/UOContent/Misc/AOS.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using ModernUO.Serialization; +using Server.Collections; using Server.Items; using Server.Mobiles; using Server.Spells; @@ -1561,7 +1562,8 @@ namespace Server { Prized = 0x00000001, Massive = 0x00000002, - Brittle = 0x00000004 + Brittle = 0x00000004, + Antique = 0x00000008 } public sealed class NegativeAttributes : BaseAttributes @@ -1594,6 +1596,13 @@ namespace Server set => this[NegativeAttribute.Massive] = Owner is BaseWeapon or BaseArmor ? value : 0; } + [CommandProperty(AccessLevel.GameMaster)] + public int Antique + { + get => Owner is BaseWeapon or BaseArmor or BaseJewel ? this[NegativeAttribute.Antique] : 0; + set => this[NegativeAttribute.Antique] = Owner is BaseWeapon or BaseArmor or BaseJewel ? value : 0; + } + [CommandProperty(AccessLevel.GameMaster)] public int Brittle { @@ -1601,6 +1610,68 @@ namespace Server set => this[NegativeAttribute.Brittle] = Owner is BaseWeapon or BaseArmor ? value : 0; } + public static bool IsAntique(Item item) + { + if (!Core.HS) + { + return false; + } + + return item switch + { + BaseWeapon weapon => weapon.NegativeAttributes.Antique != 0, + BaseArmor armor => armor.NegativeAttributes.Antique != 0, + BaseJewel jewel => jewel.NegativeAttributes.Antique != 0, + _ => false + }; + } + + public static void ApplyAntiqueWear(Mobile wearer) + { + if (!wearer.Alive || !Core.HS) + { + return; + } + + using var equipped = PooledRefList.Create(wearer.Items.Count); + + foreach (var item in wearer.Items) + { + equipped.Add(item); + } + + foreach (var item in equipped) + { + ApplyAntiqueWear(item); + } + } + + private static void ApplyAntiqueWear(Item item) + { + if (!IsAntique(item) || Utility.Random(100) >= 2 || item is not IDurability durability) + { + return; + } + + if (durability.HitPoints > 0) + { + --durability.HitPoints; + } + else if (durability.MaxHitPoints > 1) + { + --durability.MaxHitPoints; + + if (item.Parent is Mobile mobile) + { + mobile.LocalOverheadMessage(MessageType.Regular, 0x3B2, 1061121); + } + } + else + { + item.Delete(); + } + } + public static bool IsBrittle(Item item) { if (!Core.HS) @@ -1649,6 +1720,11 @@ namespace Server public void GetProperties(IPropertyList list) { + if (Core.HS && Antique != 0) + { + list.Add(1076187); // Antique + } + if (Core.HS && Prized != 0) { list.Add(1154910); // Prized