fix: bound and purge NPC vendor buyback stock (#2677)

## Summary

Stacked on #2676. Review that first; this PR's diff is against it.

Items players sell to NPC vendors go into the vendor's buyback (`ShopBuy`) pack. That pack was purged only when someone opened the buy menu, so a vendor that players sold to but never bought from grew without limit.
- A server operator reported 250k+ items piling up from players crafting and selling on a macro.
- Every one of those items was saved.
- Purging or deleting that vendor then sent one remove packet per item to every nearby client and disconnected them.

The behavior is inherited from RunUO.

- **`VendorBuybackPack`** replaces the plain backpack on `Layer.ShopBuy`.
  - It opts in to `SkipsChildSerialization`, so buyback items are never saved.
  - Its contents are private like any container's, so removing them reaches only the vendor (no client) and openers. Purges and deletes send nothing to bystanders.
- **Capacity.** The buyback holds at most `250 − buy-list entries` items, which is what the buy gump can show. The oldest items are evicted first. At capacity 0 the sold item is consumed and the seller is still paid.
- **Purge on the restock grid.**
  - `Restock()` empties the buyback.
  - A purge-only timer, armed on the first sale, covers vendors nobody buys from. It is due at the next restock boundary after now, so a vendor whose restock hasn't run for hours doesn't purge an item the moment it's sold.
  - The timer never restocks, so stock doubling and halving is unchanged.
  - The old per-item one-hour `LastMoved` check is gone.
- **Existing saves.** A legacy `ShopBuy` pack is replaced on the first tick after load. Thanks to #2676, deleting a pack of 250k+ items takes well under a second.

**Behavior changes for shard operators:**
- A buyback item lasts until the next restock boundary (at most `RestockDelay`), not one hour after its sale.
- A vendor that changes maps drops its buyback.
- A buy window that is open during a purge can show a row whose item is gone. Buying that row does nothing, and reopening shows the current list.

## Test plan

- [x] `VendorBuybackPackTests`: evicts oldest first; capacity 0 consumes the item; purge; a stale front entry can't spin eviction
- [x] `VendorBuybackTests`:
  - purge due time stays on the restock grid, including a stale restock and a restock time in the future;
  - partial stack sale: the split goes to buyback and the seller keeps the remainder;
  - zero capacity consumes the item and still pays;
  - restock purges and clears the timer, and the next sale re-arms it;
  - deleting the vendor cancels the timer;
  - a legacy 20k-item pack is replaced.
- [x] UOContent.Tests and Server.Tests green; schema generator clean
- [x] Manual in-game pass:
  - sell and buy back, including a partial stack;
  - over-capacity sale;
  - purge with no buyer;
  - `[restock`;
  - vendor facet change;
  - restart;
  - legacy save with a large `ShopBuy` pack;
  - mass selling with bystanders present;
  - deleting a vendor with a full buyback.
This commit is contained in:
Kamron Batman 2026-10-09 22:56:39 -07:00 • committed by GitHub
parent 89a86d81b4
commit d857f03b68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 545 additions and 59 deletions

View file

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

View file

@ -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<Mobile> _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<GenericBuyInfo> BuyInfo { get; } = [];
}
private sealed class VendorStub : BaseVendor
{
private readonly List<SBInfo> _sbInfos = [];
public VendorStub() : base("the stub")
{
}
public VendorStub(Serial serial) : base(serial)
{
}
protected override List<SBInfo> 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<GenericBuyInfo> _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<GenericBuyInfo> BuyInfo => _buyInfo;
}
private sealed class FullStockVendorStub : BaseVendor
{
private readonly List<SBInfo> _sbInfos = [];
public FullStockVendorStub() : base("the stub")
{
}
protected override List<SBInfo> 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<Bandage>(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<VendorBuybackPack>(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<Gold>();
Assert.NotNull(gold);
Assert.Equal(20, gold.Amount);
}
}