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); +}