diff --git a/.luarc.json b/.luarc.json
index 43f3bb0..a43f168 100644
--- a/.luarc.json
+++ b/.luarc.json
@@ -57,6 +57,8 @@
"InviteUnit",
"IsAddOnLoaded",
"MAX_PLAYER_LEVEL",
+ "MERCHANT_ITEMS_PER_PAGE",
+ "MerchantFrame",
"NORMAL_FONT_COLOR",
"OKAY",
"OpacitySliderFrame",
@@ -78,6 +80,7 @@
"UIDropDownMenu_SetWidth",
"UIDROPDOWNMENU_INIT_MENU",
"UIDROPDOWNMENU_MENU_LEVEL",
+ "UIErrorsFrame",
"UnitPopupButtons",
"UnitPopupMenus",
"UnitPopupShown",
diff --git a/CleanBot.lua b/CleanBot.lua
index 7f39560..75311c8 100644
--- a/CleanBot.lua
+++ b/CleanBot.lua
@@ -268,6 +268,7 @@ NS.THEME_DEFAULTS = {
NS.botEmotes = true
NS.itemGlow = true -- Blizz-path rarity overlay on items/equipment (uncommon+); see ItemVisuals
NS.hideBotChatter = true -- hide CleanBot's own whisper/command spam from chat; see ChatFilter.lua
+NS.vendorEnabled = true -- merchant-frame bot vendor tabs (bots buy/sell at a vendor); see Merchant.lua
NS.manageSelf = false -- preference: auto-enable self-bot on fresh login; see Bridge self-bot section
NS.selfBotActive = false -- live state: is the player currently a self-bot (driven by server botAI messages)
NS.scale = NS.THEME_DEFAULTS.scale
@@ -514,6 +515,9 @@ initFrame:SetScript("OnEvent", function(self, event)
if type(CleanBot_SavedVars.hideBotChatter) == "boolean" then
NS.hideBotChatter = CleanBot_SavedVars.hideBotChatter
end
+ if type(CleanBot_SavedVars.vendorEnabled) == "boolean" then
+ NS.vendorEnabled = CleanBot_SavedVars.vendorEnabled
+ end
if type(CleanBot_SavedVars.manageSelf) == "boolean" then
NS.manageSelf = CleanBot_SavedVars.manageSelf
end
diff --git a/CleanBot.toc b/CleanBot.toc
index 47c523f..8c75b76 100644
--- a/CleanBot.toc
+++ b/CleanBot.toc
@@ -2,7 +2,7 @@
## Title: CleanBot
## Notes: A clean interface to manage Playerbots. No more complex trees of buttons!
## Author: BennyBroseph
-## Version: 0.8
+## Version: 0.9
## SavedVariables: CleanBot_SavedVars
CleanBot.xml
@@ -27,6 +27,7 @@ Individual\Equip.lua
Individual\Model.lua
Individual\Individual.lua
Trade.lua
+Merchant.lua
UnitMenu.lua
ManageTab.lua
GroupTab.lua
diff --git a/Individual/Inventory.lua b/Individual/Inventory.lua
index 05059f3..4ea0c2a 100644
--- a/Individual/Inventory.lua
+++ b/Individual/Inventory.lua
@@ -248,6 +248,52 @@ end
KINDS.inventory.menu = CB_ShowInvMenu
KINDS.bank.menu = CB_ShowBankMenu
+-- ── Sell at a vendor (CleanBot vendor feature; see Merchant.lua) ─────────────
+-- While a merchant is open, a plain right-click on a bot inventory item sells it.
+-- Uncommon-or-better items confirm first (bots have no vendor buyback). The popup
+-- registers lazily because NS.CB_RegisterConfirmPopup lives in ManageTab.lua, which
+-- loads after this file.
+local function CB_DoSell(cell, key, entry)
+ local link = cell.itemLink
+ if not link then return end
+ NS.CB_SendBotCommand(entry.name, "s " .. NS.CB_CleanItemLink(link))
+ -- Optimistic update: a vendor sale clears the whole stack — blank the cell now (mirrors the
+ -- right-click "Use" path), then reconcile against the bot's real bags.
+ cell.icon:Hide()
+ cell.countText:Hide()
+ cell.itemLink = nil
+ NS.CB_ClearQualityBorder(cell)
+ NS.CB_SetRarityOverlay(cell, nil)
+ NS.CB_ScheduleReconcile(key, entry.name)
+end
+
+local function CB_EnsureSellPopup()
+ if StaticPopupDialogs["CLEANBOT_MERCHANT_SELL"] or not NS.CB_RegisterConfirmPopup then return end
+ NS.CB_RegisterConfirmPopup("CLEANBOT_MERCHANT_SELL",
+ "Sell %s? Bots have no buyback, so this can't be undone.",
+ function(_, data)
+ local e = data and data.key and CleanBot_PartyBots[data.key]
+ -- Only sell if the cell still holds the same item (guard against a refresh between
+ -- the popup opening and confirmation).
+ if e and data.cell and data.cell.itemLink == data.link then CB_DoSell(data.cell, data.key, e) end
+ end)
+end
+
+--- Right-click-to-sell from a bot's inventory cell. Quality >= 2 (uncommon/green or
+--- better) asks for confirmation first; poor/common sell immediately.
+local function CB_MerchantSellCell(cell, key, entry)
+ local link = cell.itemLink
+ if not link then return end
+ if NS.CB_CheckBotVendorRange and not NS.CB_CheckBotVendorRange(entry.name) then return end
+ local _, _, quality = GetItemInfo(link)
+ if (quality or 0) >= 2 then
+ CB_EnsureSellPopup()
+ StaticPopup_Show("CLEANBOT_MERCHANT_SELL", link, nil, { cell = cell, key = key, link = link })
+ else
+ CB_DoSell(cell, key, entry)
+ end
+end
+
-- Optimistically moves an item from a grid cell to the first empty cell of the
-- destination frame, mirroring the server-side deposit/withdraw before the refetch
-- confirms (same eager-update spirit as the right-click "Use"). srcCell is blanked
@@ -288,6 +334,29 @@ local function CB_OptimisticMove(srcCell, destFrame, destCell)
else dest.countText:Hide() end
end
+-- Optimistically shows a freshly BOUGHT item in the bot's open bag window (if any), then
+-- reconciles. Buying happens at the merchant, so the window is usually closed — then this is a
+-- no-op. Adds to the first empty cell; the count/stacking is left to the reconcile to correct.
+---@param key string Bot name-key.
+---@param botName string Bot's display name (for the reconcile).
+---@param link string The bought item's link.
+NS.CB_OptimisticBuy = function(key, botName, link)
+ local f = NS.botInventoryFrames and NS.botInventoryFrames[key]
+ if f and f:IsShown() and f.cells then
+ for _, c in ipairs(f.cells) do
+ if c:IsShown() and not c.itemLink then
+ c.icon:SetTexture(GetItemIcon(strmatch(link, "item:(%d+)") or 0))
+ c.icon:Show()
+ c.itemLink = link
+ NS.CB_ApplyItemVisuals(c, link)
+ c.countText:Hide()
+ break
+ end
+ end
+ NS.CB_ScheduleReconcile(key, botName)
+ end
+end
+
-- ── Deposit / withdraw between a bot's bags and bank ─────────────────────
-- Both directions share the "bank" trigger ("bank " deposits from bags,
-- "bank -" withdraws); both need a banker NPC near the bot (handled by the
@@ -1112,6 +1181,17 @@ local function CB_RenderGrid(kind, key, forceFull)
-- plain right-click opens the menu as usual.
local bankF = NS.botBankFrames[key]
local bankOpen = bankF and bankF:IsShown()
+ -- At a vendor (CleanBot vendor feature enabled), a plain right-click on an
+ -- INVENTORY item sells it — uncommon+ confirms first (bots have no buyback).
+ -- The context menu stays on shift+right-click; a bank move wins if the bank
+ -- is open. With no merchant open we fall through to the bank/menu logic below.
+ if kind == "inventory" and not bankOpen and not IsShiftKeyDown()
+ and NS.vendorEnabled and NS.CB_IsMerchantOpen and NS.CB_IsMerchantOpen() then
+ if CB_GridLocked(kind, key) then return end -- mid whisper refresh
+ local entry = CleanBot_PartyBots[key]
+ if entry then CB_MerchantSellCell(self, key, entry) end
+ return
+ end
local canMove = (kind == "bank") or (kind == "inventory" and bankOpen)
if canMove and not IsShiftKeyDown() then
-- A move touches both grids — block while either is mid-refresh.
diff --git a/Merchant.lua b/Merchant.lua
new file mode 100644
index 0000000..be29d65
--- /dev/null
+++ b/Merchant.lua
@@ -0,0 +1,545 @@
+-- ============================================================
+-- Merchant.lua — Vendor frame extension.
+--
+-- While a merchant is open AND the player has bots in the group, a vertical column
+-- of 2D portrait tabs (the player + each bot) runs down the merchant frame's right edge:
+-- • Selecting a BOT tab and right-clicking a vendor item makes that bot BUY it.
+-- • The PLAYER tab (selected by default) is a passthrough — the default UI buys.
+-- • Right-clicking a bot's portrait opens its inventory (Inventory.lua), where a
+-- plain right-click on an item sells it (uncommon+ asks first — no buyback).
+-- In a raid the portrait column becomes a top-center dropdown plus an "Inventory" button.
+-- A cog at the top of the column (always shown while at a vendor with bots; its panel
+-- fades in on hover) holds the "Enable" toggle so the feature can never lock you out.
+--
+-- Mirrors Trade.lua's overlay-interception pattern: invisible HIGH-strata right-click
+-- Buttons over the Blizzard MerchantItem buttons, shown only while a bot is selected
+-- (so the player's own purchase is never triggered). We never call BuyMerchantItem —
+-- the bot is whispered "b " — so there is no taint/secure concern.
+-- ============================================================
+
+local NS = CleanBotNS
+
+-- ── State ────────────────────────────────────────────────────────────────────
+local merchantOpen = false -- between MERCHANT_SHOW and MERCHANT_CLOSED
+local selectedKey = "player" -- "player" (don't interfere) | strlower(botName)
+local built = false -- overlays + strip/cog created once at PLAYER_LOGIN
+
+-- Widgets (assigned in the build step; referenced as upvalues by the helpers below).
+local stripContainer, cogBtn, cogPanel, dropdown, invBtn, shoppingLabel
+local tabPool = {} -- [key] = portrait tab Button (pooled across rebuilds)
+local merchantOverlays = {} -- [i] = right-click overlay over MerchantItem{i}ItemButton
+local openedInvKeys = {} -- bot inventory windows we opened, hidden on MERCHANT_CLOSED
+
+local MERCHANT_SLOTS = MERCHANT_ITEMS_PER_PAGE or 10
+
+-- ── Layout constants (positions & sizes — tweak these to move the UI around) ──
+local PORTRAIT_SIZE = 32 -- portrait tab / cog square (matches the spellbook side-tab template)
+local TAB_PITCH = -17 -- vertical step between stacked side tabs (native spellbook spacing)
+local STRIP_X = -33 -- column X from MerchantFrame TOPRIGHT (the cog shares it)
+local STRIP_Y = -80 -- first portrait tab Y (the cog sits above, at COG_Y)
+local STRIP_H = 400 -- column height (tall enough for any party)
+local COG_Y = -28 -- cog Y from MerchantFrame TOPRIGHT
+local PANEL_W = 150 -- cog settings panel size
+local PANEL_H = 44
+local PANEL_X = 2 -- settings panel offset from the cog's TOPRIGHT (opens up-right)
+local PANEL_Y = 2
+local DROP_X = -52 -- raid dropdown offset from MerchantFrame TOP (top-center-ish)
+local DROP_Y = 12
+local INV_BTN_W = 80 -- raid "Inventory" button size
+local INV_BTN_H = 22
+local LABEL_Y_BLIZZ = -50 -- "Shopping for" banner Y (Blizzard title height)
+local LABEL_Y_ELVUI = -35 -- "Shopping for" banner Y (ElvUI title height)
+
+--- Accessor for Inventory.lua's cell right-click: true while a vendor is open so a
+--- plain right-click on a bot inventory item sells it (mirrors NS.CB_GetActiveTradeKey).
+---@return boolean
+NS.CB_IsMerchantOpen = function() return merchantOpen end
+
+-- ── Roster helpers ───────────────────────────────────────────────────────────
+--- True when the player has at least one tracked bot in the current party/raid.
+---@return boolean
+local function CB_HasBots()
+ local found = false
+ NS.CB_ForEachGroupMember(function(_, name)
+ if not found and name and CleanBot_PartyBots[strlower(name)] then found = true end
+ end)
+ return found
+end
+
+--- True if the bot looks close enough to interact with the open vendor; otherwise warns the user
+--- with Blizzard-style red error text and returns false. There's no direct bot↔vendor distance in
+--- 3.3.5a, so we proxy off the PLAYER (who is necessarily at the vendor while it's open) via
+--- CheckInteractDistance — index 3 = Duel range (~9.9yd). nil also covers out-of-visibility bots.
+---@param botName string?
+---@return boolean inRange
+NS.CB_CheckBotVendorRange = function(botName)
+ local unit = botName and NS.CB_FindPartyUnit(botName)
+ if unit and CheckInteractDistance(unit, 3) then return true end
+ UIErrorsFrame:AddMessage((botName or "Bot") .. " is too far from the vendor.", 1.0, 0.1, 0.1)
+ return false
+end
+
+-- ── Selected-bot label ───────────────────────────────────────────────────────
+-- Class-colored "[icon] name" markup (name tinted, class icon on the left). Reused by the raid
+-- dropdown and the "Shopping for" banner. Mirrors the Individual tab (CB_ClassIconMarkup + RAID_CLASS_COLORS).
+local function CB_NameMarkup(class, name)
+ local c = class and RAID_CLASS_COLORS and RAID_CLASS_COLORS[class]
+ local colored = c and string.format("|cff%02x%02x%02x%s|r", c.r * 255, c.g * 255, c.b * 255, name) or name
+ return NS.CB_ClassIconMarkup(class) .. " " .. colored
+end
+
+-- Updates the "Shopping for " banner: shown only while a bot is selected on the buy tab.
+local function CB_RefreshShoppingLabel()
+ if not shoppingLabel then return end
+ local e = merchantOpen and NS.vendorEnabled and selectedKey ~= "player"
+ and MerchantFrame.selectedTab == 1 and CleanBot_PartyBots[selectedKey]
+ if e then
+ shoppingLabel:SetText("Shopping for " .. CB_NameMarkup(e.class, e.name))
+ shoppingLabel:Show()
+ else
+ shoppingLabel:Hide()
+ end
+end
+
+-- ── Buy overlays ─────────────────────────────────────────────────────────────
+--- Shows each overlay only when the feature is enabled, a bot is selected, we're on the
+--- buy tab (not buyback), and the merchant slot button is shown. One gate covers
+--- player-selected, disabled, buyback tab, and empty/partial-page (hidden) slots.
+local function CB_RefreshOverlays()
+ local active = merchantOpen and NS.vendorEnabled
+ and selectedKey ~= "player" and MerchantFrame.selectedTab == 1
+ for _, overlay in ipairs(merchantOverlays) do
+ -- Don't gate on a cached item link: it can be nil for a beat after the vendor opens,
+ -- which left the overlay hidden exactly when a bot was first selected (so the first buy
+ -- silently failed). The OnClick re-checks the link, so an unloaded slot is a harmless no-op.
+ if active and overlay.iconBtn:IsShown() then overlay:Show() else overlay:Hide() end
+ end
+ CB_RefreshShoppingLabel()
+end
+
+local function CB_HideOverlays()
+ for _, overlay in ipairs(merchantOverlays) do overlay:Hide() end
+end
+
+--- Creates the invisible right-click overlays over MerchantItem{i}ItemButton. The
+--- item button's GetID() is the LIVE merchant index (set every MerchantFrame_Update),
+--- so paging is handled automatically. Called once at PLAYER_LOGIN. (Mirrors Trade.lua.)
+local function CB_CreateMerchantOverlays()
+ for i = 1, MERCHANT_SLOTS do
+ local iconBtn = _G["MerchantItem" .. i .. "ItemButton"] or _G["MerchantItem" .. i]
+ if iconBtn then
+ local overlay = CreateFrame("Button", "CleanBotMerchantOverlay" .. i, UIParent)
+ overlay:SetFrameStrata("HIGH")
+ overlay:SetAllPoints(iconBtn)
+ overlay:RegisterForClicks("RightButtonUp")
+ overlay.iconBtn = iconBtn
+
+ -- No highlight of our own: the tooltip's OnEnter LockHighlights the underlying
+ -- MerchantItem button, so adding one here doubles up the merchant's native glow.
+ NS.CB_AttachTooltip(overlay, function(tt)
+ local link = GetMerchantItemLink(iconBtn:GetID())
+ if not link then return false end
+ if iconBtn.LockHighlight then iconBtn:LockHighlight() end
+ tt:SetHyperlink(link)
+ end, nil, function()
+ if iconBtn.UnlockHighlight then iconBtn:UnlockHighlight() end
+ end)
+
+ overlay:SetScript("OnClick", function()
+ if not merchantOpen or selectedKey == "player" then return end
+ if MerchantFrame.selectedTab ~= 1 then return end -- buy tab only
+ local entry = CleanBot_PartyBots and CleanBot_PartyBots[selectedKey]
+ if not entry then return end
+ local link = GetMerchantItemLink(iconBtn:GetID())
+ if not link then return end
+ if not NS.CB_CheckBotVendorRange(entry.name) then return end -- too far → warn, don't send
+ local clean = NS.CB_CleanItemLink(link)
+ NS.CB_SendBotCommand(entry.name, "b " .. clean)
+ NS.CB_OptimisticBuy(selectedKey, entry.name, clean) -- update an open bag window
+ end)
+
+ overlay:Hide()
+ merchantOverlays[i] = overlay
+ end
+ end
+end
+
+-- ── Selection ────────────────────────────────────────────────────────────────
+--- Enables/disables the raid "Inventory" button to match the current selection.
+local function CB_UpdateInvBtn()
+ if not invBtn then return end
+ if selectedKey == "player" then invBtn:Disable() else invBtn:Enable() end
+end
+
+--- Marks a portrait tab selected (bright + glow) or not (dimmed, glow hidden) — a clear,
+--- tab-like "which unit is active" indicator. Mirrors a CheckButton's checked state.
+local function CB_MarkSelected(tab, active)
+ tab:SetChecked(active) -- the template's checked-glow = "selected"
+ if tab.portrait then
+ tab.portrait:SetDesaturated(not active) -- grayscale the unselected ones
+ tab.portrait:SetAlpha(active and 1 or 0.7)
+ end
+end
+
+--- Opens a bot's inventory window beside the merchant (for selling). No-op for the player.
+--- Closes any other open bot inventory window first so only one is up at a time (no clutter).
+local function CB_OpenBotInventory(key)
+ if key == "player" then return end
+ local entry = CleanBot_PartyBots[key]
+ if not entry then return end
+ if NS.botInventoryFrames then
+ for k, f in pairs(NS.botInventoryFrames) do
+ if k ~= key and f:IsShown() then f:Hide() end
+ end
+ end
+ NS.CB_RequestInventory(key, entry.name, MerchantFrame)
+ openedInvKeys[key] = true
+end
+
+--- True if a bot inventory window we opened from the vendor is currently shown.
+local function CB_AnyInventoryShown()
+ for k in pairs(openedInvKeys) do
+ local f = NS.botInventoryFrames and NS.botInventoryFrames[k]
+ if f and f:IsShown() then return true end
+ end
+ return false
+end
+
+--- Selects a tab/dropdown entry: "player" (passthrough) or a bot key. Updates the active
+--- highlight on visible portrait tabs and refreshes the buy overlays. If a bot inventory window
+--- is already open, follows the selection — swapping it to the newly-picked bot's bags.
+local function CB_SelectKey(key)
+ selectedKey = key
+ for k, tab in pairs(tabPool) do
+ if tab:IsShown() then CB_MarkSelected(tab, k == key) end
+ end
+ CB_UpdateInvBtn()
+ CB_RefreshOverlays()
+ if key ~= "player" and CB_AnyInventoryShown() then
+ local f = NS.botInventoryFrames and NS.botInventoryFrames[key]
+ if not (f and f:IsShown()) then CB_OpenBotInventory(key) end -- swap to this bot's bags
+ end
+end
+
+-- ── Portrait tabs (party mode) ───────────────────────────────────────────────
+--- Sets a tab's portrait: the live 2D portrait when the unit is resolvable, else the
+--- bot's class icon, else a generic fallback.
+local function CB_SetTabPortrait(tab, key, name)
+ local p = tab.portrait
+ if key == "player" then
+ SetPortraitTexture(p, "player")
+ p:SetTexCoord(0, 1, 0, 1)
+ return
+ end
+ local unit = NS.CB_FindPartyUnit(name)
+ if unit then
+ SetPortraitTexture(p, unit)
+ p:SetTexCoord(0, 1, 0, 1)
+ return
+ end
+ -- Out of range / not resolvable → class icon (or a question mark if even class is unknown).
+ local entry = CleanBot_PartyBots[key]
+ local coords = entry and entry.class and NS.CLASS_ICON_COORDS[entry.class]
+ if coords then
+ p:SetTexture("Interface\\WorldStateFrame\\Icons-Classes")
+ p:SetTexCoord(unpack(coords))
+ else
+ p:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark")
+ p:SetTexCoord(0, 1, 0, 1)
+ end
+end
+
+--- On ElvUI, restyles a spellbook-template tab to ElvUI's flat dark backdrop + border (the same
+--- StripTextures + StyleButton + SetTemplate recipe ElvUI uses on the real skill-line tabs),
+--- replacing the gold Blizzard frame so it matches the skinned merchant frame. No-op without ElvUI.
+local function CB_SkinTab(button)
+ if not NS.ElvUI_S then return end
+ if button.StripTextures then button:StripTextures() end
+ if button.StyleButton then button:StyleButton(nil, true) end
+ if button.SetTemplate then button:SetTemplate("Default", true) end
+end
+
+--- Returns the pooled portrait tab for a key, creating it on first use. Built from the
+--- spellbook's side-tab template (the same one the What's Training addon reuses): on the Blizzard
+--- path the tab-frame background, fitted hover highlight, and checked-glow "selected" state come
+--- from SpellBookSkillLineTabTemplate; on ElvUI CB_SkinTab swaps that for the flat ElvUI look.
+--- The unit portrait is the tab's icon (its NormalTexture).
+local function CB_AcquireTab(key)
+ local tab = tabPool[key]
+ if not tab then
+ tab = CreateFrame("CheckButton", "CleanBotMerchantTab_" .. key, stripContainer,
+ "SpellBookSkillLineTabTemplate")
+ tab:Show() -- the template starts hidden
+ tab:RegisterForClicks("LeftButtonUp", "RightButtonUp")
+ tab:SetScript("OnClick", function(self, button)
+ if button == "RightButton" then
+ CB_OpenBotInventory(key)
+ self:SetChecked(selectedKey == key) -- undo the CheckButton's auto-toggle
+ else
+ CB_SelectKey(key)
+ end
+ end)
+
+ CB_SkinTab(tab) -- ElvUI: gold frame → flat dark backdrop (no-op on Blizzard)
+
+ -- The template's icon slot is its NormalTexture — use it for the unit portrait.
+ tab.portrait = tab:GetNormalTexture()
+ if not tab.portrait then
+ tab:SetNormalTexture("Interface\\Icons\\INV_Misc_QuestionMark")
+ tab.portrait = tab:GetNormalTexture()
+ end
+ if NS.ElvUI_S and tab.portrait and tab.portrait.SetInside then tab.portrait:SetInside() end
+
+ -- Tooltip: the character's name (class-colored) + the action-bar-style blue hint.
+ NS.CB_AttachTooltip(tab, function(tt)
+ local nm, classToken
+ if key == "player" then
+ nm, classToken = UnitName("player"), select(2, UnitClass("player"))
+ else
+ local e = CleanBot_PartyBots[key]
+ if not e then return false end
+ nm, classToken = e.name, e.class
+ end
+ if not nm then return false end
+ local c = classToken and RAID_CLASS_COLORS[classToken]
+ tt:AddLine(nm, c and c.r or 1, c and c.g or 1, c and c.b or 1)
+ if key ~= "player" then
+ tt:AddLine("|cFF80CCFFRight-Click to open Inventory|r")
+ end
+ end)
+
+ tabPool[key] = tab
+ end
+ return tab
+end
+
+-- ── Dropdown (raid mode) ─────────────────────────────────────────────────────
+-- Stamps a menu entry with the class icon + class-colored name (open-list rows tint via colorCode).
+local function CB_DropEntry(info, class, name)
+ info.text = NS.CB_ClassIconMarkup(class) .. " " .. name
+ info.notCheckable = true
+ local c = class and RAID_CLASS_COLORS and RAID_CLASS_COLORS[class]
+ if c then info.colorCode = string.format("|cff%02x%02x%02x", c.r * 255, c.g * 255, c.b * 255) end
+end
+
+local function CB_BuildDropdown()
+ if not dropdown then
+ dropdown = NS.CB_CreateDropdown(stripContainer, "CleanBotMerchantDropdown", 120)
+ -- Top-center of the vendor frame (the top-left corner holds the frame's portrait icon),
+ -- nudged down like the cog. Anchored to MerchantFrame, not the narrow vertical column.
+ dropdown:SetPoint("TOP", MerchantFrame, "TOP", DROP_X, DROP_Y)
+ invBtn = NS.CB_CreateButton(stripContainer, "CleanBotMerchantInvBtn", "Inventory", INV_BTN_W, INV_BTN_H,
+ function() CB_OpenBotInventory(selectedKey) end)
+ -- Gap from the dropdown uses the spacing model (element-to-element = before.marginRight +
+ -- widget.marginLeft); LEFT↔RIGHT centers the button vertically on the dropdown.
+ invBtn:SetPoint("LEFT", dropdown, "RIGHT",
+ (dropdown.marginRight or 0) + (invBtn.marginLeft or 0), 0)
+ end
+ local _, playerClass = UnitClass("player")
+ UIDropDownMenu_Initialize(dropdown, function()
+ local info = UIDropDownMenu_CreateInfo()
+ CB_DropEntry(info, playerClass, "Player") -- the player's own class icon + color
+ info.func = function()
+ CB_SelectKey("player"); UIDropDownMenu_SetText(dropdown, CB_NameMarkup(playerClass, "Player"))
+ end
+ UIDropDownMenu_AddButton(info)
+ NS.CB_ForEachGroupMember(function(_, name)
+ local k = name and strlower(name)
+ local e = k and CleanBot_PartyBots[k]
+ if e then
+ local i2 = UIDropDownMenu_CreateInfo()
+ CB_DropEntry(i2, e.class, e.name)
+ i2.func = function()
+ CB_SelectKey(k); UIDropDownMenu_SetText(dropdown, CB_NameMarkup(e.class, e.name))
+ end
+ UIDropDownMenu_AddButton(i2)
+ end
+ end)
+ end)
+ UIDropDownMenu_SetText(dropdown, CB_NameMarkup(playerClass, "Player"))
+ dropdown:Show()
+ invBtn:Show()
+end
+
+-- ── Visibility ───────────────────────────────────────────────────────────────
+--- The cog shows whenever a vendor is open and the player has bots (independent of
+--- Enable, so the user can always re-enable). The tab/dropdown strip additionally
+--- requires Enable. Hidden entirely with no bots.
+local function CB_UpdateVisibility()
+ if not (merchantOpen and CB_HasBots()) then
+ if stripContainer then stripContainer:Hide() end
+ if cogBtn then cogBtn:Hide() end
+ CB_HideOverlays()
+ return
+ end
+ cogBtn:Show()
+ if NS.vendorEnabled then stripContainer:Show() else stripContainer:Hide() end
+ CB_RefreshOverlays()
+end
+
+-- ── Strip rebuild (party tabs vs raid dropdown) ──────────────────────────────
+local function CB_RebuildStrip()
+ -- Reset to the passthrough default and clear last mode's widgets.
+ selectedKey = "player"
+ for _, tab in pairs(tabPool) do tab:Hide() end
+ if dropdown then dropdown:Hide() end
+ if invBtn then invBtn:Hide() end
+
+ if not CB_HasBots() then
+ CB_UpdateVisibility()
+ return
+ end
+
+ if NS.CB_GroupInfo() == "raid" then
+ CB_BuildDropdown()
+ CB_UpdateInvBtn()
+ else
+ -- Party: player tab first, then each tracked bot, stacking top → bottom (a vertical
+ -- column down the frame's left edge).
+ local keys = { "player" }
+ NS.CB_ForEachGroupMember(function(_, name)
+ local k = name and strlower(name)
+ if k and CleanBot_PartyBots[k] then keys[#keys + 1] = k end
+ end)
+ local prev
+ for _, key in ipairs(keys) do
+ local tab = CB_AcquireTab(key)
+ local name = (key == "player") and UnitName("player") or CleanBot_PartyBots[key].name
+ CB_SetTabPortrait(tab, key, name)
+ tab:ClearAllPoints()
+ if prev then tab:SetPoint("TOPLEFT", prev, "BOTTOMLEFT", 0, TAB_PITCH)
+ else tab:SetPoint("TOPLEFT", stripContainer, "TOPLEFT", 0, 0) end
+ CB_MarkSelected(tab, key == "player")
+ tab:Show()
+ prev = tab
+ end
+ end
+
+ CB_UpdateVisibility()
+end
+
+-- ── Build (once, at PLAYER_LOGIN) ────────────────────────────────────────────
+local function CB_BuildStrip()
+ stripContainer = CreateFrame("Frame", "CleanBotMerchantStrip", UIParent)
+ stripContainer:SetFrameStrata("HIGH")
+ -- Vertical column down the frame's RIGHT outer edge, below the cog. (MerchantFrame opens at
+ -- the screen's top-left by default, so a left-edge column would sit off-screen; the right is clear.)
+ stripContainer:SetPoint("TOPLEFT", MerchantFrame, "TOPRIGHT", STRIP_X, STRIP_Y)
+ stripContainer:SetSize(PORTRAIT_SIZE, STRIP_H)
+ stripContainer:Hide()
+
+ -- "Shopping for " banner, centered over the top of the vendor. Parented to the strip (so it
+ -- hides with the feature) but anchored to MerchantFrame. The merchant title sits at a different
+ -- height on ElvUI vs the Blizzard frame, so the vertical offset is path-specific (tune in-game).
+ shoppingLabel = stripContainer:CreateFontString(nil, "OVERLAY", "GameFontNormal")
+ shoppingLabel:SetPoint("TOP", MerchantFrame, "TOP", 0, NS.ElvUI_S and LABEL_Y_ELVUI or LABEL_Y_BLIZZ)
+ shoppingLabel:Hide()
+
+ -- Cog: a tab matching the portraits (same spellbook side-tab template), at the top of the
+ -- column. Separate frame so Enable-off (which hides the strip) never hides it. Hover fades
+ -- its settings panel in/out; it never enters the "selected" (checked) state.
+ cogBtn = CreateFrame("CheckButton", "CleanBotMerchantCog", UIParent, "SpellBookSkillLineTabTemplate")
+ cogBtn:SetFrameStrata("HIGH")
+ cogBtn:SetPoint("TOPLEFT", MerchantFrame, "TOPRIGHT", STRIP_X, COG_Y)
+ CB_SkinTab(cogBtn) -- ElvUI: gold frame → flat dark backdrop (no-op on Blizzard)
+ -- Gear as a separate ARTWORK icon, mirroring the inventory-cell skin (CB_SkinItemButtonCore):
+ -- the template's NormalTexture kept the icon's baked border under ElvUI, but a plain texture
+ -- crops cleanly. It sits over the gold tab frame on Blizzard, inside the flat backdrop on ElvUI.
+ local cogIcon = cogBtn:CreateTexture(nil, "ARTWORK")
+ cogIcon:SetTexture("Interface\\Icons\\INV_Misc_Gear_01")
+ cogIcon:SetAllPoints()
+ NS.CB_CropIcon(cogIcon) -- trim the icon's baked border
+ if NS.ElvUI_S and cogIcon.SetInside then cogIcon:SetInside() end
+ cogBtn:SetScript("OnClick", function(self) self:SetChecked(false) end) -- hover-only; never "selected"
+ cogBtn:Hide()
+
+ cogPanel = NS.CB_CreatePanel(UIParent, "CleanBotMerchantCogPanel", 1, "panel")
+ cogPanel:SetFrameStrata("HIGH")
+ cogPanel:SetSize(PANEL_W, PANEL_H)
+ cogPanel:SetPoint("BOTTOMLEFT", cogBtn, "TOPRIGHT", PANEL_X, PANEL_Y) -- opens up and to the right
+ cogPanel:EnableMouse(true)
+ cogPanel:SetAlpha(0)
+ cogPanel:Hide()
+
+ local enableCB = NS.CB_CreateLabeledCheckBox(cogPanel, "CleanBotVendorEnableCB", "Enable",
+ "Show the bot vendor tabs and let bots buy/sell here. The cog stays even when off so you can turn it back on.")
+ enableCB:SetChecked(NS.vendorEnabled)
+ enableCB:SetPoint("TOPLEFT", cogPanel, "TOPLEFT",
+ (cogPanel.paddingLeft or 8), -(cogPanel.paddingTop or 8))
+ enableCB:SetScript("OnClick", function(self)
+ local checked = self:GetChecked() and true or false
+ self:SetChecked(checked)
+ NS.vendorEnabled = checked
+ if CleanBot_SavedVars then CleanBot_SavedVars.vendorEnabled = checked end
+ if not checked then selectedKey = "player" end
+ if merchantOpen then CB_UpdateVisibility() end
+ end)
+ cogPanel.enableCB = enableCB
+
+ -- Hover fade: show + fade in on enter; on leave, after a short grace (to bridge the
+ -- cog→panel cursor gap), fade out and HIDE (a shown alpha-0 frame still eats clicks).
+ local function fadeIn()
+ cogPanel:Show()
+ UIFrameFadeIn(cogPanel, 0.08, cogPanel:GetAlpha(), 1)
+ end
+ local function fadeOut()
+ if cogBtn:IsMouseOver() or cogPanel:IsMouseOver() then return end
+ UIFrameFade(cogPanel, { mode = "OUT", timeToFade = 0.08,
+ startAlpha = cogPanel:GetAlpha(), endAlpha = 0,
+ finishedFunc = function()
+ if not (cogBtn:IsMouseOver() or cogPanel:IsMouseOver()) then cogPanel:Hide() end
+ end })
+ end
+ local function scheduleFadeOut() NS.CB_After(0.1, fadeOut) end
+ cogBtn:SetScript("OnEnter", fadeIn)
+ cogBtn:SetScript("OnLeave", scheduleFadeOut)
+ cogPanel:SetScript("OnEnter", fadeIn)
+ cogPanel:SetScript("OnLeave", scheduleFadeOut)
+end
+
+-- ── Event handler ────────────────────────────────────────────────────────────
+local eventFrame = CreateFrame("Frame", "CleanBotMerchantEventFrame")
+eventFrame:RegisterEvent("PLAYER_LOGIN")
+eventFrame:RegisterEvent("MERCHANT_SHOW")
+eventFrame:RegisterEvent("MERCHANT_CLOSED")
+eventFrame:RegisterEvent("MERCHANT_UPDATE")
+eventFrame:RegisterEvent("PARTY_MEMBERS_CHANGED")
+eventFrame:RegisterEvent("RAID_ROSTER_UPDATE")
+
+eventFrame:SetScript("OnEvent", function(_, event)
+ if event == "PLAYER_LOGIN" then
+ if not built then
+ built = true
+ CB_BuildStrip()
+ CB_CreateMerchantOverlays()
+ end
+ return
+ end
+
+ if not built then return end -- nothing to drive until the login build ran
+
+ if event == "MERCHANT_SHOW" then
+ merchantOpen = true
+ CB_RebuildStrip()
+
+ elseif event == "MERCHANT_UPDATE" then
+ if merchantOpen then CB_RefreshOverlays() end
+
+ elseif event == "MERCHANT_CLOSED" then
+ merchantOpen = false
+ selectedKey = "player"
+ CB_HideOverlays()
+ if stripContainer then stripContainer:Hide() end
+ if cogBtn then cogBtn:Hide() end
+ if cogPanel then cogPanel:Hide() end
+ if shoppingLabel then shoppingLabel:Hide() end
+ for key in pairs(openedInvKeys) do
+ local f = NS.botInventoryFrames and NS.botInventoryFrames[key]
+ if f and f:IsShown() then f:Hide() end
+ end
+ wipe(openedInvKeys)
+
+ elseif event == "PARTY_MEMBERS_CHANGED" or event == "RAID_ROSTER_UPDATE" then
+ if merchantOpen then CB_RebuildStrip() end
+ end
+end)
diff --git a/Skinning/ItemVisuals.lua b/Skinning/ItemVisuals.lua
index 26c0e33..edbf153 100644
--- a/Skinning/ItemVisuals.lua
+++ b/Skinning/ItemVisuals.lua
@@ -4,9 +4,21 @@
-- ============================================================
local NS = CleanBotNS
--- Applies ElvUI's standard icon crop to a texture. Trims the rounded edges that
--- are baked into WoW's icon and paperdoll slot textures, giving them a square look.
--- No-op when ElvUI is not installed.
+-- The standard icon-border crop (~8% per side). A FIXED value on purpose: ElvUI-WotLK sets
+-- E.TexCoords = {0,1,0,1} (it does not crop icons), so deferring to it would be a no-op.
+local ICON_CROP = { 0.08, 0.92, 0.08, 0.92 }
+
+-- Crops the rounded border baked into a WoW icon texture so it reads as a square. Always applies
+-- a real crop, on BOTH paths. Use for any standalone icon button (e.g. the merchant cog).
+---@param texture table? The texture to crop.
+NS.CB_CropIcon = function(texture)
+ if not texture then return end
+ texture:SetTexCoord(unpack(ICON_CROP))
+end
+
+-- Crops an icon to ElvUI's OWN coords, ElvUI path only, so paperdoll/inventory item buttons match
+-- ElvUI's native treatment (which on the WotLK backport is {0,1,0,1} = uncropped). No-op without
+-- ElvUI. Deliberately distinct from CB_CropIcon, which always applies a real crop.
---@param texture table The texture to crop.
NS.CB_ApplyElvCoords = function(texture)
if not NS.ElvUI_E then return end
diff --git a/docs/Home.md b/docs/Home.md
index 88cbbb4..06d13b2 100644
--- a/docs/Home.md
+++ b/docs/Home.md
@@ -21,7 +21,7 @@ New here? Start with **[Getting Started](getting-started.md)**.
- **[Action Bar](action-bar.md)** — one-click bot commands you can keep on screen.
- **[Managing Bots](managing-bots.md)** — invite, summon, spawn, and save bot presets.
- **[Bot Behavior](bot-behavior.md)** — roles, rotations, movement, and loot.
-- **[Gear & Bags](gear-and-bags.md)** — equipment, inventory, trading, banking, and quests.
+- **[Gear & Bags](gear-and-bags.md)** — equipment, inventory, vendors, trading, banking, and quests.
- **[Settings](settings.md)** — appearance, behavior toggles, and the action bar.
## Reference
diff --git a/docs/gear-and-bags.md b/docs/gear-and-bags.md
index c6e4b4a..1e7c883 100644
--- a/docs/gear-and-bags.md
+++ b/docs/gear-and-bags.md
@@ -1,7 +1,7 @@
# Gear & Bags
-CleanBot lets you look after a bot's equipment, bags, and quests almost like your own
-character. Most of this lives on the **Individual** tab; bags and quests can also be opened
+CleanBot lets you look after a bot's equipment, bags, quests, and even shopping almost like your
+own character. Most of this lives on the **Individual** tab; bags and quests can also be opened
straight from the [right-click menu](managing-bots.md#the-right-click-menu).
## The bot's character (Individual tab)
@@ -22,6 +22,22 @@ window shows everything the bot carries plus its money. From here you can:
- **Equip** gear straight from the bags.
- **Sell Trash** — vendor all the bot's gray items in one click (a vendor must be nearby).
+## At a vendor
+
+Open a vendor **while you have bots in your group** and a strip of small **portrait tabs** (you +
+each bot) appears down the right edge of the merchant window — a **dropdown** in a raid. You're
+selected by default, so buying and selling work exactly as normal. Pick a **bot** instead and:
+
+- **Buy for the bot** — right-click any item the vendor sells and that bot buys it. A
+ **"Shopping for *Bot*"** banner shows who you're shopping for.
+- **Sell from the bot** — right-click the bot's portrait (or the **Inventory** button in a raid)
+ to open its bags, then right-click an item to sell it. Green-or-better items ask first, since
+ **bots have no buyback**. (Opening another bot's bags swaps the window over.)
+
+The bot has to be standing near the vendor — if it's too far, CleanBot flashes a red warning and
+doesn't send the order. A small **cog** at the top of the strip turns the whole thing on or off
+(and there's nothing to see at all when you have no bots).
+
## Trading
You can move items between you and a bot through a normal trade window. Drag an item into the
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 556fcc5..fb4d32e 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -44,5 +44,5 @@ a self-bot or testing without a group.
- [Action Bar](action-bar.md) — one-click bot commands.
- [Managing Bots](managing-bots.md) — building and running your group.
- [Bot Behavior](bot-behavior.md) — roles, rotations, movement, loot.
-- [Gear & Bags](gear-and-bags.md) — equipment, inventory, trading, quests.
+- [Gear & Bags](gear-and-bags.md) — equipment, inventory, vendors, trading, quests.
- [Settings](settings.md) — appearance and toggles.
diff --git a/docs/playerbot-commands.md b/docs/playerbot-commands.md
index b649456..efc234d 100644
--- a/docs/playerbot-commands.md
+++ b/docs/playerbot-commands.md
@@ -67,6 +67,8 @@ action name in `ChatActionContext.h`.**
| `pull my target` | `pull` | `TriggerNode("pull")` → `pull my target` action | ✅ (Action Bar "Pull" button) |
| `release` | `release` | `supported` | ✅ (Action Bar "Release" flyout) |
| `revive` | `revive` | `TriggerNode("revive")` → `spirit healer` action | ✅ (Action Bar "Release" flyout) |
+| `b ` | `b` | `TriggerNode("b")` → buy (`BuyAction`) | ✅ (vendor extension — a selected bot buys the right-clicked merchant item) |
+| `s ` | `s` | `TriggerNode("s")` → sell (`SellAction`) | ✅ (vendor extension + inventory "Sell" menu) |
---
@@ -79,6 +81,8 @@ action name in `ChatActionContext.h`.**
| `talents spec ` | ⚠️ | One of five `talents` sub-forms — see "talents" below. |
| `talents spec list` | ✅ | Populates the premade-spec dropdown; reply is one premade per line, `"1. arms pve (51-0-20)"` (parsed in `Bridge.lua`). |
| `s gray` | ✅ | Inventory "Sell Trash" button. **The trigger is `s`, not `sell`** — `ChatCommandHandlerStrategy` registers `TriggerNode("s") → SellAction`; there is NO `sell` trigger. Whispering `sell gray` matches no command and (with `enableAutoTradeOnItemMention`) makes the bot open a *trade* on the "gray" keyword instead of vendor-selling. Whisper-only (not bridge-allowlisted); sells only when a vendor NPC is in interaction range; "gray" = `ITEM_QUALITY_POOR` (quality 0). Params: `s gray` / `s *` / `s vendor` / `s `. |
+| `b ` (vendor buy) | ✅ | **Vendor frame extension** (`Merchant.lua`). With a bot selected on the vendor's portrait tabs/dropdown, right-clicking a merchant item sends `b ` so that bot buys it. Trigger `b` (`BuyAction`); whisper-only (not bridge-allowlisted); the bot must be within interaction range of the vendor — CleanBot does a client-side proxy range check (`CheckInteractDistance` to the bot, since the player is at the vendor) and flashes a `UIErrorsFrame` warning instead of sending if it's too far. |
+| `s ` (vendor sell) | ✅ | Vendor-sells a single item from a bot's bags. Used by the **vendor extension** (right-click an item in a bot's open bag window while a merchant is up) and the inventory **"Sell"** context-menu entry. Uncommon-or-better items confirm first (bots have **no buyback**). Trigger `s` (`SellAction`); whisper-only; needs a vendor in range. (Bulk `s gray` is the separate "Sell Trash" button above.) |
| `e ` (equip) | ⚠️ | Resolves via `parseItems` — accepts far more than links. |
| `ue ` (unequip) | ✅ | |
| `u ` (use item) | ✅ | |
@@ -185,16 +189,13 @@ repair cost and rest-XP are present but unused.
item (one link per call; no `reward all`). Pairs with the quest panel's reward display.
4. **`repair` / `repair all`** (`RepairAllAction`) — send a bot to repair. Companion to the
durability `stats` already reports.
-5. **`s`** (`SellAction`) / **`b`** (`BuyAction`) — vendor interactions (triggers are the short
- forms `s`/`b`, not `sell`/`buy`). `s gray` is used (Sell Trash button); other `s` forms and
- `b` remain unused.
-6. **`reset`** (`ResetAiAction`) — reset the bot's AI/strategies (stronger than `co !`).
-7. **`go `** (`GoAction.cpp`) — parameterized movement (see the `go` subsection below).
+5. **`reset`** (`ResetAiAction`) — reset the bot's AI/strategies (stronger than `co !`).
+6. **`go `** (`GoAction.cpp`) — parameterized movement (see the `go` subsection below).
Most relevant form: **`go ` walks the bot to a nearby matching NPC/player by
name** — e.g. stepping a bot onto a **banker** so the `bank` commands pass their proximity
check. (Bounded by the bot's search range — it's the last-leg positioner, not a long-haul
travel; pair with `summon`/`follow` to get the bot into the area first.)
-8. **Movement one-shot triggers:** `follow`, `stay`, `guard`, `flee`, `sit`, `return`, `runaway`
+7. **Movement one-shot triggers:** `follow`, `stay`, `guard`, `flee`, `sit`, `return`, `runaway`
(TriggerNodes → `" chat shortcut"`). **Unused by CleanBot** — the Action Bar's Movement
flyout (Follow / Stay / Runaway) instead drives the *persistent* **movement strategies** via
`co`/`nc` (the exclusive movement group), not these one-shot shortcuts. Note `flee` ≠ `runaway`: