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:
parent
89a86d81b4
commit
d857f03b68
5 changed files with 545 additions and 59 deletions
111
Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs
Normal file
111
Projects/UOContent.Tests/Tests/Items/VendorBuybackPackTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
278
Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs
Normal file
278
Projects/UOContent.Tests/Tests/Mobiles/VendorBuybackTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
4
Projects/UOContent/Migrations/Server.Items.VendorBuybackPack.v0.json
generated
Normal file
4
Projects/UOContent/Migrations/Server.Items.VendorBuybackPack.v0.json
generated
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"version": 0,
|
||||
"type": "Server.Items.VendorBuybackPack"
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
56
Projects/UOContent/Mobiles/Vendors/VendorBuybackPack.cs
Normal file
56
Projects/UOContent/Mobiles/Vendors/VendorBuybackPack.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue