#include "BaseVendorComponent.h" #include "ObjectMessages.h" #include "BitStream.h" #include "dServer.h" #include "dZoneManager.h" #include "WorldConfig.h" #include "CDComponentsRegistryTable.h" #include "CDVendorComponentTable.h" #include "CDLootMatrixTable.h" #include "CDLootTableTable.h" #include "CDItemComponentTable.h" #include "InventoryComponent.h" #include "Character.h" #include "eVendorTransactionResult.h" #include "UserManager.h" #include "CheatDetection.h" #include "VendorMessages.h" #include "InventoryMessages.h" #include "Item.h" #include "EconomyLedger.h" #include "VendorPricing.h" #include #include BaseVendorComponent::BaseVendorComponent(Entity* parent, const int32_t componentID) : Component(parent, componentID) { RegisterMsg(&BaseVendorComponent::OnRequestUse); m_HasStandardCostItems = false; m_HasMultiCostItems = false; } // The vendor's constants and its stock (the base's refresh: a derived vendor adds its own after) void BaseVendorComponent::LoadDataFromTemplate() { SetupConstants(); BaseVendorComponent::RefreshInventory(true); } void BaseVendorComponent::Serialize(RakNet::BitStream& outBitStream, bool bIsInitialUpdate) { outBitStream.Write(bIsInitialUpdate || m_DirtyVendor); if (bIsInitialUpdate || m_DirtyVendor) { outBitStream.Write(m_HasStandardCostItems); outBitStream.Write(m_HasMultiCostItems); if (!bIsInitialUpdate) m_DirtyVendor = false; } } bool BaseVendorComponent::OnRequestUse(GameMessages::RequestUseEvent& event) { if (event.msg.IsMissionChoice()) return false; auto* const originator = event.user; GameMessages::VendorOpenWindow openWindow; openWindow.target = m_Parent->GetObjectID(); openWindow.SendToClient(originator->GetSystemAddress()); // Only the entity's VENDOR component sends its stock (achievement and donation vendors never did). auto* vendor = static_cast(m_Parent->GetComponent(eReplicaComponentType::VENDOR)); if (vendor) vendor->SendStatusUpdate(originator->GetSystemAddress()); return true; } void BaseVendorComponent::RefreshInventory(bool isCreation) { SetHasStandardCostItems(false); SetHasMultiCostItems(false); m_Inventory.clear(); // Custom code for Vanity Vendor Invetory Override if(m_Parent->HasVar(u"vendorInvOverride")) { std::vector items = GeneralUtils::SplitString(m_Parent->GetVarAsString(u"vendorInvOverride"), ','); uint32_t sortPriority = -1; for (auto& itemString : items) { itemString.erase(remove_if(itemString.begin(), itemString.end(), isspace), itemString.end()); auto item = GeneralUtils::TryParse(itemString); if (!item) continue; if (SetupItem(item.value())) { sortPriority++; m_Inventory.push_back(SoldItem(item.value(), sortPriority)); } } return; } auto* lootMatrixTable = CDClientManager::GetTable(); const auto& lootMatrices = lootMatrixTable->GetMatrix(m_LootMatrixID); if (lootMatrices.empty()) return; auto* lootTableTable = CDClientManager::GetTable(); for (const auto& lootMatrix : lootMatrices) { auto vendorItems = lootTableTable->GetTable(lootMatrix.LootTableIndex); if (lootMatrix.maxToDrop == 0 || lootMatrix.minToDrop == 0) { for (const auto& item : vendorItems) { if (SetupItem(item.itemid)) m_Inventory.push_back(SoldItem(item.itemid, item.sortPriority)); } } else { auto randomCount = GeneralUtils::GenerateRandomNumber(lootMatrix.minToDrop, lootMatrix.maxToDrop); for (size_t i = 0; i < randomCount; i++) { if (vendorItems.empty()) break; auto randomItemIndex = GeneralUtils::GenerateRandomNumber(0, vendorItems.size() - 1); const auto& randomItem = vendorItems.at(randomItemIndex); if (SetupItem(randomItem.itemid)) m_Inventory.push_back(SoldItem(randomItem.itemid, randomItem.sortPriority)); vendorItems.erase(vendorItems.begin() + randomItemIndex); } } } HandleMrReeCameras(); // Callback timer to refresh this inventory. if (m_RefreshTimeSeconds > 0.0) { m_Parent->AddCallbackTimer(m_RefreshTimeSeconds, [this]() { RefreshInventory(); }); } Game::entityManager->SerializeEntity(m_Parent); // Only the entity's VENDOR component sends its stock (achievement and donation vendors never did). auto* vendor = static_cast(m_Parent->GetComponent(eReplicaComponentType::VENDOR)); if (vendor) vendor->SendStatusUpdate(UNASSIGNED_SYSTEM_ADDRESS); } void BaseVendorComponent::SetupConstants() { auto* compRegistryTable = CDClientManager::GetTable(); int componentID = compRegistryTable->GetByIDAndType(m_Parent->GetLOT(), eReplicaComponentType::VENDOR); auto* vendorComponentTable = CDClientManager::GetTable(); std::vector vendorComps = vendorComponentTable->Query([=](CDVendorComponent entry) { return (entry.id == componentID); }); if (vendorComps.empty()) return; auto vendorData = vendorComps.at(0); if (vendorData.buyScalar == 0.0) m_BuyScalar = Game::zoneManager->GetWorldConfig().vendorBuyMultiplier; else m_BuyScalar = vendorData.buyScalar; m_SellScalar = vendorData.sellScalar; m_RefreshTimeSeconds = vendorData.refreshTimeSeconds; m_LootMatrixID = vendorData.LootMatrixIndex; } bool BaseVendorComponent::SellsItem(const LOT item) const { return std::count_if(m_Inventory.begin(), m_Inventory.end(), [item](const SoldItem& lhs) { return lhs.lot == item; }) > 0; } void BaseVendorComponent::HandleMrReeCameras(){ if (m_Parent->GetLOT() == 13569) { SetHasStandardCostItems(true); auto randomCamera = GeneralUtils::GenerateRandomNumber(0, 2); LOT camera = 0; DluAssert(randomCamera >= 0 && randomCamera <= 2); switch (randomCamera) { case 0: camera = 16253; // Grungagroid break; case 1: camera = 16254; // Hipstabrick break; case 2: camera = 16204; // Megabrixel snapshot break; } m_Inventory.push_back(SoldItem(camera, 0)); } } void BaseVendorComponent::Buy(Entity* buyer, LOT lot, uint32_t count) { // A count the client did not send as a positive number arrives here as a very large one if (!VendorPricing::IsValidBuyCount(count)) { LOG("Player %llu tried to buy %u of item %i from vendor %i", buyer->GetObjectID(), count, lot, m_Parent->GetLOT()); SendTransactionResult(buyer->GetObjectID(), buyer->GetSystemAddress(), eVendorTransactionResult::PURCHASE_FAIL); return; } if (!SellsItem(lot)) { auto* user = UserManager::Instance()->GetUser(buyer->GetSystemAddress()); CheatDetection::ReportCheat(user, buyer->GetSystemAddress(), "Attempted to buy item %i from achievement vendor %i that is not purchasable", lot, m_Parent->GetLOT()); SendTransactionResult(buyer->GetObjectID(), buyer->GetSystemAddress(), eVendorTransactionResult::PURCHASE_FAIL); return; } auto* inventoryComponent = buyer->GetComponent(); if (!inventoryComponent) { SendTransactionResult(buyer->GetObjectID(), buyer->GetSystemAddress(), eVendorTransactionResult::PURCHASE_FAIL); return; } CDComponentsRegistryTable* compRegistryTable = CDClientManager::GetTable(); CDItemComponentTable* itemComponentTable = CDClientManager::GetTable(); int itemCompID = compRegistryTable->GetByIDAndType(lot, eReplicaComponentType::ITEM); CDItemComponent itemComp = itemComponentTable->GetItemComponentByID(itemCompID); // Extra currency that needs to be deducted in case of crafting auto craftingCurrencies = CDItemComponentTable::ParseCraftingCurrencies(itemComp); for (const auto& [crafintCurrencyLOT, crafintCurrencyCount]: craftingCurrencies) { if (inventoryComponent->GetLotCount(crafintCurrencyLOT) < (static_cast(crafintCurrencyCount) * count)) { SendTransactionResult(buyer->GetObjectID(), buyer->GetSystemAddress(), eVendorTransactionResult::PURCHASE_FAIL); return; } } bool success = true; for (const auto& [crafintCurrencyLOT, crafintCurrencyCount]: craftingCurrencies) { success = inventoryComponent->RemoveItem(crafintCurrencyLOT, crafintCurrencyCount * count, eInventoryType::ALL); if (!success) break; } if (!success) { SendTransactionResult(buyer->GetObjectID(), buyer->GetSystemAddress(), eVendorTransactionResult::PURCHASE_FAIL); return; } float buyScalar = GetBuyScalar(); const uint64_t coinCost = VendorPricing::GetPriceTotal(itemComp.baseValue, buyScalar, count); Character* character = buyer->GetCharacter(); if (!character || character->GetCoins() < 0 || static_cast(character->GetCoins()) < coinCost) { SendTransactionResult(buyer->GetObjectID(), buyer->GetSystemAddress(), eVendorTransactionResult::PURCHASE_FAIL); return; } if (Inventory::IsValidItem(itemComp.currencyLOT)) { const uint64_t altCurrencyCost = VendorPricing::GetPricePerItem(itemComp.altCurrencyCost, buyScalar, count); if (inventoryComponent->GetLotCount(itemComp.currencyLOT) < altCurrencyCost) { SendTransactionResult(buyer->GetObjectID(), buyer->GetSystemAddress(), eVendorTransactionResult::PURCHASE_FAIL); return; } inventoryComponent->RemoveItem(itemComp.currencyLOT, static_cast(altCurrencyCost), eInventoryType::ALL); } character->SetCoins(character->GetCoins() - static_cast(coinCost), eLootSourceType::VENDOR, CoinSource::Object(*m_Parent)); // Live sent the vendor's LOT, and the coins paid when there were any, with the bought items LootMetrics metrics{}; metrics.sourceLot = m_Parent->GetLOT(); if (coinCost > 0) metrics.currencyDelta = -static_cast(coinCost); inventoryComponent->AddItem(lot, count, eLootSourceType::VENDOR, eInventoryType::INVALID, {}, LWOOBJID_EMPTY, true, false, LWOOBJID_EMPTY, eInventoryType::INVALID, 0, false, -1, metrics); SendTransactionResult(buyer->GetObjectID(), buyer->GetSystemAddress(), eVendorTransactionResult::PURCHASE_SUCCESS); } bool BaseVendorComponent::SetupItem(LOT item) { auto* itemComponentTable = CDClientManager::GetTable(); auto* compRegistryTable = CDClientManager::GetTable(); auto itemComponentID = compRegistryTable->GetByIDAndType(item, eReplicaComponentType::ITEM, -1); if (itemComponentID == -1) { LOG("Attempted to add item %i with ItemComponent ID -1 to vendor %i inventory. Not adding item!", itemComponentID, m_Parent->GetLOT()); return false; } if (!m_HasStandardCostItems || !m_HasMultiCostItems) { auto itemComponent = itemComponentTable->GetItemComponentByID(itemComponentID); if (!m_HasStandardCostItems && itemComponent.baseValue != -1) SetHasStandardCostItems(true); if (!m_HasMultiCostItems && !itemComponent.currencyCosts.empty()) SetHasMultiCostItems(true); } return true; } void BaseVendorComponent::SendStatusUpdate(const SystemAddress& sysAddr, bool bUpdateOnly) { GameMessages::VendorStatusUpdate statusUpdate; statusUpdate.target = m_Parent->GetObjectID(); statusUpdate.bUpdateOnly = bUpdateOnly; statusUpdate.inventoryList.reserve(m_Inventory.size()); for (const auto& item : m_Inventory) statusUpdate.inventoryList.push_back({ item.lot, item.sortPriority }); statusUpdate.Send(sysAddr); } void BaseVendorComponent::SendTransactionResult(LWOOBJID target, const SystemAddress& sysAddr, eVendorTransactionResult result) { GameMessages::VendorTransactionResult transactionResult; transactionResult.target = target; transactionResult.iResult = result; transactionResult.SendToClient(sysAddr); } void BaseVendorComponent::SellToVendor(Entity& player, const SystemAddress& sysAddr, LWOOBJID itemObjID, int32_t count) { Character* character = player.GetCharacter(); if (!character) return; InventoryComponent* inv = static_cast(player.GetComponent(eReplicaComponentType::INVENTORY)); if (!inv) return; Item* item = inv->FindItemById(itemObjID); if (!item) return; CDComponentsRegistryTable* compRegistryTable = CDClientManager::GetTable(); CDItemComponentTable* itemComponentTable = CDClientManager::GetTable(); int itemCompID = compRegistryTable->GetByIDAndType(item->GetLot(), eReplicaComponentType::ITEM); CDItemComponent itemComp = itemComponentTable->GetItemComponentByID(itemCompID); // Items with a base value of 0 or max int are special items that should not be sold if they're not sub items if (itemComp.baseValue == 0 || itemComp.baseValue == UINT_MAX) { SendTransactionResult(m_Parent->GetObjectID(), sysAddr, eVendorTransactionResult::SELL_FAIL); return; } // Only a count the stack holds can be sold, and an item the vendor already has can not be sold again const auto* const itemInventory = item->GetInventory(); if (!VendorPricing::IsValidStackCount(count, item->GetCount()) || !itemInventory || itemInventory->GetType() == eInventoryType::VENDOR_BUYBACK) { SendTransactionResult(m_Parent->GetObjectID(), sysAddr, eVendorTransactionResult::SELL_FAIL); return; } const float sellScalar = GetSellScalar(); const LOT soldLot = item->GetLot(); const uint32_t heldBefore = itemInventory->GetLotCount(soldLot); MakeRoomInBuyback(*inv, *item, static_cast(count)); inv->MoveItemToInventory(item, eInventoryType::VENDOR_BUYBACK, count, true, false, true); // Pay for the items that left the inventory const uint32_t heldAfter = itemInventory->GetLotCount(soldLot); const uint32_t soldCount = heldBefore > heldAfter ? heldBefore - heldAfter : 0; if (soldCount == 0) { SendTransactionResult(m_Parent->GetObjectID(), sysAddr, eVendorTransactionResult::SELL_FAIL); return; } if (Inventory::IsValidItem(itemComp.currencyLOT)) { const uint64_t altCurrency = VendorPricing::GetPricePerItem(itemComp.altCurrencyCost, sellScalar, soldCount); // Return alt currencies like faction tokens. if (altCurrency > 0) inv->AddItem(itemComp.currencyLOT, static_cast(std::min(altCurrency, UINT32_MAX)), eLootSourceType::VENDOR); } const uint64_t coins = VendorPricing::GetPricePerItem(itemComp.baseValue, sellScalar, soldCount); character->SetCoins(character->GetCoins() + static_cast(coins), eLootSourceType::VENDOR, CoinSource::Object(*m_Parent)); SendTransactionResult(m_Parent->GetObjectID(), sysAddr, eVendorTransactionResult::SELL_SUCCESS); } void BaseVendorComponent::BuybackFromVendor(Entity& player, const SystemAddress& sysAddr, LWOOBJID itemObjID, int32_t count) { Character* character = player.GetCharacter(); if (!character) return; InventoryComponent* inv = static_cast(player.GetComponent(eReplicaComponentType::INVENTORY)); if (!inv) return; Item* item = inv->FindItemById(itemObjID); if (!item) return; CDComponentsRegistryTable* compRegistryTable = CDClientManager::GetTable(); CDItemComponentTable* itemComponentTable = CDClientManager::GetTable(); int itemCompID = compRegistryTable->GetByIDAndType(item->GetLot(), eReplicaComponentType::ITEM); CDItemComponent itemComp = itemComponentTable->GetItemComponentByID(itemCompID); // Only an item the vendor holds for the player can be bought back, and only as many as there are const auto* const itemInventory = item->GetInventory(); if (!VendorPricing::IsValidStackCount(count, item->GetCount()) || !itemInventory || itemInventory->GetType() != eInventoryType::VENDOR_BUYBACK) { SendTransactionResult(m_Parent->GetObjectID(), sysAddr, eVendorTransactionResult::PURCHASE_FAIL); return; } const float sellScalar = GetSellScalar(); const uint64_t cost = VendorPricing::GetPriceTotal(itemComp.baseValue, sellScalar, count); if (character->GetCoins() < 0 || static_cast(character->GetCoins()) < cost) { SendTransactionResult(m_Parent->GetObjectID(), sysAddr, eVendorTransactionResult::PURCHASE_FAIL); return; } if (Inventory::IsValidItem(itemComp.currencyLOT)) { const uint64_t altCurrencyCost = VendorPricing::GetPricePerItem(itemComp.altCurrencyCost, sellScalar, count); if (inv->GetLotCount(itemComp.currencyLOT) < altCurrencyCost || !inv->RemoveItem(itemComp.currencyLOT, static_cast(altCurrencyCost), eInventoryType::ALL)) { SendTransactionResult(m_Parent->GetObjectID(), sysAddr, eVendorTransactionResult::PURCHASE_FAIL); return; } } //inv->RemoveItem(count, -1, iObjID); inv->MoveItemToInventory(item, Inventory::FindInventoryTypeForLot(item->GetLot()), count, true, false); character->SetCoins(character->GetCoins() - static_cast(cost), eLootSourceType::VENDOR, CoinSource::Object(*m_Parent)); //Game::entityManager->SerializeEntity(player); // so inventory updates SendTransactionResult(m_Parent->GetObjectID(), sysAddr, eVendorTransactionResult::PURCHASE_SUCCESS); } void BaseVendorComponent::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; 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++; } }