feat(items): add Massive negative property

This commit is contained in:
Crome696 2026-07-09 08:29:26 +02:00
parent 48f616e2d5
commit 0a8bd69adb
5 changed files with 478 additions and 4 deletions

View file

@ -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>(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>(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<PropertyEntry> 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<char> argument) => Entries.Add(new PropertyEntry(0, argument.ToString()));
public void Add(int number, ReadOnlySpan<char> argument) => Entries.Add(new PropertyEntry(number, argument.ToString()));
public void AddChunked(ReadOnlySpan<char> 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>(T value) => _interpolated += value;
public void AppendFormatted<T>(T value, string format) =>
_interpolated += value is IFormattable formattable ? formattable.ToString(format, null) : value;
public void AppendFormatted<T>(T value, int alignment) => _interpolated += value;
public void AppendFormatted<T>(T value, int alignment, string format) =>
_interpolated += value is IFormattable formattable ? formattable.ToString(format, null) : value;
public void AppendFormatted(ReadOnlySpan<char> value) => _interpolated += value.ToString();
public void AppendFormatted(ReadOnlySpan<char> 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;
}
}

View file

@ -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,

View file

@ -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<BaseWeapon>())
@ -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)
{

View file

@ -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)

View file

@ -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;
}