diff --git a/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs b/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs new file mode 100644 index 000000000..05595068a --- /dev/null +++ b/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs @@ -0,0 +1,136 @@ +using Xunit; + +namespace Server.Tests; + +[Collection("Sequential Server Tests")] +public class PlayerConstructedStackingTests +{ + // PlayerConstructed is per-instance provenance, and stack operations were written when no + // item carried any. Merging keeps the receiver's copy of a field and splitting rebuilds one + // half from a fixed list of fields, so a flag that is not accounted for in both places is + // one that ordinary stacking can launder or erase. + + // Stands in for a real stackable type. LiftItemDupe builds the remainder through the + // parameterless constructor and copies only a fixed list of fields onto it -- Stackable is + // not on that list -- so the remainder is only stackable if the type restores it the way + // every genuine stackable does. + private class StackableItem : Item + { + public StackableItem() => Stackable = true; + + public StackableItem(Serial serial) : base(serial) => Stackable = true; + } + + private static StackableItem MakeStack(Serial serial, int amount, bool playerConstructed) => + new(serial) { Amount = amount, PlayerConstructed = playerConstructed }; + + [Fact] + public void CanStackWith_IsFalseWhenProvenanceDiffers() + { + var bought = MakeStack((Serial)0x1, 5, false); + var crafted = MakeStack((Serial)0x2, 5, true); + + try + { + // Both orders must fail. Whichever is the receiver decides the merged pile's flag, + // so allowing either one means the result is decided by drag direction. + Assert.False(bought.CanStackWith(crafted)); + Assert.False(crafted.CanStackWith(bought)); + } + finally + { + bought.Delete(); + crafted.Delete(); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CanStackWith_IsTrueWhenProvenanceMatches(bool playerConstructed) + { + var first = MakeStack((Serial)0x1, 5, playerConstructed); + var second = MakeStack((Serial)0x2, 7, playerConstructed); + + try + { + Assert.True(first.CanStackWith(second)); + } + finally + { + first.Delete(); + second.Delete(); + } + } + + [Fact] + public void StackWith_RefusesToMergeAcrossProvenance() + { + var bought = MakeStack((Serial)0x1, 5, false); + var crafted = MakeStack((Serial)0x2, 5, true); + + try + { + Assert.False(bought.StackWith(null, crafted, false)); + Assert.Equal(5, bought.Amount); + Assert.Equal(5, crafted.Amount); + Assert.False(bought.PlayerConstructed); + Assert.False(crafted.Deleted); + } + finally + { + bought.Delete(); + crafted.Delete(); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void LiftItemDupe_CopiesPlayerConstructedToRemainder(bool playerConstructed) + { + var stack = MakeStack((Serial)0x1, 10, playerConstructed); + Item remainder = null; + + try + { + remainder = Mobile.LiftItemDupe(stack, 4); + + Assert.NotNull(remainder); + Assert.NotSame(stack, remainder); + Assert.Equal(4, stack.Amount); + Assert.Equal(6, remainder.Amount); + Assert.Equal(playerConstructed, remainder.PlayerConstructed); + } + finally + { + stack.Delete(); + remainder?.Delete(); + } + } + + [Fact] + public void SplitHalvesRemainStackableWithEachOther() + { + // The two halves of a split must still be one pile's worth: if the split dropped the + // flag, the remainder would no longer stack back onto what it came from. + var stack = MakeStack((Serial)0x1, 10, true); + Item remainder = null; + + try + { + remainder = Mobile.LiftItemDupe(stack, 4); + Assert.NotNull(remainder); + + Assert.True(stack.CanStackWith(remainder)); + Assert.True(stack.StackWith(null, remainder, false)); + Assert.Equal(10, stack.Amount); + Assert.True(stack.PlayerConstructed); + } + finally + { + stack.Delete(); + remainder?.Delete(); + } + } +} diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs index 61e6f644f..889be4619 100644 --- a/Projects/Server/Items/Item.cs +++ b/Projects/Server/Items/Item.cs @@ -2350,6 +2350,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert dropped.ItemID == ItemID && dropped.Hue == Hue && dropped.Name == Name && + dropped.PlayerConstructed == PlayerConstructed && dropped.Amount + Amount <= 60000 && dropped != this; diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index e47f977a7..6755c244b 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -5248,6 +5248,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro item.Name = oldItem.Name; item.Weight = oldItem.Weight; + item.PlayerConstructed = oldItem.PlayerConstructed; item.Amount = oldAmount - amount; item.Map = oldItem.Map;