diff --git a/data-canary/npc/the_lootmonger.lua b/data-canary/npc/the_lootmonger.lua new file mode 100644 index 00000000000..d26f1c8b3e8 --- /dev/null +++ b/data-canary/npc/the_lootmonger.lua @@ -0,0 +1,87 @@ +local internalNpcName = "The Lootmonger" +local npcType = Game.createNpcType(internalNpcName) +local npcConfig = {} + +npcConfig.name = internalNpcName +npcConfig.description = internalNpcName + +npcConfig.health = 100 +npcConfig.maxHealth = npcConfig.health +npcConfig.walkInterval = 2000 +npcConfig.walkRadius = 2 + +npcConfig.outfit = { + lookType = 1575, + lookHead = 96, + lookBody = 101, + lookLegs = 120, + lookFeet = 120, + lookAddons = 2, +} + +npcConfig.flags = { + floorchange = false, +} + +local keywordHandler = KeywordHandler:new() +local npcHandler = NpcHandler:new(keywordHandler) + +npcType.onThink = function(npc, interval) + npcHandler:onThink(npc, interval) +end + +npcType.onAppear = function(npc, creature) + npcHandler:onAppear(npc, creature) +end + +npcType.onDisappear = function(npc, creature) + npcHandler:onDisappear(npc, creature) +end + +npcType.onMove = function(npc, creature, fromPosition, toPosition) + npcHandler:onMove(npc, creature, fromPosition, toPosition) +end + +npcType.onSay = function(npc, creature, type, message) + npcHandler:onSay(npc, creature, type, message) +end + +npcType.onCloseChannel = function(npc, creature) + npcHandler:onCloseChannel(npc, creature) +end + +npcHandler:addModule(FocusModule:new(), npcConfig.name, true, true, true) + +npcConfig.shop = LootShopConfig + +local function creatureSayCallback(npc, player, type, message) + if not npcHandler:checkInteraction(npc, player) then + return false + end + local categoryTable = LootShopConfigTable[message:lower()] + if MsgContains(message, "shop options") then + npcHandler:say("I sell a selection of " .. GetFormattedShopCategoryNames(LootShopConfigTable), npc, player) + elseif categoryTable then + local remainingCategories = npc:getRemainingShopCategories(message:lower(), LootShopConfigTable) + npcHandler:say("Of course, just browse through my wares. You can also look at " .. remainingCategories .. ".", npc, player) + npc:openShopWindowTable(player, categoryTable) + end + return true +end + +npcHandler:setCallback(CALLBACK_MESSAGE_DEFAULT, creatureSayCallback) +npcHandler:setMessage(MESSAGE_GREET, "Ah, a customer! Be greeted, |PLAYERNAME|! I buy all kinds of loot, would you like a {trade}? I can also show you my {shop options}.") +npcHandler:setMessage(MESSAGE_SENDTRADE, "Ah, a customer! Be greeted, |PLAYERNAME|! I buy all kinds of loot, would you look at " .. GetFormattedShopCategoryNames(LootShopConfigTable) .. ".") + +-- On buy npc shop message +npcType.onBuyItem = function(npc, player, itemId, subType, amount, ignore, inBackpacks, totalCost) + npc:sellItem(player, itemId, amount, subType, 0, ignore, inBackpacks) +end +-- On sell npc shop message +npcType.onSellItem = function(npc, player, itemId, subtype, amount, ignore, name, totalCost) + player:sendTextMessage(MESSAGE_TRADE, string.format("Sold %ix %s for %i gold.", amount, name, totalCost)) +end +-- On check npc shop message (look item) +npcType.onCheckItem = function(npc, player, clientId, subType) end + +npcType:register(npcConfig) diff --git a/data/scripts/eventcallbacks/README.md b/data/scripts/eventcallbacks/README.md index 49e566e03d2..231bd35533a 100644 --- a/data/scripts/eventcallbacks/README.md +++ b/data/scripts/eventcallbacks/README.md @@ -144,7 +144,7 @@ Some event callbacks are expected to return a enum value, in this case, the enum Here is an example of a ReturnValue event callback: ```lua -local callback = EventCallback() +local callback = EventCallback("CreatureOnAreaCombat") function callback.creatureOnAreaCombat(creature, tile, isAggressive) -- if the creature is not aggressive, stop the execution of the C++ function diff --git a/data/scripts/talkactions/god/add_item_to_rewardchest.lua b/data/scripts/talkactions/god/add_item_to_rewardchest.lua new file mode 100644 index 00000000000..60e1e95a130 --- /dev/null +++ b/data/scripts/talkactions/god/add_item_to_rewardchest.lua @@ -0,0 +1,56 @@ +local addReward = TalkAction("/addreward") + +function addReward.onSay(player, words, param) + local args = param:split(",") + local itemInput = (args[1] or ""):trim() + local amount = tonumber(args[2]) or 1 + local targetName = args[3] and args[3]:trim() or player:getName() + + if itemInput == "" then + player:sendCancelMessage("Usage: /addreward item_name_or_id, amount[, target_player]") + return false + end + + -- resolve target + local target = Player(targetName) + if not target then + player:sendCancelMessage("Target player not found.") + return false + end + + -- resolve item + local it = ItemType(itemInput) + if it:getId() == 0 then + it = ItemType(tonumber(itemInput) or -1) + end + if it:getId() == 0 then + player:sendCancelMessage("Invalid item name or ID.") + return false + end + + if amount < 1 then + player:sendCancelMessage("Invalid amount.") + return false + end + + -- create/get the reward chest with a unique timestamp + local timestamp = systemTime() + local rewardBag = target:getReward(timestamp, true) + + -- **this** will split your `amount` across as many + -- containers as necessary and return the total added + local added = target:addItemBatchToPaginedContainer(rewardBag, it:getId(), amount) + if added > 0 then + player:sendTextMessage(MESSAGE_EVENT_ADVANCE, string.format("Added %d x %s to %s's reward chest(s).", added, it:getName(), target:getName())) + target:sendTextMessage(MESSAGE_EVENT_ADVANCE, string.format("You received %d x %s in your reward chest(s).", added, it:getName())) + target:getPosition():sendMagicEffect(CONST_ME_MAGIC_GREEN) + else + player:sendCancelMessage("Could not add item to reward chest.") + end + + return false +end + +addReward:separator(" ") +addReward:groupType("god") +addReward:register() diff --git a/data/scripts/talkactions/god/createitemtest.lua b/data/scripts/talkactions/god/createitemtest.lua new file mode 100644 index 00000000000..1a7162d513f --- /dev/null +++ b/data/scripts/talkactions/god/createitemtest.lua @@ -0,0 +1,316 @@ +local createLootTalkAction = TalkAction("/createloot") + +local availableItems = { + 3347, + 3348, + 3349, + 3350, + 3351, + 3352, + 3353, + 3354, + 3355, + 3356, + 3357, + 3358, + 3359, + 3360, + 3361, + 3362, +} + +-- Stackable items with chance and count range +local stackableItems = { + { id = 281, minCount = 10, maxCount = 25 }, -- giant shimmering pearl (green) + { id = 282, minCount = 10, maxCount = 35 }, -- giant shimmering pearl (brown) + { id = 3029, minCount = 50, maxCount = 100 }, -- small sapphire + { id = 3026, minCount = 50, maxCount = 90 }, -- white pearl + { id = 3033, minCount = 50, maxCount = 60 }, -- small amethyst + { id = 9057, minCount = 50, maxCount = 75 }, -- small topaz +} + +function createLootTalkAction.onSay(player, words, param) + local inbox = player:getSlotItem(CONST_SLOT_STORE_INBOX) + if not inbox then + player:sendCancelMessage("You don't have any store inbox.") + return true + end + + local lootPouch = nil + for _, item in ipairs(inbox:getItems(true)) do + if item:getId() == ITEM_GOLD_POUCH then + lootPouch = item + break + end + end + + if not lootPouch then + player:sendCancelMessage("You don't have a Loot Pouch in your store inbox.") + return true + end + + local amount = tonumber(param) + if not amount or amount <= 0 then + player:sendCancelMessage("Please provide a valid number of items to create.") + return true + end + + local maxAmount = 10000 + if amount > maxAmount then + player:sendCancelMessage("You can only create up to " .. maxAmount .. " items at once.") + return true + end + + local batchUpdate = BatchUpdate(player) + batchUpdate:add(lootPouch) + + local createdCount = 0 + local itemTotals = {} + for i = 1, amount do + local isStackable = math.random(100) <= 30 -- 30% chance to pick stackable + local itemId, itemCount + + if isStackable then + local itemData = stackableItems[math.random(#stackableItems)] + itemId = itemData.id + itemCount = math.random(itemData.minCount, itemData.maxCount) + else + itemId = availableItems[math.random(#availableItems)] + itemCount = 1 + end + + itemTotals[itemId] = (itemTotals[itemId] or 0) + itemCount + end + + local flags = bit.bor(FLAG_NOLIMIT, FLAG_LOOTPOUCH) + for itemId, itemCount in pairs(itemTotals) do + local added = player:addItemBatchToPaginedContainer(lootPouch, itemId, itemCount, 0, flags) + if added > 0 then + createdCount = createdCount + added + if added < itemCount then + logger.warn("[/createloot] - Player: {} partial add for item {}: requested {}, added {}", player:getName(), itemId, itemCount, added) + end + else + logger.warn("[/createloot] - Player: {} failed to add item {}", player:getName(), itemId) + end + end + + if createdCount > 0 then + player:sendTextMessage(MESSAGE_EVENT_ADVANCE, createdCount .. " items have been created in your Loot Pouch.") + logger.info("[/createloot] - Player: " .. player:getName() .. " created " .. createdCount .. " items in their Loot Pouch.") + else + player:sendCancelMessage("Failed to create items in the Loot Pouch.") + logger.error("[/createloot] - Player: " .. player:getName() .. " failed to create items in their Loot Pouch.") + end + + batchUpdate:delete() + return true +end + +createLootTalkAction:separator(" ") +createLootTalkAction:groupType("god") +createLootTalkAction:register() + +local clearStoreAction = TalkAction("/clearloot") + +function clearStoreAction.onSay(player, words, param) + local lootPouch = player:getLootPouch() + if not lootPouch then + player:sendCancelMessage("You don't have a Loot Pouch in your store inbox.") + return true + end + + local batchUpdate = BatchUpdate(player) + batchUpdate:add(lootPouch) + + local removedCount = lootPouch:removeAllItems(player) + player:sendTextMessage(MESSAGE_EVENT_ADVANCE, removedCount .. " items have been removed from your Loot Pouch.") + logger.info("[/clearloot] - Player: " .. player:getName() .. " removed " .. removedCount .. " items from their Loot Pouch.") + batchUpdate:delete() + return true +end + +clearStoreAction:separator(" ") +clearStoreAction:groupType("god") +clearStoreAction:register() + +local createShopLootAction = TalkAction("/createtestshop") + +function createShopLootAction.onSay(player, words, param) + -- get the Store Inbox slot and the Loot Pouch inside it + local inbox = player:getSlotItem(CONST_SLOT_STORE_INBOX) + if not inbox then + player:sendCancelMessage("You don't have any store inbox.") + return true + end + + local lootPouch + for _, item in ipairs(inbox:getItems(true)) do + if item:getId() == ITEM_GOLD_POUCH then + lootPouch = item + break + end + end + if not lootPouch then + player:sendCancelMessage("You don't have a Loot Pouch in your store inbox.") + return true + end + + local createdCount = 0 + + local batchUpdate = BatchUpdate(player) + batchUpdate:add(lootPouch) + + for _, cfg in ipairs(LootShopConfig) do + local count = cfg.count or 1 + + if cfg.clientId ~= ITEM_GOLD_POUCH then + local added = player:addItemBatchToPaginedContainer(lootPouch, cfg.clientId, count) + + if added > 0 then + createdCount = createdCount + 1 + logger.debug("[/createtestshop] Player {} added {} x {} (id {}), actually added {}", player:getName(), count, cfg.itemName, cfg.clientId, added) + if added < count then + logger.warn("[/createtestshop] Partial add for {} (id {}): requested {}, added {}", cfg.itemName, cfg.clientId, count, added) + end + else + logger.warn("[/createtestshop] Player {} failed to add {} (id {})", player:getName(), cfg.itemName, cfg.clientId) + end + end + end + + if createdCount > 0 then + player:sendTextMessage(MESSAGE_EVENT_ADVANCE, createdCount .. " item types have been created in your Loot Pouch.") + else + player:sendCancelMessage("No items could be created in the Loot Pouch.") + end + + batchUpdate:delete() + return true +end + +createShopLootAction:separator(" ") +createShopLootAction:groupType("god") +createShopLootAction:register() + +local countLootPouchAction = TalkAction("/countloot") + +function countLootPouchAction.onSay(player, words, param) + local lootPouch = player:getLootPouch() + if not lootPouch then + player:sendCancelMessage("You don't have a Loot Pouch in your store inbox.") + return true + end + + local totalStacks = #lootPouch:getItems(true) + + local totalCount = 0 + for _, item in ipairs(lootPouch:getItems(true)) do + totalCount = totalCount + item:getCount() + end + + player:sendTextMessage(MESSAGE_EVENT_ADVANCE, ("Your Loot Pouch contains %d stacks, totaling %d items."):format(totalStacks, totalCount)) + + return true +end + +countLootPouchAction:separator(" ") +countLootPouchAction:groupType("god") +countLootPouchAction:register() + +local addLootAction = TalkAction("/addloot") + +local function trim(s) + return (s:gsub("^%s+", ""):gsub("%s+$", "")) +end + +local function resolveItemId(token) + local id = tonumber(token) + if id then + return id + end + local it = ItemType(token) + if it and it:getId() ~= 0 then + return it:getId() + end + return nil +end + +function addLootAction.onSay(player, words, param) + param = trim(param or "") + if param == "" then + player:sendCancelMessage("Usage: /addloot [count]") + return true + end + + local lootPouch = player:getLootPouch() + if not lootPouch then + player:sendCancelMessage("You don't have a Loot Pouch in your store inbox.") + return true + end + + local namePart, countPart = param:match("^(.-)%s+(%d+)$") + local token = namePart and trim(namePart) or param + local count = tonumber(countPart) or 1 + if count <= 0 then + count = 1 + end + + local itemId = resolveItemId(token) + if not itemId or itemId == 0 then + player:sendCancelMessage("Unknown item: " .. token) + return true + end + if itemId == ITEM_GOLD_POUCH then + player:sendCancelMessage("You can't create a Loot Pouch inside a Loot Pouch.") + return true + end + + local batchUpdate = BatchUpdate(player) + batchUpdate:add(lootPouch) + + local itype = ItemType(itemId) + local name = itype and itype:getName() or ("item " .. itemId) + local created = 0 + + if itype and itype:isStackable() then + local item = Game.createItem(itemId, count) + local flags = bit.bor(FLAG_NOLIMIT, FLAG_LOOTPOUCH) + if item and lootPouch:addItemEx(item, INDEX_WHEREEVER, flags) == RETURNVALUE_NOERROR then + created = 1 + else + if item then + item:remove() + end + end + else + for i = 1, count do + local item = Game.createItem(itemId, 1) + if not item then + break + end + local flags = bit.bor(FLAG_NOLIMIT, FLAG_LOOTPOUCH) + if lootPouch:addItemEx(item, INDEX_WHEREEVER, flags) ~= RETURNVALUE_NOERROR then + item:remove() + break + end + created = created + 1 + end + end + + if created == 0 then + player:sendCancelMessage("Failed to create " .. name .. " in your Loot Pouch.") + else + if itype and itype:isStackable() then + player:sendTextMessage(MESSAGE_EVENT_ADVANCE, string.format("Added %dx %s to your Loot Pouch.", count, name)) + else + player:sendTextMessage(MESSAGE_EVENT_ADVANCE, string.format("Added %dx %s to your Loot Pouch.", created, name)) + end + end + batchUpdate:delete() + return true +end + +addLootAction:separator(" ") +addLootAction:groupType("god") +addLootAction:register() diff --git a/src/creatures/creature.hpp b/src/creatures/creature.hpp index 387a9f94654..bf6e4b425b5 100644 --- a/src/creatures/creature.hpp +++ b/src/creatures/creature.hpp @@ -575,6 +575,10 @@ class Creature : virtual public Thing, public SharedObject { return position; } + const Position &getPosition() const final { + return position; + } + std::shared_ptr getTile() final { return m_tile.lock(); } diff --git a/src/creatures/npcs/npc.cpp b/src/creatures/npcs/npc.cpp index ca9f6cb34e9..df03924f165 100644 --- a/src/creatures/npcs/npc.cpp +++ b/src/creatures/npcs/npc.cpp @@ -17,14 +17,298 @@ #include "game/scheduling/dispatcher.hpp" #include "lib/metrics/metrics.hpp" #include "lua/callbacks/creaturecallback.hpp" -#include "lua/global/shared_object.hpp" #include "map/spectators.hpp" +#include "utils/batch_update.hpp" int32_t Npc::despawnRange; int32_t Npc::despawnRadius; uint32_t Npc::npcAutoID = 0x80000000; +namespace { + constexpr uint32_t kShoppingBagPrice = 20; + constexpr uint32_t kShoppingBagSlots = 20; + + bool isBackpackSlotUnavailable(const std::shared_ptr &player, uint16_t itemId, bool ignore) { + if (ignore || player->getFreeBackpackSlots() != 0) { + return false; + } + + if (player->getInventoryItem(CONST_SLOT_BACKPACK)) { + return true; + } + + const auto &itemType = Item::items[itemId]; + return !itemType.isContainer() || !(itemType.slotPosition & SLOTP_BACKPACK); + } + + double calculateSlotsNeeded(const ItemType &itemType, uint16_t amount, bool inBackpacks) { + if (itemType.stackable) { + const auto stackSlots = std::ceil(static_cast(amount) / itemType.stackSize); + return inBackpacks ? std::ceil(stackSlots / kShoppingBagSlots) : stackSlots; + } + + return inBackpacks ? std::ceil(static_cast(amount) / kShoppingBagSlots) : static_cast(amount); + } + + bool exceedsTileLimit(const std::shared_ptr &player, const ItemType &itemType, uint16_t amount, bool inBackpacks, bool ignore) { + if (!ignore) { + return false; + } + + const std::shared_ptr &tile = player->getTile(); + if (!tile) { + return false; + } + + const auto slotsNeeded = calculateSlotsNeeded(itemType, amount, inBackpacks); + const auto* itemList = tile->getItemList(); + const auto itemCount = itemList ? static_cast(itemList->size()) : 0.0; + return (itemCount + (slotsNeeded - player->getFreeBackpackSlots())) > 30; + } + + uint64_t calculateBagsCost(const ItemType &itemType, uint16_t amount, bool inBackpacks) { + if (!inBackpacks) { + return 0; + } + + const auto slotsNeeded = calculateSlotsNeeded(itemType, amount, true); + return kShoppingBagPrice * static_cast(slotsNeeded); + } + + bool hasInsufficientFunds(const std::shared_ptr &player, uint16_t itemId, const std::string &npcName, uint16_t currency, uint64_t totalCost, uint64_t bagsCost) { + if (currency == ITEM_GOLD_COIN) { + const uint64_t totalRequired = totalCost + bagsCost; + const uint64_t availableFunds = player->getMoney() + player->getBankBalance(); + if (availableFunds < totalRequired) { + g_logger().error("[Npc::onPlayerBuyItem (getMoney)] - Player {} have a problem for buy item {} on shop for npc {}", player->getName(), itemId, npcName); + g_logger().debug("[Information] Player {} tried to buy item {} on shop for npc {}, at position {}", player->getName(), itemId, npcName, player->getPosition().toString()); + return true; + } + return false; + } + + const Cylinder* cylinder = player.get(); + if (cylinder->getItemTypeCount(currency) < totalCost || ((player->getMoney() + player->getBankBalance()) < bagsCost)) { + g_logger().error("[Npc::onPlayerBuyItem (getItemTypeCount)] - Player {} have a problem for buy item {} on shop for npc {}", player->getName(), itemId, npcName); + g_logger().debug("[Information] Player {} tried to buy item {} on shop for npc {}, at position {}", player->getName(), itemId, npcName, player->getPosition().toString()); + return true; + } + + return false; + } + + bool addCustomCurrencyItems(const std::shared_ptr &player, uint16_t currency, uint64_t totalCost, const std::string &npcName, const char* createErrorContext, const char* addErrorContext) { + const auto ¤cyType = Item::items[currency]; + const auto maxStack = static_cast(currencyType.stackable ? currencyType.stackSize : 1); + uint64_t remainingCost = totalCost; + while (remainingCost > 0) { + const auto stackSize = static_cast(std::min(remainingCost, maxStack)); + const auto &newItem = Item::CreateItem(currency, stackSize); + if (!newItem) { + g_logger().error("{} - Failed to create custom currency item {} for npc {}", createErrorContext, currency, npcName); + return false; + } + + const auto returnValue = g_game().internalPlayerAddItem(player, newItem, true); + if (returnValue != RETURNVALUE_NOERROR) { + g_logger().error("{} - Player: {} have a problem with custom currency, for add item: {} on shop for npc: {}, error code: {}", addErrorContext, player->getName(), newItem->getID(), npcName, getReturnMessage(returnValue)); + return false; + } + + remainingCost -= stackSize; + } + + return true; + } + + [[nodiscard]] uint32_t getSellPriceForItem(const std::vector &shopVector, uint16_t itemId) { + for (const ShopBlock &shopBlock : shopVector) { + if (itemId == shopBlock.itemId && shopBlock.itemSellPrice != 0) { + return shopBlock.itemSellPrice; + } + } + + return 0; + } + + uint32_t removeItemsFromLootPouch(const std::shared_ptr &lootPouch, uint16_t itemId, uint32_t amount, BatchUpdate* batchUpdate) { + if (!lootPouch || amount == 0) { + return 0; + } + + if (batchUpdate) { + const auto addResult = batchUpdate->add(lootPouch); + (void)addResult; + } + + uint32_t removed = 0; + uint32_t toRemove = amount; + for (size_t i = lootPouch->size(); i-- > 0 && toRemove > 0;) { + const auto &list = lootPouch->getItemList(); + const auto &item = list[i]; + if (!item || item->getID() != itemId || item->getTier() > 0 || item->hasImbuements()) { + continue; + } + + const auto removeCount = std::min(toRemove, static_cast(item->getItemAmount())); + lootPouch->removeItemByIndex(i, removeCount); + + toRemove -= removeCount; + removed += removeCount; + } + + return removed; + } + + uint32_t removeItemsFromInventory(const std::shared_ptr &player, uint16_t itemId, bool ignore, uint32_t amount, BatchUpdate* batchUpdate, const std::string &npcName) { + auto inventoryItems = player->getInventoryItemsFromId(itemId, ignore); + uint32_t removed = 0; + uint32_t toRemove = amount; + for (const auto &item : inventoryItems) { + if (toRemove == 0) { + break; + } + + if (!item || item->getTier() > 0 || item->hasImbuements()) { + continue; + } + + const auto &itemParent = item->getParent(); + auto container = itemParent ? itemParent->getContainer() : nullptr; + if (batchUpdate && container) { + const auto addResult = batchUpdate->add(container); + (void)addResult; + } + + const auto removeCount = std::min(toRemove, static_cast(item->getItemCount())); + if (player->removeItem(item, removeCount) != RETURNVALUE_NOERROR) { + g_logger().error("[Npc::onPlayerSellItem] - Player {} have a problem for sell item {} on shop for npc {}", player->getName(), item->getID(), npcName); + continue; + } + + toRemove -= removeCount; + removed += removeCount; + } + + return removed; + } + + void applyGoldSaleProceeds(const std::shared_ptr &player, uint64_t totalCost, bool notifyBankTransfer) { + if (g_configManager().getBoolean(AUTOBANK)) { + player->setBankBalance(player->getBankBalance() + totalCost); + if (notifyBankTransfer) { + player->sendTextMessage(MESSAGE_EVENT_ADVANCE, fmt::format("{} gold coins transferred to your bank.", totalCost)); + } + } else { + g_game().addMoney(player, totalCost); + } + g_metrics().addCounter("balance_increase", totalCost, { { "player", player->getName() }, { "context", "npc_sale" } }); + } + + struct CustomSaleContext { + const char* createErrorContext; + const char* addErrorContext; + const char* errorContext; + const char* failureMessage; + }; + + bool applyCustomSaleProceeds(const std::shared_ptr &player, uint16_t currency, uint64_t totalCost, const std::string &npcName, const CustomSaleContext &context) { + if (addCustomCurrencyItems(player, currency, totalCost, npcName, context.createErrorContext, context.addErrorContext)) { + return true; + } + + g_logger().error( + "{} - Failed to add custom currency {} (amount {}) to player {}. Sale aborted.", + context.errorContext, + currency, + totalCost, + player->getName() + ); + + player->sendTextMessage(MESSAGE_EVENT_ADVANCE, context.failureMessage); + return false; + } + + bool applySaleProceedsForLoot(const std::shared_ptr &player, uint16_t currency, uint64_t totalCost, const std::string &npcName) { + if (!totalCost) { + return true; + } + + if (currency == ITEM_GOLD_COIN) { + applyGoldSaleProceeds(player, totalCost, false); + return true; + } + + return applyCustomSaleProceeds( + player, + currency, + totalCost, + npcName, + CustomSaleContext { + "[Npc::onPlayerSellAllLoot]", + "[Npc::onPlayerSellAllLoot]", + "[Npc::onPlayerSellAllLoot]", + "An error occurred while completing the sale of your loot. No items were exchanged." } + ); + } + + bool applySaleProceedsForItem(const std::shared_ptr &player, uint16_t currency, uint64_t totalCost, const std::string &npcName, const Npc::SellItemContext &context) { + if (!totalCost) { + return true; + } + + if (currency == ITEM_GOLD_COIN) { + if (context.totalPrice) { + *context.totalPrice += totalCost; + } + applyGoldSaleProceeds(player, totalCost, !context.lootPouch); + return true; + } + + return applyCustomSaleProceeds( + player, + currency, + totalCost, + npcName, + CustomSaleContext { + "[Npc::onPlayerSellItem]", + "[Npc::onPlayerSellItem]", + "[Npc::onPlayerSellItem]", + "An error occurred while completing the sale. Your items were not exchanged." } + ); + } + + void sendSaleLetterIfNeeded(const std::shared_ptr &player, BatchUpdate &batching, const std::string &log, uint64_t totalPrice, const std::string &npcName) { + if (totalPrice == 0 || log.empty()) { + return; + } + + const auto &storeInbox = player->getStoreInbox(); + if (!storeInbox) { + g_logger().error("[Npc::onPlayerSellAllLoot] - Store inbox is nullptr for player {} when sending sale letter (npc: {})", player->getName(), npcName); + return; + } + + const auto addResult = batching.add(storeInbox); + (void)addResult; + + auto letter = Item::CreateItem(ITEM_LETTER_STAMPED); + if (!letter) { + return; + } + + letter->setAttribute(ItemAttribute_t::WRITER, fmt::format("Npc Seller: {}", npcName)); + letter->setAttribute(ItemAttribute_t::DATE, getTimeNow()); + letter->setAttribute(ItemAttribute_t::TEXT, log); + const auto returnValue = g_game().internalAddItem(storeInbox, letter, INDEX_WHEREEVER, FLAG_NOLIMIT); + if (returnValue != RETURNVALUE_NOERROR) { + g_logger().error("[Npc::onPlayerSellAllLoot] - Failed to add sale letter for player {} to store inbox (npc: {}), error: {}", player->getName(), npcName, getReturnMessage(returnValue)); + player->sendTextMessage(MESSAGE_EVENT_ADVANCE, getReturnMessage(returnValue)); + } + } +} + std::shared_ptr Npc::createNpc(const std::string &name) { const auto &npcType = g_npcs().getNpcType(name); if (!npcType) { @@ -93,7 +377,7 @@ void Npc::setName(std::string newName) const { npcType->name = std::move(newName); } -const std::string &Npc::getLowerName() const { +[[nodiscard]] const std::string &Npc::getLowerName() const { return npcType->m_lowerName; } @@ -101,7 +385,7 @@ CreatureType_t Npc::getType() const { return CREATURETYPE_NPC; } -const Position &Npc::getMasterPos() const { +[[nodiscard]] const Position &Npc::getMasterPos() const { return masterPos; } @@ -358,26 +642,15 @@ void Npc::onPlayerBuyItem(const std::shared_ptr &player, uint16_t itemId } // Check if the player not have empty slots or the item is not a container - if (!ignore && (player->getFreeBackpackSlots() == 0 && (player->getInventoryItem(CONST_SLOT_BACKPACK) || (!Item::items[itemId].isContainer() || !(Item::items[itemId].slotPosition & SLOTP_BACKPACK))))) { + if (isBackpackSlotUnavailable(player, itemId, ignore)) { player->sendCancelMessage(RETURNVALUE_NOTENOUGHROOM); return; } - constexpr uint32_t shoppingBagPrice = 20; - constexpr uint32_t shoppingBagSlots = 20; - const ItemType &itemType = Item::items[itemId]; - if (const std::shared_ptr &tile = ignore ? player->getTile() : nullptr; tile) { - double slotsNedeed; - if (itemType.stackable) { - slotsNedeed = inBackpacks ? std::ceil(std::ceil(static_cast(amount) / itemType.stackSize) / shoppingBagSlots) : std::ceil(static_cast(amount) / itemType.stackSize); - } else { - slotsNedeed = inBackpacks ? std::ceil(static_cast(amount) / shoppingBagSlots) : static_cast(amount); - } - - if ((static_cast(tile->getItemList()->size()) + (slotsNedeed - player->getFreeBackpackSlots())) > 30) { - player->sendCancelMessage(RETURNVALUE_NOTENOUGHROOM); - return; - } + const auto &itemType = Item::items[itemId]; + if (exceedsTileLimit(player, itemType, amount, inBackpacks, ignore)) { + player->sendCancelMessage(RETURNVALUE_NOTENOUGHROOM); + return; } uint32_t buyPrice = 0; @@ -389,22 +662,14 @@ void Npc::onPlayerBuyItem(const std::shared_ptr &player, uint16_t itemId } const uint32_t totalCost = buyPrice * amount; - uint32_t bagsCost = 0; - if (inBackpacks && itemType.stackable) { - bagsCost = shoppingBagPrice * static_cast(std::ceil(std::ceil(static_cast(amount) / itemType.stackSize) / shoppingBagSlots)); - } else if (inBackpacks && !itemType.stackable) { - bagsCost = shoppingBagPrice * static_cast(std::ceil(static_cast(amount) / shoppingBagSlots)); + const uint32_t bagsCost = calculateBagsCost(itemType, amount, inBackpacks); + const uint64_t totalRequired = static_cast(totalCost) + bagsCost; + if (hasInsufficientFunds(player, itemId, getName(), getCurrency(), totalCost, bagsCost)) { + return; } - if (getCurrency() == ITEM_GOLD_COIN && (player->getMoney() + player->getBankBalance()) < totalCost) { - g_logger().error("[Npc::onPlayerBuyItem (getMoney)] - Player {} have a problem for buy item {} on shop for npc {}", player->getName(), itemId, getName()); - g_logger().debug("[Information] Player {} tried to buy item {} on shop for npc {}, at position {}", player->getName(), itemId, getName(), player->getPosition().toString()); - g_metrics().addCounter("balance_decrease", totalCost, { { "player", player->getName() }, { "context", "npc_purchase" } }); - return; - } else if (getCurrency() != ITEM_GOLD_COIN && (player->getItemTypeCount(getCurrency()) < totalCost || ((player->getMoney() + player->getBankBalance()) < bagsCost))) { - g_logger().error("[Npc::onPlayerBuyItem (getItemTypeCount)] - Player {} have a problem for buy item {} on shop for npc {}", player->getName(), itemId, getName()); - g_logger().debug("[Information] Player {} tried to buy item {} on shop for npc {}, at position {}", player->getName(), itemId, getName(), player->getPosition().toString()); - return; + if (getCurrency() == ITEM_GOLD_COIN) { + g_metrics().addCounter("balance_decrease", totalRequired, { { "player", player->getName() }, { "context", "npc_purchase" } }); } // npc:onBuyItem(player, itemId, subType, amount, ignore, inBackpacks, totalCost) @@ -425,141 +690,160 @@ void Npc::onPlayerBuyItem(const std::shared_ptr &player, uint16_t itemId } } -void Npc::onPlayerSellItem(const std::shared_ptr &player, uint16_t itemId, uint8_t subType, uint16_t amount, bool ignore) { +void Npc::onPlayerSellItem(const std::shared_ptr &player, uint16_t itemId, uint8_t subType, uint32_t amount, bool ignore) { uint64_t totalPrice = 0; - onPlayerSellItem(player, itemId, subType, amount, ignore, totalPrice); + onPlayerSellItem(player, itemId, subType, amount, ignore, SellItemContext(totalPrice)); } -void Npc::onPlayerSellAllLoot(uint32_t playerId, uint16_t itemId, bool ignore, uint64_t totalPrice) { - const auto &player = g_game().getPlayerByID(playerId); +void Npc::onPlayerSellAllLoot(const std::shared_ptr &player, bool ignore, uint64_t &totalPrice) { if (!player) { return; } - if (itemId == ITEM_GOLD_POUCH) { - const auto &container = player->getLootPouch(); - if (!container) { - return; - } - bool hasMore = false; - uint64_t toSellCount = 0; - phmap::flat_hash_map toSell; - for (ContainerIterator it = container->iterator(); it.hasNext(); it.advance()) { - if (toSellCount >= 500) { - hasMore = true; - break; - } - const auto &item = *it; - if (!item) { - continue; - } - toSell[item->getID()] += item->getItemAmount(); - if (item->isStackable()) { - toSellCount++; - } else { - toSellCount += item->getItemAmount(); - } - } - for (const auto &[m_itemId, amount] : toSell) { - onPlayerSellItem(player, m_itemId, 0, amount, ignore, totalPrice, container); - } - auto ss = std::stringstream(); - if (totalPrice == 0) { - ss << "You have no items in your loot pouch."; - player->sendTextMessage(MESSAGE_TRANSACTION, ss.str()); - return; - } - if (hasMore) { - g_dispatcher().scheduleEvent( - SCHEDULER_MINTICKS, [this, playerId = player->getID(), itemId, ignore, totalPrice] { onPlayerSellAllLoot(playerId, itemId, ignore, totalPrice); }, __FUNCTION__ - ); - return; - } - ss << "You sold all of the items from your loot pouch for "; - ss << totalPrice << " gold."; - player->sendTextMessage(MESSAGE_TRANSACTION, ss.str()); - player->openPlayerContainers(); - } -} -void Npc::onPlayerSellItem(const std::shared_ptr &player, uint16_t itemId, uint8_t subType, uint16_t amount, bool ignore, uint64_t &totalPrice, const std::shared_ptr &parent /*= nullptr*/) { - if (!player) { - return; - } - if (itemId == ITEM_GOLD_POUCH) { - g_dispatcher().scheduleEvent( - SCHEDULER_MINTICKS, [this, playerId = player->getID(), itemId, ignore] { onPlayerSellAllLoot(playerId, itemId, ignore, 0); }, __FUNCTION__ - ); + const auto &lootPouch = player->getLootPouch(); + if (!lootPouch) { return; } - uint32_t sellPrice = 0; - const ItemType &itemType = Item::items[itemId]; + struct LootSaleData { + uint32_t price = 0; + uint32_t amount = 0; + }; + const auto &shopVector = getShopItemVector(player->getGUID()); + phmap::flat_hash_map saleData; + saleData.reserve(shopVector.size()); for (const ShopBlock &shopBlock : shopVector) { - if (itemType.id == shopBlock.itemId && shopBlock.itemSellPrice != 0) { - sellPrice = shopBlock.itemSellPrice; + if (shopBlock.itemSellPrice == 0) { + continue; } + + auto &data = saleData[shopBlock.itemId]; + data.price = shopBlock.itemSellPrice; } - if (sellPrice == 0) { - return; + + BatchUpdate batching(player); + if (!saleData.empty()) { + const auto addResult = batching.add(lootPouch); + (void)addResult; } - auto toRemove = amount; - for (const auto &item : player->getInventoryItemsFromId(itemId, ignore)) { - if (!item || item->getTier() > 0 || item->hasImbuements()) { + for (size_t index = lootPouch->size(); index > 0;) { + --index; + + const auto &list = lootPouch->getItemList(); + if (index >= list.size()) { continue; } - if (const auto &container = item->getContainer()) { - if (container->size() > 0) { - player->sendTextMessage(MESSAGE_EVENT_ADVANCE, "You must empty the container before selling it."); - continue; - } + const auto &item = list[index]; + if (!item) { + continue; } - if (parent && item->getParent() != parent) { + const auto it = saleData.find(item->getID()); + if (it == saleData.end()) { continue; } - if (!item->hasMarketAttributes()) { + if (item->getTier() > 0 || item->hasImbuements()) { continue; } - auto removeCount = std::min(toRemove, item->getItemCount()); + const auto removeCount = static_cast(item->getItemAmount()); + if (removeCount == 0) { + continue; + } + + it->second.amount += removeCount; + } + + std::string log; + log.reserve(saleData.size() * 64); // Median of 64 bytes per line + uint32_t totalItemsSold = 0; + for (const auto &[itemId, data] : saleData) { + if (data.amount == 0) { + continue; + } - if (g_game().internalRemoveItem(item, removeCount) != RETURNVALUE_NOERROR) { - g_logger().error("[Npc::onPlayerSellItem] - Player {} have a problem for sell item {} on shop for npc {}", player->getName(), item->getID(), getName()); + const auto totalCost = static_cast(data.price) * data.amount; + if (!totalCost) { continue; } - toRemove -= removeCount; - if (toRemove == 0) { - break; + totalPrice += totalCost; + + const auto &itemName = Item::items.getItemType(itemId).name; + log += fmt::format("Sold {}x {} for {} gold.\n", data.amount, itemName, totalCost); + totalItemsSold += data.amount; + } + + if (totalPrice > 0) { + if (!applySaleProceedsForLoot(player, getCurrency(), totalPrice, getName())) { + return; } + + for (const auto &[itemId, data] : saleData) { + if (data.amount == 0) { + continue; + } + + removeItemsFromLootPouch(lootPouch, itemId, data.amount, &batching); + } + } + + std::string finalMessage; + if (totalPrice == 0) { + const auto &appendResult = finalMessage.append("You have no items in your loot pouch."); + (void)appendResult; + } else { + finalMessage = fmt::format("You sold {} item{} from your loot pouch for {} gold. A letter with the full list has been sent to your store inbox.", totalItemsSold, (totalItemsSold == 1 ? "" : "s"), totalPrice); } - auto totalRemoved = amount - toRemove; - if (totalRemoved == 0) { + player->sendTextMessage(MESSAGE_TRANSACTION, finalMessage); + g_logger().debug("Npc::onPlayerSellItem Finished npc sell items"); + + sendSaleLetterIfNeeded(player, batching, log, totalPrice, getName()); +} + +void Npc::onPlayerSellItem(const std::shared_ptr &player, uint16_t itemId, uint8_t subType, uint32_t amount, bool ignore, const SellItemContext &context) { + if (!player) { return; } - auto totalCost = static_cast(sellPrice * totalRemoved); - g_logger().debug("[Npc::onPlayerSellItem] - Removing items from player {} amount {} of items with id {} on shop for npc {}", player->getName(), toRemove, itemId, getName()); - if (totalRemoved > 0 && totalCost > 0) { - if (getCurrency() == ITEM_GOLD_COIN) { - totalPrice += totalCost; - if (g_configManager().getBoolean(AUTOBANK)) { - player->setBankBalance(player->getBankBalance() + totalCost); - } else { - g_game().addMoney(player, totalCost); - } - g_metrics().addCounter("balance_increase", totalCost, { { "player", player->getName() }, { "context", "npc_sale" } }); - } else { - const auto &newItem = Item::CreateItem(getCurrency(), totalCost); - if (newItem) { - g_game().internalPlayerAddItem(player, newItem, true); - } + if (itemId == ITEM_GOLD_POUCH && context.lootPouch == nullptr) { + uint64_t totalPrice = 0; + auto &totalPriceRef = context.totalPrice ? *context.totalPrice : totalPrice; + onPlayerSellAllLoot(player, ignore, totalPriceRef); + return; + } + + const auto &itemType = Item::items[itemId]; + const auto &shopVector = getShopItemVector(player->getGUID()); + const auto sellPrice = getSellPriceForItem(shopVector, itemType.id); + if (sellPrice == 0) { + return; + } + + const auto removed = context.lootPouch + ? removeItemsFromLootPouch(context.lootPouch, itemId, amount, context.batchUpdate) + : removeItemsFromInventory(player, itemId, ignore, amount, context.batchUpdate, getName()); + + if (removed == 0) { + if (!context.lootPouch) { + player->sendTextMessage(MESSAGE_EVENT_ADVANCE, "You have no items to sell."); } + return; + } + + auto totalCost = static_cast(sellPrice) * removed; + + if (!applySaleProceedsForItem(player, getCurrency(), totalCost, getName(), context)) { + return; + } + + if (context.lootPouch) { + return; } // npc:onSellItem(player, itemId, subType, amount, ignore, itemName, totalCost) @@ -569,7 +853,7 @@ void Npc::onPlayerSellItem(const std::shared_ptr &player, uint16_t itemI callback.pushCreature(player); callback.pushNumber(itemType.id); callback.pushNumber(subType); - callback.pushNumber(totalRemoved); + callback.pushNumber(removed); callback.pushBoolean(ignore); callback.pushString(itemType.name); callback.pushNumber(totalCost); @@ -660,7 +944,7 @@ void Npc::onThinkWalk(uint32_t interval) { if (Direction newDirection; getRandomStep(newDirection)) { - listWalkDir.emplace_back(newDirection); + listWalkDir.push_back(newDirection); addEventWalk(); } @@ -716,7 +1000,7 @@ void Npc::setPlayerInteraction(uint32_t playerId, uint16_t topicId /*= 0*/) { } if (playerInteractionsOrder.empty() || std::ranges::find(playerInteractionsOrder, playerId) == playerInteractionsOrder.end()) { - playerInteractionsOrder.emplace_back(playerId); + playerInteractionsOrder.push_back(playerId); turnToCreature(creature); } diff --git a/src/creatures/npcs/npc.hpp b/src/creatures/npcs/npc.hpp index 1ec9bbf990b..117544944c3 100644 --- a/src/creatures/npcs/npc.hpp +++ b/src/creatures/npcs/npc.hpp @@ -19,6 +19,7 @@ class Tile; class Creature; class Game; class SpawnNpc; +class BatchUpdate; class Npc final : public Creature { public: @@ -49,11 +50,11 @@ class Npc final : public Creature { void setName(std::string newName) const; - const std::string &getLowerName() const; + [[nodiscard]] const std::string &getLowerName() const; CreatureType_t getType() const override; - const Position &getMasterPos() const; + [[nodiscard]] const Position &getMasterPos() const; void setMasterPos(Position pos); uint8_t getSpeechBubble() const override; @@ -86,9 +87,21 @@ class Npc final : public Creature { void onCreatureSay(const std::shared_ptr &creature, SpeakClasses type, const std::string &text) override; void onThink(uint32_t interval) override; void onPlayerBuyItem(const std::shared_ptr &player, uint16_t itemid, uint8_t count, uint16_t amount, bool ignore, bool inBackpacks); - void onPlayerSellAllLoot(uint32_t playerId, uint16_t itemid, bool ignore, uint64_t totalPrice); - void onPlayerSellItem(const std::shared_ptr &player, uint16_t itemid, uint8_t count, uint16_t amount, bool ignore); - void onPlayerSellItem(const std::shared_ptr &player, uint16_t itemid, uint8_t count, uint16_t amount, bool ignore, uint64_t &totalPrice, const std::shared_ptr &parent = nullptr); + void onPlayerSellAllLoot(const std::shared_ptr &player, bool ignore, uint64_t &totalPrice); + struct SellItemContext { + SellItemContext() = default; + explicit SellItemContext(uint64_t &price, const std::shared_ptr &lootPouchIn = {}, BatchUpdate* batchUpdateIn = nullptr) : + totalPrice(&price), + lootPouch(lootPouchIn), + batchUpdate(batchUpdateIn) { } + + uint64_t* totalPrice = nullptr; + std::shared_ptr lootPouch {}; + BatchUpdate* batchUpdate = nullptr; + }; + + void onPlayerSellItem(const std::shared_ptr &player, uint16_t itemid, uint8_t count, uint32_t amount, bool ignore); + void onPlayerSellItem(const std::shared_ptr &player, uint16_t itemid, uint8_t count, uint32_t amount, bool ignore, const SellItemContext &context); void onPlayerCheckItem(const std::shared_ptr &player, uint16_t itemid, uint8_t count); void onPlayerCloseChannel(const std::shared_ptr &creature); void onPlacedCreature() override; diff --git a/src/game/game.cpp b/src/game/game.cpp index 4f4416fdb35..4da80c3541c 100644 --- a/src/game/game.cpp +++ b/src/game/game.cpp @@ -59,6 +59,7 @@ #include "utils/wildcardtree.hpp" #include "creatures/players/vocations/vocation.hpp" #include "creatures/players/components/wheel/wheel_definitions.hpp" +#include "utils/batch_update.hpp" #include "enums/account_coins.hpp" #include "enums/account_errors.hpp" @@ -2742,7 +2743,8 @@ void Game::addMoney(const std::shared_ptr &cylinder, uint64_t money, u ReturnValue ret = internalAddItem(cylinder, remaindItem, INDEX_WHEREEVER, flags); if (ret != RETURNVALUE_NOERROR) { - internalAddItem(cylinder->getTile(), remaindItem, INDEX_WHEREEVER, FLAG_NOLIMIT); + const auto fallbackResult = internalAddItem(cylinder->getTile(), remaindItem, INDEX_WHEREEVER, FLAG_NOLIMIT); + (void)fallbackResult; } count -= createCount; @@ -3095,21 +3097,37 @@ void Game::playerQuickLootCorpse(const std::shared_ptr &player, const st player->lastQuickLootNotification = OTSYS_TIME(); } -std::shared_ptr Game::findManagedContainer(const std::shared_ptr &player, bool &fallbackConsumed, ObjectCategory_t category, bool isLootContainer) { - auto lootContainer = player->getManagedContainer(category, isLootContainer); - if (!lootContainer && player->quickLootFallbackToMainContainer && !fallbackConsumed) { - auto fallbackItem = player->getInventoryItem(CONST_SLOT_BACKPACK); - auto mainBackpack = fallbackItem ? fallbackItem->getContainer() : nullptr; +std::shared_ptr Game::findManagedContainer( + const std::shared_ptr &player, + bool &fallbackConsumed, + ObjectCategory_t category, + bool isLootContainer +) { + const auto candidate = player->getManagedContainer(category, isLootContainer); - if (mainBackpack) { - player->refreshManagedContainer(OBJECTCATEGORY_DEFAULT, mainBackpack, isLootContainer); + std::shared_ptr result = nullptr; + if (candidate) { + if (player->isHoldingItem(candidate)) { + result = candidate; + } else { + player->checkLootContainers(candidate); + player->sendLootContainers(); + } + } + + if (!result && player->quickLootFallbackToMainContainer && !fallbackConsumed) { + const auto fbItem = player->getInventoryItem(CONST_SLOT_BACKPACK); + const auto mainBp = fbItem ? fbItem->getContainer() : nullptr; + if (mainBp) { + const auto previousContainer = player->refreshManagedContainer(OBJECTCATEGORY_DEFAULT, mainBp, isLootContainer); + (void)previousContainer; player->sendInventoryItem(CONST_SLOT_BACKPACK, player->getInventoryItem(CONST_SLOT_BACKPACK)); - lootContainer = mainBackpack; + result = mainBp; fallbackConsumed = true; } } - return lootContainer; + return result; } std::shared_ptr Game::findNextAvailableContainer(ContainerIterator &containerIterator, std::shared_ptr &lootContainer, std::shared_ptr &lastSubContainer) { @@ -3141,24 +3159,28 @@ bool Game::handleFallbackLogic(const std::shared_ptr &player, std::share return false; } - std::shared_ptr fallbackItem = player->getInventoryItem(CONST_SLOT_BACKPACK); + std::shared_ptr fallbackItem = player->getBackpack(); if (!fallbackItem || !fallbackItem->getContainer()) { return false; } lootContainer = fallbackItem->getContainer(); - containerIterator = lootContainer->iterator(); return true; } ReturnValue Game::processMoveOrAddItemToLootContainer(const std::shared_ptr &item, const std::shared_ptr &lootContainer, uint32_t &remainderCount, const std::shared_ptr &player) { + if (!lootContainer || !item) { + return RETURNVALUE_NOTPOSSIBLE; + } + std::shared_ptr moveItem = nullptr; ReturnValue ret; + uint32_t flags = lootContainer->getID() == ITEM_GOLD_POUCH ? FLAG_LOOTPOUCH : 0; if (item->getParent()) { - ret = internalMoveItem(item->getParent(), lootContainer, INDEX_WHEREEVER, item, item->getItemCount(), &moveItem, 0, player, nullptr, false); + ret = internalMoveItem(item->getParent(), lootContainer, INDEX_WHEREEVER, item, item->getItemCount(), &moveItem, flags, player, nullptr, false); } else { - ret = internalAddItem(lootContainer, item, INDEX_WHEREEVER); + ret = internalAddItem(lootContainer, item, INDEX_WHEREEVER, flags); } if (moveItem) { remainderCount -= moveItem->getItemCount(); @@ -3166,10 +3188,14 @@ ReturnValue Game::processMoveOrAddItemToLootContainer(const std::shared_ptr &player, std::shared_ptr lootContainer, const std::shared_ptr &item, bool &fallbackConsumed) { +ReturnValue Game::processLootItems(const std::shared_ptr &player, std::shared_ptr lootContainer, const std::shared_ptr &item, bool &fallbackConsumed, BatchUpdate* batchUpdate) { std::shared_ptr lastSubContainer = nullptr; uint32_t remainderCount = item->getItemCount(); ContainerIterator containerIterator = lootContainer->iterator(); + if (batchUpdate) { + const auto addResult = batchUpdate->add(lootContainer); + (void)addResult; + } ReturnValue ret; do { @@ -3182,13 +3208,17 @@ ReturnValue Game::processLootItems(const std::shared_ptr &player, std::s if (!nextContainer && !handleFallbackLogic(player, lootContainer, containerIterator, fallbackConsumed)) { break; } + if (batchUpdate) { + const auto addResult = batchUpdate->add(lootContainer); + (void)addResult; + } fallbackConsumed = fallbackConsumed || (nextContainer == nullptr); } while (remainderCount != 0); return ret; } -ReturnValue Game::internalCollectManagedItems(const std::shared_ptr &player, const std::shared_ptr &item, ObjectCategory_t category, bool isLootContainer /* = true*/) { +ReturnValue Game::internalCollectManagedItems(const std::shared_ptr &player, const std::shared_ptr &item, ObjectCategory_t category, bool isLootContainer) { if (!player || !item) { return RETURNVALUE_NOTPOSSIBLE; } @@ -3233,45 +3263,92 @@ ReturnValue Game::internalCollectManagedItems(const std::shared_ptr &pla return RETURNVALUE_NOTPOSSIBLE; } - return processLootItems(player, lootContainer, item, fallbackConsumed); + BatchUpdate batchUpdate(player); + auto returnValue = processLootItems(player, lootContainer, item, fallbackConsumed, &batchUpdate); + return returnValue; } ReturnValue Game::collectRewardChestItems(const std::shared_ptr &player, uint32_t maxMoveItems /* = 0*/) { // Check if have item on player reward chest - const std::shared_ptr &rewardChest = player->getRewardChest(); + std::shared_ptr rewardChest = player->getRewardChest(); if (rewardChest->empty()) { g_logger().debug("Reward chest is empty"); return RETURNVALUE_REWARDCHESTISEMPTY; } - const auto &container = rewardChest->getContainer(); - if (!container) { - return RETURNVALUE_REWARDCHESTISEMPTY; - } - - auto rewardItemsVector = player->getRewardsFromContainer(container); + auto rewardItemsVector = player->getRewardsFromContainer(rewardChest->getContainer()); auto rewardCount = rewardItemsVector.size(); uint32_t movedRewardItems = 0; std::string lootedItemsMessage; + + BatchUpdate batchUpdate(player); for (const auto &item : rewardItemsVector) { - // Stop if player not have free capacity - if (item && player->getCapacity() < item->getWeight()) { - player->sendCancelMessage(RETURNVALUE_NOTENOUGHCAPACITY); - break; + if (!item) { + continue; + } + const auto &parent = item->getParent(); + if (!parent) { + continue; + } + const auto &container = parent->getContainer(); + if (!container) { + continue; } - // Limit the collect count if the "maxMoveItems" is not "0" - auto limitMove = maxMoveItems != 0 && movedRewardItems == maxMoveItems; - if (limitMove) { + batchUpdate.add(container); + } + + const auto &quickList = player->quickLootListItemIds; + auto filterMode = player->quickLootFilter; + + // Process items + for (const auto &item : rewardItemsVector) { + if (!item) { + continue; + } + + uint16_t itemId = item->getID(); + bool inList = std::ranges::find(quickList, itemId) != quickList.end(); + + if (!quickList.empty()) { + if (filterMode == QuickLootFilter_t::QUICKLOOTFILTER_ACCEPTEDLOOT && !inList) { + continue; + } else if (filterMode == QuickLootFilter_t::QUICKLOOTFILTER_SKIPPEDLOOT && inList) { + continue; + } + } + + if (player->getCapacity() < item->getWeight()) { + return RETURNVALUE_NOTENOUGHCAPACITY; + } + + if (maxMoveItems && movedRewardItems == maxMoveItems) { lootedItemsMessage = fmt::format("You can only collect {} items at a time. {} of {} objects were picked up.", maxMoveItems, movedRewardItems, rewardCount); player->sendTextMessage(MESSAGE_EVENT_ADVANCE, lootedItemsMessage); + // Already send message here + return RETURNVALUE_NOTPOSSIBLE; + } + + bool fallbackConsumed = false; + auto category = getObjectCategory(item); + auto toContainer = findManagedContainer(player, fallbackConsumed, category, true); + if (!toContainer) { + player->sendCancelMessage("No managed loot container configured to receive the items."); return RETURNVALUE_NOERROR; } - ObjectCategory_t category = getObjectCategory(item); - if (internalCollectManagedItems(player, item, category) == RETURNVALUE_NOERROR) { - movedRewardItems++; + batchUpdate.add(toContainer); + + ReturnValue ret = processLootItems(player, toContainer, item, fallbackConsumed, &batchUpdate); + if (ret == RETURNVALUE_CONTAINERNOTENOUGHROOM) { + player->sendCancelMessage(ret); + continue; + } else if (ret != RETURNVALUE_NOERROR) { + return ret; } + + player->sendLootStats(item, item->getItemCount()); + ++movedRewardItems; } lootedItemsMessage = fmt::format("{} of {} objects were picked up.", movedRewardItems, rewardCount); @@ -7107,7 +7184,8 @@ void Game::combatGetTypeInfo(CombatType_t combatType, const std::shared_ptrgetTile(), splash, INDEX_WHEREEVER, FLAG_NOLIMIT); + const auto addResult = internalAddItem(target->getTile(), splash, INDEX_WHEREEVER, FLAG_NOLIMIT); + (void)addResult; splash->startDecaying(); } @@ -10604,7 +10682,8 @@ uint32_t Game::makeFiendishMonster(uint32_t forgeableMonsterId /* = 0*/, bool cr // If the fiendish is no longer on the map, we remove it from the vector auto monster = getMonsterByID(monsterId); if (!monster) { - removeFiendishMonster(monsterId); + const auto removed = removeFiendishMonster(monsterId); + (void)removed; continue; } @@ -10613,7 +10692,8 @@ uint32_t Game::makeFiendishMonster(uint32_t forgeableMonsterId /* = 0*/, bool cr // Condition getFiendishMonsters().size() >= fiendishLimit) { monster->clearFiendishStatus(); - removeFiendishMonster(monsterId); + const auto removed = removeFiendishMonster(monsterId); + (void)removed; break; } } @@ -10709,13 +10789,15 @@ void Game::updateFiendishMonsterStatus(uint32_t monsterId, const std::string &mo } monster->clearFiendishStatus(); - removeFiendishMonster(monsterId, false); + const auto removed = removeFiendishMonster(monsterId, false); + (void)removed; makeFiendishMonster(); } bool Game::removeForgeMonster(uint32_t id, ForgeClassifications_t monsterForgeClassification, bool create) { if (monsterForgeClassification == ForgeClassifications_t::FORGE_FIENDISH_MONSTER) { - removeFiendishMonster(id, create); + const auto removed = removeFiendishMonster(id, create); + (void)removed; } else if (monsterForgeClassification == ForgeClassifications_t::FORGE_INFLUENCED_MONSTER) { removeInfluencedMonster(id, create); } @@ -10727,7 +10809,8 @@ bool Game::removeInfluencedMonster(uint32_t id, bool create /* = false*/) { if (auto find = influencedMonsters.find(id); // Condition find != influencedMonsters.end()) { - influencedMonsters.erase(find); + const auto erased = influencedMonsters.erase(find); + (void)erased; if (create) { [[maybe_unused]] auto eventId = g_dispatcher().scheduleEvent( @@ -10744,7 +10827,8 @@ bool Game::removeFiendishMonster(uint32_t id, bool create /* = true*/) { if (auto find = fiendishMonsters.find(id); // Condition find != fiendishMonsters.end()) { - fiendishMonsters.erase(find); + const auto erased = fiendishMonsters.erase(find); + (void)erased; checkForgeEventId(id); if (create) { @@ -10777,7 +10861,8 @@ void Game::updateForgeableMonsters() { for (const auto &monsterId : getFiendishMonsters()) { if (!getMonsterByID(monsterId)) { - removeFiendishMonster(monsterId); + const auto removed = removeFiendishMonster(monsterId); + (void)removed; } } diff --git a/src/game/game.hpp b/src/game/game.hpp index 61b7fc25ed6..afee2df9eca 100644 --- a/src/game/game.hpp +++ b/src/game/game.hpp @@ -33,6 +33,7 @@ class Creature; class Monster; class Npc; class Charm; +class BatchUpdate; class IOPrey; class IOWheel; class ItemClassification; @@ -775,7 +776,7 @@ class Game { * @param fallbackConsumed Reference to a boolean flag indicating whether a fallback has been consumed. * @return Return value indicating success or error. */ - ReturnValue processLootItems(const std::shared_ptr &player, std::shared_ptr lootContainer, const std::shared_ptr &item, bool &fallbackConsumed); + ReturnValue processLootItems(const std::shared_ptr &player, std::shared_ptr lootContainer, const std::shared_ptr &item, bool &fallbackConsumed, BatchUpdate* batchUpdate = nullptr); /** * @brief Internally collects loot or obtain items from a given item and places them into the managed container. diff --git a/src/items/containers/container.cpp b/src/items/containers/container.cpp index 84246c87388..0aa622b4b7c 100644 --- a/src/items/containers/container.cpp +++ b/src/items/containers/container.cpp @@ -52,6 +52,9 @@ std::shared_ptr Container::createBrowseField(const std::shared_ptrgetItemList(); if (itemVector) { + std::vector> itemsToAdd; + itemsToAdd.reserve(itemVector->size()); + for (const auto &item : *itemVector) { if (!item) { continue; @@ -65,9 +68,11 @@ std::shared_ptr Container::createBrowseField(const std::shared_ptritemlist.push_front(item); - item->setParent(newContainer); + itemsToAdd.push_back(item); + } + + for (auto it = itemsToAdd.rbegin(); it != itemsToAdd.rend(); ++it) { + newContainer->addItem(*it); } } @@ -150,6 +155,8 @@ bool Container::hasParent() { void Container::addItem(const std::shared_ptr &item) { itemlist.push_back(item); item->setParent(getContainer()); + updateCacheOnAdd(item); + updateItemWeight(item->getWeight()); } StashContainerList Container::getStowableItems() { @@ -209,7 +216,6 @@ bool Container::unserializeItemNode(OTB::Loader &loader, const OTB::Node &node, } addItem(item); - updateItemWeight(item->getWeight()); } return true; } @@ -234,6 +240,54 @@ void Container::updateItemWeight(int32_t diff) { } } +void Container::updateCacheOnAdd(const std::shared_ptr &item) { + if (const auto &subContainer = item->getContainer()) { + // Adding a container: count the container itself (+1) plus all its internal items and containers. + m_cachedItemCount += 1 + subContainer->m_cachedItemCount; + m_cachedContainerCount += 1 + subContainer->m_cachedContainerCount; + } else { + // Adding a regular item: increment item count by one. + m_cachedItemCount += 1; + } + + // Propagate the update to the parent container, maintaining accurate cached counts up the hierarchy. + if (const auto &parent = getParentContainer()) { + parent->updateCacheOnAdd(item); + } +} + +void Container::updateCacheOnRemove(const std::shared_ptr &item) { + if (auto subContainer = item->getContainer()) { + // Removing a container: subtract one (itself) plus all items and containers within it. + const uint32_t itemDelta = 1 + subContainer->m_cachedItemCount; + const uint32_t containerDelta = 1 + subContainer->m_cachedContainerCount; + m_cachedItemCount = m_cachedItemCount > itemDelta ? m_cachedItemCount - itemDelta : 0; + m_cachedContainerCount = m_cachedContainerCount > containerDelta ? m_cachedContainerCount - containerDelta : 0; + } else { + // Removing a regular item: decrease the item count by one. + m_cachedItemCount = m_cachedItemCount > 0 ? m_cachedItemCount - 1 : 0; + } + + // Propagate the update to the parent container, ensuring correct counts up the chain. + if (auto parent = getParentContainer()) { + parent->updateCacheOnRemove(item); + } +} + +void Container::updateItemCountCache() { + m_cachedItemCount = 0; + m_cachedContainerCount = 0; + + for (const auto &item : getItemList()) { + ++m_cachedItemCount; + if (const auto &sub = item->getContainer()) { + sub->updateItemCountCache(); + m_cachedItemCount += sub->getItemHoldingCount(); + m_cachedContainerCount += 1 + sub->getContainerHoldingCount(); + } + } +} + uint32_t Container::getWeight() const { return Item::getWeight() + totalWeight; } @@ -349,22 +403,12 @@ std::shared_ptr Container::getItemByIndex(size_t index) const { return itemlist[index]; } -uint32_t Container::getItemHoldingCount() { - uint32_t counter = 0; - for (ContainerIterator it = iterator(); it.hasNext(); it.advance()) { - ++counter; - } - return counter; +uint32_t Container::getItemHoldingCount() const { + return m_cachedItemCount; } -uint32_t Container::getContainerHoldingCount() { - uint32_t counter = 0; - for (ContainerIterator it = iterator(); it.hasNext(); it.advance()) { - if ((*it)->getContainer()) { - ++counter; - } - } - return counter; +uint32_t Container::getContainerHoldingCount() const { + return m_cachedContainerCount; } bool Container::isHoldingItem(const std::shared_ptr &item) { @@ -419,6 +463,10 @@ bool Container::isBrowseFieldAndHoldsRewardChest() { } void Container::onAddContainerItem(const std::shared_ptr &item) { + if (m_batching) { + return; + } + const auto spectators = Spectators().find(getPosition(), false, 2, 2, 2, 2); // send to client @@ -433,6 +481,10 @@ void Container::onAddContainerItem(const std::shared_ptr &item) { } void Container::onUpdateContainerItem(uint32_t index, const std::shared_ptr &oldItem, const std::shared_ptr &newItem) { + if (m_batching) { + return; + } + const auto &holdingPlayer = getHoldingPlayer(); const auto &thisContainer = getContainer(); if (holdingPlayer) { @@ -455,6 +507,10 @@ void Container::onUpdateContainerItem(uint32_t index, const std::shared_ptr &item) { + if (m_batching) { + return; + } + const auto &holdingPlayer = getHoldingPlayer(); const auto &thisContainer = getContainer(); if (holdingPlayer) { @@ -484,7 +540,7 @@ ReturnValue Container::queryAdd(int32_t addIndex, const std::shared_ptr & return RETURNVALUE_NOERROR; } - if (!unlocked) { + if (!unlocked && !hasBitSet(FLAG_LOOTPOUCH, flags)) { return RETURNVALUE_NOTPOSSIBLE; } @@ -645,9 +701,10 @@ ReturnValue Container::queryRemove(const std::shared_ptr &thing, uint32_t } std::shared_ptr Container::queryDestination(int32_t &index, const std::shared_ptr &thing, std::shared_ptr &destItem, uint32_t &flags) { - if (!unlocked) { + const auto &thisContainer = getContainer(); + if (!unlocked && !hasBitSet(FLAG_LOOTPOUCH, flags)) { destItem = nullptr; - return getContainer(); + return thisContainer; } if (index == 254 /*move up*/) { @@ -661,17 +718,15 @@ std::shared_ptr Container::queryDestination(int32_t &index, const std: return getContainer(); } - if (index == 255 /*add wherever*/) { - index = INDEX_WHEREEVER; - destItem = nullptr; - } else if (index >= static_cast(capacity()) && !hasPagination()) { - /* - if you have a container, maximize it to show all 20 slots - then you open a bag that is inside the container you will have a bag with 8 slots - and a "grey" area where the other 12 slots where from the container - if you drop the item on that grey area - the client calculates the slot position as if the bag has 20 slots - */ + const bool isOutOfRange = index >= static_cast(capacity()) && !hasPagination(); + /* + if you have a container, maximize it to show all 20 slots + then you open a bag that is inside the container you will have a bag with 8 slots + and a "grey" area where the other 12 slots where from the container + if you drop the item on that grey area + the client calculates the slot position as if the bag has 20 slots + */ + if (index == 255 /*add wherever*/ || isOutOfRange) { index = INDEX_WHEREEVER; destItem = nullptr; } @@ -733,6 +788,8 @@ void Container::addThing(int32_t index, const std::shared_ptr &thing) { return /*RETURNVALUE_NOTPOSSIBLE*/; } + updateCacheOnAdd(item); + item->setParent(getContainer()); itemlist.push_front(item); updateItemWeight(item->getWeight()); @@ -745,7 +802,6 @@ void Container::addThing(int32_t index, const std::shared_ptr &thing) { void Container::addItemBack(const std::shared_ptr &item) { addItem(item); - updateItemWeight(item->getWeight()); // send change to client if (getParent() && (getParent() != VirtualCylinder::virtualCylinder)) { @@ -765,8 +821,10 @@ void Container::updateThing(const std::shared_ptr &thing, uint16_t itemId } const int32_t oldWeight = item->getWeight(); + updateCacheOnRemove(item); item->setID(itemId); item->setSubType(count); + updateCacheOnAdd(item); updateItemWeight(-oldWeight + item->getWeight()); // send change to client @@ -796,6 +854,9 @@ void Container::replaceThing(uint32_t index, const std::shared_ptr &thing itemlist[index] = item; item->setParent(getContainer()); + + updateCacheOnRemove(replacedItem); + updateCacheOnAdd(item); updateItemWeight(-static_cast(replacedItem->getWeight()) + item->getWeight()); // send change to client @@ -828,26 +889,110 @@ void Container::removeThing(const std::shared_ptr &thing, uint32_t count) onUpdateContainerItem(index, item, item); } } else { - updateItemWeight(-static_cast(item->getWeight())); + removeItemByIndex(static_cast(index), count); + + if (isCorpse() && empty()) { + clearLootHighlight(); + } + } +} + +void Container::removeItemByIndex(size_t index, uint32_t count) { + if (index >= itemlist.size() || count == 0) { + return; + } + + // Use copy to avoid aliasing after swap + auto removed = itemlist[index]; + if (!removed) { + return; + } + + int32_t weightDiff = 0; + + if (removed->isStackable() && count < removed->getItemCount()) { + const int32_t oldWeight = removed->getWeight(); + const auto newCount = static_cast(removed->getItemCount() - count); + removed->setItemCount(newCount); + weightDiff = -oldWeight + removed->getWeight(); + + if (!m_batching && getParent()) { + onUpdateContainerItem(static_cast(index), removed, removed); + } + } else { + weightDiff = -static_cast(removed->getWeight()); + updateCacheOnRemove(removed); + + if (!m_batching && getParent()) { + onRemoveContainerItem(static_cast(index), removed); + } + + removed->stopDecaying(); + removed->onRemoved(); + + if (m_batching) { + if (index == itemlist.size() - 1) { + itemlist.pop_back(); + } else { + itemlist.erase(itemlist.begin() + static_cast(index)); + } + removed->resetParent(); + } else { + removed->resetParent(); + itemlist.erase(itemlist.begin() + static_cast(index)); + } - // send change to client if (getParent()) { - onRemoveContainerItem(index, item); + postRemoveNotification(removed, nullptr, static_cast(index), LINK_PARENT); } + } - item->resetParent(); - itemlist.erase(itemlist.begin() + index); + updateItemWeight(weightDiff); +} - if (isCorpse() && empty()) { - clearLootHighlight(); +bool Container::removeItemById(uint16_t itemId, uint32_t count, int32_t subType /*= -1*/) { + if (getItemTypeCount(itemId, subType) < count) { + return false; + } + + uint32_t removed = 0; + for (const auto &item : getItems(false)) { + if (item->getID() != itemId) { + continue; + } + if (subType > -1 && item->getSubType() != subType) { + continue; + } + + uint32_t stack = item->getItemCount(); + const auto removeCount = std::min(stack, count - removed); + const auto returnValue = g_game().internalRemoveItem(item, removeCount); + if (returnValue != RETURNVALUE_NOERROR) { + continue; + } + removed += removeCount; + if (removed >= count) { + break; } } + + return true; } int32_t Container::getThingIndex(const std::shared_ptr &thing) const { int32_t index = 0; - for (const std::shared_ptr &item : itemlist) { - if (item == thing) { + if (thing == nullptr) { + return -1; + } + + const auto item = thing->getItem(); + if (item == nullptr) { + return -1; + } + + auto rawPtr = item.get(); + for (const std::shared_ptr &listItem : itemlist) { + if (listItem.get() == rawPtr) { return index; } ++index; @@ -943,6 +1088,7 @@ void Container::internalAddThing(uint32_t, const std::shared_ptr &thing) item->setParent(getContainer()); itemlist.push_front(item); updateItemWeight(item->getWeight()); + updateCacheOnAdd(item); } uint16_t Container::getFreeSlots() const { @@ -957,8 +1103,60 @@ uint16_t Container::getFreeSlots() const { return counter; } -ContainerIterator Container::iterator() { - return { getContainer(), static_cast(g_configManager().getNumber(MAX_CONTAINER_DEPTH)) }; +ContainerIterator Container::iterator() const { + const auto &selfContainer = getContainer(); + if (!selfContainer) { + return { nullptr, 0 }; + } + return { selfContainer, static_cast(g_configManager().getNumber(MAX_CONTAINER_DEPTH)) }; +} + +void Container::beginBatchUpdate() { + m_batching = true; +} + +void Container::endBatchUpdate(Player* actor) { + if (!m_batching) { + return; + } + m_batching = false; + if (actor) { + actor->sendBatchUpdateContainer(this, true, getFirstIndex()); + } +} + +uint32_t Container::removeAllItems(const std::shared_ptr &actor, bool isRecursive /*= false*/) { + beginBatchUpdate(); + + uint32_t removedCount = 0; + std::vector> itemsToRemove; + + if (isRecursive) { + for (ContainerIterator it = iterator(); it.hasNext(); it.advance()) { + const auto &item = *it; + if (!item) { + continue; + } + itemsToRemove.push_back(item); + } + } else { + const auto &itemList = getItemList(); + for (const auto &item : itemList) { + if (!item) { + continue; + } + itemsToRemove.push_back(item); + } + } + + for (const auto &item : itemsToRemove) { + removeThing(item, item->getItemCount()); + ++removedCount; + } + + endBatchUpdate(actor.get()); + + return removedCount; } void Container::removeItem(const std::shared_ptr &thing, bool sendUpdateToClient /* = false*/) { @@ -1017,75 +1215,82 @@ uint32_t Container::getOwnerId() const { * ContainerIterator * @brief Iterator for iterating over the items in a container */ -ContainerIterator::ContainerIterator(const std::shared_ptr &container, size_t maxDepth) : +ContainerIterator::ContainerIterator(const std::shared_ptr &container, size_t maxDepth) : maxTraversalDepth(maxDepth) { if (container) { states.reserve(maxDepth); - visitedContainers.reserve(g_configManager().getNumber(MAX_CONTAINER)); - (void)states.emplace_back(container, 0, 1); - (void)visitedContainers.insert(container); + const auto &items = container->getItemList(); + + states.emplace_back(container, items, 0, 1); + const auto insertResult = visitedContainers.insert(container.get()); + (void)insertResult; } } bool ContainerIterator::hasNext() const { while (!states.empty()) { - const auto &top = states.back(); - const auto &container = top.container.lock(); - if (!container) { - // Container has been deleted + auto &s = states.back(); + auto lockedContainer = s.container.lock(); + if (!lockedContainer) { states.pop_back(); - } else if (top.index < container->itemlist.size()) { + continue; + } + const auto* items = s.items; + if (items && s.index < items->size()) { return true; - } else { - states.pop_back(); } + states.pop_back(); } return false; } void ContainerIterator::advance() { - if (states.empty()) { - return; - } + while (!states.empty()) { + auto &top = states.back(); - auto &top = states.back(); - const auto &container = top.container.lock(); - if (!container) { - // Container has been deleted - states.pop_back(); - return; - } + auto lockedContainer = top.container.lock(); + if (!lockedContainer) { + states.pop_back(); + continue; + } + const auto* items = top.items; + if (!items || top.index >= items->size()) { + states.pop_back(); + continue; + } - if (top.index >= container->itemlist.size()) { - states.pop_back(); - return; - } + const auto ¤tItem = (*items)[top.index]; + ++top.index; - const auto ¤tItem = container->itemlist[top.index]; - if (currentItem) { - const auto &subContainer = currentItem->getContainer(); - if (subContainer && !subContainer->itemlist.empty()) { - size_t newDepth = top.depth + 1; - if (newDepth <= maxTraversalDepth) { - if (visitedContainers.find(subContainer) == visitedContainers.end()) { - states.emplace_back(subContainer, 0, newDepth); - visitedContainers.insert(subContainer); - } else { - if (!m_cycleDetected) { - g_logger().trace("[{}] Cycle detected in container: {}", __FUNCTION__, subContainer->getName()); - m_cycleDetected = true; - } - } - } else { - if (!m_maxDepthReached) { - g_logger().trace("[{}] Maximum iteration depth reached", __FUNCTION__); - m_maxDepthReached = true; - } - } + if (!currentItem) { + return; + } + + const auto &sub = currentItem->getContainer(); + if (!sub) { + return; + } + + const auto &list = sub->getItemList(); + if (list.empty()) { + return; } - } - ++top.index; + const size_t newDepth = top.depth + 1; + if (newDepth > maxTraversalDepth) { + m_maxDepthReached = true; + return; + } + + const auto raw = sub.get(); + if (!visitedContainers.insert(raw).second) { + m_cycleDetected = true; + return; + } + + states.emplace_back(sub, list, 0, newDepth); + return; + } } std::shared_ptr ContainerIterator::operator*() const { @@ -1094,26 +1299,23 @@ std::shared_ptr ContainerIterator::operator*() const { } const auto &top = states.back(); - if (const auto &container = top.container.lock()) { - if (top.index < container->itemlist.size()) { - return container->itemlist[top.index]; - } + auto lockedContainer = top.container.lock(); + if (!lockedContainer) { + return nullptr; } - return nullptr; + + const auto* items = top.items; + if (!items || top.index >= items->size()) { + return nullptr; + } + + return (*items)[top.index]; } bool ContainerIterator::hasReachedMaxDepth() const { return m_maxDepthReached; } -std::shared_ptr ContainerIterator::getCurrentContainer() const { - if (states.empty()) { - return nullptr; - } - const auto &top = states.back(); - return top.container.lock(); -} - size_t ContainerIterator::getCurrentIndex() const { if (states.empty()) { return 0; diff --git a/src/items/containers/container.hpp b/src/items/containers/container.hpp index 4a81a5f08cb..9d244914970 100644 --- a/src/items/containers/container.hpp +++ b/src/items/containers/container.hpp @@ -20,6 +20,13 @@ class DepotChest; class DepotLocker; class RewardChest; class Reward; +class Player; + +#ifdef BUILD_TESTS + #define PRIVATE_FOR_TESTS public +#else + #define PRIVATE_FOR_TESTS private +#endif class ContainerIterator { public: @@ -32,7 +39,7 @@ class ContainerIterator { * @param container The root container to start iterating from. * @param maxDepth The maximum depth of nested containers to traverse. */ - ContainerIterator(const std::shared_ptr &container, size_t maxDepth); + ContainerIterator(const std::shared_ptr &container, size_t maxDepth); /** * @brief Checks if there are more items to iterate over in the container. @@ -65,24 +72,25 @@ class ContainerIterator { bool hasReachedMaxDepth() const; - std::shared_ptr getCurrentContainer() const; size_t getCurrentIndex() const; -private: - /** - * @brief Represents the state of the iterator at a given point in time. - * - * This structure is used to keep track of the current container, - * the index of the current item within that container, and the depth - * of traversal for nested containers. It is primarily used in the - * ContainerIterator to manage the state of the iteration as it traverses - * through containers and their sub-containers. - */ - struct IteratorState { + PRIVATE_FOR_TESTS : + /** + * @brief Represents the state of the iterator at a given point in time. + * + * This structure is used to keep track of the current container, + * the index of the current item within that container, and the depth + * of traversal for nested containers. It is primarily used in the + * ContainerIterator to manage the state of the iteration as it traverses + * through containers and their sub-containers. + */ + struct IteratorState { /** * @brief The container being iterated over. */ - std::weak_ptr container; + std::weak_ptr container; + + const ItemDeque* items; /** * @brief The current index within the container's item list. @@ -102,8 +110,11 @@ class ContainerIterator { * @param i The starting index within the container. * @param d The depth of traversal. */ - IteratorState(std::shared_ptr c, size_t i, size_t d) : - container(c), index(i), depth(d) { } + IteratorState(const std::shared_ptr &container, const ItemDeque &list, size_t index, size_t depth) : + container(container), + items(&list), + index(index), + depth(depth) { } }; /** @@ -123,7 +134,7 @@ class ContainerIterator { * that each container is processed only once, preventing redundant processing * and potential crashes due to cyclic references. */ - mutable std::unordered_set> visitedContainers; + mutable std::unordered_set visitedContainers; size_t maxTraversalDepth = 0; bool m_maxDepthReached = false; @@ -210,7 +221,10 @@ class Container : public Item, public Cylinder { return maxSize; } - ContainerIterator iterator(); + ContainerIterator iterator() const; + + virtual void beginBatchUpdate(); + virtual void endBatchUpdate(Player* actor); const ItemDeque &getItemList() const { return itemlist; @@ -236,8 +250,8 @@ class Container : public Item, public Cylinder { bool isHoldingItem(const std::shared_ptr &item); bool isHoldingItemWithId(uint16_t id); - uint32_t getItemHoldingCount(); - uint32_t getContainerHoldingCount(); + [[nodiscard]] uint32_t getItemHoldingCount() const; + [[nodiscard]] uint32_t getContainerHoldingCount() const; uint16_t getFreeSlots() const; uint32_t getWeight() const final; @@ -263,6 +277,21 @@ class Container : public Item, public Cylinder { void removeThing(const std::shared_ptr &thing, uint32_t count) final; + /** + * @brief Removes an item directly by its index. + * + * This helper avoids an extra linear search when the caller already knows + * the position of the item inside the container. It mirrors the behaviour + * of `removeThing` but uses the provided index instead of calling + * `getThingIndex`. + * + * @param index position of the item inside the container + * @param count how many items to remove from a stackable item + */ + virtual void removeItemByIndex(size_t index, uint32_t count); + + bool removeItemById(uint16_t itemId, uint32_t count, int32_t subType = -1); + int32_t getThingIndex(const std::shared_ptr &thing) const final; size_t getFirstIndex() const final; size_t getLastIndex() const final; @@ -278,6 +307,7 @@ class Container : public Item, public Cylinder { void internalAddThing(const std::shared_ptr &thing) final; void internalAddThing(uint32_t index, const std::shared_ptr &thing) final; + uint32_t removeAllItems(const std::shared_ptr &actor, bool isRecursive = false); virtual void removeItem(const std::shared_ptr &thing, bool sendUpdateToClient = false); uint32_t getOwnerId() const final; @@ -322,7 +352,11 @@ class Container : public Item, public Cylinder { friend class MapCache; -private: + PRIVATE_FOR_TESTS : + + uint32_t m_cachedContainerCount {}; + uint32_t m_cachedItemCount {}; + void onAddContainerItem(const std::shared_ptr &item); void onUpdateContainerItem(uint32_t index, const std::shared_ptr &oldItem, const std::shared_ptr &newItem); void onRemoveContainerItem(uint32_t index, const std::shared_ptr &item); @@ -331,6 +365,49 @@ class Container : public Item, public Cylinder { std::shared_ptr getTopParentContainer(); void updateItemWeight(int32_t diff); + /** + * @brief Updates the cached item and container counts when an item is added. + * + * When a new item is inserted into this container, this function updates the cached counts + * of items and containers. If the added item is itself a container, it is counted as one item, + * plus all the items and containers it contains. If the added item is a regular item, only + * the item count is incremented by one. + * + * After updating the values for this container, the changes are propagated up to its parent + * container, ensuring that all ancestors in the container hierarchy are updated. This does not + * require iterating through all subcontainers since each container already maintains its own + * cached counts. + * + * Previously, adding an item might involve recalculating all counts by iterating recursively + * through subcontainers, risking duplicate counts and excessive CPU usage. Now, since each container + * carries its own pre-computed counts, the update becomes O(1), significantly improving performance. + * + * @param item The item being added to the container. + */ + void updateCacheOnAdd(const std::shared_ptr &item); + + /** + * @brief Updates the cached item and container counts when an item is removed. + * + * When an item is removed from this container, this function updates the cached counts of items + * and containers accordingly. If the removed item is a container, it removes not only one (the container itself) + * but also all items and containers inside it. If it is a regular item, the item count is simply reduced by one. + * + * After adjusting this container's counts, the changes are also propagated to the parent container, + * ensuring the entire chain of containers maintains correct cached counts. + * + * Previously, removing an item might have required fully recalculating all subitems by iterating through + * the hierarchy, causing performance overhead. With this incremental approach, each container already + * knows its own counts, allowing O(1) updates and significantly reducing CPU usage. + * + * @param item The item being removed from the container. + */ + void updateCacheOnRemove(const std::shared_ptr &item); + + void updateItemCountCache(); + + bool m_batching = false; + friend class ContainerIterator; friend class IOMapSerialize; }; diff --git a/src/items/containers/depot/depotchest.hpp b/src/items/containers/depot/depotchest.hpp index 66418604b61..46ca82c715d 100644 --- a/src/items/containers/depot/depotchest.hpp +++ b/src/items/containers/depot/depotchest.hpp @@ -26,6 +26,29 @@ class DepotChest final : public Container { void postAddNotification(const std::shared_ptr &thing, const std::shared_ptr &oldParent, int32_t index, CylinderLink_t link = LINK_OWNER) override; void postRemoveNotification(const std::shared_ptr &thing, const std::shared_ptr &newParent, int32_t index, CylinderLink_t link = LINK_OWNER) override; + std::shared_ptr getDepotChest() override { + auto selfDepotChest = m_selfDepotChest.lock(); + if (selfDepotChest) { + return selfDepotChest; + } + + selfDepotChest = static_self_cast(); + m_selfDepotChest = selfDepotChest; + return selfDepotChest; + } + + std::shared_ptr getDepotChest() const override { + auto selfDepotChest = m_selfDepotChest.lock(); + if (selfDepotChest) { + return selfDepotChest; + } + + auto self = static_self_cast(); + auto noConstSelf = std::const_pointer_cast(self); + m_selfDepotChest = noConstSelf; + return self; + } + bool isDepotChest() const override { return true; } @@ -44,5 +67,7 @@ class DepotChest final : public Container { } private: + mutable std::weak_ptr m_selfDepotChest; + uint32_t maxDepotItems; }; diff --git a/src/items/thing.cpp b/src/items/thing.cpp index 54ba921a88e..1a5246962c1 100644 --- a/src/items/thing.cpp +++ b/src/items/thing.cpp @@ -18,3 +18,11 @@ const Position &Thing::getPosition() { } return tile->getPosition(); } + +const Position &Thing::getPosition() const { + const auto &tile = getTile(); + if (!tile) { + return Tile::nullptr_tile->getPosition(); + } + return tile->getPosition(); +} diff --git a/src/items/thing.hpp b/src/items/thing.hpp index 3869d2eb950..0ff16507705 100644 --- a/src/items/thing.hpp +++ b/src/items/thing.hpp @@ -17,6 +17,7 @@ class Item; class Creature; class Container; class Player; +class DepotChest; class Thing { public: @@ -49,6 +50,7 @@ class Thing { } virtual const Position &getPosition(); + virtual const Position &getPosition() const; virtual int32_t getThrowRange() const = 0; virtual bool isPushable() = 0; @@ -77,6 +79,13 @@ class Thing { return nullptr; } + virtual std::shared_ptr getDepotChest() { + return nullptr; + } + virtual std::shared_ptr getDepotChest() const { + return nullptr; + } + virtual bool isRemoved() { return true; } diff --git a/src/items/tile.hpp b/src/items/tile.hpp index c59c0a519dd..0ef32191c54 100644 --- a/src/items/tile.hpp +++ b/src/items/tile.hpp @@ -239,6 +239,10 @@ class Tile : public Cylinder, public SharedObject { return tilePos; } + const Position &getPosition() const final { + return tilePos; + } + bool isRemoved() final { return false; } diff --git a/src/lua/functions/core/CMakeLists.txt b/src/lua/functions/core/CMakeLists.txt index 53430c5aee5..2ce8676b7cd 100644 --- a/src/lua/functions/core/CMakeLists.txt +++ b/src/lua/functions/core/CMakeLists.txt @@ -3,6 +3,7 @@ target_sources( PRIVATE game/config_functions.cpp game/game_functions.cpp game/bank_functions.cpp + game/batch_update_functions.cpp game/global_functions.cpp game/lua_enums.cpp game/modal_window_functions.cpp diff --git a/src/lua/functions/core/game/batch_update_functions.cpp b/src/lua/functions/core/game/batch_update_functions.cpp new file mode 100644 index 00000000000..37ec1f2ee33 --- /dev/null +++ b/src/lua/functions/core/game/batch_update_functions.cpp @@ -0,0 +1,55 @@ +/** + * Canary - A free and open-source MMORPG server emulator + * Copyright (©) 2019-2024 OpenTibiaBR + * Repository: https://github.com/opentibiabr/canary + * License: https://github.com/opentibiabr/canary/blob/main/LICENSE + * Contributors: https://github.com/opentibiabr/canary/graphs/contributors + * Website: https://docs.opentibiabr.com/ + */ + +#include "lua/functions/core/game/batch_update_functions.hpp" + +#include "lua/functions/lua_functions_loader.hpp" +#include "utils/batch_update.hpp" +#include "creatures/players/player.hpp" + +void BatchUpdateFunctions::init(lua_State* L) { + Lua::registerSharedClass(L, "BatchUpdate", "", luaBatchUpdateCreate); + Lua::registerMethod(L, "BatchUpdate", "delete", Lua::luaGarbageCollection); + Lua::registerMetaMethod(L, "BatchUpdate", "__gc", Lua::luaGarbageCollection); + Lua::registerMethod(L, "BatchUpdate", "add", luaBatchUpdateAdd); +} + +int BatchUpdateFunctions::luaBatchUpdateCreate(lua_State* L) { + // BatchUpdate(playerActor) + const auto &playerActor = Lua::getPlayer(L, 2); + if (!playerActor) { + Lua::reportErrorFunc(Lua::getErrorDesc(LUA_ERROR_PLAYER_NOT_FOUND)); + lua_pushnil(L); + return 1; + } + + Lua::pushUserdata(L, std::make_shared(playerActor)); + Lua::setMetatable(L, -1, "BatchUpdate"); + return 1; +} + +int BatchUpdateFunctions::luaBatchUpdateAdd(lua_State* L) { + // BatchUpdate:add(container) + const auto &batchUpdate = Lua::getUserdataShared(L, 1, "BatchUpdate"); + if (!batchUpdate) { + Lua::reportErrorFunc(Lua::getErrorDesc(LUA_ERROR_BATCHUPDATE_NOT_FOUND)); + Lua::pushBoolean(L, false); + return 1; + } + + const auto &container = Lua::getUserdataShared(L, 2, "Container"); + if (!container) { + Lua::reportErrorFunc(Lua::getErrorDesc(LUA_ERROR_CONTAINER_NOT_FOUND)); + Lua::pushBoolean(L, false); + return 1; + } + + Lua::pushBoolean(L, batchUpdate->add(container)); + return 1; +} diff --git a/src/lua/functions/core/game/batch_update_functions.hpp b/src/lua/functions/core/game/batch_update_functions.hpp new file mode 100644 index 00000000000..a7e06d5e9a8 --- /dev/null +++ b/src/lua/functions/core/game/batch_update_functions.hpp @@ -0,0 +1,20 @@ +/** + * Canary - A free and open-source MMORPG server emulator + * Copyright (©) 2019-2024 OpenTibiaBR + * Repository: https://github.com/opentibiabr/canary + * License: https://github.com/opentibiabr/canary/blob/main/LICENSE + * Contributors: https://github.com/opentibiabr/canary/graphs/contributors + * Website: https://docs.opentibiabr.com/ + */ + +#pragma once + +class BatchUpdateFunctions { +public: + static void init(lua_State* L); + +private: + static int luaBatchUpdateCreate(lua_State* L); + static int luaBatchUpdateAdd(lua_State* L); + static int luaBatchUpdateExecute(lua_State* L); +}; diff --git a/src/lua/functions/core/game/core_game_functions.hpp b/src/lua/functions/core/game/core_game_functions.hpp index dc6e48259e2..a9bf4c26215 100644 --- a/src/lua/functions/core/game/core_game_functions.hpp +++ b/src/lua/functions/core/game/core_game_functions.hpp @@ -12,6 +12,7 @@ #include "lua/scripts/luascript.hpp" #include "lua/functions/core/game/config_functions.hpp" #include "lua/functions/core/game/game_functions.hpp" +#include "lua/functions/core/game/batch_update_functions.hpp" #include "lua/functions/core/game/bank_functions.hpp" #include "lua/functions/core/game/global_functions.hpp" #include "lua/functions/core/game/lua_enums.hpp" @@ -28,6 +29,7 @@ class CoreGameFunctions final : LuaScriptInterface { static void init(lua_State* L) { ConfigFunctions::init(L); GameFunctions::init(L); + BatchUpdateFunctions::init(L); BankFunctions::init(L); GlobalFunctions::init(L); LuaEnums::init(L); diff --git a/src/lua/functions/core/game/lua_enums.cpp b/src/lua/functions/core/game/lua_enums.cpp index cd931f8548f..85c4a2a6c3c 100644 --- a/src/lua/functions/core/game/lua_enums.cpp +++ b/src/lua/functions/core/game/lua_enums.cpp @@ -149,6 +149,7 @@ void LuaEnums::initOthersEnums(lua_State* L) { registerEnum(L, FLAG_IGNOREFIELDDAMAGE); registerEnum(L, FLAG_IGNORENOTMOVABLE); registerEnum(L, FLAG_IGNOREAUTOSTACK); + registerEnum(L, FLAG_LOOTPOUCH); // Use with house:getAccessList, house:setAccessList registerEnum(L, GUEST_LIST); diff --git a/src/lua/functions/creatures/player/player_functions.cpp b/src/lua/functions/creatures/player/player_functions.cpp index d57e2ac8dea..5871c3d887e 100644 --- a/src/lua/functions/creatures/player/player_functions.cpp +++ b/src/lua/functions/creatures/player/player_functions.cpp @@ -197,6 +197,7 @@ void PlayerFunctions::init(lua_State* L) { Lua::registerMethod(L, "Player", "addItem", PlayerFunctions::luaPlayerAddItem); Lua::registerMethod(L, "Player", "addItemEx", PlayerFunctions::luaPlayerAddItemEx); + Lua::registerMethod(L, "Player", "addItemBatchToPaginedContainer", PlayerFunctions::luaPlayerAddItemBatchToPaginedContainer); Lua::registerMethod(L, "Player", "addItemStash", PlayerFunctions::luaPlayerAddItemStash); Lua::registerMethod(L, "Player", "removeStashItem", PlayerFunctions::luaPlayerRemoveStashItem); Lua::registerMethod(L, "Player", "removeItem", PlayerFunctions::luaPlayerRemoveItem); @@ -216,6 +217,8 @@ void PlayerFunctions::init(lua_State* L) { Lua::registerMethod(L, "Player", "openChannel", PlayerFunctions::luaPlayerOpenChannel); Lua::registerMethod(L, "Player", "getSlotItem", PlayerFunctions::luaPlayerGetSlotItem); + Lua::registerMethod(L, "Player", "getBackpack", PlayerFunctions::luaPlayerGetBackpack); + Lua::registerMethod(L, "Player", "getLootPouch", PlayerFunctions::luaPlayerGetLootPouch); Lua::registerMethod(L, "Player", "getParty", PlayerFunctions::luaPlayerGetParty); @@ -2358,6 +2361,42 @@ int PlayerFunctions::luaPlayerAddItemEx(lua_State* L) { return 1; } +int PlayerFunctions::luaPlayerAddItemBatchToPaginedContainer(lua_State* L) { + // player:addItemBatchToPaginedContainer(container, itemId, count = 1, tier = 0, flags = 0) + const auto &player = Lua::getUserdataShared(L, 1, "Player"); + if (!player) { + lua_pushnil(L); + return 1; + } + + const auto &container = Lua::getUserdataShared(L, 2, "Container"); + if (!container || !container->hasPagination()) { + player->sendCancelMessage("Invalid or non-paginated container."); + lua_pushnumber(L, 0); + return 1; + } + + const auto itemId = Lua::getNumber(L, 3); + if (itemId == 0) { + player->sendCancelMessage("Invalid item id."); + lua_pushnumber(L, 0); + return 1; + } + const auto count = Lua::getNumber(L, 4, 1); + const auto tier = Lua::getNumber(L, 5, 0); + const auto flags = Lua::getNumber(L, 6, 0); + + uint32_t actuallyAdded = 0; + const auto ret = player->addItemBatchToPaginedContainer(container, itemId, count, actuallyAdded, flags, tier); + + if (ret != RETURNVALUE_NOERROR) { + player->sendCancelMessage(ret); + } + + lua_pushnumber(L, actuallyAdded); + return 1; +} + int PlayerFunctions::luaPlayerAddItemStash(lua_State* L) { // player:addItemStash(itemId, count = 1) const auto &player = Lua::getUserdataShared(L, 1, "Player"); @@ -2689,6 +2728,42 @@ int PlayerFunctions::luaPlayerGetSlotItem(lua_State* L) { return 1; } +int PlayerFunctions::luaPlayerGetBackpack(lua_State* L) { + // player:getBackpack() + const auto &player = Lua::getUserdataShared(L, 1, "Player"); + if (!player) { + lua_pushnil(L); + return 1; + } + + const auto &backpack = player->getBackpack(); + if (backpack) { + Lua::pushUserdata(L, backpack); + Lua::setItemMetatable(L, -1, backpack); + } else { + lua_pushnil(L); + } + return 1; +} + +int PlayerFunctions::luaPlayerGetLootPouch(lua_State* L) { + // player:getLootPouch() + const auto &player = Lua::getUserdataShared(L, 1, "Player"); + if (!player) { + lua_pushnil(L); + return 1; + } + + const auto &lootPouch = player->getLootPouch(); + if (lootPouch) { + Lua::pushUserdata(L, lootPouch); + Lua::setItemMetatable(L, -1, lootPouch); + } else { + lua_pushnil(L); + } + return 1; +} + int PlayerFunctions::luaPlayerGetParty(lua_State* L) { // player:getParty() const auto &player = Lua::getUserdataShared(L, 1, "Player"); diff --git a/src/lua/functions/creatures/player/player_functions.hpp b/src/lua/functions/creatures/player/player_functions.hpp index a68754b45ed..c9e1847ca91 100644 --- a/src/lua/functions/creatures/player/player_functions.hpp +++ b/src/lua/functions/creatures/player/player_functions.hpp @@ -181,6 +181,7 @@ class PlayerFunctions { static int luaPlayerAddItem(lua_State* L); static int luaPlayerAddItemEx(lua_State* L); + static int luaPlayerAddItemBatchToPaginedContainer(lua_State* L); static int luaPlayerAddItemStash(lua_State* L); static int luaPlayerRemoveStashItem(lua_State* L); static int luaPlayerRemoveItem(lua_State* L); @@ -201,6 +202,8 @@ class PlayerFunctions { static int luaPlayerOpenChannel(lua_State* L); static int luaPlayerGetSlotItem(lua_State* L); + static int luaPlayerGetBackpack(lua_State* L); + static int luaPlayerGetLootPouch(lua_State* L); static int luaPlayerGetParty(lua_State* L); diff --git a/src/lua/functions/items/container_functions.cpp b/src/lua/functions/items/container_functions.cpp index 186eb00c88a..5fa6b32f42a 100644 --- a/src/lua/functions/items/container_functions.cpp +++ b/src/lua/functions/items/container_functions.cpp @@ -33,6 +33,8 @@ void ContainerFunctions::init(lua_State* L) { Lua::registerMethod(L, "Container", "addItemEx", ContainerFunctions::luaContainerAddItemEx); Lua::registerMethod(L, "Container", "getCorpseOwner", ContainerFunctions::luaContainerGetCorpseOwner); Lua::registerMethod(L, "Container", "registerReward", ContainerFunctions::luaContainerRegisterReward); + Lua::registerMethod(L, "Container", "removeAllItems", ContainerFunctions::luaContainerRemoveAllItems); + Lua::registerMethod(L, "Container", "removeItemById", ContainerFunctions::luaContainerRemoveItemById); } int ContainerFunctions::luaContainerCreate(lua_State* L) { @@ -310,3 +312,45 @@ int ContainerFunctions::luaContainerRegisterReward(lua_State* L) { Lua::pushBoolean(L, true); return 1; } + +int ContainerFunctions::luaContainerRemoveAllItems(lua_State* L) { + // container:removeAllItems(actor = nil, recursive = false) + const auto &container = Lua::getUserdataShared(L, 1, "Container"); + if (!container) { + lua_pushnil(L); + return 1; + } + + const auto &actor = Lua::getUserdataShared(L, 2, "Player"); + bool isRecursive = Lua::getBoolean(L, 3, false); + + auto removedItems = container->removeAllItems(actor ? actor : nullptr, isRecursive); + + lua_pushnumber(L, static_cast(removedItems)); + return 1; +} + +int ContainerFunctions::luaContainerRemoveItemById(lua_State* L) { + // container:removeItemById(itemId, count[, subType = -1]) + const auto &container = Lua::getUserdataShared(L, 1, "Container"); + if (!container) { + lua_pushnil(L); + return 1; + } + + uint16_t itemId; + if (Lua::isNumber(L, 2)) { + itemId = Lua::getNumber(L, 2); + } else { + itemId = Item::items.getItemIdByName(Lua::getString(L, 2)); + if (itemId == 0) { + lua_pushnil(L); + return 1; + } + } + + uint32_t count = Lua::getNumber(L, 3, 1); + int32_t subType = Lua::getNumber(L, 4, -1); + Lua::pushBoolean(L, container->removeItemById(itemId, count, subType)); + return 1; +} diff --git a/src/lua/functions/items/container_functions.hpp b/src/lua/functions/items/container_functions.hpp index b1a9e276c67..6ce690e52ee 100644 --- a/src/lua/functions/items/container_functions.hpp +++ b/src/lua/functions/items/container_functions.hpp @@ -32,6 +32,8 @@ class ContainerFunctions { static int luaContainerGetCorpseOwner(lua_State* L); static int luaContainerRegisterReward(lua_State* L); + static int luaContainerRemoveAllItems(lua_State* L); + static int luaContainerRemoveItemById(lua_State* L); friend class ItemFunctions; }; diff --git a/src/lua/functions/lua_functions_loader.cpp b/src/lua/functions/lua_functions_loader.cpp index f086a48ca69..996b2d137c7 100644 --- a/src/lua/functions/lua_functions_loader.cpp +++ b/src/lua/functions/lua_functions_loader.cpp @@ -87,6 +87,8 @@ std::string Lua::getErrorDesc(ErrorCode_t code) { return "TalkAction not found"; case LUA_ERROR_ZONE_NOT_FOUND: return "Zone not found"; + case LUA_ERROR_BATCHUPDATE_NOT_FOUND: + return "BatchUpdate not found"; default: return "Bad error code"; } diff --git a/src/lua/lua_definitions.hpp b/src/lua/lua_definitions.hpp index 24435bc70cc..4f6ac0058ab 100644 --- a/src/lua/lua_definitions.hpp +++ b/src/lua/lua_definitions.hpp @@ -137,6 +137,7 @@ enum ErrorCode_t { LUA_ERROR_ACTION_NOT_FOUND, LUA_ERROR_TALK_ACTION_NOT_FOUND, LUA_ERROR_ZONE_NOT_FOUND, + LUA_ERROR_BATCHUPDATE_NOT_FOUND, }; enum TargetSearchType_t { diff --git a/src/pch.hpp b/src/pch.hpp index d202a21f6d3..11e0e681658 100644 --- a/src/pch.hpp +++ b/src/pch.hpp @@ -106,8 +106,6 @@ format_as(E e) { #include #endif -#include "lua/global/shared_object.hpp" - /** * @brief Magic Enum is a C++ library that facilitates easy conversion between enums and strings. * By default, the range of supported enum values is from -128 to 128. We need extends that range. diff --git a/src/utils/CMakeLists.txt b/src/utils/CMakeLists.txt index aaf951fe8af..f86f2973c61 100644 --- a/src/utils/CMakeLists.txt +++ b/src/utils/CMakeLists.txt @@ -5,4 +5,5 @@ target_sources( pugicast.cpp tools.cpp wildcardtree.cpp + batch_update.cpp ) diff --git a/src/utils/batch_update.cpp b/src/utils/batch_update.cpp new file mode 100644 index 00000000000..021e2c2d338 --- /dev/null +++ b/src/utils/batch_update.cpp @@ -0,0 +1,65 @@ +/** + * Canary - A free and open-source MMORPG server emulator + * Copyright (©) 2019–present OpenTibiaBR + * Repository: https://github.com/opentibiabr/canary + * License: https://github.com/opentibiabr/canary/blob/main/LICENSE + * Contributors: https://github.com/opentibiabr/canary/graphs/contributors + * Website: https://docs.opentibiabr.com/ + */ + +#include "utils/batch_update.hpp" + +#include "creatures/players/player.hpp" +#include "items/containers/container.hpp" + +BatchUpdate::State::State(const std::shared_ptr &actor) : + actor(actor) { + if (auto actorLocked = this->actor.lock()) { + actorLocked->beginBatchUpdate(); + } +} + +BatchUpdate::BatchUpdate(const std::shared_ptr &actor) : + m_state(actor) { } + +BatchUpdate::~BatchUpdate() { + const auto actorLocked = m_state.actor.lock(); + auto* actorPtr = actorLocked.get(); + for (const auto &containerWeak : m_state.cached) { + if (auto container = containerWeak.lock()) { + container->endBatchUpdate(actorPtr); + } + } + if (actorLocked) { + actorLocked->endBatchUpdate(); + } +} + +bool BatchUpdate::add(const std::shared_ptr &container) { + if (!container) { + return false; + } + + for (auto it = m_state.cached.begin(); it != m_state.cached.end();) { + if (auto existing = it->lock()) { + if (existing.get() == container.get()) { + return false; + } + ++it; + } else { + it = m_state.cached.erase(it); + } + } + + const auto &added = m_state.cached.emplace_back(container); + (void)added; + container->beginBatchUpdate(); + return true; +} + +void BatchUpdate::addContainers(const std::vector> &containers) { + for (const auto &container : containers) { + const auto addResult = add(container); + (void)addResult; + } +} diff --git a/src/utils/batch_update.hpp b/src/utils/batch_update.hpp new file mode 100644 index 00000000000..f43ab9705c6 --- /dev/null +++ b/src/utils/batch_update.hpp @@ -0,0 +1,46 @@ +/** + * Canary - A free and open-source MMORPG server emulator + * Copyright (©) 2019–present OpenTibiaBR + * Repository: https://github.com/opentibiabr/canary + * License: https://github.com/opentibiabr/canary/blob/main/LICENSE + * Contributors: https://github.com/opentibiabr/canary/graphs/contributors + * Website: https://docs.opentibiabr.com/ + */ + +#pragma once + +#ifndef PRECOMPILED_HEADERS + #include + #include +#endif + +#include "lua/global/shared_object.hpp" + +class Player; +class Container; + +class BatchUpdate : public SharedObject { +public: + explicit BatchUpdate(const std::shared_ptr &actor); + ~BatchUpdate(); + BatchUpdate(const BatchUpdate &) = delete; + BatchUpdate &operator=(const BatchUpdate &) = delete; + BatchUpdate(BatchUpdate &&) = delete; + BatchUpdate &operator=(BatchUpdate &&) = delete; + bool add(const std::shared_ptr &container); + void addContainers(const std::vector> &containerVector); + +private: + struct State { + explicit State(const std::shared_ptr &actor); + State(const State &) = delete; + State &operator=(const State &) = delete; + State(State &&) = delete; + State &operator=(State &&) = delete; + + std::weak_ptr actor; + std::vector> cached; + }; + + State m_state; +}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ccd5390ead7..61eb7e9ca62 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -41,7 +41,9 @@ function( target_compile_definitions( ${TARGET_NAME} - PUBLIC -DDEBUG_LOG -DBUILD_TESTS + PUBLIC -DDEBUG_LOG + -DBUILD_TESTS + TESTS_SOURCE_DIR=\"${CMAKE_SOURCE_DIR}\" ) target_link_libraries( diff --git a/tests/fixture/test_items.hpp b/tests/fixture/test_items.hpp new file mode 100644 index 00000000000..51d5f3a684d --- /dev/null +++ b/tests/fixture/test_items.hpp @@ -0,0 +1,90 @@ +#pragma once + +#include "items/item.hpp" + +namespace test_items { + inline void seedFallbackTestItems() { + auto &items = Item::items.getItems(); + constexpr uint16_t kFallbackRewardContainer = 1987; + constexpr uint16_t kMaxId = ITEM_REWARD_CHEST > kFallbackRewardContainer ? ITEM_REWARD_CHEST : kFallbackRewardContainer; + if (items.size() <= kMaxId) { + items.resize(static_cast(kMaxId) + 1); + } + + for (uint32_t id = 0; id < items.size(); ++id) { + auto &entry = items[id]; + entry.id = static_cast(id); + } + + const auto ensureContainer = [&items](uint16_t id) { + auto &entry = items[id]; + entry.id = id; + if (entry.group == ITEM_GROUP_NONE) { + entry.group = ITEM_GROUP_CONTAINER; + } + if (entry.type == ITEM_TYPE_NONE) { + entry.type = ITEM_TYPE_CONTAINER; + } + if (entry.maxItems == 0) { + entry.maxItems = 10; + } + }; + + const auto ensureItem = [&items](uint16_t id) { + auto &entry = items[id]; + entry.id = id; + if (entry.type == ITEM_TYPE_NONE) { + entry.type = ITEM_TYPE_OTHER; + } + }; + + ensureContainer(ITEM_REWARD_CONTAINER); + ensureContainer(ITEM_REWARD_CHEST); + ensureContainer(kFallbackRewardContainer); + + for (const uint16_t id : { static_cast(100), static_cast(101), static_cast(102), static_cast(103), static_cast(104), static_cast(105) }) { + ensureItem(id); + } + } + + inline void ensureTestItemTypes() { + auto &items = Item::items.getItems(); + if (items.empty()) { + seedFallbackTestItems(); + } + + if (items.size() <= ITEM_REWARD_CHEST) { + items.resize(static_cast(ITEM_REWARD_CHEST) + 1); + } + + for (uint32_t id = 0; id < items.size(); ++id) { + auto &entry = items[id]; + entry.id = static_cast(id); + const bool isContainer = entry.maxItems > 0 || entry.type == ITEM_TYPE_CONTAINER || entry.type == ITEM_TYPE_REWARDCHEST; + if (isContainer && entry.group == ITEM_GROUP_NONE) { + entry.group = ITEM_GROUP_CONTAINER; + } + if (isContainer && entry.type == ITEM_TYPE_NONE) { + entry.type = ITEM_TYPE_CONTAINER; + } + if (!isContainer && entry.type == ITEM_TYPE_NONE) { + entry.type = ITEM_TYPE_OTHER; + } + } + + items[0].id = 0; + } +} + +class TestItems final { +public: + static void init() { + static const bool loaded = [] { + const auto loadedItems = Item::items.loadFromXml(); + (void)loadedItems; + return true; + }(); + (void)loaded; + test_items::ensureTestItemTypes(); + } +}; diff --git a/tests/integration/game/batch_update_it.cpp b/tests/integration/game/batch_update_it.cpp new file mode 100644 index 00000000000..a0ae424daed --- /dev/null +++ b/tests/integration/game/batch_update_it.cpp @@ -0,0 +1,178 @@ +#include + +#include "creatures/players/player.hpp" +#include "items/containers/container.hpp" + +#include "creatures/npcs/npc.hpp" +#include "creatures/npcs/npcs.hpp" +#include "items/item.hpp" +#include "items/items_definitions.hpp" +#include "server/network/protocol/protocolgame.hpp" +#include "test_items.hpp" +#include "utils/batch_update.hpp" + +namespace it_batch_update { + + class FakeContainer : public Container { + public: + using Container::Container; + int beginCount { 0 }; + int endCount { 0 }; + + void beginBatchUpdate() override { + ++beginCount; + Container::beginBatchUpdate(); + } + + void endBatchUpdate(Player* actor) override { + ++endCount; + Container::endBatchUpdate(actor); + } + }; + + class BatchUpdateIntegrationTest : public ::testing::Test { + protected: + void SetUp() override { + TestItems::init(); + } + }; + + std::shared_ptr createShopNpc(uint16_t itemId, uint32_t sellPrice) { + auto npcType = std::make_shared("BatchNpc"); + npcType->name = "BatchNpc"; + npcType->nameDescription = "BatchNpc"; + npcType->info.shopItemVector.emplace_back(itemId, "TestItem", 0, 0, sellPrice); + return std::make_shared(npcType); + } + + TEST_F(BatchUpdateIntegrationTest, EndsBatchUpdatesWhenActorLeavesScope) { + auto player = Player::createForTests(); + auto c1 = std::make_shared(ITEM_REWARD_CONTAINER, 10); + auto c2 = std::make_shared(ITEM_REWARD_CONTAINER, 10); + + { + BatchUpdate batch(player); + EXPECT_TRUE(batch.add(c1)); + EXPECT_TRUE(batch.add(c2)); + EXPECT_TRUE(player->isBatching()); + EXPECT_EQ(c1->beginCount, 1); + EXPECT_EQ(c2->beginCount, 1); + } + + EXPECT_FALSE(player->isBatching()); + EXPECT_EQ(c1->endCount, 1); + EXPECT_EQ(c2->endCount, 1); + } + + TEST_F(BatchUpdateIntegrationTest, DeduplicatesContainersWithinScope) { + auto player = Player::createForTests(); + auto c1 = std::make_shared(ITEM_REWARD_CONTAINER, 10); + + { + BatchUpdate batch(player); + EXPECT_TRUE(batch.add(c1)); + EXPECT_FALSE(batch.add(c1)); + EXPECT_TRUE(player->isBatching()); + EXPECT_EQ(c1->beginCount, 1); + } + + EXPECT_FALSE(player->isBatching()); + EXPECT_EQ(c1->endCount, 1); + } + + TEST_F(BatchUpdateIntegrationTest, SendsLootSaleWithBatchingAcrossContainers) { + auto player = Player::createForTests(); + + auto lootPouch = std::make_shared(ITEM_GOLD_POUCH, 10); + auto storeInbox = std::make_shared(ITEM_STORE_INBOX, 10); + + player->internalAddThing(CONST_SLOT_BACKPACK, lootPouch); + player->internalAddThing(CONST_SLOT_STORE_INBOX, storeInbox); + + { + BatchUpdate batch(player); + batch.add(lootPouch); + batch.add(storeInbox); + + lootPouch->removeAllItems(player); + storeInbox->addItem(Item::CreateItem(ITEM_LETTER_STAMPED, 1)); + } + + EXPECT_TRUE(lootPouch->empty()); + EXPECT_EQ(storeInbox->beginCount, 1); + EXPECT_EQ(storeInbox->endCount, 1); + } + + ReturnValue addItemBatchToPaginatedContainerFake( + const std::shared_ptr &container, + uint16_t itemId, + uint32_t totalCount, + uint32_t &actuallyAdded, + uint32_t maxStackSize = 100 + ) { + actuallyAdded = 0; + + if (!container) { + return RETURNVALUE_NOTPOSSIBLE; + } + if (totalCount == 0) { + return RETURNVALUE_NOERROR; + } + + uint32_t remaining = totalCount; + + while (remaining > 0) { + uint32_t toAdd = std::min(remaining, maxStackSize); + + auto item = Item::CreateItem(itemId, 1); + if (!item) { + return RETURNVALUE_NOTPOSSIBLE; + } + + if (item->isStackable()) { + item->setItemCount(static_cast(toAdd)); + } else { + toAdd = 1; + } + + container->addThing(item); + + actuallyAdded += toAdd; + remaining -= toAdd; + } + + return RETURNVALUE_NOERROR; + } + + TEST_F(BatchUpdateIntegrationTest, AddsItemsToPaginatedContainerBatch_BatchesOnlyOnce) { + auto player = Player::createForTests(); + + auto storeInbox = std::make_shared(ITEM_STORE_INBOX, 10); + player->internalAddThing(CONST_SLOT_STORE_INBOX, storeInbox); + + uint32_t actuallyAdded = 0; + constexpr uint32_t kAddCount = 250; + + { + BatchUpdate batch(player); + EXPECT_TRUE(batch.add(storeInbox)); + + const auto result = addItemBatchToPaginatedContainerFake(storeInbox, ITEM_GOLD_COIN, kAddCount, actuallyAdded); + + EXPECT_EQ(result, RETURNVALUE_NOERROR); + EXPECT_EQ(actuallyAdded, kAddCount); + + EXPECT_EQ(storeInbox->beginCount, 1); + } + + EXPECT_EQ(storeInbox->endCount, 1); + + uint32_t totalCount = 0; + for (const auto &item : storeInbox->getItemList()) { + if (item) { + totalCount += item->getItemCount(); + } + } + EXPECT_EQ(totalCount, kAddCount); + } +} diff --git a/tests/integration/test_database.hpp b/tests/integration/test_database.hpp index 021a5e5af16..7531dee8444 100644 --- a/tests/integration/test_database.hpp +++ b/tests/integration/test_database.hpp @@ -102,7 +102,7 @@ class TestDatabase final { std::string host = get(env, "TEST_DB_HOST", "127.0.0.1"); std::string user = get(env, "TEST_DB_USER", "root"); std::string pass = get(env, "TEST_DB_PASSWORD", nullptr, /*required=*/true); - std::string database = get(env, "TEST_DB_NAME", "otservbr-global"); + std::string database = get(env, "TEST_DB_NAME", "otservbr-global-test"); std::string portStr = get(env, "TEST_DB_PORT", "3306"); auto port = static_cast(std::strtoul(portStr.c_str(), nullptr, 10)); std::string sock = get(env, "TEST_DB_SOCKET", ""); diff --git a/tests/test.env b/tests/test.env index fc0dc9ab137..35ad4ae3539 100644 --- a/tests/test.env +++ b/tests/test.env @@ -1,5 +1,5 @@ TEST_DB_HOST=127.0.0.1 TEST_DB_USER=root TEST_DB_PASSWORD=root -TEST_DB_NAME=otservbr-global +TEST_DB_NAME=otservbr-global-test TEST_DB_PORT=3306 diff --git a/tests/unit/players/CMakeLists.txt b/tests/unit/players/CMakeLists.txt index 4b2cbd6f3f0..7742c849687 100644 --- a/tests/unit/players/CMakeLists.txt +++ b/tests/unit/players/CMakeLists.txt @@ -1,2 +1,7 @@ +target_sources( + canary_ut + PRIVATE reward_iteration_test.cpp +) + add_subdirectory(components) add_subdirectory(imbuements) diff --git a/tests/unit/players/reward_iteration_test.cpp b/tests/unit/players/reward_iteration_test.cpp new file mode 100644 index 00000000000..cf40db99abe --- /dev/null +++ b/tests/unit/players/reward_iteration_test.cpp @@ -0,0 +1,79 @@ +#include "pch.hpp" + +#include + +#include + +#include "creatures/players/player.hpp" +#include "items/containers/container.hpp" +#include "test_items.hpp" +#include "injection_fixture.hpp" + +class PlayerRewardIterationTest : public ::testing::Test { +protected: + void SetUp() override { + TestItems::init(); + } + +private: + InjectionFixture fixture_ {}; +}; + +TEST_F(PlayerRewardIterationTest, ForEachRewardItemSkipsRewardContainersAndVisitsItems) { + auto player = std::make_shared(); + auto root = std::make_shared(ITEM_REWARD_CHEST, 10); + auto itemA = std::make_shared(100); + root->addItem(itemA); + auto reward1 = std::make_shared(ITEM_REWARD_CONTAINER, 10); + auto itemB = std::make_shared(101); + reward1->addItem(itemB); + auto reward2 = std::make_shared(ITEM_REWARD_CONTAINER, 10); + auto itemC = std::make_shared(102); + reward2->addItem(itemC); + reward1->addItem(reward2); + root->addItem(reward1); + auto normal = std::make_shared(1987, 10); + auto itemD = std::make_shared(103); + normal->addItem(itemD); + root->addItem(normal); + + const auto rewards = player->getRewardsFromContainer(root); + ASSERT_EQ(rewards.size(), std::size_t { 4 }); + std::vector ids; + ids.reserve(rewards.size()); + for (const auto &item : rewards) { + const auto &added = ids.emplace_back(item->getID()); + (void)added; + } + const auto sortResult = std::ranges::sort(ids); + (void)sortResult; + EXPECT_EQ(ids, (std::vector { 100, 101, 102, 1987 })); +} + +TEST_F(PlayerRewardIterationTest, ForEachRewardItemHandlesNullRoot) { + auto player = std::make_shared(); + const auto rewards = player->getRewardsFromContainer(nullptr); + EXPECT_TRUE(rewards.empty()); +} + +TEST_F(PlayerRewardIterationTest, ForEachRewardItemTraversesNestedNormalContainersInsideRewards) { + auto player = std::make_shared(); + auto root = std::make_shared(ITEM_REWARD_CHEST, 10); + auto reward = std::make_shared(ITEM_REWARD_CONTAINER, 10); + auto nestedNormal = std::make_shared(1987, 10); + reward->addItem(nestedNormal); + auto plainItem = std::make_shared(105); + reward->addItem(plainItem); + root->addItem(reward); + + const auto rewards = player->getRewardsFromContainer(root); + std::vector visited; + visited.reserve(rewards.size()); + for (const auto &item : rewards) { + const auto &added = visited.emplace_back(item->getID()); + (void)added; + } + const auto sortResult = std::ranges::sort(visited); + (void)sortResult; + EXPECT_EQ(visited, (std::vector { 105, 1987 })); +} diff --git a/tests/unit/utils/CMakeLists.txt b/tests/unit/utils/CMakeLists.txt index 4bc525afea7..16e501e3d5f 100644 --- a/tests/unit/utils/CMakeLists.txt +++ b/tests/unit/utils/CMakeLists.txt @@ -1,4 +1,6 @@ target_sources( canary_ut - PRIVATE position_functions_test.cpp string_functions_test.cpp + PRIVATE position_functions_test.cpp + string_functions_test.cpp + test_batch_update.cpp ) diff --git a/tests/unit/utils/test_batch_update.cpp b/tests/unit/utils/test_batch_update.cpp new file mode 100644 index 00000000000..29a72198459 --- /dev/null +++ b/tests/unit/utils/test_batch_update.cpp @@ -0,0 +1,149 @@ +#include "pch.hpp" + +#include + +#include "utils/batch_update.hpp" + +#include "creatures/players/player.hpp" +#include "items/containers/container.hpp" +#include "items/item.hpp" +#include "test_items.hpp" + +namespace { + + class FakeContainer : public Container { + public: + using Container::Container; + int beginCount { 0 }; + int endCount { 0 }; + + void beginBatchUpdate() override { + ++beginCount; + Container::beginBatchUpdate(); + } + + void endBatchUpdate(Player* actor) override { + ++endCount; + Container::endBatchUpdate(actor); + } + }; + + template + void withBatchUpdate(const std::shared_ptr &player, Func &&action) { + BatchUpdate batch(player); + action(batch); + } + +} + +class BatchUpdateTest : public ::testing::Test { +protected: + void SetUp() override { + TestItems::init(); + } +}; + +TEST_F(BatchUpdateTest, DeduplicatesContainersAndBalancesBeginEndCalls) { + auto player = std::make_shared(); + auto c1 = std::make_shared(ITEM_REWARD_CONTAINER, 10); + withBatchUpdate(player, [&player, &c1](BatchUpdate &batch) { + EXPECT_TRUE(batch.add(c1)); + EXPECT_FALSE(batch.add(c1)); + EXPECT_TRUE(player->isBatching()); + EXPECT_EQ(c1->beginCount, 1); + }); + EXPECT_FALSE(player->isBatching()); + EXPECT_EQ(c1->endCount, 1); +} + +TEST_F(BatchUpdateTest, AddReturnsFalseForNullContainers) { + auto player = std::make_shared(); + auto c1 = std::make_shared(ITEM_REWARD_CONTAINER, 10); + BatchUpdate batch(player); + EXPECT_TRUE(batch.add(c1)); + EXPECT_FALSE(batch.add(nullptr)); + EXPECT_TRUE(player->isBatching()); + EXPECT_EQ(c1->beginCount, 1); +} + +TEST_F(BatchUpdateTest, AddContainersProcessesUniqueContainersOnce) { + auto player = std::make_shared(); + auto c1 = std::make_shared(ITEM_REWARD_CONTAINER, 10); + auto c2 = std::make_shared(ITEM_REWARD_CONTAINER, 10); + withBatchUpdate(player, [&player, &c1, &c2](BatchUpdate &batch) { + EXPECT_TRUE(batch.add(c1)); + batch.addContainers({ c1, c2 }); + EXPECT_TRUE(player->isBatching()); + EXPECT_EQ(c1->beginCount, 1); + EXPECT_EQ(c2->beginCount, 1); + }); + EXPECT_FALSE(player->isBatching()); + EXPECT_EQ(c1->endCount, 1); + EXPECT_EQ(c2->endCount, 1); +} + +TEST_F(BatchUpdateTest, EndBatchUpdateHandlesExpiredActor) { + auto player = std::make_shared(); + auto c1 = std::make_shared(ITEM_REWARD_CONTAINER, 10); + withBatchUpdate(player, [&player, &c1](BatchUpdate &batch) { + EXPECT_TRUE(batch.add(c1)); + player.reset(); + }); + EXPECT_EQ(c1->endCount, 1); +} + +TEST_F(BatchUpdateTest, AddSkipsExpiredCachedContainers) { + auto player = std::make_shared(); + auto c1 = std::make_shared(ITEM_REWARD_CONTAINER, 10); + auto c2 = std::make_shared(ITEM_REWARD_CONTAINER, 10); + BatchUpdate batch(player); + EXPECT_TRUE(batch.add(c1)); + c1.reset(); + EXPECT_TRUE(batch.add(c2)); + EXPECT_EQ(c2->beginCount, 1); +} + +TEST_F(BatchUpdateTest, AddContainersSkipsNullAndDuplicates) { + auto player = std::make_shared(); + auto c1 = std::make_shared(ITEM_REWARD_CONTAINER, 10); + auto c2 = std::make_shared(ITEM_REWARD_CONTAINER, 10); + withBatchUpdate(player, [&player, &c1, &c2](BatchUpdate &batch) { + batch.addContainers({ nullptr, c1, c1, c2, nullptr }); + EXPECT_TRUE(player->isBatching()); + EXPECT_EQ(c1->beginCount, 1); + EXPECT_EQ(c2->beginCount, 1); + }); + EXPECT_FALSE(player->isBatching()); + EXPECT_EQ(c1->endCount, 1); + EXPECT_EQ(c2->endCount, 1); +} + +TEST_F(BatchUpdateTest, EmptyScopeBalancesBatchingState) { + auto player = std::make_shared(); + withBatchUpdate(player, [&player](BatchUpdate &) { + EXPECT_TRUE(player->isBatching()); + }); + EXPECT_FALSE(player->isBatching()); +} + +TEST_F(BatchUpdateTest, ContainerBatchingSuppressesUpdateCallbacks) { + auto player = std::make_shared(); + auto container = std::make_shared(ITEM_GOLD_POUCH, 10); + auto item = Item::CreateItem(ITEM_GOLD_COIN, 5); + ASSERT_NE(item, nullptr); + container->addThing(item); + player->internalAddThing(CONST_SLOT_BACKPACK, container); + + player->tradeItem = item; + player->setTradeState(TRADE_ACKNOWLEDGE); + + container->beginBatchUpdate(); + container->onUpdateContainerItem(0, item, item); + EXPECT_EQ(player->getTradeState(), TRADE_ACKNOWLEDGE); + EXPECT_EQ(player->tradeItem, item); + container->endBatchUpdate(nullptr); + + container->onUpdateContainerItem(0, item, item); + EXPECT_EQ(player->getTradeState(), TRADE_NONE); + EXPECT_EQ(player->tradeItem, nullptr); +}