diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a70cdb..229d6a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,23 @@ All notable changes to MetaHunt will be documented in this file. +## [1.5.1] - 2026-04-01 + + +### Added + +- **zAmmo — bag ammo labels**: Quality-colored short-name labels overlaid on ammo items in bags and bank, with optional heat-colored DPS damage indicator. Grey below 7.5 DPS, red-to-green gradient from 7.5 to 20.5. Three new checkboxes in Options → zButtons → zAmmo → Bags section: bag labels, bank labels, and damage display. Works with both default bags and pfUI bags. + +- **MM Widget — immunity detection & rotation toggles**: The MM Widget keybind now detects when the current target is immune to a damage school (Fire / Nature / Arcane) and automatically skips the useless shot, casting Aimed or Steady Shot instead. Immunity is detected in two ways: reactively via combat log "immune" messages, and proactively via hardcoded creature-type rules (e.g. Elementals are always flagged Nature-immune on target). Immune cells show a red border. Three new checkboxes in Options → ExpAmmo → Rotation let you permanently disable individual shots (Multi-Shot, Serpent Sting, Arcane Shot) — unchecked shots are skipped during their proc window. + + +### Fixed + +- **Zone mapping — 22 beasts with raw zone IDs**: Fixed 8 unmapped zone IDs (Blackrock Depths, Molten Core, Wailing Caverns, Maraudon, Sunnyglade, Icepoint Rock, Winter Veil Vale, Gilneas City, Karazhan) that caused 22 beasts to display as "Zone 617" instead of their proper zone name in the Hunter Book. + +- **MM Widget — IsStateBlocked nil error**: Moved `MTH_EA_IsStateBlocked` definition before its first use, fixing a nil function call on load. + + ## [1.5.0] - 2026-03-31 diff --git a/api/bag-ammo-labels.lua b/api/bag-ammo-labels.lua new file mode 100644 index 0000000..eb7b53a --- /dev/null +++ b/api/bag-ammo-labels.lua @@ -0,0 +1,463 @@ +-- ============================================================ +-- Bag Ammo Labels — standalone overlay for bag & bank ammo slots +-- Uses globals from zButtonAmmo.lua: +-- zButtonAmmo_ShortArrowLabels, zButtonAmmo_ShortBulletLabels, +-- zButtonAmmo_GetQualityColor, zButtonAmmo_GetColorGradient, +-- zButtonAmmo_GetKnownAmmoTypes +-- Uses DPS data from MTH_DS_AmmoItems[id].dps +-- ============================================================ + +-- Saved variable cache (per-character, stored in "general" module) +local cache = {} -- bagAmmoLabels, bankAmmoLabels, bagAmmoDamage + +local function GetStore() + return MTH and MTH.GetModuleCharSavedVariables and MTH:GetModuleCharSavedVariables("general") +end + +local function GetCached(key, default) + if cache[key] == nil then + local store = GetStore() + if type(store) == "table" and store[key] ~= nil then + cache[key] = store[key] and true or false + else + cache[key] = default or false + end + end + return cache[key] +end + +local function SetCached(key, enabled) + cache[key] = enabled and true or false + local store = GetStore() + if type(store) == "table" then + store[key] = cache[key] + end +end + +-- Public getters/setters +function MTH_BagAmmoLabels_GetEnabled() + return GetCached("bagAmmoLabels") +end +function MTH_BagAmmoLabels_SetEnabled(enabled) + SetCached("bagAmmoLabels", enabled) + MTH_BagAmmoLabels_RefreshAll() +end + +function MTH_BankAmmoLabels_GetEnabled() + return GetCached("bankAmmoLabels") +end +function MTH_BankAmmoLabels_SetEnabled(enabled) + SetCached("bankAmmoLabels", enabled) + MTH_BagAmmoLabels_RefreshAllBank() +end + +function MTH_BagAmmoDamage_GetEnabled() + return GetCached("bagAmmoDamage") +end +function MTH_BagAmmoDamage_SetEnabled(enabled) + SetCached("bagAmmoDamage", enabled) + MTH_BagAmmoLabels_RefreshAll() + MTH_BagAmmoLabels_RefreshAllBank() +end + +-- Heat color: red (7.5 DPS) → yellow → green (20.5 DPS); below 7.5 = grey +local DPS_MIN = 7.5 +local DPS_MAX = 20.5 + +local function GetDpsHeatColor(dps) + if not dps then return 1, 1, 1 end + if dps < DPS_MIN then return 0.6, 0.6, 0.6 end + local perc = (dps - DPS_MIN) / (DPS_MAX - DPS_MIN) + return zButtonAmmo_GetColorGradient(perc) +end + +-- Look up DPS from MTH_DS_AmmoItems by numeric item ID +local function GetAmmoDps(numericId) + if not MTH_DS_AmmoItems then return nil end + local entry = MTH_DS_AmmoItems[numericId] + return entry and entry.dps +end + +-- Detect whether pfUI bags are active +local function HasPfUI() + return pfUI and pfUI.bags and pfUI.bag and true or false +end + +-- Is this bagID a bank bag? +local BANK_BAGS = { [-1] = true, [5] = true, [6] = true, [7] = true, [8] = true, [9] = true, [10] = true, [11] = true } + +local function IsBankBag(bagID) + return BANK_BAGS[bagID] or false +end + +-- Apply or clear labels on a single bag-slot button +local function UpdateButton(button, bagID, slot, showName, showDmg) + if not button then return end + + -- Lazy-create name label (TOPLEFT) + if not button._mthAmmoLabel then + button._mthAmmoLabel = button:CreateFontString(nil, "OVERLAY") + button._mthAmmoLabel:SetPoint("TOPLEFT", button, "TOPLEFT", 1, -1) + button._mthAmmoLabel:SetFont("Fonts\\ARIALN.ttf", 9, "OUTLINE") + button._mthAmmoLabel:Hide() + end + -- Lazy-create damage label (CENTER, large + bold) + if not button._mthAmmoDmgLabel then + button._mthAmmoDmgLabel = button:CreateFontString(nil, "OVERLAY") + button._mthAmmoDmgLabel:SetPoint("CENTER", button, "CENTER", 0, 0) + button._mthAmmoDmgLabel:SetFont("Fonts\\ARIALN.ttf", 10, "THICKOUTLINE") + button._mthAmmoDmgLabel:Hide() + end + + local nameLabel = button._mthAmmoLabel + local dmgLabel = button._mthAmmoDmgLabel + + if not showName and not showDmg then + nameLabel:Hide() + dmgLabel:Hide() + return + end + + local link = GetContainerItemLink(bagID, slot) + if not link then + nameLabel:Hide() + dmgLabel:Hide() + return + end + + local _, _, id = string.find(link, "Hitem:(%d+)") + if not id then + nameLabel:Hide() + dmgLabel:Hide() + return + end + + local numericId = tonumber(id) + local itemName, _, itemQuality, _, _, _, _, _, itemSlot = GetItemInfo(id) + if not itemName then + nameLabel:Hide() + dmgLabel:Hide() + return + end + + local isAmmo = (itemSlot == "INVTYPE_AMMO") + if not isAmmo then + local knownAmmo = zButtonAmmo_GetKnownAmmoTypes and zButtonAmmo_GetKnownAmmoTypes() + isAmmo = knownAmmo and knownAmmo[itemName] + end + if not isAmmo then + nameLabel:Hide() + dmgLabel:Hide() + return + end + + -- Name label + if showName then + local shortText = zButtonAmmo_ShortArrowLabels[itemName] + or zButtonAmmo_ShortBulletLabels[itemName] + if shortText then + local cr, cg, cb = zButtonAmmo_GetQualityColor(itemQuality) + nameLabel:SetTextColor(cr, cg, cb, 1) + nameLabel:SetText(shortText) + nameLabel:Show() + else + nameLabel:Hide() + end + else + nameLabel:Hide() + end + + -- Damage label + if showDmg then + local dps = GetAmmoDps(numericId) + if dps then + local cr, cg, cb = GetDpsHeatColor(dps) + dmgLabel:SetTextColor(cr, cg, cb, 1) + dmgLabel:SetText(dps) + dmgLabel:Show() + else + dmgLabel:Hide() + end + else + dmgLabel:Hide() + end +end + +-- Clear both labels on a button +local function ClearButton(button) + if not button then return end + if button._mthAmmoLabel then button._mthAmmoLabel:Hide() end + if button._mthAmmoDmgLabel then button._mthAmmoDmgLabel:Hide() end +end + +-- Forward declaration +local RefreshAllVisibleFrames + +-- Refresh labels on a single bag (works for any bagID) +local function RefreshBag(bagID, showName, showDmg) + local numSlots = GetContainerNumSlots(bagID) + if numSlots == 0 then return end + + -- pfUI path + if HasPfUI() then + local pfBag = pfUI.bags[bagID] + if not pfBag or not pfBag.slots then return end + for slot = 1, numSlots do + local slotData = pfBag.slots[slot] + local button = slotData and slotData.frame + if button then + if showName or showDmg then + UpdateButton(button, bagID, slot, showName, showDmg) + else + ClearButton(button) + end + end + end + return + end + + -- Default UI path + + -- Main bank (bagID -1): buttons are BankFrameItem1..28 + if bagID == -1 then + for slot = 1, numSlots do + local button = getglobal("BankFrameItem" .. slot) + if button then + if showName or showDmg then + UpdateButton(button, -1, slot, showName, showDmg) + else + ClearButton(button) + end + end + end + return + end + + -- All other bags (0-4 and 5-11): search all ContainerFrames + for ci = 1, 13 do + local frame = getglobal("ContainerFrame" .. ci) + if frame and frame:IsVisible() and frame:GetID() == bagID then + local frameName = frame:GetName() + -- Visual button i = API slot (numSlots - i + 1) + for i = 1, numSlots do + local button = getglobal(frameName .. "Item" .. i) + local apiSlot = numSlots - i + 1 + if button then + if showName or showDmg then + UpdateButton(button, bagID, apiSlot, showName, showDmg) + else + ClearButton(button) + end + end + end + -- Clear stale labels on any buttons beyond numSlots + for slot = numSlots + 1, 24 do + local button = getglobal(frameName .. "Item" .. slot) + if not button then break end + ClearButton(button) + end + return + end + end +end + +-- Public: refresh all inventory bags (0-4) +function MTH_BagAmmoLabels_RefreshAll() + RefreshAllVisibleFrames() +end + +-- Public: refresh all bank bags (-1, 5-11) +function MTH_BagAmmoLabels_RefreshAllBank() + RefreshAllVisibleFrames() +end + +-- Refresh all visible default UI ContainerFrames (frame-centric: no bagID lookup) +RefreshAllVisibleFrames = function() + local bagShowName = MTH_BagAmmoLabels_GetEnabled() + local bankShowName = MTH_BankAmmoLabels_GetEnabled() + local showDmg = MTH_BagAmmoDamage_GetEnabled() + + if HasPfUI() then + -- pfUI: refresh via pfUI bag structure + for bagID = 0, 4 do + RefreshBag(bagID, bagShowName, showDmg) + end + local bankBags = { -1, 5, 6, 7, 8, 9, 10, 11 } + for _, bagID in ipairs(bankBags) do + RefreshBag(bagID, bankShowName, showDmg) + end + return + end + + -- Default UI: iterate all visible ContainerFrames + for ci = 1, 13 do + local cf = getglobal("ContainerFrame" .. ci) + if cf and cf:IsVisible() then + local bagID = cf:GetID() + local showName = IsBankBag(bagID) and bankShowName or bagShowName + local numSlots = GetContainerNumSlots(bagID) + local fname = cf:GetName() + -- Visual button i = API slot (numSlots - i + 1) + for i = 1, numSlots do + local button = getglobal(fname .. "Item" .. i) + local apiSlot = numSlots - i + 1 + if button then + if showName or showDmg then + UpdateButton(button, bagID, apiSlot, showName, showDmg) + else + ClearButton(button) + end + end + end + for slot = numSlots + 1, 24 do + local button = getglobal(fname .. "Item" .. slot) + if not button then break end + ClearButton(button) + end + end + end + + -- Bank bag -1: BankFrameItem buttons + if bankShowName or showDmg then + local numSlots = GetContainerNumSlots(-1) + for slot = 1, numSlots do + local button = getglobal("BankFrameItem" .. slot) + if button then + UpdateButton(button, -1, slot, bankShowName, showDmg) + end + end + end +end + +-- Event-driven refresh: coalesce with a short delay +do + local frame = CreateFrame("Frame", "MTH_BagAmmoLabelsFrame", UIParent) + local needsRefresh = false + local elapsed = 0 + + frame:RegisterEvent("BAG_UPDATE") + frame:RegisterEvent("PLAYERBANKSLOTS_CHANGED") + frame:RegisterEvent("PLAYERBANKBAGSLOTS_CHANGED") + frame:RegisterEvent("BANKFRAME_OPENED") + frame:RegisterEvent("PLAYER_ENTERING_WORLD") + + frame:SetScript("OnEvent", function() + if event == "PLAYER_ENTERING_WORLD" then + -- After login/reload, delay 1s for item cache to populate + elapsed = -0.95 + needsRefresh = true + frame:Show() + return + end + + if event == "BANKFRAME_OPENED" then + if MTH_BankAmmoLabels_GetEnabled() or MTH_BagAmmoDamage_GetEnabled() then + needsRefresh = true + if not frame:IsShown() then + elapsed = 0 + frame:Show() + end + end + return + end + + if event == "PLAYERBANKSLOTS_CHANGED" or event == "PLAYERBANKBAGSLOTS_CHANGED" then + if MTH_BankAmmoLabels_GetEnabled() or MTH_BagAmmoDamage_GetEnabled() then + needsRefresh = true + if not frame:IsShown() then + elapsed = 0 + frame:Show() + end + end + return + end + + -- BAG_UPDATE + local bagID = arg1 + if not bagID then return end + if IsBankBag(bagID) then + if not MTH_BankAmmoLabels_GetEnabled() and not MTH_BagAmmoDamage_GetEnabled() then return end + else + if not MTH_BagAmmoLabels_GetEnabled() and not MTH_BagAmmoDamage_GetEnabled() then return end + end + needsRefresh = true + if not frame:IsShown() then + elapsed = 0 + frame:Show() + end + end) + + frame:SetScript("OnUpdate", function() + elapsed = elapsed + (arg1 or 0.016) + if elapsed < 0.05 then return end + needsRefresh = false + frame:Hide() + RefreshAllVisibleFrames() + end) + frame:Hide() + + -- Hook ContainerFrame OnShow: trigger a refresh when a bag becomes visible + if not HasPfUI() then + for ci = 1, 13 do + local cf = getglobal("ContainerFrame" .. ci) + if cf then + local origOnShow = cf:GetScript("OnShow") + cf:SetScript("OnShow", function() + if origOnShow then origOnShow() end + needsRefresh = true + if not frame:IsShown() then + elapsed = 0 + frame:Show() + end + end) + end + end + end + + -- Post-hook ContainerFrame_Update for default UI (synchronous update) + if type(ContainerFrame_Update) == "function" then + local origCFU = ContainerFrame_Update + ContainerFrame_Update = function(cf) + origCFU(cf) + if cf and cf:IsVisible() then + local bagID = cf:GetID() + local showDmg = MTH_BagAmmoDamage_GetEnabled() + local showName + if IsBankBag(bagID) then + showName = MTH_BankAmmoLabels_GetEnabled() + else + showName = MTH_BagAmmoLabels_GetEnabled() + end + if showName or showDmg then + local numSlots = GetContainerNumSlots(bagID) + local fname = cf:GetName() + -- Visual button i = API slot (numSlots - i + 1) + for i = 1, numSlots do + local button = getglobal(fname .. "Item" .. i) + local apiSlot = numSlots - i + 1 + if button then + UpdateButton(button, bagID, apiSlot, showName, showDmg) + end + end + for slot = numSlots + 1, 24 do + local button = getglobal(fname .. "Item" .. slot) + if not button then break end + ClearButton(button) + end + end + else + -- Frame hidden: clear all labels + if cf then + local fname = cf:GetName() + if fname then + for slot = 1, 24 do + local button = getglobal(fname .. "Item" .. slot) + if not button then break end + ClearButton(button) + end + end + end + end + end + end +end diff --git a/api/options-expammo.lua b/api/options-expammo.lua index 780ba1a..69b0651 100644 --- a/api/options-expammo.lua +++ b/api/options-expammo.lua @@ -355,6 +355,33 @@ local function MTH_EA_OPT_BuildUI(container) if quiverTip then quiverTip:SetWidth(290) end table.insert(rightGroup, quiverTip) + -- ── Rotation ───────────────────────────────────────────── + table.insert(rightGroup, MTH_EA_OPT_Header(container, "Rotation", -400, OPT_R)) + + local rotTip = MTH_EA_OPT_Tip(container, + "Uncheck a shot to permanently skip it during its proc window.\n The keybind will cast Steady/Aimed instead.", + -418, OPT_R, 0.65, 0.65, 0.65) + if rotTip then rotTip:SetWidth(290) end + table.insert(rightGroup, rotTip) + + local multiCB = MTH_EA_OPT_CB(container, "MTH_ExpAmmoBlockMultiCB", + "Multi-Shot (Explosive)", + -452, OPT_R, cfg.blockExplosive ~= true, + function(v) if MTH_ExpAmmo then MTH_ExpAmmo.SetBlockExplosive(not v) end end) + table.insert(rightGroup, multiCB) + + local serpentCB = MTH_EA_OPT_CB(container, "MTH_ExpAmmoBlockSerpentCB", + "Serpent Sting (Poisonous)", + -476, OPT_R, cfg.blockPoisonous ~= true, + function(v) if MTH_ExpAmmo then MTH_ExpAmmo.SetBlockPoisonous(not v) end end) + table.insert(rightGroup, serpentCB) + + local arcaneCB = MTH_EA_OPT_CB(container, "MTH_ExpAmmoBlockArcaneCB", + "Arcane Shot (Enchanted)", + -500, OPT_R, cfg.blockEnchanted ~= true, + function(v) if MTH_ExpAmmo then MTH_ExpAmmo.SetBlockEnchanted(not v) end end) + table.insert(rightGroup, arcaneCB) + -- Apply initial enabled state MTH_EA_OPT_SetGroupEnabled(rightGroup, showHint) end diff --git a/api/options-smartpet.lua b/api/options-smartpet.lua new file mode 100644 index 0000000..bfb9b51 --- /dev/null +++ b/api/options-smartpet.lua @@ -0,0 +1,321 @@ +------------------------------------------------------ +-- MetaHunt: SmartPet Options Panel +-- TWoW 1.18.1 +------------------------------------------------------ + +local function MTH_SP_OPT_Ready() + return type(MTH_GetFrame) == "function" + and type(MTH_CreateCheckbox) == "function" + and type(MTH_CreateSlider) == "function" +end + +local function MTH_SP_OPT_GetCfg() + if not MTH_SmartPet or not MTH_SmartPet.GetConfig then return {} end + return MTH_SmartPet.GetConfig() +end + +local MTH_SP_OPT_built = false + +-- Layout constants +local OPT_L = 16 -- left column X +local OPT_R = 290 -- right column X +local OPT_CW = 250 -- column width (for tip text wrapping) + +-- Spacing constants +local GAP_SECTION = 34 -- between sections +local GAP_CB = 24 -- checkbox height (icon + text) +local GAP_TIP = 16 -- small tip text height +local GAP_BTN = 28 -- button height + margin +local GAP_SLIDER = 52 -- slider total height (label + track + value) +local GAP_CB_SLIDER = 40 -- checkbox to slider (checkbox + slider label above track) + +-- Helper: checkbox with click handler +local function SP_CB(container, name, label, yOff, xOff, isChecked, onClick) + local cb = MTH_CreateCheckbox(container, name, label, yOff, xOff or OPT_L) + if cb then + cb:SetChecked(isChecked and 1 or 0) + cb:SetScript("OnClick", function() + local v = this:GetChecked() + onClick(v == 1 or v == true) + end) + end + return cb +end + +-- Helper: section header label +local function SP_Header(container, text, yOff, xOff) + local fs = container:CreateFontString(nil, "ARTWORK", "GameFontNormal") + fs:SetPoint("TOPLEFT", container, "TOPLEFT", xOff or OPT_L, yOff) + fs:SetText("|cffff9900" .. text .. "|r") + return fs +end + +-- Helper: small descriptive text +local function SP_Tip(container, text, yOff, xOff) + local fs = container:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall") + fs:SetPoint("TOPLEFT", container, "TOPLEFT", xOff or OPT_L, yOff) + fs:SetWidth(OPT_CW) + fs:SetJustifyH("LEFT") + fs:SetTextColor(0.4, 0.6, 1.0) + fs:SetText(text) + return fs +end + +-- Taunt Mode cycle +local TAUNT_MODES = { "auto", "growl", "cower", "off" } +local TAUNT_LABELS = { + auto = "Auto (solo=Growl, tank=Cower)", + growl = "Always Growl", + cower = "Always Cower", + off = "Off (manual)", +} + +local function SP_NextTauntMode(current) + for i = 1, 4 do + if TAUNT_MODES[i] == current then + return TAUNT_MODES[math.mod(i, 4) + 1] + end + end + return "auto" +end + +-- Channel cycle +local CHANNELS = { "SAY", "PARTY", "RAID", "GUILD" } + +local function SP_NextChannel(current) + for i = 1, 4 do + if CHANNELS[i] == current then + return CHANNELS[math.mod(i, 4) + 1] + end + end + return "SAY" +end + +-- Build the UI +local function MTH_SP_OPT_BuildUI(container) + if MTH_SP_OPT_built then return end + MTH_SP_OPT_built = true + + local cfg = MTH_SP_OPT_GetCfg() + local api = MTH_SmartPet or {} + + --------------------------------------------------------------------------- + -- Title (large, like ExpAmmo) + --------------------------------------------------------------------------- + local title = container:CreateFontString(nil, "ARTWORK", "GameFontHighlightLarge") + title:SetPoint("TOPLEFT", container, "TOPLEFT", OPT_L, -16) + title:SetText("Smart Pet - Pet Combat Management") + + --------------------------------------------------------------------------- + -- Module enable checkbox + --------------------------------------------------------------------------- + local moduleEnabled = true + if MTH and MTH.IsModuleEnabled then + moduleEnabled = MTH:IsModuleEnabled("smartpet", false) and true or false + end + SP_CB(container, "MTH_SP_OPT_ModuleEnable", "Enable Smart Pet module", + -46, OPT_L, moduleEnabled, function(v) + if MTH and MTH.SetModuleEnabled then MTH:SetModuleEnabled("smartpet", v) end + end) + + -- Columns start below the module checkbox + local COL_TOP = -80 + + -- Vertical divider + local divider = container:CreateTexture(nil, "BACKGROUND") + divider:SetTexture(0.3, 0.3, 0.3, 0.6) + divider:SetPoint("TOPLEFT", container, "TOPLEFT", OPT_R - 14, COL_TOP) + divider:SetPoint("BOTTOMLEFT", container, "TOPLEFT", OPT_R - 14, -530) + divider:SetWidth(1) + + --------------------------------------------------------------------------- + -- LEFT COLUMN — Focus Management + --------------------------------------------------------------------------- + local y = COL_TOP + + SP_Header(container, "Focus Management", y) + y = y - 22 + + -- Smart Focus + SP_CB(container, "MTH_SP_OPT_SmartFocus", "Enable Smart Focus", + y, OPT_L, cfg.smartFocus, function(v) + if api.SetSmartFocus then api.SetSmartFocus(v) end + end) + y = y - GAP_CB + SP_Tip(container, "Budget pet focus across DPS abilities.", y) + y = y - GAP_TIP + SP_Tip(container, "|cff80ff80High focus|r — all DPS abilities ON.", y) + y = y - GAP_TIP + SP_Tip(container, "|cffffcc00Mid focus|r — expensive ability first, others wait.", y) + y = y - GAP_TIP + SP_Tip(container, "|cffff8080Low focus|r — cheapest ability always ON (never idle).", y) + y = y - GAP_TIP + SP_Tip(container, "Taunt cost is always reserved.", y) + y = y - GAP_SECTION + + --------------------------------------------------------------------------- + -- LEFT COLUMN — Taunt Management + --------------------------------------------------------------------------- + SP_Header(container, "Taunt Management", y) + y = y - GAP_TIP + SP_Tip(container, "Controls Growl/Cower autocast based on party.", y) + y = y - 22 + + local tauntBtn = CreateFrame("Button", "MTH_SP_OPT_TauntBtn", container, "UIPanelButtonTemplate") + tauntBtn:SetPoint("TOPLEFT", container, "TOPLEFT", OPT_L, y) + tauntBtn:SetWidth(250) + tauntBtn:SetHeight(22) + tauntBtn:SetText("Mode: " .. (TAUNT_LABELS[cfg.tauntMode] or "Auto")) + tauntBtn:SetScript("OnClick", function() + local c = MTH_SP_OPT_GetCfg() + local newMode = SP_NextTauntMode(c.tauntMode) + if api.SetTauntMode then api.SetTauntMode(newMode) end + this:SetText("Mode: " .. (TAUNT_LABELS[newMode] or newMode)) + end) + y = y - GAP_BTN + + SP_CB(container, "MTH_SP_OPT_PvpDetaunt", "PVP Auto-Detaunt", + y, OPT_L, cfg.pvpDetaunt, function(v) + if api.SetPvpDetaunt then api.SetPvpDetaunt(v) end + end) + y = y - GAP_SECTION + + --------------------------------------------------------------------------- + -- LEFT COLUMN — CC-Break Prevention + --------------------------------------------------------------------------- + SP_Header(container, "CC-Break Prevention", y) + y = y - 22 + + SP_CB(container, "MTH_SP_OPT_CcBreak", "Check for breakable CC", + y, OPT_L, cfg.ccBreakCheck, function(v) + if api.SetCcBreakCheck then api.SetCcBreakCheck(v) end + end) + y = y - GAP_CB + + local ccLabels = { block = "Block attack", warn = "Warn only" } + local ccModeBtn = CreateFrame("Button", "MTH_SP_OPT_CcModeBtn", container, "UIPanelButtonTemplate") + ccModeBtn:SetPoint("TOPLEFT", container, "TOPLEFT", OPT_L + 20, y) + ccModeBtn:SetWidth(180) + ccModeBtn:SetHeight(20) + ccModeBtn:SetText("CC Mode: " .. (ccLabels[cfg.ccBreakMode] or "Block")) + ccModeBtn:SetScript("OnClick", function() + local c = MTH_SP_OPT_GetCfg() + local newMode = (c.ccBreakMode == "block") and "warn" or "block" + if api.SetCcBreakMode then api.SetCcBreakMode(newMode) end + this:SetText("CC Mode: " .. (ccLabels[newMode] or newMode)) + end) + y = y - GAP_SECTION + + --------------------------------------------------------------------------- + -- RIGHT COLUMN — General + --------------------------------------------------------------------------- + local ry = COL_TOP + + -- The Button + SP_Header(container, "The Button", ry, OPT_R) + ry = ry - 22 + + SP_CB(container, "MTH_SP_OPT_TheButton", "Enable The Button keybind", + ry, OPT_R, cfg.theButton, function(v) + if api.SetTheButton then api.SetTheButton(v) end + end) + ry = ry - GAP_CB + SP_Tip(container, "Enemy=attack, friendly=assist, none=recall.", ry, OPT_R) + ry = ry - GAP_SECTION + + -- Rush on Attack + SP_CB(container, "MTH_SP_OPT_Rush", "Rush on initial attack", + ry, OPT_R, cfg.rushOnAttack, function(v) + if api.SetRushOnAttack then api.SetRushOnAttack(v) end + end) + ry = ry - GAP_CB + SP_Tip(container, "Charge / Dash / Dive when pet is sent to attack.", ry, OPT_R) + ry = ry - GAP_SECTION + + -- NoChase + SP_CB(container, "MTH_SP_OPT_NoChase", "NoChase (recall on mob flee)", + ry, OPT_R, cfg.noChase, function(v) + if api.SetNoChase then api.SetNoChase(v) end + end) + ry = ry - GAP_CB + SP_Tip(container, "Recall pet when target attempts to flee.", ry, OPT_R) + ry = ry - GAP_SECTION + + -- AutoCower + SP_Header(container, "AutoCower", ry, OPT_R) + ry = ry - 22 + + SP_CB(container, "MTH_SP_OPT_AutoCower", "Enable AutoCower on low HP", + ry, OPT_R, cfg.autoCower, function(v) + if api.SetAutoCower then api.SetAutoCower(v) end + end) + ry = ry - GAP_CB_SLIDER + + local acSlider = MTH_CreateSlider(container, "MTH_SP_OPT_AutoCowerSlider", + "HP Threshold (%)", 5, 80, 5, ry) + if acSlider then + acSlider:ClearAllPoints() + acSlider:SetPoint("TOPLEFT", container, "TOPLEFT", OPT_R, ry) + acSlider:SetWidth(200) + acSlider:SetValue(cfg.autoCowerPct or 30) + local valText = getglobal("MTH_SP_OPT_AutoCowerSliderValue") + if valText then valText:SetText(tostring(cfg.autoCowerPct or 30)) end + acSlider.onChange = function(val) + if api.SetAutoCowerPct then api.SetAutoCowerPct(val) end + end + end + ry = ry - GAP_SLIDER + + -- AutoWarn + SP_Header(container, "AutoWarn", ry, OPT_R) + ry = ry - 22 + + SP_CB(container, "MTH_SP_OPT_AutoWarn", "Warn when pet HP is low", + ry, OPT_R, cfg.autoWarn, function(v) + if api.SetAutoWarn then api.SetAutoWarn(v) end + end) + ry = ry - GAP_CB_SLIDER + + local awSlider = MTH_CreateSlider(container, "MTH_SP_OPT_AutoWarnSlider", + "HP Threshold (%)", 5, 60, 5, ry) + if awSlider then + awSlider:ClearAllPoints() + awSlider:SetPoint("TOPLEFT", container, "TOPLEFT", OPT_R, ry) + awSlider:SetWidth(200) + awSlider:SetValue(cfg.autoWarnPct or 20) + local valText = getglobal("MTH_SP_OPT_AutoWarnSliderValue") + if valText then valText:SetText(tostring(cfg.autoWarnPct or 20)) end + awSlider.onChange = function(val) + if api.SetAutoWarnPct then api.SetAutoWarnPct(val) end + end + end + ry = ry - GAP_SLIDER + + local chanBtn = CreateFrame("Button", "MTH_SP_OPT_ChanBtn", container, "UIPanelButtonTemplate") + chanBtn:SetPoint("TOPLEFT", container, "TOPLEFT", OPT_R, ry) + chanBtn:SetWidth(180) + chanBtn:SetHeight(22) + chanBtn:SetText("Channel: " .. (cfg.autoWarnChannel or "SAY")) + chanBtn:SetScript("OnClick", function() + local c = MTH_SP_OPT_GetCfg() + local newChan = SP_NextChannel(c.autoWarnChannel or "SAY") + if api.SetAutoWarnChannel then api.SetAutoWarnChannel(newChan) end + this:SetText("Channel: " .. newChan) + end) + ry = ry - GAP_CB + SP_Tip(container, "Chat channel for low-HP warnings.", ry, OPT_R) +end + +-- Public setup function (called by options-shell.lua) +function MTH_SetupSmartPetOptions() + if not MTH_SP_OPT_Ready() then return end + local container = MTH_GetFrame("MetaHuntOptionsSmartPet") + if not container then return end + local ok, err = pcall(MTH_SP_OPT_BuildUI, container) + if not ok then + if MTH and MTH.Print then + MTH:Print("[SmartPet Options] " .. tostring(err), "error") + end + MTH_SP_OPT_built = false + end +end diff --git a/api/options-zbuttons.lua b/api/options-zbuttons.lua index e005654..60930ec 100644 --- a/api/options-zbuttons.lua +++ b/api/options-zbuttons.lua @@ -1705,4 +1705,36 @@ function MTH_SetupGeneralOptions() ensureHelpText(tooltipsSection, "MetaHuntGeneralTooltipsFoodHelp", foodHelp, -298) end + local bagsSection = ensureSection("MetaHuntGeneralBagsBox", "Bag Ammo Labels", topY - 372, 168, "right") + if bagsSection then + local bagLabels = ensureCheckbox(bagsSection, "MetaHuntGeneralBagLabelsToggle", "Add text to ammunitions in bags", -10, + MTH_BagAmmoLabels_GetEnabled and MTH_BagAmmoLabels_GetEnabled() or false) + if bagLabels then + bagLabels:SetScript("OnClick", function() + local enabled = MTH_ZB_IsChecked(this) + if MTH_BagAmmoLabels_SetEnabled then MTH_BagAmmoLabels_SetEnabled(enabled) end + end) + end + + local bankLabels = ensureCheckbox(bagsSection, "MetaHuntGeneralBankLabelsToggle", "Add text to ammunitions in bank", -36, + MTH_BankAmmoLabels_GetEnabled and MTH_BankAmmoLabels_GetEnabled() or false) + if bankLabels then + bankLabels:SetScript("OnClick", function() + local enabled = MTH_ZB_IsChecked(this) + if MTH_BankAmmoLabels_SetEnabled then MTH_BankAmmoLabels_SetEnabled(enabled) end + end) + end + + local dmgLabels = ensureCheckbox(bagsSection, "MetaHuntGeneralBagDmgToggle", "Show ammo damage (heat-coloured)", -62, + MTH_BagAmmoDamage_GetEnabled and MTH_BagAmmoDamage_GetEnabled() or false) + if dmgLabels then + dmgLabels:SetScript("OnClick", function() + local enabled = MTH_ZB_IsChecked(this) + if MTH_BagAmmoDamage_SetEnabled then MTH_BagAmmoDamage_SetEnabled(enabled) end + end) + end + + ensureHelpText(bagsSection, "MetaHuntGeneralBagDmgHelp", "Damage is heat-coloured from red (7.5) to green (20.5).", -88) + end + end diff --git a/data/ds-items.lua b/data/ds-items.lua index dfff698..2d81053 100644 --- a/data/ds-items.lua +++ b/data/ds-items.lua @@ -10,6 +10,7 @@ MTH_DS_AmmoItems = { ["level"] = 5, ["reqlevel"] = 1, ["icon"] = "INV_Ammo_Arrow_02", + ["dps"] = 1.5, ["subtype"] = "arrow", ["vendors"] = { [150] = 0, @@ -118,6 +119,7 @@ MTH_DS_AmmoItems = { ["level"] = 15, ["reqlevel"] = 10, ["icon"] = "INV_Ammo_Arrow_02", + ["dps"] = 3.5, ["subtype"] = "arrow", ["vendors"] = { [150] = 0, @@ -275,6 +277,7 @@ MTH_DS_AmmoItems = { ["level"] = 5, ["reqlevel"] = 1, ["icon"] = "INV_Ammo_Bullet_02", + ["dps"] = 2, ["subtype"] = "bullet", ["vendors"] = { [151] = 0, @@ -359,6 +362,7 @@ MTH_DS_AmmoItems = { ["level"] = 15, ["reqlevel"] = 10, ["icon"] = "INV_Ammo_Bullet_02", + ["dps"] = 5.5, ["subtype"] = "bullet", ["vendors"] = { [227] = 0, @@ -494,6 +498,7 @@ MTH_DS_AmmoItems = { ["level"] = 30, ["reqlevel"] = 25, ["icon"] = "INV_Ammo_Arrow_02", + ["dps"] = 5.5, ["subtype"] = "arrow", ["vendors"] = { [227] = 0, @@ -621,6 +626,7 @@ MTH_DS_AmmoItems = { ["level"] = 30, ["reqlevel"] = 25, ["icon"] = "INV_Ammo_Bullet_02", + ["dps"] = 7.5, ["subtype"] = "bullet", ["vendors"] = { [227] = 0, @@ -736,18 +742,21 @@ MTH_DS_AmmoItems = { ["name"] = "Feathered Arrow", ["level"] = 35, ["icon"] = "INV_Ammo_Arrow_02", + ["dps"] = 7.5, ["subtype"] = "arrow", }, [3465] = { ["name"] = "Exploding Shot", ["level"] = 36, ["icon"] = "INV_Ammo_Bullet_02", + ["dps"] = 7.5, ["subtype"] = "bullet", }, [4960] = { ["name"] = "Flash Pellet", ["level"] = 7, ["icon"] = "INV_Ammo_Bullet_02", + ["dps"] = 3.5, ["subtype"] = "bullet", }, [5568] = { @@ -755,6 +764,7 @@ MTH_DS_AmmoItems = { ["level"] = 18, ["reqlevel"] = 13, ["icon"] = "INV_Ammo_Bullet_02", + ["dps"] = 3.5, ["subtype"] = "bullet", ["drops"] = { [2156] = 30.97, @@ -781,6 +791,7 @@ MTH_DS_AmmoItems = { ["level"] = 10, ["reqlevel"] = 5, ["icon"] = "INV_Ammo_Bullet_02", + ["dps"] = 2, ["subtype"] = "bullet", }, [8068] = { @@ -788,6 +799,7 @@ MTH_DS_AmmoItems = { ["level"] = 20, ["reqlevel"] = 15, ["icon"] = "INV_Ammo_Bullet_02", + ["dps"] = 5.5, ["subtype"] = "bullet", }, [8069] = { @@ -795,6 +807,7 @@ MTH_DS_AmmoItems = { ["level"] = 35, ["reqlevel"] = 30, ["icon"] = "INV_Ammo_Bullet_02", + ["dps"] = 7.5, ["subtype"] = "bullet", }, [9399] = { @@ -802,6 +815,7 @@ MTH_DS_AmmoItems = { ["level"] = 40, ["reqlevel"] = 35, ["icon"] = "INV_Ammo_Arrow_01", + ["dps"] = 7.5, ["subtype"] = "arrow", ["drops"] = { [6906] = 100, @@ -812,6 +826,7 @@ MTH_DS_AmmoItems = { ["level"] = 42, ["reqlevel"] = 37, ["icon"] = "INV_Ammo_Bullet_01", + ["dps"] = 12.5, ["subtype"] = "bullet", }, [10513] = { @@ -819,6 +834,7 @@ MTH_DS_AmmoItems = { ["level"] = 49, ["reqlevel"] = 44, ["icon"] = "INV_Ammo_Bullet_01", + ["dps"] = 12.5, ["subtype"] = "bullet", }, [10579] = { @@ -826,6 +842,7 @@ MTH_DS_AmmoItems = { ["level"] = 42, ["reqlevel"] = 37, ["icon"] = "INV_Ammo_Arrow_02", + ["dps"] = 12.5, ["subtype"] = "arrow", }, [11284] = { @@ -833,6 +850,7 @@ MTH_DS_AmmoItems = { ["level"] = 45, ["reqlevel"] = 40, ["icon"] = "INV_Ammo_Bullet_01", + ["dps"] = 10.5, ["subtype"] = "bullet", ["vendors"] = { [734] = 0, @@ -934,6 +952,7 @@ MTH_DS_AmmoItems = { ["level"] = 45, ["reqlevel"] = 40, ["icon"] = "INV_Weapon_ShortBlade_25", + ["dps"] = 10.5, ["subtype"] = "arrow", ["vendors"] = { [228] = 0, @@ -1043,6 +1062,7 @@ MTH_DS_AmmoItems = { ["level"] = 52, ["reqlevel"] = 47, ["icon"] = "INV_Ammo_Bullet_02", + ["dps"] = 10.5, ["subtype"] = "bullet", ["drops"] = { [9025] = 16, @@ -1053,6 +1073,7 @@ MTH_DS_AmmoItems = { ["level"] = 59, ["reqlevel"] = 54, ["icon"] = "Ability_Hunter_CriticalShot", + ["dps"] = 20, ["subtype"] = "arrow", ["drops"] = { [9236] = 100, @@ -1063,6 +1084,7 @@ MTH_DS_AmmoItems = { ["level"] = 61, ["reqlevel"] = 56, ["icon"] = "INV_Ammo_Bullet_02", + ["dps"] = 20.5, ["subtype"] = "bullet", ["drops"] = { [10997] = 100, @@ -1073,6 +1095,7 @@ MTH_DS_AmmoItems = { ["level"] = 57, ["reqlevel"] = 52, ["icon"] = "INV_Ammo_Bullet_03", + ["dps"] = 17.5, ["subtype"] = "bullet", ["vendors"] = { [17078] = 0, @@ -1083,6 +1106,7 @@ MTH_DS_AmmoItems = { ["level"] = 57, ["reqlevel"] = 52, ["icon"] = "INV_Ammo_Arrow_02", + ["dps"] = 17.5, ["subtype"] = "arrow", ["vendors"] = { [17078] = 0, @@ -1093,6 +1117,7 @@ MTH_DS_AmmoItems = { ["level"] = 1, ["reqlevel"] = 1, ["icon"] = "INV_Ammo_Arrow_02", + ["dps"] = 1.5, ["subtype"] = "arrow", }, [19316] = { @@ -1100,6 +1125,7 @@ MTH_DS_AmmoItems = { ["level"] = 54, ["reqlevel"] = 51, ["icon"] = "Spell_Frost_IceShard", + ["dps"] = 14.5, ["subtype"] = "arrow", ["vendors"] = { [13216] = 0, @@ -1113,6 +1139,7 @@ MTH_DS_AmmoItems = { ["level"] = 54, ["reqlevel"] = 51, ["icon"] = "Spell_Frost_FrostBlast", + ["dps"] = 14.5, ["subtype"] = "bullet", ["vendors"] = { [13216] = 0, diff --git a/data/ds-zones-fallback.lua b/data/ds-zones-fallback.lua index aae0d9d..8bf751b 100644 --- a/data/ds-zones-fallback.lua +++ b/data/ds-zones-fallback.lua @@ -54,20 +54,27 @@ MTH_DS_ZoneNamesFallback = { [1519] = "Stormwind City", [1537] = "Ironforge", [1583] = "Blackrock Spire", + [1584] = "Blackrock Depths", [1941] = "Caverns of Time", [1977] = "Zul'Gurub", [2040] = "Alah'Thalas", [2100] = "Maraudon", + [2717] = "Molten Core", [2597] = "Alterac Valley", [3456] = "Naxxramas", [3457] = "Tower of Karazhan", [4012] = "Scarlet Enclave", + [5023] = "Sunnyglade", + [5024] = "Icepoint Rock", [5077] = "Crescent Grove", [5121] = "Tel'Abim", + [5130] = "Winter Veil Vale", [5135] = "Scarlet Monastery Library", [5179] = "Gilneas", + [5208] = "Gilneas City", [5225] = "Thalassian Highlands", [5536] = "Blackstone Island", + [5723] = "Karazhan", [5561] = "Balor", [5581] = "Northwind", [5601] = "Dragonmaw Retreat", diff --git a/data/ds-zones-wma.lua b/data/ds-zones-wma.lua index 7f0ebb6..4524f5e 100644 --- a/data/ds-zones-wma.lua +++ b/data/ds-zones-wma.lua @@ -216,12 +216,15 @@ MTH_MAP_WMA_TO_PFQ = { [607] = 1477, -- TheTempleOfAtalHakkar [609] = 719, -- BlackFathomDeeps [611] = 717, -- TheStockade + [615] = 1584, -- BlackrockDepths (interior) [617] = 2717, -- MoltenCore [619] = 1977, -- ZulGurub [623] = 1584, -- BlackrockDepths [625] = 3429, -- RuinsofAhnQiraj [627] = 2159, -- OnyxiasLair [629] = 1583, -- BlackrockSpire + [631] = 718, -- WailingCaverns (interior) + [633] = 2100, -- Maraudon (interior) [635] = 2677, -- BlackwingLair [637] = 5138, -- TheDeadmines [639] = 722, -- RazorfenDowns diff --git a/init/api.xml b/init/api.xml index 2ea8c68..9bc6d81 100644 --- a/init/api.xml +++ b/init/api.xml @@ -21,6 +21,7 @@ + diff --git a/modules/expammo/engine.lua b/modules/expammo/engine.lua index 421ad9c..777cf60 100644 --- a/modules/expammo/engine.lua +++ b/modules/expammo/engine.lua @@ -91,6 +91,13 @@ local CELL_DEF = { icon = "Interface\\Icons\\Ability_TheBlackArrow" }, } +-- ── Creature-type immunity rules ────────────────────────────── +-- Maps UnitCreatureType("target") → set of ammo states the target resists. +-- Used by PLAYER_TARGET_CHANGED to proactively flag blocked states. +local CREATURE_TYPE_IMMUNITIES = { + ["Elemental"] = { [POISONOUS] = true }, -- Elementals are Nature-immune +} + -- ── Talent gate ────────────────────────────────────────────── -- The module only shows if the player has the talent. -- Checked on login and whenever MTH_HT_OnScanComplete fires. @@ -304,11 +311,28 @@ local function MTH_EA_EnsureConfigDefaults() if cfg.bigCD == nil then cfg.bigCD = false end if cfg.hideOOC == nil then cfg.hideOOC = false end if cfg.useQuiverNoClip == nil then cfg.useQuiverNoClip = false end + if cfg.blockExplosive == nil then cfg.blockExplosive = false end + if cfg.blockPoisonous == nil then cfg.blockPoisonous = false end + if cfg.blockEnchanted == nil then cfg.blockEnchanted = false end end -- Combat state flag (avoids needing InCombatLockdown, which doesn't exist in 1.12) local inCombat = false +-- ── Runtime immunity / block tracking ───────────────────────── +-- Volatile: refreshed on PLAYER_TARGET_CHANGED and combat-log "immune" messages. +local MTH_EA_BlockedStates = {} -- [state] = true if creature-type or reactive immunity + +function MTH_EA_IsStateBlocked(state) + if not state or state == IDLE then return false end + local cfg = MTH_EA_GetConfig() + if state == EXPLOSIVE and cfg.blockExplosive then return true end + if state == POISONOUS and cfg.blockPoisonous then return true end + if state == ENCHANTED and cfg.blockEnchanted then return true end + if MTH_EA_BlockedStates[state] then return true end + return false +end + -- Throttle for PLAYER_AURAS_CHANGED: even with the fast texture path this event -- fires 10-50+/sec in combat, so cap processing at ~6/sec. local MTH_EA_AurasChangedLastTime = 0 @@ -493,6 +517,13 @@ end -- Action button: what spell to show and what spell to cast right now. -- Returns displaySpell, icon, r, g, b, cooldownRemaining, castSpell local function MTH_EA_GetActionSpell() + -- If current ammo state is blocked (creature immune or rotation toggle), + -- skip the consume-spell and fall through to Aimed/Steady instead. + if rt.state ~= IDLE and MTH_EA_IsStateBlocked(rt.state) then + local cd = MTH_EA_GetSpellCD(SPELL_AIMED) + return SPELL_AIMED, ICON_AIMED, 1.00, 0.85, 0.00, cd, (cd > 0 and SPELL_STEADY or SPELL_AIMED) + end + if rt.state == EXPLOSIVE then local cd = MTH_EA_GetSpellCD(SPELL_MULTI) return SPELL_MULTI, ICON_MULTI, 1.00, 0.60, 0.05, cd, (cd > 0 and SPELL_STEADY or SPELL_MULTI) @@ -654,6 +685,11 @@ MTH_EA_UpdateUI = function() end cdTexts[i]:SetText("") end + + -- Red border override for blocked (immune / rotation-disabled) states + if MTH_EA_IsStateBlocked(def.state) then + c:SetBackdropBorderColor(1, 0, 0, 0.85) + end end -- ── TOP cell: Lock and Load ─────────────────────────────── @@ -1158,6 +1194,8 @@ frame:RegisterEvent("SPELLCAST_STOP") frame:RegisterEvent("SPELLCAST_FAILED") frame:RegisterEvent("SPELLCAST_INTERRUPTED") frame:RegisterEvent("ACTIONBAR_SLOT_CHANGED") -- invalidate Aimed Shot slot cache +frame:RegisterEvent("PLAYER_TARGET_CHANGED") -- proactive creature-type immunity +frame:RegisterEvent("CHAT_MSG_SPELL_SELF_DAMAGE") -- reactive "immune" detection frame:SetScript("OnUpdate", function() if not MTH_EA_IsEnabled() then return end @@ -1304,6 +1342,34 @@ frame:SetScript("OnEvent", function() return end + if event == "PLAYER_TARGET_CHANGED" then + MTH_EA_BlockedStates = {} + if UnitExists("target") then + local creatureType = UnitCreatureType("target") + if creatureType and CREATURE_TYPE_IMMUNITIES[creatureType] then + for st, _ in pairs(CREATURE_TYPE_IMMUNITIES[creatureType]) do + MTH_EA_BlockedStates[st] = true + end + end + end + MTH_EA_UpdateUI() + return + end + + if event == "CHAT_MSG_SPELL_SELF_DAMAGE" then + local msg = arg1 or "" + if string.find(msg, "immune", 1, true) then + for state, spell in pairs(CONSUME_SPELL) do + if string.find(msg, spell, 1, true) then + MTH_EA_BlockedStates[state] = true + MTH_EA_UpdateUI() + break + end + end + end + return + end + if event == "PLAYER_REGEN_DISABLED" then -- Entering combat inCombat = true @@ -1537,5 +1603,20 @@ MTH_ExpAmmo = { local cfg = MTH_EA_GetConfig() cfg.useQuiverNoClip = v and true or false end, + SetBlockExplosive = function(v) + local cfg = MTH_EA_GetConfig() + cfg.blockExplosive = v and true or false + MTH_EA_UpdateUI() + end, + SetBlockPoisonous = function(v) + local cfg = MTH_EA_GetConfig() + cfg.blockPoisonous = v and true or false + MTH_EA_UpdateUI() + end, + SetBlockEnchanted = function(v) + local cfg = MTH_EA_GetConfig() + cfg.blockEnchanted = v and true or false + MTH_EA_UpdateUI() + end, ToggleAnchor = MTH_EA_ToggleAnchor, } diff --git a/modules/smartpet/engine.lua b/modules/smartpet/engine.lua new file mode 100644 index 0000000..5517f6d --- /dev/null +++ b/modules/smartpet/engine.lua @@ -0,0 +1,1293 @@ +------------------------------------------------------ +-- MetaHunt: SmartPet — Pet Combat Management +-- TWoW 1.18.1 +------------------------------------------------------ +-- +-- Integrated pet combat manager inspired by SmartPet v2.5.1. +-- Handles taunt management, focus budgeting, CC-break +-- prevention, PVP auto-detaunt, The Button keybind, +-- NoChase, Dash/Dive on attack, auto-cower, and +-- low-health chat warnings. +------------------------------------------------------ + +-- ── Localised ability names ────────────────────────────────── +-- Resolved from MTH_LocaleData.spells via MTH:LocalizeSpell(). +-- PET_ACTION_FOLLOW / PET_ACTION_ATTACK are built-in WoW tokens, +-- not localised spell names. +local L_GROWL, L_COWER, L_CLAW, L_BITE, L_DASH, L_DIVE +local L_LIGHTNING_BREATH, L_SCREECH, L_SCORPID_POISON +local L_CHARGE, L_DEATH_ROLL, L_FURIOUS_HOWL, L_SAVAGE_REND +local L_THUNDERSTOMP, L_POISON_SPIT, L_POLLEN_BURST +local L_PROWL, L_SHELL_SHIELD, L_GRACE, L_BUBBLE_BARRIER +local L_WEB, L_ROAR_OF_FORTITUDE, L_PACKLEADER, L_STRIDER_PRESENCE +local L_FOLLOW = "PET_ACTION_FOLLOW" +local L_ATTACK = "PET_ACTION_ATTACK" +local L_FLEE = "attempts to run away in fear" + +-- ── CC debuff token list (English keys into spell locale) ──── +local CC_DEBUFF_TOKENS = { + "Gouge", "Sap", "Charm", "Seduction", "Sheep", "Polymorph", + "Tame Beast", "Scare Beast", "Sleep", "Hibernate", "Fear", + "Mind Control", "Blind", "Scatter Shot", "Enslave Demon", + "Shackle Undead", "Reckless Charge", "Freezing Trap Effect", + "Intimidating Shout", "Repentance", "Wyvern Sting", +} + +-- Populated once MTH:LocalizeSpell is available (VARIABLES_LOADED) +local CC_DEBUFF_SET = {} + +-- Maps localised ability names to rt.slot keys. +local ABILITY_MAP = {} +local function BuildAbilityMap() + ABILITY_MAP = { + [L_GROWL] = "growl", + [L_COWER] = "cower", + [L_CLAW] = "claw", + [L_BITE] = "bite", + [L_FOLLOW] = "follow", + [L_ATTACK] = "attack", + [L_DASH] = "dash", + [L_DIVE] = "dive", + } + -- Conditionally add family-specific abilities (nil-safe: L_ vars may be nil before locale resolution) + if L_LIGHTNING_BREATH then ABILITY_MAP[L_LIGHTNING_BREATH] = "lightningBreath" end + if L_SCREECH then ABILITY_MAP[L_SCREECH] = "screech" end + if L_SCORPID_POISON then ABILITY_MAP[L_SCORPID_POISON] = "scorpidPoison" end + if L_CHARGE then ABILITY_MAP[L_CHARGE] = "charge" end + if L_DEATH_ROLL then ABILITY_MAP[L_DEATH_ROLL] = "deathRoll" end + if L_FURIOUS_HOWL then ABILITY_MAP[L_FURIOUS_HOWL] = "furiousHowl" end + if L_SAVAGE_REND then ABILITY_MAP[L_SAVAGE_REND] = "savageRend" end + if L_THUNDERSTOMP then ABILITY_MAP[L_THUNDERSTOMP] = "thunderstomp" end + if L_POISON_SPIT then ABILITY_MAP[L_POISON_SPIT] = "poisonSpit" end + if L_POLLEN_BURST then ABILITY_MAP[L_POLLEN_BURST] = "pollenBurst" end + if L_PROWL then ABILITY_MAP[L_PROWL] = "prowl" end + if L_SHELL_SHIELD then ABILITY_MAP[L_SHELL_SHIELD] = "shellShield" end + if L_GRACE then ABILITY_MAP[L_GRACE] = "grace" end + if L_BUBBLE_BARRIER then ABILITY_MAP[L_BUBBLE_BARRIER] = "bubbleBarrier" end + if L_WEB then ABILITY_MAP[L_WEB] = "web" end + if L_ROAR_OF_FORTITUDE then ABILITY_MAP[L_ROAR_OF_FORTITUDE] = "roarOfFortitude" end + if L_PACKLEADER then ABILITY_MAP[L_PACKLEADER] = "packleader" end + if L_STRIDER_PRESENCE then ABILITY_MAP[L_STRIDER_PRESENCE] = "striderPresence" end +end + +-- Called at VARIABLES_LOADED when the locale system is ready +local function ResolveLocaleStrings() + if not (MTH and MTH.LocalizeSpell) then return end + + L_GROWL = MTH:LocalizeSpell("Growl") + L_COWER = MTH:LocalizeSpell("Cower") + L_CLAW = MTH:LocalizeSpell("Claw") + L_BITE = MTH:LocalizeSpell("Bite") + L_DASH = MTH:LocalizeSpell("Dash") + L_DIVE = MTH:LocalizeSpell("Dive") + + -- Family-specific abilities + L_LIGHTNING_BREATH = MTH:LocalizeSpell("Lightning Breath") + L_SCREECH = MTH:LocalizeSpell("Screech") + L_SCORPID_POISON = MTH:LocalizeSpell("Scorpid Poison") + L_CHARGE = MTH:LocalizeSpell("Charge") + L_DEATH_ROLL = MTH:LocalizeSpell("Death Roll") + L_FURIOUS_HOWL = MTH:LocalizeSpell("Furious Howl") + L_SAVAGE_REND = MTH:LocalizeSpell("Savage Rend") + L_THUNDERSTOMP = MTH:LocalizeSpell("Thunderstomp") + L_POISON_SPIT = MTH:LocalizeSpell("Poison Spit") + L_POLLEN_BURST = MTH:LocalizeSpell("Pollen Burst") + L_PROWL = MTH:LocalizeSpell("Prowl") + L_SHELL_SHIELD = MTH:LocalizeSpell("Shell Shield") + L_GRACE = MTH:LocalizeSpell("Grace") + L_BUBBLE_BARRIER = MTH:LocalizeSpell("Bubble Barrier") + L_WEB = MTH:LocalizeSpell("Web") + L_ROAR_OF_FORTITUDE = MTH:LocalizeSpell("Roar of Fortitude") + L_PACKLEADER = MTH:LocalizeSpell("Packleader") + L_STRIDER_PRESENCE = MTH:LocalizeSpell("Strider Presence") + + -- Flee pattern from spell locale (special key) + local spellLocales = MTH_LocaleData and MTH_LocaleData.spells + if spellLocales then + local locale = MTH.currentLocale or "enUS" + local aliases = { enGB = "enUS", esMX = "esES", zhTW = "zhCN" } + if aliases[locale] then locale = aliases[locale] end + local map = spellLocales[locale] or spellLocales.enUS + if map and map["SP_FLEE_PATTERN"] then + L_FLEE = map["SP_FLEE_PATTERN"] + end + end + + -- Rebuild CC debuff set from localised names + CC_DEBUFF_SET = {} + for _, token in ipairs(CC_DEBUFF_TOKENS) do + local localized = MTH:LocalizeSpell(token) + CC_DEBUFF_SET[localized] = true + end + + -- Rebuild ability map with resolved names + BuildAbilityMap() +end + +-- ── Constants ──────────────────────────────────────────────── +local FOCUS_REGEN_INTERVAL = 5 -- pet focus ticks roughly every 5s + +-- Pet action bar slot count (WoW 1.12) +local NUM_PET_SLOTS = NUM_PET_ACTION_SLOTS or 10 + +-- Ability metadata: focus cost and behaviour type. +-- type: "taunt", "autocastDPS" (spammable, no meaningful CD), +-- "semiAutocast" (short CD, still autocasts frequently), +-- "cdDPS" (significant cooldown), "mobility", "defensive", +-- "utility", "partyBuff" +local ABILITY_INFO = { + growl = { cost = 15, type = "taunt" }, + cower = { cost = 15, type = "taunt" }, + bite = { cost = 35, type = "autocastDPS" }, + claw = { cost = 25, type = "autocastDPS" }, + lightningBreath = { cost = 50, type = "autocastDPS" }, + scorpidPoison = { cost = 25, type = "semiAutocast" }, + screech = { cost = 20, type = "semiAutocast" }, + charge = { cost = 35, type = "cdDPS" }, + deathRoll = { cost = 50, type = "cdDPS" }, + furiousHowl = { cost = 50, type = "cdDPS" }, + savageRend = { cost = 25, type = "cdDPS" }, + thunderstomp = { cost = 60, type = "cdDPS" }, + poisonSpit = { cost = 35, type = "cdDPS" }, + pollenBurst = { cost = 40, type = "cdDPS" }, + dash = { cost = 20, type = "mobility" }, + dive = { cost = 20, type = "mobility" }, + prowl = { cost = 40, type = "utility" }, + shellShield = { cost = 10, type = "defensive" }, + grace = { cost = 10, type = "defensive" }, + bubbleBarrier = { cost = 20, type = "defensive" }, + web = { cost = 0, type = "utility" }, + roarOfFortitude = { cost = 50, type = "partyBuff" }, + packleader = { cost = 50, type = "partyBuff" }, + striderPresence = { cost = 50, type = "partyBuff" }, +} + +-- Ability keys whose autocast is managed by the focus budget system. +local MANAGED_DPS_TYPES = { autocastDPS = true, semiAutocast = true, cdDPS = true } + +-- ── Config defaults (persisted per-character) ──────────────── +local CONFIG_DEFAULTS = { + tauntMode = "auto", -- "auto" | "growl" | "cower" | "off" + pvpDetaunt = true, + ccBreakCheck = true, + ccBreakMode = "block", -- "block" | "warn" + theButton = true, + smartFocus = false, + autoCower = false, + autoCowerPct = 30, + noChase = false, + rushOnAttack = true, + autoWarn = false, + autoWarnPct = 20, + autoWarnChannel = "SAY", +} + +-- ── Runtime state (not persisted) ──────────────────────────── +local rt = { + inCombat = false, + inPVP = false, + -- Backed-up pre-PVP autocast states + pvpBackup = nil, -- { growl=bool, cower=bool, autoCower=bool } + -- Pet action bar slot indices (-1 = not found) + slot = { + attack = -1, + follow = -1, + growl = -1, + cower = -1, + bite = -1, + claw = -1, + dash = -1, + dive = -1, + lightningBreath = -1, + screech = -1, + scorpidPoison = -1, + charge = -1, + deathRoll = -1, + furiousHowl = -1, + savageRend = -1, + thunderstomp = -1, + poisonSpit = -1, + pollenBurst = -1, + prowl = -1, + shellShield = -1, + grace = -1, + bubbleBarrier = -1, + web = -1, + roarOfFortitude = -1, + packleader = -1, + striderPresence = -1, + }, + -- Pet spellbook indices for rush abilities (used for CastSpell) + spellbook = { + dash = -1, + dive = -1, + charge = -1, + }, + -- Focus tracking + focus1 = 100, + focus2 = 100, + lastFocusTick = 0, + nextFocusTick = 0, + -- Smart Focus: remember which DPS autocasts were ON at combat start + preCombatAutocast = {}, -- { [slotKey] = bool } + -- AutoCower: track if we swapped to cower + autoCowered = false, + -- AutoWarn throttle + lastWarnTime = 0, + lastWarnPct = 0, + -- NoChase recall flag + recallPending = false, + -- TheButton context + theButtonRecall = false, + -- Party has a tank (cached) + partyHasTank = false, +} + +-- ── Hidden tooltip for debuff scanning (legacy path) ───────── +local debuffTip = nil +local function EnsureDebuffTip() + if debuffTip then return debuffTip end + debuffTip = CreateFrame("GameTooltip", "MTH_SP_DebuffTip", UIParent, "GameTooltipTemplate") + debuffTip:SetOwner(UIParent, "ANCHOR_NONE") + return debuffTip +end + +-- ── Config helpers ─────────────────────────────────────────── +local function GetCfg() + if type(MTH_SavedVariables) ~= "table" then return CONFIG_DEFAULTS end + if type(MTH_SavedVariables.modules) ~= "table" then + MTH_SavedVariables.modules = {} + end + local cfg = MTH_SavedVariables.modules["smartpet"] + if type(cfg) ~= "table" then + cfg = {} + MTH_SavedVariables.modules["smartpet"] = cfg + end + -- Fill defaults for missing keys + for k, v in pairs(CONFIG_DEFAULTS) do + if cfg[k] == nil then + cfg[k] = v + end + end + return cfg +end + +local function IsEnabled() + if not (MTH and MTH.IsModuleEnabled) then return false end + return MTH:IsModuleEnabled("smartpet", false) +end + +-- ── Pet action bar helpers ─────────────────────────────────── +local function GetAutocast(slotIndex) + if slotIndex < 1 then return false end + local name, subtext, texture, isToken, isActive, autoCastAllowed, autoCastEnabled = GetPetActionInfo(slotIndex) + return autoCastEnabled and true or false +end + +local function SetAutocast(slotIndex, enabled) + if slotIndex < 1 then return end + local current = GetAutocast(slotIndex) + if (current and not enabled) or (not current and enabled) then + TogglePetAutocast(slotIndex) + end +end + +-- ── Pet action bar scanning ────────────────────────────────── + +local function ScanPetActionBar() + for key in pairs(rt.slot) do + rt.slot[key] = -1 + end + for i = 1, NUM_PET_SLOTS do + local name = GetPetActionInfo(i) + if name then + local key = ABILITY_MAP[name] + if key then + rt.slot[key] = i + end + end + end +end + +-- Scan pet spellbook for rush ability indices (for CastSpell) +local function ScanPetSpellbook() + rt.spellbook.dash = -1 + rt.spellbook.dive = -1 + rt.spellbook.charge = -1 + for i = 1, 20 do + local spellName = GetSpellName(i, BOOKTYPE_PET) + if not spellName then break end + if spellName == L_DASH then + rt.spellbook.dash = i + elseif spellName == L_DIVE then + rt.spellbook.dive = i + elseif spellName == L_CHARGE then + rt.spellbook.charge = i + end + end +end + +-- ── Party/tank detection ───────────────────────────────────── +local TANK_CLASSES = { WARRIOR = true, PALADIN = true } +-- Druid tanks detected by class + bear form buff (approximate) + +local function ScanPartyForTank() + local members = GetNumPartyMembers and GetNumPartyMembers() or 0 + local raid = GetNumRaidMembers and GetNumRaidMembers() or 0 + + if members == 0 and raid == 0 then + rt.partyHasTank = false + return + end + + -- Raid scan + if raid > 0 then + for i = 1, raid do + local unit = "raid" .. i + if UnitExists(unit) and not UnitIsUnit(unit, "player") then + local _, cls = UnitClass(unit) + if cls and TANK_CLASSES[cls] then + rt.partyHasTank = true + return + end + if cls == "DRUID" then + -- Check for bear form via power type (rage = 1) + if UnitPowerType and UnitPowerType(unit) == 1 then + rt.partyHasTank = true + return + end + end + end + end + rt.partyHasTank = false + return + end + + -- Party scan + for i = 1, members do + local unit = "party" .. i + if UnitExists(unit) then + local _, cls = UnitClass(unit) + if cls and TANK_CLASSES[cls] then + rt.partyHasTank = true + return + end + if cls == "DRUID" then + if UnitPowerType and UnitPowerType(unit) == 1 then + rt.partyHasTank = true + return + end + end + end + end + rt.partyHasTank = false +end + +-- ── Taunt Management ───────────────────────────────────────── +local function ApplyTauntMode() + if not IsEnabled() then return end + local cfg = GetCfg() + local mode = cfg.tauntMode + + if mode == "off" then return end + if mode == "growl" then + SetAutocast(rt.slot.growl, true) + SetAutocast(rt.slot.cower, false) + return + end + if mode == "cower" then + SetAutocast(rt.slot.growl, false) + SetAutocast(rt.slot.cower, true) + return + end + -- auto: solo → growl, party with tank → cower + if rt.partyHasTank then + SetAutocast(rt.slot.growl, false) + if rt.slot.cower > 0 then + SetAutocast(rt.slot.cower, true) + end + else + SetAutocast(rt.slot.growl, true) + SetAutocast(rt.slot.cower, false) + end +end + +-- ── PVP Auto-Detaunt ───────────────────────────────────────── +local function StartPVP() + if rt.inPVP then return end + local cfg = GetCfg() + if not cfg.pvpDetaunt then return end + + rt.inPVP = true + rt.pvpBackup = { + growl = GetAutocast(rt.slot.growl), + cower = GetAutocast(rt.slot.cower), + autoCower = cfg.autoCower, + } + SetAutocast(rt.slot.growl, false) + SetAutocast(rt.slot.cower, false) +end + +local function EndPVP() + if not rt.inPVP then return end + rt.inPVP = false + if rt.pvpBackup then + SetAutocast(rt.slot.growl, rt.pvpBackup.growl) + SetAutocast(rt.slot.cower, rt.pvpBackup.cower) + -- Restore autoCower setting — it doesn't get toggled on the bar, just the config flag + rt.pvpBackup = nil + end +end + +-- ── CC-Break Prevention ────────────────────────────────────── +-- Returns true if the target has a breakable CC debuff. +local function TargetHasBreakableCC() + local cfg = GetCfg() + if not cfg.ccBreakCheck then return false end + + -- NamPower path: GetUnitField aura array + if type(GetUnitField) == "function" then + local auras = GetUnitField("target", "aura") + if type(auras) == "table" then + for _, aura in ipairs(auras) do + if type(aura) == "table" then + local auraName = aura.name or (aura.spellId and GetSpellRecField and GetSpellRecField(aura.spellId, "name")) + if auraName and CC_DEBUFF_SET[auraName] then + return true + end + end + end + return false + end + end + + -- Legacy path: hidden tooltip scan + local tip = EnsureDebuffTip() + tip:ClearLines() + for j = 1, 16 do + if not UnitDebuff("target", j) then break end + tip:SetUnitDebuff("target", j) + local nameRegion = getglobal("MTH_SP_DebuffTipTextLeft1") + if nameRegion then + local debuffName = nameRegion:GetText() + if debuffName and CC_DEBUFF_SET[debuffName] then + return true + end + end + end + return false +end + +-- ── Rush on attack (Dash / Dive / Charge) ──────────────────── +local function CastChargeAbility() + local cfg = GetCfg() + if not cfg.rushOnAttack then return end + if rt.inCombat then return end -- only on initial engage + + -- Try Charge first (Boar — rush + damage), then Dash, then Dive + local idx = -1 + if rt.spellbook.charge > 0 then + idx = rt.spellbook.charge + elseif rt.spellbook.dash > 0 then + idx = rt.spellbook.dash + elseif rt.spellbook.dive > 0 then + idx = rt.spellbook.dive + end + if idx < 1 then return end + + local start, duration = GetSpellCooldown(idx, BOOKTYPE_PET) + if start == 0 and duration == 0 then + CastSpell(idx, BOOKTYPE_PET) + end +end + +-- ── Smart Focus ────────────────────────────────────────────── +-- Estimate how many focus-regen ticks will occur during an ability's cooldown. +local function EstimatedFocusRegen(slotIndex) + if slotIndex < 1 then return 0 end + local start, duration = GetPetActionCooldown(slotIndex) + if not start or start == 0 then return 0 end + local remaining = (start + duration) - GetTime() + if remaining <= 0 then return 0 end + -- Each tick gives ~FOCUS_REGEN_INTERVAL worth of focus ticks at ~24 focus per 5s cycle + -- SmartPet used FocusManager * 4.8; we use a simpler remaining/5 * 24 estimate + local ticks = remaining / FOCUS_REGEN_INTERVAL + return ticks * 24 +end + +local function HandleFocusEvent() + if not IsEnabled() then return end + local cfg = GetCfg() + if not cfg.smartFocus and cfg.tauntMode == "off" then return end + if not rt.inCombat then return end + if rt.inPVP then return end + + -- Track focus regen tick timing + rt.focus2 = rt.focus1 + rt.focus1 = UnitMana("pet") or 0 + if rt.focus1 > rt.focus2 then + rt.lastFocusTick = GetTime() + rt.nextFocusTick = rt.lastFocusTick + 4.5 + end + + local currentFocus = rt.focus1 + + -- Determine taunt cost (must always be reservable) + local tauntCost = 0 + if cfg.tauntMode ~= "off" then + if GetAutocast(rt.slot.cower) then + tauntCost = ABILITY_INFO.cower.cost + elseif rt.slot.growl > 0 then + tauntCost = ABILITY_INFO.growl.cost + end + end + + -- Collect managed DPS abilities (on bar AND pre-combat autocast was ON) + local managed = {} -- { {key, cost, slot}, ... } + local managedN = 0 + for key, wasOn in pairs(rt.preCombatAutocast) do + if wasOn and rt.slot[key] > 0 and ABILITY_INFO[key] then + managedN = managedN + 1 + managed[managedN] = { key = key, cost = ABILITY_INFO[key].cost, slot = rt.slot[key] } + end + end + + if managedN == 0 then return end + + -- Sort by cost descending (most expensive = highest priority to keep active) + table.sort(managed, function(a, b) return a.cost > b.cost end) + + if cfg.smartFocus and managedN >= 2 then + -- Smart Focus: reserve taunt cost once, then budget the rest across DPS. + -- Always keep the cheapest ability ON so the pet never sits idle; + -- the worst case is growl is delayed by one regen tick (~5s). + local budget = currentFocus - tauntCost + if budget < 0 then budget = 0 end + if MTH and MTH.Print then + MTH:Print("[SP] focus=" .. currentFocus .. " reserve=" .. tauntCost .. " budget=" .. budget .. " managed=" .. managedN, "debug") + end + local anyEnabled = false + for i = 1, managedN do + local m = managed[i] + if budget >= m.cost then + SetAutocast(m.slot, true) + anyEnabled = true + if MTH and MTH.Print then + MTH:Print("[SP] " .. m.key .. " ON (cost=" .. m.cost .. " left=" .. (budget - m.cost) .. ")", "debug") + end + budget = budget - m.cost + elseif i == managedN and not anyEnabled then + -- Cheapest ability: keep ON so pet isn't idle + SetAutocast(m.slot, true) + if MTH and MTH.Print then + MTH:Print("[SP] " .. m.key .. " ON* (cheapest, waiting for regen)", "debug") + end + else + SetAutocast(m.slot, false) + if MTH and MTH.Print then + MTH:Print("[SP] " .. m.key .. " OFF (cost=" .. m.cost .. " budget=" .. budget .. ")", "debug") + end + end + end + return + end + + -- Basic taunt-priority: disable any DPS ability that would starve the taunt + if cfg.tauntMode ~= "off" and tauntCost > 0 then + for i = 1, managedN do + local m = managed[i] + local regen = EstimatedFocusRegen(m.slot) + if (currentFocus + regen) - m.cost < tauntCost then + SetAutocast(m.slot, false) + if MTH and MTH.Print then + MTH:Print("[SP] tauntGuard " .. m.key .. " OFF (focus=" .. currentFocus .. " regen=" .. math.floor(regen) .. ")", "debug") + end + else + SetAutocast(m.slot, true) + end + end + end +end + +-- ── AutoCower on low HP ────────────────────────────────────── +local function HandlePetHealth() + if not IsEnabled() then return end + if not rt.inCombat then return end + local cfg = GetCfg() + + local hp = UnitHealth("pet") or 0 + local hpMax = UnitHealthMax("pet") or 1 + if hpMax == 0 then hpMax = 1 end + local pct = (100 * hp) / hpMax + + -- AutoCower + if cfg.autoCower and rt.slot.cower > 0 then + if pct < cfg.autoCowerPct then + if not rt.autoCowered then + rt.autoCowered = true + SetAutocast(rt.slot.growl, false) + SetAutocast(rt.slot.cower, true) + end + else + if rt.autoCowered then + rt.autoCowered = false + ApplyTauntMode() + end + end + end + + -- AutoWarn + if cfg.autoWarn then + local warnInterval + if pct >= 60 then + warnInterval = 10 + elseif pct >= 50 then + warnInterval = 8 + elseif pct >= 30 then + warnInterval = 6 + else + warnInterval = 4 + end + if pct < cfg.autoWarnPct + and (GetTime() - rt.lastWarnTime) > warnInterval + and rt.lastWarnPct > pct then + local petName = UnitName("pet") or "Pet" + local msg = petName .. " needs healing! (" .. string.format("%d", pct) .. "% Health)" + local chan = string.upper(cfg.autoWarnChannel or "SAY") + -- Validate channel + if chan == "PARTY" then + if not (UnitInParty and UnitInParty("player")) then chan = "SAY" end + elseif chan == "RAID" then + if not (GetNumRaidMembers and GetNumRaidMembers() > 0) then chan = "SAY" end + elseif chan == "GUILD" then + if not (IsInGuild and IsInGuild()) then chan = "SAY" end + elseif chan ~= "SAY" then + chan = "SAY" + end + SendChatMessage(msg, chan) + rt.lastWarnTime = GetTime() + end + rt.lastWarnPct = pct + end +end + +-- ── NoChase ────────────────────────────────────────────────── +local function HandleMonsterEmote(emoteText, sourceName) + if not IsEnabled() then return end + local cfg = GetCfg() + if not cfg.noChase then return end + + local petTarget = UnitName("pettarget") + if sourceName and petTarget and sourceName == petTarget + and strfind(emoteText or "", L_FLEE) then + PetFollow() + rt.theButtonRecall = true + if UIErrorsFrame then + UIErrorsFrame:AddMessage("Recall Pet", 0.0, 0.0, 1.0, 1.0, UIERRORS_HOLD_TIME) + end + end +end + +-- ── Pet Damage Meter ───────────────────────────────────────── +local meter = { + active = false, + recording = false, -- true while actively collecting data + startTime = 0, + duration = 0, -- 0 = unlimited, >0 = auto-stop after N seconds + totalDmg = 0, + totalHits = 0, + totalCrits = 0, + totalMisses = 0, + abilities = {}, -- [name] = { dmg=N, hits=N, crits=N, misses=N } + petName = "", + autocastSnapshot = "", -- "Bite=ON Claw=ON Growl=OFF ..." +} + +local MeterPrintSummary -- forward declaration +local MeterStop -- forward declaration + +local function MeterReset() + meter.startTime = GetTime() + meter.recording = true + meter.totalDmg = 0 + meter.totalHits = 0 + meter.totalCrits = 0 + meter.totalMisses = 0 + meter.abilities = {} + -- Snapshot pet name + meter.petName = UnitName("pet") or "Unknown" + -- Ensure slot map is current before reading autocast states + ScanPetActionBar() + -- Snapshot all known ability autocast states + local parts = {} + local partN = 0 + for key, info in pairs(ABILITY_INFO) do + if rt.slot[key] and rt.slot[key] > 0 then + local on = GetAutocast(rt.slot[key]) + partN = partN + 1 + parts[partN] = key .. "=" .. (on and "ON" or "OFF") + end + end + table.sort(parts) + meter.autocastSnapshot = table.concat(parts, " ") +end + +MeterStop = function() + meter.recording = false + meter.active = false + if meter.totalDmg > 0 then MeterPrintSummary() end + MTH:Print("|cffff9900[SP Meter]|r Stopped.") +end + +local function MeterGetAbility(name) + if not meter.abilities[name] then + meter.abilities[name] = { dmg = 0, hits = 0, crits = 0, misses = 0 } + end + return meter.abilities[name] +end + +local function MeterParseCombatMsg(msg) + if not meter.recording or not msg then return end + + local ability, dmg = nil, nil + + -- hit: "'s hits for ." + _, _, ability, dmg = strfind(msg, "'s ([%a ]-) hits .+ for (%d+)") + if ability and dmg then + dmg = tonumber(dmg) or 0 + local a = MeterGetAbility(ability) + a.hits = a.hits + 1 + a.dmg = a.dmg + dmg + meter.totalHits = meter.totalHits + 1 + meter.totalDmg = meter.totalDmg + dmg + return + end + + -- crit: "'s crits for ." + _, _, ability, dmg = strfind(msg, "'s ([%a ]-) crits .+ for (%d+)") + if ability and dmg then + dmg = tonumber(dmg) or 0 + local a = MeterGetAbility(ability) + a.crits = a.crits + 1 + a.dmg = a.dmg + dmg + meter.totalCrits = meter.totalCrits + 1 + meter.totalDmg = meter.totalDmg + dmg + return + end + + -- miss/resist: "'s misses" / "'s was resisted" + _, _, ability = strfind(msg, "'s ([%a ]-) misses") + if not ability then + _, _, ability = strfind(msg, "'s ([%a ]-) was ") + end + if ability then + local a = MeterGetAbility(ability) + a.misses = a.misses + 1 + meter.totalMisses = meter.totalMisses + 1 + return + end +end + +local function MeterParsePetMelee(msg) + if not meter.recording or not msg then return end + + local dmg = nil + + -- hit: " hits for ." + _, _, dmg = strfind(msg, " hits .+ for (%d+)") + if dmg then + dmg = tonumber(dmg) or 0 + local a = MeterGetAbility("Melee") + a.hits = a.hits + 1 + a.dmg = a.dmg + dmg + meter.totalHits = meter.totalHits + 1 + meter.totalDmg = meter.totalDmg + dmg + return + end + + -- crit: " crits for ." + _, _, dmg = strfind(msg, " crits .+ for (%d+)") + if dmg then + dmg = tonumber(dmg) or 0 + local a = MeterGetAbility("Melee") + a.crits = a.crits + 1 + a.dmg = a.dmg + dmg + meter.totalCrits = meter.totalCrits + 1 + meter.totalDmg = meter.totalDmg + dmg + return + end + + -- miss: " misses ." + if strfind(msg, " misses ") then + local a = MeterGetAbility("Melee") + a.misses = a.misses + 1 + meter.totalMisses = meter.totalMisses + 1 + return + end +end + +MeterPrintSummary = function() + if not MTH or not MTH.Print then return end + local elapsed = GetTime() - meter.startTime + if elapsed < 1 then elapsed = 1 end + local dps = meter.totalDmg / elapsed + local smartLabel = GetCfg().smartFocus and "ON" or "OFF" + local totalSwings = meter.totalHits + meter.totalCrits + meter.totalMisses + + MTH:Print(string.format( + "[SP Meter] Pet=%s SmartFocus=%s Duration=%.1fs TotalDmg=%d DPS=%.1f Swings=%d (H:%d C:%d M:%d)", + meter.petName, smartLabel, elapsed, meter.totalDmg, dps, totalSwings, + meter.totalHits, meter.totalCrits, meter.totalMisses), "debug") + + MTH:Print("[SP Meter] Autocasts: " .. meter.autocastSnapshot, "debug") + + -- Per-ability breakdown, sorted by damage + local sorted = {} + local sortN = 0 + for name, data in pairs(meter.abilities) do + sortN = sortN + 1 + sorted[sortN] = { name = name, dmg = data.dmg, hits = data.hits, crits = data.crits, misses = data.misses } + end + table.sort(sorted, function(a, b) return a.dmg > b.dmg end) + for i = 1, sortN do + local e = sorted[i] + local uses = e.hits + e.crits + e.misses + local pct = (meter.totalDmg > 0) and (100 * e.dmg / meter.totalDmg) or 0 + MTH:Print(string.format( + " %s: %d dmg (%.0f%%) %d uses (H:%d C:%d M:%d)", + e.name, e.dmg, pct, uses, e.hits, e.crits, e.misses), "debug") + end +end + +-- ── Combat lifecycle ───────────────────────────────────────── +local function OnCombatStart() + if not IsEnabled() then return end + ScanPetActionBar() + rt.inCombat = true + rt.theButtonRecall = false + rt.autoCowered = false + rt.lastWarnTime = 0 + rt.lastWarnPct = (100 * (UnitHealth("pet") or 0)) / math.max(UnitHealthMax("pet") or 1, 1) + + -- Reset meter on new fight (only for untimed meters; timed meters keep recording across fights) + if meter.active and meter.duration == 0 then + MeterReset() + end + + -- Record pre-combat autocast states for all managed DPS abilities + rt.preCombatAutocast = {} + for key, info in pairs(ABILITY_INFO) do + if MANAGED_DPS_TYPES[info.type] and rt.slot[key] and rt.slot[key] > 0 then + rt.preCombatAutocast[key] = GetAutocast(rt.slot[key]) + if MTH and MTH.Print then + MTH:Print("[SP] snapshot " .. key .. "=" .. (GetAutocast(rt.slot[key]) and "ON" or "OFF") .. " slot=" .. rt.slot[key] .. " cost=" .. ABILITY_INFO[key].cost, "debug") + end + end + end + + -- Smart Focus initial state: keep only the most expensive ability enabled + local cfg = GetCfg() + if cfg.smartFocus then + local managed = {} + local managedN = 0 + for key, wasOn in pairs(rt.preCombatAutocast) do + if wasOn and ABILITY_INFO[key] then + managedN = managedN + 1 + managed[managedN] = { key = key, cost = ABILITY_INFO[key].cost, slot = rt.slot[key] } + end + end + if MTH and MTH.Print then + MTH:Print("[SP] combatStart smartFocus=ON managedON=" .. managedN, "debug") + end + if managedN >= 2 then + table.sort(managed, function(a, b) return a.cost > b.cost end) + for i = 1, managedN do + local state = (i == 1) and "ON" or "OFF" + if MTH and MTH.Print then + MTH:Print("[SP] init " .. managed[i].key .. "=" .. state .. " (cost=" .. managed[i].cost .. ")", "debug") + end + SetAutocast(managed[i].slot, i == 1) + end + end + else + if MTH and MTH.Print then + MTH:Print("[SP] combatStart smartFocus=OFF", "debug") + end + end +end + +local function OnCombatEnd() + -- Print meter summary on combat end (only for untimed meters) + if meter.active and meter.duration == 0 and meter.totalDmg > 0 then + MeterPrintSummary() + end + + -- Restore all managed DPS autocasts to pre-combat state + if rt.preCombatAutocast then + for key, wasOn in pairs(rt.preCombatAutocast) do + if rt.slot[key] and rt.slot[key] > 0 then + SetAutocast(rt.slot[key], wasOn) + if MTH and MTH.Print then + MTH:Print("[SP] restore " .. key .. "=" .. (wasOn and "ON" or "OFF"), "debug") + end + end + end + end + + if rt.inPVP then EndPVP() end + + rt.inCombat = false + rt.autoCowered = false + rt.theButtonRecall = false + + -- Re-apply taunt mode (restores growl/cower to config state) + ApplyTauntMode() +end + +-- ── PetAttack wrapper (used by The Button and hook) ────────── +local MTH_SP_OriginalPetAttack = nil + +local function SmartPetAttack() + if not IsEnabled() then + if MTH_SP_OriginalPetAttack then MTH_SP_OriginalPetAttack() end + return + end + + local cfg = GetCfg() + + -- CC-Break check + if cfg.ccBreakCheck and UnitExists("target") then + if TargetHasBreakableCC() then + if cfg.ccBreakMode == "block" then + if UIErrorsFrame then + UIErrorsFrame:AddMessage("Breakable debuff found — pet attack blocked", 1.0, 0.3, 0.0, 1.0, UIERRORS_HOLD_TIME) + end + return + else + if UIErrorsFrame then + UIErrorsFrame:AddMessage("Warning: target has breakable CC", 1.0, 1.0, 0.0, 1.0, UIERRORS_HOLD_TIME) + end + end + end + end + + -- PVP check + if UnitExists("target") and UnitIsPlayer("target") and UnitCanAttack("player", "target") then + StartPVP() + end + + -- Dash/Dive on attack + CastChargeAbility() + + -- Issue the actual PetAttack + if MTH_SP_OriginalPetAttack then MTH_SP_OriginalPetAttack() end +end + +-- ── The Button ─────────────────────────────────────────────── +-- Global function called from Bindings.xml +function MTH_SmartPet_TheButton() + if not IsEnabled() then return end + local cfg = GetCfg() + if not cfg.theButton then return end + + -- NoChase recall pending + if rt.theButtonRecall then + PetFollow() + rt.theButtonRecall = false + return + end + + -- No pet alive + if not UnitExists("pet") or UnitIsDead("pet") then return end + + -- No target or dead target → recall + if not UnitExists("target") or UnitIsDead("target") then + PetFollow() + return + end + + -- Friendly target → assist (attack their target) + if UnitIsPlayer("target") and UnitCanCooperate("player", "target") then + if UnitExists("targettarget") and not UnitIsDead("targettarget") + and UnitCanAttack("player", "targettarget") then + AssistUnit("target") + SmartPetAttack() + end + return + end + + -- Enemy target → attack + if UnitCanAttack("player", "target") and not UnitIsDead("target") then + SmartPetAttack() + return + end + + -- Fallback: recall + PetFollow() +end + +-- ── Taunt toggle (keybind) ─────────────────────────────────── +function MTH_SmartPet_TauntToggle() + if not IsEnabled() then return end + local growlOn = GetAutocast(rt.slot.growl) + local cowerOn = GetAutocast(rt.slot.cower) + + if growlOn then + -- Switch to cower + SetAutocast(rt.slot.growl, false) + if rt.slot.cower > 0 then SetAutocast(rt.slot.cower, true) end + elseif cowerOn then + -- Switch to growl + SetAutocast(rt.slot.cower, false) + if rt.slot.growl > 0 then SetAutocast(rt.slot.growl, true) end + else + -- Neither on: enable growl + if rt.slot.growl > 0 then SetAutocast(rt.slot.growl, true) end + end +end + +-- ── Event frame ────────────────────────────────────────────── +local frame = CreateFrame("Frame", "MTH_SmartPetFrame") +frame:Hide() + +-- Meter auto-stop ticker (runs only while meter is active with a duration) +local meterTickFrame = CreateFrame("Frame") +meterTickFrame:Hide() +meterTickFrame:SetScript("OnUpdate", function() + if not meter.active or not meter.recording then + meterTickFrame:Hide() + return + end + if meter.duration > 0 and (GetTime() - meter.startTime) >= meter.duration then + MeterStop() + meterTickFrame:Hide() + end +end) + +frame:RegisterEvent("VARIABLES_LOADED") +frame:RegisterEvent("PET_BAR_UPDATE") +frame:RegisterEvent("PET_UI_UPDATE") +frame:RegisterEvent("PET_ATTACK_START") +frame:RegisterEvent("PET_ATTACK_STOP") +frame:RegisterEvent("PLAYER_DEAD") +frame:RegisterEvent("UNIT_HEALTH") +frame:RegisterEvent("UNIT_FOCUS") +frame:RegisterEvent("PARTY_MEMBERS_CHANGED") +frame:RegisterEvent("RAID_ROSTER_UPDATE") +frame:RegisterEvent("CHAT_MSG_MONSTER_EMOTE") +frame:RegisterEvent("PLAYER_ENTERING_WORLD") +frame:RegisterEvent("CHAT_MSG_SPELL_PET_DAMAGE") +frame:RegisterEvent("CHAT_MSG_COMBAT_PET_HITS") +frame:RegisterEvent("CHAT_MSG_COMBAT_PET_MISSES") + +frame:SetScript("OnEvent", function() + -- Meter events bypass the module-enabled check + if event == "CHAT_MSG_SPELL_PET_DAMAGE" then + if meter.recording then + MTH:Print("[SP Meter] " .. event .. ": " .. (arg1 or "nil"), "debug") + MeterParseCombatMsg(arg1) + end + return + end + if event == "CHAT_MSG_COMBAT_PET_HITS" or event == "CHAT_MSG_COMBAT_PET_MISSES" then + if meter.recording then + MTH:Print("[SP Meter] " .. event .. ": " .. (arg1 or "nil"), "debug") + MeterParsePetMelee(arg1) + end + return + end + + if not IsEnabled() then + -- Even with module off, handle meter start/stop (untimed only) + if meter.active and meter.duration == 0 then + if event == "PET_ATTACK_START" then + MeterReset() + elseif event == "PET_ATTACK_STOP" or event == "PLAYER_DEAD" then + if meter.totalDmg > 0 then MeterPrintSummary() end + end + end + return + end + + if event == "VARIABLES_LOADED" or event == "PLAYER_ENTERING_WORLD" then + ResolveLocaleStrings() + ScanPetActionBar() + ScanPetSpellbook() + ScanPartyForTank() + -- Hook PetAttack once + if not MTH_SP_OriginalPetAttack and type(PetAttack) == "function" then + MTH_SP_OriginalPetAttack = PetAttack + PetAttack = SmartPetAttack + end + ApplyTauntMode() + return + end + + if event == "PET_BAR_UPDATE" or event == "PET_UI_UPDATE" then + ScanPetActionBar() + ScanPetSpellbook() + return + end + + if event == "PET_ATTACK_START" then + OnCombatStart() + return + end + + if event == "PET_ATTACK_STOP" or event == "PLAYER_DEAD" then + OnCombatEnd() + return + end + + if event == "UNIT_FOCUS" and arg1 == "pet" then + HandleFocusEvent() + return + end + + if event == "UNIT_HEALTH" and arg1 == "pet" then + HandlePetHealth() + return + end + + if event == "PARTY_MEMBERS_CHANGED" or event == "RAID_ROSTER_UPDATE" then + ScanPartyForTank() + if not rt.inCombat then + ApplyTauntMode() + end + return + end + + if event == "CHAT_MSG_MONSTER_EMOTE" then + HandleMonsterEmote(arg1, arg2) + return + end +end) + +-- ── Public API (for options panel and external access) ──────── +MTH_SmartPet = { + GetConfig = GetCfg, + + SetTauntMode = function(mode) + local cfg = GetCfg() + if mode == "auto" or mode == "growl" or mode == "cower" or mode == "off" then + cfg.tauntMode = mode + if not rt.inCombat then ApplyTauntMode() end + end + end, + + SetPvpDetaunt = function(v) GetCfg().pvpDetaunt = v and true or false end, + + SetCcBreakCheck = function(v) GetCfg().ccBreakCheck = v and true or false end, + SetCcBreakMode = function(mode) + if mode == "block" or mode == "warn" then + GetCfg().ccBreakMode = mode + end + end, + + SetTheButton = function(v) GetCfg().theButton = v and true or false end, + SetSmartFocus = function(v) GetCfg().smartFocus = v and true or false end, + + SetAutoCower = function(v) GetCfg().autoCower = v and true or false end, + SetAutoCowerPct = function(v) + v = tonumber(v) + if v and v >= 1 and v <= 99 then GetCfg().autoCowerPct = v end + end, + + SetNoChase = function(v) GetCfg().noChase = v and true or false end, + SetRushOnAttack = function(v) GetCfg().rushOnAttack = v and true or false end, + + SetAutoWarn = function(v) GetCfg().autoWarn = v and true or false end, + SetAutoWarnPct = function(v) + v = tonumber(v) + if v and v >= 1 and v <= 99 then GetCfg().autoWarnPct = v end + end, + SetAutoWarnChannel = function(chan) + chan = string.upper(tostring(chan or "SAY")) + if chan == "SAY" or chan == "PARTY" or chan == "RAID" or chan == "GUILD" then + GetCfg().autoWarnChannel = chan + end + end, + + -- Force a rescan (useful after pet swap) + Rescan = function() + ScanPetActionBar() + ScanPetSpellbook() + ScanPartyForTank() + if not rt.inCombat then ApplyTauntMode() end + end, + + -- Damage meter (duration=0 for unlimited, >0 for timed auto-stop) + StartMeter = function(duration) + meter.active = true + meter.duration = duration or 0 + MeterReset() + if duration and duration > 0 then + meterTickFrame:Show() + MTH:Print(string.format("|cffff9900[SP Meter]|r ON — recording for %ds.", duration)) + else + MTH:Print("|cffff9900[SP Meter]|r ON — /sp meter to stop.") + end + end, + + StopMeter = function() + if meter.active then + MeterStop() + meterTickFrame:Hide() + else + MTH:Print("|cffff9900[SP Meter]|r Not running.") + end + end, + + -- Reprint last summary + PrintMeter = function() + if meter.totalDmg > 0 then + MeterPrintSummary() + else + MTH:Print("|cffff9900[SP Meter]|r No data recorded.") + end + end, +} + +-- ── Register with MTH framework so IsModuleEnabled works ───── +local MTH_SmartPetModule = { + name = "smartpet", + enabled = false, + events = {}, +} + +function MTH_SmartPetModule:init() + self.initialized = true +end + +function MTH_SmartPetModule:setEnabled(enabled) + -- No-op: SmartPet checks IsEnabled() on every event +end + +if MTH and MTH.RegisterModule then + MTH:RegisterModule("smartpet", MTH_SmartPetModule) +end + +-- ── Slash command ──────────────────────────────────────────── +SLASH_MTHSmartPet1 = "/sp" +SlashCmdList["MTHSmartPet"] = function(msg) + msg = string.lower(msg or "") + if msg == "meter" then + if meter.active then + MTH_SmartPet.StopMeter() + else + MTH_SmartPet.StartMeter(0) + end + elseif msg == "meter print" then + MTH_SmartPet.PrintMeter() + else + -- "/sp meter 60" — timed start + local _, _, secs = strfind(msg, "^meter (%d+)$") + if secs then + MTH_SmartPet.StartMeter(tonumber(secs)) + else + MTH:Print("|cffff9900[SmartPet]|r /sp meter — toggle damage meter") + MTH:Print("|cffff9900[SmartPet]|r /sp meter 60 — record for 60s then stop") + MTH:Print("|cffff9900[SmartPet]|r /sp meter print — reprint last fight") + end + end +end diff --git a/modules/smartpet/loader.xml b/modules/smartpet/loader.xml new file mode 100644 index 0000000..6d68df7 --- /dev/null +++ b/modules/smartpet/loader.xml @@ -0,0 +1,6 @@ + + + + diff --git a/modules/zhunter/ZSpellButtonTemplate.lua b/modules/zhunter/ZSpellButtonTemplate.lua index f804208..e5b5ca0 100644 --- a/modules/zhunter/ZSpellButtonTemplate.lua +++ b/modules/zhunter/ZSpellButtonTemplate.lua @@ -219,7 +219,7 @@ function ZSpellButton_SetSize(parent, size, setChildren) local cdscale = 0.75 / 36 if setChildren then local button - for i=1, parent.count do + for i=1, (parent.count or 0) do button = getglobal(parent.name..i) if button then button:SetHeight(size) diff --git a/modules/zhunter/zButtonAmmo.lua b/modules/zhunter/zButtonAmmo.lua index 2629c9c..4786c7f 100644 --- a/modules/zhunter/zButtonAmmo.lua +++ b/modules/zhunter/zButtonAmmo.lua @@ -38,7 +38,6 @@ local zButtonAmmo_LastFullScanAt = 0 local zButtonAmmo_MinUnknownBagScanInterval = 5.0 local zButtonAmmo_EmptyScanStreak = 0 local zButtonAmmo_LastEquippedAmmoSeen = nil -local zButtonAmmo_GetKnownAmmoTypes local function zButtonAmmo_BagHasAmmoNow(bagId) local bag = tonumber(bagId) @@ -117,7 +116,7 @@ zButtonAmmo_GetKnownAmmoTypes = function() return knownAmmo end -local zButtonAmmo_ShortArrowLabels = { +zButtonAmmo_ShortArrowLabels = { [ARROWS_ROUGH] = "Rough", [ARROWS_SHARP] = "Sharp", [ARROWS_RAZOR] = "Razor", @@ -131,7 +130,7 @@ local zButtonAmmo_ShortArrowLabels = { [ARROWS_DOOMSHOT] = "Doom", } -local zButtonAmmo_ShortBulletLabels = { +zButtonAmmo_ShortBulletLabels = { [BULLETS_LIGHT] = "Light", [BULLETS_CRAFTLIGHT] = "C.Light", [BULLETS_FLASH] = "Flash", @@ -151,7 +150,7 @@ local zButtonAmmo_ShortBulletLabels = { [BULLETS_ICETHREADED] = "Ice", } -local function zButtonAmmo_GetColorGradient(perc) +function zButtonAmmo_GetColorGradient(perc) perc = perc > 1 and 1 or perc perc = perc < 0 and 0 or perc local r1, g1, b1, r2, g2, b2 @@ -188,7 +187,7 @@ local function zButtonAmmo_EnsureShortLabelText(button) return button and button.ammoShortLabelText end -local function zButtonAmmo_GetQualityColor(quality) +function zButtonAmmo_GetQualityColor(quality) local tableRef = MTH_ITEM_QUALITY_COLORS or ITEM_QUALITY_COLORS if tableRef then local q = tonumber(quality)