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`: