From 27d37564ce4d656d995e0fa4753722f0cfbdc1c9 Mon Sep 17 00:00:00 2001 From: DuvelCorp Date: Mon, 9 Mar 2026 13:41:30 +0100 Subject: [PATCH] v1.1.0 --- CHANGELOG.md | 32 + MetaHunt.toc | 2 +- README.MD | 16 +- api/AutoStrip.lua | 152 +++ api/AutoStrip.xml | 6 + api/constants.lua | 2 +- api/core-feed-tracking.lua | 112 ++- api/core-framework.lua | 25 +- api/core-scan-spellbookpet.lua | 66 +- api/core-scan-stablemaster.lua | 1166 +++++++++++++++++++--- api/core.lua | 61 +- api/hunterbook.lua | 98 +- api/localization.lua | 3 + api/options-autobuy.lua | 23 +- api/options-autoquest.lua | 232 +++++ api/options-chronometer.lua | 29 +- api/options-credits.lua | 10 + api/options-icu.lua | 598 +++++++++++ api/options-shell.lua | 15 + api/options-tree.lua | 4 + api/options-zbuttons.lua | 62 +- api/options.xml | 2 + api/pets-service-lifecycle.lua | 19 + api/pets-service-runaway.lua | 10 + api/pets-service-stablescan.lua | 10 + api/pets-service-vitals.lua | 10 + init/api.xml | 6 + init/modules.xml | 6 + modules/autobuy/engine.lua | 8 +- modules/autobuy/module.lua | 2 +- modules/autoquest/loader.xml | 6 + modules/autoquest/module.lua | 987 ++++++++++++++++++ modules/chronometer/data-hunter.lua | 3 +- modules/chronometer/engine.lua | 323 +++++- modules/chronometer/module.lua | 22 +- modules/feedomatic/FeedOMatic.lua | 1058 ++++++++++++++++++-- modules/feedomatic/module.lua | 4 +- modules/icu/MTH_ICU.lua | 1127 +++++++++++++++++++++ modules/icu/MTH_ICU.xml | 320 ++++++ modules/icu/loader.xml | 8 + modules/icu/module.lua | 52 + modules/smartammo/module.lua | 2 +- modules/tooltips/module.lua | 242 ++++- modules/zhunter/ZHunterMod.lua | 67 ++ modules/zhunter/ZSpellButtonTemplate.lua | 128 +++ modules/zhunter/module.lua | 37 +- modules/zhunter/zButtonAmmo.lua | 12 +- 47 files changed, 6786 insertions(+), 399 deletions(-) create mode 100644 api/options-autoquest.lua create mode 100644 api/options-icu.lua create mode 100644 api/pets-service-lifecycle.lua create mode 100644 api/pets-service-runaway.lua create mode 100644 api/pets-service-stablescan.lua create mode 100644 api/pets-service-vitals.lua create mode 100644 modules/autoquest/loader.xml create mode 100644 modules/autoquest/module.lua create mode 100644 modules/icu/MTH_ICU.lua create mode 100644 modules/icu/MTH_ICU.xml create mode 100644 modules/icu/loader.xml create mode 100644 modules/icu/module.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a0f4a0..95b95ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ All notable changes to MetaHunt will be documented in this file. +## [1.1.0] - 2026-03-09 + +### Added +- Added new `ICU` module with dedicated options panel. +- Added ICU custom popup anchor mode with draggable anchor and direction toggle. +- Added ICU popup hide delay options (`INSTANT`, 1..10 seconds). +- Added new `Auto Quest` module for : + - `Salt of the Scorpok` (`Bloodmage Drazial`) + - `Arrows Are For Sissies` (`Artilleryman Sheldonore`) +- Added a Food Feed learning feature to Feed-O-Matic. +- Added Chronometer `Entrapment` effect tracking support. + +### Changed +- Loads of Frame/Handlers/Processes adjustments to optimize addon's CPU time and Memory usage. +- Updated Chronometer Improved Wing Clip effect color to `MAROON` for clearer effect-bar distinction. +- Reworked Stable Master processing to minimize swap-time overhead: + - No auto-scan on `PET_STABLE_SHOW`. + - Deferred stable scan moved to `PET_STABLE_CLOSED`. + - Optional heavy workloads are skipped while stable UI is open. +- Changed Hunter Book page order, and default opening page to `Beast Lore`. +- Optimized `Pet History` result build/sort path using cached precomputed sort keys. +- Modules Auto-Buy, Auto-Quest, ICU, Feed-O-Matic are by default disabled on first use on a character. + +### Fixed +- Fixed issues with zBouttons,Autostrp and Chronometer bar anchor not retaining their configured spawn position after the disabling/renabling of the Addon. +- Fixed issues with options checkbox-state not always retaining their state on startup. +- Fixed multiple issues in Feeed-O-Matic with some foods that were accepted by lvl 60 pets in Vanilla and are not anymore on Turtle. +- Fixed Chronometer spell/event disable toggles in options so disabled entries now reliably block bar creation. +- Fixed Chronometer `Feed Pet` bar missing trigger by adding `UNIT_AURA` fallback detection. +- Fixed stable swap stutter/freezes by removing heavy processing from repeated `UNIT_PET` bursts during stable interactions. + + ## [1.0.6] - 2026-03-05 ### Fixed diff --git a/MetaHunt.toc b/MetaHunt.toc index c0465ba..e20a2fe 100644 --- a/MetaHunt.toc +++ b/MetaHunt.toc @@ -1,5 +1,5 @@ ## Interface: 11200 -## Version: 1.0.6 +## Version: 1.1.0 ## Title: MetaHunt - |cff00ff00Hunter ## Author: Metasploit and his Copilot ;) ## Notes: Unified addon suite for huntards making old addons compatible with TurtleWoW, and adding an arsenal of never seen Hunter's utilities. diff --git a/README.MD b/README.MD index 0db418d..8d41fe3 100644 --- a/README.MD +++ b/README.MD @@ -1,8 +1,8 @@ # MetaHunt MetaHunt is a Turtle WoW Hunter toolkit. -It combines never-seen pet-centric and beast tracking features, and revives old broken hunter add-ons. -It provides usefull tools for all hunters, wheter they are still leveling, lone-wolf, or HL raider. +It is a modern modular addon that combines never-seen features, and improves/fixes a few old hunter add-ons. +It provides a bunch usefull tools for all huntards, wheter they are still leveling, lone-wolf, or HL raider. ## Twow Data @@ -48,10 +48,20 @@ Now compatible with Turtle, feed your pet with one key bind, it will always pick The good old Chronometer addon, purged of non-necessary things and reconfigured for a pure hunter use. - ## Autobuy + ## ICU (I see you) + +Old addon used to display Targets' enhanced information when you click on your minimap tracking things. MetaHunt offers many more customization options for it. + + + ## Auto-Buy Define your own simple rules for auto buy ammo and pet food when you open a vendor. + ## Auto-Quest + +Just spam SHIFT-click on the NPC to validate quests Scorpok and Thorium arrows. Addionally you can have an enhanced tooltip that display your actual Scorpok-related items when you mouseover the mobs involved. + + ## The book of Hunters The book of Hunters is the main GUI of Metahunt, it allows to display the data constantly collected about you and your pet, and browse the addon's data to find anything a Hunter needs. diff --git a/api/AutoStrip.lua b/api/AutoStrip.lua index ce17ff3..780d1f1 100644 --- a/api/AutoStrip.lua +++ b/api/AutoStrip.lua @@ -6,6 +6,29 @@ local function AutoStrip_Print(msg) end end +local AutoStrip_TraceEnabled = false + +local function AutoStrip_Trace(msg) + if not AutoStrip_TraceEnabled then + return + end + AutoStrip_Print("[ASTRACE] " .. tostring(msg)) +end + +function AutoStrip_CommandTrace(mode) + local action = string.lower(tostring(mode or "status")) + if action == "on" or action == "1" or action == "true" then + AutoStrip_TraceEnabled = true + AutoStrip_Trace("trace enabled") + return + elseif action == "off" or action == "0" or action == "false" then + AutoStrip_Trace("trace disabled") + AutoStrip_TraceEnabled = false + return + end + AutoStrip_Print("[ASTRACE] status=" .. tostring(AutoStrip_TraceEnabled and true or false)) +end + function AutoStrip_GetSaved() if type(MTH_CharSavedVariables) ~= "table" then MTH_CharSavedVariables = {} @@ -20,6 +43,90 @@ local AUTO_STRIP_ORDER = {16, 17, 18, 5, 7, 1, 3, 10, 8, 6, 9} AutoStrip_On = nil AutoStrip_WasInCombat = nil AutoStrip_Frame = nil +local AutoStrip_RestoreFrame = nil + +local function AutoStrip_DescribePoint(frame) + if not (frame and frame.GetPoint) then + return "" + end + local point, relTo, relPoint, x, y = frame:GetPoint() + local relName = "nil" + if type(relTo) == "table" and relTo.GetName then + relName = tostring(relTo:GetName() or "") + elseif relTo then + relName = tostring(relTo) + end + return tostring(point or "nil") + .. "->" .. relName + .. ":" .. tostring(relPoint or "nil") + .. " @(" .. tostring(x) .. "," .. tostring(y) .. ")" +end + +function AutoStrip_SaveDisplayPosition(reason) + if not (AutoStripDisplay and AutoStripDisplay.GetPoint) then + return + end + local saved = AutoStrip_GetSaved() + local point, _, relPoint, x, y = AutoStripDisplay:GetPoint() + x = tonumber(x) + y = tonumber(y) + if not (point and relPoint and x and y) then + return + end + saved["point"] = tostring(point) + saved["relativePoint"] = tostring(relPoint) + saved["x"] = math.floor(x + 0.5) + saved["y"] = math.floor(y + 0.5) + AutoStrip_Trace("persist-display-position reason=" .. tostring(reason or "") + .. " point=" .. tostring(saved["point"]) + .. " rel=" .. tostring(saved["relativePoint"]) + .. " x=" .. tostring(saved["x"]) + .. " y=" .. tostring(saved["y"])) +end + +local function AutoStrip_RestoreDisplayPosition(reason) + if not (AutoStripDisplay and AutoStripDisplay.ClearAllPoints and AutoStripDisplay.SetPoint) then + return false + end + local saved = AutoStrip_GetSaved() + local point = tostring(saved["point"] or "") + local relPoint = tostring(saved["relativePoint"] or "") + local x = tonumber(saved["x"]) + local y = tonumber(saved["y"]) + if point == "" or not x or not y then + AutoStrip_Trace("restore-display-position skipped reason=" .. tostring(reason or "") + .. " savedPoint=" .. tostring(saved["point"]) + .. " savedX=" .. tostring(saved["x"]) + .. " savedY=" .. tostring(saved["y"])) + return false + end + if relPoint == "" then + relPoint = point + end + AutoStripDisplay:ClearAllPoints() + AutoStripDisplay:SetPoint(point, UIParent, relPoint, x, y) + AutoStrip_Trace("restore-display-position reason=" .. tostring(reason or "") + .. " frame=" .. AutoStrip_DescribePoint(AutoStripDisplay)) + return true +end + +local function AutoStrip_QueueDeferredRestore(reason) + if not AutoStrip_RestoreFrame then + AutoStrip_RestoreFrame = CreateFrame("Frame", "AutoStripRestoreFrame") + if not AutoStrip_RestoreFrame then + return + end + end + AutoStrip_RestoreFrame._elapsed = 0 + AutoStrip_RestoreFrame:SetScript("OnUpdate", function() + this._elapsed = (this._elapsed or 0) + (arg1 or 0) + if this._elapsed < 0.2 then + return + end + this:SetScript("OnUpdate", nil) + AutoStrip_RestoreDisplayPosition("deferred:" .. tostring(reason or "")) + end) +end function AutoStrip_SetDisplayIcon() local icon = getglobal("AutoStripDisplayIcon") or getglobal("AutoStripDisplayIconTexture") @@ -35,6 +142,11 @@ end function AutoStrip_SetAutoStripToggle(enabled, silent) local saved = AutoStrip_GetSaved() + AutoStrip_Trace("SetAutoStripToggle enabled=" .. tostring(enabled and true or false) + .. " silent=" .. tostring(silent and true or false) + .. " savedBefore=" .. tostring(saved["autostrip"] and 1 or 0) + .. " runtimeBefore=" .. tostring(AutoStrip_On and 1 or 0) + .. " castShownBefore=" .. tostring((AutoStripDisplayAutoCast and AutoStripDisplayAutoCast:IsVisible()) and 1 or 0)) if enabled then saved["autostrip"] = 1 AutoStrip_On = 1 @@ -54,10 +166,17 @@ function AutoStrip_SetAutoStripToggle(enabled, silent) AutoStrip_Print("Auto-Strip disabled.") end end + AutoStrip_Trace("SetAutoStripToggle savedAfter=" .. tostring(saved["autostrip"] and 1 or 0) + .. " runtimeAfter=" .. tostring(AutoStrip_On and 1 or 0) + .. " castShownAfter=" .. tostring((AutoStripDisplayAutoCast and AutoStripDisplayAutoCast:IsVisible()) and 1 or 0)) end function AutoStrip_SetDisplayToggle(enabled, silent) local saved = AutoStrip_GetSaved() + AutoStrip_Trace("SetDisplayToggle enabled=" .. tostring(enabled and true or false) + .. " silent=" .. tostring(silent and true or false) + .. " savedDisplayBefore=" .. tostring(saved["display"] and 1 or 0) + .. " frameShownBefore=" .. tostring((AutoStripDisplay and AutoStripDisplay:IsVisible()) and 1 or 0)) if enabled then saved["display"] = 1 if AutoStripDisplay then @@ -79,9 +198,15 @@ function AutoStrip_SetDisplayToggle(enabled, silent) AutoStrip_Print("AutoStrip button is now hidden.") end end + AutoStrip_Trace("SetDisplayToggle savedDisplayAfter=" .. tostring(saved["display"] and 1 or 0) + .. " frameShownAfter=" .. tostring((AutoStripDisplay and AutoStripDisplay:IsVisible()) and 1 or 0) + .. " castShownAfter=" .. tostring((AutoStripDisplayAutoCast and AutoStripDisplayAutoCast:IsVisible()) and 1 or 0)) end function AutoStrip_UnequipAll(weaponsOnly) + AutoStrip_Trace("UnequipAll called weaponsOnly=" .. tostring(weaponsOnly and 1 or 0) + .. " runtimeBefore=" .. tostring(AutoStrip_On and 1 or 0) + .. " wasInCombat=" .. tostring(AutoStrip_WasInCombat and 1 or 0)) AutoStrip_On = nil if AutoStripDisplayAutoCast then AutoStripDisplayAutoCast:Hide() @@ -102,6 +227,7 @@ function AutoStrip_UnequipAll(weaponsOnly) if not hasEmptySlot then AutoStrip_Print("You need at least one empty bag slot to auto-strip.") + AutoStrip_Trace("UnequipAll aborted no-empty-slot") return end @@ -121,19 +247,36 @@ function AutoStrip_UnequipAll(weaponsOnly) end end end + AutoStrip_Trace("UnequipAll completed runtimeAfter=" .. tostring(AutoStrip_On and 1 or 0) + .. " castShownAfter=" .. tostring((AutoStripDisplayAutoCast and AutoStripDisplayAutoCast:IsVisible()) and 1 or 0)) end local function AutoStrip_OnEvent() + AutoStrip_Trace("OnEvent evt=" .. tostring(event or "") + .. " runtime=" .. tostring(AutoStrip_On and 1 or 0) + .. " wasInCombat=" .. tostring(AutoStrip_WasInCombat and 1 or 0) + .. " playerInCombat=" .. tostring((type(UnitAffectingCombat) == "function" and UnitAffectingCombat("player")) and 1 or 0)) if event == "VARIABLES_LOADED" then local saved = AutoStrip_GetSaved() + AutoStrip_Trace("VARIABLES_LOADED saved.autostrip=" .. tostring(saved["autostrip"] and 1 or 0) + .. " saved.display=" .. tostring(saved["display"] and 1 or 0)) AutoStrip_WasInCombat = UnitAffectingCombat("player") and 1 or nil + AutoStrip_RestoreDisplayPosition("VARIABLES_LOADED") AutoStrip_SetDisplayToggle(saved["display"] and true or false, 1) AutoStrip_SetAutoStripToggle(saved["autostrip"] and true or false, 1) + AutoStrip_QueueDeferredRestore("VARIABLES_LOADED") return end if event == "PLAYER_ENTERING_WORLD" then AutoStrip_WasInCombat = UnitAffectingCombat("player") and 1 or nil + AutoStrip_RestoreDisplayPosition("PLAYER_ENTERING_WORLD") + AutoStrip_QueueDeferredRestore("PLAYER_ENTERING_WORLD") + return + end + + if event == "PLAYER_LOGOUT" then + AutoStrip_SaveDisplayPosition("PLAYER_LOGOUT") return end @@ -144,6 +287,7 @@ local function AutoStrip_OnEvent() if event == "PLAYER_REGEN_ENABLED" then if AutoStrip_On and AutoStrip_WasInCombat then + AutoStrip_Trace("PLAYER_REGEN_ENABLED triggering auto strip") AutoStrip_UnequipAll() end AutoStrip_WasInCombat = nil @@ -152,6 +296,8 @@ end local function AutoStrip_HandleSlash(msg) local saved = AutoStrip_GetSaved() + AutoStrip_Trace("Slash msg='" .. tostring(msg or "") .. "' saved.autostrip=" .. tostring(saved["autostrip"] and 1 or 0) + .. " runtime=" .. tostring(AutoStrip_On and 1 or 0)) if msg == "display" then AutoStrip_SetDisplayToggle(not (saved["display"] and true or false)) elseif msg == "toggle" then @@ -176,6 +322,7 @@ if AutoStrip_Frame then AutoStrip_Frame:RegisterEvent("PLAYER_REGEN_DISABLED") AutoStrip_Frame:RegisterEvent("PLAYER_REGEN_ENABLED") AutoStrip_Frame:RegisterEvent("PLAYER_ENTERING_WORLD") + AutoStrip_Frame:RegisterEvent("PLAYER_LOGOUT") AutoStrip_Frame:SetScript("OnEvent", AutoStrip_OnEvent) end @@ -187,3 +334,8 @@ SLASH_AUTOSTRIP1 = "/autostrip" SlashCmdList["AUTOSTRIP"] = AutoStrip_HandleSlash SLASH_ZSTRIP1 = "/zstrip" SlashCmdList["ZSTRIP"] = AutoStrip_HandleSlash +SLASH_AUTOSTRIPTRACE1 = "/autostriptrace" +SLASH_AUTOSTRIPTRACE2 = "/astrace" +SlashCmdList["AUTOSTRIPTRACE"] = function(msg) + AutoStrip_CommandTrace(msg) +end diff --git a/api/AutoStrip.xml b/api/AutoStrip.xml index b7646ff..820574d 100644 --- a/api/AutoStrip.xml +++ b/api/AutoStrip.xml @@ -68,6 +68,9 @@ if ( this.isMoving ) then this:StopMovingOrSizing() this.isMoving = false + if AutoStrip_SaveDisplayPosition then + AutoStrip_SaveDisplayPosition("mouse-up-stop") + end end @@ -79,6 +82,9 @@ this:StopMovingOrSizing() this.isMoving = false + if AutoStrip_SaveDisplayPosition then + AutoStrip_SaveDisplayPosition("drag-stop") + end diff --git a/api/constants.lua b/api/constants.lua index 87f10b1..fdf66ca 100644 --- a/api/constants.lua +++ b/api/constants.lua @@ -1,5 +1,5 @@ MTH_CONST = MTH_CONST or {} -MTH_CONST.version = MTH_CONST.version or "1.0.6" +MTH_CONST.version = MTH_CONST.version or "1.1.0" MTH_CONST.WEAPON_TYPES = { BOWS = "Bows", diff --git a/api/core-feed-tracking.lua b/api/core-feed-tracking.lua index 08e2126..f3feb06 100644 --- a/api/core-feed-tracking.lua +++ b/api/core-feed-tracking.lua @@ -944,9 +944,6 @@ function MTH_FEED_BeginAttempt(payload) itemIcon = infoTexture end end - if (not itemIcon or itemIcon == "") then - itemIcon = MTH_FEED_FindItemIconInBags(itemId, payload and payload.itemName) - end MTH_FEED_Runtime.attempts[attemptId] = { id = attemptId, @@ -1117,7 +1114,7 @@ function MTH_FEED_IsFoodCompatible(petLevel, foodLevel) return true end -function MTH_FEED_IsExceptionReject(itemId) +function MTH_FEED_IsExceptionReject(itemId, petLevel) local key = tonumber(itemId) if not key then return false @@ -1127,6 +1124,11 @@ function MTH_FEED_IsExceptionReject(itemId) if type(row) ~= "table" then return false end + local numericPet = tonumber(petLevel) + local blockedAtOrAbove = tonumber(row.blockAtOrAbovePetLevel) + if blockedAtOrAbove and numericPet and numericPet >= blockedAtOrAbove then + return true + end if row.anomalyReject ~= true then return false end @@ -1141,6 +1143,17 @@ function MTH_FEED_IsExceptionReject(itemId) if not reasons then return false end + local lowLevelCount = tonumber(reasons.lowLevel) or 0 + if lowLevelCount >= 1 then + local maxPetLevel = tonumber(row.maxPetLevel) + if numericPet and maxPetLevel then + if numericPet > maxPetLevel then + return true + end + else + return true + end + end local wrongFoodCount = tonumber(reasons.wrongFood) or 0 if wrongFoodCount < 2 then return false @@ -1148,6 +1161,49 @@ function MTH_FEED_IsExceptionReject(itemId) return true end +function MTH_FEED_BlockItemForPetLevel(itemId, petLevel, reason) + local key = tonumber(itemId) + local numericPet = tonumber(petLevel) + if not key or not numericPet then + return false + end + if numericPet < 1 then + numericPet = 1 + end + local store = MTH_FEED_EnsureStore() + if type(store.exceptions.byItemId[key]) ~= "table" then + store.exceptions.byItemId[key] = { + anomalyReject = true, + reasons = { noBuff = 0, wrongFood = 0, lowLevel = 0, unknown = 0 }, + lastObservedAt = 0, + } + end + local row = store.exceptions.byItemId[key] + if type(row.reasons) ~= "table" then + row.reasons = { noBuff = 0, wrongFood = 0, lowLevel = 0, unknown = 0 } + end + row.anomalyReject = true + row.lastObservedAt = MTH_FEED_Now() + if tonumber(row.blockAtOrAbovePetLevel) == nil or numericPet < tonumber(row.blockAtOrAbovePetLevel) then + row.blockAtOrAbovePetLevel = numericPet + end + local normalizedReason = MTH_FEED_NormalizeReason(reason, nil) + if normalizedReason == "no-buff" then + row.reasons.noBuff = (tonumber(row.reasons.noBuff) or 0) + 1 + elseif normalizedReason == "low-level" then + row.reasons.lowLevel = (tonumber(row.reasons.lowLevel) or 0) + 1 + elseif normalizedReason == "wrong-food" then + row.reasons.wrongFood = (tonumber(row.reasons.wrongFood) or 0) + 1 + else + row.reasons.unknown = (tonumber(row.reasons.unknown) or 0) + 1 + end + MTH_FEED_TouchStore(store) + MTH_FEED_Trace("BlockItemForPetLevel itemId=" .. tostring(key) + .. " blockAtOrAbove=" .. tostring(row.blockAtOrAbovePetLevel) + .. " reason=" .. tostring(normalizedReason)) + return true +end + function MTH_FEED_GetPetFeedStats(petId) local key = tostring(petId or "") if key == "" then @@ -1157,7 +1213,7 @@ function MTH_FEED_GetPetFeedStats(petId) return store.byPetId[key] end -local function MTH_FEED_UpsertException(store, itemId, reason) +local function MTH_FEED_UpsertException(store, itemId, reason, petLevel) if not itemId then return end @@ -1179,6 +1235,16 @@ local function MTH_FEED_UpsertException(store, itemId, reason) row.reasons.wrongFood = (tonumber(row.reasons.wrongFood) or 0) + 1 elseif reason == "low-level" then row.reasons.lowLevel = (tonumber(row.reasons.lowLevel) or 0) + 1 + local numericPetLevel = tonumber(petLevel) + if numericPetLevel and numericPetLevel > 1 then + local derivedMaxPetLevel = numericPetLevel - 1 + if derivedMaxPetLevel < 1 then + derivedMaxPetLevel = 1 + end + if tonumber(row.maxPetLevel) == nil or derivedMaxPetLevel < tonumber(row.maxPetLevel) then + row.maxPetLevel = derivedMaxPetLevel + end + end else row.reasons.unknown = (tonumber(row.reasons.unknown) or 0) + 1 end @@ -1186,6 +1252,36 @@ local function MTH_FEED_UpsertException(store, itemId, reason) row.lastObservedAt = MTH_FEED_Now() end +local function MTH_FEED_ClearStaleExceptionOnAccept(store, itemId) + if not itemId or type(store) ~= "table" or type(store.exceptions) ~= "table" or type(store.exceptions.byItemId) ~= "table" then + return + end + local key = tonumber(itemId) or itemId + local row = store.exceptions.byItemId[key] + if type(row) ~= "table" then + return + end + if type(row.reasons) ~= "table" then + store.exceptions.byItemId[key] = nil + return + end + row.reasons.noBuff = 0 + row.lastObservedAt = MTH_FEED_Now() + + local noBuff = tonumber(row.reasons.noBuff) or 0 + local lowLevel = tonumber(row.reasons.lowLevel) or 0 + local wrongFood = tonumber(row.reasons.wrongFood) or 0 + local unknown = tonumber(row.reasons.unknown) or 0 + if noBuff <= 0 + and lowLevel <= 0 + and wrongFood <= 0 + and unknown <= 0 + and tonumber(row.blockAtOrAbovePetLevel) == nil + and tonumber(row.maxPetLevel) == nil then + store.exceptions.byItemId[key] = nil + end +end + function MTH_FEED_FinalizeAttempt(attemptId, payload) local row = MTH_FEED_Runtime.attempts[attemptId] if type(row) ~= "table" then @@ -1245,6 +1341,7 @@ function MTH_FEED_FinalizeAttempt(attemptId, payload) if itemRow then itemRow.accepted = itemRow.accepted + 1 end + MTH_FEED_ClearStaleExceptionOnAccept(store, row.itemId) local petKey = tostring((petRow and petRow.petId) or row.petId or "") if petKey ~= "" then MTH_FEED_Runtime.sessionFeedsByPetId[petKey] = (tonumber(MTH_FEED_Runtime.sessionFeedsByPetId[petKey]) or 0) + 1 @@ -1261,7 +1358,7 @@ function MTH_FEED_FinalizeAttempt(attemptId, payload) if itemRow then itemRow.rejected = itemRow.rejected + 1 end - MTH_FEED_UpsertException(store, row.itemId, reason) + MTH_FEED_UpsertException(store, row.itemId, reason, row.petLevel) end if itemRow then @@ -1284,9 +1381,6 @@ function MTH_FEED_FinalizeAttempt(attemptId, payload) persistedIcon = directTexture end end - if (not persistedIcon or persistedIcon == "") then - persistedIcon = MTH_FEED_FindItemIconInBags(row.itemId, row.itemName) - end if persistedIcon and persistedIcon ~= "" then itemRow.itemIcon = persistedIcon end diff --git a/api/core-framework.lua b/api/core-framework.lua index 58546f9..77275f1 100644 --- a/api/core-framework.lua +++ b/api/core-framework.lua @@ -1,5 +1,5 @@ MTH = MTH or { - version = "1.0.6", + version = "1.1.0", name = "MetaHunt", modules = {}, config = {}, @@ -19,6 +19,13 @@ local MTH_MESSAGE_DEFAULTS = { stableScan = true, } +local MTH_MODULE_DEFAULT_STATES = { + autoquest = false, + autobuy = false, + feedomatic = false, + icu = false, +} + local function MTH_ClassGateTrace(_step, _detail) return end @@ -434,6 +441,9 @@ local function MTH_GetPersistedModuleEnabled(name, defaultEnabled) if type(MTH_CharSavedVariables.modules) ~= "table" then MTH_CharSavedVariables.modules = {} end + if type(MTH_CharSavedVariables.modules[resolvedName]) ~= "table" then + MTH_CharSavedVariables.modules[resolvedName] = {} + end if type(MTH_SavedVariables) ~= "table" then MTH_SavedVariables = {} @@ -445,6 +455,19 @@ local function MTH_GetPersistedModuleEnabled(name, defaultEnabled) MTH_SavedVariables.modules = {} end + local hardDefault = MTH_MODULE_DEFAULT_STATES[resolvedName] + if hardDefault ~= nil then + local hasCharState = MTH_CharSavedVariables.moduleStates[resolvedName] ~= nil + or MTH_CharSavedVariables.modules[resolvedName].enabled ~= nil + if not hasCharState then + local resolved = hardDefault and true or false + MTH_CharSavedVariables.moduleStates[resolvedName] = resolved + MTH_CharSavedVariables.modules[resolvedName].enabled = resolved + MTH_ModuleStateDebug("read " .. tostring(name) .. " seeded char default=" .. tostring(resolved)) + return resolved + end + end + if type(MTH_CharSavedVariables.moduleStates) == "table" then for i = 1, table.getn(candidates) do diff --git a/api/core-scan-spellbookpet.lua b/api/core-scan-spellbookpet.lua index 2cbc67b..e75a1be 100644 --- a/api/core-scan-spellbookpet.lua +++ b/api/core-scan-spellbookpet.lua @@ -8,6 +8,24 @@ local MTH_PS_VERBOSE_LOGS = false local MTH_PS_ForceScanUntil = 0 local MTH_PS_ForceScanBudget = 0 local MTH_PS_EventThrottle = {} +local MTH_PS_LastNoPetScanAt = 0 + +local function MTH_PS_IsPlayerDeadOrGhost() + if type(UnitIsDeadOrGhost) == "function" then + return UnitIsDeadOrGhost("player") and true or false + end + if type(UnitIsDead) == "function" then + return UnitIsDead("player") and true or false + end + return false +end + +local function MTH_PS_HasLivePet() + if type(UnitExists) ~= "function" then + return false + end + return UnitExists("pet") and true or false +end local function MTH_PS_ClearForceWindow() MTH_PS_ForceScanUntil = 0 @@ -207,6 +225,41 @@ function MTH_PSP_RequestScan(trigger, minIntervalSeconds) local minInterval = tonumber(minIntervalSeconds) or 1 local bypassThrottle = false local bypassReason = "" + local deadOrGhost = MTH_PS_IsPlayerDeadOrGhost() + local hasLivePet = MTH_PS_HasLivePet() + + if deadOrGhost then + if triggerText == "PET_BAR_UPDATE" or triggerText == "event:PET_BAR_UPDATE" + or triggerText == "SPELLS_CHANGED" or triggerText == "event:SPELLS_CHANGED" + or triggerText == "LEARNED_SPELL_IN_TAB" or triggerText == "event:LEARNED_SPELL_IN_TAB" + then + MTH_PS_TraceTrigger(triggerText, false, "dead-skip") + return false + end + end + + if not hasLivePet then + if MTH_PS_IsFollowupEventTrigger(triggerText) then + MTH_PS_TraceTrigger(triggerText, false, "no-pet-followup") + return false + end + if triggerText ~= "manual" + and triggerText ~= "PLAYER_ENTERING_WORLD" + and triggerText ~= "event:PLAYER_ENTERING_WORLD" + and triggerText ~= "UNIT_PET" + and triggerText ~= "event:UNIT_PET" + then + MTH_PS_TraceTrigger(triggerText, false, "no-pet") + return false + end + if (now - (tonumber(MTH_PS_LastNoPetScanAt) or 0)) < 5 then + MTH_PS_TraceTrigger(triggerText, false, "no-pet-throttled") + return false + end + MTH_PS_LastNoPetScanAt = now + else + MTH_PS_LastNoPetScanAt = 0 + end if MTH_PS_IsPriorityTrigger(triggerText) then bypassThrottle = true @@ -256,16 +309,16 @@ local function MTH_PS_OnEvent(_, evt, eventArg1) if evt == "PLAYER_ENTERING_WORLD" or evt == "UNIT_PET" or evt == "PET_BAR_UPDATE" or evt == "SPELLS_CHANGED" or evt == "LEARNED_SPELL_IN_TAB" then local minInterval = 1 if evt == "PET_BAR_UPDATE" then - minInterval = 2 + minInterval = 4 elseif evt == "SPELLS_CHANGED" then - minInterval = 2.5 + minInterval = 4 elseif evt == "LEARNED_SPELL_IN_TAB" then - minInterval = 0.75 + minInterval = 1.5 elseif evt == "UNIT_PET" then - minInterval = 0.5 + minInterval = 1 end if MTH_PS_ShouldHandleEvent(evt, minInterval) then - MTH_PSP_RequestScan(evt, 1) + MTH_PSP_RequestScan(evt, minInterval) end end end @@ -278,7 +331,7 @@ function MTH_PS_InitService() return end - MTH_PS_Frame = CreateFrame("Frame", "MTHPetSpellScanFrame") + MTH_PS_Frame = CreateFrame("Frame", "MTHPetSpellbookEventFrame") MTH_PS_Frame:RegisterEvent("PLAYER_ENTERING_WORLD") MTH_PS_Frame:RegisterEvent("UNIT_PET") MTH_PS_Frame:RegisterEvent("PET_BAR_UPDATE") @@ -295,6 +348,7 @@ function MTH_PS_ShutdownService(_reason) MTH_PS_Frame:SetScript("OnEvent", nil) MTH_PS_Frame = nil MTH_PS_LastScanAt = 0 + MTH_PS_LastNoPetScanAt = 0 MTH_PS_ClearForceWindow() end diff --git a/api/core-scan-stablemaster.lua b/api/core-scan-stablemaster.lua index 6846176..8a8572f 100644 --- a/api/core-scan-stablemaster.lua +++ b/api/core-scan-stablemaster.lua @@ -6,10 +6,40 @@ local MTH_PETS_SCHEMA_VERSION = 2 local MTH_PETS_CORE_HOOK_BOUNDARY_KEY = "core-pet-rename-hook" MTH_PETS_TRACE_CONSISTENCY = false -local MTH_StableFrame = nil -local MTH_ST_BootstrapFrame = nil +local MTH_PetLifecycleEventFrame = nil +local MTH_PetLifecycleBootstrapFrame = nil local MTH_ST_LastAutoScanAt = 0 local MTH_ST_LastUnitPetHadPet = false +local MTH_ST_DeferredStableScanFrame = nil +local MTH_ST_Profile = { + enabled = false, + windowStart = 0, + windowSeconds = 5, + eventCount = 0, + totalMs = 0, + maxMs = 0, + byEvent = {}, + runawayChecks = 0, + runawayMs = 0, + runawayMaxMs = 0, +} +local MTH_ST_Trace = { + enabled = false, + verbose = false, + windowStart = 0, + windowSeconds = 5, + eventCount = 0, + byEvent = {}, + byRoute = {}, + byNote = {}, +} +local MTH_ST_Diag = { + lines = {}, + maxLines = 200, + chatEcho = false, +} +local MTH_ST_DiagCopyFrame = nil +local MTH_ST_PrintStableDiag local MTH_PETS_LastTameAttempt = nil local MTH_PETS_LiveState = nil local MTH_PETS_LiveStateSeq = 0 @@ -23,6 +53,378 @@ local function MTH_ST_Log(line) return end +local function MTH_ST_TraceNow() + return (type(GetTime) == "function" and GetTime()) or (type(time) == "function" and time()) or nil +end + +local function MTH_ST_TraceResetCounters() + MTH_ST_Trace.eventCount = 0 + MTH_ST_Trace.byEvent = {} + MTH_ST_Trace.byRoute = {} + MTH_ST_Trace.byNote = {} + MTH_ST_Trace.windowStart = MTH_ST_TraceNow() or 0 +end + +local function MTH_ST_TraceCount(bucket, key) + if type(bucket) ~= "table" then + return + end + local k = tostring(key or "?") + bucket[k] = (tonumber(bucket[k]) or 0) + 1 +end + +local function MTH_ST_TraceBuildTopList(bucket, maxRows) + local rows = {} + for key, value in pairs(bucket or {}) do + table.insert(rows, { key = tostring(key), count = tonumber(value) or 0 }) + end + table.sort(rows, function(a, b) + if a.count == b.count then + return a.key < b.key + end + return a.count > b.count + end) + local limit = math.min(tonumber(maxRows) or 4, table.getn(rows)) + if limit <= 0 then + return "none" + end + local parts = {} + for i = 1, limit do + table.insert(parts, tostring(rows[i].key) .. ":" .. tostring(rows[i].count)) + end + return table.concat(parts, ",") +end + +local function MTH_ST_TraceRecord(evt, route, note) + if not (MTH_ST_Trace and MTH_ST_Trace.enabled) then + return + end + MTH_ST_Trace.eventCount = (tonumber(MTH_ST_Trace.eventCount) or 0) + 1 + MTH_ST_TraceCount(MTH_ST_Trace.byEvent, evt) + MTH_ST_TraceCount(MTH_ST_Trace.byRoute, route) + if note and note ~= "" then + MTH_ST_TraceCount(MTH_ST_Trace.byNote, note) + end + + local evtText = tostring(evt or "") + local routeText = tostring(route or "") + local noteText = tostring(note or "") + if string.find(evtText, "PET_STABLE_", 1, true) + or routeText == "scan" + or string.find(noteText, "stable", 1, true) + or string.find(noteText, "deferred", 1, true) + or string.find(noteText, "throttled", 1, true) then + MTH_ST_PrintStableDiag("trace evt=" .. evtText .. " route=" .. routeText .. " note=" .. noteText) + end + + if MTH_ST_Trace.verbose and MTH and MTH.Print then + local hasPet = (type(UnitExists) == "function" and UnitExists("pet")) and true or false + local dead = (type(UnitIsDeadOrGhost) == "function" and UnitIsDeadOrGhost("player")) or false + MTH:Print("[STTRACE] evt=" .. tostring(evt) + .. " route=" .. tostring(route) + .. " note=" .. tostring(note or "-") + .. " pet=" .. tostring(hasPet) + .. " dead=" .. tostring(dead and true or false), "debug") + end +end + +local function MTH_ST_TraceFlushIfDue(force) + if not (MTH_ST_Trace and MTH_ST_Trace.enabled) then + return + end + local now = MTH_ST_TraceNow() + if not now then + return + end + local started = tonumber(MTH_ST_Trace.windowStart) or 0 + if started <= 0 then + MTH_ST_Trace.windowStart = now + return + end + local elapsed = now - started + local windowSeconds = tonumber(MTH_ST_Trace.windowSeconds) or 5 + if not force and elapsed < windowSeconds then + return + end + if MTH and MTH.Print then + MTH:Print("[STTRACE] " .. tostring(string.format("%.1fs", elapsed)) + .. " events=" .. tostring(tonumber(MTH_ST_Trace.eventCount) or 0) + .. " byEvent=" .. tostring(MTH_ST_TraceBuildTopList(MTH_ST_Trace.byEvent, 6)) + .. " byRoute=" .. tostring(MTH_ST_TraceBuildTopList(MTH_ST_Trace.byRoute, 6)) + .. " notes=" .. tostring(MTH_ST_TraceBuildTopList(MTH_ST_Trace.byNote, 6)), + "debug") + end + MTH_ST_TraceResetCounters() +end + +local function MTH_ST_IsStableUiEvent(evt) + return evt == "PET_STABLE_SHOW" + or evt == "PET_STABLE_UPDATE" + or evt == "PET_STABLE_CLOSED" +end + +MTH_ST_PrintStableDiag = function(line) + return +end + +local function MTH_ST_BuildDiagDumpText() + local lines = MTH_ST_Diag.lines or {} + if table.getn(lines) <= 0 then + return "(no stable diagnostics captured yet)" + end + return table.concat(lines, "\n") +end + +local function MTH_ST_EnsureDiagCopyFrame() + if MTH_ST_DiagCopyFrame then + return MTH_ST_DiagCopyFrame + end + local frame = CreateFrame("Frame", "MTHStableDiagCopyFrame", UIParent) + if not frame then + return nil + end + frame:SetWidth(820) + frame:SetHeight(440) + frame:SetPoint("CENTER", UIParent, "CENTER") + frame:SetFrameStrata("DIALOG") + frame:SetBackdrop({ + bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", + edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", + tile = true, + tileSize = 32, + edgeSize = 32, + insets = { left = 11, right = 12, top = 12, bottom = 11 }, + }) + frame:SetBackdropColor(0, 0, 0, 0.9) + frame:SetMovable(true) + frame:EnableMouse(true) + frame:RegisterForDrag("LeftButton") + frame:SetScript("OnDragStart", function() frame:StartMoving() end) + frame:SetScript("OnDragStop", function() frame:StopMovingOrSizing() end) + + local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") + title:SetPoint("TOPLEFT", frame, "TOPLEFT", 20, -15) + title:SetText("MetaHunt Stable Diagnostics") + + local closeButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") + closeButton:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -12, -12) + closeButton:SetWidth(24) + closeButton:SetHeight(24) + closeButton:SetText("X") + closeButton:SetScript("OnClick", function() frame:Hide() end) + + local copyButton = CreateFrame("Button", nil, frame, "GameMenuButtonTemplate") + copyButton:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 12, 12) + copyButton:SetWidth(110) + copyButton:SetHeight(24) + copyButton:SetText("Select All") + + local scrollFrame = CreateFrame("ScrollFrame", "MTHStableDiagCopyScroll", frame, "UIPanelScrollFrameTemplate") + scrollFrame:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -48) + scrollFrame:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -36, 44) + + local editBox = CreateFrame("EditBox", "MTHStableDiagCopyText", scrollFrame) + editBox:SetMultiLine(true) + editBox:SetMaxLetters(999999) + editBox:SetFont("Fonts\\FRIZQT__.TTF", 11, "") + editBox:SetTextColor(0.85, 0.85, 0.85, 1) + editBox:SetWidth(760) + editBox:SetHeight(1) + editBox:SetAutoFocus(false) + editBox:EnableMouse(true) + editBox:EnableMouseWheel(true) + editBox:SetScript("OnEscapePressed", function() editBox:ClearFocus() end) + editBox:SetScript("OnMouseWheel", function() + local curr = scrollFrame:GetVerticalScroll() + if arg1 and arg1 > 0 then + scrollFrame:SetVerticalScroll(curr - 24) + else + scrollFrame:SetVerticalScroll(curr + 24) + end + end) + scrollFrame:SetScrollChild(editBox) + + copyButton:SetScript("OnClick", function() + editBox:HighlightText(0, -1) + editBox:SetFocus() + end) + + frame._scroll = scrollFrame + frame._edit = editBox + MTH_ST_DiagCopyFrame = frame + if UISpecialFrames then + local exists = false + for i = 1, table.getn(UISpecialFrames) do + if UISpecialFrames[i] == "MTHStableDiagCopyFrame" then + exists = true + break + end + end + if not exists then + table.insert(UISpecialFrames, "MTHStableDiagCopyFrame") + end + end + return frame +end + +function MTH_CommandStableDiag(mode) + MTH_ST_Diag.lines = {} + MTH_ST_Diag.chatEcho = false + if MTH and MTH.Print then + MTH:Print("Stable diagnostics are disabled.") + end + return false +end + +function MTH_CommandStableTrace(mode) + MTH_ST_Trace.enabled = false + MTH_ST_Trace.verbose = false + if MTH and MTH.Print then + MTH:Print("Stable trace is disabled.") + end + return false +end + +local function MTH_ST_ProfileNow() + return (type(GetTime) == "function" and GetTime()) or (type(time) == "function" and time()) or nil +end + +local function MTH_ST_ProfileResetCounters() + MTH_ST_Profile.eventCount = 0 + MTH_ST_Profile.totalMs = 0 + MTH_ST_Profile.maxMs = 0 + MTH_ST_Profile.byEvent = {} + MTH_ST_Profile.runawayChecks = 0 + MTH_ST_Profile.runawayMs = 0 + MTH_ST_Profile.runawayMaxMs = 0 + MTH_ST_Profile.windowStart = MTH_ST_ProfileNow() or 0 +end + +local function MTH_ST_ProfileRecordEvent(evt, elapsedMs) + if not (MTH_ST_Profile and MTH_ST_Profile.enabled) then + return + end + local eventName = tostring(evt or "?") + local elapsed = tonumber(elapsedMs) or 0 + MTH_ST_Profile.eventCount = (tonumber(MTH_ST_Profile.eventCount) or 0) + 1 + MTH_ST_Profile.totalMs = (tonumber(MTH_ST_Profile.totalMs) or 0) + elapsed + if elapsed > (tonumber(MTH_ST_Profile.maxMs) or 0) then + MTH_ST_Profile.maxMs = elapsed + end + if type(MTH_ST_Profile.byEvent[eventName]) ~= "table" then + MTH_ST_Profile.byEvent[eventName] = { count = 0, totalMs = 0, maxMs = 0 } + end + local row = MTH_ST_Profile.byEvent[eventName] + row.count = (tonumber(row.count) or 0) + 1 + row.totalMs = (tonumber(row.totalMs) or 0) + elapsed + if elapsed > (tonumber(row.maxMs) or 0) then + row.maxMs = elapsed + end +end + +local function MTH_ST_ProfileRecordRunaway(elapsedMs) + if not (MTH_ST_Profile and MTH_ST_Profile.enabled) then + return + end + local elapsed = tonumber(elapsedMs) or 0 + MTH_ST_Profile.runawayChecks = (tonumber(MTH_ST_Profile.runawayChecks) or 0) + 1 + MTH_ST_Profile.runawayMs = (tonumber(MTH_ST_Profile.runawayMs) or 0) + elapsed + if elapsed > (tonumber(MTH_ST_Profile.runawayMaxMs) or 0) then + MTH_ST_Profile.runawayMaxMs = elapsed + end +end + +local function MTH_ST_ProfileBuildTopList(metricKey, maxRows) + local rows = {} + for eventName, row in pairs(MTH_ST_Profile.byEvent or {}) do + if type(row) == "table" then + table.insert(rows, { + event = tostring(eventName), + count = tonumber(row.count) or 0, + totalMs = tonumber(row.totalMs) or 0, + maxMs = tonumber(row.maxMs) or 0, + }) + end + end + table.sort(rows, function(a, b) + local av = tonumber(a[metricKey]) or 0 + local bv = tonumber(b[metricKey]) or 0 + if av == bv then + return tostring(a.event or "") < tostring(b.event or "") + end + return av > bv + end) + + local limit = math.min(tonumber(maxRows) or 3, table.getn(rows)) + if limit <= 0 then + return "none" + end + + local parts = {} + for i = 1, limit do + local row = rows[i] + if metricKey == "count" then + table.insert(parts, tostring(row.event) .. ":" .. tostring(row.count)) + else + table.insert(parts, tostring(row.event) .. ":" .. string.format("%.2f", tonumber(row.totalMs) or 0) .. "ms") + end + end + return table.concat(parts, ",") +end + +local function MTH_ST_ProfileFlushIfDue(force) + if not (MTH_ST_Profile and MTH_ST_Profile.enabled) then + return + end + local now = MTH_ST_ProfileNow() + if not now then + return + end + local windowStart = tonumber(MTH_ST_Profile.windowStart) or 0 + if windowStart <= 0 then + MTH_ST_Profile.windowStart = now + return + end + local elapsed = now - windowStart + local windowSeconds = tonumber(MTH_ST_Profile.windowSeconds) or 5 + if not force and elapsed < windowSeconds then + return + end + if MTH and MTH.Print then + local eventCount = tonumber(MTH_ST_Profile.eventCount) or 0 + local totalMs = tonumber(MTH_ST_Profile.totalMs) or 0 + local avgMs = (eventCount > 0) and (totalMs / eventCount) or 0 + local maxMs = tonumber(MTH_ST_Profile.maxMs) or 0 + local runawayChecks = tonumber(MTH_ST_Profile.runawayChecks) or 0 + local runawayMs = tonumber(MTH_ST_Profile.runawayMs) or 0 + local runawayAvg = (runawayChecks > 0) and (runawayMs / runawayChecks) or 0 + local runawayMax = tonumber(MTH_ST_Profile.runawayMaxMs) or 0 + local topCount = MTH_ST_ProfileBuildTopList("count", 4) + local topMs = MTH_ST_ProfileBuildTopList("totalMs", 4) + MTH:Print("[STPROF] " .. tostring(string.format("%.1fs", elapsed)) + .. " events=" .. tostring(eventCount) + .. " total=" .. tostring(string.format("%.2f", totalMs)) .. "ms" + .. " avg=" .. tostring(string.format("%.3f", avgMs)) .. "ms" + .. " max=" .. tostring(string.format("%.3f", maxMs)) .. "ms" + .. " topCount=" .. tostring(topCount) + .. " topMs=" .. tostring(topMs) + .. " runaway=" .. tostring(runawayChecks) + .. "/" .. tostring(string.format("%.2f", runawayMs)) .. "ms" + .. " avg=" .. tostring(string.format("%.3f", runawayAvg)) .. "ms" + .. " max=" .. tostring(string.format("%.3f", runawayMax)) .. "ms", + "debug") + end + MTH_ST_ProfileResetCounters() +end + +function MTH_CommandStableProfile(mode) + MTH_ST_Profile.enabled = false + if MTH and MTH.Print then + MTH:Print("Stable profiler is disabled.") + end + return false +end + local function MTH_PETS_LogConsistency(line) return end @@ -57,11 +459,22 @@ local function MTH_PETS_FormatCurrentConsistency(cp) end local function MTH_PETS_RequestPetSpellScan(reason) + local stableFrame = (type(getglobal) == "function" and getglobal("PetStableFrame")) or (_G and _G["PetStableFrame"]) + if stableFrame and type(stableFrame.IsShown) == "function" and stableFrame:IsShown() then + MTH_ST_PrintStableDiag("swap spellscan-skip reason=stable-open src=" .. tostring(reason or "stablemaster-current-change")) + return + end + MTH_ST_PrintStableDiag("swap spellscan-request reason=" .. tostring(reason or "stablemaster-current-change")) if type(MTH_PSP_RequestScan) == "function" then MTH_PSP_RequestScan(tostring(reason or "stablemaster-current-change"), 1) end end +local function MTH_ST_IsStableFrameOpen() + local stableFrame = (type(getglobal) == "function" and getglobal("PetStableFrame")) or (_G and _G["PetStableFrame"]) + return stableFrame and type(stableFrame.IsShown) == "function" and stableFrame:IsShown() and true or false +end + local function MTH_PETS_ShouldHandleThrottledEvent(key, minIntervalSeconds) local now = (type(GetTime) == "function" and GetTime()) or (type(time) == "function" and time()) or 0 local threshold = tonumber(minIntervalSeconds) or 0 @@ -84,8 +497,8 @@ local function MTH_PETS_CopyLiveState(state) return copy end -local function MTH_PETS_BuildLiveState(source) - local info = (type(MTH_GetCurrentPetInfo) == "function") and MTH_GetCurrentPetInfo() or nil +local function MTH_PETS_BuildLiveState(source, skipRefresh) + local info = (type(MTH_GetCurrentPetInfo) == "function") and MTH_GetCurrentPetInfo(skipRefresh and true or false) or nil if type(info) ~= "table" then info = {} end @@ -157,12 +570,16 @@ local function MTH_PETS_NotifyLiveSubscribers(state, source) end end -function MTH_PETS_EmitLiveState(source, force) +function MTH_PETS_EmitLiveState(source, force, options) local emittedSource = source or "stablemaster" if type(emittedSource) ~= "string" then emittedSource = tostring(emittedSource) end - local nextState = MTH_PETS_BuildLiveState(emittedSource) + local skipRefresh = false + if type(options) == "table" and options.skipRefresh == true then + skipRefresh = true + end + local nextState = MTH_PETS_BuildLiveState(emittedSource, skipRefresh) local changed = force and true or (not MTH_PETS_AreLiveStatesEqual(MTH_PETS_LiveState, nextState)) if not changed then return false @@ -2212,13 +2629,18 @@ function MTH_PETS_RefreshCurrentPet() eventType = "pet-acquired" end - local eventContext = MTH_PETS_CaptureContext() - table.insert(row.events, { - type = eventType, - source = "refresh-current-pet", - at = now, - context = eventContext, - }) + local shouldRecordLifecycleEvent = (eventType == "pet-acquired") + or (cp.id ~= nil and previousCurrentId ~= nil and cp.id ~= previousCurrentId) + local eventContext = nil + if shouldRecordLifecycleEvent then + eventContext = MTH_PETS_CaptureContext() + table.insert(row.events, { + type = eventType, + source = "refresh-current-pet", + at = now, + context = eventContext, + }) + end if eventType == "pet-acquired" then local hasTameRecord = (row.tameRecorded == true) @@ -2261,9 +2683,101 @@ function MTH_GetCurrentPetSnapshot() return pets and pets.currentPet or nil end -function MTH_GetCurrentPetInfo() +local function MTH_PETS_ApplyLivePetVitalsFast(source) + local pets = type(MTH_PETS_GetRootStore) == "function" and MTH_PETS_GetRootStore() or nil + if type(pets) ~= "table" then + return false + end + MTH_PETS_EnsureCurrentPetSchema(pets) + + local cp = pets.currentPet + if type(cp) ~= "table" or cp.id == nil then + return false + end + if type(UnitExists) ~= "function" or not UnitExists("pet") then + return false + end + + local changed = false + local function assignIfChanged(target, key, value) + if type(target) ~= "table" then + return + end + if not MTH_PETS_IsSameLiveStateField(target[key], value) then + target[key] = value + changed = true + end + end + + local happiness = nil + local loyalty = nil + if type(GetPetHappiness) == "function" then + local h, _, l = GetPetHappiness() + happiness = h + loyalty = l + end + + local loyaltyLevel = nil + local getPetLoyalty = (type(getglobal) == "function" and getglobal("GetPetLoyalty")) or (_G and _G["GetPetLoyalty"]) + if type(getPetLoyalty) == "function" then + local level = getPetLoyalty() + loyaltyLevel = tonumber(level) or nil + end + + local xp = nil + local xpMax = nil + local xpPercent = nil + local getPetExperience = (type(getglobal) == "function" and getglobal("GetPetExperience")) or (_G and _G["GetPetExperience"]) + if type(getPetExperience) == "function" then + local x, xMax = getPetExperience() + xp = tonumber(x) + xpMax = tonumber(xMax) + if xp and xpMax and xpMax > 0 then + xpPercent = math.floor((xp / xpMax) * 1000 + 0.5) / 10 + end + end + + assignIfChanged(cp, "happiness", happiness) + assignIfChanged(cp, "loyalty", loyalty) + if loyaltyLevel ~= nil then + assignIfChanged(cp, "loyaltyLevel", loyaltyLevel) + end + assignIfChanged(cp, "xp", xp) + assignIfChanged(cp, "xpMax", xpMax) + assignIfChanged(cp, "xpPercent", xpPercent) + + local activeRow = nil + if type(pets.petStore) == "table" and type(pets.petStore.activeById) == "table" then + activeRow = pets.petStore.activeById[cp.id] or pets.petStore.activeById[tostring(cp.id)] + end + if type(activeRow) == "table" then + assignIfChanged(activeRow, "happiness", happiness) + assignIfChanged(activeRow, "loyalty", loyalty) + if loyaltyLevel ~= nil then + assignIfChanged(activeRow, "loyaltyLevel", loyaltyLevel) + end + assignIfChanged(activeRow, "xp", xp) + assignIfChanged(activeRow, "xpMax", xpMax) + assignIfChanged(activeRow, "xpPercent", xpPercent) + if changed then + activeRow.lastUpdated = time() + end + end + + if changed then + cp.lastUpdated = time() + cp.lastSource = source or "live-vitals" + pets.updatedAt = cp.lastUpdated + end + + return changed +end + +function MTH_GetCurrentPetInfo(skipRefresh) local pets = MTH_PETS_GetRootStore() - MTH_PETS_RefreshCurrentPet() + if not skipRefresh then + MTH_PETS_RefreshCurrentPet() + end local cp = pets and pets.currentPet or nil local info = { @@ -2499,19 +3013,28 @@ local function MTH_ST_GetStore() end function MTH_ST_Scan(reason) + local startedAt = MTH_ST_ProfileNow() + MTH_ST_PrintStableDiag("scan begin reason=" .. tostring(reason or "")) + local slotDiagParts = {} if UnitClass then local _, classToken = UnitClass("player") if classToken ~= "HUNTER" then + MTH_ST_TraceRecord("MTH_ST_Scan", "scan", "class-gate:" .. tostring(classToken or "unknown")) + MTH_ST_PrintStableDiag("scan skip reason=class-gate class=" .. tostring(classToken or "unknown")) return false end end if type(GetNumStableSlots) ~= "function" or type(GetStablePetInfo) ~= "function" then MTH_ST_Log("scan skipped: stable APIs unavailable") + MTH_ST_TraceRecord("MTH_ST_Scan", "scan", "api-unavailable") + MTH_ST_PrintStableDiag("scan skip reason=api-unavailable") return false end local slotCount = tonumber(GetNumStableSlots()) or 0 + MTH_ST_PrintStableDiag("scan api-ok slots=" .. tostring(slotCount)) + MTH_ST_TraceRecord("MTH_ST_Scan", "scan", "begin reason=" .. tostring(reason or "") .. " slots=" .. tostring(slotCount)) local store = MTH_ST_GetStore() local petsRoot = MTH_PETS_GetRootStore() MTH_PETS_RefreshCurrentPet() @@ -2529,6 +3052,10 @@ function MTH_ST_Scan(reason) for slot = 1, slotCount do local r1, r2, r3, r4, r5, r6, r7, r8 = GetStablePetInfo(slot) + local slotName = tostring(r2 or "-") + local slotLevel = tostring(tonumber(r3) or "-") + local slotFamily = tostring(r4 or "-") + table.insert(slotDiagParts, "s" .. tostring(slot) .. "=" .. slotName .. "(lvl=" .. slotLevel .. ",fam=" .. slotFamily .. ")") MTH_PETS_RecordStableSlot(petsRoot, slot, r1, r2, r3, r4, r5, r6, r7, r8, scanContext, scanStableMasterName or store.stableMasterName) local petId = nil if type(petsRoot) == "table" and type(petsRoot.petStore) == "table" and type(petsRoot.petStore.stableSlotIndex) == "table" then @@ -2567,11 +3094,36 @@ function MTH_ST_Scan(reason) loyalty = c5, raw = { c1, c2, c3, c4, c5, c6, c7, c8 }, } + MTH_ST_PrintStableDiag("scan current name=" .. tostring(c2 or "-") + .. " lvl=" .. tostring(tonumber(c3) or "-") + .. " fam=" .. tostring(c4 or "-") + .. " petId=" .. tostring(currentPetId or "-")) + if table.getn(slotDiagParts) > 0 then + MTH_ST_PrintStableDiag("scan slots " .. table.concat(slotDiagParts, " | ")) + end MTH_PETS_MarkStableVisited(petsRoot, tostring(reason or "stable-scan")) MTH_PETS_RefreshCurrentPet() + local elapsedMs = nil + local finishedAt = MTH_ST_ProfileNow() + if startedAt and finishedAt then + elapsedMs = (finishedAt - startedAt) * 1000 + end + if elapsedMs then + MTH_ST_TraceRecord("MTH_ST_Scan", "scan", "done reason=" .. tostring(reason or "") + .. " slots=" .. tostring(slotCount) + .. " elapsedMs=" .. string.format("%.2f", elapsedMs)) + MTH_ST_PrintStableDiag("scan done ok=true slots=" .. tostring(slotCount) .. " elapsedMs=" .. string.format("%.2f", elapsedMs)) + else + MTH_ST_TraceRecord("MTH_ST_Scan", "scan", "done reason=" .. tostring(reason or "") + .. " slots=" .. tostring(slotCount)) + MTH_ST_PrintStableDiag("scan done ok=true slots=" .. tostring(slotCount)) + end + + MTH_PETS_EmitLiveState("STABLE_SCAN:" .. tostring(reason or "stable"), false) + return true end @@ -2598,12 +3150,50 @@ local function MTH_ST_RunScanThrottled(reason, minIntervalSeconds) local now = tonumber(time()) or 0 local minInterval = tonumber(minIntervalSeconds) or 0 if minInterval > 0 and (now - (MTH_ST_LastAutoScanAt or 0)) < minInterval then + MTH_ST_PrintStableDiag("scan throttled reason=" .. tostring(reason or "") + .. " min=" .. tostring(minInterval) + .. " age=" .. tostring(now - (MTH_ST_LastAutoScanAt or 0))) + MTH_ST_TraceRecord("MTH_ST_RunScanThrottled", "scan", "throttled reason=" .. tostring(reason or "") + .. " min=" .. tostring(minInterval) + .. " age=" .. tostring(now - (MTH_ST_LastAutoScanAt or 0))) return false end MTH_ST_LastAutoScanAt = now + MTH_ST_PrintStableDiag("scan trigger reason=" .. tostring(reason or "") .. " min=" .. tostring(minInterval)) + MTH_ST_TraceRecord("MTH_ST_RunScanThrottled", "scan", "trigger reason=" .. tostring(reason or "") + .. " min=" .. tostring(minInterval)) return MTH_ST_Scan(reason) end +local function MTH_ST_QueueDeferredStableScan(reason, delaySeconds) + if not MTH_ST_DeferredStableScanFrame then + MTH_ST_DeferredStableScanFrame = CreateFrame("Frame", "MTHDeferredStableScanFrame") + if not MTH_ST_DeferredStableScanFrame then + MTH_ST_TraceRecord("MTH_ST_QueueDeferredStableScan", "scan", "frame-create-failed") + return false + end + end + + local frame = MTH_ST_DeferredStableScanFrame + frame._reason = tostring(reason or "deferred") + frame._elapsed = 0 + frame._delay = tonumber(delaySeconds) or 0.30 + MTH_ST_PrintStableDiag("deferred queue reason=" .. tostring(frame._reason) .. " delay=" .. tostring(frame._delay)) + frame:SetScript("OnUpdate", function() + this._elapsed = (this._elapsed or 0) + (arg1 or 0) + if this._elapsed < (this._delay or 0.30) then + return + end + this:SetScript("OnUpdate", nil) + MTH_ST_PrintStableDiag("deferred execute reason=" .. tostring(this._reason or "deferred")) + MTH_ST_TraceRecord("MTH_ST_QueueDeferredStableScan", "scan", "execute reason=" .. tostring(this._reason or "deferred")) + MTH_ST_RunScanThrottled("deferred:" .. tostring(this._reason or "stable"), 1) + end) + MTH_ST_TraceRecord("MTH_ST_QueueDeferredStableScan", "scan", "queued reason=" .. tostring(frame._reason) + .. " delay=" .. tostring(frame._delay)) + return true +end + local function MTH_PETS_HandleUnitPetTransition(source) local pets = MTH_PETS_GetRootStore() if type(pets) ~= "table" then @@ -2679,12 +3269,17 @@ local function MTH_PETS_HandleUnitPetTransition(source) if type(row.events) ~= "table" then row.events = {} end - table.insert(row.events, { - type = (not hadPet) and "pet-acquired" or "pet-updated", - source = source or transitionSource, - at = time(), - context = MTH_PETS_CaptureContext(), - }) + local eventType = (not hadPet) and "pet-acquired" or "pet-updated" + if eventType == "pet-updated" and source == "UNIT_PET" and MTH_ST_IsStableFrameOpen() then + MTH_ST_PrintStableDiag("swap history-skip reason=stable-open") + else + table.insert(row.events, { + type = eventType, + source = source or transitionSource, + at = time(), + context = MTH_PETS_CaptureContext(), + }) + end end if not hadPet and hasPendingTame then MTH_PETS_LogTame("Acquire completed; clearing pending tame attempt") @@ -3133,7 +3728,281 @@ function MTH_CommandStableScan() MTH:Print("Stable scan captured " .. tostring(slotCount) .. " slot(s).") end -local function MTH_ST_OnEvent(_, evt, eventArg1, eventArg2) +function MTH_ST_HandlePlayerEnteringWorld(evt) + if evt ~= "PLAYER_ENTERING_WORLD" then + return false + end + local pets = MTH_PETS_GetRootStore() + MTH_PETS_MaybePromptStableVisit(pets) + MTH_PETS_EmitLiveState("PLAYER_ENTERING_WORLD", true) + return true +end + +function MTH_ST_HandleSpellcastEvent(evt, eventArg1, eventArg2) + local isUnitSpellcast = (evt == "UNIT_SPELLCAST_START" or evt == "UNIT_SPELLCAST_STOP" + or evt == "UNIT_SPELLCAST_FAILED" or evt == "UNIT_SPELLCAST_INTERRUPTED" + or evt == "UNIT_SPELLCAST_CHANNEL_START" or evt == "UNIT_SPELLCAST_CHANNEL_STOP") + local castSpellName = eventArg1 + local hasPendingTame = type(MTH_PETS_LastTameAttempt) == "table" + if isUnitSpellcast then + if eventArg1 ~= "player" then + return true + end + castSpellName = eventArg2 + end + + if evt == "SPELLCAST_START" or evt == "SPELLCAST_CHANNEL_START" + or evt == "UNIT_SPELLCAST_START" or evt == "UNIT_SPELLCAST_CHANNEL_START" then + if not MTH_PETS_IsTameBeastSpellName(castSpellName) then + return true + end + MTH_PETS_RecordTameAttempt(evt, castSpellName) + return true + end + + if evt == "SPELLCAST_STOP" or evt == "SPELLCAST_CHANNEL_STOP" or evt == "SPELLCAST_FAILED" or evt == "SPELLCAST_INTERRUPTED" + or evt == "UNIT_SPELLCAST_STOP" or evt == "UNIT_SPELLCAST_CHANNEL_STOP" + or evt == "UNIT_SPELLCAST_FAILED" or evt == "UNIT_SPELLCAST_INTERRUPTED" then + local terminalIsTame = MTH_PETS_IsTameBeastSpellName(castSpellName) + if not terminalIsTame and hasPendingTame then + terminalIsTame = true + end + if not terminalIsTame then + return true + end + if terminalIsTame then + if evt == "SPELLCAST_FAILED" or evt == "SPELLCAST_INTERRUPTED" then + MTH_PETS_LastTameAttempt = nil + end + end + return true + end + + return false +end + +function MTH_ST_HandleUnitPetEvent(evt, eventArg1) + if evt ~= "UNIT_PET" then + return false + end + if eventArg1 ~= "player" then + return true + end + + local startedAt = MTH_ST_ProfileNow() + local beforeId = nil + local beforeName = nil + local beforeFamily = nil + local beforeLevel = nil + local beforeExists = nil + local petsBefore = type(MTH_PETS_GetRootStore) == "function" and MTH_PETS_GetRootStore() or nil + if type(petsBefore) == "table" and type(petsBefore.currentPet) == "table" then + beforeId = petsBefore.currentPet.id + beforeName = petsBefore.currentPet.name + beforeFamily = petsBefore.currentPet.family + beforeLevel = petsBefore.currentPet.level + beforeExists = petsBefore.currentPet.exists and true or false + end + MTH_ST_PrintStableDiag("swap UNIT_PET begin beforeId=" .. tostring(beforeId or "-") .. " beforeName=" .. tostring(beforeName or "-")) + + MTH_PETS_HandleUnitPetTransition("UNIT_PET") + + local afterId = nil + local afterName = nil + local afterFamily = nil + local afterLevel = nil + local afterExists = nil + local petsAfter = type(MTH_PETS_GetRootStore) == "function" and MTH_PETS_GetRootStore() or nil + if type(petsAfter) == "table" and type(petsAfter.currentPet) == "table" then + afterId = petsAfter.currentPet.id + afterName = petsAfter.currentPet.name + afterFamily = petsAfter.currentPet.family + afterLevel = petsAfter.currentPet.level + afterExists = petsAfter.currentPet.exists and true or false + end + + local sameCore = (tostring(beforeId or "") == tostring(afterId or "")) + and (tostring(beforeName or "") == tostring(afterName or "")) + and (tostring(beforeFamily or "") == tostring(afterFamily or "")) + and (tonumber(beforeLevel) or -1) == (tonumber(afterLevel) or -1) + and (beforeExists and true or false) == (afterExists and true or false) + + local emitSkipped = false + local liveChanged = false + if MTH_ST_IsStableFrameOpen() then + emitSkipped = true + MTH_ST_PrintStableDiag("swap UNIT_PET emit-skip reason=stable-open") + elseif sameCore then + emitSkipped = true + MTH_ST_PrintStableDiag("swap UNIT_PET emit-skip reason=unchanged-core") + else + liveChanged = MTH_PETS_EmitLiveState("UNIT_PET", false, { skipRefresh = true }) and true or false + end + + local elapsedMs = 0 + local finishedAt = MTH_ST_ProfileNow() + if startedAt and finishedAt then + elapsedMs = math.floor(math.max(0, (finishedAt - startedAt) * 1000) + 0.5) + end + MTH_ST_PrintStableDiag("swap UNIT_PET end afterId=" .. tostring(afterId or "-") + .. " afterName=" .. tostring(afterName or "-") + .. " sameCore=" .. tostring(sameCore) + .. " emitSkipped=" .. tostring(emitSkipped) + .. " liveChanged=" .. tostring(liveChanged) + .. " elapsedMs=" .. tostring(elapsedMs)) + return true +end + +local function MTH_ST_IsPlayerDeadOrGhost() + if type(UnitIsDeadOrGhost) == "function" then + return UnitIsDeadOrGhost("player") and true or false + end + if type(UnitIsDead) == "function" then + return UnitIsDead("player") and true or false + end + return false +end + +function MTH_ST_HandleVitalsEvent(evt, eventArg1) + local playerDead = MTH_ST_IsPlayerDeadOrGhost() + local hasLivePet = (type(UnitExists) == "function" and UnitExists("pet")) and true or false + + if evt == "PET_BAR_UPDATE" then + if not hasLivePet then + MTH_ST_TraceRecord(evt, "vitals", "pet_bar:no-pet") + return true + end + local interval = playerDead and 1.75 or 0.5 + if not MTH_PETS_ShouldHandleThrottledEvent("PET_BAR_UPDATE", interval) then + MTH_ST_TraceRecord(evt, "vitals", "pet_bar:throttled") + return true + end + local changed = MTH_PETS_ApplyLivePetVitalsFast("PET_BAR_UPDATE") + if changed then + MTH_ST_TraceRecord(evt, "vitals", "pet_bar:changed") + MTH_PETS_EmitLiveState("PET_BAR_UPDATE", false, { skipRefresh = true }) + else + MTH_ST_TraceRecord(evt, "vitals", "pet_bar:no-change") + end + return true + end + + if evt == "UNIT_HAPPINESS" then + if not hasLivePet then + MTH_ST_TraceRecord(evt, "vitals", "happiness:no-pet") + return true + end + if eventArg1 and eventArg1 ~= "pet" then + MTH_ST_TraceRecord(evt, "vitals", "happiness:not-pet-arg") + return true + end + local interval = playerDead and 2.25 or 0.75 + if not MTH_PETS_ShouldHandleThrottledEvent("UNIT_HAPPINESS", interval) then + MTH_ST_TraceRecord(evt, "vitals", "happiness:throttled") + return true + end + local changed = MTH_PETS_ApplyLivePetVitalsFast("UNIT_HAPPINESS") + if changed then + MTH_ST_TraceRecord(evt, "vitals", "happiness:changed") + MTH_PETS_EmitLiveState("UNIT_HAPPINESS", false, { skipRefresh = true }) + else + MTH_ST_TraceRecord(evt, "vitals", "happiness:no-change") + end + return true + end + + if evt == "CHAT_MSG_COMBAT_XP_GAIN" or evt == "PLAYER_XP_UPDATE" then + if not hasLivePet then + MTH_ST_TraceRecord(evt, "vitals", "xp:no-pet") + return true + end + if playerDead then + MTH_ST_TraceRecord(evt, "vitals", "xp:dead") + return true + end + if not MTH_PETS_ShouldHandleThrottledEvent("XP_UPDATE", 1.5) then + MTH_ST_TraceRecord(evt, "vitals", "xp:throttled") + return true + end + local changed = MTH_PETS_ApplyLivePetVitalsFast(evt) + if changed then + MTH_ST_TraceRecord(evt, "vitals", "xp:changed") + MTH_PETS_EmitLiveState(evt, false, { skipRefresh = true }) + else + MTH_ST_TraceRecord(evt, "vitals", "xp:no-change") + end + return true + end + + return false +end + +function MTH_ST_HandleRunawayEvent(evt, eventArg1) + if evt ~= "CHAT_MSG_SYSTEM" and evt ~= "UI_ERROR_MESSAGE" + and evt ~= "CHAT_MSG_SPELL_PET_INFO" and evt ~= "CHAT_MSG_SPELL_PET_DAMAGE" + and evt ~= "CHAT_MSG_COMBAT_PET_HITS" and evt ~= "CHAT_MSG_COMBAT_PET_MISSES" then + return false + end + + if type(UnitExists) == "function" and not UnitExists("pet") then + if not MTH_PETS_ShouldHandleThrottledEvent("RUNAWAY_MSG_NO_PET", 2.0) then + MTH_ST_TraceRecord(evt, "runaway", "no-pet:throttled") + return true + end + MTH_ST_TraceRecord(evt, "runaway", "no-pet:parse") + end + + if MTH_ST_IsPlayerDeadOrGhost() then + if not MTH_PETS_ShouldHandleThrottledEvent("RUNAWAY_MSG_DEAD", 0.30) then + MTH_ST_TraceRecord(evt, "runaway", "dead:throttled") + return true + end + end + + local runawayStarted = MTH_ST_ProfileNow() + local matchedRunaway = MTH_PETS_IsRunawaySystemMessage(eventArg1) + if runawayStarted then + local runawayNow = MTH_ST_ProfileNow() + if runawayNow then + MTH_ST_ProfileRecordRunaway((runawayNow - runawayStarted) * 1000) + end + end + if matchedRunaway then + MTH_ST_TraceRecord(evt, "runaway", "matched") + MTH_PETS_RecordPetRunaway(evt, eventArg1) + MTH_PETS_EmitLiveState(evt) + else + MTH_ST_TraceRecord(evt, "runaway", "not-matched") + end + return true +end + +function MTH_ST_HandleStableUiEvent(evt) + if evt == "PET_STABLE_SHOW" then + MTH_ST_TraceRecord(evt, "stable-ui", "show") + MTH_ST_TraceRecord(evt, "stable-ui", "show:no-scan") + return true + end + + if evt == "PET_STABLE_UPDATE" then + MTH_ST_TraceRecord(evt, "stable-ui", "update") + MTH_ST_TraceRecord(evt, "stable-ui", "update:no-scan") + MTH_PETS_EmitLiveState(evt) + return true + end + + if evt == "PET_STABLE_CLOSED" then + MTH_ST_TraceRecord(evt, "stable-ui", "closed") + MTH_ST_QueueDeferredStableScan("PET_STABLE_CLOSED", 0.20) + MTH_ST_TraceRecord(evt, "stable-ui", "closed:deferred-scan") + MTH_PETS_EmitLiveState(evt) + return true + end + + return false +end + +local function MTH_ST_DispatchEvent(_, evt, eventArg1, eventArg2) evt = evt or event eventArg1 = eventArg1 or arg1 eventArg2 = eventArg2 or arg2 @@ -3141,131 +4010,105 @@ local function MTH_ST_OnEvent(_, evt, eventArg1, eventArg2) return end - if evt == "PLAYER_ENTERING_WORLD" then - local pets = MTH_PETS_GetRootStore() - MTH_PETS_MaybePromptStableVisit(pets) - MTH_PETS_RefreshCurrentPet() - MTH_PETS_EmitLiveState("PLAYER_ENTERING_WORLD", true) - return - end - - local isUnitSpellcast = (evt == "UNIT_SPELLCAST_START" or evt == "UNIT_SPELLCAST_STOP" - or evt == "UNIT_SPELLCAST_FAILED" or evt == "UNIT_SPELLCAST_INTERRUPTED" - or evt == "UNIT_SPELLCAST_CHANNEL_START" or evt == "UNIT_SPELLCAST_CHANNEL_STOP") - local castSpellName = eventArg1 - if isUnitSpellcast then - if eventArg1 ~= "player" then + if type(MTH_ST_LifecycleService) == "table" and type(MTH_ST_LifecycleService.HandleEvent) == "function" then + local lifecycleHandled, lifecycleNote = MTH_ST_LifecycleService:HandleEvent(evt, eventArg1, eventArg2) + if lifecycleHandled then + MTH_ST_TraceRecord(evt, "dispatch", lifecycleNote or "lifecycle-service") return end - castSpellName = eventArg2 - end - - if evt == "SPELLCAST_START" or evt == "SPELLCAST_CHANNEL_START" - or evt == "UNIT_SPELLCAST_START" or evt == "UNIT_SPELLCAST_CHANNEL_START" then - MTH_PETS_RecordTameAttempt(evt, castSpellName) - return - end - - if evt == "SPELLCAST_STOP" or evt == "SPELLCAST_CHANNEL_STOP" or evt == "SPELLCAST_FAILED" or evt == "SPELLCAST_INTERRUPTED" - or evt == "UNIT_SPELLCAST_STOP" or evt == "UNIT_SPELLCAST_CHANNEL_STOP" - or evt == "UNIT_SPELLCAST_FAILED" or evt == "UNIT_SPELLCAST_INTERRUPTED" then - local terminalIsTame = MTH_PETS_IsTameBeastSpellName(castSpellName) - if not terminalIsTame and type(MTH_PETS_LastTameAttempt) == "table" then - terminalIsTame = true - end - if not terminalIsTame then - local fallbackName, fallbackSource = MTH_PETS_GetCurrentPlayerCastOrChannelName() - if fallbackName and MTH_PETS_IsTameBeastSpellName(fallbackName) then - terminalIsTame = true - end - end - if terminalIsTame then - if evt == "SPELLCAST_FAILED" or evt == "SPELLCAST_INTERRUPTED" then - MTH_PETS_LastTameAttempt = nil - end - end - return - end - - if evt == "UNIT_PET" then - if eventArg1 ~= "player" then + else + if MTH_ST_HandlePlayerEnteringWorld(evt) then + MTH_ST_TraceRecord(evt, "dispatch", "lifecycle-enter-world") return end - MTH_PETS_HandleUnitPetTransition("UNIT_PET") - MTH_PETS_EmitLiveState("UNIT_PET") - return - end - - if evt == "PET_BAR_UPDATE" then - if not MTH_PETS_ShouldHandleThrottledEvent("PET_BAR_UPDATE", 0.5) then + if MTH_ST_HandleSpellcastEvent(evt, eventArg1, eventArg2) then + MTH_ST_TraceRecord(evt, "dispatch", "lifecycle-spellcast") return end - MTH_PETS_RefreshCurrentPet() - MTH_PETS_EmitLiveState("PET_BAR_UPDATE") - return - end - - if evt == "UNIT_HAPPINESS" then - if eventArg1 and eventArg1 ~= "pet" then + if MTH_ST_HandleUnitPetEvent(evt, eventArg1) then + MTH_ST_TraceRecord(evt, "dispatch", "lifecycle-unit-pet") return end - if not MTH_PETS_ShouldHandleThrottledEvent("UNIT_HAPPINESS", 0.75) then + end + + if type(MTH_ST_VitalsService) == "table" and type(MTH_ST_VitalsService.HandleEvent) == "function" then + if MTH_ST_VitalsService:HandleEvent(evt, eventArg1, eventArg2) then + MTH_ST_TraceRecord(evt, "dispatch", "vitals-service") return end - MTH_PETS_RefreshCurrentPet() - MTH_PETS_EmitLiveState("UNIT_HAPPINESS") - return - end - - if evt == "CHAT_MSG_COMBAT_XP_GAIN" or evt == "PLAYER_XP_UPDATE" then - if not MTH_PETS_ShouldHandleThrottledEvent("XP_UPDATE", 1.5) then + else + if MTH_ST_HandleVitalsEvent(evt, eventArg1) then + MTH_ST_TraceRecord(evt, "dispatch", "vitals") return end - MTH_PETS_RefreshCurrentPet() - MTH_PETS_EmitLiveState(evt) - return end - if evt == "CHAT_MSG_SYSTEM" or evt == "UI_ERROR_MESSAGE" - or evt == "CHAT_MSG_SPELL_PET_INFO" or evt == "CHAT_MSG_SPELL_PET_DAMAGE" - or evt == "CHAT_MSG_COMBAT_PET_HITS" or evt == "CHAT_MSG_COMBAT_PET_MISSES" then - local matchedRunaway = MTH_PETS_IsRunawaySystemMessage(eventArg1) - if matchedRunaway then - MTH_PETS_RecordPetRunaway(evt, eventArg1) - MTH_PETS_RefreshCurrentPet() - MTH_PETS_EmitLiveState(evt) + if type(MTH_ST_RunawayService) == "table" and type(MTH_ST_RunawayService.HandleEvent) == "function" then + if MTH_ST_RunawayService:HandleEvent(evt, eventArg1, eventArg2) then + MTH_ST_TraceRecord(evt, "dispatch", "runaway-service") + return end - return - end - - if evt == "PET_STABLE_SHOW" then - local scanned = MTH_ST_RunScanThrottled(evt, 1) - if scanned and MTH and MTH.Print then - local shouldPrint = true - if MTH.IsMessageEnabled then - shouldPrint = MTH:IsMessageEnabled("stableScan", true) - end - if shouldPrint then - local store = MTH_ST_GetStore() - local slotCount = tonumber(store and store.slotCount) or 0 - MTH:Print("Stable scan complete: " .. tostring(slotCount) .. " slot(s).") - end + else + if MTH_ST_HandleRunawayEvent(evt, eventArg1) then + MTH_ST_TraceRecord(evt, "dispatch", "runaway") + return end - return end - if evt == "PET_STABLE_UPDATE" then - MTH_ST_Scan(evt) - MTH_PETS_RefreshCurrentPet() - MTH_PETS_EmitLiveState(evt) - return + if type(MTH_ST_StableScanService) == "table" and type(MTH_ST_StableScanService.HandleEvent) == "function" then + if MTH_ST_StableScanService:HandleEvent(evt, eventArg1, eventArg2) then + MTH_ST_TraceRecord(evt, "dispatch", "stable-service") + return + end + else + if MTH_ST_HandleStableUiEvent(evt) then + MTH_ST_TraceRecord(evt, "dispatch", "stable-ui") + return + end end - if evt == "PET_STABLE_CLOSED" then - MTH_PETS_RefreshCurrentPet() - MTH_PETS_EmitLiveState(evt) - return + MTH_ST_TraceRecord(evt, "dispatch", "unhandled") +end + +local function MTH_ST_OnEvent(frame, evt, eventArg1, eventArg2) + local started = nil + if MTH_ST_Profile and MTH_ST_Profile.enabled then + started = MTH_ST_ProfileNow() end + + local stableDiagStart = nil + local isStableUiEvt = MTH_ST_IsStableUiEvent(evt) + if isStableUiEvt then + stableDiagStart = MTH_ST_ProfileNow() + MTH_ST_PrintStableDiag("evt=" .. tostring(evt) .. " phase=begin") + if MTH_ST_Trace and not MTH_ST_Trace.enabled then + MTH_ST_Trace.enabled = true + MTH_ST_Trace.verbose = false + MTH_ST_TraceResetCounters() + MTH_ST_PrintStableDiag("trace=auto-enabled") + end + end + + MTH_ST_DispatchEvent(frame, evt, eventArg1, eventArg2) + + if isStableUiEvt then + local stableDiagEnd = MTH_ST_ProfileNow() + local elapsedMs = 0 + if stableDiagStart and stableDiagEnd then + elapsedMs = math.floor(math.max(0, (stableDiagEnd - stableDiagStart) * 1000) + 0.5) + end + MTH_ST_PrintStableDiag("evt=" .. tostring(evt) .. " phase=end elapsedMs=" .. tostring(elapsedMs)) + MTH_ST_TraceFlushIfDue(true) + end + + if started then + local finished = MTH_ST_ProfileNow() + if finished then + MTH_ST_ProfileRecordEvent(evt or event, (finished - started) * 1000) + MTH_ST_ProfileFlushIfDue(false) + end + end + MTH_ST_TraceFlushIfDue(false) end function MTH_ST_InitService() @@ -3273,39 +4116,27 @@ function MTH_ST_InitService() return end - if MTH_StableFrame then + if MTH_PetLifecycleEventFrame then return end - MTH_StableFrame = CreateFrame("Frame", "MTHStableScanFrame") - MTH_StableFrame:RegisterEvent("PLAYER_ENTERING_WORLD") - MTH_StableFrame:RegisterEvent("SPELLCAST_START") - MTH_StableFrame:RegisterEvent("SPELLCAST_STOP") - MTH_StableFrame:RegisterEvent("SPELLCAST_CHANNEL_START") - MTH_StableFrame:RegisterEvent("SPELLCAST_CHANNEL_STOP") - MTH_StableFrame:RegisterEvent("SPELLCAST_FAILED") - MTH_StableFrame:RegisterEvent("SPELLCAST_INTERRUPTED") - MTH_StableFrame:RegisterEvent("UNIT_SPELLCAST_START") - MTH_StableFrame:RegisterEvent("UNIT_SPELLCAST_STOP") - MTH_StableFrame:RegisterEvent("UNIT_SPELLCAST_CHANNEL_START") - MTH_StableFrame:RegisterEvent("UNIT_SPELLCAST_CHANNEL_STOP") - MTH_StableFrame:RegisterEvent("UNIT_SPELLCAST_FAILED") - MTH_StableFrame:RegisterEvent("UNIT_SPELLCAST_INTERRUPTED") - MTH_StableFrame:RegisterEvent("UNIT_PET") - MTH_StableFrame:RegisterEvent("PET_BAR_UPDATE") - MTH_StableFrame:RegisterEvent("UNIT_HAPPINESS") - MTH_StableFrame:RegisterEvent("CHAT_MSG_COMBAT_XP_GAIN") - MTH_StableFrame:RegisterEvent("PLAYER_XP_UPDATE") - MTH_StableFrame:RegisterEvent("CHAT_MSG_SYSTEM") - MTH_StableFrame:RegisterEvent("CHAT_MSG_SPELL_PET_INFO") - MTH_StableFrame:RegisterEvent("CHAT_MSG_SPELL_PET_DAMAGE") - MTH_StableFrame:RegisterEvent("CHAT_MSG_COMBAT_PET_HITS") - MTH_StableFrame:RegisterEvent("CHAT_MSG_COMBAT_PET_MISSES") - MTH_StableFrame:RegisterEvent("UI_ERROR_MESSAGE") - MTH_StableFrame:RegisterEvent("PET_STABLE_SHOW") - MTH_StableFrame:RegisterEvent("PET_STABLE_UPDATE") - MTH_StableFrame:RegisterEvent("PET_STABLE_CLOSED") - MTH_StableFrame:SetScript("OnEvent", MTH_ST_OnEvent) + MTH_PetLifecycleEventFrame = CreateFrame("Frame", "MTHPetLifecycleEventFrame") + MTH_PetLifecycleEventFrame:RegisterEvent("PLAYER_ENTERING_WORLD") + MTH_PetLifecycleEventFrame:RegisterEvent("UNIT_PET") + MTH_PetLifecycleEventFrame:RegisterEvent("PET_BAR_UPDATE") + MTH_PetLifecycleEventFrame:RegisterEvent("UNIT_HAPPINESS") + MTH_PetLifecycleEventFrame:RegisterEvent("CHAT_MSG_COMBAT_XP_GAIN") + MTH_PetLifecycleEventFrame:RegisterEvent("PLAYER_XP_UPDATE") + MTH_PetLifecycleEventFrame:RegisterEvent("CHAT_MSG_SYSTEM") + MTH_PetLifecycleEventFrame:RegisterEvent("CHAT_MSG_SPELL_PET_INFO") + MTH_PetLifecycleEventFrame:RegisterEvent("CHAT_MSG_SPELL_PET_DAMAGE") + MTH_PetLifecycleEventFrame:RegisterEvent("CHAT_MSG_COMBAT_PET_HITS") + MTH_PetLifecycleEventFrame:RegisterEvent("CHAT_MSG_COMBAT_PET_MISSES") + MTH_PetLifecycleEventFrame:RegisterEvent("UI_ERROR_MESSAGE") + MTH_PetLifecycleEventFrame:RegisterEvent("PET_STABLE_SHOW") + MTH_PetLifecycleEventFrame:RegisterEvent("PET_STABLE_UPDATE") + MTH_PetLifecycleEventFrame:RegisterEvent("PET_STABLE_CLOSED") + MTH_PetLifecycleEventFrame:SetScript("OnEvent", MTH_ST_OnEvent) MTH_ST_LastUnitPetHadPet = ((type(UnitExists) == "function") and UnitExists("pet")) and true or false MTH_PETS_InstallCoreRenameHook() MTH_PETS_EmitLiveState("service:init", true) @@ -3318,11 +4149,11 @@ function MTH_ST_InitBootstrap() if MTH and MTH.ApplyClassGate and MTH:ApplyClassGate("stablescan-bootstrap") then return end - if MTH_StableFrame or MTH_ST_BootstrapFrame then + if MTH_PetLifecycleEventFrame or MTH_PetLifecycleBootstrapFrame then return end - local frame = CreateFrame("Frame") + local frame = CreateFrame("Frame", "MTHPetLifecycleBootstrapFrame") if not frame then return end @@ -3334,27 +4165,30 @@ function MTH_ST_InitBootstrap() if evt ~= "PLAYER_ENTERING_WORLD" and evt ~= "PET_STABLE_SHOW" and evt ~= "PET_STABLE_UPDATE" then return end + if evt == "PET_STABLE_SHOW" or evt == "PET_STABLE_UPDATE" then + MTH_ST_PrintStableDiag("bootstrap evt=" .. tostring(evt) .. " phase=begin") + end MTH_ST_InitService() - if (evt == "PET_STABLE_SHOW" or evt == "PET_STABLE_UPDATE") and type(MTH_ST_Scan) == "function" then - MTH_ST_Scan("bootstrap:" .. tostring(evt)) + if evt == "PET_STABLE_SHOW" or evt == "PET_STABLE_UPDATE" then + MTH_ST_PrintStableDiag("bootstrap evt=" .. tostring(evt) .. " phase=end action=no-scan") end if self and self.UnregisterAllEvents then self:UnregisterAllEvents() self:SetScript("OnEvent", nil) end - MTH_ST_BootstrapFrame = nil + MTH_PetLifecycleBootstrapFrame = nil end) - MTH_ST_BootstrapFrame = frame + MTH_PetLifecycleBootstrapFrame = frame end function MTH_ST_ShutdownService(_reason) - if not MTH_StableFrame then + if not MTH_PetLifecycleEventFrame then return end - MTH_StableFrame:UnregisterAllEvents() - MTH_StableFrame:SetScript("OnEvent", nil) - MTH_StableFrame = nil + MTH_PetLifecycleEventFrame:UnregisterAllEvents() + MTH_PetLifecycleEventFrame:SetScript("OnEvent", nil) + MTH_PetLifecycleEventFrame = nil MTH_ST_LastAutoScanAt = 0 MTH_ST_LastUnitPetHadPet = false MTH_PETS_LiveState = nil diff --git a/api/core.lua b/api/core.lua index 71cdfa3..f077880 100644 --- a/api/core.lua +++ b/api/core.lua @@ -240,6 +240,34 @@ local function MTH_CommandPetSpellScanFallback() end end +local function MTH_CommandFoodData(limitText) + if type(FOM_PrintCollectedFoodData) ~= "function" then + MTH:Print("Food data command is not available yet") + return + end + local trimmed = string.gsub(tostring(limitText or ""), "^%s+", "") + trimmed = string.gsub(trimmed, "%s+$", "") + if string.lower(trimmed) == "purge" then + if type(FOM_PruneCollectedFoodData) ~= "function" then + MTH:Print("Food data purge is not available yet") + return + end + local removed = tonumber(FOM_PruneCollectedFoodData()) or 0 + MTH:Print("Food data purge removed rows: " .. tostring(removed)) + return + end + if trimmed == "" then + FOM_PrintCollectedFoodData(nil) + return + end + local limit = tonumber(trimmed) + if not limit then + MTH:Print("Usage: /mth food [limit|purge]") + return + end + FOM_PrintCollectedFoodData(limit) +end + if type(MTH_PS_ScanNow) ~= "function" then function MTH_PS_ScanNow(trigger) local ok, count = MTH_CommandPetSpellScanFallback() @@ -282,49 +310,18 @@ function SlashCmdList.MTH(msg, editbox) msg = string.gsub(tostring(msg), "^%s+", "") msg = string.gsub(msg, "%s+$", "") local lowerMsg = string.lower(msg) + local _, _, lowerCmd, lowerArg = string.find(lowerMsg, "^(%S+)%s*(.-)%s*$") if msg == "" then MTH:Print("Available: /mth options, /mth book") - MTH:Print("Debug: /mth petsstate, /mth petsdump, /mth petssnap, /mth petsdiff") elseif lowerMsg == "options" then MTH_CommandOptions() elseif lowerMsg == "book" or lowerMsg == "hunterbook" then MTH_CommandBook() elseif lowerMsg == "peers" or lowerMsg == "who" then MTH_CommandPeers() - elseif lowerMsg == "petsstate" then - if type(MTH_CommandPetsState) == "function" then - MTH_CommandPetsState() - else - MTH:Print("Pets state command is not available yet") - end - elseif lowerMsg == "petsdump" then - if type(MTH_CommandPetsDump) == "function" then - MTH_CommandPetsDump() - else - MTH:Print("Pets dump command is not available yet") - end - elseif lowerMsg == "petssnap" then - if type(MTH_CommandPetsSnap) == "function" then - MTH_CommandPetsSnap() - else - MTH:Print("Pets snapshot command is not available yet") - end - elseif lowerMsg == "petsdiff" then - if type(MTH_CommandPetsDiff) == "function" then - MTH_CommandPetsDiff() - else - MTH:Print("Pets diff command is not available yet") - end - elseif lowerMsg == "err" then - if MTH_DebugFrame and type(MTH_DebugFrame.Toggle) == "function" then - MTH_DebugFrame:Toggle() - else - MTH:Print("Debug frame is not available yet") - end else MTH:Print("Unknown command: " .. tostring(msg)) MTH:Print("Available: /mth options, /mth book") - MTH:Print("Debug: /mth petsstate, /mth petsdump, /mth petssnap, /mth petsdiff") end end diff --git a/api/hunterbook.lua b/api/hunterbook.lua index eddd642..f91d069 100644 --- a/api/hunterbook.lua +++ b/api/hunterbook.lua @@ -55,7 +55,7 @@ local function MTH_BOOK_FamiliesTrace(message) end local MTH_BOOK_STATE = { - mode = "stable", + mode = "pets", search = "", quick = "all", minLevel = nil, @@ -1537,13 +1537,75 @@ local function MTH_BOOK_BuildResults() if MTH_BOOK_STATE.mode == "pethistory" then local store = MTH_BOOK_GetPetDatastore() if type(store) ~= "table" or type(store.historyById) ~= "table" then return results end + + local cached = {} for petId, row in pairs(store.historyById) do if type(row) == "table" then - table.insert(results, petId) + local idText = tostring(petId) + table.insert(cached, { + id = petId, + idText = idText, + name = MTH_BOOK_SafeLower(row.name), + family = MTH_BOOK_SafeLower(row.family), + level = tonumber(row.level) or 0, + abandonedAt = tonumber(row.abandonedAt) or 0, + reason = MTH_BOOK_SafeLower(tostring(row.abandonReason or "")), + }) end end - table.sort(results, MTH_BOOK_PetHistorySort) - MTH_BOOK_ApplyActiveSort(results) + + local function defaultHistoryCompare(a, b) + if a.abandonedAt ~= b.abandonedAt then + return a.abandonedAt > b.abandonedAt + end + if a.name ~= b.name then + return a.name < b.name + end + return a.idText < b.idText + end + + local state = MTH_BOOK_GetSortState() + if state and state.col then + table.sort(cached, function(a, b) + local ka = nil + local kb = nil + if state.col == 1 then + ka = a.idText + kb = b.idText + elseif state.col == 2 then + ka = a.name + kb = b.name + elseif state.col == 3 then + ka = a.family + kb = b.family + elseif state.col == 4 then + ka = a.level + kb = b.level + elseif state.col == 5 then + ka = a.abandonedAt + kb = b.abandonedAt + elseif state.col == 6 then + ka = a.reason + kb = b.reason + end + + if ka ~= kb then + if ka == nil then return not state.asc end + if kb == nil then return state.asc end + if state.asc then + return ka < kb + end + return ka > kb + end + return defaultHistoryCompare(a, b) + end) + else + table.sort(cached, defaultHistoryCompare) + end + + for i = 1, table.getn(cached) do + table.insert(results, cached[i].id) + end return results end @@ -4379,23 +4441,14 @@ local function MTH_BOOK_LayoutSectionTabs() local sectionPetHistory = getglobal("MTH_BOOK_SectionPetHistory") if not (tabBar and sectionPets and sectionFamilies and sectionPetAbilities and sectionItems and sectionNPCs and sectionProjectiles and sectionAmmoBags) then return end - if sectionStable then - sectionStable:ClearAllPoints() - sectionStable:SetPoint("TOPLEFT", tabBar, "TOPLEFT", 0, 0) - end + sectionPets:ClearAllPoints() + sectionPets:SetPoint("TOPLEFT", tabBar, "TOPLEFT", 0, 0) sectionPetAbilities:ClearAllPoints() - if sectionStable then - sectionPetAbilities:SetPoint("LEFT", sectionStable, "RIGHT", 2, 0) - else - sectionPetAbilities:SetPoint("TOPLEFT", tabBar, "TOPLEFT", 0, 0) - end - - sectionPets:ClearAllPoints() - sectionPets:SetPoint("LEFT", sectionPetAbilities, "RIGHT", 2, 0) + sectionPetAbilities:SetPoint("LEFT", sectionPets, "RIGHT", 2, 0) sectionFamilies:ClearAllPoints() - sectionFamilies:SetPoint("LEFT", sectionPets, "RIGHT", 2, 0) + sectionFamilies:SetPoint("LEFT", sectionPetAbilities, "RIGHT", 2, 0) sectionNPCs:ClearAllPoints() sectionNPCs:SetPoint("LEFT", sectionFamilies, "RIGHT", 2, 0) @@ -4409,9 +4462,18 @@ local function MTH_BOOK_LayoutSectionTabs() sectionAmmoBags:ClearAllPoints() sectionAmmoBags:SetPoint("LEFT", sectionProjectiles, "RIGHT", 2, 0) + if sectionStable then + sectionStable:ClearAllPoints() + sectionStable:SetPoint("LEFT", sectionAmmoBags, "RIGHT", 2, 0) + end + if sectionPetHistory then sectionPetHistory:ClearAllPoints() - sectionPetHistory:SetPoint("LEFT", sectionAmmoBags, "RIGHT", 2, 0) + if sectionStable then + sectionPetHistory:SetPoint("LEFT", sectionStable, "RIGHT", 2, 0) + else + sectionPetHistory:SetPoint("LEFT", sectionAmmoBags, "RIGHT", 2, 0) + end end end diff --git a/api/localization.lua b/api/localization.lua index 9d9e439..a165f36 100644 --- a/api/localization.lua +++ b/api/localization.lua @@ -198,6 +198,9 @@ local MTH_SPELL_ID_BY_TOKEN = { ["Counterattack"] = 19306, ["Clever Traps"] = 19239, ["Deterrence"] = 19263, + ["Entrapment"] = 19184, + ["Entrapment (Rank 2)"] = 19387, + ["Entrapment (Rank 3)"] = 19388, ["Explosive Trap"] = 13813, ["Explosive Trap Effect"] = 13812, ["Feed Pet"] = 6991, diff --git a/api/options-autobuy.lua b/api/options-autobuy.lua index 6f8e148..8f9eaf8 100644 --- a/api/options-autobuy.lua +++ b/api/options-autobuy.lua @@ -53,6 +53,14 @@ local function MTH_AB_SetCheckedNoClick(check, checked) MTH_AB_STATE.syncing = false end +local function MTH_AB_IsChecked(control) + if not control or type(control.GetChecked) ~= "function" then + return false + end + local checked = control:GetChecked() + return checked == 1 or checked == true +end + local function MTH_AB_GetEngine() return MTH_AutoBuyEngine end @@ -92,10 +100,10 @@ end local function MTH_AB_EnsureConfig() local store = MTH_AB_GetStore() - if store.enabled == nil then store.enabled = true end + if store.enabled == nil then store.enabled = false end if type(store.projectiles) ~= "table" then store.projectiles = {} end if type(store.petFood) ~= "table" then store.petFood = {} end - if store.projectiles.enabled == nil then store.projectiles.enabled = true end + if store.projectiles.enabled == nil then store.projectiles.enabled = false end if store.petFood.enabled == nil then store.petFood.enabled = false end if store.petFood.stacks == nil then store.petFood.stacks = 1 end if store.petFood.scope == nil then store.petFood.scope = "current" end @@ -510,7 +518,7 @@ local function MTH_AB_RefreshOptionsUI() local store = MTH_AB_EnsureConfig() local moduleEnabled = true if MTH and MTH.IsModuleEnabled then - moduleEnabled = MTH:IsModuleEnabled("autobuy", true) and true or false + moduleEnabled = MTH:IsModuleEnabled("autobuy", false) and true or false end MTH_AB_SetCheckedNoClick(MTH_AB_STATE.controls.moduleEnabled, moduleEnabled) @@ -653,7 +661,7 @@ local function MTH_AB_CreateRow(container, subtype, index, xBase, yOffset) self = self or this if not self or MTH_AB_STATE.syncing then return end local store = MTH_AB_EnsureConfig() - MTH_AB_SetRuleEnabled(store, subtype, index, self:GetChecked() and true or false) + MTH_AB_SetRuleEnabled(store, subtype, index, MTH_AB_IsChecked(self)) MTH_AB_RefreshOptionsUI() end) end @@ -711,7 +719,7 @@ local function MTH_AB_BuildUI(container) self = self or this if not self or MTH_AB_STATE.syncing then return end if MTH and MTH.SetModuleEnabled then - local ok, err = MTH:SetModuleEnabled("autobuy", self:GetChecked() and true or false) + local ok, err = MTH:SetModuleEnabled("autobuy", MTH_AB_IsChecked(self)) if not ok and MTH and MTH.Print then MTH:Print("Failed to change Auto Buy state: " .. tostring(err), "error") end @@ -726,7 +734,7 @@ local function MTH_AB_BuildUI(container) self = self or this if not self or MTH_AB_STATE.syncing then return end local store = MTH_AB_EnsureConfig() - store.projectiles.enabled = self:GetChecked() and true or false + store.projectiles.enabled = MTH_AB_IsChecked(self) MTH_AB_RefreshOptionsUI() end) end @@ -765,7 +773,7 @@ local function MTH_AB_BuildUI(container) self = self or this if not self or MTH_AB_STATE.syncing then return end local store = MTH_AB_EnsureConfig() - store.petFood.enabled = self:GetChecked() and true or false + store.petFood.enabled = MTH_AB_IsChecked(self) MTH_AB_RefreshOptionsUI() end) end @@ -843,6 +851,7 @@ local function MTH_AB_BuildUI(container) text:SetText("") controls.petFoodPetLines[i] = { icon = icon, text = text } end + end function MTH_SetupAutoBuyOptions() diff --git a/api/options-autoquest.lua b/api/options-autoquest.lua new file mode 100644 index 0000000..7451b53 --- /dev/null +++ b/api/options-autoquest.lua @@ -0,0 +1,232 @@ +local MTH_AQ_READY = MTH_OptionsRequire and MTH_OptionsRequire("options-autoquest", { + "MTH_GetFrame", + "MTH_ClearContainer", + "MTH_CreateCheckbox", +}) + +local function MTH_AQ_IsChecked(control) + if not control or type(control.GetChecked) ~= "function" then + return false + end + local checked = control:GetChecked() + return checked == 1 or checked == true +end + +local function MTH_AQ_EnsureStore() + if type(MTH_CharSavedVariables) ~= "table" then + MTH_CharSavedVariables = {} + end + if type(MTH_CharSavedVariables.autoquest) ~= "table" then + MTH_CharSavedVariables.autoquest = {} + end + local store = MTH_CharSavedVariables.autoquest + if type(MTH_CharSavedVariables.questautomation) == "table" then + local legacy = MTH_CharSavedVariables.questautomation + if store.scorpokDrazial == nil and legacy.scorpokDrazial ~= nil then + store.scorpokDrazial = legacy.scorpokDrazial and true or false + end + if store.scorpokTooltip == nil and legacy.scorpokTooltip ~= nil then + store.scorpokTooltip = legacy.scorpokTooltip and true or false + end + end + if store.scorpokDrazial == nil then + store.scorpokDrazial = false + end + if store.arrowsForSissies == nil then + store.arrowsForSissies = false + end + return store +end + +function MTH_SetupAutoQuestOptions() + if not MTH_AQ_READY then return end + + local container = MTH_GetFrame("MetaHuntOptionsAutoQuest") + if not container then return end + + MTH_ClearContainer(container) + + local title = container:CreateFontString("MetaHuntOptionsAutoQuestTitle", "ARTWORK", "GameFontHighlight") + title:SetPoint("TOPLEFT", container, "TOPLEFT", 20, -10) + title:SetText("Auto Quest") + + local body = container:CreateFontString("MetaHuntOptionsAutoQuestBody", "ARTWORK", "GameFontNormalSmall") + body:SetPoint("TOPLEFT", container, "TOPLEFT", 20, -30) + body:SetWidth(560) + body:SetJustifyH("LEFT") + body:SetJustifyV("TOP") + body:SetTextColor(0.45, 0.75, 1) + body:SetText("Automation for specific quest interactions.") + + local moduleEnabled = true + if MTH and MTH.GetModule then + local module = MTH:GetModule("autoquest") + if type(module) == "table" and module.enabled ~= nil then + moduleEnabled = module.enabled and true or false + elseif MTH and MTH.IsModuleEnabled then + moduleEnabled = MTH:IsModuleEnabled("autoquest", false) and true or false + end + elseif MTH and MTH.IsModuleEnabled then + moduleEnabled = MTH:IsModuleEnabled("autoquest", false) and true or false + end + local moduleToggle = MTH_CreateCheckbox(container, "MetaHuntOptionsAutoQuestModuleToggle", "Enable Auto Quest module", -70, 20) + if moduleToggle then + moduleToggle:SetChecked(moduleEnabled and true or false) + moduleToggle:SetScript("OnClick", function(self) + self = self or this + if not self then return end + local enabled = MTH_AQ_IsChecked(self) + if MTH and MTH.SetModuleEnabled then + local ok, err = MTH:SetModuleEnabled("autoquest", enabled) + if not ok and MTH and MTH.Print then + MTH:Print("Failed to change Auto Quest module state: " .. tostring(err), "error") + end + end + local module = MTH and MTH.GetModule and MTH:GetModule("autoquest") or nil + local actual = (type(module) == "table" and module.enabled ~= nil) and (module.enabled and true or false) or enabled + self:SetChecked(actual and true or false) + end) + end + + local store = MTH_AQ_EnsureStore() + if store.scorpokTooltip == nil then + store.scorpokTooltip = false + end + + local sectionWidth = (container:GetWidth() or 600) - 40 + if sectionWidth < 360 then + sectionWidth = 360 + end + + local function ensureSection(name, sectionTitle, yOffset, height) + local section = getglobal(name) + if not section then + section = CreateFrame("Frame", name, container, "OptionFrameBoxTemplate") + end + if not section then + return nil + end + section:SetParent(container) + section:ClearAllPoints() + section:SetPoint("TOPLEFT", container, "TOPLEFT", 20, yOffset) + section:SetWidth(sectionWidth) + section:SetHeight(height) + section:Show() + + local titleFrame = getglobal(name .. "Title") + if titleFrame then + titleFrame:SetText(sectionTitle) + end + return section + end + + local function ensureCheckbox(section, name, label, yOffset, checked) + local check = getglobal(name) + if not check then + check = CreateFrame("CheckButton", name, section, "OptionsCheckButtonTemplate") + end + if not check then + return nil + end + check:SetParent(section) + check:ClearAllPoints() + check:SetPoint("TOPLEFT", section, "TOPLEFT", 12, yOffset) + check:SetChecked(checked and true or false) + check:Show() + + local text = getglobal(name .. "Text") + if text then + text:SetText(label) + end + return check + end + + local scorpokSection = ensureSection("MetaHuntAutoQuestScorpokBox", "Salt of the Scorpok", -130, 112) + if scorpokSection then + local scorpokToggle = ensureCheckbox( + scorpokSection, + "MetaHuntOptionsAutoQuestScorpokToggle", + "Enable SHIFT-rightclick for Bloodmage Drazial", + -10, + store.scorpokDrazial and true or false + ) + if scorpokToggle then + scorpokToggle:SetScript("OnClick", function(self) + self = self or this + local enabled = MTH_AQ_IsChecked(self) + store.scorpokDrazial = enabled and true or false + if MTH and MTH.GetModuleCharSavedVariables then + local moduleStore = MTH:GetModuleCharSavedVariables("autoquest") + if type(moduleStore) == "table" then + moduleStore.scorpokDrazial = store.scorpokDrazial and true or false + end + end + local module = MTH and MTH.GetModule and MTH:GetModule("autoquest") + if module and module.SetScorpokDrazialEnabled then + module:SetScorpokDrazialEnabled(enabled) + end + end) + end + + local tooltipToggle = ensureCheckbox( + scorpokSection, + "MetaHuntOptionsAutoQuestScorpokTooltipToggle", + "Enhance Tooltip on Drazial and related beasts (requires Tooltips module enabled)", + -42, + store.scorpokTooltip and true or false + ) + if tooltipToggle then + tooltipToggle:SetScript("OnClick", function(self) + self = self or this + local enabled = MTH_AQ_IsChecked(self) + store.scorpokTooltip = enabled and true or false + if MTH and MTH.GetModuleCharSavedVariables then + local moduleStore = MTH:GetModuleCharSavedVariables("autoquest") + if type(moduleStore) == "table" then + moduleStore.scorpokTooltip = store.scorpokTooltip and true or false + end + end + end) + end + end + + local sissiesSection = ensureSection("MetaHuntAutoQuestSissiesBox", "Arrows Are For Sissies", -255, 108) + if sissiesSection then + local sissiesNote = getglobal("MetaHuntOptionsAutoQuestSissiesNote") + if not sissiesNote then + sissiesNote = sissiesSection:CreateFontString("MetaHuntOptionsAutoQuestSissiesNote", "ARTWORK", "GameFontNormalSmall") + end + sissiesNote:ClearAllPoints() + sissiesNote:SetPoint("TOPLEFT", sissiesSection, "TOPLEFT", 14, -10) + sissiesNote:SetWidth(sectionWidth - 28) + sissiesNote:SetJustifyH("LEFT") + sissiesNote:SetTextColor(0.45, 0.75, 1) + sissiesNote:SetText("Note that you should not activate this if you are using LazyPig") + sissiesNote:Show() + + local sissiesToggle = ensureCheckbox( + sissiesSection, + "MetaHuntOptionsAutoQuestSissiesToggle", + "Enable SHIFT-rightclick for Artilleryman Sheldonore", + -40, + store.arrowsForSissies and true or false + ) + if sissiesToggle then + sissiesToggle:SetScript("OnClick", function(self) + self = self or this + local enabled = MTH_AQ_IsChecked(self) + store.arrowsForSissies = enabled and true or false + if MTH and MTH.GetModuleCharSavedVariables then + local moduleStore = MTH:GetModuleCharSavedVariables("autoquest") + if type(moduleStore) == "table" then + moduleStore.arrowsForSissies = store.arrowsForSissies and true or false + end + end + local module = MTH and MTH.GetModule and MTH:GetModule("autoquest") + if module and module.SetArrowsForSissiesEnabled then + module:SetArrowsForSissiesEnabled(enabled) + end + end) + end + end +end diff --git a/api/options-chronometer.lua b/api/options-chronometer.lua index 33e81c2..fcfaf00 100644 --- a/api/options-chronometer.lua +++ b/api/options-chronometer.lua @@ -307,6 +307,14 @@ local function MTH_CHRON_MakeSmall(parent, name, text, x, y) return label end +local function MTH_CHRON_IsChecked(control) + if not control or type(control.GetChecked) ~= "function" then + return false + end + local checked = control:GetChecked() + return checked == 1 or checked == true +end + local function MTH_CHRON_MakeInput(parent, name, x, y, width, height, value, onAccept) local edit = CreateFrame("EditBox", name, parent, "InputBoxTemplate") @@ -380,11 +388,16 @@ local function MTH_CHRON_RenderTimerSection(container, profile, title, items, bu check:SetScript("OnClick", function(self) self = self or this if not self then return end - if self:GetChecked() then + local currentlyDisabled = profile.disabledSpells[bucket][timerName] ~= nil + if currentlyDisabled then profile.disabledSpells[bucket][timerName] = nil + self:SetChecked(true) else profile.disabledSpells[bucket][timerName] = {} + self:SetChecked(nil) end + + MTH_CHRON_ApplyLiveProfile(profile) end) end timerY = timerY - 20 @@ -460,7 +473,7 @@ function MTH_SetupChronometerOptions(tabKey) moduleCheck:SetScript("OnClick", function(self) self = self or this if not self then return end - local enabled = self:GetChecked() and true or false + local enabled = MTH_CHRON_IsChecked(self) if MTH and MTH.SetModuleEnabled then MTH:SetModuleEnabled("chronometer", enabled) end @@ -485,7 +498,7 @@ function MTH_SetupChronometerOptions(tabKey) killCheck:SetScript("OnClick", function(self) self = self or this if not self then return end - profile.fadeonkill = self:GetChecked() and true or false + profile.fadeonkill = MTH_CHRON_IsChecked(self) end) end @@ -495,7 +508,7 @@ function MTH_SetupChronometerOptions(tabKey) fadeCheck:SetScript("OnClick", function(self) self = self or this if not self then return end - profile.fadeonfade = self:GetChecked() and true or false + profile.fadeonfade = MTH_CHRON_IsChecked(self) end) end @@ -505,7 +518,7 @@ function MTH_SetupChronometerOptions(tabKey) selfCheck:SetScript("OnClick", function(self) self = self or this if not self then return end - profile.selfbars = self:GetChecked() and true or false + profile.selfbars = MTH_CHRON_IsChecked(self) end) end @@ -515,7 +528,7 @@ function MTH_SetupChronometerOptions(tabKey) onlySelfCheck:SetScript("OnClick", function(self) self = self or this if not self then return end - profile.onlyself = self:GetChecked() and true or false + profile.onlyself = MTH_CHRON_IsChecked(self) end) end elseif section == "bar" then @@ -600,7 +613,7 @@ function MTH_SetupChronometerOptions(tabKey) growCheck:SetScript("OnClick", function(self) self = self or this if not self then return end - profile.growup = self:GetChecked() and true or false + profile.growup = MTH_CHRON_IsChecked(self) MTH_CHRON_ApplyLiveProfile(profile) end) end @@ -611,7 +624,7 @@ function MTH_SetupChronometerOptions(tabKey) reverseCheck:SetScript("OnClick", function(self) self = self or this if not self then return end - profile.reverse = self:GetChecked() and true or false + profile.reverse = MTH_CHRON_IsChecked(self) MTH_CHRON_ApplyLiveProfile(profile) end) end diff --git a/api/options-credits.lua b/api/options-credits.lua index a73d921..79d59f4 100644 --- a/api/options-credits.lua +++ b/api/options-credits.lua @@ -159,6 +159,16 @@ function MTH_SetupCreditsOptions() cursor, -4) + + cursor = MTH_CreditsCreateText(content, cursor, + MTH_CR_L("CREDITS_ABOUT_ICU", "- ICU was an old vanilla addon. I used the following last revision and added many more features of config options to it."), + "GameFontNormalSmall", -10) + cursor = MTH_CreditsCreateLink(content, + "https://github.com/wigan91/ICU-TWoW", + "https://github.com/wigan91/ICU-TWoW", + cursor, + -4) + cursor = MTH_CreditsCreateText(content, cursor, MTH_CR_L("CREDITS_ABOUT_MAP", "- The Map/Marker system is the one of pfQuest from Master Shagu. Unfortunately he is no more reachable for some months and I could not have some talk with him about this. I mostly let it untouched, I just made slight modifications so it can integrate better within Metahunt, and doesn't conflict with pfQuest."), "GameFontNormalSmall", -10) diff --git a/api/options-icu.lua b/api/options-icu.lua new file mode 100644 index 0000000..8ca2e26 --- /dev/null +++ b/api/options-icu.lua @@ -0,0 +1,598 @@ +local MTH_ICU_OPT_READY = true +local MTH_ICU_OPT_MISSING = {} + +if type(MTH_GetFrame) ~= "function" then table.insert(MTH_ICU_OPT_MISSING, "MTH_GetFrame") end +if type(MTH_ClearContainer) ~= "function" then table.insert(MTH_ICU_OPT_MISSING, "MTH_ClearContainer") end +if type(MTH_CreateCheckbox) ~= "function" then table.insert(MTH_ICU_OPT_MISSING, "MTH_CreateCheckbox") end + +if table.getn(MTH_ICU_OPT_MISSING) > 0 then + MTH_ICU_OPT_READY = false + if MTH and MTH.Print then + MTH:Print("[MTH ICU OPTIONS] missing dependencies: " .. table.concat(MTH_ICU_OPT_MISSING, ", "), "error") + end +end + +local MTH_ICU_OPT_STATE = { + container = nil, + built = false, + controls = {}, +} + +local MTH_ICU_LAYOUT = { + LEFT_X = 20, + LEFT_DROPDOWN_X = 94, + RIGHT_X = 280, + RIGHT_DROPDOWN_X = 366, + DROPDOWN_WIDTH = 108, + COLOR_BUTTON_X = 378, + COLOR_BUTTON_WIDTH = 78, + COLOR_ROW_STEP = 24, +} + +local MTH_ICU_OPTIONS_FALLBACK = { + ALERT = { "AUTO", "PARTY", "RAID", "SELF", "MTH Message", "OFF" }, + ANNOUNCE = { "AUTO", "SAY", "YELL", "PARTY", "RAID", "SELF", "MTH Message", "OFF" }, + ANCHOR = { "TOP", "TOPLEFT", "TOPRIGHT", "BOTTOM", "BOTTOMLEFT", "BOTTOMRIGHT", "LEFT", "RIGHT", "CUSTOM" }, + POPUP_HIDE_DELAY = { "INSTANT", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10" }, + HEALTH_TEXT_MODE = { "NONE", "PERCENT", "HP", "BOTH" }, + PLAYER_COLOR_MODE = { "CLASS", "REACTION", "FACTION", "CUSTOM" }, +} + +local MTH_ICU_DEFAULT_BG_COLORS = { + PLAYER_HOSTILE = { 1.00, 0.20, 0.20 }, + PLAYER_NEUTRAL = { 1.00, 0.85, 0.10 }, + PLAYER_FRIENDLY = { 0.20, 0.75, 1.00 }, + NPC_HOSTILE = { 1.00, 0.20, 0.20 }, + NPC_NEUTRAL = { 1.00, 0.85, 0.10 }, + NPC_FRIENDLY = { 0.10, 0.90, 0.10 }, + UNKNOWN = { 0.70, 0.70, 0.70 }, +} + +local MTH_ICU_COLOR_ROWS = { + { key = "PLAYER_HOSTILE", label = "Player hostile" }, + { key = "PLAYER_NEUTRAL", label = "Player neutral" }, + { key = "PLAYER_FRIENDLY", label = "Player friendly" }, + { key = "NPC_HOSTILE", label = "NPC hostile" }, + { key = "NPC_NEUTRAL", label = "NPC neutral" }, + { key = "NPC_FRIENDLY", label = "NPC friendly" }, + { key = "UNKNOWN", label = "Unknown fallback" }, +} + +local function MTH_ICU_GetStore() + local store = nil + if MTH and MTH.GetModuleCharSavedVariables then + store = MTH:GetModuleCharSavedVariables("icu") + end + if type(store) ~= "table" then + ICUvars = ICUvars or {} + store = ICUvars + end + + if store.ALERT == nil then store.ALERT = "MTH Message" end + if store.ALERT == "SAY" or store.ALERT == "YELL" then store.ALERT = "SELF" end + if store.ANNOUNCE == nil then store.ANNOUNCE = "MTH Message" end + if store.ANCHOR == nil then store.ANCHOR = "BOTTOMRIGHT" end + if store.POPUP_HIDE_DELAY == nil then + store.POPUP_HIDE_DELAY = "INSTANT" + else + local rawDelay = tostring(store.POPUP_HIDE_DELAY) + if rawDelay ~= "INSTANT" then + local n = tonumber(rawDelay) + if not n then + store.POPUP_HIDE_DELAY = "INSTANT" + else + n = math.floor(n) + if n < 1 then + store.POPUP_HIDE_DELAY = "INSTANT" + elseif n > 10 then + store.POPUP_HIDE_DELAY = "10" + else + store.POPUP_HIDE_DELAY = tostring(n) + end + end + end + end + if store.mouseOver == nil then store.mouseOver = false end + if store.HEALTH_TEXT_MODE == nil then store.HEALTH_TEXT_MODE = "BOTH" end + if store.HEALTH_TEXT_MODE == "AUTO" then store.HEALTH_TEXT_MODE = "BOTH" end + if store.PLAYER_COLOR_MODE == nil then store.PLAYER_COLOR_MODE = "CLASS" end + if store.REACTION_ONLY_PVP_PLAYERS == nil then store.REACTION_ONLY_PVP_PLAYERS = true end + if store.SHOW_GUILD_NAME == nil then store.SHOW_GUILD_NAME = false end + if store.SHOW_PLAYER_CLASS == nil then store.SHOW_PLAYER_CLASS = true end + if store.SHOW_PLAYER_RACE == nil then store.SHOW_PLAYER_RACE = true end + if store.SHOW_CUSTOM_TITLES == nil then store.SHOW_CUSTOM_TITLES = false end + if store.EXPAND_UP == nil then store.EXPAND_UP = false end + + if type(store.COLORS) ~= "table" then + store.COLORS = {} + end + for key, def in pairs(MTH_ICU_DEFAULT_BG_COLORS) do + if type(store.COLORS[key]) ~= "table" then + store.COLORS[key] = { def[1], def[2], def[3] } + end + end + + ICUvars = store + return store +end + +local function MTH_ICU_GetOptionValues(key) + if type(ICU_OPTIONS) == "table" and type(ICU_OPTIONS[key]) == "table" then + return ICU_OPTIONS[key] + end + return MTH_ICU_OPTIONS_FALLBACK[key] or {} +end + +local function MTH_ICU_SetValue(key, value) + local store = MTH_ICU_GetStore() + store[key] = tostring(value or "") + if key == "ANCHOR" and type(ICU_SetPoints) == "function" then + ICU_SetPoints() + end +end + +local function MTH_ICU_DropdownSetText(frame, text) + if UIDropDownMenu_SetText then + UIDropDownMenu_SetText(tostring(text or ""), frame) + return + end + if not frame or not frame.GetName then return end + local textRegion = getglobal(frame:GetName() .. "Text") + if textRegion and textRegion.SetText then + textRegion:SetText(tostring(text or "")) + end +end + +local function MTH_ICU_GetColor(key) + local store = MTH_ICU_GetStore() + local row = store.COLORS and store.COLORS[key] + if type(row) == "table" then + return tonumber(row[1]) or 0, tonumber(row[2]) or 0, tonumber(row[3]) or 0 + end + local def = MTH_ICU_DEFAULT_BG_COLORS[key] or MTH_ICU_DEFAULT_BG_COLORS.UNKNOWN + return def[1], def[2], def[3] +end + +local function MTH_ICU_SetColor(key, r, g, b) + local store = MTH_ICU_GetStore() + if type(store.COLORS) ~= "table" then + store.COLORS = {} + end + store.COLORS[key] = { + math.max(0, math.min(1, tonumber(r) or 0)), + math.max(0, math.min(1, tonumber(g) or 0)), + math.max(0, math.min(1, tonumber(b) or 0)), + } +end + +local function MTH_ICU_OpenColorPicker(colorKey) + if not ColorPickerFrame then + if MTH and MTH.Print then + MTH:Print("ColorPickerFrame is not available in this client", "error") + end + return + end + + local currentR, currentG, currentB = MTH_ICU_GetColor(colorKey) + local function applyColor() + local r, g, b = ColorPickerFrame:GetColorRGB() + MTH_ICU_SetColor(colorKey, r, g, b) + MTH_RefreshICUOptions() + end + + ColorPickerFrame.func = applyColor + ColorPickerFrame.cancelFunc = function(prev) + if type(prev) == "table" then + local r = prev.r or prev[1] or currentR + local g = prev.g or prev[2] or currentG + local b = prev.b or prev[3] or currentB + MTH_ICU_SetColor(colorKey, r, g, b) + MTH_RefreshICUOptions() + end + end + ColorPickerFrame:SetColorRGB(currentR, currentG, currentB) + ColorPickerFrame.hasOpacity = false + ColorPickerFrame.opacity = 1 + ColorPickerFrame.previousValues = { currentR, currentG, currentB } + ColorPickerFrame:Show() +end + +local function MTH_ICU_CreateOptionRow(container, rowName, label, key, y, labelX, dropdownX) + labelX = labelX or MTH_ICU_LAYOUT.LEFT_X + dropdownX = dropdownX or MTH_ICU_LAYOUT.LEFT_DROPDOWN_X + + local labelFs = container:CreateFontString(rowName .. "Label", "ARTWORK", "GameFontNormalSmall") + labelFs:SetPoint("TOPLEFT", container, "TOPLEFT", labelX, y) + labelFs:SetText(label) + + local dropdown = CreateFrame("Frame", rowName .. "Drop", container, "UIDropDownMenuTemplate") + dropdown:ClearAllPoints() + dropdown:SetPoint("TOPLEFT", container, "TOPLEFT", dropdownX, y + 12) + if UIDropDownMenu_SetWidth then + UIDropDownMenu_SetWidth(MTH_ICU_LAYOUT.DROPDOWN_WIDTH, dropdown) + end + if UIDropDownMenu_JustifyText then + UIDropDownMenu_JustifyText("LEFT", dropdown) + end + + if UIDropDownMenu_Initialize then + UIDropDownMenu_Initialize(dropdown, function() + local values = MTH_ICU_GetOptionValues(key) + local store = MTH_ICU_GetStore() + local current = tostring(store[key] or "") + local infoFactory = UIDropDownMenu_CreateInfo + + for i = 1, table.getn(values) do + local optionValue = tostring(values[i]) + local info = type(infoFactory) == "function" and infoFactory() or {} + info.text = optionValue + info.checked = optionValue == current and 1 or nil + info.func = function() + MTH_ICU_SetValue(key, optionValue) + MTH_RefreshICUOptions() + end + if UIDropDownMenu_AddButton then UIDropDownMenu_AddButton(info) end + end + end) + end + + MTH_ICU_OPT_STATE.controls[key] = { + label = labelFs, + dropdown = dropdown, + } +end + +local function MTH_ICU_CreateColorRow(container, rowName, label, colorKey, y, labelX, buttonX) + labelX = labelX or MTH_ICU_LAYOUT.RIGHT_X + buttonX = buttonX or MTH_ICU_LAYOUT.COLOR_BUTTON_X + + local labelFs = container:CreateFontString(rowName .. "Label", "ARTWORK", "GameFontNormalSmall") + labelFs:SetPoint("TOPLEFT", container, "TOPLEFT", labelX, y) + labelFs:SetText(label) + + local button = CreateFrame("Button", rowName .. "Button", container, "UIPanelButtonTemplate") + button:SetPoint("TOPLEFT", container, "TOPLEFT", buttonX, y + 8) + button:SetWidth(MTH_ICU_LAYOUT.COLOR_BUTTON_WIDTH) + button:SetHeight(22) + button:SetText("Pick") + button:SetScript("OnClick", function() + MTH_ICU_OpenColorPicker(colorKey) + end) + + local swatch = button:CreateTexture(rowName .. "Swatch", "ARTWORK") + swatch:SetTexture("Interface\\Buttons\\WHITE8X8") + swatch:SetPoint("LEFT", button, "LEFT", 6, 0) + swatch:SetWidth(14) + swatch:SetHeight(14) + + MTH_ICU_OPT_STATE.controls[colorKey] = { + label = labelFs, + button = button, + swatch = swatch, + isColor = true, + } +end + +local function MTH_ICU_BuildUI(container) + if MTH_ICU_OPT_STATE.built and MTH_ICU_OPT_STATE.container == container then + return + end + + MTH_ClearContainer(container) + MTH_ICU_OPT_STATE.controls = {} + MTH_ICU_OPT_STATE.container = container + MTH_ICU_OPT_STATE.built = true + + local title = container:CreateFontString("MetaHuntOptionsICUTitle", "ARTWORK", "GameFontHighlight") + title:SetPoint("TOPLEFT", container, "TOPLEFT", 20, -10) + title:SetText("MTH ICU") + + local note = container:CreateFontString("MetaHuntOptionsICUNote", "ARTWORK", "GameFontNormalSmall") + note:SetPoint("TOPLEFT", container, "TOPLEFT", 20, -32) + note:SetWidth(560) + note:SetJustifyH("LEFT") + note:SetJustifyV("TOP") + note:SetTextColor(0.35, 0.65, 1) + note:SetText("ICU is a minimap targeting utility module for your Trackings.\nIt provides enhanced target information and click actions for players and NPCs on the minimap.") + + local moduleEnabled = MTH_CreateCheckbox(container, "MetaHuntOptionsICUModuleEnabled", "Enable ICU module", -72, MTH_ICU_LAYOUT.LEFT_X) + if moduleEnabled then + moduleEnabled:SetScript("OnClick", function(self) + self = self or this + if not self then return end + if MTH and MTH.SetModuleEnabled then + local ok, err = MTH:SetModuleEnabled("icu", self:GetChecked() == 1) + if not ok and MTH and MTH.Print then + MTH:Print("Failed to change ICU module state: " .. tostring(err), "error") + end + end + MTH_RefreshICUOptions() + end) + MTH_ICU_OPT_STATE.controls.moduleEnabled = moduleEnabled + end + + local mouseOver = MTH_CreateCheckbox(container, "MetaHuntOptionsICUMouseOver", "Enable CTRL mouseover scan", -98, MTH_ICU_LAYOUT.LEFT_X) + if mouseOver then + mouseOver:SetScript("OnClick", function(self) + self = self or this + if not self then return end + local store = MTH_ICU_GetStore() + store.mouseOver = self:GetChecked() == 1 and true or false + end) + MTH_ICU_OPT_STATE.controls.mouseOver = mouseOver + end + + local sectionHeader = container:CreateFontString("MetaHuntOptionsICUSectionHeader", "ARTWORK", "GameFontHighlight") + sectionHeader:SetPoint("TOPLEFT", container, "TOPLEFT", MTH_ICU_LAYOUT.LEFT_X, -146) + sectionHeader:SetText("General Settings") + + MTH_ICU_CreateOptionRow(container, "MetaHuntOptionsICUAlert", "ALERT PVP", "ALERT", -172, MTH_ICU_LAYOUT.LEFT_X, MTH_ICU_LAYOUT.LEFT_DROPDOWN_X) + MTH_ICU_CreateOptionRow(container, "MetaHuntOptionsICUAnnounce", "ANNOUNCE on click", "ANNOUNCE", -198, MTH_ICU_LAYOUT.LEFT_X, MTH_ICU_LAYOUT.LEFT_DROPDOWN_X) + MTH_ICU_CreateOptionRow(container, "MetaHuntOptionsICUHealthMode", "Health text", "HEALTH_TEXT_MODE", -224, MTH_ICU_LAYOUT.LEFT_X, MTH_ICU_LAYOUT.LEFT_DROPDOWN_X) + MTH_ICU_CreateOptionRow(container, "MetaHuntOptionsICUHideDelay", "List hide delay", "POPUP_HIDE_DELAY", -250, MTH_ICU_LAYOUT.LEFT_X, MTH_ICU_LAYOUT.LEFT_DROPDOWN_X) + MTH_ICU_CreateOptionRow(container, "MetaHuntOptionsICUAnchor", "ANCHOR", "ANCHOR", -276, MTH_ICU_LAYOUT.LEFT_X, MTH_ICU_LAYOUT.LEFT_DROPDOWN_X) + + local toggleAnchorButton = CreateFrame("Button", "MetaHuntOptionsICUToggleAnchorButton", container, "UIPanelButtonTemplate") + toggleAnchorButton:SetPoint("TOPLEFT", container, "TOPLEFT", MTH_ICU_LAYOUT.LEFT_X, -302) + toggleAnchorButton:SetWidth(116) + toggleAnchorButton:SetHeight(22) + toggleAnchorButton:SetText("Toggle Anchor") + toggleAnchorButton:SetScript("OnClick", function() + if type(ICU_ToggleAnchor) == "function" then + ICU_ToggleAnchor() + end + end) + MTH_ICU_OPT_STATE.controls.customAnchorButton = toggleAnchorButton + + local expandUp = MTH_CreateCheckbox(container, "MetaHuntOptionsICUExpandUp", "Expand Up", -301, MTH_ICU_LAYOUT.LEFT_X + 132) + if expandUp then + expandUp:SetScript("OnClick", function(self) + self = self or this + if not self then return end + local store = MTH_ICU_GetStore() + store.EXPAND_UP = self:GetChecked() == 1 and true or false + if type(ICU_SetPoints) == "function" then + ICU_SetPoints() + end + end) + MTH_ICU_OPT_STATE.controls.expandUp = expandUp + end + + local showGuild = MTH_CreateCheckbox(container, "MetaHuntOptionsICUShowGuild", "Show player guild names", -344, MTH_ICU_LAYOUT.LEFT_X) + if showGuild then + showGuild:SetScript("OnClick", function(self) + self = self or this + if not self then return end + local store = MTH_ICU_GetStore() + store.SHOW_GUILD_NAME = self:GetChecked() == 1 and true or false + end) + MTH_ICU_OPT_STATE.controls.showGuildName = showGuild + end + + local showClass = MTH_CreateCheckbox(container, "MetaHuntOptionsICUShowClass", "Show player class", -368, MTH_ICU_LAYOUT.LEFT_X) + if showClass then + showClass:SetScript("OnClick", function(self) + self = self or this + if not self then return end + local store = MTH_ICU_GetStore() + store.SHOW_PLAYER_CLASS = self:GetChecked() == 1 and true or false + end) + MTH_ICU_OPT_STATE.controls.showPlayerClass = showClass + end + + local showRace = MTH_CreateCheckbox(container, "MetaHuntOptionsICUShowRace", "Show player race", -392, MTH_ICU_LAYOUT.LEFT_X) + if showRace then + showRace:SetScript("OnClick", function(self) + self = self or this + if not self then return end + local store = MTH_ICU_GetStore() + store.SHOW_PLAYER_RACE = self:GetChecked() == 1 and true or false + end) + MTH_ICU_OPT_STATE.controls.showPlayerRace = showRace + end + + local showTitles = MTH_CreateCheckbox(container, "MetaHuntOptionsICUShowTitles", "Show custom titles in alerts", -416, MTH_ICU_LAYOUT.LEFT_X) + if showTitles then + showTitles:SetScript("OnClick", function(self) + self = self or this + if not self then return end + local store = MTH_ICU_GetStore() + store.SHOW_CUSTOM_TITLES = self:GetChecked() == 1 and true or false + end) + MTH_ICU_OPT_STATE.controls.showCustomTitles = showTitles + end + + local rightHeader = container:CreateFontString("MetaHuntOptionsICURightHeader", "ARTWORK", "GameFontHighlight") + rightHeader:SetPoint("TOPLEFT", container, "TOPLEFT", MTH_ICU_LAYOUT.RIGHT_X, -146) + rightHeader:SetText("Background Colors") + + MTH_ICU_CreateOptionRow(container, "MetaHuntOptionsICUPlayerColorMode", "Player colors", "PLAYER_COLOR_MODE", -172, MTH_ICU_LAYOUT.RIGHT_X, MTH_ICU_LAYOUT.RIGHT_DROPDOWN_X) + + local reactionOnlyPvp = MTH_CreateCheckbox(container, "MetaHuntOptionsICUReactionOnlyPvp", "Reaction only when PvP-flagged", -200, MTH_ICU_LAYOUT.RIGHT_X) + if reactionOnlyPvp then + reactionOnlyPvp:SetScript("OnClick", function(self) + self = self or this + if not self then return end + local store = MTH_ICU_GetStore() + store.REACTION_ONLY_PVP_PLAYERS = self:GetChecked() == 1 and true or false + end) + MTH_ICU_OPT_STATE.controls.reactionOnlyPvp = reactionOnlyPvp + end + + local colorHeader = container:CreateFontString("MetaHuntOptionsICUColorsHeader", "ARTWORK", "GameFontHighlight") + colorHeader:SetPoint("TOPLEFT", container, "TOPLEFT", MTH_ICU_LAYOUT.RIGHT_X, -224) + + local colorScope = container:CreateFontString("MetaHuntOptionsICUColorsScope", "ARTWORK", "GameFontNormalSmall") + colorScope:SetPoint("TOPLEFT", container, "TOPLEFT", MTH_ICU_LAYOUT.RIGHT_X, -242) + colorScope:SetWidth(250) + colorScope:SetJustifyH("LEFT") + colorScope:SetJustifyV("TOP") + colorScope:SetTextColor(0.35, 0.65, 1) + colorScope:SetText("Player pickers: REACTION / FACTION / CUSTOM\nNPC pickers: always") + + local colorY = -284 + for i = 1, table.getn(MTH_ICU_COLOR_ROWS) do + local row = MTH_ICU_COLOR_ROWS[i] + MTH_ICU_CreateColorRow(container, "MetaHuntOptionsICUColor" .. tostring(i), row.label, row.key, colorY, MTH_ICU_LAYOUT.RIGHT_X, MTH_ICU_LAYOUT.COLOR_BUTTON_X) + colorY = colorY - MTH_ICU_LAYOUT.COLOR_ROW_STEP + end + + +end + +function MTH_RefreshICUOptions() + if not MTH_ICU_OPT_READY then return end + + local controls = MTH_ICU_OPT_STATE.controls + if type(controls) ~= "table" then + return + end + + local store = MTH_ICU_GetStore() + local moduleEnabled = MTH and MTH.IsModuleEnabled and MTH:IsModuleEnabled("icu", false) or false + + if controls.moduleEnabled then + controls.moduleEnabled:SetChecked(moduleEnabled and 1 or nil) + end + + if controls.mouseOver then + controls.mouseOver:SetChecked(store.mouseOver and 1 or nil) + if controls.mouseOver.Enable then + if moduleEnabled then controls.mouseOver:Enable() else controls.mouseOver:Disable() end + end + end + + if controls.showGuildName then + controls.showGuildName:SetChecked(store.SHOW_GUILD_NAME and 1 or nil) + if controls.showGuildName.Enable then + if moduleEnabled then controls.showGuildName:Enable() else controls.showGuildName:Disable() end + end + end + + if controls.showPlayerClass then + controls.showPlayerClass:SetChecked(store.SHOW_PLAYER_CLASS and 1 or nil) + if controls.showPlayerClass.Enable then + if moduleEnabled then controls.showPlayerClass:Enable() else controls.showPlayerClass:Disable() end + end + end + + if controls.showPlayerRace then + controls.showPlayerRace:SetChecked(store.SHOW_PLAYER_RACE and 1 or nil) + if controls.showPlayerRace.Enable then + if moduleEnabled then controls.showPlayerRace:Enable() else controls.showPlayerRace:Disable() end + end + end + + if controls.showCustomTitles then + controls.showCustomTitles:SetChecked(store.SHOW_CUSTOM_TITLES and 1 or nil) + if controls.showCustomTitles.Enable then + if moduleEnabled then controls.showCustomTitles:Enable() else controls.showCustomTitles:Disable() end + end + end + + local isCustomAnchor = tostring(store.ANCHOR or "") == "CUSTOM" + + if controls.customAnchorButton then + if moduleEnabled and isCustomAnchor then + controls.customAnchorButton:Show() + if controls.customAnchorButton.Enable then controls.customAnchorButton:Enable() end + else + controls.customAnchorButton:Hide() + end + end + + if controls.expandUp then + controls.expandUp:SetChecked(store.EXPAND_UP and 1 or nil) + if moduleEnabled and isCustomAnchor then + controls.expandUp:Show() + if controls.expandUp.Enable then controls.expandUp:Enable() end + else + controls.expandUp:Hide() + end + end + + local optionKeys = { "ALERT", "ANNOUNCE", "ANCHOR", "HEALTH_TEXT_MODE", "POPUP_HIDE_DELAY", "PLAYER_COLOR_MODE" } + for i = 1, table.getn(optionKeys) do + local key = optionKeys[i] + local row = controls[key] + if row and row.dropdown then + MTH_ICU_DropdownSetText(row.dropdown, tostring(store[key] or "")) + if UIDropDownMenu_EnableDropDown and UIDropDownMenu_DisableDropDown then + if moduleEnabled then + UIDropDownMenu_EnableDropDown(row.dropdown) + else + UIDropDownMenu_DisableDropDown(row.dropdown) + end + end + end + end + + if controls.reactionOnlyPvp then + controls.reactionOnlyPvp:SetChecked(store.REACTION_ONLY_PVP_PLAYERS and 1 or nil) + if controls.reactionOnlyPvp.Enable then + if moduleEnabled and tostring(store.PLAYER_COLOR_MODE or "") == "REACTION" then + controls.reactionOnlyPvp:Enable() + else + controls.reactionOnlyPvp:Disable() + end + end + end + + for i = 1, table.getn(MTH_ICU_COLOR_ROWS) do + local row = MTH_ICU_COLOR_ROWS[i] + local ctrl = controls[row.key] + if ctrl and ctrl.swatch then + local r, g, b = MTH_ICU_GetColor(row.key) + ctrl.swatch:SetVertexColor(r, g, b) + if ctrl.button and ctrl.button.Enable then + if moduleEnabled then ctrl.button:Enable() else ctrl.button:Disable() end + end + end + end +end + +function MTH_SetupICUOptions() + local container = MTH_GetFrame("MetaHuntOptionsICU") + if not container then return end + + if not MTH_ICU_OPT_READY then + if type(MTH_ClearContainer) == "function" then + MTH_ClearContainer(container) + end + local title = container:CreateFontString("MetaHuntOptionsICUErrorTitle", "ARTWORK", "GameFontHighlight") + title:SetPoint("TOPLEFT", container, "TOPLEFT", 20, -10) + title:SetText("MTH ICU") + + local body = container:CreateFontString("MetaHuntOptionsICUErrorBody", "ARTWORK", "GameFontNormalSmall") + body:SetPoint("TOPLEFT", container, "TOPLEFT", 20, -34) + body:SetWidth(620) + body:SetJustifyH("LEFT") + body:SetJustifyV("TOP") + body:SetTextColor(1, 0.3, 0.3) + body:SetText("ICU options cannot initialize. Missing dependencies: " .. table.concat(MTH_ICU_OPT_MISSING, ", ")) + return + end + + local ok, err = pcall(function() + MTH_ICU_BuildUI(container) + MTH_RefreshICUOptions() + end) + if not ok then + if MTH and MTH.Print then + MTH:Print("[MTH ICU OPTIONS] setup failed: " .. tostring(err), "error") + end + if type(MTH_ClearContainer) == "function" then + MTH_ClearContainer(container) + end + local title = container:CreateFontString("MetaHuntOptionsICUCrashTitle", "ARTWORK", "GameFontHighlight") + title:SetPoint("TOPLEFT", container, "TOPLEFT", 20, -10) + title:SetText("MTH ICU") + local body = container:CreateFontString("MetaHuntOptionsICUCrashBody", "ARTWORK", "GameFontNormalSmall") + body:SetPoint("TOPLEFT", container, "TOPLEFT", 20, -34) + body:SetWidth(620) + body:SetJustifyH("LEFT") + body:SetJustifyV("TOP") + body:SetTextColor(1, 0.3, 0.3) + body:SetText("ICU options failed to render. Check chat/debug for details.") + end +end diff --git a/api/options-shell.lua b/api/options-shell.lua index 15a7034..627837c 100644 --- a/api/options-shell.lua +++ b/api/options-shell.lua @@ -195,6 +195,21 @@ function MTH_SelectOptionsTab(tabKey) else MTH_SetupAutoBuyOptions() end + elseif tabKey == "AutoQuest" then + if type(MTH_SetupAutoQuestOptions) == "function" then + MTH_SetupAutoQuestOptions() + MTH_OPTIONS_SETUP["AutoQuest"] = true + end + elseif tabKey == "ICU" then + if type(MTH_SetupICUOptions) == "function" then + MTH_SetupICUOptions() + MTH_OPTIONS_SETUP["ICU"] = true + if type(MTH_RefreshICUOptions) == "function" then + MTH_RefreshICUOptions() + end + elseif MTH and MTH.Print then + MTH:Print("ICU options setup function is not loaded", "error") + end elseif tabKey == "Credits" then local setupCredits = _G and _G["MTH_SetupCreditsOptions"] if type(setupCredits) == "function" then diff --git a/api/options-tree.lua b/api/options-tree.lua index 3aaf6f0..41c3918 100644 --- a/api/options-tree.lua +++ b/api/options-tree.lua @@ -41,6 +41,8 @@ MTH_OPTIONS_TABS = MTH_OPTIONS_TABS or { { key = "SmartAmmo", label = "Smart Ammo", frame = "MetaHuntOptionsSmartAmmo" }, { key = "FeedOMatic", label = "FeedOMatic", frame = "MetaHuntOptionsFeedOMatic" }, { key = "AutoBuy", label = "Auto Buy", frame = "MetaHuntOptionsAutoBuy" }, + { key = "AutoQuest", label = "Auto Quest", frame = "MetaHuntOptionsAutoQuest" }, + { key = "ICU", label = "ICU", frame = "MetaHuntOptionsICU" }, { key = "ChronometerGeneral", label = "General", frame = "MetaHuntOptionsChronometer" }, { key = "ChronometerBar", label = "Bar", frame = "MetaHuntOptionsChronometer" }, { key = "ChronometerClassSpells", label = "Hunter Spells", frame = "MetaHuntOptionsChronometer" }, @@ -53,7 +55,9 @@ MTH_OPTIONS_TREE = MTH_OPTIONS_TREE or { { label = "General", key = "General" }, { label = "Messages", key = "Messages" }, { label = "Auto Buy", key = "AutoBuy" }, + { label = "Auto Quest", key = "AutoQuest" }, { label = "Feed-O-Matic", key = "FeedOMatic" }, + { label = "ICU", key = "ICU" }, { label = "zButtons", node = "zButtons", diff --git a/api/options-zbuttons.lua b/api/options-zbuttons.lua index cf663cc..529e511 100644 --- a/api/options-zbuttons.lua +++ b/api/options-zbuttons.lua @@ -21,6 +21,14 @@ local function MTH_ZB_RefreshTab(tabName) end end +local function MTH_ZB_IsChecked(control) + if not control or type(control.GetChecked) ~= "function" then + return false + end + local checked = control:GetChecked() + return checked == 1 or checked == true +end + local function MTH_RebuildSpellOrder(buttonName, buttonObj, itemList, maxButtons) if not (buttonName and buttonObj and itemList and maxButtons) then return end local info = {} @@ -328,7 +336,7 @@ local function MTH_SetupButtonOptions(containerName, buttonName, displayName, ma enableButton:SetScript("OnClick", function(self) self = self or this if not self then return end - local checked = self:GetChecked() and true or false + local checked = MTH_ZB_IsChecked(self) MTH_SetButtonEnabledState(self.buttonName, self.buttonObj, checked) end) end @@ -387,7 +395,7 @@ local function MTH_SetupButtonOptions(containerName, buttonName, displayName, ma expandLeft:SetScript("OnClick", function(self) self = self or this if not self then return end - local checked = self:GetChecked() and true or false + local checked = MTH_ZB_IsChecked(self) local btnName = self.buttonName if not btnName or not (ZHunterMod_Saved and ZHunterMod_Saved[btnName]) then return end ZHunterMod_Saved[btnName]["firstbutton"] = checked and "LEFT" or "RIGHT" @@ -409,7 +417,7 @@ local function MTH_SetupButtonOptions(containerName, buttonName, displayName, ma hideClick:SetScript("OnClick", function(self) self = self or this if not self then return end - local checked = self:GetChecked() and true or false + local checked = MTH_ZB_IsChecked(self) ZHunterMod_Saved[self.buttonName]["children"]["hideonclick"] = checked local btnObj = self.buttonObj or getglobal(self.buttonName) if btnObj then btnObj.hideonclick = checked end @@ -428,7 +436,7 @@ local function MTH_SetupButtonOptions(containerName, buttonName, displayName, ma showTooltip:SetScript("OnClick", function(self) self = self or this if not self then return end - local checked = self:GetChecked() and true or false + local checked = MTH_ZB_IsChecked(self) ZHunterMod_Saved[self.buttonName]["tooltip"] = checked local btnObj = self.buttonObj or getglobal(self.buttonName) if btnObj then btnObj.tooltip = checked end @@ -446,7 +454,7 @@ local function MTH_SetupButtonOptions(containerName, buttonName, displayName, ma showAmmoName:SetScript("OnClick", function(self) self = self or this if not self then return end - local checked = self:GetChecked() and true or false + local checked = MTH_ZB_IsChecked(self) local btnName = self.buttonName ZHunterMod_Saved[btnName]["showammoname"] = checked local btnObj = self.buttonObj or getglobal(btnName) @@ -745,7 +753,7 @@ function MTH_SetupSmartAmmoOptions() self = self or this if not self then return end if MTH and MTH.SetModuleEnabled then - MTH:SetModuleEnabled("smartammo", self:GetChecked() and true or false) + MTH:SetModuleEnabled("smartammo", MTH_ZB_IsChecked(self)) end MTH_SetupSmartAmmoOptions() end) @@ -762,7 +770,7 @@ function MTH_SetupSmartAmmoOptions() self = self or this if not self then return end if type(MTHSmartAmmo_SetSmartEnabled) == "function" then - MTHSmartAmmo_SetSmartEnabled(self:GetChecked() and 1 or nil) + MTHSmartAmmo_SetSmartEnabled(MTH_ZB_IsChecked(self) and 1 or nil) end end) end @@ -781,7 +789,7 @@ function MTH_SetupSmartAmmoOptions() self = self or this if not self then return end if type(MTHSmartAmmo_SetReloadEnabled) == "function" then - MTHSmartAmmo_SetReloadEnabled(self:GetChecked() and 1 or nil) + MTHSmartAmmo_SetReloadEnabled(MTH_ZB_IsChecked(self) and 1 or nil) end end) end @@ -821,7 +829,7 @@ function MTH_SetupMessagesOptions() self = self or this if not self then return end if MTH and MTH.SetMessageEnabled then - MTH:SetMessageEnabled("initModulesLoaded", self:GetChecked() and true or false) + MTH:SetMessageEnabled("initModulesLoaded", MTH_ZB_IsChecked(self)) end end) end @@ -833,7 +841,7 @@ function MTH_SetupMessagesOptions() self = self or this if not self then return end if MTH and MTH.SetMessageEnabled then - MTH:SetMessageEnabled("initSarcasticWelcome", self:GetChecked() and true or false) + MTH:SetMessageEnabled("initSarcasticWelcome", MTH_ZB_IsChecked(self)) end end) end @@ -845,7 +853,7 @@ function MTH_SetupMessagesOptions() self = self or this if not self then return end if MTH and MTH.SetMessageEnabled then - MTH:SetMessageEnabled("petHungry", self:GetChecked() and true or false) + MTH:SetMessageEnabled("petHungry", MTH_ZB_IsChecked(self)) end end) end @@ -857,7 +865,7 @@ function MTH_SetupMessagesOptions() self = self or this if not self then return end if MTH and MTH.SetMessageEnabled then - MTH:SetMessageEnabled("beastTrainingScan", self:GetChecked() and true or false) + MTH:SetMessageEnabled("beastTrainingScan", MTH_ZB_IsChecked(self)) end end) end @@ -869,7 +877,7 @@ function MTH_SetupMessagesOptions() self = self or this if not self then return end if MTH and MTH.SetMessageEnabled then - MTH:SetMessageEnabled("spellbookScan", self:GetChecked() and true or false) + MTH:SetMessageEnabled("spellbookScan", MTH_ZB_IsChecked(self)) end end) end @@ -881,7 +889,7 @@ function MTH_SetupMessagesOptions() self = self or this if not self then return end if MTH and MTH.SetMessageEnabled then - MTH:SetMessageEnabled("petRanAway", self:GetChecked() and true or false) + MTH:SetMessageEnabled("petRanAway", MTH_ZB_IsChecked(self)) end end) end @@ -893,7 +901,7 @@ function MTH_SetupMessagesOptions() self = self or this if not self then return end if MTH and MTH.SetMessageEnabled then - MTH:SetMessageEnabled("mapMarkers", self:GetChecked() and true or false) + MTH:SetMessageEnabled("mapMarkers", MTH_ZB_IsChecked(self)) end end) end @@ -1002,7 +1010,7 @@ function MTH_SetupGeneralOptions() local smartModuleToggle = ensureCheckbox(smartAmmoSection, "MetaHuntGeneralSmartAmmoModuleToggle", "Enable module", -10, smartModuleEnabled) if smartModuleToggle then smartModuleToggle:SetScript("OnClick", function() - local enabled = this:GetChecked() and true or false + local enabled = MTH_ZB_IsChecked(this) if MTH and MTH.SetModuleEnabled then local ok = MTH:SetModuleEnabled("smartammo", enabled) if not ok then @@ -1031,7 +1039,7 @@ function MTH_SetupGeneralOptions() end smartToggle:SetScript("OnClick", function() if type(MTHSmartAmmo_SetSmartEnabled) == "function" then - MTHSmartAmmo_SetSmartEnabled(this:GetChecked() and 1 or nil) + MTHSmartAmmo_SetSmartEnabled(MTH_ZB_IsChecked(this) and 1 or nil) end end) end @@ -1045,7 +1053,7 @@ function MTH_SetupGeneralOptions() end reloadToggle:SetScript("OnClick", function() if type(MTHSmartAmmo_SetReloadEnabled) == "function" then - MTHSmartAmmo_SetReloadEnabled(this:GetChecked() and 1 or nil) + MTHSmartAmmo_SetReloadEnabled(MTH_ZB_IsChecked(this) and 1 or nil) end end) end @@ -1059,7 +1067,7 @@ function MTH_SetupGeneralOptions() end weaponSwapToggle:SetScript("OnClick", function() if type(MTHSmartAmmo_SetWeaponSwapEnabled) == "function" then - MTHSmartAmmo_SetWeaponSwapEnabled(this:GetChecked() and 1 or nil) + MTHSmartAmmo_SetWeaponSwapEnabled(MTH_ZB_IsChecked(this) and 1 or nil) end end) end @@ -1072,7 +1080,7 @@ function MTH_SetupGeneralOptions() if autoStripToggle then autoStripToggle:SetScript("OnClick", function() if type(AutoStrip_SetAutoStripToggle) == "function" then - AutoStrip_SetAutoStripToggle(this:GetChecked()) + AutoStrip_SetAutoStripToggle(MTH_ZB_IsChecked(this)) end end) end @@ -1081,7 +1089,7 @@ function MTH_SetupGeneralOptions() if displayToggle then displayToggle:SetScript("OnClick", function() if type(AutoStrip_SetDisplayToggle) == "function" then - AutoStrip_SetDisplayToggle(this:GetChecked()) + AutoStrip_SetDisplayToggle(MTH_ZB_IsChecked(this)) end end) end @@ -1095,7 +1103,7 @@ function MTH_SetupGeneralOptions() local antiToggle = ensureCheckbox(antiSection, "MetaHuntGeneralAntiDazeToggle", "Enable Anti-Daze", -10, antiDazeEnabled) if antiToggle then antiToggle:SetScript("OnClick", function() - local enabled = this:GetChecked() and true or false + local enabled = MTH_ZB_IsChecked(this) if type(AntiDaze_SetEnabled) == "function" then AntiDaze_SetEnabled(enabled, 1) end @@ -1158,7 +1166,7 @@ function MTH_SetupGeneralOptions() local moduleToggle = ensureCheckbox(tooltipsSection, "MetaHuntGeneralTooltipsModuleToggle", "Enable module", -10, moduleEnabled) if moduleToggle then moduleToggle:SetScript("OnClick", function() - local enabled = this:GetChecked() and true or false + local enabled = MTH_ZB_IsChecked(this) if MTH and MTH.SetModuleEnabled then local ok = MTH:SetModuleEnabled("tooltips", enabled) if not ok then @@ -1175,7 +1183,7 @@ function MTH_SetupGeneralOptions() local beastToggle = ensureCheckbox(tooltipsSection, "MetaHuntGeneralTooltipsBeastToggle", "Activate on Beasts", -86, tooltipsStore.beastTooltips and true or false) if beastToggle then beastToggle:SetScript("OnClick", function() - local enabled = this:GetChecked() and true or false + local enabled = MTH_ZB_IsChecked(this) tooltipsStore.beastTooltips = enabled local tooltipsModule = MTH and MTH.GetModule and MTH:GetModule("tooltips") if tooltipsModule and tooltipsModule.SetBeastTooltipsEnabled then @@ -1191,7 +1199,7 @@ function MTH_SetupGeneralOptions() local ownPetToggle = ensureCheckbox(tooltipsSection, "MetaHuntGeneralTooltipsOwnPetToggle", ownPetLabel, -158, tooltipsStore.ownPetTooltips and true or false) if ownPetToggle then ownPetToggle:SetScript("OnClick", function() - local enabled = this:GetChecked() and true or false + local enabled = MTH_ZB_IsChecked(this) tooltipsStore.ownPetTooltips = enabled local tooltipsModule = MTH and MTH.GetModule and MTH:GetModule("tooltips") if tooltipsModule and tooltipsModule.SetOwnPetTooltipsEnabled then @@ -1205,7 +1213,7 @@ function MTH_SetupGeneralOptions() local ammoToggle = ensureCheckbox(tooltipsSection, "MetaHuntGeneralTooltipsAmmoVendorToggle", "Activate on Vendors", -222, tooltipsStore.ammoVendorTooltips and true or false) if ammoToggle then ammoToggle:SetScript("OnClick", function() - local enabled = this:GetChecked() and true or false + local enabled = MTH_ZB_IsChecked(this) tooltipsStore.ammoVendorTooltips = enabled local tooltipsModule = MTH and MTH.GetModule and MTH:GetModule("tooltips") if tooltipsModule and tooltipsModule.SetAmmoVendorTooltipsEnabled then @@ -1219,7 +1227,7 @@ function MTH_SetupGeneralOptions() local foodToggle = ensureCheckbox(tooltipsSection, "MetaHuntGeneralTooltipsFoodToggle", foodLabel, -274, tooltipsStore.foodItemTooltips and true or false) if foodToggle then foodToggle:SetScript("OnClick", function() - local enabled = this:GetChecked() and true or false + local enabled = MTH_ZB_IsChecked(this) tooltipsStore.foodItemTooltips = enabled local tooltipsModule = MTH and MTH.GetModule and MTH:GetModule("tooltips") if tooltipsModule and tooltipsModule.SetFoodItemTooltipsEnabled then diff --git a/api/options.xml b/api/options.xml index b8d4672..c3b1604 100644 --- a/api/options.xml +++ b/api/options.xml @@ -79,6 +79,8 @@ + + diff --git a/api/pets-service-lifecycle.lua b/api/pets-service-lifecycle.lua new file mode 100644 index 0000000..b8e415d --- /dev/null +++ b/api/pets-service-lifecycle.lua @@ -0,0 +1,19 @@ +if not MTH_ST_LifecycleService then + MTH_ST_LifecycleService = {} +end + +function MTH_ST_LifecycleService:HandleEvent(evt, eventArg1, eventArg2) + if type(MTH_ST_HandlePlayerEnteringWorld) == "function" and MTH_ST_HandlePlayerEnteringWorld(evt) then + return true, "lifecycle-enter-world" + end + if type(MTH_ST_HandleUnitPetEvent) == "function" and MTH_ST_HandleUnitPetEvent(evt, eventArg1) then + if evt == "UNIT_PET" then + if eventArg1 == "player" then + return true, "lifecycle-unit-pet-player" + end + return true, "lifecycle-unit-pet-other" + end + return true, "lifecycle-unit-pet" + end + return false +end diff --git a/api/pets-service-runaway.lua b/api/pets-service-runaway.lua new file mode 100644 index 0000000..34998e0 --- /dev/null +++ b/api/pets-service-runaway.lua @@ -0,0 +1,10 @@ +if not MTH_ST_RunawayService then + MTH_ST_RunawayService = {} +end + +function MTH_ST_RunawayService:HandleEvent(evt, eventArg1, _eventArg2) + if type(MTH_ST_HandleRunawayEvent) ~= "function" then + return false + end + return MTH_ST_HandleRunawayEvent(evt, eventArg1) and true or false +end diff --git a/api/pets-service-stablescan.lua b/api/pets-service-stablescan.lua new file mode 100644 index 0000000..171f52e --- /dev/null +++ b/api/pets-service-stablescan.lua @@ -0,0 +1,10 @@ +if not MTH_ST_StableScanService then + MTH_ST_StableScanService = {} +end + +function MTH_ST_StableScanService:HandleEvent(evt, _eventArg1, _eventArg2) + if type(MTH_ST_HandleStableUiEvent) ~= "function" then + return false + end + return MTH_ST_HandleStableUiEvent(evt) and true or false +end diff --git a/api/pets-service-vitals.lua b/api/pets-service-vitals.lua new file mode 100644 index 0000000..f654011 --- /dev/null +++ b/api/pets-service-vitals.lua @@ -0,0 +1,10 @@ +if not MTH_ST_VitalsService then + MTH_ST_VitalsService = {} +end + +function MTH_ST_VitalsService:HandleEvent(evt, eventArg1, _eventArg2) + if type(MTH_ST_HandleVitalsEvent) ~= "function" then + return false + end + return MTH_ST_HandleVitalsEvent(evt, eventArg1) and true or false +end diff --git a/init/api.xml b/init/api.xml index 9d628c5..2f63560 100644 --- a/init/api.xml +++ b/init/api.xml @@ -15,6 +15,8 @@ + + @@ -30,6 +32,10 @@ + + + + diff --git a/init/modules.xml b/init/modules.xml index 2ecfd0f..05300d3 100644 --- a/init/modules.xml +++ b/init/modules.xml @@ -20,4 +20,10 @@ MetaHunt Module Loader + + + + + + diff --git a/modules/autobuy/engine.lua b/modules/autobuy/engine.lua index eda313b..71c17a1 100644 --- a/modules/autobuy/engine.lua +++ b/modules/autobuy/engine.lua @@ -632,7 +632,7 @@ end function Engine:EnsureDefaults() local store = self:GetConfigStore() if store.enabled == nil then - store.enabled = true + store.enabled = false end if type(store.food) ~= "table" then store.food = {} @@ -647,13 +647,13 @@ function Engine:EnsureDefaults() store.petFood = {} end if store.food.enabled == nil then - store.food.enabled = true + store.food.enabled = false end if store.ammo.enabled == nil then - store.ammo.enabled = true + store.ammo.enabled = false end if store.projectiles.enabled == nil then - store.projectiles.enabled = true + store.projectiles.enabled = false end if store.petFood.enabled == nil then store.petFood.enabled = false diff --git a/modules/autobuy/module.lua b/modules/autobuy/module.lua index 19ec24c..54de574 100644 --- a/modules/autobuy/module.lua +++ b/modules/autobuy/module.lua @@ -6,7 +6,7 @@ local MTH_AutoBuy = { name = "autobuy", enabled = false, - version = "1.0.6", + version = "1.1.0", events = { "VARIABLES_LOADED", "MERCHANT_SHOW", diff --git a/modules/autoquest/loader.xml b/modules/autoquest/loader.xml new file mode 100644 index 0000000..0ad9ff4 --- /dev/null +++ b/modules/autoquest/loader.xml @@ -0,0 +1,6 @@ + + + + diff --git a/modules/autoquest/module.lua b/modules/autoquest/module.lua new file mode 100644 index 0000000..5e30e0b --- /dev/null +++ b/modules/autoquest/module.lua @@ -0,0 +1,987 @@ +------------------------------------------------------ +-- MetaHunt: Quest Automation Module +-- SHIFT-rightclick automation for specific quest flows +------------------------------------------------------ + +local MTH_AutoQuest = { + name = "autoquest", + enabled = false, + version = "1.1.0", + events = { + "GOSSIP_SHOW", + "QUEST_GREETING", + "QUEST_DETAIL", + "QUEST_PROGRESS", + "QUEST_COMPLETE", + }, + initialized = false, +} + +local QA_SCORPOK_QUEST_ID = 2586 +local QA_SCORPOK_QUEST_TITLE = "Salt of the Scorpok" +local QA_SCORPOK_NPC_ID = 7505 +local QA_SCORPOK_NPC_NAME = "Bloodmage Drazial" +local QA_SISSIES_QUEST_ID = 7342 +local QA_SISSIES_QUEST_TITLE = "Arrows Are For Sissies" +local QA_SISSIES_NPC_ID = 14183 +local QA_SISSIES_NPC_NAME = "Artilleryman Sheldonore" +local QA_ITEM_SCORPOK_PINCER_ID = 8393 +local QA_ITEM_BLASTED_BOAR_LUNG_ID = 8392 +local QA_ITEM_VULTURE_GIZZARD_ID = 8396 +local QA_ITEM_GROUND_SCORPOK_ASSAY_ID = 8412 +local QA_ITEM_SCORPOK_PINCER_NAME = "Scorpok Pincer" +local QA_ITEM_BLASTED_BOAR_LUNG_NAME = "Blasted Boar Lung" +local QA_ITEM_VULTURE_GIZZARD_NAME = "Vulture Gizzard" +local QA_ITEM_GROUND_SCORPOK_ASSAY_NAME = "Ground Scorpok Assay" + +local QA_DirectEventFrame = nil +local QA_TooltipHooked = false +local QA_TooltipPollFrame = nil +local QA_TooltipRefreshFrame = nil + +local function QA_Debug(message) + return +end + +local function QA_CopyValue(value) + if type(value) ~= "table" then + return value + end + local copy = {} + for key, entry in pairs(value) do + copy[key] = QA_CopyValue(entry) + end + return copy +end + +local function QA_GetOptionsStore() + if type(MTH_CharSavedVariables) ~= "table" then + MTH_CharSavedVariables = {} + end + if type(MTH_CharSavedVariables.autoquest) ~= "table" then + MTH_CharSavedVariables.autoquest = {} + end + local store = MTH_CharSavedVariables.autoquest + + if store._migrated ~= true then + if type(MTH_CharSavedVariables.questautomation) == "table" then + local legacyStore = MTH_CharSavedVariables.questautomation + if legacyStore.scorpokDrazial ~= nil and store.scorpokDrazial == nil then + store.scorpokDrazial = legacyStore.scorpokDrazial and true or false + end + if legacyStore.scorpokTooltip ~= nil and store.scorpokTooltip == nil then + store.scorpokTooltip = legacyStore.scorpokTooltip and true or false + end + end + local legacyChar = nil + local legacyAccount = nil + if MTH and MTH.GetModuleCharSavedVariables then + legacyChar = MTH:GetModuleCharSavedVariables("autoquest") + if type(legacyChar) ~= "table" then + legacyChar = MTH:GetModuleCharSavedVariables("questautomation") + end + end + if MTH and MTH.GetModuleSavedVariables then + legacyAccount = MTH:GetModuleSavedVariables("autoquest") + if type(legacyAccount) ~= "table" then + legacyAccount = MTH:GetModuleSavedVariables("questautomation") + end + end + if type(legacyChar) == "table" and legacyChar.scorpokDrazial ~= nil and store.scorpokDrazial == nil then + store.scorpokDrazial = legacyChar.scorpokDrazial and true or false + elseif type(legacyAccount) == "table" and legacyAccount.scorpokDrazial ~= nil and store.scorpokDrazial == nil then + store.scorpokDrazial = legacyAccount.scorpokDrazial and true or false + elseif store.scorpokDrazial == nil and MTH and MTH.GetModuleCharSavedVariables then + local autobuyStore = MTH:GetModuleCharSavedVariables("autobuy") + if type(autobuyStore) == "table" + and type(autobuyStore.questAutomation) == "table" + and autobuyStore.questAutomation.scorpokDrazial ~= nil then + store.scorpokDrazial = autobuyStore.questAutomation.scorpokDrazial and true or false + end + end + store._migrated = true + end + + if store.scorpokDrazial == nil then + store.scorpokDrazial = false + end + if store.arrowsForSissies == nil then + store.arrowsForSissies = false + end + if store.scorpokTooltip == nil then + store.scorpokTooltip = false + end + + if MTH and MTH.GetModuleCharSavedVariables then + local moduleStore = MTH:GetModuleCharSavedVariables("autoquest") + if type(moduleStore) == "table" then + moduleStore.scorpokDrazial = store.scorpokDrazial and true or false + moduleStore.arrowsForSissies = store.arrowsForSissies and true or false + moduleStore.scorpokTooltip = store.scorpokTooltip and true or false + end + end + + return store +end + +function MTH_AutoQuest:GetStore() + return QA_GetOptionsStore() +end + +function MTH_AutoQuest:SetScorpokDrazialEnabled(enabled) + local store = QA_GetOptionsStore() + store.scorpokDrazial = enabled and true or false + if MTH and MTH.GetModuleCharSavedVariables then + local moduleStore = MTH:GetModuleCharSavedVariables("autoquest") + if type(moduleStore) == "table" then + moduleStore.scorpokDrazial = store.scorpokDrazial and true or false + end + end + QA_Debug("option write: scorpokDrazial=" .. tostring(store.scorpokDrazial and true or false)) +end + +function MTH_AutoQuest:SetArrowsForSissiesEnabled(enabled) + local store = QA_GetOptionsStore() + store.arrowsForSissies = enabled and true or false + if MTH and MTH.GetModuleCharSavedVariables then + local moduleStore = MTH:GetModuleCharSavedVariables("autoquest") + if type(moduleStore) == "table" then + moduleStore.arrowsForSissies = store.arrowsForSissies and true or false + end + end + QA_Debug("option write: arrowsForSissies=" .. tostring(store.arrowsForSissies and true or false)) +end + +function MTH_AutoQuest:SetScorpokTooltipEnabled(enabled) + local store = QA_GetOptionsStore() + store.scorpokTooltip = enabled and true or false + if MTH and MTH.GetModuleCharSavedVariables then + local moduleStore = MTH:GetModuleCharSavedVariables("autoquest") + if type(moduleStore) == "table" then + moduleStore.scorpokTooltip = store.scorpokTooltip and true or false + end + end + QA_Debug("option write: scorpokTooltip=" .. tostring(store.scorpokTooltip and true or false)) +end + +local function QA_IsScorpokAutomationEnabled() + local store = QA_GetOptionsStore() + return store and store.scorpokDrazial and true or false +end + +local function QA_IsArrowsForSissiesAutomationEnabled() + local store = QA_GetOptionsStore() + return store and store.arrowsForSissies and true or false +end + +local function QA_IsScorpokTooltipEnabled() + local store = QA_GetOptionsStore() + return store and store.scorpokTooltip and true or false +end + +local function QA_NormalizeText(value) + local text = string.lower(tostring(value or "")) + text = string.gsub(text, "[^%a%d]", "") + return text +end + +local function QA_IsTitleMatch(title, expectedTitle) + if type(title) ~= "string" or title == "" then + return false + end + return QA_NormalizeText(title) == QA_NormalizeText(expectedTitle) +end + +local function QA_IsScorpokTitle(title) + return QA_IsTitleMatch(title, QA_SCORPOK_QUEST_TITLE) +end + +local function QA_IsArrowsForSissiesTitle(title) + return QA_IsTitleMatch(title, QA_SISSIES_QUEST_TITLE) +end + +local function QA_IsQuestContext(title, expectedQuestId, titleMatcher) + if type(GetQuestID) == "function" then + local questId = tonumber(GetQuestID()) + if questId and questId == expectedQuestId then + return true + end + end + if type(titleMatcher) == "function" then + return titleMatcher(title) + end + return false +end + +local function QA_IsScorpokQuestContext(title) + return QA_IsQuestContext(title, QA_SCORPOK_QUEST_ID, QA_IsScorpokTitle) +end + +local function QA_IsArrowsForSissiesQuestContext(title) + return QA_IsQuestContext(title, QA_SISSIES_QUEST_ID, QA_IsArrowsForSissiesTitle) +end + +local function QA_GetNpcIdFromGuid(guid) + if type(guid) ~= "string" or guid == "" then + return nil + end + + local _, _, _, _, _, npcId = string.find(guid, "^Creature%-%d+%-%d+%-%d+%-%d+%-(%d+)%-%x+$") + if npcId then + return tonumber(npcId) + end + + if string.sub(guid, 1, 2) == "0x" and string.len(guid) >= 12 then + local hex = string.sub(guid, 9, 12) + local parsed = tonumber(hex, 16) + if parsed and parsed > 0 then + return parsed + end + end + + return nil +end + +local function QA_ColorByThreshold(count, threshold) + local value = math.floor(tonumber(count) or 0) + if value <= 0 then + return "|cffff0000" .. tostring(value) .. "|r" + end + local modFn = nil + if type(rawget) == "function" and type(math) == "table" then + modFn = rawget(math, "mod") or rawget(math, "fmod") + elseif type(math) == "table" then + modFn = math.fmod + end + if threshold > 0 and type(modFn) == "function" and modFn(value, threshold) == 0 then + return "|cff00ff00" .. tostring(value) .. "|r" + end + return "|cffffa500" .. tostring(value) .. "|r" +end + +local function QA_ColorAssay(count) + local value = math.floor(tonumber(count) or 0) + if value > 0 then + return "|cff66ccff" .. tostring(value) .. "|r" + end + return tostring(value) +end + +local function QA_MatchesItem(itemId, itemName, expectedId, expectedName) + if expectedId and tonumber(itemId) == tonumber(expectedId) then + return true + end + if type(itemName) == "string" and type(expectedName) == "string" and QA_NormalizeText(itemName) == QA_NormalizeText(expectedName) then + return true + end + return false +end + +local function QA_CountItemInBags(expectedId, expectedName) + local getContainerNumSlots = _G and _G["GetContainerNumSlots"] or nil + local getContainerItemLink = _G and _G["GetContainerItemLink"] or nil + local getContainerItemInfo = _G and _G["GetContainerItemInfo"] or nil + local getItemInfo = _G and _G["GetItemInfo"] or nil + if type(getContainerNumSlots) ~= "function" or type(getContainerItemLink) ~= "function" then + return 0 + end + + local total = 0 + for bag = 0, 4 do + local slots = math.floor(tonumber(getContainerNumSlots(bag)) or 0) + for slot = 1, slots do + local link = getContainerItemLink(bag, slot) + if type(link) == "string" and link ~= "" then + local itemId = tonumber(string.match(link, "item:(%d+)")) + local itemName = nil + if type(getItemInfo) == "function" then + itemName = getItemInfo(link) + end + if QA_MatchesItem(itemId, itemName, expectedId, expectedName) then + local texture, stackCount = nil, nil + if type(getContainerItemInfo) == "function" then + texture, stackCount = getContainerItemInfo(bag, slot) + end + total = total + math.floor(tonumber(stackCount) or 1) + end + end + end + end + + return total +end + +local function QA_TooltipTrace(message, force) + return +end + +local function QA_GetTooltipNpc(tooltip) + if not tooltip or type(tooltip.GetUnit) ~= "function" then + return nil, nil, nil, nil + end + local ok, unitA, unitB = pcall(function() + return tooltip:GetUnit() + end) + local unit = nil + if ok then + if type(unitB) == "string" and unitB ~= "" then + unit = unitB + elseif type(unitA) == "string" and unitA ~= "" then + unit = unitA + end + end + if (type(unit) ~= "string" or unit == "") and type(UnitExists) == "function" and UnitExists("mouseover") then + unit = "mouseover" + end + if type(unit) ~= "string" or unit == "" then + local left1 = (type(getglobal) == "function" and getglobal("GameTooltipTextLeft1")) or (_G and _G["GameTooltipTextLeft1"]) + local title = left1 and left1.GetText and left1:GetText() or nil + if type(title) == "string" and title ~= "" then + return nil, title, nil, title + end + return nil, nil, nil, nil + end + local guid = UnitGUID and UnitGUID(unit) or nil + local npcId = QA_GetNpcIdFromGuid(guid) + local name = UnitName and UnitName(unit) or nil + local title = nil + if (type(name) ~= "string" or name == "") then + local left1 = (type(getglobal) == "function" and getglobal("GameTooltipTextLeft1")) or (_G and _G["GameTooltipTextLeft1"]) + title = left1 and left1.GetText and left1:GetText() or nil + if type(title) == "string" and title ~= "" then + name = title + end + end + return npcId, name, unit, title +end + +local function QA_TooltipAlreadyExtended(tooltip) + if not tooltip or type(tooltip.NumLines) ~= "function" then + return false + end + local lines = math.floor(tonumber(tooltip:NumLines()) or 0) + for i = 1, lines do + local line = (type(getglobal) == "function" and getglobal("GameTooltipTextLeft" .. tostring(i))) or (_G and _G["GameTooltipTextLeft" .. tostring(i)]) + local text = line and line.GetText and line:GetText() or nil + if type(text) == "string" and QA_NormalizeText(text) == QA_NormalizeText("Quest Automation") then + return true + end + end + return false +end + +local function QA_IsTooltipDrazial(tooltip) + local npcId, npcName = QA_GetTooltipNpc(tooltip) + if tonumber(npcId) == QA_SCORPOK_NPC_ID then + return true + end + if type(npcName) == "string" and npcName ~= "" and QA_NormalizeText(npcName) == QA_NormalizeText(QA_SCORPOK_NPC_NAME) then + return true + end + return false +end + +local function QA_AppendDrazialTooltip(tooltip, sourceTag) + sourceTag = tostring(sourceTag or "unknown") + if not (MTH_AutoQuest and MTH_AutoQuest.enabled) then + QA_TooltipTrace("skip: module disabled source=" .. sourceTag) + return + end + if not QA_IsScorpokAutomationEnabled() or not QA_IsScorpokTooltipEnabled() then + QA_TooltipTrace("skip: option disabled source=" .. sourceTag) + return + end + local npcId, npcName, unitToken, title = QA_GetTooltipNpc(tooltip) + local isMatch = tonumber(npcId) == QA_SCORPOK_NPC_ID + if not isMatch and type(npcName) == "string" and npcName ~= "" and QA_NormalizeText(npcName) == QA_NormalizeText(QA_SCORPOK_NPC_NAME) then + isMatch = true + end + if not isMatch then + QA_TooltipTrace("skip: npc mismatch source=" .. sourceTag + .. " unit=" .. tostring(unitToken) + .. " id=" .. tostring(npcId) + .. " name='" .. tostring(npcName or "") .. "'" + .. " title='" .. tostring(title or "") .. "'") + return + end + if QA_TooltipAlreadyExtended(tooltip) then + QA_TooltipTrace("skip: already-extended source=" .. sourceTag) + return + end + + local pincerCount = QA_CountItemInBags(QA_ITEM_SCORPOK_PINCER_ID, QA_ITEM_SCORPOK_PINCER_NAME) + local gizzardCount = QA_CountItemInBags(QA_ITEM_VULTURE_GIZZARD_ID, QA_ITEM_VULTURE_GIZZARD_NAME) + local lungCount = QA_CountItemInBags(QA_ITEM_BLASTED_BOAR_LUNG_ID, QA_ITEM_BLASTED_BOAR_LUNG_NAME) + local assayCount = QA_CountItemInBags(QA_ITEM_GROUND_SCORPOK_ASSAY_ID, QA_ITEM_GROUND_SCORPOK_ASSAY_NAME) + + tooltip:AddLine(" ") + tooltip:AddLine("|cffffff00Quest Automation|r") + tooltip:AddLine("Scorpok Pincer: " .. QA_ColorByThreshold(pincerCount, 3), 1, 1, 1) + tooltip:AddLine("Vulture Gizzard: " .. QA_ColorByThreshold(gizzardCount, 2), 1, 1, 1) + tooltip:AddLine("Blasted Boar Lung: " .. QA_ColorByThreshold(lungCount, 1), 1, 1, 1) + tooltip:AddLine("Ground Scorpok Assay: " .. QA_ColorAssay(assayCount), 1, 1, 1) + tooltip:AddLine("Spam SHIFT-click!", 0.7, 0.9, 1) + tooltip:AddLine("Recipes: 3x Pincer, 2x Gizzard, 1x Lung", 0.7, 0.7, 0.7) + tooltip:Show() + QA_TooltipTrace("added: source=" .. sourceTag + .. " id=" .. tostring(npcId) + .. " name='" .. tostring(npcName or "") .. "'", true) +end + +local function QA_EnsureTooltipHook() + if QA_TooltipHooked then + return + end + if not GameTooltip then + return + end + + local hasGetScript = type(GameTooltip.GetScript) == "function" + local hasSetScript = type(GameTooltip.SetScript) == "function" + local hasHookScript = type(GameTooltip.HookScript) == "function" + + local onTooltipSetUnitExists = false + local onShowExists = false + if hasGetScript then + local okSetUnit, existingSetUnit = pcall(function() + return GameTooltip:GetScript("OnTooltipSetUnit") + end) + onTooltipSetUnitExists = okSetUnit and type(existingSetUnit) == "function" + + local okShow, existingShow = pcall(function() + return GameTooltip:GetScript("OnShow") + end) + onShowExists = okShow and type(existingShow) == "function" + end + + if hasHookScript and onTooltipSetUnitExists then + pcall(function() + GameTooltip:HookScript("OnTooltipSetUnit", function(self) + QA_AppendDrazialTooltip(self, "hook:setunit") + end) + end) + elseif hasSetScript and hasGetScript and onTooltipSetUnitExists then + local okGetPrev, previous = pcall(function() + return GameTooltip:GetScript("OnTooltipSetUnit") + end) + if okGetPrev then + pcall(function() + GameTooltip:SetScript("OnTooltipSetUnit", function(self) + if type(previous) == "function" then + previous(self) + end + QA_AppendDrazialTooltip(self, "script:setunit") + end) + end) + end + end + + if hasHookScript and onShowExists then + pcall(function() + GameTooltip:HookScript("OnShow", function(self) + QA_AppendDrazialTooltip(self, "hook:show") + end) + end) + elseif hasSetScript and hasGetScript and onShowExists then + local okGetPrevShow, previousShow = pcall(function() + return GameTooltip:GetScript("OnShow") + end) + if okGetPrevShow then + pcall(function() + GameTooltip:SetScript("OnShow", function(self) + if type(previousShow) == "function" then + previousShow(self) + end + QA_AppendDrazialTooltip(self, "script:show") + end) + end) + end + end + + QA_TooltipHooked = true +end + +local function QA_EnsureTooltipPoller() + if QA_TooltipPollFrame then + return + end + local frame = CreateFrame("Frame", "MTH_AutoQuestTooltipPollFrame") + if not frame then + return + end + frame._mthElapsed = 0 + frame:SetScript("OnUpdate", function(self, elapsed) + self._mthElapsed = (self._mthElapsed or 0) + (elapsed or arg1 or 0) + if self._mthElapsed < 0.20 then + return + end + self._mthElapsed = 0 + if not (MTH_AutoQuest and MTH_AutoQuest.enabled) then + return + end + if GameTooltip and type(GameTooltip.IsVisible) == "function" and GameTooltip:IsVisible() then + QA_AppendDrazialTooltip(GameTooltip, "poll") + end + end) + QA_TooltipPollFrame = frame +end + +local function QA_GetInteractedNpcId() + local getGuid = _G and _G["UnitGUID"] or nil + if type(getGuid) ~= "function" then + return nil + end + local npcGuid = getGuid("npc") + local npcId = QA_GetNpcIdFromGuid(npcGuid) + if npcId then + return npcId + end + return QA_GetNpcIdFromGuid(getGuid("target")) +end + +local function QA_GetInteractedNpcName() + local getName = _G and _G["UnitName"] or nil + if type(getName) ~= "function" then + return nil + end + local name = getName("npc") + if type(name) == "string" and name ~= "" then + return name + end + name = getName("target") + if type(name) == "string" and name ~= "" then + return name + end + return nil +end + +local function QA_IsInteractedNpc(expectedNpcId, expectedNpcName) + local npcId = QA_GetInteractedNpcId() + if tonumber(npcId) == tonumber(expectedNpcId) then + return true + end + local npcName = QA_GetInteractedNpcName() + if type(npcName) == "string" and npcName ~= "" and QA_NormalizeText(npcName) == QA_NormalizeText(expectedNpcName) then + return true + end + return false +end + +local function QA_IsScorpokNpc() + return QA_IsInteractedNpc(QA_SCORPOK_NPC_ID, QA_SCORPOK_NPC_NAME) +end + +local function QA_IsArrowsForSissiesNpc() + return QA_IsInteractedNpc(QA_SISSIES_NPC_ID, QA_SISSIES_NPC_NAME) +end + +local function QA_RefreshMouseoverTooltip() + local tooltip = _G and _G["GameTooltip"] or GameTooltip + if not tooltip then + return + end + if type(UnitExists) == "function" and UnitExists("mouseover") and type(tooltip.SetUnit) == "function" then + tooltip:SetUnit("mouseover") + if type(tooltip.Show) == "function" then + tooltip:Show() + end + end +end + +local function QA_RequestTooltipRefresh(delaySeconds) + delaySeconds = tonumber(delaySeconds) or 0.10 + if not QA_TooltipRefreshFrame then + QA_TooltipRefreshFrame = CreateFrame("Frame", "MTH_AutoQuestTooltipRefreshFrame") + if not QA_TooltipRefreshFrame then + return + end + QA_TooltipRefreshFrame:SetScript("OnUpdate", function(self, elapsed) + local frameRef = self or this or QA_TooltipRefreshFrame + if not frameRef then + return + end + frameRef._elapsed = (frameRef._elapsed or 0) + (elapsed or arg1 or 0) + if frameRef._elapsed < (frameRef._delay or 0.10) then + return + end + frameRef._elapsed = 0 + frameRef._delay = 0 + frameRef:SetScript("OnUpdate", nil) + QA_RefreshMouseoverTooltip() + end) + end + QA_TooltipRefreshFrame._delay = delaySeconds + QA_TooltipRefreshFrame._elapsed = 0 + QA_TooltipRefreshFrame:SetScript("OnUpdate", QA_TooltipRefreshFrame:GetScript("OnUpdate")) +end + +local QA_PROFILE_SCORPOK = { + key = "scorpokDrazial", + label = "Scorpok", + optionCheck = QA_IsScorpokAutomationEnabled, + npcCheck = QA_IsScorpokNpc, + titleCheck = QA_IsScorpokTitle, + questContextCheck = QA_IsScorpokQuestContext, + lastActionField = "_scorpokLastActionAt", + refreshTooltipOnComplete = true, +} + +local QA_PROFILE_SISSIES = { + key = "arrowsForSissies", + label = "ArrowsForSissies", + optionCheck = QA_IsArrowsForSissiesAutomationEnabled, + npcCheck = QA_IsArrowsForSissiesNpc, + titleCheck = QA_IsArrowsForSissiesTitle, + questContextCheck = QA_IsArrowsForSissiesQuestContext, + lastActionField = "_sissiesLastActionAt", + refreshTooltipOnComplete = false, +} + +local QA_LastActionAt = {} + +local function QA_GetLastActionAt(profile) + local key = type(profile) == "table" and tostring(profile.lastActionField or "") or "" + if key == "" then + return 0 + end + return tonumber(QA_LastActionAt[key]) or 0 +end + +local function QA_SetLastActionAt(profile, value) + local key = type(profile) == "table" and tostring(profile.lastActionField or "") or "" + if key == "" then + return + end + QA_LastActionAt[key] = tonumber(value) or 0 +end + +local function QA_CanRunProfile(profile) + if type(profile) ~= "table" then + return false + end + if type(profile.optionCheck) ~= "function" or not profile.optionCheck() then + QA_Debug("[" .. tostring(profile.label or "AutoQuest") .. "] skip: option disabled") + return false + end + if type(IsShiftKeyDown) ~= "function" or not IsShiftKeyDown() then + QA_Debug("[" .. tostring(profile.label or "AutoQuest") .. "] skip: SHIFT not held") + return false + end + local npcOk = type(profile.npcCheck) == "function" and profile.npcCheck() or false + if not npcOk then + QA_Debug("[" .. tostring(profile.label or "AutoQuest") .. "] skip: NPC mismatch (id=" .. tostring(QA_GetInteractedNpcId()) .. ", name='" .. tostring(QA_GetInteractedNpcName() or "") .. "')") + end + return npcOk +end + +local function QA_FindGossipQuestIndex(rawEntries, count, profile) + if type(rawEntries) ~= "table" or tonumber(count) == nil or tonumber(count) <= 0 then + return nil + end + if type(profile) ~= "table" or type(profile.titleCheck) ~= "function" then + return nil + end + local entryCount = math.floor(tonumber(count) or 0) + local tupleSize = math.floor(table.getn(rawEntries) / entryCount) + if tupleSize < 1 then + tupleSize = 1 + end + for i = 1, entryCount do + local title = tostring(rawEntries[((i - 1) * tupleSize) + 1] or "") + if profile.titleCheck(title) then + return i + end + end + if entryCount == 1 and type(profile.npcCheck) == "function" and profile.npcCheck() then + QA_Debug("[" .. tostring(profile.label or "AutoQuest") .. "] gossip fallback: single quest entry -> selecting index 1") + return 1 + end + return nil +end + +local function QA_FindQuestGreetingIndex(isActive, profile) + local getCount = isActive and _G and _G["GetNumActiveQuests"] or _G and _G["GetNumAvailableQuests"] + local getTitle = isActive and _G and _G["GetActiveTitle"] or _G and _G["GetAvailableTitle"] + if type(getCount) ~= "function" or type(getTitle) ~= "function" or type(profile) ~= "table" or type(profile.titleCheck) ~= "function" then + return nil + end + local count = math.floor(tonumber(getCount()) or 0) + if count <= 0 then + return nil + end + for i = 1, count do + local title = tostring(getTitle(i) or "") + if profile.titleCheck(title) then + return i + end + end + if count == 1 and type(profile.npcCheck) == "function" and profile.npcCheck() then + QA_Debug("[" .. tostring(profile.label or "AutoQuest") .. "] quest greeting fallback: single quest entry -> selecting index 1") + return 1 + end + return nil +end + +local function QA_GetFrameText(frame) + if type(frame) ~= "table" then + return "" + end + if type(frame.GetText) == "function" then + return tostring(frame:GetText() or "") + end + if type(frame.GetName) == "function" then + local name = frame:GetName() + if type(name) == "string" and name ~= "" then + local textRegion = _G and _G[name .. "Text"] or nil + if textRegion and type(textRegion.GetText) == "function" then + return tostring(textRegion:GetText() or "") + end + end + end + return "" +end + +local function QA_ClickGossipTitleButtonFallback(profile) + if type(profile) ~= "table" or type(profile.titleCheck) ~= "function" then + return false + end + for i = 1, 40 do + local button = _G and _G["GossipTitleButton" .. tostring(i)] or nil + if button then + local visible = true + if type(button.IsVisible) == "function" then + visible = button:IsVisible() and true or false + end + if visible then + local title = QA_GetFrameText(button) + if profile.titleCheck(title) then + QA_Debug("action: GossipTitleButton" .. tostring(i) .. " click title='" .. tostring(title) .. "'") + if type(button.Click) == "function" then + button:Click() + return true + end + local onClick = type(button.GetScript) == "function" and button:GetScript("OnClick") or nil + if type(onClick) == "function" then + onClick(button) + return true + end + end + end + end + end + return false +end + +local function QA_RunProfileAutomation(eventName, profile) + if type(profile) ~= "table" then + return + end + QA_Debug("[" .. tostring(profile.label or "AutoQuest") .. "] event=" .. tostring(eventName)) + if not QA_CanRunProfile(profile) then + return + end + + local getTimeFn = _G and _G["GetTime"] or nil + if eventName == "GOSSIP_SHOW" and type(getTimeFn) == "function" then + local now = tonumber(getTimeFn()) or 0 + local last = QA_GetLastActionAt(profile) + if (now - last) < 0.05 then + QA_Debug("skip: throttled dt=" .. tostring(now - last)) + return + end + end + + if eventName == "QUEST_GREETING" then + local selectAvailableQuest = _G and _G["SelectAvailableQuest"] or nil + local selectActiveQuest = _G and _G["SelectActiveQuest"] or nil + local availableIndex = QA_FindQuestGreetingIndex(false, profile) + if availableIndex and type(selectAvailableQuest) == "function" then + QA_Debug("action: SelectAvailableQuest (compat) index=" .. tostring(availableIndex)) + selectAvailableQuest() + QA_SetLastActionAt(profile, type(getTimeFn) == "function" and getTimeFn() or 0) + return + end + local activeIndex = QA_FindQuestGreetingIndex(true, profile) + if activeIndex and type(selectActiveQuest) == "function" then + QA_Debug("action: SelectActiveQuest (compat) index=" .. tostring(activeIndex)) + selectActiveQuest() + QA_SetLastActionAt(profile, type(getTimeFn) == "function" and getTimeFn() or 0) + return + end + for i = 1, 40 do + local button = _G and _G["QuestTitleButton" .. tostring(i)] or nil + if button and (type(button.IsVisible) ~= "function" or button:IsVisible()) then + local title = QA_GetFrameText(button) + if profile.titleCheck(title) then + QA_Debug("action: QuestTitleButton" .. tostring(i) .. " click title='" .. tostring(title) .. "'") + if type(button.Click) == "function" then + button:Click() + QA_SetLastActionAt(profile, type(getTimeFn) == "function" and getTimeFn() or 0) + return + end + end + end + end + QA_Debug("quest greeting: no action taken") + return + end + + if eventName == "GOSSIP_SHOW" then + local getNumGossipAvailableQuests = _G and _G["GetNumGossipAvailableQuests"] or nil + local getGossipAvailableQuests = _G and _G["GetGossipAvailableQuests"] or nil + local selectGossipAvailableQuest = _G and _G["SelectGossipAvailableQuest"] or nil + QA_Debug("api available: numAvail=" .. tostring(type(getNumGossipAvailableQuests) == "function") + .. " getAvail=" .. tostring(type(getGossipAvailableQuests) == "function") + .. " selAvail=" .. tostring(type(selectGossipAvailableQuest) == "function")) + if type(getNumGossipAvailableQuests) == "function" and type(getGossipAvailableQuests) == "function" and type(selectGossipAvailableQuest) == "function" then + local availableCount = math.floor(tonumber(getNumGossipAvailableQuests()) or 0) + QA_Debug("gossip available count=" .. tostring(availableCount)) + if availableCount > 0 then + local availableIndex = QA_FindGossipQuestIndex({ getGossipAvailableQuests() }, availableCount, profile) + if availableIndex then + QA_Debug("action: SelectGossipAvailableQuest index=" .. tostring(availableIndex)) + selectGossipAvailableQuest(availableIndex) + QA_SetLastActionAt(profile, type(getTimeFn) == "function" and getTimeFn() or 0) + return + end + QA_Debug("gossip available: no Scorpok match") + end + end + local getNumGossipActiveQuests = _G and _G["GetNumGossipActiveQuests"] or nil + local getGossipActiveQuests = _G and _G["GetGossipActiveQuests"] or nil + local selectGossipActiveQuest = _G and _G["SelectGossipActiveQuest"] or nil + QA_Debug("api active: numActive=" .. tostring(type(getNumGossipActiveQuests) == "function") + .. " getActive=" .. tostring(type(getGossipActiveQuests) == "function") + .. " selActive=" .. tostring(type(selectGossipActiveQuest) == "function")) + if type(getNumGossipActiveQuests) == "function" and type(getGossipActiveQuests) == "function" and type(selectGossipActiveQuest) == "function" then + local activeCount = math.floor(tonumber(getNumGossipActiveQuests()) or 0) + QA_Debug("gossip active count=" .. tostring(activeCount)) + if activeCount > 0 then + local activeIndex = QA_FindGossipQuestIndex({ getGossipActiveQuests() }, activeCount, profile) + if activeIndex then + QA_Debug("action: SelectGossipActiveQuest index=" .. tostring(activeIndex)) + selectGossipActiveQuest(activeIndex) + QA_SetLastActionAt(profile, type(getTimeFn) == "function" and getTimeFn() or 0) + return + end + QA_Debug("gossip active: no Scorpok match") + end + end + if QA_ClickGossipTitleButtonFallback(profile) then + QA_SetLastActionAt(profile, type(getTimeFn) == "function" and getTimeFn() or 0) + return + end + QA_Debug("gossip: no action taken") + return + end + + local title = type(GetTitleText) == "function" and tostring(GetTitleText() or "") or "" + QA_Debug("quest frame title='" .. tostring(title) .. "'") + if type(profile.questContextCheck) ~= "function" or not profile.questContextCheck(title) then + QA_Debug("skip: quest context mismatch") + return + end + + if eventName == "QUEST_DETAIL" then + if type(AcceptQuest) == "function" then + QA_Debug("action: AcceptQuest") + AcceptQuest() + QA_SetLastActionAt(profile, type(getTimeFn) == "function" and getTimeFn() or 0) + end + elseif eventName == "QUEST_PROGRESS" then + if type(CompleteQuest) == "function" then + if type(IsQuestCompletable) ~= "function" or IsQuestCompletable() then + QA_Debug("action: CompleteQuest") + CompleteQuest() + QA_SetLastActionAt(profile, type(getTimeFn) == "function" and getTimeFn() or 0) + else + QA_Debug("skip: quest not completable yet") + end + end + elseif eventName == "QUEST_COMPLETE" then + if type(GetQuestReward) == "function" then + local choices = type(GetNumQuestChoices) == "function" and math.floor(tonumber(GetNumQuestChoices()) or 0) or 0 + if choices <= 1 then + QA_Debug("action: GetQuestReward(1)") + GetQuestReward(1) + QA_SetLastActionAt(profile, type(getTimeFn) == "function" and getTimeFn() or 0) + if profile.refreshTooltipOnComplete then + QA_RequestTooltipRefresh(0.10) + end + else + QA_Debug("skip: multiple reward choices=" .. tostring(choices)) + end + end + end +end + +local function QA_SetDirectEventFrameActive(active) + if not QA_DirectEventFrame then + return + end + if active then + QA_DirectEventFrame:RegisterEvent("GOSSIP_SHOW") + QA_DirectEventFrame:RegisterEvent("QUEST_GREETING") + QA_DirectEventFrame:RegisterEvent("QUEST_DETAIL") + QA_DirectEventFrame:RegisterEvent("QUEST_PROGRESS") + QA_DirectEventFrame:RegisterEvent("QUEST_COMPLETE") + else + QA_DirectEventFrame:UnregisterAllEvents() + end +end + +local function QA_EnsureDirectEventFrame(moduleRef) + if QA_DirectEventFrame then + return QA_DirectEventFrame + end + local frame = CreateFrame("Frame", "MTH_AutoQuestEventFrame") + if not frame then + QA_Debug("direct frame create failed") + return nil + end + frame:SetScript("OnEvent", function(_, eventName) + eventName = eventName or event + if not (moduleRef and moduleRef.enabled) then + return + end + QA_Debug("directEvent=" .. tostring(eventName)) + QA_RunProfileAutomation(eventName, QA_PROFILE_SCORPOK) + QA_RunProfileAutomation(eventName, QA_PROFILE_SISSIES) + end) + QA_DirectEventFrame = frame + return frame +end + +function MTH_AutoQuest:init() + local store = QA_GetOptionsStore() + self.initialized = true + QA_EnsureDirectEventFrame(self) + QA_SetDirectEventFrameActive(self.enabled and true or false) + QA_Debug("init: module active=" .. tostring(self.enabled and true or false) + .. ", option scorpokDrazial=" .. tostring(store and store.scorpokDrazial and true or false) + .. ", option arrowsForSissies=" .. tostring(store and store.arrowsForSissies and true or false) + .. ", option scorpokTooltip=" .. tostring(store and store.scorpokTooltip and true or false)) +end + +function MTH_AutoQuest:setEnabled(enabled) + self.enabled = enabled and true or false + QA_SetDirectEventFrameActive(self.enabled and true or false) +end + +function MTH_AutoQuest:onEvent(eventName) + if eventName == "GOSSIP_SHOW" + or eventName == "QUEST_GREETING" + or eventName == "QUEST_DETAIL" + or eventName == "QUEST_PROGRESS" + or eventName == "QUEST_COMPLETE" then + QA_Debug("onEvent=" .. tostring(eventName)) + QA_RunProfileAutomation(eventName, QA_PROFILE_SCORPOK) + QA_RunProfileAutomation(eventName, QA_PROFILE_SISSIES) + end +end + +function MTH_AutoQuest:cleanup() + QA_SetDirectEventFrameActive(false) +end + +MTH:RegisterModule("autoquest", MTH_AutoQuest) diff --git a/modules/chronometer/data-hunter.lua b/modules/chronometer/data-hunter.lua index 188327d..30dc2da 100644 --- a/modules/chronometer/data-hunter.lua +++ b/modules/chronometer/data-hunter.lua @@ -43,8 +43,9 @@ function MTH_ChronometerHunterInstallData(engine) engine:AddTimer(engine.EVENT, BS["Frost Trap Aura"], 30, 0, 0, 0, { gr = 1, cr = "CYAN", a = 1, d = { tn = BS["Clever Traps"], tb = 0.15, tp = 1 }, xn = BS["Frost Trap"] }) engine:AddTimer(engine.EVENT, BS["Immolation Trap Effect"], 15, 1, 0, 1, { gr = 1, cr = "ORANGE", a = 1, xn = BS["Immolation Trap"] }) engine:AddTimer(engine.EVENT, BS["Improved Concussive Shot"], 3, 1, 0, 0, { a = 1, cr = "BLUE", xn = BS["Concussive Shot"] }) - engine:AddTimer(engine.EVENT, BS["Improved Wing Clip"], 5, 1, 0, 0, { a = 1, cr = "YELLOW", xn = BS["Wing Clip"] }) + engine:AddTimer(engine.EVENT, BS["Improved Wing Clip"], 5, 1, 0, 0, { a = 1, cr = "MAROON", xn = BS["Wing Clip"] }) engine:AddTimer(engine.EVENT, BS["Piercing Shot"], 8, 1, 0, 1, { a = 1, cr = "PURPLE", tx = "Interface\\Icons\\Ability_Hunter_DisarmingShot" }) + engine:AddTimer(engine.EVENT, BS["Entrapment"], 5, 1, 0, 1, { a = 1, cr = "MAROON", tx = "Interface\\Icons\\Spell_Nature_StrangleVines" }) engine:AddTimer(engine.EVENT, BS["Quick Shots"], 12, 0, 1, 1, { a = 1, cr = "MAROON", tx = "Interface\\Icons\\Ability_Warrior_InnerRage" }) engine:AddTimer(engine.EVENT, BS["Wing Clip"], 10, 1, 0, 1, { a = 1, cr = "YELLOW" }) engine:AddTimer(engine.EVENT, BS["Scorpid Poison"], 10, 1, 0, 0, { gr = 3, cr = "PURPLE", a = 1, dn = 1 }) diff --git a/modules/chronometer/engine.lua b/modules/chronometer/engine.lua index 4abdee2..e559bcf 100644 --- a/modules/chronometer/engine.lua +++ b/modules/chronometer/engine.lua @@ -59,6 +59,45 @@ Chronometer.dataSetup = {} local latins = { I = 1, II = 2, III = 3, IV = 4, V = 5, VI = 6, VII = 7, VIII = 8, IX = 9, X = 10, XI = 11, XII = 12, XIII = 13, XIV = 14 } local BAR_GROUP = "MTHChronometer" local PARSER_OWNER = "MTHChronometerHunter" +local MTH_CHRON_TRACE_ENABLED = false + +local function MTH_CHRON_Trace(msg) + if not MTH_CHRON_TRACE_ENABLED then + return + end + if MTH and MTH.Print then + MTH:Print("[CHRONTRACE] " .. tostring(msg), "debug") + elseif DEFAULT_CHAT_FRAME and DEFAULT_CHAT_FRAME.AddMessage then + DEFAULT_CHAT_FRAME:AddMessage("[CHRONTRACE] " .. tostring(msg)) + end +end + +local function MTH_CHRON_FormatBarPosition(profile) + local bp = profile and profile.barposition + if type(bp) ~= "table" then + return "" + end + return tostring(bp.point or "nil") + .. "/" .. tostring(bp.relativePoint or "nil") + .. " @(" .. tostring(bp.x) .. "," .. tostring(bp.y) .. ")" +end + +local function MTH_CHRON_DescribePoint(frame) + if not (frame and frame.GetPoint) then + return "" + end + local point, relTo, relPoint, x, y = frame:GetPoint() + local relName = "nil" + if type(relTo) == "table" and relTo.GetName then + relName = tostring(relTo:GetName() or "") + elseif relTo then + relName = tostring(relTo) + end + return tostring(point or "nil") + .. "->" .. relName + .. ":" .. tostring(relPoint or "nil") + .. " @(" .. tostring(x) .. "," .. tostring(y) .. ")" +end local COLOR_NAME_MAP = { white = { 1.0, 1.0, 1.0 }, @@ -156,22 +195,67 @@ end local function ensureProfile() if not MTH or not MTH.GetModuleCharSavedVariables then + MTH_CHRON_Trace("ensureProfile fallback defaults (no MTH store)") return shallowCopy(DEFAULTS) end local store = MTH:GetModuleCharSavedVariables("chronometer") if type(store) ~= "table" then + MTH_CHRON_Trace("ensureProfile fallback defaults (invalid store)") return shallowCopy(DEFAULTS) end + if type(store.profile) ~= "table" + and type(MTH_CharSavedVariables) == "table" + and type(MTH_CharSavedVariables.chronometer) == "table" + then + local legacyChar = MTH_CharSavedVariables.chronometer + if type(legacyChar.profile) == "table" then + store.profile = deepCopyValue(legacyChar.profile) + MTH_CHRON_Trace("ensureProfile migrated legacy MTH_CharSavedVariables.chronometer.profile") + elseif type(legacyChar.barposition) == "table" then + store.profile = { barposition = deepCopyValue(legacyChar.barposition) } + MTH_CHRON_Trace("ensureProfile migrated legacy MTH_CharSavedVariables.chronometer.barposition") + end + end + if type(store.profile) ~= "table" + and type(MTH_SavedVariables) == "table" + and type(MTH_SavedVariables.chronometer) == "table" + then + local legacyAccount = MTH_SavedVariables.chronometer + if type(legacyAccount.profile) == "table" then + store.profile = deepCopyValue(legacyAccount.profile) + MTH_CHRON_Trace("ensureProfile migrated legacy MTH_SavedVariables.chronometer.profile") + elseif type(legacyAccount.barposition) == "table" then + store.profile = { barposition = deepCopyValue(legacyAccount.barposition) } + MTH_CHRON_Trace("ensureProfile migrated legacy MTH_SavedVariables.chronometer.barposition") + end + end if type(store.profile) ~= "table" and MTH.GetModuleSavedVariables then local accountStore = MTH:GetModuleSavedVariables("chronometer") if type(accountStore) == "table" and type(accountStore.profile) == "table" then store.profile = deepCopyValue(accountStore.profile) + MTH_CHRON_Trace("ensureProfile copied account profile into char store") end end if type(store.profile) ~= "table" then store.profile = {} end + if type(store.profile.barposition) ~= "table" and type(store.barposition) == "table" then + store.profile.barposition = deepCopyValue(store.barposition) + MTH_CHRON_Trace("ensureProfile migrated legacy char barposition -> profile.barposition") + end + if type(store.profile.barposition) ~= "table" and MTH.GetModuleSavedVariables then + local accountStore = MTH:GetModuleSavedVariables("chronometer") + if type(accountStore) == "table" then + if type(accountStore.barposition) == "table" then + store.profile.barposition = deepCopyValue(accountStore.barposition) + MTH_CHRON_Trace("ensureProfile migrated legacy account barposition -> profile.barposition") + elseif type(accountStore.profile) == "table" and type(accountStore.profile.barposition) == "table" then + store.profile.barposition = deepCopyValue(accountStore.profile.barposition) + MTH_CHRON_Trace("ensureProfile copied account profile.barposition -> char profile.barposition") + end + end + end local profile = store.profile for key, value in pairs(DEFAULTS) do @@ -198,9 +282,68 @@ local function ensureProfile() profile.disabledSpells.HUNTER = {} end + MTH_CHRON_Trace("ensureProfile result barposition=" .. MTH_CHRON_FormatBarPosition(profile)) + return profile end +local function MTH_CHRON_PersistAnchorPosition(self, reason) + if not (self and self.anchor and self.anchor.GetPoint) then + return false + end + if type(self.profile) ~= "table" then + return false + end + if type(self.profile.barposition) ~= "table" then + self.profile.barposition = {} + end + local point, _, relPoint, x, y = self.anchor:GetPoint() + x = tonumber(x) + y = tonumber(y) + if not (point and relPoint and x and y) then + return false + end + x = math.floor(x + 0.5) + y = math.floor(y + 0.5) + self.profile.barposition.point = point + self.profile.barposition.relativePoint = relPoint + self.profile.barposition.x = x + self.profile.barposition.y = y + MTH_CHRON_Trace("persist-anchor reason=" .. tostring(reason or "") + .. " profile=" .. MTH_CHRON_FormatBarPosition(self.profile)) + return true +end + +local function MTH_CHRON_RestoreAnchorFromProfile(self, reason) + if not (self and self.anchor and self.anchor.ClearAllPoints and self.anchor.SetPoint) then + return false + end + if type(self.profile) ~= "table" then + return false + end + local bp = self.profile.barposition + if type(bp) ~= "table" then + return false + end + local px = tonumber(bp.x) + local py = tonumber(bp.y) + local ppoint = tostring(bp.point or "") + local prelpoint = tostring(bp.relativePoint or "") + if not (px and py and ppoint ~= "") then + MTH_CHRON_Trace("restore-anchor skipped reason=" .. tostring(reason or "") + .. " profile=" .. MTH_CHRON_FormatBarPosition(self.profile)) + return false + end + if prelpoint == "" then + prelpoint = ppoint + end + self.anchor:ClearAllPoints() + self.anchor:SetPoint(ppoint, UIParent, prelpoint, px, py) + MTH_CHRON_Trace("restore-anchor applied reason=" .. tostring(reason or "") + .. " frame=" .. MTH_CHRON_DescribePoint(self.anchor)) + return true +end + local function MTH_CHRON_IsPersistedEnabled() if MTH and MTH.IsModuleEnabled then return MTH:IsModuleEnabled("chronometer", true) and true or false @@ -272,6 +415,70 @@ local function parseRankNumber(text) return 0 end +local function resolvePlayerClassBucket(profile) + profile = profile or {} + local buckets = profile.disabledSpells + if type(buckets) ~= "table" then + return "HUNTER" + end + + local localizedClass, classToken = UnitClass("player") + local candidates = { + classToken, + localizedClass, + string.upper(tostring(classToken or "")), + string.upper(tostring(localizedClass or "")), + "HUNTER", + } + + for i = 1, table.getn(candidates) do + local key = tostring(candidates[i] or "") + if key ~= "" and type(buckets[key]) == "table" then + return key + end + end + + return tostring(classToken or localizedClass or "HUNTER") +end + +local function normalizeTimerName(name) + local normalized = tostring(name or "") + normalized = string.gsub(normalized, "^%s+", "") + normalized = string.gsub(normalized, "%s+$", "") + normalized = string.gsub(normalized, "%s+", " ") + normalized = string.lower(normalized) + return normalized +end + +local function isTimerDisabled(profile, timerClass, timerName) + local disabledSpells = profile and profile.disabledSpells + if type(disabledSpells) ~= "table" then + return false, "no-disabled-table" + end + + local classBucket = disabledSpells[timerClass] + if type(classBucket) ~= "table" then + return false, "no-class-bucket" + end + + if classBucket[timerName] ~= nil then + return true, "exact" + end + + local wanted = normalizeTimerName(timerName) + if wanted == "" then + return false, "empty-name" + end + + for key, value in pairs(classBucket) do + if value ~= nil and normalizeTimerName(key) == wanted then + return true, "normalized" + end + end + + return false, "miss" +end + local function getActionSpell(slot) if GetActionText(slot) or not HasAction(slot) then return nil, 0 @@ -314,6 +521,7 @@ function Chronometer:MTH_Initialize() end self.profile = ensureProfile() + MTH_CHRON_Trace("MTH_Initialize profile=" .. MTH_CHRON_FormatBarPosition(self.profile)) self.parser = ParserLib:GetInstance("1.1") self.COLOR_MAP = { @@ -324,6 +532,7 @@ function Chronometer:MTH_Initialize() } self.anchor = self:CreateAnchor("MetaHunt Chronometer", 0, 1, 0) + MTH_CHRON_Trace("MTH_Initialize anchor-created point=" .. MTH_CHRON_DescribePoint(self.anchor)) self:RegisterCandyBarGroup(BAR_GROUP) self:SetCandyBarGroupPoint(BAR_GROUP, "TOP", self.anchor, "BOTTOM", 0, 0) @@ -332,16 +541,19 @@ end function Chronometer:MTH_Enable() self._mth_explicitEnable = true + MTH_CHRON_Trace("MTH_Enable called") self:MTH_Initialize() self:OnEnable() self._mth_explicitEnable = nil end function Chronometer:MTH_Disable() + MTH_CHRON_Trace("MTH_Disable called") self:OnDisable() end function Chronometer:OnEnable() + MTH_CHRON_Trace("OnEnable begin enabled=" .. tostring(self._mth_enabled and true or false)) if self._mth_enabled then return end @@ -371,8 +583,13 @@ function Chronometer:OnEnable() self._mth_enabled = true self.profile = ensureProfile() + MTH_CHRON_Trace("OnEnable profile-after-ensure=" .. MTH_CHRON_FormatBarPosition(self.profile)) self:SetCandyBarGroupGrowth(BAR_GROUP, self.profile.growup and true or false) self:SetCandyBarGroupVerticalSpacing(BAR_GROUP, self.profile.spacing or 0) + if self.anchor then + MTH_CHRON_Trace("OnEnable anchor-current-point=" .. MTH_CHRON_DescribePoint(self.anchor)) + end + MTH_CHRON_RestoreAnchorFromProfile(self, "OnEnable") self.groups = {} self.timers = {} @@ -450,16 +667,39 @@ function Chronometer:OnEnable() self:RegisterEvent("SPELLCAST_INTERRUPTED") self:RegisterEvent("SPELLCAST_START") self:RegisterEvent("SPELLCAST_STOP") + self:RegisterEvent("UNIT_AURA") self:RegisterEvent("PLAYER_DEAD") + self:RegisterEvent("VARIABLES_LOADED") + self:RegisterEvent("PLAYER_ENTERING_WORLD") + self:RegisterEvent("PLAYER_LOGOUT") self.parser:RegisterEvent(PARSER_OWNER, "CHAT_MSG_SPELL_SELF_DAMAGE", function(event, info) self:SELF_DAMAGE(event, info) end) self.parser:RegisterEvent(PARSER_OWNER, "CHAT_MSG_SPELL_DAMAGESHIELDS_ON_SELF", function(event, info) self:SELF_DAMAGE(event, info) end) self.parser:RegisterEvent(PARSER_OWNER, "CHAT_MSG_SPELL_FAILED_LOCALPLAYER", function(event, info) self:SPELL_FAILED(event, info) end) end +function Chronometer:VARIABLES_LOADED() + self.profile = ensureProfile() + MTH_CHRON_RestoreAnchorFromProfile(self, "VARIABLES_LOADED") +end + +function Chronometer:PLAYER_ENTERING_WORLD() + self.profile = ensureProfile() + MTH_CHRON_RestoreAnchorFromProfile(self, "PLAYER_ENTERING_WORLD") +end + +function Chronometer:PLAYER_LOGOUT() + MTH_CHRON_PersistAnchorPosition(self, "PLAYER_LOGOUT") +end + function Chronometer:OnDisable() if not self._mth_enabled then return end + MTH_CHRON_Trace("OnDisable begin profile=" .. MTH_CHRON_FormatBarPosition(self.profile)) + if self.anchor then + MTH_CHRON_Trace("OnDisable anchor-point=" .. MTH_CHRON_DescribePoint(self.anchor)) + end + MTH_CHRON_PersistAnchorPosition(self, "OnDisable") self._mth_enabled = false if self.bars then @@ -563,9 +803,12 @@ function Chronometer:StartTimer(timer, name, target, rank, durmod) end end - local _, class = UnitClass("player") - local timerClass = timer.x.cl == nil and class or timer.x.cl - if self.profile.disabledSpells[timerClass] and self.profile.disabledSpells[timerClass][name] ~= nil then + local timerClass = timer.x.cl + if timerClass == nil then + timerClass = resolvePlayerClassBucket(self.profile) + end + local blocked, reason = isTimerDisabled(self.profile, timerClass, name) + if blocked then return end @@ -958,11 +1201,22 @@ function Chronometer:CreateAnchor(text, r, g, b) local px = tonumber(self.profile and self.profile.barposition and self.profile.barposition.x) local py = tonumber(self.profile and self.profile.barposition and self.profile.barposition.y) + local ppoint = tostring(self.profile and self.profile.barposition and self.profile.barposition.point or "") + local prelpoint = tostring(self.profile and self.profile.barposition and self.profile.barposition.relativePoint or "") + MTH_CHRON_Trace("CreateAnchor saved-in profile=" .. MTH_CHRON_FormatBarPosition(self.profile)) if px ~= nil and py ~= nil then + if ppoint == "" then + ppoint = "TOPLEFT" + end + if prelpoint == "" then + prelpoint = ppoint + end frame:ClearAllPoints() - frame:SetPoint("TOPLEFT", UIParent, "TOPLEFT", px, py) + frame:SetPoint(ppoint, UIParent, prelpoint, px, py) + MTH_CHRON_Trace("CreateAnchor restored point=" .. MTH_CHRON_DescribePoint(frame)) else frame:SetPoint("CENTER", UIParent, "CENTER", 0, 50) + MTH_CHRON_Trace("CreateAnchor default point=" .. MTH_CHRON_DescribePoint(frame)) end frame:SetScript("OnDragStart", function(self) @@ -970,6 +1224,7 @@ function Chronometer:CreateAnchor(text, r, g, b) if not self or not self.StartMoving then return end + MTH_CHRON_Trace("anchor drag-start point=" .. MTH_CHRON_DescribePoint(self)) self:StartMoving() end) frame:SetScript("OnDragStop", function(self) @@ -995,8 +1250,12 @@ function Chronometer:CreateAnchor(text, r, g, b) self:SetPoint(point or "TOPLEFT", UIParent, point or "TOPLEFT", x, y) if self.owner and self.owner.profile and type(self.owner.profile.barposition) == "table" then + self.owner.profile.barposition.point = point or "TOPLEFT" + self.owner.profile.barposition.relativePoint = point or "TOPLEFT" self.owner.profile.barposition.x = x self.owner.profile.barposition.y = y + MTH_CHRON_Trace("anchor drag-stop saved profile=" .. MTH_CHRON_FormatBarPosition(self.owner.profile) + .. " frame=" .. MTH_CHRON_DescribePoint(self)) end end) @@ -1122,6 +1381,62 @@ function Chronometer:PLAYER_DEAD() end end +function Chronometer:UNIT_AURA(unit) + local auraUnit = unit + if (not auraUnit or auraUnit == "") and type(arg1) == "string" then + auraUnit = arg1 + end + if auraUnit ~= "pet" then + return + end + if not (UnitExists and UnitExists("pet")) then + return + end + + local feedSpell = BS and BS["Feed Pet"] or "Feed Pet" + local timer = self.timers and self.timers[self.SPELL] and self.timers[self.SPELL][feedSpell] + if not timer then + return + end + + local hasFeedAura = false + if type(UnitBuff) == "function" then + for i = 1, 32 do + local texture = UnitBuff("pet", i) + if not texture then + break + end + local tex = tostring(texture) + if string.find(tex, "Ability_Hunter_BeastTraining", 1, true) + or string.find(tex, "INV_Misc_Fork&Knife", 1, true) + then + hasFeedAura = true + break + end + end + end + + if not hasFeedAura then + return + end + + local petName = UnitName and UnitName("pet") or "none" + if not petName or petName == "" then + petName = "none" + end + + for i = 1, 20 do + local bar = self.bars and self.bars[i] + if bar and bar.id and bar.name == feedSpell then + if bar.target == petName or bar.target == "none" then + return + end + end + end + + self:StartTimer(timer, feedSpell, petName) +end + function Chronometer:UseAction(slot, clicked, onself) local name, rank = getActionSpell(slot) if name then diff --git a/modules/chronometer/module.lua b/modules/chronometer/module.lua index f705453..d187c10 100644 --- a/modules/chronometer/module.lua +++ b/modules/chronometer/module.lua @@ -1,13 +1,28 @@ local MTH_ChronometerModule = { name = "chronometer", enabled = true, - version = "1.0.6", + version = "1.1.0", events = {}, initialized = false, } +local MTH_CHRON_MODULE_TRACE_ENABLED = false + +local function MTH_CHRON_ModuleTrace(msg) + if not MTH_CHRON_MODULE_TRACE_ENABLED then + return + end + if MTH and MTH.Print then + MTH:Print("[CHRONTRACE] " .. tostring(msg), "debug") + elseif DEFAULT_CHAT_FRAME and DEFAULT_CHAT_FRAME.AddMessage then + DEFAULT_CHAT_FRAME:AddMessage("[CHRONTRACE] " .. tostring(msg)) + end +end + local function MTH_CHRON_ApplyRuntimeEnabledState(enabled) + MTH_CHRON_ModuleTrace("module apply runtime enabled=" .. tostring(enabled and true or false)) if not MTH_ChronometerHunter then + MTH_CHRON_ModuleTrace("module apply runtime skipped (engine unavailable)") return end if enabled then @@ -22,14 +37,18 @@ local function MTH_CHRON_ApplyRuntimeEnabledState(enabled) end function MTH_ChronometerModule:init() + MTH_CHRON_ModuleTrace("module init begin enabled=" .. tostring(self.enabled and true or false)) if MTH_ChronometerHunter and MTH_ChronometerHunter.MTH_Initialize then MTH_ChronometerHunter:MTH_Initialize() end MTH_CHRON_ApplyRuntimeEnabledState(self.enabled and true or false) self.initialized = true + MTH_CHRON_ModuleTrace("module init end initialized=true") end function MTH_ChronometerModule:setEnabled(enabled) + MTH_CHRON_ModuleTrace("module setEnabled enabled=" .. tostring(enabled and true or false) + .. " initialized=" .. tostring(self.initialized and true or false)) if not self.initialized then self:init() end @@ -37,6 +56,7 @@ function MTH_ChronometerModule:setEnabled(enabled) end function MTH_ChronometerModule:cleanup() + MTH_CHRON_ModuleTrace("module cleanup") if MTH_ChronometerHunter and MTH_ChronometerHunter.MTH_Disable then MTH_ChronometerHunter:MTH_Disable() end diff --git a/modules/feedomatic/FeedOMatic.lua b/modules/feedomatic/FeedOMatic.lua index a869805..e32ad96 100644 --- a/modules/feedomatic/FeedOMatic.lua +++ b/modules/feedomatic/FeedOMatic.lua @@ -56,6 +56,37 @@ FOM_LastChoiceReason = nil; FOM_RealmPlayer = nil; FOM_LastPetName = nil; FOM_LastFeedAttempt = nil; +FOM_TRACE_ENABLED = false; +local FOM_CORE_ATTEMPT_TRACKING_ENABLED = false; +local FOM_PERF_TRACE_ENABLED = false; +local FOM_PERF_TRACE_HISTORY_LIMIT = 80; +local FOM_PERF_TRACE_SPIKE_MS = 12; +local FOM_PERF_TRACE_HISTORY = {}; +local FOM_LAST_SETUP_AT = 0; +local FOM_LAST_SETUP_PET = nil; +local FOM_LAST_QUEST_SCAN_AT = 0; +local FOM_TEMP_FOOD_CACHE = {}; +local FOM_FEED_SCAN_CACHE = nil; +local FOM_FEED_SCAN_CACHE_TTL = 5.0; +local FOM_LAST_AUTO_CHOICE = nil; +local FOM_LAST_AUTO_CHOICE_TTL = 2.0; + +local FOM_REQUIRE_KNOWN_LEVEL_PET_LEVEL = 60; +local FOM_MAX_RETRIES_PER_REQUEST = 2; +local FOM_UNKNOWN_QUALITY_FOR_DESC = -1; +local FOM_UNKNOWN_QUALITY_FOR_ASC = MAX_QUALITY + 1; +local FOM_SERVER_PROFILE = "turtle"; +local FOM_ServerProfiles = { + turtle = { + itemMaxPetLevel = { + [12037] = 44, + }, + itemFoodLevel = { + [8952] = 45, + [12208] = 35, + }, + }, +}; -- Anti-freeze code borrowed from ReagentInfo (in turn, from Quest-I-On): -- keeps WoW from locking up if we try to scan the tradeskill window too fast. @@ -98,7 +129,251 @@ local function FOM_Trace(message) return; end +local function FOM_PerfNowMs() + if (type(debugprofilestop) == "function") then + local raw = tonumber(debugprofilestop()) or 0; + return raw / 1000; + end + if (type(GetTime) == "function") then + return (tonumber(GetTime()) or 0) * 1000; + end + return 0; +end + +local function FOM_PerfOut(message, forceShow) + local text = tostring(message or ""); + if (text == "") then + return; + end + if (type(MTH_DebugFrame) == "table" and type(MTH_DebugFrame.AddInfo) == "function") then + if (forceShow and type(MTH_DebugFrame.Show) == "function") then + MTH_DebugFrame:Show(); + end + MTH_DebugFrame:AddInfo(text); + return; + end + if (MTH and type(MTH.Print) == "function") then + MTH:Print(text, "debug"); + return; + end + if (type(GFWUtils) == "table" and type(GFWUtils.Print) == "function") then + GFWUtils.Print(text); + end +end + +local function FOM_PerfRecord(label, elapsedMs, detail, forcePrint) + if (not FOM_PERF_TRACE_ENABLED) then + return; + end + local numericElapsed = tonumber(elapsedMs) or 0; + if (numericElapsed > 10000) then + numericElapsed = numericElapsed / 1000; + end + local row = { + ts = time and time() or 0, + label = tostring(label or "unknown"), + ms = numericElapsed, + detail = tostring(detail or ""), + }; + table.insert(FOM_PERF_TRACE_HISTORY, row); + if (table.getn(FOM_PERF_TRACE_HISTORY) > FOM_PERF_TRACE_HISTORY_LIMIT) then + table.remove(FOM_PERF_TRACE_HISTORY, 1); + end + return; +end + +local function FOM_PerfDump() + if (table.getn(FOM_PERF_TRACE_HISTORY) <= 0) then + FOM_PerfOut("[FOM PERF] no samples.", true); + return; + end + FOM_PerfOut("[FOM PERF] recent samples: " .. tostring(table.getn(FOM_PERF_TRACE_HISTORY)), true); + for i = 1, table.getn(FOM_PERF_TRACE_HISTORY) do + local row = FOM_PERF_TRACE_HISTORY[i]; + if (type(row) == "table") then + FOM_PerfOut("[FOM PERF] #" .. tostring(i) + .. " " .. tostring(row.label) + .. " " .. string.format("%.2f", tonumber(row.ms) or 0) .. "ms" + .. (tostring(row.detail or "") ~= "" and (" | " .. tostring(row.detail)) or ""), false); + end + end +end + +local function FOM_GetServerProfile() + return FOM_ServerProfiles[FOM_SERVER_PROFILE] or {}; +end + +local function FOM_EnsureQuarantineStore() + if (type(MTH_CharSavedVariables) == "table") then + if (type(MTH_CharSavedVariables.feedTracking) ~= "table") then + MTH_CharSavedVariables.feedTracking = {}; + end + local feedStore = MTH_CharSavedVariables.feedTracking; + if (type(feedStore.fomQuarantine) ~= "table") then + feedStore.fomQuarantine = {}; + end + if (type(feedStore.fomQuarantine.byFamily) ~= "table") then + feedStore.fomQuarantine.byFamily = {}; + end + + if (type(FOM_Quarantine) == "table" and type(FOM_Quarantine.byFamily) == "table") then + for family, byItem in FOM_Quarantine.byFamily do + if (type(byItem) == "table") then + if (type(feedStore.fomQuarantine.byFamily[family]) ~= "table") then + feedStore.fomQuarantine.byFamily[family] = {}; + end + for itemId, row in byItem do + if (type(row) == "table") then + feedStore.fomQuarantine.byFamily[family][itemId] = row; + end + end + end + end + FOM_Quarantine = nil; + end + + return feedStore.fomQuarantine; + end + + if (type(FOM_Quarantine) ~= "table") then + FOM_Quarantine = {}; + end + if (type(FOM_Quarantine.byFamily) ~= "table") then + FOM_Quarantine.byFamily = {}; + end + return FOM_Quarantine; +end + +local function FOM_GetFamilyKeyFromInfo(petInfo) + local family = nil; + if (type(petInfo) == "table") then + family = petInfo.family; + end + if (family == nil or family == "") then + family = UnitCreatureFamily("pet"); + end + family = FOM_GetCanonicalPetFamily(family); + if (family == nil or family == "") then + return "unknown"; + end + return tostring(family); +end + +local function FOM_RegisterNoBuffQuarantine(itemID, petInfo) + local numericItem = tonumber(itemID); + if (numericItem == nil) then + return; + end + local petLevel = tonumber(petInfo and petInfo.level) or tonumber(UnitLevel("pet")) or 0; + if (petLevel <= 0) then + return; + end + local familyKey = FOM_GetFamilyKeyFromInfo(petInfo); + local store = FOM_EnsureQuarantineStore(); + if (type(store.byFamily[familyKey]) ~= "table") then + store.byFamily[familyKey] = {}; + end + local byItem = store.byFamily[familyKey]; + if (type(byItem[numericItem]) ~= "table") then + byItem[numericItem] = { + minRejectPetLevel = petLevel, + lastObservedAt = 0, + reason = "no-buff", + }; + end + local row = byItem[numericItem]; + local currentMin = tonumber(row.minRejectPetLevel); + if (currentMin == nil or petLevel < currentMin) then + row.minRejectPetLevel = petLevel; + end + row.lastObservedAt = time and time() or 0; + row.reason = "no-buff"; + FOM_Trace("quarantine add family='" .. tostring(familyKey) + .. "' itemId=" .. tostring(numericItem) + .. " minRejectPetLevel=" .. tostring(row.minRejectPetLevel)); +end + +local function FOM_IsQuarantined(itemID, petLevel, familyKey) + local numericItem = tonumber(itemID); + local numericPet = tonumber(petLevel); + if (numericItem == nil or numericPet == nil or numericPet <= 0) then + return false; + end + local store = FOM_EnsureQuarantineStore(); + local key = tostring(familyKey or "unknown"); + local familyTable = store.byFamily[key]; + if (type(familyTable) ~= "table") then + return false; + end + local row = familyTable[numericItem]; + if (type(row) ~= "table") then + return false; + end + local minReject = tonumber(row.minRejectPetLevel); + if (minReject == nil) then + return false; + end + return numericPet >= minReject; +end + +local function FOM_GetFoodLevelOverride(itemID) + local numericItem = tonumber(itemID); + if (numericItem == nil) then + return nil; + end + local profile = FOM_GetServerProfile(); + if (type(profile.itemFoodLevel) ~= "table") then + return nil; + end + local override = tonumber(profile.itemFoodLevel[numericItem]); + return override; +end + +local function FOM_IsItemPetLevelCompatible(itemID, petLevel) + local numericItem = tonumber(itemID); + local numericPet = tonumber(petLevel); + if (numericItem == nil or numericPet == nil) then + return true; + end + local profile = FOM_GetServerProfile(); + local maxPetLevel = nil; + if (type(profile.itemMaxPetLevel) == "table") then + maxPetLevel = tonumber(profile.itemMaxPetLevel[numericItem]); + end + if (maxPetLevel ~= nil and numericPet > maxPetLevel) then + return false; + end + return true; +end + +function FOM_CommandTrace(mode) + local arg = string.lower(tostring(mode or "")); + if (arg == "perf on") then + FOM_PERF_TRACE_ENABLED = true; + FOM_PerfOut("[FOM PERF] enabled.", true); + return; + end + if (arg == "perf off") then + FOM_PERF_TRACE_ENABLED = false; + FOM_PerfOut("[FOM PERF] disabled.", true); + return; + end + if (arg == "perf clear") then + FOM_PERF_TRACE_HISTORY = {}; + FOM_PerfOut("[FOM PERF] cleared.", true); + return; + end + if (arg == "perf dump") then + FOM_PerfDump(); + return; + end + FOM_PerfOut("Usage: /fomtrace perf on|off|dump|clear", true); +end + local function FOM_CoreFeedReady() + if (not FOM_CORE_ATTEMPT_TRACKING_ENABLED) then + return false; + end return type(MTH_FEED_BeginAttempt) == "function" and type(MTH_FEED_FinalizeAttempt) == "function"; end @@ -303,6 +578,124 @@ local function FOM_GetItemIDFromLink(itemLink) return nil; end +local FOM_UnknownNoisePrunedThisSession = false; + +local function FOM_IsConsumableItem(itemID) + local numericItemID = tonumber(itemID); + if (numericItemID == nil or type(GetItemInfo) ~= "function") then + return nil; + end + local _, _, _, _, _, itemType = GetItemInfo(numericItemID); + if (itemType == nil or itemType == "") then + return nil; + end + local itemTypeLower = string.lower(tostring(itemType)); + local consumableLabel = (type(_G) == "table" and _G["ITEM_CLASS_CONSUMABLE"]) or "Consumable"; + local consumableLower = string.lower(tostring(consumableLabel)); + return itemTypeLower == consumableLower; +end + +local function FOM_PruneUnknownFoodNoise(force) + if (FOM_UnknownNoisePrunedThisSession and not force) then + return 0; + end + FOM_UnknownNoisePrunedThisSession = true; + + if (type(MTH_FEED_GetStore) ~= "function") then + return 0; + end + local store = MTH_FEED_GetStore(); + local byItemId = store and store.unknownFoods and store.unknownFoods.byItemId or nil; + if (type(byItemId) ~= "table") then + return 0; + end + + local dietByItemID = FOM_BuildDietByItemIDMap(); + local removed = 0; + for itemKey, row in byItemId do + local numericItemID = tonumber(itemKey); + local knownDietItem = (numericItemID ~= nil and dietByItemID[numericItemID] ~= nil) and true or false; + local resolvedFood = (type(row) == "table" and type(row.resolved) == "table" and tonumber(row.resolved.foodLevel) ~= nil) and true or false; + local confirmedFood = (type(row) == "table" and tostring(row.state or "") == "confirmed-food") and true or false; + local consumableState = FOM_IsConsumableItem(numericItemID); + local likelyNonConsumable = (consumableState == false); + local hasNonVendorSource = false; + if (type(row) == "table" and type(row.sources) == "table") then + for _, source in row.sources do + if (tostring(source or "") ~= "vendor-scan") then + hasNonVendorSource = true; + break; + end + end + end + + if (not knownDietItem and not resolvedFood and not confirmedFood and (not hasNonVendorSource or likelyNonConsumable)) then + byItemId[itemKey] = nil; + removed = removed + 1; + end + end + + if (removed > 0 and store ~= nil) then + if (type(time) == "function") then + store.updatedAt = time(); + end + FOM_Trace("pruned unknown vendor-only non-food rows=" .. tostring(removed)); + end + + return removed; +end + +local function FOM_IsExceptionRowActive(row) + if (type(row) ~= "table") then + return false; + end + if (tonumber(row.blockAtOrAbovePetLevel) ~= nil) then + return true; + end + if (tonumber(row.maxPetLevel) ~= nil) then + return true; + end + local reasons = row.reasons; + if (type(reasons) == "table") then + if ((tonumber(reasons.noBuff) or 0) > 0) then return true; end + if ((tonumber(reasons.lowLevel) or 0) > 0) then return true; end + if ((tonumber(reasons.wrongFood) or 0) > 0) then return true; end + if ((tonumber(reasons.unknown) or 0) > 0) then return true; end + end + return false; +end + +local function FOM_PruneExceptionNoise() + if (type(MTH_FEED_GetStore) ~= "function") then + return 0; + end + local store = MTH_FEED_GetStore(); + local byItemId = store and store.exceptions and store.exceptions.byItemId or nil; + if (type(byItemId) ~= "table") then + return 0; + end + + local removed = 0; + for itemKey, row in byItemId do + if (not FOM_IsExceptionRowActive(row)) then + byItemId[itemKey] = nil; + removed = removed + 1; + end + end + + if (removed > 0 and store ~= nil and type(time) == "function") then + store.updatedAt = time(); + FOM_Trace("pruned inert exception rows=" .. tostring(removed)); + end + return removed; +end + +function FOM_PruneCollectedFoodData() + local removedUnknown = tonumber(FOM_PruneUnknownFoodNoise(true)) or 0; + local removedExceptions = tonumber(FOM_PruneExceptionNoise()) or 0; + return removedUnknown + removedExceptions; +end + function MTH_GetMerchantFoodsByDiet() local result = { byDiet = {}, @@ -345,17 +738,6 @@ function MTH_GetMerchantFoodsByDiet() table.insert(result.items, foodRow); else table.insert(result.unknown, foodRow); - if (type(MTH_FEED_RecordUnknownFoodCandidate) == "function") then - pcall(MTH_FEED_RecordUnknownFoodCandidate, { - itemId = itemID, - itemName = itemName, - itemLink = itemLink, - source = "vendor-scan", - observedFoodLevel = (type(MTH_FEED_GetFoodLevel) == "function") and MTH_FEED_GetFoodLevel(itemID) or nil, - observedDietHint = nil, - evidenceType = "vendor-scan", - }); - end end end end @@ -442,8 +824,12 @@ function FOM_OnLoad() SLASH_FEEDOMATIC3 = "/feed"; SLASH_FEEDOMATIC4 = "/petfeed"; -- Rauen's PetFeed compatibility SLASH_FEEDOMATIC5 = "/pf"; + SLASH_FOMTRACE1 = "/fomtrace"; + SlashCmdList["FOMTRACE"] = function(msg) + FOM_CommandTrace(msg); + end SlashCmdList["FEEDOMATIC"] = function(msg) - if MTH_IsModuleEnabled and not MTH_IsModuleEnabled("feedomatic") then + if MTH_IsModuleEnabled and not MTH_IsModuleEnabled("feedomatic", false) then if DEFAULT_CHAT_FRAME and DEFAULT_CHAT_FRAME.AddMessage then MTH:Print("Feed-O-Matic is disabled while module 'feedomatic' is disabled.") end @@ -536,6 +922,10 @@ function FOM_CheckSetup() _, realClass = UnitClass("player"); if (realClass ~= "HUNTER") then return; end + local now = (type(GetTime) == "function") and (GetTime() or 0) or 0; + if (FOM_RealmPlayer ~= nil and (now - (tonumber(FOM_LAST_SETUP_AT) or 0)) < 5.0) then + return; + end if (FOM_RealmPlayer == nil) then FOM_RealmPlayer = GetCVar("realmName") .. "." .. UnitName("player"); @@ -556,6 +946,9 @@ function FOM_CheckSetup() FOM_FoodQuality[FOM_RealmPlayer][FOM_LastPetName] = { }; end end + FOM_EnsureQuarantineStore(); + FOM_LAST_SETUP_AT = now; + FOM_LAST_SETUP_PET = FOM_LastPetName; end @@ -690,8 +1083,13 @@ function FOM_OnEvent(event, arg1) or string.find(lowerError, "wrong pet food") or string.find(lowerError, "doesn't like") or string.find(lowerError, "does not like") + FOM_Trace("ui error classify lowLevel=" .. tostring(isLowLevelError and true or false) + .. " wrongFood=" .. tostring(isWrongFoodError and true or false) + .. " hasAttempt=" .. tostring(type(FOM_LastFeedAttempt) == "table" and true or false) + .. " lastFood='" .. tostring(FOM_LastFood or "") .. "'") if (isLowLevelError) then + FOM_Trace("reject reason=low-level") if (type(FOM_LastFeedAttempt) == "table") then FOM_CoreRecordReject(FOM_LastFeedAttempt.coreAttemptId, "low-level", arg1); FOM_CoreFinalizeAttempt(FOM_LastFeedAttempt.coreAttemptId, "rejected", "low-level"); @@ -709,6 +1107,7 @@ function FOM_OnEvent(event, arg1) end elseif (isWrongFoodError) then + FOM_Trace("reject reason=wrong-food") if (type(FOM_LastFeedAttempt) == "table") then FOM_CoreRecordReject(FOM_LastFeedAttempt.coreAttemptId, "wrong-food", arg1); FOM_CoreFinalizeAttempt(FOM_LastFeedAttempt.coreAttemptId, "rejected", "wrong-food"); @@ -784,28 +1183,7 @@ function FOM_OnEvent(event, arg1) return; elseif (event == "PET_STABLE_SHOW" or event == "PET_STABLE_UPDATE") then - - -- clean up the FOM_FoodQuality sub-tables in case we missed you abandoning a pet - FOM_CheckSetup(); - local stabledPetNames = nil; - if (type(MTH_GetStablePetNames) == "function") then - stabledPetNames = MTH_GetStablePetNames(); - end - if (stabledPetNames == nil) then - stabledPetNames = {}; - end - local orphanedPetNames = {}; - for savedPetName in FOM_FoodQuality[FOM_RealmPlayer] do - if (stabledPetNames == nil) then - GFWUtils.DebugLog("stabledPetNames == nil"); - end - if (stabledPetNames ~= nil and GFWTable.IndexOf(stabledPetNames, savedPetName) == 0) then - table.insert(orphanedPetNames, savedPetName); - end - end - for _, orphanedPet in orphanedPetNames do - FOM_FoodQuality[FOM_RealmPlayer][orphanedPet] = nil; - end + return; elseif (FOM_Config.Level) then @@ -899,23 +1277,72 @@ function FOM_OnUpdate(elapsed) if (FOM_LastFeedAttempt.coreAttemptId and type(MTH_FEED_RecordBuffOutcome) == "function") then pcall(MTH_FEED_RecordBuffOutcome, FOM_LastFeedAttempt.coreAttemptId, { hasFeedBuff = true, elapsed = elapsedSinceFeed }); end + local confirmedPetName = tostring(FOM_LastFeedAttempt.petName or "Your pet") + local confirmedFoodLink = tostring(FOM_LastFeedAttempt.itemLink or FOM_LastFood or "") + local alertMode = tostring(FOM_LastFeedAttempt.alertMode or FOM_Config.Alert or "") + if (alertMode == "chat") then + GFWUtils.Print(string.format(FOM_FEEDING_EAT, confirmedPetName, GFWUtils.Hilite(confirmedFoodLink))); + elseif (alertMode == "emote") then + SendChatMessage(string.format(FOM_FEEDING_FEED, confirmedPetName, confirmedFoodLink).. FOM_RandomEmote(), "EMOTE"); + end FOM_CoreFinalizeAttempt(FOM_LastFeedAttempt.coreAttemptId, "accepted", "accepted"); FOM_Trace("feed outcome accepted-with-buff itemId=" .. tostring(FOM_LastFeedAttempt.itemId) .. " pet='" .. tostring(FOM_LastFeedAttempt.petName or "") .. "' elapsed=" .. string.format("%.2f", elapsedSinceFeed)); FOM_LastFeedAttempt = nil; elseif (elapsedSinceFeed >= 2.50) then + local failedAttempt = FOM_LastFeedAttempt; if (FOM_LastFeedAttempt.coreAttemptId and type(MTH_FEED_RecordBuffOutcome) == "function") then pcall(MTH_FEED_RecordBuffOutcome, FOM_LastFeedAttempt.coreAttemptId, { hasFeedBuff = false, elapsed = elapsedSinceFeed }); end FOM_CoreRecordReject(FOM_LastFeedAttempt.coreAttemptId, "no-buff", nil); FOM_CoreFinalizeAttempt(FOM_LastFeedAttempt.coreAttemptId, "rejected", "no-buff"); + local currentPetInfo = MTH_FOM_GetCorePetInfo(); + FOM_RegisterNoBuffQuarantine(FOM_LastFeedAttempt.itemId, currentPetInfo); + if (type(MTH_FEED_BlockItemForPetLevel) == "function") then + pcall(MTH_FEED_BlockItemForPetLevel, + FOM_LastFeedAttempt.itemId, + tonumber(currentPetInfo and currentPetInfo.level) or nil, + "no-buff"); + end FOM_Trace("feed outcome no-eating-buff itemId=" .. tostring(FOM_LastFeedAttempt.itemId) .. " pet='" .. tostring(FOM_LastFeedAttempt.petName or "") .. "' elapsed=" .. string.format("%.2f", elapsedSinceFeed) .. " (likely not accepted by pet)"); FOM_LastFood = nil; FOM_LastFeedAttempt = nil; + + local retryCount = tonumber(failedAttempt and failedAttempt.retryCount) or 0; + local canRetry = (type(failedAttempt) == "table") + and not failedAttempt.manualFood + and retryCount < FOM_MAX_RETRIES_PER_REQUEST; + if (canRetry) then + local excludedItemIds = {}; + if (type(failedAttempt.excludedItemIds) == "table") then + for blockedId, blocked in failedAttempt.excludedItemIds do + if (blocked) then + excludedItemIds[blockedId] = true; + end + end + end + local failedItemId = tonumber(failedAttempt.itemId); + if (failedItemId ~= nil) then + excludedItemIds[failedItemId] = true; + end + local excludedCount = 0; + for _, isBlocked in excludedItemIds do + if (isBlocked) then + excludedCount = excludedCount + 1; + end + end + FOM_Trace("feed retry scheduled after no-buff retry=" .. tostring(retryCount + 1) + .. " excludedCount=" .. tostring(excludedCount)); + FOM_Feed(nil, { + retryCount = retryCount + 1, + excludedItemIds = excludedItemIds, + autoRetry = true, + }); + end end end end @@ -1008,6 +1435,129 @@ function FOM_CanFeed() return true; end +function FOM_PrintCollectedFoodData(limitArg) + local removedNoise = FOM_PruneUnknownFoodNoise(true); + if (removedNoise > 0) then + GFWUtils.Print("- pruned unknown non-food rows: " .. tostring(removedNoise)); + end + local removedExceptions = FOM_PruneExceptionNoise(); + if (removedExceptions > 0) then + GFWUtils.Print("- pruned inert exception rows: " .. tostring(removedExceptions)); + end + + local limit = tonumber(limitArg) or 20; + if (limit < 1) then + limit = 1; + elseif (limit > 100) then + limit = 100; + end + + GFWUtils.Print("Feed-O-Matic collected food data (limit " .. tostring(limit) .. "):"); + GFWUtils.Print("- server profile: " .. tostring(FOM_SERVER_PROFILE)); + + local unknownRows = {}; + if (type(MTH_FEED_GetUnknownFoodQueue) == "function") then + local fetched = MTH_FEED_GetUnknownFoodQueue(); + if (type(fetched) == "table") then + unknownRows = fetched; + end + end + GFWUtils.Print("- unknown/candidate foods: " .. tostring(table.getn(unknownRows))); + for index = 1, math.min(limit, table.getn(unknownRows)) do + local row = unknownRows[index]; + if (type(row) == "table") then + local itemId = tonumber(row.itemId); + local itemName = row.itemName or (itemId and GetItemInfo(itemId)) or ("item:" .. tostring(itemId or "?")); + local resolvedLevel = row.resolved and row.resolved.foodLevel or nil; + GFWUtils.Print(" unknown#" .. tostring(index) + .. " id=" .. tostring(itemId) + .. " name='" .. tostring(itemName) .. "'" + .. " state=" .. tostring(row.state) + .. " confidence=" .. tostring(row.confidence) + .. " observedLevel=" .. tostring(row.observedFoodLevel) + .. " resolvedLevel=" .. tostring(resolvedLevel)); + end + end + + local exceptionRows = {}; + if (type(MTH_FEED_GetStore) == "function") then + local store = MTH_FEED_GetStore(); + local byItem = store and store.exceptions and store.exceptions.byItemId or nil; + if (type(byItem) == "table") then + for itemKey, row in byItem do + if (type(row) == "table" and FOM_IsExceptionRowActive(row)) then + table.insert(exceptionRows, { + itemId = tonumber(itemKey) or itemKey, + row = row, + }); + end + end + end + end + table.sort(exceptionRows, function(a, b) + return tostring(a.itemId) < tostring(b.itemId); + end); + GFWUtils.Print("- core exception rows: " .. tostring(table.getn(exceptionRows))); + for index = 1, math.min(limit, table.getn(exceptionRows)) do + local entry = exceptionRows[index]; + local itemId = tonumber(entry.itemId); + local itemName = (itemId and GetItemInfo(itemId)) or ("item:" .. tostring(entry.itemId)); + local reasons = entry.row.reasons or {}; + GFWUtils.Print(" exception#" .. tostring(index) + .. " id=" .. tostring(entry.itemId) + .. " name='" .. tostring(itemName) .. "'" + .. " blockAtOrAbove=" .. tostring(entry.row.blockAtOrAbovePetLevel) + .. " maxPetLevel=" .. tostring(entry.row.maxPetLevel) + .. " noBuff=" .. tostring(reasons.noBuff or 0) + .. " lowLevel=" .. tostring(reasons.lowLevel or 0) + .. " wrongFood=" .. tostring(reasons.wrongFood or 0)); + end + + local quarantineStore = FOM_EnsureQuarantineStore(); + local quarantineCount = 0; + local printedQuarantine = 0; + if (type(quarantineStore) == "table" and type(quarantineStore.byFamily) == "table") then + for _, byItem in quarantineStore.byFamily do + if (type(byItem) == "table") then + for _, row in byItem do + if (type(row) == "table") then + quarantineCount = quarantineCount + 1; + end + end + end + end + end + GFWUtils.Print("- FOM quarantine rows: " .. tostring(quarantineCount)); + if (type(quarantineStore) == "table" and type(quarantineStore.byFamily) == "table") then + for family, byItem in quarantineStore.byFamily do + if (type(byItem) == "table") then + for itemId, row in byItem do + if (printedQuarantine >= limit) then + break; + end + if (type(row) == "table") then + local numericItemId = tonumber(itemId); + local itemName = (numericItemId and GetItemInfo(numericItemId)) or ("item:" .. tostring(itemId)); + printedQuarantine = printedQuarantine + 1; + GFWUtils.Print(" quarantine#" .. tostring(printedQuarantine) + .. " family='" .. tostring(family) .. "'" + .. " id=" .. tostring(itemId) + .. " name='" .. tostring(itemName) .. "'" + .. " minRejectPetLevel=" .. tostring(row.minRejectPetLevel) + .. " reason=" .. tostring(row.reason)); + end + end + if (printedQuarantine >= limit) then + break; + end + end + if (printedQuarantine >= limit) then + break; + end + end + end +end + function FOM_ChatCommandHandler(msg) if ( msg == "" ) then @@ -1051,6 +1601,7 @@ function FOM_ChatCommandHandler(msg) GFWUtils.Print("- "..GFWUtils.Hilite("quality high").." | "..GFWUtils.Hilite("low").." - Set whether to prefer foods that give your pet more happiness faster or less happiness more slowly."); GFWUtils.Print("- "..GFWUtils.Hilite("feed").." - Feed your pet (automatically finds an appropriate food)."); GFWUtils.Print("- "..GFWUtils.Hilite("feed ").." - Feed your pet a specific food."); + GFWUtils.Print("- "..GFWUtils.Hilite("fooddata [limit]").." - Print collected food intelligence (unknown queue, exception blocks, quarantine). Defaults to 20 rows per section."); GFWUtils.Print("- "..GFWUtils.Hilite("add ").." - Add food to list."); GFWUtils.Print("- "..GFWUtils.Hilite("remove ").." - Remove food from list."); GFWUtils.Print("- "..GFWUtils.Hilite("show ").." - Show food list."); @@ -1295,6 +1846,12 @@ function FOM_ChatCommandHandler(msg) end -- Feed Pet + local _, _, dumpCmd, dumpLimit = string.find(msg, "(%w+) *(%w*)"); + if (dumpCmd == "fooddata" or dumpCmd == "dumpfood" or dumpCmd == "data") then + FOM_PrintCollectedFoodData(dumpLimit); + return; + end + local _, _, cmd, foodString = string.find(msg, "(%w+) *(.*)"); if ( cmd == "feed" ) then if (foodString == "") then @@ -1610,31 +2167,145 @@ function FOM_HasFeedEffect() end -- Feed Pet -function FOM_Feed(aFood) - if MTH_IsModuleEnabled and not MTH_IsModuleEnabled("feedomatic") then +local function FOM_CloneSet(source) + local clone = {}; + if (type(source) ~= "table") then + return clone; + end + for key, value in source do + if (value) then + clone[key] = true; + end + end + return clone; +end + +local function FOM_CountSetEntries(source) + if (type(source) ~= "table") then + return 0; + end + local count = 0; + for _, value in source do + if (value) then + count = count + 1; + end + end + return count; +end + +local function FOM_CloneFoodList(source) + local clone = {}; + if (type(source) ~= "table") then + return clone; + end + for i = 1, table.getn(source) do + clone[i] = source[i]; + end + return clone; +end + +local function FOM_GetFeedScanCacheKey(petInfo) + if (type(petInfo) ~= "table") then + return "none"; + end + return tostring(petInfo.name or "") + .. "|" .. tostring(petInfo.family or "") + .. "|" .. tostring(tonumber(petInfo.level) or 0); +end + +local function FOM_GetCachedAutoChoice(excludedItemIds) + if (type(FOM_LAST_AUTO_CHOICE) ~= "table") then + return nil, nil, nil; + end + local now = (type(GetTime) == "function") and (GetTime() or 0) or 0; + if ((now - (tonumber(FOM_LAST_AUTO_CHOICE.at) or 0)) > FOM_LAST_AUTO_CHOICE_TTL) then + return nil, nil, nil; + end + local bag = tonumber(FOM_LAST_AUTO_CHOICE.bag); + local slot = tonumber(FOM_LAST_AUTO_CHOICE.slot); + local itemId = tonumber(FOM_LAST_AUTO_CHOICE.itemId); + if (bag == nil or slot == nil or itemId == nil) then + return nil, nil, nil; + end + if (type(excludedItemIds) == "table" and excludedItemIds[itemId]) then + return nil, nil, nil; + end + if (type(GetContainerItemLink) ~= "function") then + return nil, nil, nil; + end + local link = GetContainerItemLink(bag, slot); + if (not link) then + return nil, nil, nil; + end + local linkId = FOM_IDFromLink(link); + if (tonumber(linkId) ~= itemId) then + return nil, nil, nil; + end + return bag, slot, itemId; +end + +local function FOM_SetCachedAutoChoice(bag, slot, itemId) + if (bag == nil or slot == nil or itemId == nil) then + return; + end + FOM_LAST_AUTO_CHOICE = { + at = (type(GetTime) == "function") and (GetTime() or 0) or 0, + bag = tonumber(bag), + slot = tonumber(slot), + itemId = tonumber(itemId), + }; +end + +local function FOM_TryPickSinglePrecomputedFood(foodList, excludedItemIds, allowUseful) + if (type(foodList) ~= "table" or table.getn(foodList) ~= 1) then + return nil, nil; + end + local foodInfo = foodList[1]; + if (type(foodInfo) ~= "table") then + return nil, nil; + end + local itemId = tonumber(foodInfo.itemId); + if (itemId ~= nil and type(excludedItemIds) == "table" and excludedItemIds[itemId]) then + return nil, nil; + end + if (not allowUseful and foodInfo.useful) then + return nil, nil; + end + return foodInfo.bag, foodInfo.slot; +end + +function FOM_Feed(aFood, options) + local feedStartMs = FOM_PerfNowMs(); + options = options or {}; + local retryCount = tonumber(options.retryCount) or 0; + local excludedItemIds = FOM_CloneSet(options.excludedItemIds); + local sourceTag = (retryCount > 0) and "feedomatic:auto-retry" or "feedomatic:auto"; + local manualFood = (aFood ~= nil and tostring(aFood) ~= ""); + + if MTH_IsModuleEnabled and not MTH_IsModuleEnabled("feedomatic", false) then if DEFAULT_CHAT_FRAME and DEFAULT_CHAT_FRAME.AddMessage then MTH:Print("[FOM WRAP] FOM_Feed blocked: module disabled", "debug") end - return; + return false; end -- Make sure we have a feedable pet local petInfo = MTH_FOM_GetCorePetInfo(); if not (petInfo and petInfo.liveExists) then GFWUtils.Note(FOM_ERROR_NO_PET); - return; + return false; end if (petInfo.dead or (tonumber(petInfo.health) and tonumber(petInfo.health) <= 0)) then GFWUtils.Note(FOM_ERROR_PET_DEAD); - return; + return false; end if (FOM_GetPetDietList() == nil) then GFWUtils.Note(FOM_ERROR_NO_FEEDABLE_PET); - return; + return false; end if (UnitAffectingCombat("player") or UnitAffectingCombat("pet")) then GFWUtils.Note(FOM_ERROR_IN_COMBAT); - return; + return false; end -- Assign Variable @@ -1642,12 +2313,17 @@ function FOM_Feed(aFood) FOM_Trace("feed request pet='" .. tostring(pet) .. "' level=" .. tostring(petInfo.level) .. " happiness=" .. tostring(petInfo.happiness) - .. " manualFood='" .. tostring(aFood or "") .. "'") + .. " manualFood='" .. tostring(aFood or "") .. "'" + .. " retry=" .. tostring(retryCount) + .. " excluded=" .. tostring(FOM_CountSetEntries(excludedItemIds))) + local checkSetupStartMs = FOM_PerfNowMs(); FOM_CheckSetup(); + FOM_PerfRecord("FOM_CheckSetup", FOM_PerfNowMs() - checkSetupStartMs, "retry=" .. tostring(retryCount), false); if (FOM_LastPetName == nil or FOM_LastPetName == "") then GFWUtils.DebugLog("Can't get pet info."); - return; + FOM_PerfRecord("FOM_Feed total", FOM_PerfNowMs() - feedStartMs, "result=no-pet-name", true); + return false; end if (GetLocale() ~= "enUS") then @@ -1662,28 +2338,80 @@ function FOM_Feed(aFood) -- Look for Food local foodBag, foodItem; - if (aFood ~= nil) then + local precomputedFoodList = nil; + local openSlots = nil; + if (manualFood) then -- if told to feed a specific food, do so foodBag, foodItem = FOM_FindSpecificFood(aFood); if ( foodBag == nil) then -- No Food Could be Found GFWUtils.Print(string.format(FOM_ERROR_FOOD_NOT_FOUND, pet, aFood)); - return; + FOM_PerfRecord("FOM_Feed total", FOM_PerfNowMs() - feedStartMs, "result=manual-not-found", true); + return false; end FOM_LastChoiceReason = "manual selection"; else - foodBag, foodItem = FOM_NewFindFood(); + local cachedChoiceStartMs = FOM_PerfNowMs(); + local cachedBag, cachedSlot, cachedItemId = FOM_GetCachedAutoChoice(excludedItemIds); + if (cachedBag ~= nil and cachedSlot ~= nil) then + foodBag, foodItem = cachedBag, cachedSlot; + FOM_LastChoiceReason = "cached previous food"; + FOM_PerfRecord("FOM_AutoChoice cache", FOM_PerfNowMs() - cachedChoiceStartMs, + "hit itemId=" .. tostring(cachedItemId), false); + else + FOM_PerfRecord("FOM_AutoChoice cache", FOM_PerfNowMs() - cachedChoiceStartMs, "miss", false); + end + + if (foodBag == nil) then + local precomputeStartMs = FOM_PerfNowMs(); + local cacheNow = (type(GetTime) == "function") and (GetTime() or 0) or 0; + if (retryCount <= 0 + and type(FOM_FEED_SCAN_CACHE) == "table" + and (cacheNow - (tonumber(FOM_FEED_SCAN_CACHE.at) or 0)) <= FOM_FEED_SCAN_CACHE_TTL + and type(FOM_FEED_SCAN_CACHE.foods) == "table") then + precomputedFoodList = FOM_CloneFoodList(FOM_FEED_SCAN_CACHE.foods); + openSlots = tonumber(FOM_FEED_SCAN_CACHE.openSlots) or 0; + FOM_PerfRecord("FOM_Feed precompute", FOM_PerfNowMs() - precomputeStartMs, + "cache-hit foods=" .. tostring(table.getn(precomputedFoodList)) .. " openSlots=" .. tostring(openSlots), false); + else + precomputedFoodList = FOM_FlatFoodList(); + openSlots = FOM_NumOpenBagSlots(); + FOM_FEED_SCAN_CACHE = { + at = cacheNow, + key = FOM_GetFeedScanCacheKey(petInfo), + foods = FOM_CloneFoodList(precomputedFoodList), + openSlots = openSlots, + }; + FOM_PerfRecord("FOM_Feed precompute", FOM_PerfNowMs() - precomputeStartMs, + "cache-miss foods=" .. tostring(table.getn(precomputedFoodList)) .. " openSlots=" .. tostring(openSlots), false); + end + + local fastPickStartMs = FOM_PerfNowMs(); + foodBag, foodItem = FOM_TryPickSinglePrecomputedFood(precomputedFoodList, excludedItemIds, (FOM_Config.AvoidUsefulFood and false or true)); + if (foodBag ~= nil) then + FOM_LastChoiceReason = "single candidate fast-path"; + FOM_PerfRecord("FOM_SingleCandidate", FOM_PerfNowMs() - fastPickStartMs, "hit", false); + else + FOM_PerfRecord("FOM_SingleCandidate", FOM_PerfNowMs() - fastPickStartMs, "miss", false); + end + + if (foodBag == nil) then + local findStartMs = FOM_PerfNowMs(); + foodBag, foodItem = FOM_NewFindFood(nil, excludedItemIds, precomputedFoodList, openSlots); + FOM_PerfRecord("FOM_NewFindFood primary", FOM_PerfNowMs() - findStartMs, "found=" .. tostring(foodBag ~= nil), false); + end + end end - local fallbackBag, fallbackItem = FOM_NewFindFood(1); + local fallbackBag, fallbackItem = nil, nil; + if (not manualFood and foodBag == nil and FOM_Config.Fallback) then + local fallbackStartMs = FOM_PerfNowMs(); + fallbackBag, fallbackItem = FOM_NewFindFood(1, excludedItemIds, precomputedFoodList, openSlots); + FOM_PerfRecord("FOM_NewFindFood fallback", FOM_PerfNowMs() - fallbackStartMs, "found=" .. tostring(fallbackBag ~= nil), false); + end if ( foodBag == nil) then - if (not FOM_Config.Fallback and fallbackBag) then - local pronoun = FOM_PRONOUN_MALE; - if (UnitSex("pet") == 3) then pronoun = FOM_PRONOUN_FEMALE; end - GFWUtils.Print(string.format(FOM_ERROR_NO_FOOD_NO_FALLBACK, pet, pronoun)); - return; - elseif (fallbackBag) then + if (fallbackBag) then foodBag, foodItem = fallbackBag, fallbackItem; if (FOM_LastChoiceReason == nil or FOM_LastChoiceReason == "") then FOM_LastChoiceReason = "fallback to useful food"; @@ -1694,24 +2422,31 @@ function FOM_Feed(aFood) -- No Food Could be Found GFWUtils.Print(string.format(FOM_ERROR_NO_FOOD, pet)); FOM_LastChoiceReason = nil; - return; + FOM_PerfRecord("FOM_Feed total", FOM_PerfNowMs() - feedStartMs, "result=no-food", true); + return false; end end + local prepareStartMs = FOM_PerfNowMs(); FOM_LastFood = GetContainerItemLink(foodBag, foodItem); local selectedId = FOM_IDFromLink(FOM_LastFood) - local selectedFoodLevel = (type(MTH_FEED_GetFoodLevel) == "function") and MTH_FEED_GetFoodLevel(selectedId) or nil + FOM_SetCachedAutoChoice(foodBag, foodItem, selectedId) + local selectedFoodLevel = FOM_GetFoodLevelOverride(selectedId); + if (selectedFoodLevel == nil and type(MTH_FEED_GetFoodLevel) == "function") then + selectedFoodLevel = MTH_FEED_GetFoodLevel(selectedId); + end local coreAttemptId = FOM_CoreBeginAttempt({ itemId = selectedId, itemName = FOM_NameFromLink(FOM_LastFood), itemLink = tostring(FOM_LastFood or ""), - source = "feedomatic:auto", + source = sourceTag, petId = tostring(petInfo and petInfo.id or ""), petName = tostring(pet or ""), family = tostring(petInfo and petInfo.family or ""), petLevel = tonumber(petInfo and petInfo.level) or nil, foodLevel = selectedFoodLevel, }) + FOM_PerfRecord("FOM_Feed prepare", FOM_PerfNowMs() - prepareStartMs, "itemId=" .. tostring(selectedId), false); FOM_Trace("selected food link='" .. tostring(FOM_LastFood) .. "' itemId=" .. tostring(selectedId) .. " bag=" .. tostring(foodBag) @@ -1721,10 +2456,12 @@ function FOM_Feed(aFood) GFWUtils.DebugLog("Picked "..FOM_LastFood.." (bag "..foodBag..", slot "..foodItem..") for feeding."); if (FOM_Config.Debug) then -- don't actually feed anything, just show what we would choose - return; + FOM_PerfRecord("FOM_Feed total", FOM_PerfNowMs() - feedStartMs, "result=debug", true); + return false; end -- Actually feed the item to the pet + local dropStartMs = FOM_PerfNowMs(); PickupContainerItem(foodBag, foodItem); FOM_Trace("feed execute pickup bag=" .. tostring(foodBag) .. " slot=" .. tostring(foodItem) @@ -1740,6 +2477,7 @@ function FOM_Feed(aFood) end FOM_Trace("feed execute drop on pet cursorHasItemAfterDrop=" .. tostring(CursorHasItem() and true or false)) end + FOM_PerfRecord("FOM_Feed drop", FOM_PerfNowMs() - dropStartMs, "cursor=" .. tostring(CursorHasItem() and true or false), false); if (coreAttemptId and type(MTH_FEED_RecordClientDropResult) == "function") then pcall(MTH_FEED_RecordClientDropResult, coreAttemptId, { consumedFromCursor = (CursorHasItem() and false or true), @@ -1751,25 +2489,33 @@ function FOM_Feed(aFood) FOM_CoreRecordReject(coreAttemptId, "client-drop-fail", nil); FOM_CoreFinalizeAttempt(coreAttemptId, "rejected", "client-drop-fail"); FOM_Trace("feed execute returned item to bag") + FOM_PerfRecord("FOM_Feed total", FOM_PerfNowMs() - feedStartMs, "result=cursor-fail", true); + return false; else FOM_Trace("feed execute accepted by client (item consumed from cursor)") + if (selectedId ~= nil) then + excludedItemIds[selectedId] = true; + end FOM_LastFeedAttempt = { startedAt = GetTime() or 0, petName = tostring(pet or ""), itemId = selectedId, itemLink = tostring(FOM_LastFood or ""), coreAttemptId = coreAttemptId, + alertMode = tostring(FOM_Config.Alert or ""), + petLevel = tonumber(petInfo and petInfo.level) or nil, + retryCount = retryCount, + excludedItemIds = excludedItemIds, + manualFood = manualFood, loggedFirstCheck = false, } FOM_State.ShouldFeed = nil; - -- Alert - if ( FOM_Config.Alert == "chat") then - GFWUtils.Print(string.format(FOM_FEEDING_EAT, pet, GFWUtils.Hilite(FOM_LastFood))); - elseif ( FOM_Config.Alert == "emote") then - SendChatMessage(string.format(FOM_FEEDING_FEED, pet, FOM_LastFood).. FOM_RandomEmote(), "EMOTE"); - end + FOM_Trace("feed alert deferred until confirmation itemId=" .. tostring(selectedId) + .. " pet='" .. tostring(pet or "") + .. "' mode='" .. tostring(FOM_Config.Alert or "") .. "'") + FOM_PerfRecord("FOM_Feed total", FOM_PerfNowMs() - feedStartMs, "result=ok itemId=" .. tostring(selectedId), true); + return true; end - end function FOM_RandomEmote() @@ -1809,22 +2555,50 @@ function FOM_FindSpecificFood(foodName) end function FOM_IsTemporaryFood(itemLink) + local numericItemId = FOM_IDFromLink(itemLink); + if (numericItemId ~= nil and FOM_TEMP_FOOD_CACHE[numericItemId] ~= nil) then + return FOM_TEMP_FOOD_CACHE[numericItemId] and true or false; + end local _, _, link = string.find(itemLink, "(item:%d+:%d+:%d+:%d+)"); if (link == nil or link == "") then + if (numericItemId ~= nil) then + FOM_TEMP_FOOD_CACHE[numericItemId] = false; + end return false; end FOMTooltip:ClearLines(); FOMTooltip:SetHyperlink(link); if (FOMTooltipTextLeft2:GetText() == ITEM_CONJURED) then + if (numericItemId ~= nil) then + FOM_TEMP_FOOD_CACHE[numericItemId] = true; + end return true; else + if (numericItemId ~= nil) then + FOM_TEMP_FOOD_CACHE[numericItemId] = false; + end return false; end end +local function FOM_EnsureQuestScanFresh(maxAgeSeconds) + if (not FOM_Config.AvoidQuestFood) then + return; + end + local now = (type(GetTime) == "function") and (GetTime() or 0) or 0; + local age = now - (tonumber(FOM_LAST_QUEST_SCAN_AT) or 0); + local maxAge = tonumber(maxAgeSeconds) or 1.5; + if (age < maxAge) then + return; + end + FOM_ScanQuests(); + FOM_LAST_QUEST_SCAN_AT = now; +end + function FOM_FlatFoodList() + local flatStartMs = FOM_PerfNowMs(); local foodList = {}; local overflowFoodList = {}; local scanned = 0; @@ -1832,9 +2606,14 @@ function FOM_FlatFoodList() local compatibleCount = 0; local overflowCompatibleCount = 0; local rejectedByException = 0; + local rejectedByLevelOverride = 0; + local rejectedByQuarantine = 0; + local unknownLevelCandidates = 0; FOM_Quantity = { }; + FOM_EnsureQuestScanFresh(1.5); local petInfo = MTH_FOM_GetCorePetInfo(); local petLevel = tonumber(petInfo and petInfo.level) or 0; + local familyKey = FOM_GetFamilyKeyFromInfo(petInfo); for bagNum = 0, 4 do if (not FOM_BagIsQuiver(bagNum) ) then -- skip bags that can't contain food @@ -1852,51 +2631,54 @@ function FOM_FlatFoodList() local name = FOM_NameFromLink(itemLink); FOM_FoodIDsToNames[itemID] = name; local isUseful = FOM_IsUsefulFood(itemID, itemCount); - local isKnownFood = (type(MTH_FEED_IsKnownFoodItem) == "function") and MTH_FEED_IsKnownFoodItem(itemID) or FOM_IsKnownFood(itemID); - local foodLevel = (type(MTH_FEED_GetFoodLevel) == "function") and MTH_FEED_GetFoodLevel(itemID) or nil; - if (not isKnownFood and type(MTH_FEED_RecordUnknownFoodCandidate) == "function") then - pcall(MTH_FEED_RecordUnknownFoodCandidate, { - itemId = itemID, - itemName = name, - itemLink = tostring(itemLink or ""), - source = "bag-scan", - observedFoodLevel = foodLevel, - observedDietHint = nil, - evidenceType = "bag-scan", - }); + local isKnownFood = FOM_IsKnownFood(itemID); + local foodLevel = FOM_GetFoodLevelOverride(itemID); + if (foodLevel == nil and type(MTH_FEED_GetFoodLevel) == "function") then + foodLevel = MTH_FEED_GetFoodLevel(itemID); end - local isExceptionReject = (type(MTH_FEED_IsExceptionReject) == "function") and MTH_FEED_IsExceptionReject(itemID) or false; + local isExceptionReject = false; + local isLevelOverrideReject = not FOM_IsItemPetLevelCompatible(itemID, petLevel); + local isQuarantineReject = FOM_IsQuarantined(itemID, petLevel, familyKey); if (isExceptionReject) then rejectedByException = rejectedByException + 1; end + if (isLevelOverrideReject) then + rejectedByLevelOverride = rejectedByLevelOverride + 1; + end + if (isQuarantineReject) then + rejectedByQuarantine = rejectedByQuarantine + 1; + end local isCompatible = true; local isAbovePreferredLevel = false; - if (petLevel > 0 and foodLevel ~= nil) then - if (type(MTH_FEED_GetFoodCompatibilityWindow) == "function") then - local minAllowed, maxAllowed = MTH_FEED_GetFoodCompatibilityWindow(petLevel); - local numericFood = tonumber(foodLevel) or 0; - if (minAllowed ~= nil and numericFood < tonumber(minAllowed)) then - isCompatible = false; - end - if (maxAllowed ~= nil and numericFood > tonumber(maxAllowed)) then - isCompatible = false; - isAbovePreferredLevel = true; - end - elseif (type(MTH_FEED_IsFoodCompatible) == "function") then - isCompatible = MTH_FEED_IsFoodCompatible(petLevel, foodLevel); - end + local hasKnownLevel = (tonumber(foodLevel) ~= nil); + if (not hasKnownLevel) then + unknownLevelCandidates = unknownLevelCandidates + 1; end - if (isCompatible and not isExceptionReject) then + if (isCompatible and not isExceptionReject and not isLevelOverrideReject and not isQuarantineReject) then compatibleCount = compatibleCount + 1; - local foodQuality = tonumber(foodLevel) or MAX_QUALITY; - table.insert(foodList, {bag=bagNum, slot=itemNum, link=itemLink, count=itemCount, quality=foodQuality, useful=isUseful, temp=FOM_IsTemporaryFood(itemLink)}); - elseif (isAbovePreferredLevel and not isExceptionReject) then + local foodQuality = tonumber(foodLevel); + if (foodQuality == nil) then + if (FOM_Config.PreferHigherQuality) then + foodQuality = FOM_UNKNOWN_QUALITY_FOR_DESC; + else + foodQuality = FOM_UNKNOWN_QUALITY_FOR_ASC; + end + end + table.insert(foodList, {bag=bagNum, slot=itemNum, link=itemLink, itemId=itemID, count=itemCount, quality=foodQuality, useful=isUseful, temp=FOM_IsTemporaryFood(itemLink), knownLevel=hasKnownLevel}); + elseif (isAbovePreferredLevel and not isExceptionReject and not isLevelOverrideReject and not isQuarantineReject) then overflowCompatibleCount = overflowCompatibleCount + 1; - local overflowQuality = tonumber(foodLevel) or MAX_QUALITY; - table.insert(overflowFoodList, {bag=bagNum, slot=itemNum, link=itemLink, count=itemCount, quality=overflowQuality, useful=isUseful, temp=FOM_IsTemporaryFood(itemLink), overflow=true}); + local overflowQuality = tonumber(foodLevel); + if (overflowQuality == nil) then + if (FOM_Config.PreferHigherQuality) then + overflowQuality = FOM_UNKNOWN_QUALITY_FOR_DESC; + else + overflowQuality = FOM_UNKNOWN_QUALITY_FOR_ASC; + end + end + table.insert(overflowFoodList, {bag=bagNum, slot=itemNum, link=itemLink, itemId=itemID, count=itemCount, quality=overflowQuality, useful=isUseful, temp=FOM_IsTemporaryFood(itemLink), overflow=true, knownLevel=hasKnownLevel}); end end end @@ -1911,14 +2693,78 @@ function FOM_FlatFoodList() .. " compatible=" .. tostring(compatibleCount) .. " overflowCompatible=" .. tostring(overflowCompatibleCount) .. " exceptionRejects=" .. tostring(rejectedByException) + .. " levelOverrideRejects=" .. tostring(rejectedByLevelOverride) + .. " quarantineRejects=" .. tostring(rejectedByQuarantine) + .. " unknownLevel=" .. tostring(unknownLevelCandidates) .. " candidates=" .. tostring(table.getn(foodList))) + if (FOM_PERF_TRACE_ENABLED) then + FOM_PerfRecord("FOM_FlatFoodList", FOM_PerfNowMs() - flatStartMs, + "scanned=" .. tostring(scanned) + .. " inDiet=" .. tostring(inDiet) + .. " candidates=" .. tostring(table.getn(foodList)), + false); + end return foodList; end -function FOM_NewFindFood(fallback) - local FlatFoodList = FOM_FlatFoodList(); +function FOM_NewFindFood(fallback, excludedItemIds, precomputedFoodList, openSlotsOverride) + local findStartMs = FOM_PerfNowMs(); + local FlatFoodList = nil; + if (type(precomputedFoodList) == "table") then + FlatFoodList = FOM_CloneFoodList(precomputedFoodList); + else + FlatFoodList = FOM_FlatFoodList(); + end local initialCount = table.getn(FlatFoodList) local reasonParts = {}; + if (initialCount <= 0) then + FOM_LastChoiceReason = nil; + if (FOM_PERF_TRACE_ENABLED) then + FOM_PerfRecord("FOM_NewFindFood", FOM_PerfNowMs() - findStartMs, + "fallback=" .. tostring(fallback and true or false) + .. " initial=0 final=0", + false); + end + return nil; + end + local petInfo = MTH_FOM_GetCorePetInfo(); + local petLevel = tonumber(petInfo and petInfo.level) or 0; + + if (petLevel >= FOM_REQUIRE_KNOWN_LEVEL_PET_LEVEL) then + local knownCount = 0; + for _, foodInfo in FlatFoodList do + if (foodInfo.knownLevel) then + knownCount = knownCount + 1; + end + end + if (knownCount > 0) then + local knownOnly = {}; + for _, foodInfo in FlatFoodList do + if (foodInfo.knownLevel) then + table.insert(knownOnly, foodInfo); + end + end + FlatFoodList = knownOnly; + table.insert(reasonParts, "known-level required at high pet level"); + end + end + + if (type(excludedItemIds) == "table") then + local allowedFoods = {}; + local excludedCount = 0; + for _, foodInfo in FlatFoodList do + local id = tonumber(foodInfo.itemId); + if (id ~= nil and excludedItemIds[id]) then + excludedCount = excludedCount + 1; + else + table.insert(allowedFoods, foodInfo); + end + end + if (excludedCount > 0) then + FlatFoodList = allowedFoods; + table.insert(reasonParts, "excluded failed foods=" .. tostring(excludedCount)); + end + end -- if there are any conjured foods, drop everything else from the list local tempFoodsOnly = {}; @@ -1935,7 +2781,11 @@ function FOM_NewFindFood(fallback) table.sort(FlatFoodList, FOM_SortCount); -- small stacks first - if (FOM_NumOpenBagSlots() > FOM_Config.KeepOpenSlots) then + local openSlots = tonumber(openSlotsOverride); + if (openSlots == nil) then + openSlots = FOM_NumOpenBagSlots(); + end + if (openSlots > FOM_Config.KeepOpenSlots) then if (FOM_Config.PreferHigherQuality) then table.sort(FlatFoodList, FOM_SortQualityDescending); -- higher quality first table.insert(reasonParts, "higher quality preferred"); @@ -1966,6 +2816,13 @@ function FOM_NewFindFood(fallback) .. " postConjured=" .. tostring(postConjuredCount) .. " postFilters=" .. tostring(table.getn(FlatFoodList)) .. " reasons='" .. tostring(table.concat(reasonParts, ", ")) .. "'") + if (FOM_PERF_TRACE_ENABLED) then + FOM_PerfRecord("FOM_NewFindFood", FOM_PerfNowMs() - findStartMs, + "fallback=" .. tostring(fallback and true or false) + .. " initial=" .. tostring(initialCount) + .. " final=" .. tostring(table.getn(FlatFoodList)), + false); + end for _, foodInfo in FlatFoodList do if (foodInfo.overflow) then @@ -2031,7 +2888,6 @@ function FOM_IsUsefulFood(itemID, quantity) end end if (FOM_Config.AvoidQuestFood) then - FOM_ScanQuests(); if (FOM_QuestFood ~= nil and FOM_QuestFood[FOM_RealmPlayer] ~= nil and FOM_QuestFood[FOM_RealmPlayer][foodName]) then if (FOM_Quantity[foodName] == nil) then FOM_Quantity[foodName] = quantity; diff --git a/modules/feedomatic/module.lua b/modules/feedomatic/module.lua index c87a7d2..c37f157 100644 --- a/modules/feedomatic/module.lua +++ b/modules/feedomatic/module.lua @@ -6,7 +6,7 @@ local MTH_FeedOMatic = { name = "feedomatic", enabled = false, - version = "1.0.6", + version = "1.1.0", events = { "VARIABLES_LOADED", "MERCHANT_SHOW", @@ -38,7 +38,7 @@ function MTH_FeedOMatic_OnUpdate(elapsed) if not elapsed then return end - if not (MTH and MTH.IsModuleEnabled and MTH:IsModuleEnabled("feedomatic", true)) then + if not (MTH and MTH.IsModuleEnabled and MTH:IsModuleEnabled("feedomatic", false)) then return end if type(FOM_OnUpdate) == "function" then diff --git a/modules/icu/MTH_ICU.lua b/modules/icu/MTH_ICU.lua new file mode 100644 index 0000000..43b1982 --- /dev/null +++ b/modules/icu/MTH_ICU.lua @@ -0,0 +1,1127 @@ +ICU_VERSION = "ICU 1.4 - Shanktank's Version"; + +ICU_MAX_LINES = 10; + +ICU_CLASSES = { + ["Warrior"] = { .25, 0, 0, .25; }, + ["Mage"] = { .5, .25, 0, .25; }, + ["Rogue"] = { .75, .5, 0, .25; }, + ["Druid"] = { 1, .75, 0, .25; }, + ["Hunter"] = { .25, 0, .25, .5; }, + ["Shaman"] = { .5, .25, .25, .5; }, + ["Priest"] = { .75, .5, .25, .5; }, + ["Warlock"] = { 1, .75, .25, .5; }, + ["Paladin"] = { .25, 0, .5, .75; } +}; + +ICU_DESCRIPTIONS = { + ["ALERT"] = "ALERT will immediately ping and add a message in the specified chat when you click the blip of a PvP-flagged player of the opposite faction on the minimap", + ["ANNOUNCE"] = "ANNOUNCE will add a message in the specified chat when you click an entry in the popup frame", + ["ANCHOR"] = "ANCHOR sets the location of the frame that pops up when you click a blip on the minimap", + ["PLAYER_COLOR_MODE"] = "PLAYER_COLOR_MODE controls how player rows are colored (CLASS, REACTION, FACTION, CUSTOM)" +}; +ICU_OPTIONS = { + ["ALERT"] = { "AUTO", "PARTY", "RAID", "SELF", "MTH Message", "OFF"; }, + ["ANNOUNCE"] = { "AUTO", "SAY", "YELL", "PARTY", "RAID", "SELF", "MTH Message", "OFF"; }, + ["ANCHOR"] = { "TOP", "TOPLEFT", "TOPRIGHT", "BOTTOM", "BOTTOMLEFT", "BOTTOMRIGHT", "LEFT", "RIGHT", "CUSTOM"; }, + ["POPUP_HIDE_DELAY"] = { "INSTANT", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"; }, + ["HEALTH_TEXT_MODE"] = { "NONE", "PERCENT", "HP", "BOTH"; }, + ["PLAYER_COLOR_MODE"] = { "CLASS", "REACTION", "FACTION", "CUSTOM"; } +}; + +ICU_PING_X = 0; +ICU_PING_Y = 0; + +local icu_prevtooltip = nil; +local ICU_ENABLED = false; +local ICU_HOOK_ACTIVE = false; +local ICU_Original_Minimap_OnClick = nil; +local ICU_LAST_ALERT_KEY = ""; +local ICU_LAST_ALERT_AT = 0; +local ICU_POPUP_MOUSE_OUT_AT = nil; +local ICU_GetStore; +local ICU_CustomAnchor = nil; +local ICU_STORE_CACHE = nil; +local ICU_POPUP_LAST_UPDATE_AT = 0; +local ICU_POPUP_UPDATE_INTERVAL = 0.10; +local ICU_MOUSEOVER_LAST_UPDATE_AT = 0; +local ICU_MOUSEOVER_UPDATE_INTERVAL = 0.15; +local ICU_TRACE_ENABLED = false; + +local ICU_CLASS_COLOR_FALLBACK = { + WARRIOR = { 0.78, 0.61, 0.43 }, + MAGE = { 0.41, 0.80, 0.94 }, + ROGUE = { 1.00, 0.96, 0.41 }, + DRUID = { 1.00, 0.49, 0.04 }, + HUNTER = { 0.67, 0.83, 0.45 }, + SHAMAN = { 0.00, 0.44, 0.87 }, + PRIEST = { 1.00, 1.00, 1.00 }, + WARLOCK = { 0.58, 0.51, 0.79 }, + PALADIN = { 0.96, 0.55, 0.73 }, +}; + +local ICU_DEFAULT_BG_COLORS = { + PLAYER_HOSTILE = { 1.00, 0.20, 0.20 }, + PLAYER_NEUTRAL = { 1.00, 0.85, 0.10 }, + PLAYER_FRIENDLY = { 0.20, 0.75, 1.00 }, + NPC_HOSTILE = { 1.00, 0.20, 0.20 }, + NPC_NEUTRAL = { 1.00, 0.85, 0.10 }, + NPC_FRIENDLY = { 0.10, 0.90, 0.10 }, + UNKNOWN = { 0.70, 0.70, 0.70 }, +}; + +-------------------------------------------------------------------------------- +-- Auxiliary functions +-------------------------------------------------------------------------------- + +function ICU_Print(msg) + DEFAULT_CHAT_FRAME:AddMessage(msg, 1, 1, 1); +end + +local function ICU_PrintMTH(msg) + if MTH and MTH.Print then + MTH:Print(msg); + return; + end + ICU_Print(msg); +end + +local function ICU_Trace(msg) + if not ICU_TRACE_ENABLED then + return; + end + ICU_PrintMTH("[ICUTRACE] " .. tostring(msg)); +end + +function ICU_CommandTrace(mode) + local action = string.lower(tostring(mode or "status")); + if action == "on" or action == "1" or action == "true" then + ICU_TRACE_ENABLED = true; + ICU_Trace("trace enabled"); + return; + elseif action == "off" or action == "0" or action == "false" then + ICU_Trace("trace disabled"); + ICU_TRACE_ENABLED = false; + return; + end + ICU_PrintMTH("[ICUTRACE] status=" .. tostring(ICU_TRACE_ENABLED and true or false)); +end + +local function ICU_GetAlertPlayerName(unit, store) + unit = unit or "target"; + store = store or ICU_GetStore(); + + local baseName = UnitName(unit) or ""; + if baseName == "" then + return "Unknown"; + end + + if not store["SHOW_CUSTOM_TITLES"] then + return baseName; + end + + local titledName = UnitPVPName(unit); + if type(titledName) ~= "string" or titledName == "" then + return baseName; + end + + titledName = string.gsub(titledName, "\r\n", " "); + titledName = string.gsub(titledName, "\n", " "); + titledName = string.gsub(titledName, "%s+", " "); + titledName = string.gsub(titledName, "^%s+", ""); + titledName = string.gsub(titledName, "%s+$", ""); + + if titledName == "" then + return baseName; + end + + return titledName; +end + +local function ICU_ClampColor(v) + v = tonumber(v) or 0; + if v < 0 then return 0 end + if v > 1 then return 1 end + return v; +end + +local function ICU_EnsureStoreColors(store) + if type(store["COLORS"]) ~= "table" then + store["COLORS"] = {}; + end + + for key, def in pairs(ICU_DEFAULT_BG_COLORS) do + local row = store["COLORS"][key]; + if type(row) ~= "table" then + store["COLORS"][key] = { def[1], def[2], def[3] }; + else + row[1] = ICU_ClampColor(row[1] ~= nil and row[1] or def[1]); + row[2] = ICU_ClampColor(row[2] ~= nil and row[2] or def[2]); + row[3] = ICU_ClampColor(row[3] ~= nil and row[3] or def[3]); + end + end +end + +local function ICU_GetConfiguredColor(store, key) + store = store or ICU_GetStore(); + ICU_EnsureStoreColors(store); + + local row = store["COLORS"][key]; + if type(row) == "table" then + return row[1], row[2], row[3]; + end + + local def = ICU_DEFAULT_BG_COLORS[key] or ICU_DEFAULT_BG_COLORS.UNKNOWN; + return def[1], def[2], def[3]; +end + +ICU_GetStore = function() + if type(ICU_STORE_CACHE) == "table" then + return ICU_STORE_CACHE; + end + + local store = nil; + + if MTH and MTH.GetModuleCharSavedVariables then + store = MTH:GetModuleCharSavedVariables("icu"); + if type(store) == "table" and MTH.GetModuleSavedVariables and next(store) == nil then + local accountStore = MTH:GetModuleSavedVariables("icu"); + if type(accountStore) == "table" then + for key, value in pairs(accountStore) do + if store[key] == nil then + store[key] = value; + end + end + ICU_Trace("store migrated account->char (empty char store)"); + end + end + end + + if type(store) ~= "table" then + ICUvars = ICUvars or {}; + store = ICUvars; + end + + if type(ICUvars) == "table" and ICUvars ~= store then + for key, value in pairs(ICUvars) do + if store[key] == nil then + store[key] = value; + end + end + ICU_Trace("store migrated legacy ICUvars -> module store"); + end + + if store["ALERT"] == nil then + store["ALERT"] = "MTH Message"; + elseif store["ALERT"] == "SAY" or store["ALERT"] == "YELL" then + store["ALERT"] = "SELF"; + end + if store["ANNOUNCE"] == nil then + store["ANNOUNCE"] = "MTH Message"; + end + if store["ANCHOR"] == nil then + store["ANCHOR"] = "BOTTOMRIGHT"; + end + if store["POPUP_HIDE_DELAY"] == nil then + store["POPUP_HIDE_DELAY"] = "INSTANT"; + else + local rawDelay = tostring(store["POPUP_HIDE_DELAY"]); + if rawDelay ~= "INSTANT" then + local n = tonumber(rawDelay); + if not n then + store["POPUP_HIDE_DELAY"] = "INSTANT"; + else + n = math.floor(n); + if n < 1 then + store["POPUP_HIDE_DELAY"] = "INSTANT"; + elseif n > 10 then + store["POPUP_HIDE_DELAY"] = "10"; + else + store["POPUP_HIDE_DELAY"] = tostring(n); + end + end + end + end + if store["mouseOver"] == nil then + store["mouseOver"] = false; + end + if store["HEALTH_TEXT_MODE"] == nil then + store["HEALTH_TEXT_MODE"] = "BOTH"; + elseif store["HEALTH_TEXT_MODE"] == "AUTO" then + store["HEALTH_TEXT_MODE"] = "BOTH"; + end + if store["PLAYER_COLOR_MODE"] == nil then + store["PLAYER_COLOR_MODE"] = "CLASS"; + end + if store["REACTION_ONLY_PVP_PLAYERS"] == nil then + store["REACTION_ONLY_PVP_PLAYERS"] = true; + end + if store["SHOW_GUILD_NAME"] == nil then + store["SHOW_GUILD_NAME"] = false; + end + if store["SHOW_PLAYER_CLASS"] == nil then + store["SHOW_PLAYER_CLASS"] = true; + end + if store["SHOW_PLAYER_RACE"] == nil then + store["SHOW_PLAYER_RACE"] = true; + end + if store["SHOW_CUSTOM_TITLES"] == nil then + store["SHOW_CUSTOM_TITLES"] = false; + end + if store["EXPAND_UP"] == nil then + store["EXPAND_UP"] = false; + end + if store["CUSTOM_ANCHOR_X"] ~= nil then + store["CUSTOM_ANCHOR_X"] = math.floor((tonumber(store["CUSTOM_ANCHOR_X"]) or 0) + 0.5); + end + if store["CUSTOM_ANCHOR_Y"] ~= nil then + store["CUSTOM_ANCHOR_Y"] = math.floor((tonumber(store["CUSTOM_ANCHOR_Y"]) or 0) + 0.5); + end + + ICU_EnsureStoreColors(store); + + ICU_STORE_CACHE = store; + ICUvars = store; + return store; +end + +local function ICU_ShouldEmitAlert(key) + local now = 0; + if type(GetTime) == "function" then + now = GetTime(); + end + + local normalized = tostring(key or ""); + if normalized ~= "" and ICU_LAST_ALERT_KEY == normalized and (now - ICU_LAST_ALERT_AT) < 1.5 then + return false; + end + + ICU_LAST_ALERT_KEY = normalized; + ICU_LAST_ALERT_AT = now; + return true; +end + +local function ICU_GetPopupHideDelaySeconds(store) + store = store or ICU_GetStore(); + + local raw = tostring(store["POPUP_HIDE_DELAY"] or "INSTANT"); + if raw == "INSTANT" then + return 0; + end + + local n = tonumber(raw); + if not n then + return 0; + end + + n = math.floor(n); + if n < 1 then + return 0; + end + if n > 10 then + return 10; + end + return n; +end + +local function ICU_EnsureCustomAnchor(store) + if ICU_CustomAnchor then + return ICU_CustomAnchor; + end + + store = store or ICU_GetStore(); + + local frame = CreateFrame("Button", "MetaHuntICUAnchor", UIParent); + frame:SetWidth(220); + frame:SetHeight(25); + frame:SetClampedToScreen(true); + frame:SetMovable(true); + frame:RegisterForDrag("LeftButton"); + + local px = tonumber(store["CUSTOM_ANCHOR_X"]); + local py = tonumber(store["CUSTOM_ANCHOR_Y"]); + if px ~= nil and py ~= nil then + frame:SetPoint("TOPLEFT", UIParent, "TOPLEFT", px, py); + ICU_Trace("custom-anchor restore point=TOPLEFT->UIParent:TOPLEFT @(" .. tostring(px) .. "," .. tostring(py) .. ")"); + else + frame:SetPoint("CENTER", UIParent, "CENTER", 0, 50); + ICU_Trace("custom-anchor default point=CENTER->UIParent:CENTER @(0,50)"); + end + + frame:SetScript("OnDragStart", function(self) + self = self or this; + if self and self.StartMoving then + self:StartMoving(); + end + end); + + frame:SetScript("OnDragStop", function(self) + self = self or this; + if not self then return end + + if self.StopMovingOrSizing then + self:StopMovingOrSizing(); + end + + local _, _, _, x, y = self:GetPoint(); + x = tonumber(x); + y = tonumber(y); + if not x or not y then return end + + x = math.floor(x + 0.5); + y = math.floor(y + 0.5); + self:ClearAllPoints(); + self:SetPoint("TOPLEFT", UIParent, "TOPLEFT", x, y); + + local storeNow = ICU_GetStore(); + storeNow["CUSTOM_ANCHOR_X"] = x; + storeNow["CUSTOM_ANCHOR_Y"] = y; + ICU_Trace("custom-anchor drag-stop saved x=" .. tostring(x) .. " y=" .. tostring(y)); + ICU_SetPoints(); + end); + + frame:SetBackdrop({ + bgFile = "Interface/Tooltips/UI-Tooltip-Background", + edgeFile = "Interface/Tooltips/UI-Tooltip-Border", + tile = false, + tileSize = 16, + edgeSize = 16, + insets = { left = 5, right = 5, top = 5, bottom = 5 }, + }); + frame:SetBackdropColor(0, 1, 0, 0.6); + + frame.Text = frame:CreateFontString(nil, "OVERLAY"); + frame.Text:SetFontObject(GameFontNormalSmall); + frame.Text:ClearAllPoints(); + frame.Text:SetTextColor(1, 1, 1, 1); + frame.Text:SetWidth(220); + frame.Text:SetHeight(25); + frame.Text:SetPoint("TOPLEFT", frame, "TOPLEFT"); + frame.Text:SetJustifyH("CENTER"); + frame.Text:SetJustifyV("MIDDLE"); + frame.Text:SetText("MetaHunt MCU"); + frame:Hide(); + + ICU_CustomAnchor = frame; + return frame; +end + +function ICU_ToggleAnchor() + local store = ICU_GetStore(); + if store["ANCHOR"] ~= "CUSTOM" then + store["ANCHOR"] = "CUSTOM"; + ICU_SetPoints(); + ICU_Trace("toggle-anchor forced CUSTOM mode"); + end + + local anchor = ICU_EnsureCustomAnchor(store); + if anchor:IsVisible() then + anchor:Hide(); + else + anchor:Show(); + end +end + +local function ICU_ClampPercent(value) + local pct = math.floor((tonumber(value) or 0) + 0.5); + if pct < 0 then + return 0; + end + if pct > 100 then + return 100; + end + return pct; +end + +local function ICU_GetHealthLabelAndBar(currentHealth, maxHealth, mode) + currentHealth = tonumber(currentHealth) or 0; + maxHealth = tonumber(maxHealth) or 0; + mode = tostring(mode or "AUTO"); + + if mode == "NONE" then + return "", nil; + end + + local hasMax = maxHealth > 0 and currentHealth <= maxHealth; + local percent = nil; + if hasMax then + percent = ICU_ClampPercent((currentHealth / maxHealth) * 100); + elseif currentHealth <= 100 then + percent = ICU_ClampPercent(currentHealth); + end + + if mode == "PERCENT" then + if percent ~= nil then + return "[" .. percent .. "%]", percent; + end + return "[" .. currentHealth .. " HP]", 100; + elseif mode == "HP" then + if hasMax then + return "[" .. currentHealth .. "/" .. maxHealth .. " HP]", percent; + end + return "[" .. currentHealth .. " HP]", (percent or 100); + elseif mode == "BOTH" then + if hasMax and percent ~= nil then + return "[" .. currentHealth .. "/" .. maxHealth .. " | " .. percent .. "%]", percent; + elseif percent ~= nil then + return "[" .. percent .. "% | " .. currentHealth .. " HP]", percent; + end + return "[" .. currentHealth .. " HP]", 100; + end + + if hasMax and percent ~= nil then + return "[" .. percent .. "%]", percent; + elseif percent ~= nil then + return "[" .. percent .. "%]", percent; + end + return "[" .. currentHealth .. " HP]", 100; +end + +local function ICU_GetClassColor(classToken) + classToken = tostring(classToken or ""); + if classToken == "" then + return nil; + end + + if RAID_CLASS_COLORS and RAID_CLASS_COLORS[classToken] then + local c = RAID_CLASS_COLORS[classToken]; + return c.r or 1, c.g or 1, c.b or 1; + end + + local fallback = ICU_CLASS_COLOR_FALLBACK[classToken]; + if fallback then + return fallback[1], fallback[2], fallback[3]; + end + + return nil; +end + +local function ICU_GetDisplayColor(unit, classToken, store) + unit = tostring(unit or "target"); + store = store or ICU_GetStore(); + + if UnitIsPlayer(unit) then + local mode = tostring(store["PLAYER_COLOR_MODE"] or "CLASS"); + local reactionOnlyPvp = store["REACTION_ONLY_PVP_PLAYERS"] and true or false; + local myFaction = UnitFactionGroup("player"); + local theirFaction = UnitFactionGroup(unit); + local hostileByFaction = myFaction and theirFaction and myFaction ~= theirFaction; + local reaction = UnitReaction(unit, "player"); + local isPvp = UnitIsPVP and UnitIsPVP(unit) and true or false; + local hostileByPvp = hostileByFaction and isPvp; + + local function classOrFriendly() + local cr, cg, cb = ICU_GetClassColor(classToken); + if cr and cg and cb then + return cr, cg, cb; + end + return ICU_GetConfiguredColor(store, "PLAYER_FRIENDLY"); + end + + if mode == "REACTION" then + if reactionOnlyPvp and not isPvp then + local r, g, b = classOrFriendly(); + return r, g, b; + end + + if reaction and reaction <= 3 then + local r, g, b = ICU_GetConfiguredColor(store, "PLAYER_HOSTILE"); + return r, g, b; + elseif reaction and reaction == 4 then + local r, g, b = ICU_GetConfiguredColor(store, "PLAYER_NEUTRAL"); + return r, g, b; + elseif reaction and reaction >= 5 then + local r, g, b = ICU_GetConfiguredColor(store, "PLAYER_FRIENDLY"); + return r, g, b; + end + elseif mode == "FACTION" then + if hostileByPvp then + local r, g, b = ICU_GetConfiguredColor(store, "PLAYER_HOSTILE"); + return r, g, b; + end + local r, g, b = ICU_GetConfiguredColor(store, "PLAYER_FRIENDLY"); + return r, g, b; + elseif mode == "CUSTOM" then + if hostileByPvp then + local r, g, b = ICU_GetConfiguredColor(store, "PLAYER_HOSTILE"); + return r, g, b; + elseif reaction and reaction == 4 then + local r, g, b = ICU_GetConfiguredColor(store, "PLAYER_NEUTRAL"); + return r, g, b; + end + local r, g, b = ICU_GetConfiguredColor(store, "PLAYER_FRIENDLY"); + return r, g, b; + end + + local r, g, b = classOrFriendly(); + return r, g, b; + end + + local reaction = UnitReaction(unit, "player"); + if reaction and reaction <= 3 then + return ICU_GetConfiguredColor(store, "NPC_HOSTILE"); + elseif reaction and reaction == 4 then + return ICU_GetConfiguredColor(store, "NPC_NEUTRAL"); + elseif reaction and reaction >= 5 then + return ICU_GetConfiguredColor(store, "NPC_FRIENDLY"); + end + + return ICU_GetConfiguredColor(store, "UNKNOWN"); +end + +function ICU_IsEnabled() + return ICU_ENABLED and true or false; +end + +local function ICU_InstallHook() + if ICU_HOOK_ACTIVE then + return; + end + + if type(Minimap_OnClick) == "function" then + ICU_Original_Minimap_OnClick = Minimap_OnClick; + Minimap_OnClick = ICU_Minimap_OnClick_Event; + ICU_HOOK_ACTIVE = true; + end +end + +local function ICU_UninstallHook() + if not ICU_HOOK_ACTIVE then + return; + end + + if Minimap_OnClick == ICU_Minimap_OnClick_Event and type(ICU_Original_Minimap_OnClick) == "function" then + Minimap_OnClick = ICU_Original_Minimap_OnClick; + end + + ICU_HOOK_ACTIVE = false; +end + +function ICU_SetEnabled(enabled) + ICU_ENABLED = enabled and true or false; + ICU_GetStore(); + + if ICU_ENABLED then + ICU_InstallHook(); + if ICU_UpdateFrame then + ICU_UpdateFrame:Show(); + end + return; + end + + ICU_UninstallHook(); + if ICU_UpdateFrame then + ICU_UpdateFrame:Hide(); + end + if ICU_CustomAnchor then + ICU_CustomAnchor:Hide(); + end + icu_prevtooltip = nil; + ICU_Clear_Popup(); +end + +function ICU_StringifyKeys(tab) + local str = ""; + + for key, _ in pairs(tab) do + str = str .. key .. ", "; + end + + return string.sub(str, 1, -3); +end + +function ICU_TableHasValue(tab, val) + for _, v in ipairs(tab) do + if val == v then + return true; + end + end + + return false; +end + +-------------------------------------------------------------------------------- +-- OnFoo() functions +-------------------------------------------------------------------------------- + +function ICU_OnLoad() + this:RegisterEvent("VARIABLES_LOADED"); + + SLASH_ICUTRACE1 = "/icutrace"; + SlashCmdList["ICUTRACE"] = function(msg) + ICU_CommandTrace(msg); + end + + ICU_STORE_CACHE = nil; + if ICU_UpdateFrame then + ICU_UpdateFrame:Hide(); + end + ICU_Clear_Popup(); +end + +function ICU_OnEvent(event) + if event == "VARIABLES_LOADED" then + ICU_STORE_CACHE = nil; + ICU_GetStore(); + ICU_SetPoints(); + end +end + +function ICU_SetPoints() + local store = ICU_GetStore(); + + if not ICU_Popup then + return; + end + + ICU_Popup:ClearAllPoints(); + + if store["ANCHOR"] == "CUSTOM" then + local anchor = ICU_EnsureCustomAnchor(store); + local px = tonumber(store["CUSTOM_ANCHOR_X"]); + local py = tonumber(store["CUSTOM_ANCHOR_Y"]); + if px ~= nil and py ~= nil then + anchor:ClearAllPoints(); + anchor:SetPoint("TOPLEFT", UIParent, "TOPLEFT", px, py); + end + + if store["EXPAND_UP"] then + ICU_Popup:SetPoint("BOTTOMLEFT", anchor, "TOPLEFT", 0, 0); + else + ICU_Popup:SetPoint("TOPLEFT", anchor, "BOTTOMLEFT", 0, 0); + end + return; + end + + if ICU_CustomAnchor then + ICU_CustomAnchor:Hide(); + end + + if store["ANCHOR"] == "BOTTOMRIGHT" then + ICU_Popup:SetPoint("TOPRIGHT", "MinimapCluster", "BOTTOMRIGHT", 0, 0); + elseif store["ANCHOR"] == "TOPRIGHT" then + ICU_Popup:SetPoint("BOTTOMRIGHT", "MinimapCluster", "TOPRIGHT", 0, 0); + elseif store["ANCHOR"] == "BOTTOM" then + ICU_Popup:SetPoint("TOP", "MinimapCluster", "BOTTOM", 0, 0); + elseif store["ANCHOR"] == "TOP" then + ICU_Popup:SetPoint("BOTTOM", "MinimapCluster", "TOP", 0, 0); + elseif store["ANCHOR"] == "BOTTOMLEFT" then + ICU_Popup:SetPoint("TOPLEFT", "MinimapCluster", "BOTTOMLEFT", 0, 0); + elseif store["ANCHOR"] == "TOPLEFT" then + ICU_Popup:SetPoint("BOTTOMLEFT", "MinimapCluster", "TOPLEFT", 0, 0); + elseif store["ANCHOR"] == "RIGHT" then + ICU_Popup:SetPoint("RIGHT", "MinimapCluster", "LEFT", 0, 0); + elseif store["ANCHOR"] == "LEFT" then + ICU_Popup:SetPoint("LEFT", "MinimapCluster", "RIGHT", 0, 0); + end +end + +function ICU_Popup_OnUpdate() + if not ICU_IsEnabled() then + return; + end + + local now = type(GetTime) == "function" and GetTime() or nil; + if now then + if (now - (ICU_POPUP_LAST_UPDATE_AT or 0)) < ICU_POPUP_UPDATE_INTERVAL then + return; + end + ICU_POPUP_LAST_UPDATE_AT = now; + end + + local store = ICU_GetStore(); + if MouseIsOver(MinimapCluster) or MouseIsOver(ICU_Popup) then + if ICU_POPUP_MOUSE_OUT_AT then + ICU_Trace("popup mouse returned -> reset mouse-out timer"); + end + ICU_POPUP_MOUSE_OUT_AT = nil; + return; + end + + local delay = ICU_GetPopupHideDelaySeconds(store); + if delay <= 0 then + ICU_Trace("popup clear reason=delay-instant configured='" .. tostring(store["POPUP_HIDE_DELAY"]) + .. "' resolvedDelay=" .. tostring(delay)); + ICU_Clear_Popup(); + return; + end + + if not now then + ICU_Trace("popup clear reason=no-time-api configured='" .. tostring(store["POPUP_HIDE_DELAY"]) + .. "' resolvedDelay=" .. tostring(delay)); + ICU_Clear_Popup(); + return; + end + + if not ICU_POPUP_MOUSE_OUT_AT then + ICU_POPUP_MOUSE_OUT_AT = now; + ICU_Trace("popup mouse-out start delay=" .. tostring(delay) + .. " configured='" .. tostring(store["POPUP_HIDE_DELAY"]) .. "'"); + return; + end + + if (now - ICU_POPUP_MOUSE_OUT_AT) >= delay then + ICU_Trace("popup clear reason=mouse-out-timeout elapsed=" .. string.format("%.2f", (now - ICU_POPUP_MOUSE_OUT_AT)) + .. " delay=" .. tostring(delay) + .. " configured='" .. tostring(store["POPUP_HIDE_DELAY"]) .. "'"); + ICU_Clear_Popup(); + end +end + +function ICU_ButtonClick() + if not ICU_IsEnabled() then + return; + end + + local store = ICU_GetStore(); + if string.len(this.ICU_DATA) ~= string.len(this:GetText()) then + local lOriginal_ERR_UNIT_NOT_FOUND = ERR_UNIT_NOT_FOUND; + local lOriginal_ERR_GENERIC_NO_TARGET = ERR_GENERIC_NO_TARGET; + ERR_UNIT_NOT_FOUND = ""; + ERR_GENERIC_NO_TARGET = ""; + + TargetByName(this.ICU_DATA); + + if UnitIsDead("target") then + ClearTarget(); + end + + if not IsControlKeyDown() then + if store["ANNOUNCE"] == "MTH Message" then + ICU_PrintMTH("ICU -> " .. this:GetText() .. "."); + elseif store["ANNOUNCE"] == "SELF" then + ICU_Print("ICU -> " .. this:GetText() .. "."); + elseif store["ANNOUNCE"] ~= "OFF" then + if store["ANNOUNCE"] ~= "AUTO" then + SendChatMessage("ICU -> " .. this:GetText() .. ".", store["ANNOUNCE"]); + else + if GetNumRaidMembers() > 0 then + SendChatMessage("ICU -> " .. this:GetText() .. ".", "RAID"); + elseif GetNumPartyMembers() > 0 then + SendChatMessage("ICU -> " .. this:GetText() .. ".", "PARTY"); + else + ICU_Print("ICU -> " .. this:GetText() .. "."); + end + end + end + else + ICU_Print("ICU -> " .. this:GetText() .. "."); + end + + ERR_UNIT_NOT_FOUND = lOriginal_ERR_UNIT_NOT_FOUND; + ERR_GENERIC_NO_TARGET = lOriginal_ERR_GENERIC_NO_TARGET; + end +end + +-------------------------------------------------------------------------------- +-- OnFoo() hooked function +-------------------------------------------------------------------------------- + +function ICU_Minimap_OnClick_Event() + if not ICU_IsEnabled() then + if type(ICU_Original_Minimap_OnClick) == "function" then + ICU_Original_Minimap_OnClick(); + end + return; + end + + if IsShiftKeyDown() then + if type(ICU_Original_Minimap_OnClick) == "function" then + ICU_Original_Minimap_OnClick(); + end + else + if GameTooltip:IsVisible() and GameTooltipTextLeft1 and GameTooltipTextLeft1:GetText() then + local x, y = GetCursorPosition(); + x = x / Minimap:GetEffectiveScale(); + y = y / Minimap:GetEffectiveScale(); + + local cx, cy = Minimap:GetCenter(); + ICU_PING_X = x + CURSOR_OFFSET_X - cx; + ICU_PING_Y = y + CURSOR_OFFSET_Y - cy; + + ICU_Clear_Popup(); + ICU_Process_Tooltip(GameTooltipTextLeft1:GetText()); + + PlaySound("UChatScrollButton"); + elseif type(ICU_Original_Minimap_OnClick) == "function" then + ICU_Original_Minimap_OnClick(); + end + end +end + +-------------------------------------------------------------------------------- +-- Internal functions +-------------------------------------------------------------------------------- + +function ICU_Process_Tooltip(tooltip, silent) + if not ICU_IsEnabled() then + return; + end + + if type(tooltip) ~= "string" or tooltip == "" then + return; + end + + local pos = 0; + local width = 0; + local result_line, r, g, b, target, class, health, rank; + local prev_trg = nil; + local lOriginal_TargetFrame_OnShow_Event = TargetFrame_OnShow; + local lOriginal_TargetFrame_OnHide_Event = TargetFrame_OnHide; + TargetFrame_OnShow = ICU_TargetFrame_OnShow_Event; + TargetFrame_OnHide = ICU_TargetFrame_OnHide_Event; + local lOriginal_ERR_UNIT_NOT_FOUND = ERR_UNIT_NOT_FOUND; + local lOriginal_ERR_GENERIC_NO_TARGET = ERR_GENERIC_NO_TARGET; + ERR_UNIT_NOT_FOUND = ""; + ERR_GENERIC_NO_TARGET = ""; + + prev_trg = UnitName("target"); + ClearTarget(); + + for target in string.gfind(tooltip, "[^\n]*") do + if string.len(target) > 0 then + result_line, class, health, rank, r, g, b = ICU2_Process_Trg(target); + + if result_line ~= nil then + pos = pos + 1; + + if not health then + health = 0; + end + + local button = getglobal("ICU_PopupButton" .. pos .. "Button"); + local bar = getglobal("ICU_PopupButton" .. pos .. "ButtonBar"); + local bg = getglobal("ICU_PopupButton" .. pos .. "ButtonBGBar"); + local ranktex = getglobal("ICU_PopupButton" .. pos .. "ButtonRankIcon"); + + SetPortraitTexture(getglobal("ICU_PopupButton" .. pos .. "ButtonPortraitIcon"), "target"); + + if ICU_CLASSES[class] then + getglobal("ICU_PopupButton" .. pos .. "ButtonClassIcon"):SetTexCoord(unpack(ICU_CLASSES[class])); + else + getglobal("ICU_PopupButton" .. pos .. "ButtonClassIcon"):SetTexCoord(0, .25, 0, .25); + end + + getglobal("ICU_PopupButton" .. pos .. "ButtonClassIcon"):SetTexture("Interface\\Glues\\CharacterCreate\\UI-CharacterCreate-Classes"); + + if rank and rank ~= 0 then + ranktex:SetTexture(format("%s%02d","Interface\\PvPRankBadges\\PvPRank", rank - 4)); + ranktex:Show(); + else + ranktex:Hide(); + end + + getglobal("ICU_PopupButton" .. pos):SetBackdropBorderColor(r, g, b); + bar:SetStatusBarColor(r, g, b, 0.75); + bar:SetValue(health); + bg:SetStatusBarColor(r, g, b, 0.1); + button:SetTextColor(r + 0.3, g + 0.3, b + 0.3); + button:SetText(result_line); + button.ICU_DATA = target; + button:GetParent():Show(); + button:Show(); + + local w = button:GetTextWidth(); + if w > width then + width = w; + end + end + end + + if pos >= ICU_MAX_LINES then + break + end; + end + + TargetFrame_OnShow = lOriginal_TargetFrame_OnShow_Event; + TargetFrame_OnHide = lOriginal_TargetFrame_OnHide_Event; + ERR_UNIT_NOT_FOUND = lOriginal_ERR_UNIT_NOT_FOUND; + ERR_GENERIC_NO_TARGET = lOriginal_ERR_GENERIC_NO_TARGET; + + if pos > 0 then + ICU_Display_Popup(pos, width + 10); + else + ICU_Clear_Popup(); + end +end + +function ICU2_Process_Trg(trg) + local store = ICU_GetStore(); + + for name in string.gfind(trg, "|c%x%x%x%x%x%x%x%x([^|]+)|r") do + trg = name; + end + + local result_strn = nil; + local health, rank; + + TargetByName(trg); + + if UnitExists("target") and UnitName("target") then + local _, classToken = UnitClass("target"); + result_strn = trg .. " " .. UnitLevel( "target" ); + + if UnitIsPlayer("target") then + myFaction, _ = UnitFactionGroup("player"); + theirFaction, _ = UnitFactionGroup("target"); + local isPvp = UnitIsPVP and UnitIsPVP("target") and true or false; + + if myFaction ~= theirFaction and isPvp and not UnitOnTaxi("target") then + local enemyName = UnitName("target") or ""; + if ICU_ShouldEmitAlert(enemyName) then + Minimap:PingLocation(ICU_PING_X, ICU_PING_Y); + end + + local alertName = ICU_GetAlertPlayerName("target", store); + message = "ICU -> " .. alertName .. " " .. UnitLevel("target") .. " " .. UnitRace("target") .. " " .. UnitClass("target"); + + local shouldSendAlertMessage = ICU_ShouldEmitAlert(enemyName .. "-msg"); + if shouldSendAlertMessage and store["ALERT"] == "AUTO" then + if GetNumRaidMembers() > 0 then + SendChatMessage(message, "RAID"); + elseif GetNumPartyMembers() > 0 then + SendChatMessage(message, "PARTY"); + else + ICU_Print(message); + end + elseif shouldSendAlertMessage and store["ALERT"] == "MTH Message" then + ICU_PrintMTH(message); + elseif shouldSendAlertMessage and store["ALERT"] == "SELF" then + ICU_Print(message); + elseif shouldSendAlertMessage and store["ALERT"] ~= "OFF" and not ((store["ALERT"] == "PARTY" and GetNumPartyMembers() == 0) or (store["ALERT"] == "RAID" and GetNumRaidMembers() == 0)) then + SendChatMessage(message, store["ALERT"]); + end + end + + local playerRace = UnitRace("target") or ""; + local playerClass = UnitClass("target") or ""; + local playerMeta = {}; + if store["SHOW_PLAYER_RACE"] and playerRace ~= "" then + table.insert(playerMeta, playerRace); + end + if store["SHOW_PLAYER_CLASS"] and playerClass ~= "" then + table.insert(playerMeta, playerClass); + end + if table.getn(playerMeta) > 0 then + result_strn = result_strn .. " " .. table.concat(playerMeta, " "); + end + rank = UnitPVPRank("target"); + local guildname, _, _ = GetGuildInfo("target"); + + if store["SHOW_GUILD_NAME"] and guildname ~= nil and guildname ~= "" then + result_strn = result_strn .. " <" .. guildname .. ">"; + end + + local currentHealth = tonumber(UnitHealth("target")) or 0; + local maxHealth = tonumber(UnitHealthMax("target")) or 0; + local healthMode = store["HEALTH_TEXT_MODE"] or "AUTO"; + + if UnitInParty("target") or UnitInRaid("target") then + result_strn = result_strn .. " [" .. currentHealth .. "/" .. maxHealth .. "]"; + if maxHealth > 0 then + health = currentHealth / maxHealth * 100; + else + health = 0; + end + else + local healthLabel, barValue = ICU_GetHealthLabelAndBar(currentHealth, maxHealth, healthMode); + if healthLabel and healthLabel ~= "" then + result_strn = result_strn .. " " .. healthLabel; + end + health = barValue; + end + else + result_strn = "NPC:- " .. result_strn; + local currentHealth = tonumber(UnitHealth("target")) or 0; + local maxHealth = tonumber(UnitHealthMax("target")) or 0; + local healthMode = store["HEALTH_TEXT_MODE"] or "AUTO"; + local healthLabel, barValue = ICU_GetHealthLabelAndBar(currentHealth, maxHealth, healthMode); + if healthLabel and healthLabel ~= "" then + result_strn = result_strn .. " " .. healthLabel; + end + health = barValue; + end + + local r, g, b = ICU_GetDisplayColor("target", classToken, store); + + return result_strn, UnitClass("target"), health, rank, r, g, b; + end + + return result_strn; +end + +function ICU_TargetFrame_OnShow_Event() + -- Do nothing +end + +function ICU_TargetFrame_OnHide_Event() + CloseDropDownMenus(); +end + +function ICU_Display_Popup(numTrgs, width) + if not ICU_IsEnabled() then + return; + end + + for i = 1, 10 do + getglobal("ICU_PopupButton" .. i):SetWidth(width + 40 + 9); + getglobal("ICU_PopupButton" .. i .. "Button"):SetWidth(width + 40); + getglobal("ICU_PopupButton" .. i .. "ButtonBar"):SetWidth(width); + getglobal("ICU_PopupButton" .. i .. "ButtonBGBar"):SetWidth(width); + end + + ICU_Popup:SetWidth(width + 40 + UNITPOPUP_BORDER_WIDTH); + ICU_Popup:SetHeight(numTrgs * ICU_PopupButton1:GetHeight() + 12); + ICU_POPUP_MOUSE_OUT_AT = nil; + ICU_Popup:Show(); +end + +function ICU_Clear_Popup() + if not ICU_Popup then + return; + end + + for i = 1, 10 do + getglobal("ICU_PopupButton" .. i .. "Button"):SetText(""); + getglobal("ICU_PopupButton" .. i).ICU_DATA = ""; + getglobal("ICU_PopupButton" .. i):Hide(); + end + + ICU_POPUP_MOUSE_OUT_AT = nil; + ICU_Popup:Hide(); +end + +function ICU_MouseOverUpdate() + if not ICU_IsEnabled() then + return; + end + + local now = type(GetTime) == "function" and GetTime() or nil; + if now then + if (now - (ICU_MOUSEOVER_LAST_UPDATE_AT or 0)) < ICU_MOUSEOVER_UPDATE_INTERVAL then + return; + end + ICU_MOUSEOVER_LAST_UPDATE_AT = now; + end + + local store = ICU_GetStore(); + if not GetMouseFocus then + return; + end + + local focus = GetMouseFocus(); + if not focus then + return; + end + + if store["mouseOver"] and IsControlKeyDown() and MouseIsOver(MinimapCluster) and focus.GetName and focus:GetName() == "Minimap" then + if GameTooltip and GameTooltip.IsVisible and GameTooltip:IsVisible() and GameTooltipTextLeft1 and GameTooltipTextLeft1.GetText then + local tooltipText = GameTooltipTextLeft1:GetText(); + if tooltipText ~= icu_prevtooltip then + ICU_Clear_Popup(); + icu_prevtooltip = tooltipText; + ICU_Minimap_OnClick_Event(); + end + end + end +end diff --git a/modules/icu/MTH_ICU.xml b/modules/icu/MTH_ICU.xml new file mode 100644 index 0000000..be4ac87 --- /dev/null +++ b/modules/icu/MTH_ICU.xml @@ -0,0 +1,320 @@ + +