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 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-10-02 10:50:25 -05:00
parent 1ac1248161
commit 66dca36b63
3 changed files with 78 additions and 0 deletions

View File

@@ -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<int32_t>(slot);
move.SendToClient(player.GetSystemAddress());
}
slot++;
}
}

View File

@@ -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);

View File

@@ -8,6 +8,7 @@
#include "Item.h"
#include "VendorComponent.h"
#include "eReplicaComponentType.h"
#include "InventoryMessages.h"
#include <gtest/gtest.h>
@@ -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<dServerMock*>(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<LWOOBJID>(VendorComponent::BUYBACK_SIZE); id++) {
EXPECT_EQ(buyback->FindItemById(id)->GetSlot(), static_cast<uint32_t>(id - oldest - 1));
}
EXPECT_EQ(buyback->FindEmptySlot(), static_cast<int32_t>(VendorComponent::BUYBACK_SIZE - 1));
// A MoveItemInInventory for each, in the buyback inventory, as live sent them
std::vector<std::pair<LWOOBJID, int32_t>> moves;
for (const auto& packet : server->GetSentPackets()) {
RakNet::BitStream bitStream(const_cast<uint8_t*>(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<LWOOBJID>(i));
EXPECT_EQ(moves[i].second, static_cast<int32_t>(i));
}
}
TEST_F(VendorBuybackTests, NothingMovesWhenNothingIsDropped) {
Give(buyback, 3001);
auto* const sold = Give(inventory->GetInventory(eInventoryType::ITEMS), 3001);
auto* const server = static_cast<dServerMock*>(Game::server);
server->ClearSentPackets();
VendorComponent::MakeRoomInBuyback(*inventory, *sold, 1);
EXPECT_TRUE(server->GetSentPackets().empty());
}