## 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.
111 lines
2.5 KiB
C#
111 lines
2.5 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|