mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 19:03:43 +00:00
The buyback inventory grew by 9 slots whenever it was nearly full, so the vendor's buyback page kept resizing. Live kept 27 items: a 2014 capture of 29 sales shows that on the 28th, the server sent RemoveItemFromInventory (buyback inventory) for the first item sold, then added the new one. The buyback inventory now keeps its size. Before a sale needs a new buyback slot and the inventory holds 27 or more items, the oldest items (lowest object ID: each sale gives a new, higher ID) are removed. Sales that fit on an existing buyback stack remove nothing. The removal is not counted again by the economy ledger, which counted the items as gone when they were sold. Fixes issue 1129. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
352 lines
15 KiB
C++
352 lines
15 KiB
C++
#include "VendorComponent.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 "Item.h"
|
|
#include "EconomyLedger.h"
|
|
|
|
#include <algorithm>
|
|
#include <ranges>
|
|
|
|
VendorComponent::VendorComponent(Entity* parent, const int32_t componentID) : Component(parent, componentID) {
|
|
m_HasStandardCostItems = false;
|
|
m_HasMultiCostItems = false;
|
|
SetupConstants();
|
|
RefreshInventory(true);
|
|
}
|
|
|
|
void VendorComponent::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;
|
|
}
|
|
}
|
|
|
|
void VendorComponent::OnUse(Entity* originator) {
|
|
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<VendorComponent*>(m_Parent->GetComponent(eReplicaComponentType::VENDOR));
|
|
if (vendor) vendor->SendStatusUpdate(originator->GetSystemAddress());
|
|
}
|
|
|
|
void VendorComponent::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<VendorComponent*>(m_Parent->GetComponent(eReplicaComponentType::VENDOR));
|
|
if (vendor) vendor->SendStatusUpdate(UNASSIGNED_SYSTEM_ADDRESS);
|
|
}
|
|
|
|
void VendorComponent::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 VendorComponent::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 VendorComponent::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 VendorComponent::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);
|
|
inventoryComponent->AddItem(lot, count, eLootSourceType::VENDOR);
|
|
SendTransactionResult(buyer->GetObjectID(), buyer->GetSystemAddress(), eVendorTransactionResult::PURCHASE_SUCCESS);
|
|
}
|
|
|
|
bool VendorComponent::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 VendorComponent::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 VendorComponent::SendTransactionResult(LWOOBJID target, const SystemAddress& sysAddr, eVendorTransactionResult result) {
|
|
GameMessages::VendorTransactionResult transactionResult;
|
|
transactionResult.target = target;
|
|
transactionResult.iResult = result;
|
|
transactionResult.SendToClient(sysAddr);
|
|
}
|
|
|
|
void VendorComponent::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;
|
|
}
|
|
|
|
float sellScalar = GetSellScalar();
|
|
if (Inventory::IsValidItem(itemComp.currencyLOT)) {
|
|
const auto altCurrency = static_cast<uint32_t>(itemComp.altCurrencyCost * sellScalar) * count;
|
|
inv->AddItem(itemComp.currencyLOT, std::floor(altCurrency), eLootSourceType::VENDOR); // Return alt currencies like faction tokens.
|
|
}
|
|
|
|
MakeRoomInBuyback(*inv, *item, static_cast<uint32_t>(std::max(count, 0)));
|
|
inv->MoveItemToInventory(item, eInventoryType::VENDOR_BUYBACK, count, true, false, true);
|
|
character->SetCoins(std::floor(character->GetCoins() + (static_cast<uint32_t>(itemComp.baseValue * sellScalar) * count)), eLootSourceType::VENDOR);
|
|
SendTransactionResult(m_Parent->GetObjectID(), sysAddr, eVendorTransactionResult::SELL_SUCCESS);
|
|
}
|
|
|
|
void VendorComponent::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);
|
|
|
|
float sellScalar = GetSellScalar();
|
|
|
|
const auto cost = static_cast<uint32_t>(std::floor(((itemComp.baseValue * sellScalar) * count)));
|
|
|
|
if (character->GetCoins() < cost) {
|
|
SendTransactionResult(m_Parent->GetObjectID(), sysAddr, eVendorTransactionResult::PURCHASE_FAIL);
|
|
return;
|
|
}
|
|
|
|
if (Inventory::IsValidItem(itemComp.currencyLOT)) {
|
|
const uint32_t altCurrencyCost = std::floor(itemComp.altCurrencyCost * sellScalar) * count;
|
|
if (inv->GetLotCount(itemComp.currencyLOT) < altCurrencyCost || !inv->RemoveItem(itemComp.currencyLOT, 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() - cost, eLootSourceType::VENDOR);
|
|
//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)
|
|
}
|
|
}
|