From c759bd1a40a124d593c0f238bb88f2ae20621890 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Sun, 27 Sep 2026 08:57:30 -0500 Subject: [PATCH] fix(vendor): keep 27 buyback items and drop the oldest The buyback inventory grew by 9 slots whenever it was nearly full, so the vendor's buyback page kept resizing. Live kept 27 items: a 2014 capture of 29 sales shows that on the 28th, the server sent RemoveItemFromInventory (buyback inventory) for the first item sold, then added the new one. The buyback inventory now keeps its size. Before a sale needs a new buyback slot and the inventory holds 27 or more items, the oldest items (lowest object ID: each sale gives a new, higher ID) are removed. Sales that fit on an existing buyback stack remove nothing. The removal is not counted again by the economy ledger, which counted the items as gone when they were sold. Fixes issue 1129. Co-Authored-By: Claude Opus 5.5 --- dGame/dComponents/VendorComponent.cpp | 34 ++++++++ dGame/dComponents/VendorComponent.h | 9 +++ dGame/dInventory/Inventory.cpp | 5 +- .../dComponentsTests/CMakeLists.txt | 1 + .../dComponentsTests/VendorBuybackTests.cpp | 79 +++++++++++++++++++ 5 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 tests/dGameTests/dComponentsTests/VendorBuybackTests.cpp diff --git a/dGame/dComponents/VendorComponent.cpp b/dGame/dComponents/VendorComponent.cpp index 44496c1a6..b998d7030 100644 --- a/dGame/dComponents/VendorComponent.cpp +++ b/dGame/dComponents/VendorComponent.cpp @@ -15,6 +15,10 @@ #include "CheatDetection.h" #include "VendorMessages.h" #include "Item.h" +#include "EconomyLedger.h" + +#include +#include VendorComponent::VendorComponent(Entity* parent, const int32_t componentID) : Component(parent, componentID) { m_HasStandardCostItems = false; @@ -272,6 +276,7 @@ void VendorComponent::SellToVendor(Entity& player, const SystemAddress& sysAddr, inv->AddItem(itemComp.currencyLOT, std::floor(altCurrency), eLootSourceType::VENDOR); // Return alt currencies like faction tokens. } + MakeRoomInBuyback(*inv, *item, static_cast(std::max(count, 0))); inv->MoveItemToInventory(item, eInventoryType::VENDOR_BUYBACK, count, true, false, true); character->SetCoins(std::floor(character->GetCoins() + (static_cast(itemComp.baseValue * sellScalar) * count)), eLootSourceType::VENDOR); SendTransactionResult(m_Parent->GetObjectID(), sysAddr, eVendorTransactionResult::SELL_SUCCESS); @@ -315,3 +320,32 @@ void VendorComponent::BuybackFromVendor(Entity& player, const SystemAddress& sys //Game::entityManager->SerializeEntity(player); // so inventory updates SendTransactionResult(m_Parent->GetObjectID(), sysAddr, eVendorTransactionResult::PURCHASE_SUCCESS); } + +void VendorComponent::MakeRoomInBuyback(InventoryComponent& inventory, const Item& sold, const uint32_t count) { + auto* const buyback = inventory.GetInventory(eInventoryType::VENDOR_BUYBACK); + if (!buyback) return; + + // No new slot is needed when all of it fits on buyback stacks of the same item + const auto lot = sold.GetLot(); + const auto stackSize = sold.GetInfo().stackSize; + const bool mergeable = sold.GetSubKey() == LWOOBJID_EMPTY && sold.GetConfig().values.empty() && !InventoryComponent::IsUniqueLot(lot); + if (mergeable) { + uint64_t room = 0; + for (const auto* const item : buyback->GetItems() | std::views::values) { + if (item->GetLot() == lot && item->GetCount() < stackSize) room += stackSize - item->GetCount(); + } + if (room >= count) return; + } + + // Live kept 27 items (the buyback window's size) and dropped the oldest when a 28th was sold: a 2014 capture shows + // RemoveItemFromInventory for the first item sold, in the buyback inventory, just before the 28th sale's item was + // added. Each item gets a new, higher object ID when it is sold, so the oldest is the one with the lowest ID. + // The ledger counted these items as gone when they were sold + EconomyLedger::ScopedItemTransfer alreadyCounted; + while (buyback->GetItems().size() >= BUYBACK_SIZE) { + const auto items = buyback->GetItems().size(); + const auto oldest = std::ranges::min_element(buyback->GetItems(), {}, [](const auto& entry) { return entry.first; }); + oldest->second->SetCount(0, false, false, false, eLootSourceType::VENDOR); + if (buyback->GetItems().size() >= items) break; // not removed (an empty stack) + } +} diff --git a/dGame/dComponents/VendorComponent.h b/dGame/dComponents/VendorComponent.h index f2d271c09..08eb8337a 100644 --- a/dGame/dComponents/VendorComponent.h +++ b/dGame/dComponents/VendorComponent.h @@ -10,6 +10,8 @@ #include "eReplicaComponentType.h" enum class eVendorTransactionResult : uint32_t; +class InventoryComponent; +class Item; struct SoldItem { SoldItem(const LOT lot, const int32_t sortPriority) { @@ -61,6 +63,13 @@ public: // Sends a VendorTransactionResult targeting target to sysAddr only. static void SendTransactionResult(LWOOBJID target, const SystemAddress& sysAddr, eVendorTransactionResult result); + // How many items the buyback inventory keeps (the vendor window's buyback page) + static constexpr size_t BUYBACK_SIZE = 27; + + // Before count of sold go to the buyback inventory: drops the oldest buyback items while it is full and they need + // a slot of their own. + static void MakeRoomInBuyback(InventoryComponent& inventory, const Item& sold, uint32_t count); + private: void HandleMrReeCameras(); bool SetupItem(LOT item); diff --git a/dGame/dInventory/Inventory.cpp b/dGame/dInventory/Inventory.cpp index 0d24f57d5..363458e53 100644 --- a/dGame/dInventory/Inventory.cpp +++ b/dGame/dInventory/Inventory.cpp @@ -91,13 +91,12 @@ void Inventory::SetSize(const uint32_t value) { int32_t Inventory::FindEmptySlot() { if (free <= 6) // Up from 1 { - if (type != ITEMS && type != VAULT_ITEMS && type != eInventoryType::VAULT_MODELS) { + // The buyback inventory keeps its size: selling to a full one drops the oldest item (VendorComponent::SellToVendor) + if (type != ITEMS && type != VAULT_ITEMS && type != eInventoryType::VAULT_MODELS && type != eInventoryType::VENDOR_BUYBACK) { uint32_t newSize = size; if (type == MODELS) { newSize = 240; - } else if (type == eInventoryType::VENDOR_BUYBACK) { - newSize += 9u; } else { newSize += 10u; } diff --git a/tests/dGameTests/dComponentsTests/CMakeLists.txt b/tests/dGameTests/dComponentsTests/CMakeLists.txt index 33186193c..e641ac8be 100644 --- a/tests/dGameTests/dComponentsTests/CMakeLists.txt +++ b/tests/dGameTests/dComponentsTests/CMakeLists.txt @@ -1,5 +1,6 @@ set(DCOMPONENTS_TESTS "DeletionRestrictionTests.cpp" + "VendorBuybackTests.cpp" "DestroyableComponentTests.cpp" "PetComponentTests.cpp" "SimplePhysicsComponentTests.cpp" diff --git a/tests/dGameTests/dComponentsTests/VendorBuybackTests.cpp b/tests/dGameTests/dComponentsTests/VendorBuybackTests.cpp new file mode 100644 index 000000000..043ca2f48 --- /dev/null +++ b/tests/dGameTests/dComponentsTests/VendorBuybackTests.cpp @@ -0,0 +1,79 @@ +#include "GameDependencies.h" + +#include "CDClientManager.h" +#include "CDComponentsRegistryTable.h" +#include "CDItemComponentTable.h" +#include "Entity.h" +#include "InventoryComponent.h" +#include "Item.h" +#include "VendorComponent.h" +#include "eReplicaComponentType.h" + +#include + +class VendorBuybackTests : public GameDependenciesTest { +protected: + std::unique_ptr entity; + InventoryComponent* inventory{}; + Inventory* buyback{}; + LWOOBJID nextId = 0x7000; + + void SetUp() override { + SetUpDependencies(); + RegisterLot(3001, 1); // one per slot + RegisterLot(3002, 10); // stacks + CDClientManager::GetEntriesMutable().insert_or_assign(static_cast(info.lot), 0); + entity = std::make_unique(1, info); + inventory = entity->AddComponent(-1); + buyback = inventory->GetInventory(eInventoryType::VENDOR_BUYBACK); + } + + void TearDown() override { + entity.reset(); + TearDownDependencies(); + } + + static void RegisterLot(const LOT lot, const int32_t stackSize) { + const auto componentID = static_cast(96000 + lot); + auto& registry = CDClientManager::GetEntriesMutable(); + registry.insert_or_assign(static_cast(lot), componentID); + registry.insert_or_assign(static_cast(eReplicaComponentType::ITEM) << 32 | static_cast(lot), componentID); + CDItemComponent component{}; + component.id = componentID; + component.stackSize = stackSize; + CDClientManager::GetEntriesMutable().insert_or_assign(componentID, component); + } + + // The inventory owns the item + Item* Give(Inventory* to, const LOT lot, const uint32_t count = 1) { + return new Item(nextId++, lot, to, static_cast(to->GetItems().size()), count, false, {}, LWOOBJID_EMPTY, LWOOBJID_EMPTY, eLootSourceType::NONE); + } +}; + +TEST_F(VendorBuybackTests, KeepsTheSizeWhenFull) { + for (size_t i = 0; i < VendorComponent::BUYBACK_SIZE; i++) Give(buyback, 3001); + EXPECT_EQ(buyback->FindEmptySlot(), -1); + EXPECT_EQ(buyback->GetSize(), VendorComponent::BUYBACK_SIZE); +} + +TEST_F(VendorBuybackTests, SellingToAFullBuybackDropsTheOldest) { + const auto oldest = nextId; + for (size_t i = 0; i < VendorComponent::BUYBACK_SIZE; i++) Give(buyback, 3001); + auto* const sold = Give(inventory->GetInventory(eInventoryType::ITEMS), 3001); + + VendorComponent::MakeRoomInBuyback(*inventory, *sold, 1); + EXPECT_EQ(buyback->GetItems().size(), VendorComponent::BUYBACK_SIZE - 1); + EXPECT_FALSE(buyback->GetItems().contains(oldest)); + EXPECT_TRUE(buyback->GetItems().contains(oldest + 1)); +} + +TEST_F(VendorBuybackTests, RoomLeftOnAStackKeepsEverything) { + Give(buyback, 3002, 5); + for (size_t i = 1; i < VendorComponent::BUYBACK_SIZE; i++) Give(buyback, 3001); + auto* const sold = Give(inventory->GetInventory(eInventoryType::ITEMS), 3002, 5); + + VendorComponent::MakeRoomInBuyback(*inventory, *sold, 5); + EXPECT_EQ(buyback->GetItems().size(), VendorComponent::BUYBACK_SIZE); + VendorComponent::MakeRoomInBuyback(*inventory, *sold, 6); + EXPECT_EQ(buyback->GetItems().size(), VendorComponent::BUYBACK_SIZE - 1); +}