diff --git a/Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs b/Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs new file mode 100644 index 000000000..cc0ccad42 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs @@ -0,0 +1,111 @@ +using Server.Items; +using Xunit; + +namespace Server.Tests.Items; + +[Collection("Sequential UOContent Tests")] +public class VendorBuybackPackTests +{ + [Fact] + public void AddBuyback_EvictsOldestFirst_WhenFull() + { + var pack = new VendorBuybackPack(); + var items = new Item[5]; + + try + { + for (var i = 0; i < items.Length; i++) + { + items[i] = new Item(0x1234); + pack.AddBuyback(items[i], 3); + } + + Assert.Equal(3, pack.Items.Count); + Assert.True(items[0].Deleted); + Assert.True(items[1].Deleted); + Assert.Same(items[2], pack.Items[0]); + Assert.Same(items[3], pack.Items[1]); + Assert.Same(items[4], pack.Items[2]); + } + finally + { + pack.Delete(); + } + } + + [Fact] + public void AddBuyback_WithNoCapacity_ConsumesTheItem() + { + var pack = new VendorBuybackPack(); + var item = new Item(0x1234); + + try + { + pack.AddBuyback(item, 0); + + Assert.True(item.Deleted); + Assert.Empty(pack.Items); + } + finally + { + pack.Delete(); + } + } + + // DropItem rejects deleted items, so the stale entry is forced into Items directly. + [Fact] + public void AddBuyback_SkipsAlreadyDeletedEntry_WithoutSpinning() + { + var pack = new VendorBuybackPack(); + var real1 = new Item(0x1234); + var stale = new Item(0x1234); + var real2 = new Item(0x1234); + + try + { + pack.AddBuyback(real1, 100); + + stale.Delete(); + pack.Items.Insert(0, stale); + + pack.AddBuyback(real2, 1); + + Assert.True(real1.Deleted); + Assert.True(stale.Deleted); + Assert.Single(pack.Items); + Assert.Same(real2, pack.Items[0]); + } + finally + { + pack.Delete(); + } + } + + [Fact] + public void Purge_DeletesEveryChild() + { + var pack = new VendorBuybackPack(); + var items = new Item[10]; + + for (var i = 0; i < items.Length; i++) + { + items[i] = new Item(0x1234); + pack.AddBuyback(items[i], 250); + } + + try + { + pack.Purge(); + + Assert.Empty(pack.Items); + for (var i = 0; i < items.Length; i++) + { + Assert.True(items[i].Deleted); + } + } + finally + { + pack.Delete(); + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs new file mode 100644 index 000000000..f61ca6229 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using Server.Items; +using Server.Mobiles; +using Xunit; + +namespace Server.Tests.Mobiles; + +[Collection("Sequential UOContent Tests")] +public class VendorBuybackTests : IDisposable +{ + private readonly List _created = []; + + public void Dispose() + { + for (var i = 0; i < _created.Count; i++) + { + _created[i].Delete(); + } + } + + private sealed class BandageSB : SBInfo + { + private readonly GenericSellInfo _sellInfo = new(); + + public BandageSB() => _sellInfo.Add(typeof(Bandage), 2); + + public override IShopSellInfo SellInfo => _sellInfo; + + public override List BuyInfo { get; } = []; + } + + private sealed class VendorStub : BaseVendor + { + private readonly List _sbInfos = []; + + public VendorStub() : base("the stub") + { + } + + public VendorStub(Serial serial) : base(serial) + { + } + + protected override List SBInfos => _sbInfos; + + public override void InitSBInfo() + { + _sbInfos.Add(new BandageSB()); + } + + public override void InitOutfit() + { + } + + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.3; + passiveSpeed = 0.6; + } + } + + // Same sell price as BandageSB, but a buy list at the 250-entry limit so buyback capacity is 0. + private sealed class FullStockSB : SBInfo + { + private readonly GenericSellInfo _sellInfo = new(); + private readonly List _buyInfo = []; + + public FullStockSB() + { + _sellInfo.Add(typeof(Bandage), 2); + + for (var i = 0; i < 250; i++) + { + _buyInfo.Add(new GenericBuyInfo(typeof(Bandage), 5, 20, 0xE21, 0)); + } + } + + public override IShopSellInfo SellInfo => _sellInfo; + + public override List BuyInfo => _buyInfo; + } + + private sealed class FullStockVendorStub : BaseVendor + { + private readonly List _sbInfos = []; + + public FullStockVendorStub() : base("the stub") + { + } + + protected override List SBInfos => _sbInfos; + + public override void InitSBInfo() + { + _sbInfos.Add(new FullStockSB()); + } + + public override void InitOutfit() + { + } + + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.3; + passiveSpeed = 0.6; + } + } + + private VendorStub NewVendor() + { + var vendor = new VendorStub(); + _created.Add(vendor); + return vendor; + } + + private FullStockVendorStub NewFullStockVendor() + { + var vendor = new FullStockVendorStub(); + _created.Add(vendor); + return vendor; + } + + private Mobile NewSeller() + { + var seller = new Mobile(World.NewMobile); + seller.DefaultMobileInit(); + seller.AddItem(new Backpack()); + _created.Add(seller); + return seller; + } + + [Theory] + [InlineData(0.25, 1.0)] // restocked 15m ago: next boundary is the regular one + [InlineData(5.0, 6.0)] // exactly on a boundary: strictly after now + [InlineData(5.5, 6.0)] // lazy restock hasn't run for hours: stay on the grid, don't fire now + public void NextBuybackPurge_IsTheNextRestockBoundaryAfterNow(double hoursSinceRestock, double expectedHours) + { + var lastRestock = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var now = lastRestock + TimeSpan.FromHours(hoursSinceRestock); + + var next = BaseVendor.GetNextBuybackPurge(lastRestock, TimeSpan.FromHours(1), now); + + Assert.Equal(lastRestock + TimeSpan.FromHours(expectedHours), next); + } + + // Backwards clock adjustment: LastRestock ahead of now clamps to now. + [Fact] + public void NextBuybackPurge_FutureAnchor_IsClampedToNow() + { + var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var lastRestock = now + TimeSpan.FromHours(4); + + var next = BaseVendor.GetNextBuybackPurge(lastRestock, TimeSpan.FromHours(1), now); + + Assert.Equal(now + TimeSpan.FromHours(1), next); + } + + [Fact] + public void PartialStackSale_SplitGoesToBuyback_SellerKeepsRemainder() + { + var vendor = NewVendor(); + var seller = NewSeller(); + var bandages = new Bandage(10); + seller.Backpack.DropItem(bandages); + + Assert.True(vendor.OnSellItems(seller, [new SellItemResponse(bandages, 4)])); + + Assert.Equal(6, bandages.Amount); + Assert.Same(seller.Backpack, bandages.Parent); + + var buyback = vendor.BuyPack.Items; + Assert.Single(buyback); + Assert.IsType(buyback[0]); + Assert.Equal(4, buyback[0].Amount); + Assert.True(buyback[0].SkipSerialization); + Assert.True(vendor.BuybackPurgeScheduled); + } + + [Fact] + public void Restock_PurgesBuyback() + { + var vendor = NewVendor(); + var seller = NewSeller(); + var bandages = new Bandage(10); + seller.Backpack.DropItem(bandages); + vendor.OnSellItems(seller, [new SellItemResponse(bandages, 10)]); + Assert.Single(vendor.BuyPack.Items); + + vendor.Restock(); + + Assert.Empty(vendor.BuyPack.Items); + Assert.True(bandages.Deleted); + } + + [Fact] + public void Restock_ClearsPurgeSchedule_AndASubsequentSaleRearmsIt() + { + var vendor = NewVendor(); + var seller = NewSeller(); + var bandages = new Bandage(10); + seller.Backpack.DropItem(bandages); + vendor.OnSellItems(seller, [new SellItemResponse(bandages, 10)]); + Assert.True(vendor.BuybackPurgeScheduled); + + vendor.Restock(); + + Assert.False(vendor.BuybackPurgeScheduled); + + var seller2 = NewSeller(); + var moreBandages = new Bandage(10); + seller2.Backpack.DropItem(moreBandages); + vendor.OnSellItems(seller2, [new SellItemResponse(moreBandages, 10)]); + + Assert.True(vendor.BuybackPurgeScheduled); + } + + [Fact] + public void Delete_CancelsBuybackPurgeTimer() + { + var vendor = NewVendor(); + var seller = NewSeller(); + var bandages = new Bandage(10); + seller.Backpack.DropItem(bandages); + vendor.OnSellItems(seller, [new SellItemResponse(bandages, 10)]); + Assert.True(vendor.BuybackPurgeScheduled); + + vendor.Delete(); + + Assert.False(vendor.BuybackPurgeScheduled); + } + + [Fact] + public void LegacyBuyPack_IsReplaced_AndItsContentsDeleted() + { + var vendor = NewVendor(); + vendor.FindItemOnLayer(Layer.ShopBuy).Delete(); + + var legacy = new Backpack { Layer = Layer.ShopBuy, Movable = false, Visible = false }; + vendor.AddItem(legacy); + + var children = new Item[20_000]; + for (var i = 0; i < children.Length; i++) + { + children[i] = new Item(0x1234); + legacy.DropItem(children[i]); + } + + var pack = vendor.BuyPack; + + Assert.IsType(pack); + Assert.Same(pack, vendor.FindItemOnLayer(Layer.ShopBuy)); + Assert.True(legacy.Deleted); + for (var i = 0; i < children.Length; i++) + { + Assert.True(children[i].Deleted); + } + } + + [Fact] + public void SaleWithNoBuybackCapacity_ConsumesItem_AndStillPays() + { + var vendor = NewFullStockVendor(); + var seller = NewSeller(); + var bandages = new Bandage(10); + seller.Backpack.DropItem(bandages); + + Assert.True(vendor.OnSellItems(seller, [new SellItemResponse(bandages, 10)])); + + Assert.True(bandages.Deleted); + Assert.Empty(vendor.BuyPack.Items); + Assert.False(vendor.BuybackPurgeScheduled); + + var gold = seller.Backpack.FindItemByType(); + Assert.NotNull(gold); + Assert.Equal(20, gold.Amount); + } +} diff --git a/Projects/UOContent/Migrations/Server.Items.VendorBuybackPack.v0.json b/Projects/UOContent/Migrations/Server.Items.VendorBuybackPack.v0.json new file mode 100644 index 000000000..38b4bbd59 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.VendorBuybackPack.v0.json @@ -0,0 +1,4 @@ +{ + "version": 0, + "type": "Server.Items.VendorBuybackPack" +} \ No newline at end of file diff --git a/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs b/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs index a6467746b..27f270648 100644 --- a/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs +++ b/Projects/UOContent/Mobiles/Vendors/BaseVendor.cs @@ -31,7 +31,10 @@ namespace Server.Mobiles private static readonly ILogger logger = LogFactory.GetLogger(typeof(BaseVendor)); private const int MaxSell = 500; - private static readonly TimeSpan InventoryDecayTime = TimeSpan.FromHours(1.0); + // One (not all) of the buy packets counts entries in a byte. + private const int BuyListLimit = 250; + + private TimerExecutionToken _buybackPurgeToken; private readonly List _buyInfo = new(); private readonly List _sellInfo = new(); @@ -71,11 +74,8 @@ namespace Server.Mobiles SetSpeed(0.5, 2.0); // these packs MUST exist, or the client will crash when the packets are sent - Container pack = new Backpack { Layer = Layer.ShopBuy, Movable = false, Visible = false }; - AddItem(pack); - - pack = new Backpack { Layer = Layer.ShopResale, Movable = false, Visible = false }; - AddItem(pack); + AddItem(new VendorBuybackPack()); + AddItem(new Backpack { Layer = Layer.ShopResale, Movable = false, Visible = false }); LastRestock = Core.Now; } @@ -105,20 +105,79 @@ namespace Server.Mobiles public override bool ShowFameTitle => false; - public Container BuyPack + public VendorBuybackPack BuyPack { get { - if (FindItemOnLayer(Layer.ShopBuy) is not Container pack) + var existing = FindItemOnLayer(Layer.ShopBuy); + + if (existing is VendorBuybackPack pack) { - pack = new Backpack { Layer = Layer.ShopBuy, Visible = false }; - AddItem(pack); + return pack; } + // Legacy pack; deleting it stays linear even with a huge backlog. + existing?.Delete(); + + pack = new VendorBuybackPack(); + AddItem(pack); return pack; } } + internal bool BuybackPurgeScheduled => _buybackPurgeToken.Running; + + internal static DateTime GetNextBuybackPurge(DateTime lastRestock, TimeSpan restockDelay, DateTime now) + { + if (restockDelay <= TimeSpan.Zero) + { + return now; + } + + // A backwards clock adjustment can future-date the anchor past RestockDelay. + if (lastRestock > now) + { + lastRestock = now; + } + + var next = lastRestock + restockDelay; + + if (next > now) + { + return next; + } + + // Restock is lazy and may be hours overdue; stay on its grid. + var periods = (now - lastRestock).Ticks / restockDelay.Ticks + 1; + return lastRestock + TimeSpan.FromTicks(restockDelay.Ticks * periods); + } + + private void AddToBuyback(Item item, int capacity) + { + BuyPack.AddBuyback(item, capacity); + + if (!item.Deleted && !_buybackPurgeToken.Running) + { + var delay = GetNextBuybackPurge(LastRestock, RestockDelay, Core.Now) - Core.Now; + Timer.StartTimer(delay, CancelAndPurgeBuyback, out _buybackPurgeToken); + } + } + + private void PurgeBuyback() => (FindItemOnLayer(Layer.ShopBuy) as VendorBuybackPack)?.Purge(); + + // A fired token-bearing timer isn't returned to its pool until its token is cancelled. + private void CancelAndPurgeBuyback() + { + _buybackPurgeToken.Cancel(); + PurgeBuyback(); + } + + public override void OnDelete() + { + _buybackPurgeToken.Cancel(); + base.OnDelete(); + } + public virtual bool IsTokunoVendor => Map == Map.Tokuno; public virtual bool IsTerMurVendor => Map == Map.TerMur; @@ -132,6 +191,7 @@ namespace Server.Mobiles public virtual void Restock() { LastRestock = Core.Now; + CancelAndPurgeBuyback(); var buyInfo = GetBuyInfo(); @@ -427,6 +487,7 @@ namespace Server.Mobiles var info = GetSellInfo(); var buyInfo = GetBuyInfo(); + var buybackCapacity = Math.Max(0, BuyListLimit - buyInfo.Length); var GiveGold = 0; var Sold = 0; @@ -498,28 +559,14 @@ namespace Server.Mobiles if (!found) { - var cont = BuyPack; + var sold = resp.Item; if (amount < resp.Item.Amount) { - var item = LiftItemDupe(resp.Item, resp.Item.Amount - amount); + sold = LiftItemDupe(resp.Item, resp.Item.Amount - amount) ?? resp.Item; + } - if (item != null) - { - item.SetLastMoved(); - cont.DropItem(item); - } - else - { - resp.Item.SetLastMoved(); - cont.DropItem(resp.Item); - } - } - else - { - resp.Item.SetLastMoved(); - cont.DropItem(resp.Item); - } + AddToBuyback(sold, buybackCapacity); } } else @@ -738,6 +785,9 @@ namespace Server.Mobiles CheckMorph(); LoadSBInfo(); + + // LoadSBInfo re-anchored the restock grid. Not done there since it also runs on deserialize. + CancelAndPurgeBuyback(); } public virtual int GetRandomNecromancerHue() @@ -882,7 +932,7 @@ namespace Server.Mobiles { var buyItem = buyInfo[idx]; - if (buyItem.Amount <= 0 || list.Count >= 250) + if (buyItem.Amount <= 0 || list.Count >= BuyListLimit) { continue; } @@ -914,21 +964,6 @@ namespace Server.Mobiles var playerItems = cont.Items; - for (var i = playerItems.Count - 1; i >= 0; --i) - { - if (i >= playerItems.Count) - { - continue; - } - - var item = playerItems[i]; - - if (item.LastMoved + InventoryDecayTime <= Core.Now) - { - item.Delete(); - } - } - for (var i = 0; i < playerItems.Count; ++i) { var item = playerItems[i]; @@ -946,17 +981,13 @@ namespace Server.Mobiles } } - if (name != null && list.Count < 250) + if (name != null && list.Count < BuyListLimit) { list.Add(new BuyItemState(name, cont.Serial, item.Serial, price, item.Amount, item.ItemID, item.Hue)); opls.Enqueue(item.PropertyList); } } - // one (not all) of the packets uses a byte to describe number of items in the list. Osi = dumb. - // if (list.Count > 255) - // Console.WriteLine( "Vendor Warning: Vendor {0} has more than 255 buy items, may cause client errors!", this ); - if (list.Count <= 0) { return; @@ -988,17 +1019,9 @@ namespace Server.Mobiles public virtual void SendPacksTo(Mobile from) { - var pack = FindItemOnLayer(Layer.ShopBuy); + from.NetState.SendEquipUpdate(BuyPack); - if (pack == null) - { - pack = new Backpack { Layer = Layer.ShopBuy, Movable = false, Visible = false }; - AddItem(pack); - } - - from.NetState.SendEquipUpdate(pack); - - pack = FindItemOnLayer(Layer.ShopSell); + var pack = FindItemOnLayer(Layer.ShopSell); if (pack != null) { @@ -1306,6 +1329,20 @@ namespace Server.Mobiles { LoadSBInfo(); + if (FindItemOnLayer(Layer.ShopBuy) is not VendorBuybackPack) + { + // Deleting during deserialization is unsafe; replace on the first tick. + Timer.StartTimer( + () => + { + if (!Deleted) + { + _ = BuyPack; + } + } + ); + } + if (IsParagon) { IsParagon = false; diff --git a/Projects/UOContent/Mobiles/Vendors/VendorBuybackPack.cs b/Projects/UOContent/Mobiles/Vendors/VendorBuybackPack.cs new file mode 100644 index 000000000..d72bec895 --- /dev/null +++ b/Projects/UOContent/Mobiles/Vendors/VendorBuybackPack.cs @@ -0,0 +1,56 @@ +using ModernUO.Serialization; + +namespace Server.Items; + +/// +/// Holds items players sold to an NPC vendor until the next restock. The contents are never saved +/// and their removal reaches only the vendor and openers, like any private container. +/// +[SerializationGenerator(0)] +public partial class VendorBuybackPack : Backpack +{ + [Constructible] + public VendorBuybackPack() + { + Layer = Layer.ShopBuy; + Movable = false; + Visible = false; + } + + public override bool SkipsChildSerialization => true; + + // DropItem appends, so Items[0] is always the oldest entry. + public void AddBuyback(Item item, int capacity) + { + if (capacity <= 0) + { + item.Delete(); + return; + } + + while (Items.Count >= capacity) + { + var oldest = Items[0]; + oldest.Delete(); + + // Delete() is a no-op on an already-deleted item, which would spin this loop. + if (Items.Count > 0 && Items[0] == oldest) + { + Items.RemoveAt(0); + } + } + + DropItem(item); + } + + public void Purge() + { + for (var i = Items.Count - 1; i >= 0; --i) + { + if (i < Items.Count) + { + Items[i].Delete(); + } + } + } +}