From 0a8bd69adb61cce54dfa591968b8d6ca079e89ee Mon Sep 17 00:00:00 2001 From: Crome696 Date: Thu, 9 Jul 2026 08:29:26 +0200 Subject: [PATCH] feat(items): add Massive negative property --- .../Tests/Items/MassivePropertyTests.cs | 440 ++++++++++++++++++ Projects/UOContent/Items/Armor/BaseArmor.cs | 5 + .../UOContent/Items/Weapons/BaseWeapon.cs | 8 +- Projects/UOContent/Misc/AOS.cs | 27 +- Projects/UOContent/Mobiles/PlayerMobile.cs | 2 +- 5 files changed, 478 insertions(+), 4 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Items/MassivePropertyTests.cs diff --git a/Projects/UOContent.Tests/Tests/Items/MassivePropertyTests.cs b/Projects/UOContent.Tests/Tests/Items/MassivePropertyTests.cs new file mode 100644 index 000000000..a1d759925 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/MassivePropertyTests.cs @@ -0,0 +1,440 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Server; +using Server.Items; +using Server.Mobiles; +using Server.Text; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class MassivePropertyTests +{ + private const int StrengthRequirementCliloc = 1061170; + private const int LowerRequirementsCliloc = 1060435; + + [Fact] + public void NegativeAttributes_StoresDupesAndSerializesMassiveOnSupportedFamilies() + { + AssertStoresAndDupesMassive(new TestKatana(), new TestKatana()); + AssertStoresAndDupesMassive(new LeatherChest(), new LeatherChest()); + AssertStoresAndDupesMassive(new Buckler(), new Buckler()); + + AssertSerializesMassive(new TestKatana(), new TestKatana()); + AssertSerializesMassive(new LeatherChest(), new LeatherChest()); + AssertSerializesMassive(new Buckler(), new Buckler()); + } + + [Fact] + public void NegativeAttributes_DoesNotStoreMassiveOnUnsupportedJewelry() + { + var ring = new GoldRing(); + + try + { + ring.NegativeAttributes.Massive = 1; + + Assert.Equal(0, ring.NegativeAttributes.Massive); + } + finally + { + ring.Delete(); + } + } + + [Fact] + public void Massive_RequiresAndDisplaysStrengthRequirement125InHighSeas() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana { StrRequirement = 10 }; + var armor = new LeatherChest { StrRequirement = 10 }; + var shield = new Buckler { StrRequirement = 10 }; + + try + { + Core.Expansion = Expansion.HS; + + weapon.NegativeAttributes.Massive = 1; + armor.NegativeAttributes.Massive = 1; + shield.NegativeAttributes.Massive = 1; + + Assert.Equal(AOS.MassiveStrengthRequirement, weapon.ComputeStrengthRequirement()); + Assert.Equal(AOS.MassiveStrengthRequirement, armor.ComputeStatReq(StatType.Str)); + Assert.Equal(AOS.MassiveStrengthRequirement, shield.ComputeStatReq(StatType.Str)); + + AssertShowsStrengthRequirement125WithoutMassiveRow(weapon); + AssertShowsStrengthRequirement125WithoutMassiveRow(armor); + AssertShowsStrengthRequirement125WithoutMassiveRow(shield); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + armor.Delete(); + shield.Delete(); + } + } + + [Fact] + public void Massive_BypassesLowerRequirementsForStrengthOnlyInHighSeas() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana { StrRequirement = 10 }; + var armor = new LeatherChest { StrRequirement = 10, DexRequirement = 10, IntRequirement = 10 }; + var shield = new Buckler { StrRequirement = 10, DexRequirement = 10, IntRequirement = 10 }; + + try + { + Core.Expansion = Expansion.HS; + + weapon.NegativeAttributes.Massive = 1; + weapon.WeaponAttributes.LowerStatReq = 100; + + armor.NegativeAttributes.Massive = 1; + armor.ArmorAttributes.LowerStatReq = 100; + + shield.NegativeAttributes.Massive = 1; + shield.ArmorAttributes.LowerStatReq = 100; + + Assert.Equal(AOS.MassiveStrengthRequirement, weapon.ComputeStrengthRequirement()); + Assert.Equal(AOS.MassiveStrengthRequirement, armor.ComputeStatReq(StatType.Str)); + Assert.Equal(AOS.MassiveStrengthRequirement, shield.ComputeStatReq(StatType.Str)); + Assert.Equal(0, armor.ComputeStatReq(StatType.Dex)); + Assert.Equal(0, armor.ComputeStatReq(StatType.Int)); + Assert.Equal(0, shield.ComputeStatReq(StatType.Dex)); + Assert.Equal(0, shield.ComputeStatReq(StatType.Int)); + + AssertShowsStrengthAndLowerRequirements(weapon); + AssertShowsStrengthAndLowerRequirements(armor); + AssertShowsStrengthAndLowerRequirements(shield); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + armor.Delete(); + shield.Delete(); + } + } + + [Fact] + public void Massive_EquipChecksUseUnscaledStrengthRequirement125InHighSeas() + { + var previousExpansion = Core.Expansion; + var weak = CreateMobile(str: 124); + var strong = CreateMobile(str: 125); + var weakWeapon = CreateMassiveWeapon(); + var strongWeapon = CreateMassiveWeapon(); + var weakArmor = CreateMassiveArmor(); + var strongArmor = CreateMassiveArmor(); + var weakShield = CreateMassiveShield(); + var strongShield = CreateMassiveShield(); + + try + { + Core.Expansion = Expansion.HS; + + Assert.False(weakWeapon.CanEquip(weak)); + Assert.True(strongWeapon.CanEquip(strong)); + Assert.False(weakArmor.CanEquip(weak)); + Assert.True(strongArmor.CanEquip(strong)); + Assert.False(weakShield.CanEquip(weak)); + Assert.True(strongShield.CanEquip(strong)); + } + finally + { + Core.Expansion = previousExpansion; + weak.Delete(); + strong.Delete(); + weakWeapon.Delete(); + strongWeapon.Delete(); + weakArmor.Delete(); + strongArmor.Delete(); + weakShield.Delete(); + strongShield.Delete(); + } + } + + [Fact] + public void Massive_IsNoOpBeforeHighSeas() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana { StrRequirement = 10 }; + var armor = new LeatherChest { StrRequirement = 10 }; + var mobile = CreateMobile(str: 10); + + try + { + Core.Expansion = Expansion.SA; + + weapon.NegativeAttributes.Massive = 1; + armor.NegativeAttributes.Massive = 1; + + Assert.False(NegativeAttributes.IsMassive(weapon)); + Assert.False(NegativeAttributes.IsMassive(armor)); + Assert.Equal(10, weapon.ComputeStrengthRequirement()); + Assert.Equal(10, armor.ComputeStatReq(StatType.Str)); + Assert.True(weapon.CanEquip(mobile)); + Assert.True(armor.CanEquip(mobile)); + + var weaponProperties = new RecordingPropertyList(); + weapon.GetProperties(weaponProperties); + Assert.Contains(weaponProperties.Entries, entry => entry.Number == StrengthRequirementCliloc && entry.Argument == "10"); + + var armorProperties = new RecordingPropertyList(); + armor.GetProperties(armorProperties); + Assert.Contains(armorProperties.Entries, entry => entry.Number == StrengthRequirementCliloc && entry.Argument == "10"); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + armor.Delete(); + mobile.Delete(); + } + } + + [Fact] + public void ValidateEquipment_DropsEquippedMassiveItemsWhenStrengthFallsBelow125() + { + var previousExpansion = Core.Expansion; + var player = CreatePlayerMobile(str: 125, new Point3D(6300, 500, 0)); + var weapon = CreateMassiveWeapon(); + var armor = CreateMassiveArmor(); + var shield = CreateMassiveShield(); + + try + { + Core.Expansion = Expansion.HS; + + Assert.True(player.EquipItem(weapon)); + Assert.True(player.EquipItem(armor)); + Assert.True(player.EquipItem(shield)); + + player.RawStr = 124; + InvokeValidateEquipmentSandbox(player); + + Assert.Same(player.Backpack, weapon.Parent); + Assert.Same(player.Backpack, armor.Parent); + Assert.Same(player.Backpack, shield.Parent); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + armor.Delete(); + shield.Delete(); + player.Delete(); + } + } + + [Fact] + public void RunicAttributeGeneration_DoesNotRollMassive() + { + 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.Massive); + Assert.Equal(0, armor.NegativeAttributes.Massive); + Assert.Equal(0, shield.NegativeAttributes.Massive); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + armor.Delete(); + shield.Delete(); + } + } + + private static TestKatana CreateMassiveWeapon() + { + var weapon = new TestKatana { StrRequirement = 10 }; + weapon.NegativeAttributes.Massive = 1; + weapon.WeaponAttributes.LowerStatReq = 100; + return weapon; + } + + private static LeatherChest CreateMassiveArmor() + { + var armor = new LeatherChest { StrRequirement = 10 }; + armor.NegativeAttributes.Massive = 1; + armor.ArmorAttributes.LowerStatReq = 100; + return armor; + } + + private static Buckler CreateMassiveShield() + { + var shield = new Buckler { StrRequirement = 10 }; + shield.NegativeAttributes.Massive = 1; + shield.ArmorAttributes.LowerStatReq = 100; + return shield; + } + + private static void AssertStoresAndDupesMassive(TItem source, TItem dupe) where TItem : Item + { + try + { + GetNegativeAttributes(source).Massive = 1; + + source.Dupe(dupe); + + Assert.Equal(1, GetNegativeAttributes(source).Massive); + Assert.Equal(1, GetNegativeAttributes(dupe).Massive); + } + finally + { + source.Delete(); + dupe.Delete(); + } + } + + private static void AssertSerializesMassive(TItem source, TItem deserialized) where TItem : Item + { + try + { + GetNegativeAttributes(source).Massive = 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).Massive); + } + finally + { + source.Delete(); + deserialized.Delete(); + } + } + + private static void AssertShowsStrengthRequirement125WithoutMassiveRow(Item item) + { + var properties = new RecordingPropertyList(); + item.GetProperties(properties); + + Assert.Contains(properties.Entries, entry => + entry.Number == StrengthRequirementCliloc && entry.Argument == AOS.MassiveStrengthRequirement.ToString()); + Assert.DoesNotContain(properties.Entries, entry => entry.Argument.Contains("Massive", StringComparison.OrdinalIgnoreCase)); + } + + private static void AssertShowsStrengthAndLowerRequirements(Item item) + { + var properties = new RecordingPropertyList(); + item.GetProperties(properties); + + Assert.Contains(properties.Entries, entry => + entry.Number == StrengthRequirementCliloc && entry.Argument == AOS.MassiveStrengthRequirement.ToString()); + Assert.Contains(properties.Entries, entry => entry.Number == LowerRequirementsCliloc && entry.Argument == "100"); + Assert.DoesNotContain(properties.Entries, entry => entry.Argument.Contains("Massive", StringComparison.OrdinalIgnoreCase)); + } + + private static NegativeAttributes GetNegativeAttributes(Item item) => item switch + { + BaseWeapon weapon => weapon.NegativeAttributes, + BaseArmor armor => armor.NegativeAttributes, + _ => throw new ArgumentException($"Unsupported Massive host: {item.GetType().FullName}", nameof(item)) + }; + + private static Mobile CreateMobile(int str) + { + var mobile = new Mobile(World.NewMobile) + { + Player = true + }; + + mobile.DefaultMobileInit(); + mobile.RawStr = str; + mobile.RawDex = 125; + mobile.RawInt = 125; + return mobile; + } + + private static PlayerMobile CreatePlayerMobile(int str, Point3D location) + { + var mobile = new PlayerMobile(World.NewMobile); + mobile.DefaultMobileInit(); + mobile.RawStr = str; + mobile.RawDex = 125; + mobile.RawInt = 125; + mobile.MoveToWorld(location, Map.Felucca); + mobile.AddItem(new Backpack()); + return mobile; + } + + private static void InvokeValidateEquipmentSandbox(PlayerMobile mobile) + { + var method = typeof(PlayerMobile).GetMethod( + "ValidateEquipment_Sandbox", + BindingFlags.Instance | BindingFlags.NonPublic + ); + + Assert.NotNull(method); + method.Invoke(mobile, null); + } + + 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/Armor/BaseArmor.cs b/Projects/UOContent/Items/Armor/BaseArmor.cs index 3341b2961..1e6197756 100644 --- a/Projects/UOContent/Items/Armor/BaseArmor.cs +++ b/Projects/UOContent/Items/Armor/BaseArmor.cs @@ -838,6 +838,11 @@ namespace Server.Items public int ComputeStatReq(StatType type) { + if (type == StatType.Str && NegativeAttributes.IsMassive(this)) + { + return AOS.MassiveStrengthRequirement; + } + var v = type switch { StatType.Str => StrRequirement, diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.cs index 56be79748..62842762f 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.cs @@ -1016,6 +1016,10 @@ public abstract partial class BaseWeapon return v; } + public int ComputeStrengthRequirement() => NegativeAttributes.IsMassive(this) + ? AOS.MassiveStrengthRequirement + : AOS.Scale(StrRequirement, 100 - GetLowerStatReq()); + public static void BlockEquip(Mobile m, TimeSpan duration) { if (m.BeginAction()) @@ -1074,7 +1078,7 @@ public abstract partial class BaseWeapon return false; } - if (from.Str < AOS.Scale(StrRequirement, 100 - GetLowerStatReq())) + if (from.Str < ComputeStrengthRequirement()) { from.SendLocalizedMessage(500213); // You are not strong enough to equip that. return false; @@ -3167,7 +3171,7 @@ public abstract partial class BaseWeapon list.Add(1061169, MaxRange); // range ~1_val~ } - var strReq = AOS.Scale(StrRequirement, 100 - GetLowerStatReq()); + var strReq = ComputeStrengthRequirement(); if (strReq > 0) { diff --git a/Projects/UOContent/Misc/AOS.cs b/Projects/UOContent/Misc/AOS.cs index cf07a1b8f..ab5574025 100644 --- a/Projects/UOContent/Misc/AOS.cs +++ b/Projects/UOContent/Misc/AOS.cs @@ -12,6 +12,8 @@ namespace Server { public static class AOS { + public const int MassiveStrengthRequirement = 125; + public static void DisableStatInfluences() { for (var i = 0; i < SkillInfo.Table.Length; ++i) @@ -1202,7 +1204,8 @@ namespace Server [Flags] public enum NegativeAttribute { - Prized = 0x00000001 + Prized = 0x00000001, + Massive = 0x00000002 } public sealed class NegativeAttributes : BaseAttributes @@ -1228,6 +1231,28 @@ namespace Server set => this[NegativeAttribute.Prized] = value; } + [CommandProperty(AccessLevel.GameMaster)] + public int Massive + { + get => Owner is BaseWeapon or BaseArmor ? this[NegativeAttribute.Massive] : 0; + set => this[NegativeAttribute.Massive] = Owner is BaseWeapon or BaseArmor ? value : 0; + } + + public static bool IsMassive(Item item) + { + if (!Core.HS) + { + return false; + } + + return item switch + { + BaseWeapon weapon => weapon.NegativeAttributes.Massive != 0, + BaseArmor armor => armor.NegativeAttributes.Massive != 0, + _ => false + }; + } + public static bool IsPrized(Item item) { if (!Core.HS) diff --git a/Projects/UOContent/Mobiles/PlayerMobile.cs b/Projects/UOContent/Mobiles/PlayerMobile.cs index 55927fb44..fb00baa88 100644 --- a/Projects/UOContent/Mobiles/PlayerMobile.cs +++ b/Projects/UOContent/Mobiles/PlayerMobile.cs @@ -1361,7 +1361,7 @@ namespace Server.Mobiles { drop = true; } - else if (str < AOS.Scale(weapon.StrRequirement, 100 - weapon.GetLowerStatReq())) + else if (str < weapon.ComputeStrengthRequirement()) { drop = true; }