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.
This commit is contained in:
devbox
2026-08-22 20:55:09 +10:00
parent 2e2913ca78
commit 1867d77c70
3 changed files with 138 additions and 30 deletions
@@ -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)
+6 -29
View File
@@ -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<HighGuid::Player>(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)
@@ -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|<botName>
// Reply: GEAR_SPECS_RESP|<botName>|<currentSpecID>|<specID>|<name>|... (3-tuples)
// ----------------------------------------------------------------------------
void HandleGearSpecsReq(uint32 client_seq, WorldSession* sess,
std::vector<std::string> 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<std::string> 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|<botName>|<specID>
// Reply: GEAR_SPECS_RESP|<botName>|... (fresh spec list so the UI updates)
// ----------------------------------------------------------------------------
void HandleGearSetSpec(uint32 client_seq, WorldSession* sess,
std::vector<std::string> 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 <name>`. 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.