From 69207acf76f03a2e194f2c81b64b534d07c54ddf Mon Sep 17 00:00:00 2001 From: bash Date: Sun, 10 May 2026 17:30:44 +0200 Subject: [PATCH] feat(Core/Loot): Quest GO loot, bag-make-room, item-pursuit --- src/Ai/Base/Actions/DestroyItemAction.cpp | 17 ++--- src/Ai/Base/Actions/LootAction.cpp | 75 +++++++++++++++++++-- src/Ai/Base/Value/PossibleRpgTargetsValue.h | 6 ++ src/Mgr/Item/LootObjectStack.cpp | 57 +++++++++++++--- src/Mgr/Item/LootObjectStack.h | 9 ++- 5 files changed, 139 insertions(+), 25 deletions(-) diff --git a/src/Ai/Base/Actions/DestroyItemAction.cpp b/src/Ai/Base/Actions/DestroyItemAction.cpp index ffcf0fdcf..e45712790 100644 --- a/src/Ai/Base/Actions/DestroyItemAction.cpp +++ b/src/Ai/Base/Actions/DestroyItemAction.cpp @@ -29,6 +29,10 @@ void DestroyItemAction::DestroyItem(FindItemVisitor* visitor) std::vector items = visitor->GetResult(); for (Item* item : items) { + // backstop: never drop an active quest item + if (bot->HasQuestForItem(item->GetTemplate()->ItemId)) + continue; + std::ostringstream out; out << chat->FormatItem(item->GetTemplate()) << " destroyed"; botAI->TellMaster(out); @@ -67,18 +71,11 @@ bool SmartDestroyItemAction::Execute(Event /*event*/) return true; } + // ITEM_USAGE_QUEST is excluded — those are still-needed quest items std::vector bestToDestroy = {ITEM_USAGE_NONE}; // First destroy anything useless. - if (!AI_VALUE(bool, "can sell") && - AI_VALUE( - bool, - "should get money")) // We need money so quest items are less important since they can't directly be sold. - bestToDestroy.push_back(ITEM_USAGE_QUEST); - else // We don't need money so destroy the cheapest stuff. - { - bestToDestroy.push_back(ITEM_USAGE_VENDOR); - bestToDestroy.push_back(ITEM_USAGE_AH); - } + bestToDestroy.push_back(ITEM_USAGE_VENDOR); + bestToDestroy.push_back(ITEM_USAGE_AH); // If we still need room bestToDestroy.push_back( diff --git a/src/Ai/Base/Actions/LootAction.cpp b/src/Ai/Base/Actions/LootAction.cpp index 93368f524..9ffd1e526 100644 --- a/src/Ai/Base/Actions/LootAction.cpp +++ b/src/Ai/Base/Actions/LootAction.cpp @@ -5,6 +5,9 @@ #include "LootAction.h" +#include + +#include "Bag.h" #include "ChatHelper.h" #include "Event.h" #include "GuildMgr.h" @@ -76,7 +79,11 @@ bool OpenLootAction::Execute(Event /*event*/) bool result = DoLoot(lootObject); if (result) { - AI_VALUE(LootObjectStack*, "available loot")->Remove(lootObject.guid); + // MarkCompleted (not Remove) — "add all loot" reads + // "nearest corpses" without a lootable filter, so a plain + // Remove lets the same corpse re-enter the stack on the next + // tick. The completed set blocks re-add for ~5 min. + AI_VALUE(LootObjectStack*, "available loot")->MarkCompleted(lootObject.guid); context->GetValue("loot target")->Set(LootObject()); } return result; @@ -139,8 +146,9 @@ bool OpenLootAction::DoLoot(LootObject& lootObject) if (go && (go->GetGoState() != GO_STATE_READY)) return false; - // This prevents dungeon chests like Tribunal Chest (Halls of Stone) from being ninja'd by the bots - if (go && go->HasFlag(GAMEOBJECT_FLAGS, GO_FLAG_INTERACT_COND)) + // Block event-gated chests (Tribunal Chest, Gunship Armory) but allow + // wild quest GOs (Moonpetal Lily etc.) when the bot is on the quest. + if (go && go->HasFlag(GAMEOBJECT_FLAGS, GO_FLAG_INTERACT_COND) && !lootObject.isNeededQuestItem) return false; // This prevents raid chests like Gunship Armory (ICC) from being ninja'd by the bots @@ -377,6 +385,12 @@ bool StoreLootAction::Execute(Event event) // bot->GetSession()->HandleLootMoneyOpcode(packet); } + // one make-room destroy per loot packet — CanStoreNewItem after a junk + // destroy can still report full while CMSG_AUTOSTORE_LOOT_ITEM is + // queued, so a multi-quest-item packet would otherwise destroy more + // junk than necessary + bool destroyedThisPacket = false; + for (uint8 i = 0; i < items; ++i) { uint32 itemid; @@ -402,7 +416,9 @@ bool StoreLootAction::Execute(Event event) if (!proto) continue; - if (!botAI->HasActivePlayerMaster() && AI_VALUE(uint8, "bag space") > 80) + // bags >80%: skip non-stackable junk (quest items exempt) + if (!botAI->HasActivePlayerMaster() && AI_VALUE(uint8, "bag space") > 80 && + !bot->HasQuestForItem(itemid)) { uint32 maxStack = proto->GetMaxStackSize(); if (maxStack == 1) @@ -438,6 +454,55 @@ bool StoreLootAction::Execute(Event event) GuildTaskMgr::instance().CheckItemTask(itemid, itemcount, ref->GetSource(), bot); } + // bags full + quest item: make room by dropping cheapest junk + if (!destroyedThisPacket && bot->HasQuestForItem(itemid)) + { + ItemPosCountVec dest; + InventoryResult can = + bot->CanStoreNewItem(NULL_BAG, NULL_SLOT, dest, itemid, itemcount); + if (can == EQUIP_ERR_INVENTORY_FULL || can == EQUIP_ERR_BAG_FULL) + { + // picked by usage, not quality — high-level bots have no grays + Item* victim = nullptr; + uint32 minPrice = std::numeric_limits::max(); + auto consider = [&](uint8 bag, uint8 slot) + { + Item* it = bot->GetItemByPos(bag, slot); + if (!it) + return; + ItemTemplate const* tpl = it->GetTemplate(); + if (!tpl) + return; + if (bot->HasQuestForItem(tpl->ItemId)) + return; + ItemUsage usage = AI_VALUE2(ItemUsage, "item usage", tpl->ItemId); + if (usage != ITEM_USAGE_NONE && usage != ITEM_USAGE_VENDOR && + usage != ITEM_USAGE_BAD_EQUIP && usage != ITEM_USAGE_BROKEN_EQUIP) + return; + if (tpl->SellPrice < minPrice) + { + minPrice = tpl->SellPrice; + victim = it; + } + }; + for (uint8 slot = INVENTORY_SLOT_ITEM_START; slot < INVENTORY_SLOT_ITEM_END; ++slot) + consider(INVENTORY_SLOT_BAG_0, slot); + for (uint8 bag = INVENTORY_SLOT_BAG_START; bag < INVENTORY_SLOT_BAG_END; ++bag) + { + Bag* pBag = bot->GetBagByPos(bag); + if (!pBag) + continue; + for (uint32 slot = 0; slot < pBag->GetBagSize(); ++slot) + consider(bag, static_cast(slot)); + } + if (victim) + { + bot->DestroyItem(victim->GetBagSlot(), victim->GetSlot(), true); + destroyedThisPacket = true; + } + } + } + WorldPacket* packet = new WorldPacket(CMSG_AUTOSTORE_LOOT_ITEM, 1); *packet << itemindex; bot->GetSession()->QueuePacket(packet); @@ -453,7 +518,7 @@ bool StoreLootAction::Execute(Event event) BroadcastHelper::BroadcastLootingItem(botAI, bot, proto); } - AI_VALUE(LootObjectStack*, "available loot")->Remove(guid); + AI_VALUE(LootObjectStack*, "available loot")->MarkCompleted(guid); // release loot WorldPacket* packet = new WorldPacket(CMSG_LOOT_RELEASE, 8); diff --git a/src/Ai/Base/Value/PossibleRpgTargetsValue.h b/src/Ai/Base/Value/PossibleRpgTargetsValue.h index 00e5f8012..ad00f70ff 100644 --- a/src/Ai/Base/Value/PossibleRpgTargetsValue.h +++ b/src/Ai/Base/Value/PossibleRpgTargetsValue.h @@ -47,7 +47,13 @@ public: { if (allowedGOFlags.empty()) { + // questgivers for accept/turn-in; rest for quest progression + // (chests, runes, altars, moonwells, lily piles, …) allowedGOFlags.push_back(GAMEOBJECT_TYPE_QUESTGIVER); + allowedGOFlags.push_back(GAMEOBJECT_TYPE_CHEST); + allowedGOFlags.push_back(GAMEOBJECT_TYPE_GOOBER); + allowedGOFlags.push_back(GAMEOBJECT_TYPE_SPELL_FOCUS); + allowedGOFlags.push_back(GAMEOBJECT_TYPE_GENERIC); } } diff --git a/src/Mgr/Item/LootObjectStack.cpp b/src/Mgr/Item/LootObjectStack.cpp index a62eb8fe4..5c613d070 100644 --- a/src/Mgr/Item/LootObjectStack.cpp +++ b/src/Mgr/Item/LootObjectStack.cpp @@ -45,7 +45,8 @@ void LootTargetList::shrink(time_t fromTime) } } -LootObject::LootObject(Player* bot, ObjectGuid guid) : guid(), skillId(SKILL_NONE), reqSkillValue(0), reqItem(0) +LootObject::LootObject(Player* bot, ObjectGuid guid) + : guid(), skillId(SKILL_NONE), reqSkillValue(0), reqItem(0), isNeededQuestItem(false) { Refresh(bot, guid); } @@ -55,6 +56,7 @@ void LootObject::Refresh(Player* bot, ObjectGuid lootGUID) skillId = SKILL_NONE; reqSkillValue = 0; reqItem = 0; + isNeededQuestItem = false; guid.Clear(); PlayerbotAI* botAI = GET_PLAYERBOT_AI(bot); @@ -101,6 +103,7 @@ void LootObject::Refresh(Player* bot, ObjectGuid lootGUID) if (IsNeededForQuest(bot, itemId)) { this->guid = lootGUID; + this->isNeededQuestItem = true; return; } @@ -135,10 +138,21 @@ void LootObject::Refresh(Player* bot, ObjectGuid lootGUID) if (!proto) continue; + // Moonpetal Lily, Hyacinth Mushroom etc. expose quest + // drops here (not in gameobject_questitem). Flag it so + // the INTERACT_COND gate lets the bot through. + if (IsNeededForQuest(bot, itemId)) + { + this->guid = lootGUID; + this->isNeededQuestItem = true; + return; + } + if (proto->Class != ITEM_CLASS_QUEST) { onlyHasQuestItems = false; - break; + // keep scanning — a later item may be needed + continue; } // If this item references another loot table, process it @@ -157,11 +171,15 @@ void LootObject::Refresh(Player* bot, ObjectGuid lootGUID) if (!refProto) continue; - if (refProto->Class != ITEM_CLASS_QUEST) + if (IsNeededForQuest(bot, refItemId)) { - onlyHasQuestItems = false; - break; + this->guid = lootGUID; + this->isNeededQuestItem = true; + return; } + + if (refProto->Class != ITEM_CLASS_QUEST) + onlyHasQuestItems = false; } } } @@ -270,6 +288,7 @@ LootObject::LootObject(LootObject const& other) skillId = other.skillId; reqSkillValue = other.reqSkillValue; reqItem = other.reqItem; + isNeededQuestItem = other.isNeededQuestItem; } bool LootObject::IsLootPossible(Player* bot) @@ -299,10 +318,13 @@ bool LootObject::IsLootPossible(Player* bot) return false; } - // Prevent bot from running to chests that are unlootable (e.g. Gunship Armory before completing the event) or on - // respawn time + // Block event-gated chests (Gunship Armory pre-event) and unspawned + // GOs. INTERACT_COND alone is allowed when the GO holds a quest + // item we need — ConditionMgr already gates on quest state. GameObject* go = botAI->GetGameObject(guid); - if (go && (go->HasFlag(GAMEOBJECT_FLAGS, GO_FLAG_INTERACT_COND | GO_FLAG_NOT_SELECTABLE) || !go->isSpawned())) + if (go && (go->HasFlag(GAMEOBJECT_FLAGS, GO_FLAG_NOT_SELECTABLE) || !go->isSpawned())) + return false; + if (go && go->HasFlag(GAMEOBJECT_FLAGS, GO_FLAG_INTERACT_COND) && !isNeededQuestItem) return false; if (skillId == SKILL_NONE) @@ -340,6 +362,13 @@ bool LootObject::IsLootPossible(Player* bot) bool LootObjectStack::Add(ObjectGuid guid) { + // expire old completed entries so a despawn/respawn with a reused + // guid can still be looted later + completedLoot.shrink(time(nullptr) - 300); + + if (completedLoot.find(guid) != completedLoot.end()) + return false; + if (availableLoot.size() >= MAX_LOOT_OBJECT_COUNT) { availableLoot.shrink(time(nullptr) - 30); @@ -363,7 +392,17 @@ void LootObjectStack::Remove(ObjectGuid guid) availableLoot.erase(i); } -void LootObjectStack::Clear() { availableLoot.clear(); } +void LootObjectStack::MarkCompleted(ObjectGuid guid) +{ + Remove(guid); + completedLoot.insert(guid); +} + +void LootObjectStack::Clear() +{ + availableLoot.clear(); + completedLoot.clear(); +} bool LootObjectStack::CanLoot(float maxDistance) { diff --git a/src/Mgr/Item/LootObjectStack.h b/src/Mgr/Item/LootObjectStack.h index 0f87aed70..f174fce21 100644 --- a/src/Mgr/Item/LootObjectStack.h +++ b/src/Mgr/Item/LootObjectStack.h @@ -26,7 +26,7 @@ public: class LootObject { public: - LootObject() : skillId(0), reqSkillValue(0), reqItem(0) {} + LootObject() : skillId(0), reqSkillValue(0), reqItem(0), isNeededQuestItem(false) {} LootObject(Player* bot, ObjectGuid guid); LootObject(LootObject const& other); LootObject& operator=(LootObject const& other) = default; @@ -40,6 +40,9 @@ public: uint32 skillId; uint32 reqSkillValue; uint32 reqItem; + // GO holds a quest item we still need; lets us bypass the + // INTERACT_COND blanket reject in the loot path + bool isNeededQuestItem; private: static bool IsNeededForQuest(Player* bot, uint32 itemId); @@ -73,6 +76,7 @@ public: bool Add(ObjectGuid guid); void Remove(ObjectGuid guid); + void MarkCompleted(ObjectGuid guid); void Clear(); bool CanLoot(float maxDistance); LootObject GetLoot(float maxDistance = 0); @@ -82,6 +86,9 @@ private: Player* bot; LootTargetList availableLoot; + // Guids we already opened loot on; blocks "add all loot" from + // re-adding the same corpse before it despawns. + LootTargetList completedLoot; }; #endif