-- Core.lua -- Hail Mary Guild Tools – Haupt-Addon-Logik local ADDON_NAME = "HailMaryGuildTools" -- ── AceLocale holen (Locales/*.lua wurden bereits geladen) ──── local L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME) -- ── Addon erstellen ─────────────────────────────────────────── local HMGT = LibStub("AceAddon-3.0"):NewAddon(ADDON_NAME, "AceConsole-3.0", "AceEvent-3.0", "AceComm-3.0", "AceTimer-3.0" ) _G[ADDON_NAME] = HMGT HMGT.L = L -- für Module zugänglich local AceGUI = LibStub("AceGUI-3.0", true) local LDB = LibStub("LibDataBroker-1.1", true) local LDBIcon = LibStub("LibDBIcon-1.0", true) function HMGT:SafeShowTooltip(tooltip) if not tooltip or type(tooltip.Show) ~= "function" then return false end -- Shared Blizzard tooltips can carry secret money values; re-showing them can -- explode inside MoneyFrame_Update, so keep the failure contained. local ok = pcall(tooltip.Show, tooltip) return ok end -- ── Kommunikationsprotokoll ─────────────────────────────────── local MSG_SPELL_CAST = "SC" -- Legacy: SC|spellId|serverTimestamp local MSG_CD_REDUCE = "CR" -- Legacy: CR|targetSpellId|amount|serverTimestamp|triggerSpellId local MSG_SPELL_STATE = "STA" -- STA|spellId|kind|revision|a|b|c|d|version|protocol local MSG_HELLO = "HEL" -- HEL|version|protocol|class|specIndex|talentHash|knownSpellIds local MSG_PLAYER_INFO = "PI" -- PI|class|specIndex|talentHash local MSG_SYNC_REQUEST = "SRQ" local MSG_SYNC_RESPONSE = "SRS" -- SRS|version|protocol|class|spec|talentHash|knownSpellIds|cd1:t1:d1;... local MSG_RAID_TIMELINE = "RTL" -- RTL|encounterId|time|spellId|leadTime|alertText local MSG_RAID_TIMELINE_TEST = "RTS" -- RTS|encounterId|difficultyId|serverStartTime|duration local MSG_RELIABLE = "REL" -- REL|messageId|innerPayload local MSG_ACK = "ACK" -- ACK|messageId local COMM_PREFIX = "HMGT" local MINIMAP_ICON = "Interface\\Addons\\HailMaryGuildTools\\Media\\HailMaryIcon.png" local function GetAddonMetadataValue(field) if C_AddOns and type(C_AddOns.GetAddOnMetadata) == "function" then local value = C_AddOns.GetAddOnMetadata(ADDON_NAME, field) if value ~= nil and value ~= "" then return value end end if type(GetAddOnMetadata) == "function" then local value = GetAddOnMetadata(ADDON_NAME, field) if value ~= nil and value ~= "" then return value end end return nil end local ADDON_VERSION = GetAddonMetadataValue("Version") or "dev" local BUILD_VERSION = GetAddonMetadataValue("X-Build-Version") or ADDON_VERSION local RELEASE_CHANNEL = GetAddonMetadataValue("X-Release-Channel") or "stable" local PROTOCOL_VERSION = 6 local TRACKER_MODEL_VERSION = 3 HMGT.ADDON_VERSION = ADDON_VERSION HMGT.BUILD_VERSION = BUILD_VERSION HMGT.RELEASE_CHANNEL = RELEASE_CHANNEL HMGT.PROTOCOL_VERSION = PROTOCOL_VERSION HMGT.COMM_PREFIX = COMM_PREFIX HMGT.MSG_SPELL_CAST = MSG_SPELL_CAST HMGT.MSG_CD_REDUCE = MSG_CD_REDUCE HMGT.MSG_SPELL_STATE = MSG_SPELL_STATE HMGT.MSG_HELLO = MSG_HELLO HMGT.MSG_PLAYER_INFO = MSG_PLAYER_INFO HMGT.MSG_SYNC_REQUEST = MSG_SYNC_REQUEST HMGT.MSG_SYNC_RESPONSE = MSG_SYNC_RESPONSE HMGT.MSG_RELIABLE = MSG_RELIABLE HMGT.MSG_ACK = MSG_ACK HMGT.MSG_RAID_TIMELINE = MSG_RAID_TIMELINE HMGT.MSG_RAID_TIMELINE_TEST = MSG_RAID_TIMELINE_TEST -- ── Standardwerte ───────────────────────────────────────────── local defaults = { profile = { debug = false, debugLevel = "info", devTools = { enabled = false, level = "error", scope = "ALL", window = { width = 920, height = 420, minimized = false, }, }, syncRemoteCharges = true, interruptTracker = { enabled = true, demoMode = false, testMode = false, showBar = true, showSpellTooltip = true, locked = false, posX = 200, posY = -200, anchorTo = "UIParent", anchorCustom = "", anchorPoint = "TOPLEFT", anchorRelPoint= "TOPLEFT", anchorX = 200, anchorY = -200, width = 250, barHeight = 20, barSpacing = 2, barTexture = "Blizzard", borderEnabled = false, borderColor = { r = 1, g = 1, b = 1, a = 1 }, iconSize = 32, iconSpacing = 2, iconCols = 6, iconOverlay = "sweep", -- "sweep" | "timer" textAnchor = "below", -- "onIcon" | "above" | "below" fontSize = 12, font = "Friz Quadrata TT", fontOutline = "OUTLINE", growDirection = "DOWN", showInSolo = true, showInGroup = true, showInRaid = true, enabledSpells = {}, showPlayerName= true, colorByClass = true, showChargesOnIcon = false, showOnlyReady = false, readySoonSec = 0, }, raidCooldownTracker = { enabled = true, demoMode = false, testMode = false, showBar = true, showSpellTooltip = true, locked = false, posX = 500, posY = -200, anchorTo = "UIParent", anchorCustom = "", anchorPoint = "TOPLEFT", anchorRelPoint= "TOPLEFT", anchorX = 500, anchorY = -200, width = 250, barHeight = 20, barSpacing = 2, barTexture = "Blizzard", borderEnabled = false, borderColor = { r = 1, g = 1, b = 1, a = 1 }, iconSize = 32, iconSpacing = 2, iconCols = 6, iconOverlay = "sweep", -- "sweep" | "timer" textAnchor = "below", -- "onIcon" | "above" | "below" fontSize = 12, font = "Friz Quadrata TT", fontOutline = "OUTLINE", growDirection = "DOWN", showInSolo = true, showInGroup = true, showInRaid = true, enabledSpells = {}, showPlayerName= true, colorByClass = true, showChargesOnIcon = false, showOnlyReady = false, readySoonSec = 0, }, groupCooldownTracker = { enabled = true, demoMode = false, testMode = false, showBar = true, showSpellTooltip = true, locked = false, attachToPartyFrame = false, partyAttachSide = "RIGHT", partyAttachOffsetX = 8, partyAttachOffsetY = 0, posX = 800, posY = -200, anchorTo = "UIParent", anchorCustom = "", anchorPoint = "TOPLEFT", anchorRelPoint= "TOPLEFT", anchorX = 800, anchorY = -200, width = 250, barHeight = 20, barSpacing = 2, barTexture = "Blizzard", borderEnabled = false, borderColor = { r = 1, g = 1, b = 1, a = 1 }, iconSize = 32, iconSpacing = 2, iconCols = 6, iconOverlay = "sweep", textAnchor = "below", fontSize = 12, font = "Friz Quadrata TT", fontOutline = "OUTLINE", growDirection = "DOWN", showInSolo = false, showInGroup = true, showInRaid = false, enabledSpells = {}, showPlayerName= true, colorByClass = true, showChargesOnIcon = true, showOnlyReady = false, readySoonSec = 0, includeSelfFrame = false, }, trackers = {}, buffEndingAnnouncer = { enabled = true, announceAtSec = 5, trackedBuffs = {}, }, raidTimeline = { enabled = false, leadTime = 5, assignmentLeadTime = 5, unlocked = false, alertPosX = 0, alertPosY = 180, alertFont = "Friz Quadrata TT", alertFontSize = 30, alertFontOutline = "OUTLINE", alertColor = { r = 1, g = 0.82, b = 0.15, a = 1 }, encounters = {}, }, notes = { enabled = true, mainText = "", mainEncounterId = 0, mainTitle = "", personalText = "", drafts = {}, window = { width = 1080, height = 700, }, }, minimap = { hide = false, minimapPos = 220, }, mapOverlay = { enabled = true, iconSize = 16, alpha = 1, showLabels = true, categories = { custom = true }, pois = {}, }, }, } -- ── Spieler-/CD-Daten ───────────────────────────────────────── -- { [playerName] = { class, specIndex, talentHash, talents={[spellId]=true}, knownSpells={[spellId]=true} } } HMGT.playerData = {} -- { [playerName] = { [spellId] = { startTime, duration, spellEntry } } } HMGT.activeCDs = {} -- { [playerName] = { [spellId] = { current, max, spellEntry, updatedAt } } } HMGT.availabilityStates = {} HMGT.localSpellStateRevisions = {} HMGT.remoteSpellStateRevisions = {} HMGT.knownChargeInfo = {} HMGT.powerTracking = { accumulators = {}, } HMGT.pendingSpellPowerCosts = {} HMGT.demoModeData = {} HMGT.peerVersions = {} HMGT.versionWarnings = {} HMGT.versionWhisperWarnings = {} HMGT.debugBuffer = {} HMGT.debugBufferMax = 500 HMGT.enabledDebugScopes = { General = true, Debug = true, Comm = true, TrackedSpells = true, PowerSpend = true, } HMGT.pendingReliableMessages = HMGT.pendingReliableMessages or {} HMGT.receivedReliableMessages = HMGT.receivedReliableMessages or {} HMGT.nextReliableMessageId = HMGT.nextReliableMessageId or 0 HMGT.pendingReliableBySupersede = HMGT.pendingReliableBySupersede or {} HMGT.recentGroupMessages = HMGT.recentGroupMessages or {} local DEBUG_SCOPE_ALL = "ALL" local DEBUG_SCOPE_LABELS = { General = "General", Debug = "Debug", Comm = "Communication", TrackedSpells = "Tracked Spells", PowerSpend = "Power Spend", RaidTimeline = "Raid Timeline", Notes = "Notes", } local DEBUG_LEVELS = { error = 1, info = 2, verbose = 3, } function HMGT:IsDebugScopeEnabled(scope) local normalizedScope = tostring(scope or "General") local selectedScope = self.db and self.db.profile and self.db.profile.debugScope or DEBUG_SCOPE_ALL if selectedScope and selectedScope ~= DEBUG_SCOPE_ALL and normalizedScope ~= selectedScope then return false end local enabled = self.enabledDebugScopes if type(enabled) ~= "table" then return true end if enabled[normalizedScope] == nil then return true end return enabled[normalizedScope] == true end function HMGT:GetTrackerDebugScope(tracker) local trackerName = nil if type(tracker) == "table" then trackerName = tracker.name if (not trackerName or trackerName == "") and tracker.id then trackerName = string.format("Tracker %s", tostring(tracker.id)) end else trackerName = tostring(tracker or "") end trackerName = tostring(trackerName or ""):gsub("^%s+", ""):gsub("%s+$", "") if trackerName == "" then trackerName = "Tracker" end return "Tracker: " .. trackerName end function HMGT:GetStaticDebugScopeOptions() local values = { [DEBUG_SCOPE_ALL] = (self.L and self.L["OPT_DEBUG_SCOPE_ALL"]) or "All modules", } for scope, label in pairs(DEBUG_SCOPE_LABELS) do values[scope] = label end local trackers = self.db and self.db.profile and self.db.profile.trackers if type(trackers) == "table" then for _, tracker in ipairs(trackers) do local scope = self:GetTrackerDebugScope(tracker) values[scope] = tostring(tracker.name or scope) end end return values end function HMGT:GetDebugLevelOptions() return { error = (self.L and self.L["OPT_DEBUG_LEVEL_ERROR"]) or "Errors", info = (self.L and self.L["OPT_DEBUG_LEVEL_INFO"]) or "Info", verbose = (self.L and self.L["OPT_DEBUG_LEVEL_VERBOSE"]) or "Verbose", } end function HMGT:GetConfiguredDebugLevel() local configured = self.db and self.db.profile and self.db.profile.debugLevel or "info" if DEBUG_LEVELS[configured] then return configured end return "info" end function HMGT:ShouldIncludeDebugLine(levelToken) local selectedLevel = self:GetConfiguredDebugLevel() return (DEBUG_LEVELS[tostring(levelToken or "info"):lower()] or DEBUG_LEVELS.info) <= (DEBUG_LEVELS[selectedLevel] or DEBUG_LEVELS.info) end function HMGT:GetDebugScopeOptions() local values = self:GetStaticDebugScopeOptions() local scopes = {} local function addScope(scope) local normalized = tostring(scope or ""):match("^%s*(.-)%s*$") if normalized == "" or normalized == DEBUG_SCOPE_ALL then return end scopes[normalized] = true end for scope in pairs(self.enabledDebugScopes or {}) do addScope(scope) end for _, line in ipairs(self.debugBuffer or {}) do local scope = tostring(line):match("^%d%d:%d%d:%d%d %[[^%]]+%]%[([^%]]+)%]") addScope(scope) end local names = {} for scope in pairs(scopes) do names[#names + 1] = scope end table.sort(names) for _, scope in ipairs(names) do values[scope] = values[scope] or scope end return values end function HMGT:GetFilteredDebugBuffer() local selectedLevel = self:GetConfiguredDebugLevel() local selectedScope = self.db and self.db.profile and self.db.profile.debugScope or DEBUG_SCOPE_ALL local filtered = {} for _, line in ipairs(self.debugBuffer or {}) do local level, scope = tostring(line):match("^%d%d:%d%d:%d%d %[([^%]]+)%]%[([^%]]+)%]") local normalizedLevel = tostring(level or "INFO"):lower() local scopeMatches = (not selectedScope or selectedScope == DEBUG_SCOPE_ALL or scope == selectedScope) if scopeMatches and self:ShouldIncludeDebugLine(normalizedLevel) then filtered[#filtered + 1] = line end end return filtered end function HMGT:NextReliableMessageId() self.nextReliableMessageId = (tonumber(self.nextReliableMessageId) or 0) + 1 return string.format("%s-%d-%d", tostring(GetServerTime() or 0), math.floor(GetTime() * 1000), self.nextReliableMessageId) end function HMGT:IsReliableCommType(msgType) return msgType == MSG_SPELL_STATE or msgType == MSG_SYNC_RESPONSE or msgType == MSG_RAID_TIMELINE end function HMGT:GetPeerProtocolVersion(playerName) local normalizedName = self:NormalizePlayerName(playerName) local peerProtocols = self.peerProtocols or {} return tonumber(normalizedName and peerProtocols[normalizedName]) or 0 end function HMGT:RememberPeerProtocolVersion(playerName, protocol) local normalizedName = self:NormalizePlayerName(playerName) local numeric = tonumber(protocol) if not normalizedName or not numeric then return end self.peerProtocols = self.peerProtocols or {} self.peerProtocols[normalizedName] = numeric end local function ParseVersionTokens(version) local tokens = {} local text = tostring(version or "") for number in string.gmatch(text, "(%d+)") do tokens[#tokens + 1] = tonumber(number) or 0 end return tokens end function HMGT:CompareAddonVersions(leftVersion, rightVersion) local left = ParseVersionTokens(leftVersion) local right = ParseVersionTokens(rightVersion) local count = math.max(#left, #right) for i = 1, count do local a = tonumber(left[i]) or 0 local b = tonumber(right[i]) or 0 if a ~= b then return (a < b) and -1 or 1 end end local leftText = tostring(leftVersion or "") local rightText = tostring(rightVersion or "") if leftText == rightText then return 0 end if leftText < rightText then return -1 end return 1 end function HMGT:IsPlayerGroupLeader() if not IsInGroup() and not IsInRaid() then return true end return UnitIsGroupLeader and UnitIsGroupLeader("player") or false end function HMGT:SendOutdatedVersionWhisper(playerName, remoteVersion) local target = self:NormalizePlayerName(playerName) local localVersion = tostring(self.ADDON_VERSION or "dev") local remoteText = tostring(remoteVersion or "?") if not target or target == "" or not self:IsPlayerGroupLeader() then return false end if self:CompareAddonVersions(localVersion, remoteText) <= 0 then return false end local warningKey = string.format("%s|%s|%s", tostring(target), remoteText, localVersion) if self.versionWhisperWarnings[warningKey] then return false end self.versionWhisperWarnings[warningKey] = true local message = string.format( L["VERSION_OUTDATED_WHISPER"] or "Your Hail Mary Guild Tools version is outdated. You have %s, the group leader has %s.", remoteText, localVersion ) if C_ChatInfo and type(C_ChatInfo.SendChatMessage) == "function" then C_ChatInfo.SendChatMessage(message, "WHISPER", nil, target) elseif type(SendChatMessage) == "function" then SendChatMessage(message, "WHISPER", nil, target) else return false end return true end function HMGT:RegisterLibSpecializationBridge() if self._libSpecializationBridgeRegistered then return true end if not LibStub then return false end local LibSpec = LibStub("LibSpecialization", true) if not LibSpec or type(LibSpec.RegisterGroup) ~= "function" then return false end LibSpec.RegisterGroup(self, function(specId, role, position, playerName, talentString) if not playerName or not specId then return end local classToken = HMGT:GetClassTokenForSpecId(specId) HMGT:ApplyExternalSpecInfo("LibSpecialization", playerName, classToken, specId, talentString) end) self._libSpecializationBridgeRegistered = true return true end function HMGT:SendReliableAck(target, messageId) if not target or target == "" or not messageId or messageId == "" then return end self:SendCommMessage(COMM_PREFIX, string.format("%s|%s", MSG_ACK, tostring(messageId)), "WHISPER", target, "ALERT") end function HMGT:GetReliableSupersedeKey(target, msgType, payload) local normalizedTarget = self:NormalizePlayerName(target) or tostring(target or "") if msgType == MSG_SPELL_STATE then local sid, kind = tostring(payload):match("^%a+|(%d+)|(%a+)|") if sid and kind then return table.concat({ normalizedTarget, msgType, sid, kind }, "|") end elseif msgType == MSG_SYNC_RESPONSE then return table.concat({ normalizedTarget, msgType }, "|") elseif msgType == MSG_RAID_TIMELINE then local encounterId, timeSec, spellId, leadTime, alertText = tostring(payload):match("^%a+|(%d+)|(%d+)|(%d+)|(%d+)|?(.*)$") if not encounterId then encounterId, timeSec, spellId, leadTime = tostring(payload):match("^%a+|(%d+)|(%d+)|(%d+)|(%d+)$") alertText = "" end return table.concat({ normalizedTarget, msgType, tostring(encounterId or ""), tostring(timeSec or ""), tostring(spellId or ""), tostring(leadTime or ""), tostring(alertText or ""), }, "|") end return nil end function HMGT:GetNextReliableRetryDelay() local earliest = nil local now = GetTime() for _, pending in pairs(self.pendingReliableMessages or {}) do if pending then local nextRetryAt = tonumber(pending.nextRetryAt) or now if not earliest or nextRetryAt < earliest then earliest = nextRetryAt end end end if not earliest then return nil end return math.max(0.05, earliest - now) end function HMGT:EnsureReliableCommTicker() local delay = self:GetNextReliableRetryDelay() if not delay then self:StopReliableCommTicker() return end if self.reliableCommTicker then self:CancelTimer(self.reliableCommTicker, true) self.reliableCommTicker = nil end self.reliableCommTicker = self:ScheduleTimer(function() self.reliableCommTicker = nil self:ProcessReliableMessageQueue() end, delay) end function HMGT:StopReliableCommTicker() if self.reliableCommTicker then self:CancelTimer(self.reliableCommTicker, true) self.reliableCommTicker = nil end end function HMGT:HandleReliableAck(senderName, messageId) local key = string.format("%s|%s", tostring(self:NormalizePlayerName(senderName) or ""), tostring(messageId or "")) local pending = self.pendingReliableMessages and self.pendingReliableMessages[key] if pending then if pending.supersedeKey and self.pendingReliableBySupersede then self.pendingReliableBySupersede[pending.supersedeKey] = nil end self.pendingReliableMessages[key] = nil self:DebugScoped("verbose", "Comm", "Reliable ACK sender=%s id=%s type=%s", tostring(senderName), tostring(messageId), tostring(pending.msgType)) self:EnsureReliableCommTicker() end end function HMGT:TrackReliableMessage(target, messageId, payload, msgType, supersedeKey) if not target or target == "" or not messageId or messageId == "" or not payload or payload == "" then return end local normalizedTarget = self:NormalizePlayerName(target) local key = string.format("%s|%s", tostring(normalizedTarget or target), tostring(messageId)) if supersedeKey and self.pendingReliableBySupersede and self.pendingReliableBySupersede[supersedeKey] then local previousKey = self.pendingReliableBySupersede[supersedeKey] local previousPending = self.pendingReliableMessages[previousKey] if previousPending and previousPending.supersedeKey then self.pendingReliableBySupersede[previousPending.supersedeKey] = nil end self.pendingReliableMessages[previousKey] = nil end self.pendingReliableMessages[key] = { target = target, normalizedTarget = normalizedTarget, payload = payload, msgType = msgType, supersedeKey = supersedeKey, sentAt = GetTime(), retries = 0, nextRetryAt = GetTime() + 0.75, } if supersedeKey then self.pendingReliableBySupersede[supersedeKey] = key end self:EnsureReliableCommTicker() end function HMGT:PruneReliableCaches() local now = GetTime() for key, pending in pairs(self.pendingReliableMessages or {}) do if not pending or (pending.retries or 0) >= 2 and now > ((pending.nextRetryAt or 0) + 5) then if pending and pending.supersedeKey and self.pendingReliableBySupersede then self.pendingReliableBySupersede[pending.supersedeKey] = nil end self.pendingReliableMessages[key] = nil end end for key, expiresAt in pairs(self.receivedReliableMessages or {}) do if (tonumber(expiresAt) or 0) <= now then self.receivedReliableMessages[key] = nil end end end function HMGT:ProcessReliableMessageQueue() local now = GetTime() for key, pending in pairs(self.pendingReliableMessages or {}) do if pending and now >= (tonumber(pending.nextRetryAt) or 0) then if (tonumber(pending.retries) or 0) >= 2 then self:DebugScoped("info", "Comm", "Reliable send expired target=%s type=%s id=%s", tostring(pending.target), tostring(pending.msgType), tostring(key:match("|(.+)$") or "")) if pending.supersedeKey and self.pendingReliableBySupersede then self.pendingReliableBySupersede[pending.supersedeKey] = nil end self.pendingReliableMessages[key] = nil else pending.retries = (tonumber(pending.retries) or 0) + 1 pending.nextRetryAt = now + 0.75 self:DebugScoped("verbose", "Comm", "Reliable retry target=%s type=%s try=%d", tostring(pending.target), tostring(pending.msgType), pending.retries) self:SendCommMessage(COMM_PREFIX, pending.payload, "WHISPER", pending.target, "ALERT") end end end self:PruneReliableCaches() self:EnsureReliableCommTicker() end function HMGT:SendDirectMessage(payload, target, prio) if not target or target == "" or not payload or payload == "" then return false end local msgType = tostring(payload):match("^(%a+)") local peerProtocol = self:GetPeerProtocolVersion(target) if peerProtocol >= 6 and self:IsReliableCommType(msgType) then local messageId = self:NextReliableMessageId() local wrapped = string.format("%s|%s|%s", MSG_RELIABLE, tostring(messageId), payload) local supersedeKey = self:GetReliableSupersedeKey(target, msgType, payload) self:TrackReliableMessage(target, messageId, wrapped, msgType, supersedeKey) self:DebugScoped("verbose", "Comm", "Reliable send target=%s type=%s id=%s", tostring(target), tostring(msgType), tostring(messageId)) self:SendCommMessage(COMM_PREFIX, wrapped, "WHISPER", target, prio or "ALERT") return true end self:SendCommMessage(COMM_PREFIX, payload, "WHISPER", target, prio) return true end function HMGT:DebugScoped(level, scope, fmt, ...) return end function HMGT:Debug(fmt, ...) local args = { ... } local level = "info" if fmt == "error" or fmt == "info" or fmt == "verbose" then level = fmt fmt = args[1] table.remove(args, 1) if fmt == nil then return end end self:DebugScoped(level, "General", fmt, unpack(args)) end function HMGT:RegisterPeerVersion(playerName, version, protocol, sourceTag) if not playerName then return end self.peerVersions[playerName] = version self:RememberPeerProtocolVersion(playerName, protocol) if self.versionNoticeWindow and self.versionNoticeWindow.IsShown and self.versionNoticeWindow:IsShown() and self.RefreshVersionNoticeWindow then self:RefreshVersionNoticeWindow() end if version and version ~= "" then self:SendOutdatedVersionWhisper(playerName, version) end local mismatch = false local details = {} if version and version ~= "" and version ~= ADDON_VERSION then mismatch = true details[#details + 1] = string.format("addon local=%s remote=%s", tostring(ADDON_VERSION), tostring(version)) end if protocol and tonumber(protocol) and tonumber(protocol) ~= PROTOCOL_VERSION then mismatch = true details[#details + 1] = string.format("protocol local=%s remote=%s", tostring(PROTOCOL_VERSION), tostring(protocol)) end if mismatch and not self.versionWarnings[playerName] then self.versionWarnings[playerName] = true self.latestVersionMismatch = { playerName = playerName, detail = table.concat(details, " | "), sourceTag = sourceTag, } self:DevTrace("Version", "mismatch_detected", { player = playerName, source = sourceTag, detail = table.concat(details, " | "), }) local text = string.format(L["VERSION_MISMATCH_CHAT"] or "HMGT mismatch with %s: %s", tostring(playerName), table.concat(details, " | ")) self:Print("|cffff5555HMGT|r " .. text) self:ShowVersionMismatchPopup(playerName, table.concat(details, " | "), sourceTag) self:Debug("info", "Version mismatch %s via=%s %s", tostring(playerName), tostring(sourceTag or "?"), table.concat(details, " | ")) end end local function NormalizeLayoutValue(num, minv, maxv, fallback) if type(num) ~= "number" then return fallback end if num < minv then return minv end if num > maxv then return maxv end return num end local function DeepCopy(value) if type(value) ~= "table" then return value end local out = {} for k, v in pairs(value) do out[k] = DeepCopy(v) end return out end -- ── Klassenfarben ───────────────────────────────────────────── local function SafeApiNumber(value, fallback) if value == nil then return fallback end local ok, num = pcall(function() return tonumber(tostring(value)) end) if ok and num ~= nil then return num end return fallback end local function SafeSpellApiCall(fn, ...) if type(fn) ~= "function" then return nil end local ok, a, b, c, d = pcall(fn, ...) if not ok then return nil end return a, b, c, d end local function NormalizeSpellChargesResult(a, b, c, d) if type(a) == "table" and b == nil then local t = a a = t.currentCharges or t.charges or t.current b = t.maxCharges or t.max c = t.cooldownStartTime or t.chargeStart or t.startTime or t.start d = t.cooldownDuration or t.chargeDuration or t.duration end return SafeApiNumber(a), SafeApiNumber(b), SafeApiNumber(c), SafeApiNumber(d) end local function GetSpellChargesInfo(spellId) local cur, max, chargeStart, chargeDuration = nil, nil, nil, nil if type(GetSpellCharges) == "function" then cur, max, chargeStart, chargeDuration = NormalizeSpellChargesResult(SafeSpellApiCall(GetSpellCharges, spellId)) end if cur == nil and max == nil and C_Spell and type(C_Spell.GetSpellCharges) == "function" then cur, max, chargeStart, chargeDuration = NormalizeSpellChargesResult(SafeSpellApiCall(C_Spell.GetSpellCharges, spellId)) end return cur, max, chargeStart, chargeDuration end local function NormalizeSpellCooldownResult(a, b) if type(a) == "table" and b == nil then local t = a a = t.startTime or t.cooldownStartTime or t.start b = t.duration or t.cooldownDuration end return SafeApiNumber(a, 0) or 0, SafeApiNumber(b, 0) or 0 end local function GetSpellCooldownInfo(spellId) local startTime, duration = 0, 0 if type(GetSpellCooldown) == "function" then startTime, duration = NormalizeSpellCooldownResult(SafeSpellApiCall(GetSpellCooldown, spellId)) end if startTime <= 0 and duration <= 0 and C_Spell and type(C_Spell.GetSpellCooldown) == "function" then startTime, duration = NormalizeSpellCooldownResult(SafeSpellApiCall(C_Spell.GetSpellCooldown, spellId)) end return startTime, duration end local function GetGlobalCooldownInfo() return GetSpellCooldownInfo(61304) end local function NormalizeAuraApplications(auraData) if type(auraData) ~= "table" then return 0 end local count = tonumber(auraData.applications or auraData.stackCount or auraData.stacks or auraData.charges or auraData.count) if count ~= nil then return math.max(0, count) end return 1 end local function GetPlayerAuraApplications(spellId) local sid = tonumber(spellId) if not sid or sid <= 0 then return 0 end if C_UnitAuras and type(C_UnitAuras.GetPlayerAuraBySpellID) == "function" then local auraData = C_UnitAuras.GetPlayerAuraBySpellID(sid) if auraData then return NormalizeAuraApplications(auraData) end end if AuraUtil and type(AuraUtil.FindAuraBySpellID) == "function" then local name, _, applications = AuraUtil.FindAuraBySpellID(sid, "player", "HELPFUL") if name then return math.max(0, tonumber(applications) or 1) end end return 0 end local function GetSpellCastCountInfo(spellId) if C_Spell and type(C_Spell.GetSpellCastCount) == "function" then return math.max(0, tonumber(C_Spell.GetSpellCastCount(spellId)) or 0) end return 0 end local POWER_TYPE_IDS = { MANA = (Enum and Enum.PowerType and Enum.PowerType.Mana) or _G.SPELL_POWER_MANA or 0, RAGE = (Enum and Enum.PowerType and Enum.PowerType.Rage) or _G.SPELL_POWER_RAGE or 1, FOCUS = (Enum and Enum.PowerType and Enum.PowerType.Focus) or _G.SPELL_POWER_FOCUS or 2, ENERGY = (Enum and Enum.PowerType and Enum.PowerType.Energy) or _G.SPELL_POWER_ENERGY or 3, COMBO_POINTS = (Enum and Enum.PowerType and Enum.PowerType.ComboPoints) or _G.SPELL_POWER_COMBO_POINTS or 4, RUNES = (Enum and Enum.PowerType and Enum.PowerType.Runes) or _G.SPELL_POWER_RUNES or 5, RUNIC_POWER = (Enum and Enum.PowerType and Enum.PowerType.RunicPower) or _G.SPELL_POWER_RUNIC_POWER or 6, SOUL_SHARDS = (Enum and Enum.PowerType and Enum.PowerType.SoulShards) or _G.SPELL_POWER_SOUL_SHARDS or 7, LUNAR_POWER = (Enum and Enum.PowerType and Enum.PowerType.LunarPower) or _G.SPELL_POWER_LUNAR_POWER or 8, HOLY_POWER = (Enum and Enum.PowerType and Enum.PowerType.HolyPower) or _G.SPELL_POWER_HOLY_POWER or 9, ALTERNATE = (Enum and Enum.PowerType and Enum.PowerType.Alternate) or _G.SPELL_POWER_ALTERNATE_POWER or 10, MAELSTROM = (Enum and Enum.PowerType and Enum.PowerType.Maelstrom) or _G.SPELL_POWER_MAELSTROM or 11, CHI = (Enum and Enum.PowerType and Enum.PowerType.Chi) or _G.SPELL_POWER_CHI or 12, INSANITY = (Enum and Enum.PowerType and Enum.PowerType.Insanity) or _G.SPELL_POWER_INSANITY or 13, ARCANE_CHARGES = (Enum and Enum.PowerType and Enum.PowerType.ArcaneCharges) or _G.SPELL_POWER_ARCANE_CHARGES or 16, FURY = (Enum and Enum.PowerType and Enum.PowerType.Fury) or _G.SPELL_POWER_FURY or 17, PAIN = (Enum and Enum.PowerType and Enum.PowerType.Pain) or _G.SPELL_POWER_PAIN or 18, } -- Sparse fallback only for spells where GetSpellPowerCost is unreliable in combat. -- Current Protection Warrior Ignore Pain cost is 35 Rage. local POWER_SPEND_OVERRIDES = { WARRIOR = { [3] = { [190456] = { RAGE = 35, }, }, }, } local POWER_TYPE_TOKENS = {} for token, id in pairs(POWER_TYPE_IDS) do POWER_TYPE_TOKENS[tonumber(id)] = token end local function NormalizePowerToken(powerType) if type(powerType) == "number" then return POWER_TYPE_TOKENS[math.floor(powerType + 0.5)] end local token = tostring(powerType or ""):upper() if token == "" then return nil end return token end local function GetSpellPowerCostByToken(spellId, powerType) local token = NormalizePowerToken(powerType) local sid = tonumber(spellId) if not token or not sid or sid <= 0 then return 0 end local costs if C_Spell and type(C_Spell.GetSpellPowerCost) == "function" then costs = C_Spell.GetSpellPowerCost(sid) elseif type(GetSpellPowerCost) == "function" then costs = GetSpellPowerCost(sid) end if type(costs) ~= "table" then return 0 end local bestCost = 0 for _, costInfo in pairs(costs) do if type(costInfo) == "table" then local entryToken = NormalizePowerToken(costInfo.type or costInfo.powerType or costInfo.name) local requiredAuraId = tonumber(costInfo.requiredAuraID) or 0 local auraAllowed = (requiredAuraId <= 0) or (costInfo.hasRequiredAura == true) if entryToken == token and auraAllowed then local entryCost = tonumber(costInfo.cost) or tonumber(costInfo.amount) or tonumber(costInfo.minCost) or 0 if entryCost > bestCost then bestCost = entryCost end end end end return math.max(0, bestCost) end local function GetTrackedPowerSpendOverride(classToken, specIndex, spellId, powerType) local classMap = POWER_SPEND_OVERRIDES[tostring(classToken or "")] local specMap = classMap and classMap[tonumber(specIndex) or 0] local spellMap = specMap and specMap[tonumber(spellId) or 0] local token = NormalizePowerToken(powerType) if not spellMap or not token then return 0 end return math.max(0, tonumber(spellMap[token]) or 0) end local function GetSpellDebugLabel(spellId) local sid = tonumber(spellId) if not sid or sid <= 0 then return "Unknown" end local spellName = (C_Spell and C_Spell.GetSpellName and C_Spell.GetSpellName(sid)) or (GetSpellInfo and select(1, GetSpellInfo(sid))) or ("Spell " .. sid) return string.format("%s (%d)", tostring(spellName), sid) end local function GetTrackedSpellCategoryLabel(spellEntry) local dataset = spellEntry and tostring(spellEntry._hmgtDataset or "") if dataset == "Interrupts" then return "Interrupt" elseif dataset == "RaidCooldowns" then return "Raid CD" elseif dataset == "GroupCooldowns" then return "Group CD" end return "Tracked Spell" end local function BuildTrackedSpellCastSummary(spellEntry, details) details = type(details) == "table" and details or {} local stateKind = tostring(details.stateKind or HMGT_SpellData.GetStateKind(spellEntry) or "") local currentCharges = tonumber(details.currentCharges) local maxCharges = math.max(0, math.floor((tonumber(details.maxCharges) or 0) + 0.5)) local chargeCooldown = math.max(0, tonumber(details.chargeCooldown) or tonumber(details.cooldown) or 0) local cooldown = math.max(0, tonumber(details.cooldown) or 0) if stateKind == "availability" then local required = math.max(0, math.floor((tonumber(details.required) or 0) + 0.5)) if required > 0 then return string.format("ohne Zeit-CD, aktiviert bei %d Stacks", required) end return "ohne Zeit-CD" end if maxCharges > 1 then if currentCharges == nil then currentCharges = math.max(0, maxCharges - 1) end currentCharges = math.max(0, math.min(maxCharges, math.floor(currentCharges + 0.5))) return string.format("Charges=%d/%d, Charge-CD=%.1fs", currentCharges, maxCharges, chargeCooldown) end return string.format("CD=%.1fs", cooldown) end function HMGT:LogTrackedSpellCast(playerName, spellEntry, details) if not spellEntry then return end self:DebugScoped( "verbose", "TrackedSpells", "%s -> %s von %s, %s", GetTrackedSpellCategoryLabel(spellEntry), GetSpellDebugLabel(spellEntry.spellId), tostring(playerName or "?"), BuildTrackedSpellCastSummary(spellEntry, details) ) end local function IsSpellKnownLocally(spellId) local sid = tonumber(spellId) if not sid or sid <= 0 then return false end if type(IsPlayerSpell) == "function" and IsPlayerSpell(sid) then return true end if C_SpellBook and type(C_SpellBook.IsSpellKnown) == "function" and C_SpellBook.IsSpellKnown(sid) then return true end return false end HMGT.TrackerInternals = HMGT.TrackerInternals or {} HMGT.TrackerInternals.SafeApiNumber = SafeApiNumber HMGT.TrackerInternals.GetSpellChargesInfo = GetSpellChargesInfo HMGT.TrackerInternals.GetSpellCooldownInfo = GetSpellCooldownInfo HMGT.TrackerInternals.IsSpellKnownLocally = IsSpellKnownLocally HMGT.TrackerInternals.GetGlobalCooldownInfo = GetGlobalCooldownInfo HMGT.TrackerInternals.GetPlayerAuraApplications = GetPlayerAuraApplications HMGT.TrackerInternals.GetSpellCastCountInfo = GetSpellCastCountInfo HMGT.TrackerInternals.GetSpellDebugLabel = GetSpellDebugLabel HMGT.classColors = { WARRIOR = {0.78, 0.61, 0.43}, PALADIN = {0.96, 0.55, 0.73}, HUNTER = {0.67, 0.83, 0.45}, ROGUE = {1.00, 0.96, 0.41}, PRIEST = {1.00, 1.00, 1.00}, DEATHKNIGHT = {0.77, 0.12, 0.23}, SHAMAN = {0.00, 0.44, 0.87}, MAGE = {0.41, 0.80, 0.94}, WARLOCK = {0.58, 0.51, 0.79}, MONK = {0.00, 1.00, 0.59}, DRUID = {1.00, 0.49, 0.04}, DEMONHUNTER = {0.64, 0.19, 0.79}, EVOKER = {0.20, 0.58, 0.50}, } -- ═══════════════════════════════════════════════════════════════ -- INITIALISIERUNG -- ═══════════════════════════════════════════════════════════════ function HMGT:OnInitialize() self.db = LibStub("AceDB-3.0"):New("HailMaryGuildToolsDB", defaults, true) self:MigrateProfileSettings() self:InitDefaultSpellSettings() self:ApplyCustomSpellsFromProfile() if HMGT_Config then HMGT_Config:Initialize() end self:RegisterChatCommand("hmgt", "SlashCommand") self:RegisterChatCommand("hailmary", "SlashCommand") end local function NormalizeBorderSettings(settings) if settings.borderEnabled == nil then settings.borderEnabled = (settings.borderTexture and settings.borderTexture ~= "") and true or false end if type(settings.borderColor) ~= "table" then settings.borderColor = { r = 1, g = 1, b = 1, a = 1 } else settings.borderColor.r = settings.borderColor.r or settings.borderColor[1] or 1 settings.borderColor.g = settings.borderColor.g or settings.borderColor[2] or 1 settings.borderColor.b = settings.borderColor.b or settings.borderColor[3] or 1 settings.borderColor.a = settings.borderColor.a or settings.borderColor[4] or 1 end end local IsKnownAnchorTargetKey local LEGACY_TRACKER_ANCHOR_IDS = { InterruptTracker = 1, RaidCooldownTracker = 2, GroupCooldownTracker = 3, } local function NormalizeTrackerAnchorKey(anchorTo) local trackerId = LEGACY_TRACKER_ANCHOR_IDS[tostring(anchorTo or "")] if trackerId then return "TRACKER:" .. tostring(trackerId) end return anchorTo end local function NormalizeAnchorSettings(settings) settings.anchorTo = NormalizeTrackerAnchorKey(settings.anchorTo or "UIParent") settings.anchorCustom = settings.anchorCustom or "" if settings.anchorTo ~= "UIParent" and not IsKnownAnchorTargetKey(settings.anchorTo) then settings.anchorCustom = settings.anchorTo settings.anchorTo = "CUSTOM" end settings.anchorPoint = settings.anchorPoint or "TOPLEFT" settings.anchorRelPoint = settings.anchorRelPoint or "TOPLEFT" if settings.anchorX == nil then settings.anchorX = settings.posX or 0 end if settings.anchorY == nil then settings.anchorY = settings.posY or 0 end end local function NormalizeTrackerLayout(settings, defaultShowChargesOnIcon, defaultShowSpellTooltip) local legacyTestWasDemo = (settings.demoMode == nil and settings.testMode == true) if settings.demoMode == nil then settings.demoMode = settings.testMode == true end if legacyTestWasDemo then settings.testMode = false elseif settings.testMode == nil then settings.testMode = false end settings.width = NormalizeLayoutValue(settings.width, 100, 600, 250) settings.barHeight = NormalizeLayoutValue(settings.barHeight, 10, 60, 20) settings.barSpacing = NormalizeLayoutValue(settings.barSpacing, 0, 20, 2) settings.iconSize = NormalizeLayoutValue(settings.iconSize, 12, 100, 32) settings.iconSpacing = NormalizeLayoutValue(settings.iconSpacing, 0, 20, 2) settings.iconCols = NormalizeLayoutValue(settings.iconCols, 1, 20, 6) settings.fontSize = NormalizeLayoutValue(settings.fontSize, 6, 24, 12) settings.readySoonSec = NormalizeLayoutValue(settings.readySoonSec, 0, 300, 0) if settings.showOnlyReady == nil then settings.showOnlyReady = false end local trackerType = tostring(settings.trackerType or ""):lower() if trackerType ~= "normal" and trackerType ~= "group" then if settings.perGroupMember == true or settings.attachToPartyFrame == true then trackerType = "group" else trackerType = "normal" end end settings.trackerType = trackerType settings.perGroupMember = (trackerType == "group") if settings.includeSelfFrame == nil then settings.includeSelfFrame = false end if settings.attachToPartyFrame == nil then settings.attachToPartyFrame = false end if settings.showChargesOnIcon == nil then settings.showChargesOnIcon = defaultShowChargesOnIcon and true or false end settings.showSpellTooltip = true if settings.roleFilter ~= "ALL" and settings.roleFilter ~= "TANK" and settings.roleFilter ~= "HEALER" and settings.roleFilter ~= "DAMAGER" then settings.roleFilter = "ALL" end if settings.rangeCheck == nil then settings.rangeCheck = false end if settings.hideOutOfRange == nil then settings.hideOutOfRange = false end settings.outOfRangeAlpha = NormalizeLayoutValue(settings.outOfRangeAlpha, 0.1, 1, 0.4) if settings.partyAttachSide ~= "LEFT" and settings.partyAttachSide ~= "RIGHT" then settings.partyAttachSide = "RIGHT" end settings.partyAttachOffsetX = NormalizeLayoutValue(settings.partyAttachOffsetX, -200, 200, 8) settings.partyAttachOffsetY = NormalizeLayoutValue(settings.partyAttachOffsetY, -200, 200, 0) if settings.showReadyText == nil then settings.showReadyText = true else settings.showReadyText = settings.showReadyText ~= false end settings.showRemainingOnIcon = settings.showRemainingOnIcon == true settings.iconOverlay = "sweep" end local function NormalizeMapOverlaySettings(settings) if type(settings) ~= "table" then return end if settings.enabled == nil then settings.enabled = true end settings.iconSize = NormalizeLayoutValue(settings.iconSize, 8, 48, 16) settings.alpha = NormalizeLayoutValue(settings.alpha, 0.1, 1, 1) if settings.showLabels == nil then settings.showLabels = true end if type(settings.categories) ~= "table" then settings.categories = { custom = true } end if settings.categories.custom == nil then settings.categories.custom = true end if type(settings.pois) ~= "table" then settings.pois = {} end end local function NormalizeBuffEndingAnnouncerSettings(settings) if type(settings) ~= "table" then return end if settings.enabled == nil then settings.enabled = true end settings.announceAtSec = math.floor(NormalizeLayoutValue(settings.announceAtSec, 1, 30, 5) + 0.5) if type(settings.trackedBuffs) ~= "table" then settings.trackedBuffs = {} return end local normalized = {} for sid, value in pairs(settings.trackedBuffs) do local id = tonumber(sid) if id and id > 0 then local threshold if type(value) == "number" then threshold = value elseif value == true then threshold = settings.announceAtSec elseif type(value) == "table" then if value.enabled ~= false then threshold = value.threshold or value.announceAtSec or value.value end end if threshold ~= nil then normalized[id] = math.floor(NormalizeLayoutValue(threshold, 1, 30, settings.announceAtSec) + 0.5) end end end settings.trackedBuffs = normalized end local function NormalizeRaidTimelineSettings(settings) if type(settings) ~= "table" then return end if settings.enabled == nil then settings.enabled = false end settings.leadTime = math.floor(NormalizeLayoutValue(settings.leadTime, 1, 15, 5) + 0.5) settings.assignmentLeadTime = math.floor(NormalizeLayoutValue(settings.assignmentLeadTime, 0, 60, settings.leadTime or 5) + 0.5) settings.unlocked = settings.unlocked == true settings.alertPosX = NormalizeLayoutValue(settings.alertPosX, -2000, 2000, 0) settings.alertPosY = NormalizeLayoutValue(settings.alertPosY, -2000, 2000, 180) settings.alertFont = tostring(settings.alertFont or "Friz Quadrata TT") settings.alertFontSize = math.floor(NormalizeLayoutValue(settings.alertFontSize, 10, 72, 30) + 0.5) settings.alertFontOutline = tostring(settings.alertFontOutline or "OUTLINE") if type(settings.alertColor) ~= "table" then settings.alertColor = { r = 1, g = 0.82, b = 0.15, a = 1 } else settings.alertColor.r = tonumber(settings.alertColor.r or settings.alertColor[1]) or 1 settings.alertColor.g = tonumber(settings.alertColor.g or settings.alertColor[2]) or 0.82 settings.alertColor.b = tonumber(settings.alertColor.b or settings.alertColor[3]) or 0.15 settings.alertColor.a = tonumber(settings.alertColor.a or settings.alertColor[4]) or 1 end if type(settings.encounters) ~= "table" then settings.encounters = {} return end local normalizedEncounters = {} for encounterId, encounter in pairs(settings.encounters) do local eid = tonumber(encounterId) if eid and eid > 0 and type(encounter) == "table" then local normalizedEncounter = { name = tostring(encounter.name or ""), journalInstanceId = tonumber(encounter.journalInstanceId) or 0, instanceName = tostring(encounter.instanceName or ""), difficulties = type(encounter.difficulties) == "table" and { lfr = encounter.difficulties.lfr ~= false, normal = encounter.difficulties.normal ~= false, heroic = encounter.difficulties.heroic ~= false, mythic = encounter.difficulties.mythic ~= false, } or { lfr = true, normal = true, heroic = true, mythic = true, }, entries = {}, } if type(encounter.entries) == "table" then for _, entry in ipairs(encounter.entries) do if type(entry) == "table" then local triggerType = tostring(entry.triggerType or "") local actionType = tostring(entry.actionType or "") local entryType = tostring(entry.entryType or "") local spellId = math.max(0, tonumber(entry.spellId) or 0) local timeSec = tonumber(entry.time) or 0 local alertText = tostring(entry.alertText or "") local playerName = tostring(entry.playerName or "") local targetSpec = tostring(entry.targetSpec or "") local bossAbilityId = tostring(entry.bossAbilityId or "") local bossAbilityBarName = tostring(entry.bossAbilityBarName or "") local castCount = tostring(entry.castCount or ""):gsub("^%s+", ""):gsub("%s+$", ""):lower() if castCount == "" then castCount = "1" elseif castCount ~= "all" and castCount ~= "odd" and castCount ~= "even" then castCount = tostring(math.max(1, math.floor((tonumber(castCount) or 1) + 0.5))) end local valid = false if triggerType == "bossAbility" then if actionType == "text" then valid = bossAbilityBarName ~= "" and alertText ~= "" elseif actionType == "raidCooldown" then valid = bossAbilityBarName ~= "" and spellId > 0 end timeSec = 0 else triggerType = "time" if actionType == "text" then valid = timeSec >= 0 and alertText ~= "" spellId = 0 bossAbilityId = "" bossAbilityBarName = "" castCount = "1" else actionType = "raidCooldown" valid = timeSec >= 0 and spellId > 0 bossAbilityId = "" bossAbilityBarName = "" castCount = "1" end end if valid then normalizedEncounter.entries[#normalizedEncounter.entries + 1] = { time = math.floor(timeSec + 0.5), spellId = spellId, playerName = playerName, entryType = entryType, triggerType = triggerType, actionType = actionType, targetSpec = targetSpec, alertText = alertText, bossAbilityId = bossAbilityId, bossAbilityBarName = bossAbilityBarName, castCount = castCount, } end end end end table.sort(normalizedEncounter.entries, function(a, b) if a.time ~= b.time then return a.time < b.time end if a.spellId ~= b.spellId then return a.spellId < b.spellId end return tostring(a.playerName or "") < tostring(b.playerName or "") end) normalizedEncounters[eid] = normalizedEncounter end end settings.encounters = normalizedEncounters end local function NormalizeNotesSettings(settings) if type(settings) ~= "table" then return end settings.enabled = settings.enabled ~= false settings.mainText = tostring(settings.mainText or "") settings.mainTitle = tostring(settings.mainTitle or "") settings.mainEncounterId = math.max(0, tonumber(settings.mainEncounterId) or 0) settings.personalText = tostring(settings.personalText or "") settings.window = type(settings.window) == "table" and settings.window or {} settings.window.width = math.floor(NormalizeLayoutValue(settings.window.width, 700, 1600, 1080) + 0.5) settings.window.height = math.floor(NormalizeLayoutValue(settings.window.height, 500, 1000, 700) + 0.5) local drafts = type(settings.drafts) == "table" and settings.drafts or {} local normalizedDrafts = {} local seenIds = {} for index, draft in ipairs(drafts) do if type(draft) == "table" then local draftId = math.max(1, tonumber(draft.id) or index) while seenIds[draftId] do draftId = draftId + 1 end seenIds[draftId] = true normalizedDrafts[#normalizedDrafts + 1] = { id = draftId, title = tostring(draft.title or ""), text = tostring(draft.text or ""), encounterId = math.max(0, tonumber(draft.encounterId) or 0), } end end table.sort(normalizedDrafts, function(a, b) return (tonumber(a.id) or 0) < (tonumber(b.id) or 0) end) settings.drafts = normalizedDrafts end local function NormalizeMinimapSettings(settings) if type(settings) ~= "table" then return end if settings.hide == nil then settings.hide = false end local pos = tonumber(settings.minimapPos) if not pos then pos = tonumber(settings.angle) end if not pos then pos = 220 end settings.minimapPos = math.fmod(pos, 360) if settings.minimapPos < 0 then settings.minimapPos = settings.minimapPos + 360 end settings.angle = nil end local function NormalizeTrackerCategories(categories) local normalized = {} local seen = {} if type(categories) == "table" then for _, category in ipairs(categories) do local value = tostring(category or ""):lower() if value ~= "" and not seen[value] then seen[value] = true normalized[#normalized + 1] = value end end end if #normalized == 0 then normalized[1] = "interrupt" end return normalized end local function CopyTrackerFields(target, source) if type(target) ~= "table" or type(source) ~= "table" then return target end local keys = { "enabled", "demoMode", "testMode", "showBar", "showSpellTooltip", "locked", "posX", "posY", "anchorTo", "anchorCustom", "anchorPoint", "anchorRelPoint", "anchorX", "anchorY", "width", "barHeight", "barSpacing", "barTexture", "borderEnabled", "borderColor", "iconSize", "iconSpacing", "iconCols", "iconOverlay", "textAnchor", "fontSize", "font", "fontOutline", "growDirection", "showInSolo", "showInGroup", "showInRaid", "enabledSpells", "showPlayerName", "colorByClass", "showChargesOnIcon", "showOnlyReady", "readySoonSec", "roleFilter", "rangeCheck", "hideOutOfRange", "outOfRangeAlpha", "showReadyText", "showRemainingOnIcon", "trackerType", "perGroupMember", "includeSelfFrame", "attachToPartyFrame", "partyAttachSide", "partyAttachOffsetX", "partyAttachOffsetY", } for _, key in ipairs(keys) do if source[key] ~= nil then target[key] = DeepCopy(source[key]) end end return target end function HMGT:CreateTrackerConfig(id, overrides) local trackerId = tonumber(id) or 1 local settings = { id = trackerId, name = string.format("Tracker %d", trackerId), enabled = true, demoMode = false, testMode = false, trackerType = "normal", categories = { "interrupt" }, showBar = true, showSpellTooltip = true, perGroupMember = false, includeSelfFrame = false, locked = false, attachToPartyFrame = false, partyAttachSide = "RIGHT", partyAttachOffsetX = 8, partyAttachOffsetY = 0, posX = 200 + ((trackerId - 1) * 300), posY = -200, anchorTo = "UIParent", anchorCustom = "", anchorPoint = "TOPLEFT", anchorRelPoint = "TOPLEFT", anchorX = 200 + ((trackerId - 1) * 300), anchorY = -200, width = 250, barHeight = 20, barSpacing = 2, barTexture = "Blizzard", borderEnabled = false, borderColor = { r = 1, g = 1, b = 1, a = 1 }, iconSize = 32, iconSpacing = 2, iconCols = 6, iconOverlay = "sweep", textAnchor = "below", fontSize = 12, font = "Friz Quadrata TT", fontOutline = "OUTLINE", growDirection = "DOWN", showInSolo = true, showInGroup = true, showInRaid = true, enabledSpells = {}, showPlayerName = true, colorByClass = true, showChargesOnIcon = false, showOnlyReady = false, readySoonSec = 0, roleFilter = "ALL", rangeCheck = false, hideOutOfRange = false, outOfRangeAlpha = 0.4, showReadyText = true, showRemainingOnIcon = false, } if type(overrides) == "table" then CopyTrackerFields(settings, overrides) if overrides.name ~= nil then settings.name = tostring(overrides.name) end if overrides.id ~= nil then settings.id = tonumber(overrides.id) or trackerId end if overrides.categories ~= nil then settings.categories = DeepCopy(overrides.categories) end end settings.categories = NormalizeTrackerCategories(settings.categories) settings.name = tostring(settings.name or ""):gsub("^%s+", ""):gsub("%s+$", "") if settings.name == "" then settings.name = string.format("Tracker %d", tonumber(settings.id) or trackerId) end settings.showReadyText = settings.showReadyText ~= false settings.showRemainingOnIcon = settings.showRemainingOnIcon == true if type(settings.enabledSpells) ~= "table" then settings.enabledSpells = {} end NormalizeBorderSettings(settings) NormalizeAnchorSettings(settings) NormalizeTrackerLayout(settings, settings.showChargesOnIcon == true, true) return settings end function HMGT:GetTrackerConfigs() local profile = self.db and self.db.profile if not profile or type(profile.trackers) ~= "table" then return {} end return profile.trackers end function HMGT:GetTrackerConfigById(id) local trackerId = tonumber(id) if not trackerId then return nil end for _, tracker in ipairs(self:GetTrackerConfigs()) do if tonumber(tracker.id) == trackerId then return tracker end end return nil end function HMGT:GetNextTrackerId() local nextId = 1 for _, tracker in ipairs(self:GetTrackerConfigs()) do nextId = math.max(nextId, (tonumber(tracker.id) or 0) + 1) end return nextId end function HMGT:GetTrackerAnchorKey(id) local trackerId = tonumber(id) if not trackerId then return nil end return "TRACKER:" .. tostring(trackerId) end function HMGT:MigrateProfileSettings() local p = self.db and self.db.profile if not p then return end p.debug = false if p.debugLevel ~= "error" and p.debugLevel ~= "info" and p.debugLevel ~= "verbose" then p.debugLevel = "info" end if type(p.debugScope) ~= "string" or p.debugScope == "" then p.debugScope = DEBUG_SCOPE_ALL end p.devTools = type(p.devTools) == "table" and p.devTools or {} p.devTools.enabled = p.devTools.enabled == true if p.devTools.level ~= "error" and p.devTools.level ~= "trace" then p.devTools.level = "error" end if type(p.devTools.scope) ~= "string" or p.devTools.scope == "" then p.devTools.scope = "ALL" end p.devTools.window = type(p.devTools.window) == "table" and p.devTools.window or {} p.devTools.window.width = math.max(720, tonumber(p.devTools.window.width) or 920) p.devTools.window.height = math.max(260, tonumber(p.devTools.window.height) or 420) p.devTools.window.minimized = p.devTools.window.minimized == true p.syncRemoteCharges = true if p.interruptTracker then NormalizeBorderSettings(p.interruptTracker) NormalizeAnchorSettings(p.interruptTracker) NormalizeTrackerLayout(p.interruptTracker, false, true) end if p.raidCooldownTracker then NormalizeBorderSettings(p.raidCooldownTracker) NormalizeAnchorSettings(p.raidCooldownTracker) NormalizeTrackerLayout(p.raidCooldownTracker, false, true) end if p.groupCooldownTracker then NormalizeBorderSettings(p.groupCooldownTracker) NormalizeAnchorSettings(p.groupCooldownTracker) NormalizeTrackerLayout(p.groupCooldownTracker, true, true) end if type(p.trackers) ~= "table" then p.trackers = {} end if #p.trackers == 0 and p.trackerModelVersion ~= TRACKER_MODEL_VERSION then p.trackers = { self:CreateTrackerConfig(1, CopyTrackerFields({ name = L["IT_NAME"] or "Interrupts", trackerType = "normal", categories = { "interrupt" }, }, p.interruptTracker or {})), self:CreateTrackerConfig(2, CopyTrackerFields({ name = L["RCD_NAME"] or "Raid Cooldowns", trackerType = "normal", categories = { "raid" }, }, p.raidCooldownTracker or {})), self:CreateTrackerConfig(3, CopyTrackerFields({ name = L["GCD_NAME"] or "Cooldowns", trackerType = "group", categories = { "defensive", "offensive", "tank", "healing", "utility", "cc", "lust" }, showChargesOnIcon = true, }, p.groupCooldownTracker or {})), } end local normalizedTrackers = {} local seenTrackerIds = {} for index, tracker in ipairs(p.trackers) do if type(tracker) == "table" then local trackerId = tonumber(tracker.id) or index while seenTrackerIds[trackerId] do trackerId = trackerId + 1 end tracker.id = trackerId seenTrackerIds[trackerId] = true normalizedTrackers[#normalizedTrackers + 1] = self:CreateTrackerConfig(trackerId, tracker) end end if #normalizedTrackers == 0 then normalizedTrackers[1] = self:CreateTrackerConfig(1, { name = L["IT_NAME"] or "Interrupts", trackerType = "normal", categories = { "interrupt" }, }) end p.trackers = normalizedTrackers p.trackerModelVersion = TRACKER_MODEL_VERSION p.mapOverlay = p.mapOverlay or {} NormalizeMapOverlaySettings(p.mapOverlay) p.buffEndingAnnouncer = p.buffEndingAnnouncer or {} NormalizeBuffEndingAnnouncerSettings(p.buffEndingAnnouncer) p.personalAuras = nil p.raidTimeline = p.raidTimeline or {} NormalizeRaidTimelineSettings(p.raidTimeline) p.notes = p.notes or {} NormalizeNotesSettings(p.notes) p.minimap = p.minimap or {} NormalizeMinimapSettings(p.minimap) p.autoEnemyMarker = nil end function HMGT:OnEnable() if self.EnsureTrackerStateTables then self:EnsureTrackerStateTables() end self:RegisterComm(COMM_PREFIX, "OnCommReceived") -- UNIT_SPELLCAST_SUCCEEDED für unitTag "player" → eigene Casts if not self.unitEventFrame then self.unitEventFrame = CreateFrame("Frame") self.unitEventFrame:SetScript("OnEvent", function(_, event, ...) if event == "UNIT_SPELLCAST_SENT" then self:OnUnitSpellCastSent(event, ...) elseif event == "UNIT_SPELLCAST_SUCCEEDED" then self:OnUnitSpellCastSucceeded(event, ...) elseif event == "UNIT_AURA" then self:OnUnitAura(event, ...) end end) end self.unitEventFrame:RegisterUnitEvent("UNIT_SPELLCAST_SENT", "player") self.unitEventFrame:RegisterUnitEvent("UNIT_SPELLCAST_SUCCEEDED", "player") self.unitEventFrame:RegisterUnitEvent("UNIT_AURA", "player") self:RegisterEvent("PLAYER_REGEN_ENABLED", "OnPlayerRegenEnabled") self:RegisterEvent("GROUP_ROSTER_UPDATE", "OnGroupRosterUpdate") self:RegisterEvent("PLAYER_ENTERING_WORLD", "OnPlayerEnteringWorld") self:RegisterEvent("LOADING_SCREEN_DISABLED", "OnLoadingScreenDisabled") -- PLAYER_LOGIN feuert nachdem der Char vollständig geladen ist – -- erst dann liefert GetSpecialization() zuverlässig den richtigen Wert. self:RegisterEvent("PLAYER_LOGIN", "OnPlayerLogin") -- Spec-Wechsel im Spiel self:RegisterEvent("PLAYER_TALENT_UPDATE", "OnPlayerTalentUpdate") self:RegisterEvent("ACTIVE_TALENT_GROUP_CHANGED", "OnPlayerTalentUpdate") self:RegisterEvent("PLAYER_SPECIALIZATION_CHANGED","OnPlayerTalentUpdate") -- Gruppen-Sichtbarkeit neu auswerten wenn sich die Zusammensetzung ändert self:RegisterEvent("RAID_ROSTER_UPDATE", "OnGroupRosterUpdate") self:RegisterLibSpecializationBridge() if not self.cleanupTicker then self.cleanupTicker = C_Timer.NewTicker(15, function() self:CleanupStaleCooldowns() end) end if not self.stateRepairTicker then self.stateRepairTicker = C_Timer.NewTicker(5, function() self:BroadcastRepairSpellStates() end) end self:UpdateOwnPlayerInfo() if HMGT.TrackerManager then HMGT.TrackerManager:Enable() end if HMGT.MapOverlay and not HMGT.MapOverlay:IsEnabled() then HMGT.MapOverlay:Enable() end self:RefreshFrameAnchors(true) self:UpdateDebugWindowVisibility() -- Initialize minimap launcher (LibDBIcon with legacy fallback). self:CreateMinimapButton() self:Print(L["ADDON_LOADED"]) end function HMGT:RefreshFrameAnchors(force) if not HMGT.TrackerFrame or not HMGT.TrackerFrame.ApplyAnchor then return end if HMGT.TrackerManager and HMGT.TrackerManager.RefreshAnchors then HMGT.TrackerManager:RefreshAnchors(force) end end local COMMON_ANCHOR_TARGETS = { { key = "PlayerFrame", label = "Player Frame (PlayerFrame)" }, { key = "TargetFrame", label = "Target Frame (TargetFrame)" }, { key = "FocusFrame", label = "Focus Frame (FocusFrame)" }, { key = "PetFrame", label = "Pet Frame (PetFrame)" }, { key = "PartyMemberFrame1", label = "Party 1 (PartyMemberFrame1)" }, { key = "PartyMemberFrame2", label = "Party 2 (PartyMemberFrame2)" }, { key = "PartyMemberFrame3", label = "Party 3 (PartyMemberFrame3)" }, { key = "PartyMemberFrame4", label = "Party 4 (PartyMemberFrame4)" }, { key = "CompactRaidFrameManager", label = "Raid Manager (CompactRaidFrameManager)" }, { key = "ElvUF_Player", label = "ElvUI Player (ElvUF_Player)" }, { key = "ElvUF_Target", label = "ElvUI Target (ElvUF_Target)" }, { key = "ElvUF_Focus", label = "ElvUI Focus (ElvUF_Focus)" }, { key = "ElvUF_Pet", label = "ElvUI Pet (ElvUF_Pet)" }, { key = "ElvUF_PartyGroup1UnitButton1", label = "ElvUI Party 1 (ElvUF_PartyGroup1UnitButton1)" }, { key = "ElvUF_PartyGroup1UnitButton2", label = "ElvUI Party 2 (ElvUF_PartyGroup1UnitButton2)" }, { key = "ElvUF_PartyGroup1UnitButton3", label = "ElvUI Party 3 (ElvUF_PartyGroup1UnitButton3)" }, { key = "ElvUF_PartyGroup1UnitButton4", label = "ElvUI Party 4 (ElvUF_PartyGroup1UnitButton4)" }, { key = "ElvUF_PartyGroup1UnitButton5", label = "ElvUI Party 5 (ElvUF_PartyGroup1UnitButton5)" }, { key = "ElvUF_Raid1UnitButton1", label = "ElvUI Raid 1 (ElvUF_Raid1UnitButton1)" }, } local function ParseTrackerAnchorKey(anchorTo) local trackerId = tostring(anchorTo or ""):match("^TRACKER:(%d+)$") return tonumber(trackerId) end local function GetTrackerAnchorLabelById(trackerId) local tracker = HMGT.GetTrackerConfigById and HMGT:GetTrackerConfigById(trackerId) or nil if tracker then local name = tostring(tracker.name or ""):gsub("^%s+", ""):gsub("%s+$", "") if name ~= "" then return name end end if trackerId then return string.format("%s %d", L["OPT_TRACKER"] or "Tracker", tonumber(trackerId) or 0) end return L["OPT_TRACKER"] or "Tracker" end IsKnownAnchorTargetKey = function(anchorTo) anchorTo = NormalizeTrackerAnchorKey(anchorTo) if anchorTo == "UIParent" or anchorTo == "CUSTOM" then return true end if ParseTrackerAnchorKey(anchorTo) then return true end for _, target in ipairs(COMMON_ANCHOR_TARGETS) do if target.key == anchorTo then return true end end return false end --- Liefert die aktuell waehlbaren Anchor-Targets fuer das Config-Dropdown. --- Enthalten sind Tracker-Frames sowie eine feste Liste sinnvoller UI-Frames. function HMGT:GetAnchorTargetOptions(currentTrackerId, selectedValue) local values = { UIParent = L["OPT_ANCHOR_TARGET_UI"] or "UIParent", CUSTOM = L["OPT_ANCHOR_TARGET_CUSTOM"] or "Custom frame name", } local selectedKey = NormalizeTrackerAnchorKey(selectedValue) local activeTrackerId = tonumber(currentTrackerId) or ParseTrackerAnchorKey(currentTrackerId) for _, tracker in ipairs(self:GetTrackerConfigs()) do local trackerId = tonumber(tracker.id) local anchorKey = self:GetTrackerAnchorKey(trackerId) if trackerId and anchorKey and trackerId ~= activeTrackerId then values[anchorKey] = GetTrackerAnchorLabelById(trackerId) end end for _, target in ipairs(COMMON_ANCHOR_TARGETS) do values[target.key] = target.label end if selectedKey and selectedKey ~= "" and not values[selectedKey] then local trackerId = ParseTrackerAnchorKey(selectedKey) if trackerId then values[selectedKey] = GetTrackerAnchorLabelById(trackerId) else values[selectedKey] = selectedKey end end return values end --- Loest ein gespeichertes Anchor-Ziel in ein echtes Frame-Objekt auf. function HMGT:GetAnchorTargetFrame(anchorTo, anchorCustom) anchorTo = NormalizeTrackerAnchorKey(anchorTo) local trackerId = ParseTrackerAnchorKey(anchorTo) if trackerId then local tracker = self:GetTrackerConfigById(trackerId) if tracker and HMGT.TrackerManager then if type(HMGT.TrackerManager.GetAnchorFrame) == "function" then return HMGT.TrackerManager:GetAnchorFrame(tracker) end if type(HMGT.TrackerManager.EnsureFrame) == "function" then return HMGT.TrackerManager:EnsureFrame(tracker) end end end local resolved = anchorTo if anchorTo == "CUSTOM" then resolved = anchorCustom end if type(resolved) == "string" and resolved ~= "" then local target = _G[resolved] if target and target.IsObjectType and (target:IsObjectType("Frame") or target:IsObjectType("Button")) then return target end end return UIParent end function HMGT:OnDisable() if self.unitEventFrame then self.unitEventFrame:UnregisterAllEvents() end if HMGT.TrackerManager then HMGT.TrackerManager:Disable() end self:StopReliableCommTicker() self.pendingReliableMessages = {} self.pendingReliableBySupersede = {} self.recentGroupMessages = {} if self.cleanupTicker then self.cleanupTicker:Cancel() self.cleanupTicker = nil end if self._syncRequestTimer then self:CancelTimer(self._syncRequestTimer, true) self._syncRequestTimer = nil end if self._syncBurstTimers then for _, timerHandle in ipairs(self._syncBurstTimers) do self:CancelTimer(timerHandle, true) end self._syncBurstTimers = nil end if LDBIcon and self._ldbIconRegistered then LDBIcon:Hide(ADDON_NAME) end end -- ═══════════════════════════════════════════════════════════════ -- SPELL-STANDARDS -- ═══════════════════════════════════════════════════════════════ function HMGT:InitDefaultSpellSettings() for _, tracker in ipairs(self:GetTrackerConfigs()) do if type(tracker.enabledSpells) ~= "table" then tracker.enabledSpells = {} end end end function HMGT:ApplyCustomSpellsFromProfile() if HMGT_SpellData.RebuildLookups then HMGT_SpellData.RebuildLookups() end self:InitDefaultSpellSettings() end function HMGT:AddCustomSpell(dbKey, spellId, cooldown, classToken, specText, category) return false end function HMGT:RemoveCustomSpell(dbKey, spellId) return false end -- ═══════════════════════════════════════════════════════════════ -- EIGENE SPIELER-INFO -- ═══════════════════════════════════════════════════════════════ function HMGT:UpdateOwnPlayerInfo() local name = self:NormalizePlayerName(UnitName("player")) local class = select(2, UnitClass("player")) local specIndex = GetSpecialization() -- GetSpecialization() liefert 0 wenn der Char noch nicht vollstaendig geladen ist. -- NIEMALS auf Spec 1 defaulten - das wuerde z.B. einem Prot-Krieger Pummel (Arms/Fury) -- statt Schildschlag (Prot) anzeigen. Stattdessen: 0.5s warten und erneut versuchen. if not specIndex or specIndex == 0 then C_Timer.After(0.5, function() self:UpdateOwnPlayerInfo() end) return end local talents = {} local configID = C_ClassTalents and C_ClassTalents.GetActiveConfigID and C_ClassTalents.GetActiveConfigID() if configID then local configInfo = C_Traits.GetConfigInfo(configID) if configInfo then for _, treeID in ipairs(configInfo.treeIDs or {}) do for _, nodeID in ipairs(C_Traits.GetTreeNodes(treeID) or {}) do local nodeInfo = C_Traits.GetNodeInfo(configID, nodeID) if nodeInfo and nodeInfo.activeRank and nodeInfo.activeRank > 0 then local entryID = nodeInfo.activeEntry and nodeInfo.activeEntry.entryID if entryID then local entryInfo = C_Traits.GetEntryInfo(configID, entryID) if entryInfo and entryInfo.definitionID then local defInfo = C_Traits.GetDefinitionInfo(entryInfo.definitionID) if defInfo and defInfo.spellID then talents[defInfo.spellID] = true end end end end end end end end self.knownChargeInfo = self.knownChargeInfo or {} wipe(self.knownChargeInfo) self.playerData[name] = { class = class, specIndex = specIndex, talentHash = self:HashTalents(talents), talents = talents, knownSpells = self:CollectOwnAvailableTrackerSpells(class, specIndex), isOwn = true, } self:RefreshOwnAvailabilityStates() self:ResetOwnPowerTracking() -- Tracker sofort nach erfolgreicher Spec-Erkennung aktualisieren self:TriggerTrackerUpdate() self:QueueSyncRequest(0.20) end function HMGT:ResetOwnPowerTracking() self.powerTracking = self.powerTracking or {} self.powerTracking.accumulators = self.powerTracking.accumulators or {} self.pendingSpellPowerCosts = self.pendingSpellPowerCosts or {} wipe(self.powerTracking.accumulators) wipe(self.pendingSpellPowerCosts) end function HMGT:HashTalents(talents) local ids = {} for id in pairs(talents) do table.insert(ids, id) end table.sort(ids) return table.concat(ids, ",") end function HMGT:NormalizePlayerName(name) if not name or name == "" then return nil end return Ambiguate(name, "short") end function HMGT:ParseTalentHash(hash) local talents = {} if hash and hash ~= "" then for id in hash:gmatch("(%d+)") do talents[tonumber(id)] = true end end return talents end function HMGT:ParseKnownSpellList(listStr) local knownSpells = {} if type(listStr) == "string" and listStr ~= "" then for id in listStr:gmatch("(%d+)") do knownSpells[tonumber(id)] = true end end return knownSpells end function HMGT:SerializeKnownSpellList(knownSpells) local ids = {} if type(knownSpells) == "table" then for sid, known in pairs(knownSpells) do if known then ids[#ids + 1] = tonumber(sid) end end end table.sort(ids) for i = 1, #ids do ids[i] = tostring(ids[i]) end return table.concat(ids, ",") end function HMGT:GetAvailabilityConfig(spellEntry) if HMGT_SpellData and type(HMGT_SpellData.GetAvailabilityConfig) == "function" then local availability = HMGT_SpellData.GetAvailabilityConfig(spellEntry) if type(availability) == "table" and availability.type then return availability end end local availability = spellEntry and spellEntry.availability if type(availability) ~= "table" or not availability.type then return nil end return availability end function HMGT:IsAvailabilitySpell(spellEntry) return self:GetAvailabilityConfig(spellEntry) ~= nil end function HMGT:GetAvailabilityRequiredCount(spellEntry) local required = 0 if HMGT_SpellData and type(HMGT_SpellData.GetEffectiveAvailabilityRequired) == "function" then local ownName = self:NormalizePlayerName(UnitName("player")) local pData = ownName and self.playerData and self.playerData[ownName] required = HMGT_SpellData.GetEffectiveAvailabilityRequired(spellEntry, pData and pData.talents or {}) end if required <= 0 then local availability = self:GetAvailabilityConfig(spellEntry) required = tonumber(availability and availability.required) or 0 end if required <= 0 then return 0 end return math.max(1, math.floor(required + 0.5)) end -- ═══════════════════════════════════════════════════════════════ -- KOMMUNIKATION -- ═══════════════════════════════════════════════════════════════ function HMGT:SendGroupMessage(msg, prio) -- Nur senden, wenn mindestens ein echter Mitspieler in der Gruppe ist. -- Das verhindert Fehlermeldungen in Follower-Dungeons (NPC-Begleiter, kein Party-Chat). local hasPlayers = false if IsInRaid() then for i = 1, GetNumGroupMembers() do local unit = "raid" .. i if UnitExists(unit) and not UnitIsUnit(unit, "player") and UnitIsPlayer(unit) then hasPlayers = true break end end elseif IsInGroup() then for i = 1, GetNumSubgroupMembers() do local unit = "party" .. i if UnitExists(unit) and UnitIsPlayer(unit) then hasPlayers = true break end end end if not hasPlayers then return end local channel if IsInGroup(LE_PARTY_CATEGORY_INSTANCE) then channel = "INSTANCE_CHAT" elseif IsInRaid() then channel = "RAID" elseif IsInGroup(LE_PARTY_CATEGORY_HOME) or IsInGroup() then channel = "PARTY" else return end local dedupeKey = string.format("%s|%s|%s", tostring(channel), tostring(prio or "NORMAL"), tostring(msg)) local now = GetTime() local lastSentAt = tonumber(self.recentGroupMessages[dedupeKey]) or 0 if now - lastSentAt < 0.10 then self:DebugScoped("verbose", "Comm", "SendGroupMessage deduped channel=%s prio=%s", tostring(channel), tostring(prio or "NORMAL")) return end self.recentGroupMessages[dedupeKey] = now if next(self.recentGroupMessages) then local count = 0 for key, sentAt in pairs(self.recentGroupMessages) do count = count + 1 if count > 200 or (now - (tonumber(sentAt) or 0)) > 5 then self.recentGroupMessages[key] = nil end end end self:DebugScoped("verbose", "Comm", "SendGroupMessage channel=%s prio=%s payload=%s", tostring(channel), tostring(prio or "NORMAL"), tostring(msg)) self:SendCommMessage(COMM_PREFIX, msg, channel, nil, prio) end -- ═══════════════════════════════════════════════════════════════ -- EVENTS -- ═══════════════════════════════════════════════════════════════ function HMGT:OnUnitSpellCastSent(event, unitTag, targetName, castGUID, spellId) if unitTag == "player" then self:CaptureOwnSpellPowerCosts(spellId) end end function HMGT:OnUnitSpellCastSucceeded(event, unitTag, castGUID, spellId) if unitTag == "player" then self:HandleOwnSpellCast(spellId) self:HandleOwnCooldownReductionTrigger(spellId) self:HandleOwnPowerSpendFromSpell(spellId) end end function HMGT:OnUnitAura(event, unitTag) if unitTag ~= "player" then return end if self:RefreshAndPublishOwnAvailabilityStates() then self:TriggerTrackerUpdate() end end function HMGT:OnPlayerRegenEnabled() self:RefreshAndPublishOwnAvailabilityStates() end local function BuildCooldownStateFingerprint(cdData) if not cdData then return "nil" end return table.concat({ string.format("%.3f", tonumber(cdData.startTime) or 0), string.format("%.3f", tonumber(cdData.duration) or 0), tostring(tonumber(cdData.currentCharges) or -1), tostring(tonumber(cdData.maxCharges) or -1), string.format("%.3f", tonumber(cdData.chargeStart) or 0), string.format("%.3f", tonumber(cdData.chargeDuration) or 0), }, "|") end function HMGT:ApplyCooldownReduction(playerName, targetSpellId, amount) local sid = tonumber(targetSpellId) local reduceBy = tonumber(amount) or 0 if not playerName or not sid or sid <= 0 or reduceBy <= 0 then return 0 end local spells = self:GetPlayerCooldownMap(playerName, false) if not spells then return 0 end local cdData = spells[sid] if not cdData then return 0 end local now = GetTime() local applied = 0 local hasCharges = (tonumber(cdData.maxCharges) or 0) > 0 if hasCharges then local nextRem, chargeDur, charges, maxCharges = self:ResolveChargeState(cdData, now) if chargeDur <= 0 or charges >= maxCharges then return 0 end local left = reduceBy local rem = math.max(0, nextRem) while left > 0 and charges < maxCharges do if rem <= 0 then rem = chargeDur end if rem <= left then left = left - rem applied = applied + rem charges = charges + 1 if charges < maxCharges then rem = chargeDur else rem = 0 end else rem = rem - left applied = applied + left left = 0 end end if applied <= 0 then return 0 end cdData.currentCharges = charges cdData.maxCharges = maxCharges cdData.chargeDuration = chargeDur if charges < maxCharges then cdData.chargeStart = now - math.max(0, chargeDur - rem) local missing = maxCharges - charges cdData.startTime = cdData.chargeStart cdData.duration = missing * chargeDur self:RefreshCooldownExpiryTimer(playerName, sid, cdData) else spells[sid] = nil end else local duration = tonumber(cdData.duration) or 0 local startTime = tonumber(cdData.startTime) or now local remaining = math.max(0, duration - (now - startTime)) if remaining <= 0 then return 0 end applied = math.min(reduceBy, remaining) local newRemaining = remaining - applied if newRemaining <= 0 then spells[sid] = nil else cdData.startTime = now - math.max(0, duration - newRemaining) self:RefreshCooldownExpiryTimer(playerName, sid, cdData) end end if spells and not next(spells) then self:ClearPlayerCooldowns(playerName) end if playerName == self:NormalizePlayerName(UnitName("player")) then self:PublishOwnSpellState(sid) end self:TriggerTrackerUpdate() return applied end function HMGT:IsNewCooldownReduceEvent(playerName, targetSpellId, castTimestamp, triggerSpellId) local ts = tonumber(castTimestamp) or 0 if ts <= 0 then return true end self._recentReduceEvents = self._recentReduceEvents or {} local key = string.format( "%s:%d:%d:%d", tostring(playerName or ""), tonumber(targetSpellId) or 0, tonumber(triggerSpellId) or 0, ts ) local now = GetTime() local last = self._recentReduceEvents[key] if last and (now - last) < 5 then return false end self._recentReduceEvents[key] = now if not self._recentReduceEventsGcAt or (now - self._recentReduceEventsGcAt) > 30 then for k, seenAt in pairs(self._recentReduceEvents) do if (now - seenAt) > 120 then self._recentReduceEvents[k] = nil end end self._recentReduceEventsGcAt = now end return true end local function ApplyOwnCooldownReducers(self, ownName, triggerSpellId, reducers, castTs) for _, reducer in ipairs(reducers) do local applied = self:ApplyCooldownReduction(ownName, reducer.targetSpellId, reducer.amount) if applied > 0 then self:Debug( "verbose", "LocalCooldownReduce trigger=%s target=%s amount=%.2f applied=%.2f", tostring(triggerSpellId), tostring(reducer.targetSpellId), tonumber(reducer.amount) or 0, tonumber(applied) or 0 ) self:BroadcastCooldownReduce(reducer.targetSpellId, applied, castTs, triggerSpellId) end end end local function ApplyObservedCooldownReducers(self, ownName, reducers) local targetDelays = {} for _, reducer in ipairs(reducers) do local targetSpellId = tonumber(reducer.targetSpellId) if targetSpellId and targetSpellId > 0 then local observe = reducer.observe local delay = tonumber(observe and observe.delay) or 0.12 targetDelays[targetSpellId] = math.max(targetDelays[targetSpellId] or 0, delay) end end for targetSpellId, delay in pairs(targetDelays) do local retryOffsets = { 0, 0.20, 0.55 } for _, offset in ipairs(retryOffsets) do C_Timer.After(delay + offset, function() if not self or not self.playerData or not self.playerData[ownName] then return end if self:RefreshOwnCooldownStateFromGame(targetSpellId) then self:PublishOwnSpellState(targetSpellId, { sendLegacy = true }) self:TriggerTrackerUpdate() end end) end end end HMGT.TrackerInternals.BuildCooldownStateFingerprint = BuildCooldownStateFingerprint HMGT.TrackerInternals.ApplyOwnCooldownReducers = ApplyOwnCooldownReducers HMGT.TrackerInternals.ApplyObservedCooldownReducers = ApplyObservedCooldownReducers function HMGT:CaptureOwnSpellPowerCosts(spellId) local sid = tonumber(spellId) if not sid or sid <= 0 then return end local ownName = self:NormalizePlayerName(UnitName("player")) if not ownName then return end local pData = self.playerData[ownName] local classToken = pData and pData.class or select(2, UnitClass("player")) local specIndex = pData and pData.specIndex or GetSpecialization() local talents = pData and pData.talents or {} if not classToken or not specIndex then return end local trackedPowerTypes = HMGT_SpellData and type(HMGT_SpellData.GetTrackedPowerTypes) == "function" and HMGT_SpellData.GetTrackedPowerTypes(classToken, specIndex, talents) or nil if type(trackedPowerTypes) ~= "table" then return end local pending = nil for powerType in pairs(trackedPowerTypes) do local spent = GetSpellPowerCostByToken(sid, powerType) if spent <= 0 then spent = GetTrackedPowerSpendOverride(classToken, specIndex, sid, powerType) end if spent > 0 then pending = pending or { capturedAt = GetTime() } pending[NormalizePowerToken(powerType)] = spent end end if pending then self.pendingSpellPowerCosts = self.pendingSpellPowerCosts or {} self.pendingSpellPowerCosts[sid] = pending end end function HMGT:HandleOwnPowerSpendFromSpell(spellId) local sid = tonumber(spellId) if not sid or sid <= 0 then return end local ownName = self:NormalizePlayerName(UnitName("player")) if not ownName then return end local pData = self.playerData[ownName] local classToken = pData and pData.class or select(2, UnitClass("player")) local specIndex = pData and pData.specIndex or GetSpecialization() local talents = pData and pData.talents or {} if not classToken or not specIndex then return end local trackedPowerTypes = HMGT_SpellData and type(HMGT_SpellData.GetTrackedPowerTypes) == "function" and HMGT_SpellData.GetTrackedPowerTypes(classToken, specIndex, talents) or nil if type(trackedPowerTypes) ~= "table" then return end self.pendingSpellPowerCosts = self.pendingSpellPowerCosts or {} local pending = self.pendingSpellPowerCosts[sid] local pendingFresh = type(pending) == "table" and (GetTime() - (tonumber(pending.capturedAt) or 0)) <= 2 local debugLabel = GetSpellDebugLabel(sid) local detectedAnySpend = false for powerType in pairs(trackedPowerTypes) do local token = NormalizePowerToken(powerType) local spent = pendingFresh and tonumber(pending[token]) or 0 local source = pendingFresh and spent > 0 and "cache" or nil if spent <= 0 then spent = GetSpellPowerCostByToken(sid, powerType) if spent > 0 then source = "api" end end if spent <= 0 then spent = GetTrackedPowerSpendOverride(classToken, specIndex, sid, powerType) if spent > 0 then source = "override" end end if spent > 0 then detectedAnySpend = true self:DebugScoped("verbose", "PowerSpend", "%s -> %s spend=%.0f via %s", debugLabel, tostring(token), spent, tostring(source or "unknown") ) self:HandleOwnPowerSpent(powerType, spent, { spellId = sid, spellLabel = debugLabel, source = source or "unknown", }) end end if not detectedAnySpend then self:DebugScoped("verbose", "PowerSpend", "%s -> kein getrackter Spend erkannt", debugLabel) end self.pendingSpellPowerCosts[sid] = nil end function HMGT:HandleOwnPowerSpent(powerType, amountSpent, context) local token = NormalizePowerToken(powerType) local spent = tonumber(amountSpent) or 0 if not token or spent <= 0 then return end local ownName = self:NormalizePlayerName(UnitName("player")) if not ownName then return end local pData = self.playerData[ownName] local classToken = pData and pData.class or select(2, UnitClass("player")) local specIndex = pData and pData.specIndex or GetSpecialization() local talents = pData and pData.talents or {} if not classToken or not specIndex then return end local sourceLabel = type(context) == "table" and tostring(context.source or "unknown") or "unknown" local spellLabel = type(context) == "table" and tostring(context.spellLabel or GetSpellDebugLabel(context.spellId)) or "Unknown" local reducers = HMGT_SpellData.GetCooldownReducersForPowerSpend(classToken, specIndex, token, talents) if not reducers or #reducers == 0 then self:DebugScoped("verbose", "PowerSpend", "%s -> %s spend=%.0f via %s, aber keine Power-Reducer aktiv", spellLabel, tostring(token), spent, sourceLabel ) return end self.powerTracking = self.powerTracking or {} self.powerTracking.accumulators = self.powerTracking.accumulators or {} local reducerGroups = {} for _, reducer in ipairs(reducers) do local relationKey = tostring(reducer.relationKey or "") local threshold = tonumber(reducer.amountPerTrigger) or 0 if relationKey ~= "" and threshold > 0 then local group = reducerGroups[relationKey] if not group then group = { threshold = threshold, reducers = {}, } reducerGroups[relationKey] = group end group.reducers[#group.reducers + 1] = reducer end end local eventTimestamp = GetServerTime() for relationKey, group in pairs(reducerGroups) do local threshold = tonumber(group.threshold) or 0 if threshold > 0 then local previousBucket = tonumber(self.powerTracking.accumulators[relationKey]) or 0 local bucket = previousBucket + spent local triggerCount = math.floor(bucket / threshold) self.powerTracking.accumulators[relationKey] = bucket - (triggerCount * threshold) self:DebugScoped("verbose", "PowerSpend", "%s -> bucket %s: vorher=%.0f, spend=%.0f, nachher=%.0f, threshold=%.0f, triggers=%d", spellLabel, tostring(token), previousBucket, spent, tonumber(self.powerTracking.accumulators[relationKey]) or 0, threshold, triggerCount ) if triggerCount > 0 then for _, reducer in ipairs(group.reducers) do local totalReduction = (tonumber(reducer.amount) or 0) * triggerCount local applied = self:ApplyCooldownReduction(ownName, reducer.targetSpellId, totalReduction) self:DebugScoped("verbose", "PowerSpend", "%s -> target %s requested=%.2f applied=%.2f", spellLabel, GetSpellDebugLabel(reducer.targetSpellId), tonumber(totalReduction) or 0, tonumber(applied) or 0 ) if applied > 0 then self:Debug( "verbose", "LocalPowerCooldownReduce power=%s target=%s spent=%.2f triggers=%d amount=%.2f applied=%.2f", tostring(token), tostring(reducer.targetSpellId), spent, triggerCount, tonumber(totalReduction) or 0, tonumber(applied) or 0 ) self:BroadcastCooldownReduce(reducer.targetSpellId, applied, eventTimestamp, reducer.triggerSpellId) end end end end end end function HMGT:HandleRemoteCooldownReduce(playerName, targetSpellId, amount, castTimestamp, triggerSpellId) if not playerName then return end if not self:IsPlayerInCurrentGroup(playerName) then return end if not self:IsNewCooldownReduceEvent(playerName, targetSpellId, castTimestamp, triggerSpellId) then return end local applied = self:ApplyCooldownReduction(playerName, targetSpellId, amount) if applied > 0 then self:Debug( "verbose", "RemoteCooldownReduce player=%s trigger=%s target=%s amount=%.2f applied=%.2f", tostring(playerName), tostring(triggerSpellId), tostring(targetSpellId), tonumber(amount) or 0, tonumber(applied) or 0 ) end end function HMGT:OnGroupRosterUpdate() self:QueueSyncRequest(0.35, "roster") local validPlayers = { [self:NormalizePlayerName(UnitName("player"))] = true } if IsInRaid() then for i = 1, GetNumGroupMembers() do local n = self:NormalizePlayerName(UnitName("raid"..i)) if n then validPlayers[n] = true end end elseif IsInGroup() then for i = 1, GetNumSubgroupMembers() do local n = self:NormalizePlayerName(UnitName("party"..i)) if n then validPlayers[n] = true end end end for name in pairs(self.playerData) do if not validPlayers[name] then self.playerData[name] = nil self:ClearTrackerStateForPlayer(name) self.peerVersions[name] = nil self.versionWarnings[name] = nil if self.peerProtocols then self.peerProtocols[name] = nil end end end local count = 0 for _ in pairs(validPlayers) do count = count + 1 end self:Debug("verbose", "OnGroupRosterUpdate validPlayers=%d", count) if self.versionNoticeWindow and self.versionNoticeWindow.IsShown and self.versionNoticeWindow:IsShown() and self.RefreshVersionNoticeWindow then self:RefreshVersionNoticeWindow() end if HMGT.TrackerManager and HMGT.TrackerManager.InvalidateAnchorLayout then HMGT.TrackerManager:InvalidateAnchorLayout() end self:TriggerTrackerUpdate() end function HMGT:OnPlayerLogin() -- Char vollständig geladen: Spec jetzt zuverlässig abfragen self:UpdateOwnPlayerInfo() end function HMGT:OnPlayerEnteringWorld() if HMGT.TrackerManager and HMGT.TrackerManager.InvalidateAnchorLayout then HMGT.TrackerManager:InvalidateAnchorLayout() end self:UpdateOwnPlayerInfo() self:RefreshFrameAnchors(true) self:QueueDeltaSyncBurst("entering_world", { 0.40, 1.50, 3.00 }) end function HMGT:OnLoadingScreenDisabled() self:QueueDeltaSyncBurst("loading_screen", { 0.25, 1.00, 2.25 }) end function HMGT:OnPlayerTalentUpdate() self:UpdateOwnPlayerInfo() end -- ═══════════════════════════════════════════════════════════════ -- GRUPPEN-SICHTBARKEIT -- ═══════════════════════════════════════════════════════════════ --- Gibt true zurück wenn ein Tracker laut seinen Einstellungen --- im aktuellen Gruppen-Kontext angezeigt werden soll. --- @param settings table db.profile.interruptTracker / raidCooldownTracker function HMGT:IsVisibleForCurrentGroup(settings) if not settings.enabled then return false end -- IsInRaid() MUSS vor IsInGroup() geprüft werden: -- IsInGroup() gibt auch innerhalb von Raids true zurück! if IsInRaid() then return settings.showInRaid == true end if IsInGroup() then return settings.showInGroup == true end -- Solo (weder Raid noch Gruppe) return settings.showInSolo == true end -- ═══════════════════════════════════════════════════════════════ -- UI-UPDATE TRIGGER -- ═══════════════════════════════════════════════════════════════ -- ==================================================================== -- MINIMAP BUTTON -- ==================================================================== local function OpenBlizzardSettingsCategory(categoryRef) if not categoryRef then return false end if Settings and type(Settings.OpenToCategory) == "function" then local ok = pcall(Settings.OpenToCategory, categoryRef) if ok then return true end end if type(InterfaceOptionsFrame_OpenToCategory) == "function" then local ok = pcall(InterfaceOptionsFrame_OpenToCategory, categoryRef) if ok then pcall(InterfaceOptionsFrame_OpenToCategory, categoryRef) return true end end return false end function HMGT:OpenConfig() local dialog = LibStub("AceConfigDialog-3.0") local settingsCategory = HMGT_Config and HMGT_Config.GetSettingsCategory and HMGT_Config:GetSettingsCategory() if settingsCategory then if dialog and type(dialog.Close) == "function" then dialog:Close(ADDON_NAME) end if OpenBlizzardSettingsCategory(settingsCategory) then return end end if dialog and type(dialog.GetStatusTable) == "function" and type(dialog.SetDefaultSize) == "function" then local status = dialog:GetStatusTable(ADDON_NAME) if type(status.width) ~= "number" or type(status.height) ~= "number" then local uiWidth = (UIParent and UIParent:GetWidth()) or 1920 local uiHeight = (UIParent and UIParent:GetHeight()) or 1080 local defaultWidth = math.min(1200, math.max(980, math.floor((uiWidth * 0.72) + 0.5))) local defaultHeight = math.min(860, math.max(680, math.floor((uiHeight * 0.78) + 0.5))) dialog:SetDefaultSize(ADDON_NAME, defaultWidth, defaultHeight) end end dialog:Open(ADDON_NAME) end local function GetMinimapSettings(self) if not (self and self.db and self.db.profile) then return nil end self.db.profile.minimap = self.db.profile.minimap or {} local mm = self.db.profile.minimap NormalizeMinimapSettings(mm) return mm end function HMGT:EnsureMinimapLauncher() if self._minimapLdbObject then return self._minimapLdbObject end if not (LDB and LDBIcon) then return nil end self._minimapLdbObject = LDB:NewDataObject(ADDON_NAME, { type = "launcher", icon = MINIMAP_ICON, OnClick = function(_, mouseButton) if mouseButton == "LeftButton" then HMGT:OpenConfig() elseif mouseButton == "MiddleButton" then HMGT:ToggleDevToolsWindow() end end, OnTooltipShow = function(tooltip) if not tooltip or not tooltip.AddLine then return end tooltip:AddLine(L["ADDON_TITLE"] or ADDON_NAME, 1, 1, 1) tooltip:AddLine("Left Click: Open options", 0.8, 0.8, 0.8) tooltip:AddLine("Middle Click: Toggle developer tools", 0.8, 0.8, 0.8) end, }) return self._minimapLdbObject end function HMGT:UpdateMinimapButtonPosition() local mm = GetMinimapSettings(self) if not mm then return end if LDBIcon and self._ldbIconRegistered then if mm.hide then LDBIcon:Hide(ADDON_NAME) else LDBIcon:Show(ADDON_NAME) end return end if not self.minimapButton then return end local angle = tonumber(mm.minimapPos) or 220 local radius = 80 local rad = math.rad(angle) local x = math.cos(rad) * radius local y = math.sin(rad) * radius self.minimapButton:ClearAllPoints() self.minimapButton:SetPoint("CENTER", Minimap, "CENTER", x, y) end function HMGT:CreateMinimapButton() local mm = GetMinimapSettings(self) if not mm then return end local launcher = self:EnsureMinimapLauncher() if launcher and LDBIcon then if not self._ldbIconRegistered then LDBIcon:Register(ADDON_NAME, launcher, mm) self._ldbIconRegistered = true end if self.minimapButton then self.minimapButton:Hide() end self:UpdateMinimapButtonPosition() return end self:CreateLegacyMinimapButton() end function HMGT:CreateLegacyMinimapButton() if self.minimapButton then self:UpdateMinimapButtonPosition() return end local mm = GetMinimapSettings(self) local button = CreateFrame("Button", "HMGT_MinimapButton", Minimap) button:SetSize(31, 31) button:SetFrameStrata("MEDIUM") button:SetFrameLevel(8) button:RegisterForClicks("LeftButtonUp", "MiddleButtonUp") button:RegisterForDrag("RightButton") local bg = button:CreateTexture(nil, "BACKGROUND") bg:SetTexture("Interface\\Minimap\\UI-Minimap-Background") bg:SetSize(20, 20) bg:SetPoint("CENTER", 0, 0) local icon = button:CreateTexture(nil, "ARTWORK") icon:SetTexture(MINIMAP_ICON) icon:SetSize(18, 18) icon:SetPoint("CENTER", 0, 0) icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) button.icon = icon local border = button:CreateTexture(nil, "OVERLAY") border:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder") border:SetSize(54, 54) border:SetPoint("TOPLEFT") button:SetHighlightTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight") button:SetScript("OnEnter", function(selfBtn) GameTooltip:SetOwner(selfBtn, "ANCHOR_LEFT") GameTooltip:AddLine(L["ADDON_TITLE"], 1, 1, 1) GameTooltip:AddLine("Left Click: Open options", 0.8, 0.8, 0.8) GameTooltip:AddLine("Middle Click: Toggle developer tools", 0.8, 0.8, 0.8) GameTooltip:AddLine("Right Drag: Move", 0.8, 0.8, 0.8) self:SafeShowTooltip(GameTooltip) end) button:SetScript("OnLeave", function() GameTooltip:Hide() end) button:SetScript("OnClick", function(_, mouseButton) if mouseButton == "LeftButton" then HMGT:OpenConfig() elseif mouseButton == "MiddleButton" then HMGT:ToggleDevToolsWindow() end end) button:SetScript("OnDragStart", function(selfBtn) selfBtn:SetScript("OnUpdate", function() local mx, my = Minimap:GetCenter() local cx, cy = GetCursorPosition() local scale = Minimap:GetEffectiveScale() cx, cy = cx / scale, cy / scale local angle = math.deg(math.atan2(cy - my, cx - mx)) if angle < 0 then angle = angle + 360 end local profile = GetMinimapSettings(HMGT) if profile then profile.minimapPos = angle end HMGT:UpdateMinimapButtonPosition() end) end) button:SetScript("OnDragStop", function(selfBtn) selfBtn:SetScript("OnUpdate", nil) end) self.minimapButton = button if mm and mm.hide then button:Hide() else button:Show() self:UpdateMinimapButtonPosition() end end function HMGT:SlashCommand(input) input = input:trim():lower() if input == "lock" then for _, tracker in ipairs(self:GetTrackerConfigs()) do tracker.locked = true end if HMGT.TrackerManager and HMGT.TrackerManager.SetAllLocked then HMGT.TrackerManager:SetAllLocked(true) end self:Print(L["FRAMES_LOCKED"]) elseif input == "unlock" then for _, tracker in ipairs(self:GetTrackerConfigs()) do tracker.locked = false end if HMGT.TrackerManager and HMGT.TrackerManager.SetAllLocked then HMGT.TrackerManager:SetAllLocked(false) end self:Print(L["FRAMES_UNLOCKED"]) elseif input == "demo" then self:DemoMode() elseif input == "test" then self:TestMode() elseif input == "version" then if self.IsDevToolsEnabled and self:IsDevToolsEnabled() then self:ShowVersionMismatchPopup() else self:Print(L["VERSION_WINDOW_DEVTOOLS_ONLY"] or "HMGT: /hmgt version is only available while developer tools are enabled.") end elseif input == "bridge" then if _G.HMGT_Bridge and _G.HMGT_Bridge.GetStatusLines then for _, line in ipairs(_G.HMGT_Bridge:GetStatusLines()) do self:Print(line) end else self:Print("HMGT Bridge is not loaded.") end elseif input == "debug" then if self.ToggleDevToolsWindow then self:ToggleDevToolsWindow() end elseif input == "dev" or input == "devtools" then if self.ToggleDevToolsWindow then self:ToggleDevToolsWindow() end elseif input == "notes" then if self.Notes and self.Notes.OpenWindow then self.Notes:OpenWindow() else self:OpenConfig() end elseif input:find("^debugdump") == 1 then local n = tonumber(input:match("^debugdump%s+(%d+)$")) if self.DumpDevToolsLog then self:DumpDevToolsLog(n or 60) end else self:OpenConfig() end end function HMGT:DemoMode() local trackers = self:GetTrackerConfigs() local enable = false for _, tracker in ipairs(trackers) do if tracker.demoMode ~= true then enable = true break end end for _, tracker in ipairs(trackers) do tracker.demoMode = enable if enable then tracker.testMode = false end end if HMGT.TrackerManager and HMGT.TrackerManager.Enable then HMGT.TrackerManager:Enable() end self:TriggerTrackerUpdate() self:Print(L["DEMO_MODE_ACTIVE"]) end function HMGT:TestMode() local trackers = self:GetTrackerConfigs() local enable = false for _, tracker in ipairs(trackers) do if tracker.testMode ~= true then enable = true break end end for _, tracker in ipairs(trackers) do tracker.testMode = enable if enable then tracker.demoMode = false end end if HMGT.TrackerManager and HMGT.TrackerManager.Enable then HMGT.TrackerManager:Enable() end self:TriggerTrackerUpdate() self:Print(L["TEST_MODE_ACTIVE"]) end function HMGT:GetDemoEntries(trackerKey, database, settings) local pool = {} local poolByClass = {} for _, entry in ipairs(database) do if settings.enabledSpells[entry.spellId] ~= false then pool[#pool + 1] = entry for _, cls in ipairs(entry.classes or {}) do poolByClass[cls] = poolByClass[cls] or {} poolByClass[cls][#poolByClass[cls] + 1] = entry end end end if #pool == 0 then return {} end local classKeys = {} for cls in pairs(poolByClass) do classKeys[#classKeys + 1] = cls end if #classKeys == 0 then classKeys[1] = "WARRIOR" end local count = settings.showBar and math.min(8, #pool) or math.min(12, #pool) local names = { "Alice", "Bob", "Clara", "Duke", "Elli", "Fynn", "Gina", "Hektor", "Ivo", "Jana", "Kira", "Lio" } local spellIds = {} for _, e in ipairs(pool) do spellIds[#spellIds + 1] = tostring(e.spellId) end table.sort(spellIds) local signature = table.concat(spellIds, ",") .. "|" .. tostring(settings.showBar and 1 or 0) .. "|" .. tostring(count) local now = GetTime() local cache = self.demoModeData[trackerKey] if (not cache) or (cache.signature ~= signature) or (not cache.entries) or (#cache.entries ~= count) then local entries = {} for i = 1, count do local cls = classKeys[math.random(1, #classKeys)] local classPool = poolByClass[cls] local spell = (classPool and classPool[math.random(1, #classPool)]) or pool[math.random(1, #pool)] local duration = math.max( 1, tonumber(HMGT_SpellData.GetBaseCooldown and HMGT_SpellData.GetBaseCooldown(spell)) or tonumber(spell.cooldown) or 60 ) local playerName = names[((i - 1) % #names) + 1] -- start offset so demo entries do not all tick in sync local offset = math.random() * math.min(duration * 0.85, duration - 0.1) entries[#entries + 1] = { playerName = playerName, class = cls or ((spell.classes and spell.classes[1]) or "WARRIOR"), spellEntry = spell, total = duration, cycleStart = now - offset, currentCharges = nil, maxCharges = nil, } end cache = { signature = signature, entries = entries } self.demoModeData[trackerKey] = cache end local out = {} for _, e in ipairs(cache.entries) do local total = math.max(1, tonumber(e.total) or 1) local elapsed = math.max(0, now - (e.cycleStart or now)) local phase = math.fmod(elapsed, total) local rem = total - phase -- show zero briefly at cycle boundary, then restart immediately if elapsed > 0 and phase < 0.05 then rem = 0 end out[#out + 1] = { playerName = e.playerName, class = e.class, spellEntry = e.spellEntry, remaining = rem, total = total, currentCharges = e.currentCharges, maxCharges = e.maxCharges, } end return out end function HMGT:GetOwnTestEntries(database, settings, cooldownInfoOpts) local entries = {} local enabledSpells = settings and settings.enabledSpells or {} local playerName = self:NormalizePlayerName(UnitName("player")) or "Player" local classToken = select(2, UnitClass("player")) if not classToken then return entries, playerName end local specIdx = GetSpecialization() local lookupSpec = (specIdx and specIdx > 0) and specIdx or 0 local talents = (self.playerData[playerName] and self.playerData[playerName].talents) or {} local spells = HMGT_SpellData.GetSpellsForSpec(classToken, lookupSpec, database or {}) for _, spellEntry in ipairs(spells) do if enabledSpells[spellEntry.spellId] ~= false then local remaining, total, curCharges, maxCharges = self:GetCooldownInfo(playerName, spellEntry.spellId, cooldownInfoOpts) local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) local isAvailabilitySpell = self:IsAvailabilitySpell(spellEntry) local spellKnown = self:IsTrackedSpellKnownForPlayer(playerName, spellEntry.spellId) local hasPartialCharges = (tonumber(maxCharges) or 0) > 0 and (tonumber(curCharges) or tonumber(maxCharges) or 0) < (tonumber(maxCharges) or 0) local hasActiveCd = ((remaining or 0) > 0) or hasPartialCharges local hasAvailabilityState = isAvailabilitySpell and self:HasAvailabilityState(playerName, spellEntry.spellId) if spellKnown or hasActiveCd or hasAvailabilityState then entries[#entries + 1] = { playerName = playerName, class = classToken, spellEntry = spellEntry, remaining = remaining, total = total > 0 and total or effectiveCd, currentCharges = curCharges, maxCharges = maxCharges, } end end end return entries, playerName end -- ═══════════════════════════════════════════════════════════════ -- HILFSFUNKTIONEN -- ═══════════════════════════════════════════════════════════════ function HMGT:GetClassColor(classToken) local c = self.classColors[classToken] return c and c[1] or 1, c and c[2] or 1, c and c[3] or 1 end function HMGT:GetUnitForPlayer(playerName) local target = self:NormalizePlayerName(playerName) if not target then return nil end local ownName = self:NormalizePlayerName(UnitName("player")) if target == ownName then return "player" end if IsInRaid() then for i = 1, GetNumGroupMembers() do local unitId = "raid" .. i local name = self:NormalizePlayerName(UnitName(unitId)) if name == target then return unitId end end elseif IsInGroup() then for i = 1, GetNumSubgroupMembers() do local unitId = "party" .. i local name = self:NormalizePlayerName(UnitName(unitId)) if name == target then return unitId end end end return nil end function HMGT:IsRaidTimelineEditor(playerName) if playerName and playerName ~= "" then local unitId = self:GetUnitForPlayer(playerName) if not unitId then return false end return UnitIsGroupLeader and UnitIsGroupLeader(unitId) or false end if not IsInGroup() and not IsInRaid() then return true end return UnitIsGroupLeader and UnitIsGroupLeader("player") or false end function HMGT:IsUnitInRangeSafe(unitId) if not unitId or not UnitExists(unitId) then return nil end if UnitIsUnit(unitId, "player") then return true end if UnitIsConnected and not UnitIsConnected(unitId) then return false end if type(UnitInRange) == "function" then local a, b = UnitInRange(unitId) if type(a) == "boolean" and type(b) == "boolean" then if b then return a end elseif type(a) == "boolean" then return a elseif type(a) == "number" then return a == 1 end end if type(CheckInteractDistance) == "function" then local close = CheckInteractDistance(unitId, 4) if type(close) == "boolean" then return close end end return nil end function HMGT:GetPlayerRole(playerName, unitId) local role = nil if unitId and type(UnitGroupRolesAssigned) == "function" then role = UnitGroupRolesAssigned(unitId) end if role and role ~= "" and role ~= "NONE" then return role end local ownName = self:NormalizePlayerName(UnitName("player")) if self:NormalizePlayerName(playerName) == ownName and type(GetSpecializationRole) == "function" then local spec = GetSpecialization() if spec and spec > 0 then local ownRole = GetSpecializationRole(spec) if ownRole and ownRole ~= "NONE" then return ownRole end end end return role end function HMGT:FilterDisplayEntries(settings, entries) if type(entries) ~= "table" then return entries end if type(settings) ~= "table" then return entries end local desiredRole = settings.roleFilter or "ALL" local useRoleFilter = desiredRole ~= "ALL" local useRangeCheck = settings.rangeCheck == true if not useRoleFilter and not useRangeCheck then return entries end local hideOutOfRange = useRangeCheck and settings.hideOutOfRange == true local outOfRangeAlpha = tonumber(settings.outOfRangeAlpha) or 0.4 if outOfRangeAlpha < 0.1 then outOfRangeAlpha = 0.1 end if outOfRangeAlpha > 1 then outOfRangeAlpha = 1 end local filtered = {} local unitCache = {} local roleCache = {} local rangeCache = {} for _, entry in ipairs(entries) do local include = true local key = self:NormalizePlayerName(entry.playerName) or tostring(entry.playerName or "") local unitId = unitCache[key] if unitId == nil and (useRoleFilter or useRangeCheck) then unitId = self:GetUnitForPlayer(entry.playerName) or false unitCache[key] = unitId end if unitId == false then unitId = nil end if useRoleFilter then local role = roleCache[key] if role == nil then role = self:GetPlayerRole(entry.playerName, unitId) or false roleCache[key] = role end if role == false then role = nil end if role and role ~= "NONE" and role ~= desiredRole then include = false end end if include and useRangeCheck then local inRange = rangeCache[key] if inRange == nil then inRange = unitId and self:IsUnitInRangeSafe(unitId) if inRange == nil then inRange = false end rangeCache[key] = inRange end if inRange == false and not unitId then inRange = nil end if inRange == false then if hideOutOfRange then include = false else entry.outOfRange = true entry.outOfRangeAlpha = outOfRangeAlpha end else entry.outOfRange = nil entry.outOfRangeAlpha = nil end else entry.outOfRange = nil entry.outOfRangeAlpha = nil end if include then filtered[#filtered + 1] = entry end end return filtered end function HMGT:FormatTime(seconds) if seconds >= 60 then return string.format("%d:%02d", math.floor(seconds / 60), seconds % 60) end return string.format("%.0f", seconds) end