From 1867d77c70a57d3fef600206c8d2cfd7a446f1e8 Mon Sep 17 00:00:00 2001 From: devbox Date: Sat, 22 Aug 2026 20:55:09 +1000 Subject: [PATCH] Playerbot: single quest-item propagation path + bot spec control - OnLootUnit's corpse-open quest-item scan double-granted every creature quest item (owner opens corpse -> +1, owner takes item -> +1 via OnLootItem). Remove the redundant scan; OnLootItem is now the single propagation path (covers creature AND GO loot). Fixes double loot. - BotGear: add specialization control. New GEAR_SPECS_REQ lists the bot's specs; GEAR_SET_SPEC switches via Player::ActivateTalentGroup. Addon renders a spec button row on the panel; active spec is highlighted. --- .../PlayerbotV2/Addon/BotGear/BotGear.lua | 66 +++++++++++++++++- src/modules/PlayerbotV2/PlayerbotV2.cpp | 35 ++-------- .../PlayerbotV2/Session/AddonControl.cpp | 67 +++++++++++++++++++ 3 files changed, 138 insertions(+), 30 deletions(-) diff --git a/src/modules/PlayerbotV2/Addon/BotGear/BotGear.lua b/src/modules/PlayerbotV2/Addon/BotGear/BotGear.lua index eb9383a50b..1a911a7f2c 100644 --- a/src/modules/PlayerbotV2/Addon/BotGear/BotGear.lua +++ b/src/modules/PlayerbotV2/Addon/BotGear/BotGear.lua @@ -29,6 +29,9 @@ local gold = 0 local selected = nil -- {bag=, slot=} item picked up for move local modelActor = nil local bagScroll, bagContent, goldText +local specList = {} -- { {id, order, name} } +local currentSpec = 0 +local specButtons = {} -- [order] = button local EQUIP_SLOT_NAMES = { [0]="Head",[1]="Neck",[2]="Shoulder",[3]="Shirt",[4]="Chest", @@ -68,9 +71,22 @@ local function RequestEquip() Send("GEAR_EQUIP_REQ", botName) end +local function RequestSpecs() + if not botName then return end + Send("GEAR_SPECS_REQ", botName) +end + local function RefreshAll() RequestBags() C_Timer.After(0.15, RequestEquip) + RequestSpecs() +end + +local function Unescape(s) + if not s then return s end + s = s:gsub("\\p", "|") + s = s:gsub("\\\\", "\\") + return s end -- Rendering --------------------------------------------------------- @@ -287,6 +303,47 @@ local function ParseEquipResp(guidStr, body) RefreshEquip() end +local function RefreshSpecButtons() + for _, b in pairs(specButtons) do + b:Hide() + b:SetParent(nil) + end + specButtons = {} + + local x = 205 + for _, s in ipairs(specList) do + local b = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") + b:SetSize(74, 22) + b:SetPoint("TOPLEFT", frame, "TOPLEFT", x, -66) + b:SetText(s.name) + b.specID = s.id + if s.id == currentSpec then + b:GetFontString():SetTextColor(1, 0.82, 0.2) + end + b:SetScript("OnClick", function(self) + Send("GEAR_SET_SPEC", botName, tostring(self.specID)) + end) + specButtons[s.order] = b + x = x + 78 + end +end + +local function ParseSpecsResp(guidStr, rest) + if guidStr ~= botName then return end + local cur, tupleStr = rest:match("^(%d+)|(.*)$") + currentSpec = tonumber(cur) or 0 + specList = {} + if tupleStr then + for tuple in tupleStr:gmatch("[^|]+") do + local id, order, name = (Unescape(tuple)):match("^(%d+)|(-?%d+)|(.+)$") + if id then + tinsert(specList, { id = tonumber(id), order = tonumber(order), name = name }) + end + end + end + if frame then RefreshSpecButtons() end +end + -- UI ---------------------------------------------------------------- local function RefreshTarget() local n = ResolveTarget() @@ -359,8 +416,12 @@ local function BuildFrame() end -- Bags (scroll) + local specLbl = frame:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall") + specLbl:SetPoint("TOPLEFT", frame, "TOPLEFT", 205, -44) + specLbl:SetText("Specialization:") + bagScroll = CreateFrame("ScrollFrame", nil, frame, "BackdropTemplate") - bagScroll:SetPoint("TOPLEFT", frame, "TOPLEFT", 205, -40) + bagScroll:SetPoint("TOPLEFT", frame, "TOPLEFT", 205, -92) bagScroll:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -12, 34) bagScroll:SetBackdrop({ bgFile="Interface\\DialogFrame\\UI-DialogBox-Background" }) bagContent = CreateFrame("Frame", nil, bagScroll) @@ -402,6 +463,9 @@ f:SetScript("OnEvent", function(_, _, prefix, msg, channel, sender) elseif mtype == "GEAR_EQUIP_RESP" then local guidStr, rest = body:match("^([^|]+)|(.*)$") ParseEquipResp(guidStr, rest) + elseif mtype == "GEAR_SPECS_RESP" then + local guidStr, rest = body:match("^([^|]+)|(.*)$") + ParseSpecsResp(guidStr, rest) end end) diff --git a/src/modules/PlayerbotV2/PlayerbotV2.cpp b/src/modules/PlayerbotV2/PlayerbotV2.cpp index e854b1e4c2..6decf94475 100644 --- a/src/modules/PlayerbotV2/PlayerbotV2.cpp +++ b/src/modules/PlayerbotV2/PlayerbotV2.cpp @@ -3111,35 +3111,12 @@ void Module::OnLootUnit(Player* player, ObjectGuid creature_guid) auto& reg = ::Playerbot::Services::Registry(); - // QUEST-ITEM SCAN: copy needed quest items to online alts - { - Creature* c = ObjectAccessor::GetCreature(*player, creature_guid); - if (c && !c->IsAlive()) - { - Loot* loot = c->GetLootForPlayer(player); - if (loot) - { - for (uint8 i = 0; i < loot->items.size(); ++i) - { - LootItem* li = loot->LootItemInSlot(i, player); - if (!li || li->is_blocked) - continue; - - for (BotId id : bots) - { - Player* bot = ObjectAccessor::FindConnectedPlayer( - ObjectGuid::Create(id)); - if (!bot || !bot->IsInWorld()) - continue; - if (bot->GetMapId() != player->GetMapId()) - continue; - - GiveQuestItem(bot, li->itemid, li->count); - } - } - } - } - } + // NOTE: The old "QUEST-ITEM SCAN" here granted copies of every quest item + // on the corpse when the owner opened loot. OnLootItem (hooked in + // Player::StoreLootItem) already grants per-item copies when the owner + // actually takes the item, so the corpse-open scan double-granted every + // creature quest item. The scan is removed — OnLootItem is the single + // quest-item propagation path (covers both creature and GO loot). // AI corpse walk: only bots with AI registry for (BotId id : bots) diff --git a/src/modules/PlayerbotV2/Session/AddonControl.cpp b/src/modules/PlayerbotV2/Session/AddonControl.cpp index 21c86d65af..c0086c03c5 100644 --- a/src/modules/PlayerbotV2/Session/AddonControl.cpp +++ b/src/modules/PlayerbotV2/Session/AddonControl.cpp @@ -29,6 +29,8 @@ #include "ObjectAccessor.h" #include "ObjectGuid.h" #include "ObjectMgr.h" +#include "DB2Stores.h" +#include "DB2Structure.h" #include "Player.h" #include "World.h" #include "WorldSession.h" @@ -1201,6 +1203,63 @@ void HandleGearMoveItem(uint32 /*client_seq*/, WorldSession* sess, uint16(dstBagPos << 8) | dstSlot); } +// ---------------------------------------------------------------------------- +// SPECS_REQ — list a bot's available specializations for the controller UI. +// +// Wire: GEAR_SPECS_REQ| +// Reply: GEAR_SPECS_RESP|||||... (3-tuples) +// ---------------------------------------------------------------------------- +void HandleGearSpecsReq(uint32 client_seq, WorldSession* sess, + std::vector const& fields) +{ + if (fields.empty()) return; + Player* bot = ResolveGearBot(fields[0]); + if (!bot) return; + if (!Services::Owners().IsOwner(bot->GetGUID().GetCounter(), sess->GetAccountId(), + sess->GetPlayer() ? sess->GetPlayer()->GetGUID().GetCounter() : 0u)) + return; + + std::vector out; + out.push_back(Escape(fields[0])); + out.push_back(std::to_string(AsUnderlyingType(bot->GetPrimarySpecialization()))); + for (uint32 i = 0; i < MAX_SPECIALIZATIONS; ++i) + { + ChrSpecializationEntry const* spec = sDB2Manager.GetChrSpecializationByIndex(bot->GetClass(), i); + if (!spec) + continue; + char const* name = spec->Name[sWorld->GetDefaultDbcLocale()]; + out.push_back(Escape(std::to_string(spec->ID) + "|" + + std::to_string(int32(spec->OrderIndex)) + "|" + + (name ? name : "?"))); + } + SendFields(sess, client_seq, "GEAR_SPECS_RESP", out); +} + +// ---------------------------------------------------------------------------- +// SET_SPEC — switch a bot to the given specialization. +// +// Wire: GEAR_SET_SPEC|| +// Reply: GEAR_SPECS_RESP||... (fresh spec list so the UI updates) +// ---------------------------------------------------------------------------- +void HandleGearSetSpec(uint32 client_seq, WorldSession* sess, + std::vector const& fields) +{ + if (fields.size() < 2) return; + Player* bot = ResolveGearBot(fields[0]); + if (!bot) return; + if (!Services::Owners().IsOwner(bot->GetGUID().GetCounter(), sess->GetAccountId(), + sess->GetPlayer() ? sess->GetPlayer()->GetGUID().GetCounter() : 0u)) + return; + + uint32 const specID = uint32(std::strtoul(fields[1].c_str(), nullptr, 10)); + ChrSpecializationEntry const* spec = sChrSpecializationStore.LookupEntry(specID); + if (!spec || spec->ClassID != bot->GetClass() || spec->IsPetSpecialization()) + return; + + bot->ActivateTalentGroup(spec); + HandleGearSpecsReq(client_seq, sess, { fields[0] }); +} + // ---------------------------------------------------------------------------- // SUMMON — addon counterpart to `.playerbot summon `. Enforces the // SAME guards as the chat command (account match, cap, not-online refusal, @@ -1611,6 +1670,14 @@ void DispatchAssembled(WorldSession* sess, uint32 seq, { HandleGearMoveItem(seq, sess, fields); } + else if (mtype == "GEAR_SPECS_REQ") + { + HandleGearSpecsReq(seq, sess, fields); + } + else if (mtype == "GEAR_SET_SPEC") + { + HandleGearSetSpec(seq, sess, fields); + } else if (mtype == "ACK") { // Retry spool not implemented in v1 — silent drop.