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,4 @@
{
"version": 0,
"type": "Server.Items.VendorBuybackPack"
}

View file

@ -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<IBuyItemInfo> _buyInfo = new();
private readonly List<IShopSellInfo> _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;

View file

@ -0,0 +1,56 @@
using ModernUO.Serialization;
namespace Server.Items;
/// <summary>
/// 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.
/// </summary>
[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();
}
}
}
}