mirror of
https://codeberg.org/Duvelcorp/MetaHunt.git
synced 2026-09-22 15:46:59 +00:00
1294 lines
41 KiB
Lua
1294 lines
41 KiB
Lua
------------------------------------------------------
|
|
-- 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: "<Name>'s <Spell> hits <target> for <N>."
|
|
_, _, 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: "<Name>'s <Spell> crits <target> for <N>."
|
|
_, _, 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: "<Name>'s <Spell> misses" / "<Name>'s <Spell> 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: "<Name> hits <target> for <N>."
|
|
_, _, 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: "<Name> crits <target> for <N>."
|
|
_, _, 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: "<Name> misses <target>."
|
|
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
|