Files
DarkflameServer/dGame/dComponents/Vendor/BaseVendorComponent.cpp
Aaron Kimbrell 2d24608186 fix: pay a vendor sale by the items that left the inventory
Selling paid for the count in the message while the move stopped at
what the stack held. The count now has to be between 1 and the stack's
count, the item can not be one the vendor already holds for buy back,
and the coins and alternate currency are worked out from the number of
items that left the inventory.

Buying back needs an item in the buy back inventory and a count the
stack holds. Both prices are worked out as 64 bit numbers.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 16:04:30 -06:00

421 lines
18 KiB
C++

#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 <algorithm>
#include <ranges>
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<BaseVendorComponent*>(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<std::string> 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<uint32_t>(itemString);
if (!item) continue;
if (SetupItem(item.value())) {
sortPriority++;
m_Inventory.push_back(SoldItem(item.value(), sortPriority));
}
}
return;
}
auto* lootMatrixTable = CDClientManager::GetTable<CDLootMatrixTable>();
const auto& lootMatrices = lootMatrixTable->GetMatrix(m_LootMatrixID);
if (lootMatrices.empty()) return;
auto* lootTableTable = CDClientManager::GetTable<CDLootTableTable>();
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<int32_t>(lootMatrix.minToDrop, lootMatrix.maxToDrop);
for (size_t i = 0; i < randomCount; i++) {
if (vendorItems.empty()) break;
auto randomItemIndex = GeneralUtils::GenerateRandomNumber<int32_t>(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<BaseVendorComponent*>(m_Parent->GetComponent(eReplicaComponentType::VENDOR));
if (vendor) vendor->SendStatusUpdate(UNASSIGNED_SYSTEM_ADDRESS);
}
void BaseVendorComponent::SetupConstants() {
auto* compRegistryTable = CDClientManager::GetTable<CDComponentsRegistryTable>();
int componentID = compRegistryTable->GetByIDAndType(m_Parent->GetLOT(), eReplicaComponentType::VENDOR);
auto* vendorComponentTable = CDClientManager::GetTable<CDVendorComponentTable>();
std::vector<CDVendorComponent> 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<int32_t>(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) {
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<InventoryComponent>();
if (!inventoryComponent) {
SendTransactionResult(buyer->GetObjectID(), buyer->GetSystemAddress(), eVendorTransactionResult::PURCHASE_FAIL);
return;
}
CDComponentsRegistryTable* compRegistryTable = CDClientManager::GetTable<CDComponentsRegistryTable>();
CDItemComponentTable* itemComponentTable = CDClientManager::GetTable<CDItemComponentTable>();
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) < (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 auto coinCost = static_cast<uint32_t>(std::floor((itemComp.baseValue * buyScalar) * count));
Character* character = buyer->GetCharacter();
if (!character || character->GetCoins() < coinCost) {
SendTransactionResult(buyer->GetObjectID(), buyer->GetSystemAddress(), eVendorTransactionResult::PURCHASE_FAIL);
return;
}
if (Inventory::IsValidItem(itemComp.currencyLOT)) {
const uint32_t altCurrencyCost = std::floor(itemComp.altCurrencyCost * buyScalar) * count;
if (inventoryComponent->GetLotCount(itemComp.currencyLOT) < altCurrencyCost) {
SendTransactionResult(buyer->GetObjectID(), buyer->GetSystemAddress(), eVendorTransactionResult::PURCHASE_FAIL);
return;
}
inventoryComponent->RemoveItem(itemComp.currencyLOT, altCurrencyCost, eInventoryType::ALL);
}
character->SetCoins(character->GetCoins() - (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<int32_t>(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<CDItemComponentTable>();
auto* compRegistryTable = CDClientManager::GetTable<CDComponentsRegistryTable>();
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<InventoryComponent*>(player.GetComponent(eReplicaComponentType::INVENTORY));
if (!inv) return;
Item* item = inv->FindItemById(itemObjID);
if (!item) return;
CDComponentsRegistryTable* compRegistryTable = CDClientManager::GetTable<CDComponentsRegistryTable>();
CDItemComponentTable* itemComponentTable = CDClientManager::GetTable<CDItemComponentTable>();
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<uint32_t>(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<uint32_t>(std::min<uint64_t>(altCurrency, UINT32_MAX)), eLootSourceType::VENDOR);
}
const uint64_t coins = VendorPricing::GetPricePerItem(itemComp.baseValue, sellScalar, soldCount);
character->SetCoins(character->GetCoins() + static_cast<int64_t>(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<InventoryComponent*>(player.GetComponent(eReplicaComponentType::INVENTORY));
if (!inv) return;
Item* item = inv->FindItemById(itemObjID);
if (!item) return;
CDComponentsRegistryTable* compRegistryTable = CDClientManager::GetTable<CDComponentsRegistryTable>();
CDItemComponentTable* itemComponentTable = CDClientManager::GetTable<CDItemComponentTable>();
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<uint64_t>(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<uint32_t>(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<int64_t>(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<int32_t>(slot);
move.SendToClient(player.GetSystemAddress());
}
slot++;
}
}