From 66dca36b631f4d4323ac17420d57cc626d22f6b4 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Fri, 2 Oct 2026 10:50:25 -0500 Subject: [PATCH] fix(vendor): dropping the oldest buyback item moves the rest up, as live Live's 28th sale to a full buyback removed the oldest item, then sent MoveItemInInventory (inventory 11, VENDOR_BUYBACK) for each of the 26 items left, to slots 0 to 25 in order, before the sold item was added after them. The server left the hole, so the sold item took the dropped item's slot and the buyback page was no longer in sale order. The buyback inventory is not saved and nothing changes hands; only slots move. Co-Authored-By: Claude Opus 5.5 --- .../Vendor/BaseVendorComponent.cpp | 27 +++++++++++ .../dComponents/Vendor/BaseVendorComponent.h | 3 ++ .../dComponentsTests/VendorBuybackTests.cpp | 48 +++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/dGame/dComponents/Vendor/BaseVendorComponent.cpp b/dGame/dComponents/Vendor/BaseVendorComponent.cpp index b7b11cc97..448f5d430 100644 --- a/dGame/dComponents/Vendor/BaseVendorComponent.cpp +++ b/dGame/dComponents/Vendor/BaseVendorComponent.cpp @@ -15,6 +15,7 @@ #include "UserManager.h" #include "CheatDetection.h" #include "VendorMessages.h" +#include "InventoryMessages.h" #include "Item.h" #include "EconomyLedger.h" @@ -355,10 +356,36 @@ void BaseVendorComponent::MakeRoomInBuyback(InventoryComponent& inventory, const // 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; + bool dropped = false; 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) + dropped = true; + } + if (dropped) CompactBuyback(inventory); +} + +void BaseVendorComponent::CompactBuyback(InventoryComponent& inventory) { + auto* const buyback = inventory.GetInventory(eInventoryType::VENDOR_BUYBACK); + if (!buyback) return; + + // Live moved what was left up, in order, before the sold item went in after it: the 2014 capture of a 28th sale + // has MoveItemInInventory (inventory 11) for each of the 26 items left, to slots 0 to 25, then the sold item added + const auto& player = *inventory.GetParent(); + const auto slots = buyback->GetSlots(); + uint32_t slot = 0; + for (auto* const item : slots | std::views::values) { + if (item->GetSlot() != slot) { + item->SetSlot(slot); + GameMessages::MoveItemInInventory move; + move.target = player.GetObjectID(); + move.iObjID = item->GetId(); + move.inventoryType = eInventoryType::VENDOR_BUYBACK; + move.slot = static_cast(slot); + move.SendToClient(player.GetSystemAddress()); + } + slot++; } } diff --git a/dGame/dComponents/Vendor/BaseVendorComponent.h b/dGame/dComponents/Vendor/BaseVendorComponent.h index 232626028..371fef081 100644 --- a/dGame/dComponents/Vendor/BaseVendorComponent.h +++ b/dGame/dComponents/Vendor/BaseVendorComponent.h @@ -75,6 +75,9 @@ public: // a slot of their own. static void MakeRoomInBuyback(InventoryComponent& inventory, const Item& sold, uint32_t count); + // Moves the buyback items up to slots 0, 1, ... in their order, telling the player of each one that moved + static void CompactBuyback(InventoryComponent& inventory); + private: void HandleMrReeCameras(); bool SetupItem(LOT item); diff --git a/tests/dGameTests/dComponentsTests/VendorBuybackTests.cpp b/tests/dGameTests/dComponentsTests/VendorBuybackTests.cpp index 043ca2f48..1fdf2fac7 100644 --- a/tests/dGameTests/dComponentsTests/VendorBuybackTests.cpp +++ b/tests/dGameTests/dComponentsTests/VendorBuybackTests.cpp @@ -8,6 +8,7 @@ #include "Item.h" #include "VendorComponent.h" #include "eReplicaComponentType.h" +#include "InventoryMessages.h" #include @@ -77,3 +78,50 @@ TEST_F(VendorBuybackTests, RoomLeftOnAStackKeepsEverything) { VendorComponent::MakeRoomInBuyback(*inventory, *sold, 6); EXPECT_EQ(buyback->GetItems().size(), VendorComponent::BUYBACK_SIZE - 1); } + +TEST_F(VendorBuybackTests, DroppingTheOldestMovesTheRestUp) { + 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); + auto* const server = static_cast(Game::server); + server->ClearSentPackets(); + + VendorComponent::MakeRoomInBuyback(*inventory, *sold, 1); + + // The 26 left in slots 0 to 25 in their order, the sold item's slot free after them + for (LWOOBJID id = oldest + 1; id < oldest + static_cast(VendorComponent::BUYBACK_SIZE); id++) { + EXPECT_EQ(buyback->FindItemById(id)->GetSlot(), static_cast(id - oldest - 1)); + } + EXPECT_EQ(buyback->FindEmptySlot(), static_cast(VendorComponent::BUYBACK_SIZE - 1)); + + // A MoveItemInInventory for each, in the buyback inventory, as live sent them + std::vector> moves; + for (const auto& packet : server->GetSentPackets()) { + RakNet::BitStream bitStream(const_cast(packet.bytes.data()), packet.bytes.size(), false); + LWOOBJID target{}; + MessageType::Game id{}; + if (!GameMessages::NetGameMsg::ReadPacketHeader(bitStream, target, id) || id != MessageType::Game::MOVE_ITEM_IN_INVENTORY) continue; + GameMessages::MoveItemInInventory move; + ASSERT_TRUE(move.Deserialize(bitStream)); + EXPECT_EQ(target, entity->GetObjectID()); + EXPECT_EQ(move.inventoryType, eInventoryType::VENDOR_BUYBACK); + EXPECT_EQ(move.destInvType, eInventoryType::INVALID); + EXPECT_EQ(move.responseCode, 0); + moves.emplace_back(move.iObjID, move.slot); + } + ASSERT_EQ(moves.size(), VendorComponent::BUYBACK_SIZE - 1); + for (size_t i = 0; i < moves.size(); i++) { + EXPECT_EQ(moves[i].first, oldest + 1 + static_cast(i)); + EXPECT_EQ(moves[i].second, static_cast(i)); + } +} + +TEST_F(VendorBuybackTests, NothingMovesWhenNothingIsDropped) { + Give(buyback, 3001); + auto* const sold = Give(inventory->GetInventory(eInventoryType::ITEMS), 3001); + auto* const server = static_cast(Game::server); + server->ClearSentPackets(); + + VendorComponent::MakeRoomInBuyback(*inventory, *sold, 1); + EXPECT_TRUE(server->GetSentPackets().empty()); +}