-- 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.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.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:SuppressRemoteTrackedSpellLogs(playerName, duration) local normalizedName = self:NormalizePlayerName(playerName) if not normalizedName then return end self._suppressTrackedSpellLogUntil = self._suppressTrackedSpellLogUntil or {} self._suppressTrackedSpellLogUntil[normalizedName] = GetTime() + math.max(0, tonumber(duration) or 0) end function HMGT:IsRemoteTrackedSpellLogSuppressed(playerName) local normalizedName = self:NormalizePlayerName(playerName) local suppression = self._suppressTrackedSpellLogUntil local untilTime = suppression and suppression[normalizedName] if not untilTime then return false end if untilTime <= GetTime() then suppression[normalizedName] = nil return false end return true end 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 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) 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 function HMGT:StoreKnownChargeInfo(spellId, maxCharges, chargeDuration) local sid = tonumber(spellId) local maxCount = tonumber(maxCharges) if not sid or sid <= 0 or not maxCount or maxCount <= 1 then return end self.knownChargeInfo = self.knownChargeInfo or {} self.knownChargeInfo[sid] = { maxCharges = math.max(1, math.floor(maxCount + 0.5)), chargeDuration = math.max(0, tonumber(chargeDuration) or 0), updatedAt = GetTime(), } end function HMGT:GetKnownChargeInfo(spellEntry, talents, spellId, fallbackChargeDuration) local sid = tonumber(spellId or (spellEntry and spellEntry.spellId)) if not sid or sid <= 0 then return 0, 0 end local cached = self.knownChargeInfo and self.knownChargeInfo[sid] local cachedMax = tonumber(cached and cached.maxCharges) or 0 local cachedDuration = tonumber(cached and cached.chargeDuration) or 0 local inferredMax, inferredDuration = HMGT_SpellData.GetEffectiveChargeInfo( spellEntry, talents or {}, (cachedMax > 0) and cachedMax or nil, (cachedDuration > 0) and cachedDuration or fallbackChargeDuration ) local maxCharges = math.max(cachedMax, tonumber(inferredMax) or 0) local chargeDuration = math.max( tonumber(inferredDuration) or 0, cachedDuration, tonumber(fallbackChargeDuration) or 0 ) if maxCharges > 1 then self:StoreKnownChargeInfo(sid, maxCharges, chargeDuration) end return maxCharges, chargeDuration 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.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.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() 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") 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 function HMGT:GetOwnAvailabilityProgress(spellEntry) local availability = self:GetAvailabilityConfig(spellEntry) if not availability then return nil, nil end local required = self:GetAvailabilityRequiredCount(spellEntry) if required <= 0 then return nil, nil end local current = 0 if availability.type == "auraStacks" then current = GetPlayerAuraApplications(availability.auraSpellId) if current <= 0 then local fallbackSpellId = tonumber(availability.fallbackSpellCountId) or tonumber(availability.progressSpellId) or tonumber(spellEntry and spellEntry.spellId) if fallbackSpellId and fallbackSpellId > 0 then current = GetSpellCastCountInfo(fallbackSpellId) end end else return nil, nil end current = math.max(0, math.min(required, tonumber(current) or 0)) return current, required end function HMGT:GetAvailabilityState(playerName, spellId) local normalizedName = self:NormalizePlayerName(playerName) local sid = tonumber(spellId) local states = normalizedName and self.availabilityStates[normalizedName] local state = states and sid and states[sid] if not state then return nil, nil end return tonumber(state.current) or 0, tonumber(state.max) or 0 end function HMGT:HasAvailabilityState(playerName, spellId) local _, max = self:GetAvailabilityState(playerName, spellId) return (tonumber(max) or 0) > 0 end function HMGT:StoreAvailabilityState(playerName, spellId, current, max, spellEntry) local normalizedName = self:NormalizePlayerName(playerName) local sid = tonumber(spellId) if not normalizedName or not sid or sid <= 0 then return false end local maxCount = math.max(0, math.floor((tonumber(max) or 0) + 0.5)) if maxCount <= 0 then local states = self.availabilityStates[normalizedName] if states and states[sid] then states[sid] = nil if not next(states) then self.availabilityStates[normalizedName] = nil end return true end return false end local currentCount = math.max(0, math.min(maxCount, math.floor((tonumber(current) or 0) + 0.5))) self.availabilityStates[normalizedName] = self.availabilityStates[normalizedName] or {} local previous = self.availabilityStates[normalizedName][sid] local changed = (not previous) or (tonumber(previous.current) or -1) ~= currentCount or (tonumber(previous.max) or -1) ~= maxCount self.availabilityStates[normalizedName][sid] = { current = currentCount, max = maxCount, spellEntry = spellEntry, updatedAt = GetTime(), } return changed end function HMGT:PruneAvailabilityStates(playerName, knownSpells) local normalizedName = self:NormalizePlayerName(playerName) local states = normalizedName and self.availabilityStates[normalizedName] if not states or type(knownSpells) ~= "table" then return false end local changed = false for sid in pairs(states) do if not knownSpells[tonumber(sid)] then states[sid] = nil changed = true end end if not next(states) then self.availabilityStates[normalizedName] = nil end return changed end function HMGT:BroadcastAvailabilityState(spellId, current, max, target) local sid = tonumber(spellId) local currentCount = math.max(0, math.floor((tonumber(current) or 0) + 0.5)) local maxCount = math.max(0, math.floor((tonumber(max) or 0) + 0.5)) if not sid or sid <= 0 or maxCount <= 0 then return end local payload = string.format("%s|%d|%d|%d|%s|%d", MSG_SPELL_STATE, sid, currentCount, maxCount, ADDON_VERSION, PROTOCOL_VERSION ) if target and target ~= "" then self:SendDirectMessage(payload, target, "ALERT") else self:SendGroupMessage(payload, "ALERT") end end function HMGT:RefreshOwnAvailabilitySpell(spellEntry) if not self:IsAvailabilitySpell(spellEntry) then return false end local playerName = self:NormalizePlayerName(UnitName("player")) if not playerName then return false end local current, max = self:GetOwnAvailabilityProgress(spellEntry) if (tonumber(max) or 0) > 0 then local pData = self.playerData[playerName] if pData and type(pData.knownSpells) == "table" then pData.knownSpells[tonumber(spellEntry.spellId)] = true end end return self:StoreAvailabilityState(playerName, spellEntry.spellId, current, max, spellEntry) end function HMGT:RefreshOwnAvailabilityStates() local playerName = self:NormalizePlayerName(UnitName("player")) local pData = playerName and self.playerData[playerName] if not pData or not pData.class or not pData.specIndex then return false end local changed = false local groupCooldowns = HMGT_SpellData.GetSpellsForSpec(pData.class, pData.specIndex, HMGT_SpellData.GroupCooldowns) for _, spellEntry in ipairs(groupCooldowns or {}) do if self:IsAvailabilitySpell(spellEntry) and self:RefreshOwnAvailabilitySpell(spellEntry) then changed = true end end if self:PruneAvailabilityStates(playerName, pData.knownSpells or {}) then changed = true end return changed end function HMGT:RefreshAndPublishOwnAvailabilityStates() local playerName = self:NormalizePlayerName(UnitName("player")) local pData = playerName and self.playerData[playerName] if not pData or not pData.class or not pData.specIndex then return false end local changed = false local groupCooldowns = HMGT_SpellData.GetSpellsForSpec(pData.class, pData.specIndex, HMGT_SpellData.GroupCooldowns) for _, spellEntry in ipairs(groupCooldowns or {}) do if self:IsAvailabilitySpell(spellEntry) and self:RefreshOwnAvailabilitySpell(spellEntry) then self:PublishOwnSpellState(spellEntry.spellId, { sendLegacy = true }) changed = true end end if self:PruneAvailabilityStates(playerName, pData.knownSpells or {}) then changed = true end return changed end function HMGT:SendOwnAvailabilityStates(target) local playerName = self:NormalizePlayerName(UnitName("player")) local pData = playerName and self.playerData[playerName] if not pData or not pData.class or not pData.specIndex then return 0 end self:RefreshOwnAvailabilityStates() local sent = 0 local groupCooldowns = HMGT_SpellData.GetSpellsForSpec(pData.class, pData.specIndex, HMGT_SpellData.GroupCooldowns) for _, spellEntry in ipairs(groupCooldowns or {}) do if self:IsAvailabilitySpell(spellEntry) and self:IsTrackedSpellKnownForPlayer(playerName, spellEntry.spellId) then local current, max = self:GetAvailabilityState(playerName, spellEntry.spellId) if (tonumber(max) or 0) > 0 then self:BroadcastAvailabilityState(spellEntry.spellId, current, max, target) sent = sent + 1 end end end return sent end function HMGT:GetLocalSpellStateRevision(spellId) local sid = tonumber(spellId) if not sid or sid <= 0 then return 0 end return tonumber(self.localSpellStateRevisions[sid]) or 0 end function HMGT:EnsureLocalSpellStateRevision(spellId) local sid = tonumber(spellId) if not sid or sid <= 0 then return 0 end local current = tonumber(self.localSpellStateRevisions[sid]) or 0 if current <= 0 then current = 1 self.localSpellStateRevisions[sid] = current end return current end function HMGT:NextLocalSpellStateRevision(spellId) local sid = tonumber(spellId) if not sid or sid <= 0 then return 0 end local nextRevision = (tonumber(self.localSpellStateRevisions[sid]) or 0) + 1 self.localSpellStateRevisions[sid] = nextRevision return nextRevision end function HMGT:GetRemoteSpellStateRevision(playerName, spellId) local normalizedName = self:NormalizePlayerName(playerName) local sid = tonumber(spellId) local bySpell = normalizedName and self.remoteSpellStateRevisions[normalizedName] return tonumber(bySpell and bySpell[sid]) or 0 end function HMGT:SetRemoteSpellStateRevision(playerName, spellId, revision) local normalizedName = self:NormalizePlayerName(playerName) local sid = tonumber(spellId) local rev = tonumber(revision) or 0 if not normalizedName or not sid or sid <= 0 or rev <= 0 then return end self.remoteSpellStateRevisions[normalizedName] = self.remoteSpellStateRevisions[normalizedName] or {} self.remoteSpellStateRevisions[normalizedName][sid] = rev end function HMGT:BuildClearSpellStateSnapshot(spellId, spellEntry) return { spellId = tonumber(spellId), spellEntry = spellEntry, kind = "clear", a = 0, b = 0, c = 0, d = 0, } end function HMGT:GetOwnSpellStateSnapshot(spellId) local sid = tonumber(spellId) if not sid or sid <= 0 then return nil end local spellEntry = HMGT_SpellData.InterruptLookup[sid] or HMGT_SpellData.CooldownLookup[sid] if not spellEntry then return nil end if self:IsAvailabilitySpell(spellEntry) then local current, max = self:GetOwnAvailabilityProgress(spellEntry) if (tonumber(max) or 0) > 0 then self:StoreAvailabilityState(self:NormalizePlayerName(UnitName("player")), sid, current, max, spellEntry) return { spellId = sid, spellEntry = spellEntry, kind = "availability", a = tonumber(current) or 0, b = tonumber(max) or 0, c = 0, d = 0, } end return self:BuildClearSpellStateSnapshot(sid, spellEntry) end local ownName = self:NormalizePlayerName(UnitName("player")) local pData = ownName and self.playerData and self.playerData[ownName] local talents = pData and pData.talents or {} local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) local knownMaxCharges, knownChargeDuration = self:GetKnownChargeInfo(spellEntry, talents, sid, effectiveCd) local cdData = ownName and self.activeCDs[ownName] and self.activeCDs[ownName][sid] if cdData then if (tonumber(cdData.maxCharges) or 0) > 0 then local nextRemaining, chargeDuration, charges, maxCharges = self:ResolveChargeState(cdData) self:StoreKnownChargeInfo(sid, maxCharges, chargeDuration) if (tonumber(maxCharges) or 0) > 0 and (tonumber(charges) or 0) < (tonumber(maxCharges) or 0) then return { spellId = sid, spellEntry = spellEntry, kind = "charges", a = tonumber(charges) or 0, b = tonumber(maxCharges) or 0, c = tonumber(nextRemaining) or 0, d = tonumber(chargeDuration) or 0, } end elseif knownMaxCharges > 1 then local duration = tonumber(cdData.duration) or 0 local startTime = tonumber(cdData.startTime) or GetTime() local remaining = math.max(0, duration - (GetTime() - startTime)) local currentCharges = knownMaxCharges if remaining > 0 then currentCharges = math.max(0, knownMaxCharges - 1) return { spellId = sid, spellEntry = spellEntry, kind = "charges", a = tonumber(currentCharges) or 0, b = tonumber(knownMaxCharges) or 0, c = tonumber(remaining) or 0, d = tonumber(knownChargeDuration) or tonumber(effectiveCd) or duration, } end else local duration = tonumber(cdData.duration) or 0 local startTime = tonumber(cdData.startTime) or GetTime() local remaining = math.max(0, duration - (GetTime() - startTime)) if duration > 0 and remaining > 0 then return { spellId = sid, spellEntry = spellEntry, kind = "cooldown", a = remaining, b = duration, c = 0, d = 0, } end end end if InCombatLockdown and InCombatLockdown() then if knownMaxCharges > 1 then return { spellId = sid, spellEntry = spellEntry, kind = "charges", a = tonumber(knownMaxCharges) or 0, b = tonumber(knownMaxCharges) or 0, c = 0, d = tonumber(knownChargeDuration) or tonumber(effectiveCd) or 0, } end return self:BuildClearSpellStateSnapshot(sid, spellEntry) end local remaining, total, currentCharges, maxCharges = self:GetCooldownInfo(ownName, sid) if (tonumber(maxCharges) or 0) > 0 then local cur = math.max(0, math.floor((tonumber(currentCharges) or 0) + 0.5)) local max = math.max(0, math.floor((tonumber(maxCharges) or 0) + 0.5)) local nextRemaining = math.max(0, tonumber(remaining) or 0) local chargeDuration = math.max(0, tonumber(total) or 0) if max <= 0 or cur >= max then return self:BuildClearSpellStateSnapshot(sid, spellEntry) end return { spellId = sid, spellEntry = spellEntry, kind = "charges", a = cur, b = max, c = nextRemaining, d = chargeDuration, } end local duration = math.max(0, tonumber(total) or 0) local cooldownRemaining = math.max(0, tonumber(remaining) or 0) if duration <= 0 or cooldownRemaining <= 0 then return self:BuildClearSpellStateSnapshot(sid, spellEntry) end return { spellId = sid, spellEntry = spellEntry, kind = "cooldown", a = cooldownRemaining, b = duration, c = 0, d = 0, } end function HMGT:SendSpellStateSnapshot(snapshot, target, revision) if type(snapshot) ~= "table" then return false end local sid = tonumber(snapshot.spellId) local kind = tostring(snapshot.kind or "") local rev = tonumber(revision) or 0 if not sid or sid <= 0 or kind == "" or rev <= 0 then return false end self:DebugScoped( "verbose", "TrackedSpells", "SendSpellStateSnapshot target=%s spell=%s kind=%s rev=%d a=%.3f b=%.3f c=%.3f d=%.3f", tostring(target and target ~= "" and target or "GROUP"), GetSpellDebugLabel(sid), tostring(kind), rev, tonumber(snapshot.a) or 0, tonumber(snapshot.b) or 0, tonumber(snapshot.c) or 0, tonumber(snapshot.d) or 0 ) local payload = string.format( "%s|%d|%s|%d|%.3f|%.3f|%.3f|%.3f|%s|%d", MSG_SPELL_STATE, sid, kind, rev, tonumber(snapshot.a) or 0, tonumber(snapshot.b) or 0, tonumber(snapshot.c) or 0, tonumber(snapshot.d) or 0, ADDON_VERSION, PROTOCOL_VERSION ) if target and target ~= "" then self:SendDirectMessage(payload, target, "ALERT") else self:SendGroupMessage(payload, "ALERT") end return true end function HMGT:PublishOwnSpellState(spellId, opts) opts = opts or {} local sid = tonumber(spellId) if not sid or sid <= 0 then return false end local snapshot = opts.snapshot or self:GetOwnSpellStateSnapshot(sid) if not snapshot then return false end local revision = tonumber(opts.revision) or self:NextLocalSpellStateRevision(sid) local sent = self:SendSpellStateSnapshot(snapshot, opts.target, revision) if not sent then return false end if opts.sendLegacy then if snapshot.kind == "availability" then self:BroadcastAvailabilityState(sid, snapshot.a, snapshot.b, opts.target) elseif snapshot.kind ~= "clear" then self:BroadcastSpellCast(sid, snapshot) end end return true end function HMGT:SendOwnTrackedSpellStates(target) local ownName = self:NormalizePlayerName(UnitName("player")) if not ownName then return 0 end self:RefreshOwnAvailabilityStates() local sent = 0 local sentBySpell = {} local activeStates = self.activeCDs[ownName] if type(activeStates) == "table" then for sid in pairs(activeStates) do sid = tonumber(sid) if sid and sid > 0 and not sentBySpell[sid] then local revision = self:EnsureLocalSpellStateRevision(sid) if revision > 0 and self:SendSpellStateSnapshot(self:GetOwnSpellStateSnapshot(sid), target, revision) then sent = sent + 1 sentBySpell[sid] = true end end end end local availabilityStates = self.availabilityStates[ownName] if type(availabilityStates) == "table" then for sid in pairs(availabilityStates) do sid = tonumber(sid) if sid and sid > 0 and not sentBySpell[sid] then local revision = self:EnsureLocalSpellStateRevision(sid) if revision > 0 and self:SendSpellStateSnapshot(self:GetOwnSpellStateSnapshot(sid), target, revision) then sent = sent + 1 sentBySpell[sid] = true end end end end return sent end function HMGT:BroadcastRepairSpellStates() if not self:IsEnabled() then return end local sent = self:SendOwnTrackedSpellStates() if sent > 0 then self:DebugScoped("verbose", "TrackedSpells", "RepairSpellStates sent=%d", sent) end end function HMGT:ReconcileOwnTrackedSpellStatesFromGame(publishChanges) if InCombatLockdown and InCombatLockdown() then return 0 end local ownName = self:NormalizePlayerName(UnitName("player")) local pData = ownName and self.playerData and self.playerData[ownName] if not ownName or not pData or not pData.class or not pData.specIndex then return 0 end pData.knownSpells = self:CollectOwnAvailableTrackerSpells(pData.class, pData.specIndex) local changed = 0 for sid in pairs(pData.knownSpells or {}) do local spellEntry = HMGT_SpellData.InterruptLookup[sid] or HMGT_SpellData.CooldownLookup[sid] if spellEntry and not self:IsAvailabilitySpell(spellEntry) then if self:RefreshOwnCooldownStateFromGame(sid) then changed = changed + 1 if publishChanges then self:PublishOwnSpellState(sid, { sendLegacy = true }) end end end end if changed > 0 then self:TriggerTrackerUpdate() end return changed end function HMGT:CollectOwnAvailableTrackerSpells(classToken, specIndex) local class = classToken or select(2, UnitClass("player")) local spec = tonumber(specIndex) or tonumber(GetSpecialization()) if not class or not spec or spec <= 0 then return {} end if not HMGT_SpellData or type(HMGT_SpellData.GetSpellsForSpec) ~= "function" then return {} end local knownSpells = {} for _, datasetName in ipairs({ "Interrupts", "RaidCooldowns", "GroupCooldowns" }) do local dataset = HMGT_SpellData[datasetName] if type(dataset) == "table" then local spells = HMGT_SpellData.GetSpellsForSpec(class, spec, dataset) for _, entry in ipairs(spells) do local sid = tonumber(entry.spellId) if sid and sid > 0 and IsSpellKnownLocally(sid) then knownSpells[sid] = true end end end end local ownName = self:NormalizePlayerName(UnitName("player")) local ownCDs = ownName and self.activeCDs[ownName] if ownCDs then for sid in pairs(ownCDs) do sid = tonumber(sid) if sid and sid > 0 then knownSpells[sid] = true end end end return knownSpells end function HMGT:IsTrackedSpellKnownForPlayer(playerName, spellId) local sid = tonumber(spellId) if not sid or sid <= 0 then return false end local normalizedName = self:NormalizePlayerName(playerName) local ownName = self:NormalizePlayerName(UnitName("player")) local pData = normalizedName and self.playerData[normalizedName] if pData and type(pData.knownSpells) == "table" and pData.knownSpells[sid] == true then return true end if normalizedName and ownName and normalizedName == ownName then return IsSpellKnownLocally(sid) end return false 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 function HMGT:SendHello(target) local name = self:NormalizePlayerName(UnitName("player")) local pData = self.playerData[name] if not pData or not pData.class or not pData.specIndex then return end pData.knownSpells = self:CollectOwnAvailableTrackerSpells(pData.class, pData.specIndex) self:RefreshOwnAvailabilityStates() local knownSpellList = self:SerializeKnownSpellList(pData.knownSpells) local knownCount = 0 for _ in pairs(pData.knownSpells or {}) do knownCount = knownCount + 1 end local payload = string.format("%s|%s|%d|%s|%d|%s|%s", MSG_HELLO, ADDON_VERSION, PROTOCOL_VERSION, pData.class, pData.specIndex, pData.talentHash or "", knownSpellList ) if target and target ~= "" then self:DebugScoped("verbose", "Comm", "SendHello whisper target=%s class=%s spec=%s spells=%d", tostring(target), tostring(pData.class), tostring(pData.specIndex), knownCount) self:SendDirectMessage(payload, target) self:SendOwnTrackedSpellStates(target) self:SendOwnAvailabilityStates(target) return end self:DebugScoped("verbose", "Comm", "SendHello group class=%s spec=%s spells=%d", tostring(pData.class), tostring(pData.specIndex), knownCount) self:SendGroupMessage(payload) self:SendOwnTrackedSpellStates() self:SendOwnAvailabilityStates() end function HMGT:BroadcastSpellCast(spellId, snapshot) local cur, max, chargeRemaining, chargeDuration = 0, 0, 0, 0 if type(snapshot) == "table" and tostring(snapshot.kind) == "charges" then cur = math.max(0, math.floor((tonumber(snapshot.a) or 0) + 0.5)) max = math.max(0, math.floor((tonumber(snapshot.b) or 0) + 0.5)) chargeRemaining = math.max(0, tonumber(snapshot.c) or 0) chargeDuration = math.max(0, tonumber(snapshot.d) or 0) elseif not (InCombatLockdown and InCombatLockdown()) then local c, m, cs, cd = GetSpellChargesInfo(spellId) cur = tonumber(c) or 0 max = tonumber(m) or 0 chargeDuration = tonumber(cd) or 0 if max > 0 and cur < max and cs and chargeDuration > 0 then chargeRemaining = math.max(0, chargeDuration - (GetTime() - cs)) end else local ownName = self:NormalizePlayerName(UnitName("player")) local remaining, total, currentCharges, maxCharges = self:GetCooldownInfo(ownName, spellId, { deferChargeCooldownUntilEmpty = false, }) cur = math.max(0, math.floor((tonumber(currentCharges) or 0) + 0.5)) max = math.max(0, math.floor((tonumber(maxCharges) or 0) + 0.5)) chargeRemaining = math.max(0, tonumber(remaining) or 0) chargeDuration = math.max(0, tonumber(total) or 0) end self:DebugScoped("verbose", "TrackedSpells", "BroadcastSpellCast spell=%s serverTime=%s charges=%d/%d", GetSpellDebugLabel(spellId), tostring(GetServerTime()), cur, max) self:SendGroupMessage(string.format("%s|%d|%d|%d|%d|%.3f|%.3f|%s|%d", MSG_SPELL_CAST, spellId, GetServerTime(), cur, max, chargeRemaining, chargeDuration, ADDON_VERSION, PROTOCOL_VERSION)) end function HMGT:BroadcastCooldownReduce(targetSpellId, amount, castTimestamp, triggerSpellId) local sid = tonumber(targetSpellId) local value = tonumber(amount) or 0 if not sid or sid <= 0 or value <= 0 then return end local ts = tonumber(castTimestamp) or GetServerTime() local triggerId = tonumber(triggerSpellId) or 0 self:Debug( "verbose", "BroadcastCooldownReduce target=%s amount=%.2f ts=%s trigger=%s", tostring(sid), value, tostring(ts), tostring(triggerId) ) self:SendGroupMessage(string.format( "%s|%d|%.3f|%d|%d|%s|%d", MSG_CD_REDUCE, sid, value, ts, triggerId, ADDON_VERSION, PROTOCOL_VERSION )) end function HMGT:RequestSync(reason) self:DebugScoped("info", "Comm", "RequestSync(%s)", tostring(reason or "Hello")) self:SendHello() end function HMGT:QueueSyncRequest(delay, reason) local wait = tonumber(delay) or 0.2 if wait < 0 then wait = 0 end if self._syncRequestTimer then return end self._syncRequestTimer = self:ScheduleTimer(function() self._syncRequestTimer = nil self:RequestSync(reason or "Hello") end, wait) end function HMGT:QueueDeltaSyncBurst(reason, delays) if not (IsInGroup() or IsInRaid()) then return end local now = GetTime() local normalizedReason = tostring(reason or "delta") self._deltaSyncBurstAt = self._deltaSyncBurstAt or {} if (tonumber(self._deltaSyncBurstAt[normalizedReason]) or 0) > now - 2.5 then return end self._deltaSyncBurstAt[normalizedReason] = now delays = type(delays) == "table" and delays or { 0.35, 1.25, 2.75 } self._syncBurstTimers = self._syncBurstTimers or {} for _, wait in ipairs(delays) do local delay = math.max(0, tonumber(wait) or 0) local timerHandle timerHandle = self:ScheduleTimer(function() if self._syncBurstTimers then for index, handle in ipairs(self._syncBurstTimers) do if handle == timerHandle then table.remove(self._syncBurstTimers, index) break end end end self:RequestSync(normalizedReason) end, delay) self._syncBurstTimers[#self._syncBurstTimers + 1] = timerHandle end self:DebugScoped("info", "Comm", "QueueDeltaSyncBurst reason=%s count=%d", normalizedReason, #delays) end function HMGT:SendSyncResponse(target) local name = self:NormalizePlayerName(UnitName("player")) local pData = self.playerData[name] if not pData then return end pData.knownSpells = self:CollectOwnAvailableTrackerSpells(pData.class, pData.specIndex) self:RefreshOwnAvailabilityStates() local knownSpellList = self:SerializeKnownSpellList(pData.knownSpells) local cdList = {} local now = GetTime() if self.activeCDs[name] then for spellId, cdInfo in pairs(self.activeCDs[name]) do if (tonumber(cdInfo.maxCharges) or 0) > 0 then self:ResolveChargeState(cdInfo, now) end local remaining = cdInfo.duration - (now - cdInfo.startTime) remaining = math.max(0, math.min(cdInfo.duration, remaining)) if remaining > 0 then table.insert(cdList, string.format("%d:%.3f:%.3f:%d:%d", spellId, remaining, cdInfo.duration, cdInfo.currentCharges or 0, cdInfo.maxCharges or 0)) end end end self:SendDirectMessage( string.format("%s|%s|%d|%s|%d|%s|%s|%s", MSG_SYNC_RESPONSE, ADDON_VERSION, PROTOCOL_VERSION, pData.class, pData.specIndex, pData.talentHash or "", knownSpellList, table.concat(cdList, ";")), target) local stateCount = self:SendOwnTrackedSpellStates(target) local availabilityCount = self:SendOwnAvailabilityStates(target) self:DebugScoped("verbose", "Comm", "SendSyncResponse target=%s entries=%d state=%d availability=%d", tostring(target), #cdList, stateCount, availabilityCount) end function HMGT:StoreRemotePlayerInfo(playerName, class, specIndex, talentHash, knownSpellList) if not playerName or not class then return end local previous = self.playerData[playerName] local knownSpells = previous and previous.knownSpells if knownSpellList ~= nil then knownSpells = self:ParseKnownSpellList(knownSpellList) end self.playerData[playerName] = { class = class, specIndex = tonumber(specIndex), talentHash = talentHash, talents = self:ParseTalentHash(talentHash), knownSpells = knownSpells, } if type(knownSpells) == "table" then self:PruneAvailabilityStates(playerName, knownSpells) end local knownCount = 0 if type(knownSpells) == "table" then for _ in pairs(knownSpells) do knownCount = knownCount + 1 end end self:DebugScoped( "info", "TrackedSpells", "Spielerinfo von %s: class=%s spec=%s bekannteSpells=%d", tostring(playerName), tostring(class), tostring(specIndex), knownCount ) end function HMGT:ClearRemoteSpellState(playerName, spellId) local normalizedName = self:NormalizePlayerName(playerName) local sid = tonumber(spellId) if not normalizedName or not sid or sid <= 0 then return false end local changed = false local playerCooldowns = self.activeCDs[normalizedName] if playerCooldowns and playerCooldowns[sid] then playerCooldowns[sid] = nil if not next(playerCooldowns) then self.activeCDs[normalizedName] = nil end changed = true end local playerAvailability = self.availabilityStates[normalizedName] if playerAvailability and playerAvailability[sid] then playerAvailability[sid] = nil if not next(playerAvailability) then self.availabilityStates[normalizedName] = nil end changed = true end return changed end function HMGT:ApplyRemoteSpellState(playerName, spellId, kind, revision, a, b, c, d) local normalizedName = self:NormalizePlayerName(playerName) local sid = tonumber(spellId) local rev = tonumber(revision) or 0 if not normalizedName or not sid or sid <= 0 or rev <= 0 then return false end if not self:IsPlayerInCurrentGroup(normalizedName) then return false end local currentRevision = self:GetRemoteSpellStateRevision(normalizedName, sid) if currentRevision >= rev then return false end local spellEntry = HMGT_SpellData.CooldownLookup[sid] or HMGT_SpellData.InterruptLookup[sid] if not spellEntry then return false end local now = GetTime() local stateKind = tostring(kind or "") local changed = false local shouldLogCast = false local logDetails = nil local previousEntry = self.activeCDs[normalizedName] and self.activeCDs[normalizedName][sid] local isSuppressed = self:IsRemoteTrackedSpellLogSuppressed(normalizedName) if stateKind == "clear" then changed = self:ClearRemoteSpellState(normalizedName, sid) elseif stateKind == "availability" then changed = self:StoreAvailabilityState(normalizedName, sid, tonumber(a) or 0, tonumber(b) or 0, spellEntry) local playerCooldowns = self.activeCDs[normalizedName] if playerCooldowns and playerCooldowns[sid] then playerCooldowns[sid] = nil if not next(playerCooldowns) then self.activeCDs[normalizedName] = nil end changed = true end elseif stateKind == "cooldown" then local duration = math.max(0, tonumber(b) or 0) local remaining = math.max(0, math.min(duration, tonumber(a) or 0)) if duration <= 0 or remaining <= 0 then changed = self:ClearRemoteSpellState(normalizedName, sid) else local previousRemaining = 0 if previousEntry then previousRemaining = math.max( 0, (tonumber(previousEntry.duration) or 0) - (now - (tonumber(previousEntry.startTime) or now)) ) end self.activeCDs[normalizedName] = self.activeCDs[normalizedName] or {} self.activeCDs[normalizedName][sid] = { startTime = now - (duration - remaining), duration = duration, spellEntry = spellEntry, _stateRevision = rev, _stateKind = stateKind, } changed = true shouldLogCast = (not isSuppressed) and previousRemaining <= 0.05 if shouldLogCast then logDetails = { cooldown = duration, } end end elseif stateKind == "charges" then local maxCharges = math.max(0, math.floor((tonumber(b) or 0) + 0.5)) local currentCharges = math.max(0, math.min(maxCharges, math.floor((tonumber(a) or 0) + 0.5))) local nextRemaining = math.max(0, tonumber(c) or 0) local chargeDuration = math.max(0, tonumber(d) or 0) if maxCharges <= 0 or currentCharges >= maxCharges then changed = self:ClearRemoteSpellState(normalizedName, sid) else local previousCharges = nil if previousEntry and (tonumber(previousEntry.maxCharges) or 0) > 0 then self:ResolveChargeState(previousEntry, now) previousCharges = tonumber(previousEntry.currentCharges) end local chargeStart = nil local duration = 0 local startTime = now if chargeDuration > 0 then nextRemaining = math.min(chargeDuration, nextRemaining) chargeStart = now - math.max(0, chargeDuration - nextRemaining) duration = (maxCharges - currentCharges) * chargeDuration startTime = chargeStart end self.activeCDs[normalizedName] = self.activeCDs[normalizedName] or {} self.activeCDs[normalizedName][sid] = { startTime = startTime, duration = duration, spellEntry = spellEntry, currentCharges = currentCharges, maxCharges = maxCharges, chargeStart = chargeStart, chargeDuration = chargeDuration, _stateRevision = rev, _stateKind = stateKind, } changed = true shouldLogCast = (not isSuppressed) and ( (previousCharges ~= nil and currentCharges < previousCharges) or (previousCharges == nil) ) if shouldLogCast then logDetails = { cooldown = chargeDuration, currentCharges = currentCharges, maxCharges = maxCharges, chargeCooldown = chargeDuration, } end end else return false end self:SetRemoteSpellStateRevision(normalizedName, sid, rev) if changed then self:DebugScoped( "info", "TrackedSpells", "Sync von %s: %s -> %s (rev=%d)", tostring(normalizedName), GetSpellDebugLabel(sid), tostring(stateKind), rev ) end if changed and shouldLogCast and logDetails then self:LogTrackedSpellCast(normalizedName, spellEntry, logDetails) end return changed end function HMGT:OnCommReceived(prefix, message, distribution, sender) if prefix ~= COMM_PREFIX then return end local senderName = self:NormalizePlayerName(sender) if senderName == self:NormalizePlayerName(UnitName("player")) then return end local msgType = message:match("^(%a+)") self:DebugScoped("verbose", "Comm", "OnCommReceived type=%s from=%s dist=%s", tostring(msgType), tostring(senderName), tostring(distribution)) if msgType == MSG_ACK then local messageId = message:match("^%a+|(.+)$") if messageId then self:HandleReliableAck(senderName, messageId) end return elseif msgType == MSG_RELIABLE then local messageId, innerPayload = message:match("^%a+|([^|]+)|(.+)$") if not messageId or not innerPayload then return end local dedupeKey = string.format("%s|%s", tostring(senderName or ""), tostring(messageId)) self.receivedReliableMessages = self.receivedReliableMessages or {} self:SendReliableAck(sender, messageId) if self.receivedReliableMessages[dedupeKey] then self:DebugScoped("verbose", "Comm", "Reliable duplicate sender=%s id=%s", tostring(senderName), tostring(messageId)) return end self.receivedReliableMessages[dedupeKey] = GetTime() + 30 message = innerPayload msgType = message:match("^(%a+)") self:DebugScoped("verbose", "Comm", "Reliable recv sender=%s id=%s inner=%s", tostring(senderName), tostring(messageId), tostring(msgType)) end if msgType == MSG_SPELL_CAST then local spellId, timestamp, cur, max, chargeRemaining, chargeDuration, version, protocol = message:match("^%a+|(%d+)|([%d%.]+)|(%d+)|(%d+)|([%d%.]+)|([%d%.]+)|([^|]+)|(%d+)$") if not spellId then spellId, timestamp, version = message:match("^%a+|(%d+)|([%d%.]+)|(.+)$") if not spellId then spellId, timestamp = message:match("^%a+|(%d+)|([%d%.]+)$") end end if spellId then self:RegisterPeerVersion(senderName, version, protocol, "SC") self:RememberPeerProtocolVersion(senderName, protocol) if (tonumber(protocol) or 0) >= 5 then return end self:DebugScoped("verbose", "TrackedSpells", "Legacy cast von %s: %s ts=%s", tostring(senderName), GetSpellDebugLabel(spellId), tostring(timestamp)) self:HandleRemoteSpellCast( senderName, tonumber(spellId), tonumber(timestamp), tonumber(cur) or 0, tonumber(max) or 0, tonumber(chargeRemaining) or 0, tonumber(chargeDuration) or 0 ) end elseif msgType == MSG_CD_REDUCE then local targetSpellId, amount, timestamp, triggerSpellId, version, protocol = message:match("^%a+|(%d+)|([%d%.]+)|([%d%.]+)|(%d+)|([^|]+)|(%d+)$") if not targetSpellId then targetSpellId, amount, timestamp, triggerSpellId = message:match("^%a+|(%d+)|([%d%.]+)|([%d%.]+)|(%d+)$") end if targetSpellId then self:RegisterPeerVersion(senderName, version, protocol, "CR") self:RememberPeerProtocolVersion(senderName, protocol) if (tonumber(protocol) or 0) >= 5 then return end self:HandleRemoteCooldownReduce( senderName, tonumber(targetSpellId), tonumber(amount) or 0, tonumber(timestamp), tonumber(triggerSpellId) or 0 ) end elseif msgType == MSG_SPELL_STATE then local spellId, stateKind, revision, a, b, c, d, version, protocol = message:match("^%a+|(%d+)|(%a+)|(%d+)|([%d%.%-]+)|([%d%.%-]+)|([%d%.%-]+)|([%d%.%-]+)|([^|]+)|(%d+)$") if spellId then self:RegisterPeerVersion(senderName, version, protocol, "STA") self:RememberPeerProtocolVersion(senderName, protocol) if self:ApplyRemoteSpellState(senderName, spellId, stateKind, revision, a, b, c, d) then self:TriggerTrackerUpdate() end else local current, max spellId, current, max, version, protocol = message:match("^%a+|(%d+)|(%d+)|(%d+)|([^|]+)|(%d+)$") if not spellId then spellId, current, max = message:match("^%a+|(%d+)|(%d+)|(%d+)$") end if spellId then self:RegisterPeerVersion(senderName, version, protocol, "STA") self:RememberPeerProtocolVersion(senderName, protocol) if (tonumber(protocol) or 0) >= 5 then return end local sid = tonumber(spellId) local spellEntry = HMGT_SpellData.CooldownLookup[sid] or HMGT_SpellData.InterruptLookup[sid] if self:IsAvailabilitySpell(spellEntry) then if self:StoreAvailabilityState(senderName, sid, tonumber(current) or 0, tonumber(max) or 0, spellEntry) then self:TriggerTrackerUpdate() end end end end elseif msgType == MSG_HELLO then local version, protocol, class, specIndex, talentHash, knownSpellList = message:match("^%a+|([^|]+)|(%d+)|(%u+)|(%d+)|(.-)|(.-)$") if class then self:RegisterPeerVersion(senderName, version, protocol, "HEL") self:RememberPeerProtocolVersion(senderName, protocol) self.remoteSpellStateRevisions[senderName] = nil self:StoreRemotePlayerInfo(senderName, class, specIndex, talentHash, knownSpellList) self:DebugScoped("info", "TrackedSpells", "Hello von %s: class=%s spec=%s spells=%s", tostring(senderName), tostring(class), tostring(specIndex), tostring(knownSpellList or "")) self:SendSyncResponse(sender) self:TriggerTrackerUpdate() end elseif msgType == MSG_PLAYER_INFO then local class, specIndex, talentHash, version, protocol = message:match("^%a+|(%u+)|(%d+)|(.-)|([^|]+)|(%d+)$") if not class then class, specIndex, talentHash = message:match("^%a+|(%u+)|(%d+)|(.*)") end if class then self:RegisterPeerVersion(senderName, version, protocol, "PI") self:RememberPeerProtocolVersion(senderName, protocol) self:StoreRemotePlayerInfo(senderName, class, specIndex, talentHash, nil) self:TriggerTrackerUpdate() end elseif msgType == MSG_SYNC_REQUEST then local version, protocol, class, specIndex, talentHash, knownSpellList = message:match("^%a+|([^|]+)|(%d+)|(%u+)|(%d+)|(.-)|(.-)$") if class then self:StoreRemotePlayerInfo(senderName, class, specIndex, talentHash, knownSpellList) end if not version then version, protocol = message:match("^%a+|([^|]+)|(%d+)$") end if not version then version = message:match("^%a+|(.+)$") end self:RegisterPeerVersion(senderName, version, protocol, "SRQ") self:RememberPeerProtocolVersion(senderName, protocol) self:DebugScoped("info", "Comm", "SyncRequest von %s", tostring(senderName)) self:SendSyncResponse(sender) self:TriggerTrackerUpdate() elseif msgType == MSG_SYNC_RESPONSE then local version, protocol, class, specIndex, talentHash, knownSpellList, cdListStr = message:match("^%a+|([^|]+)|(%d+)|(%u+)|(%d+)|(.-)|(.-)|(.-)$") if not class then version, protocol, class, specIndex, talentHash, cdListStr = message:match("^%a+|([^|]+)|(%d+)|(%u+)|(%d+)|(.-)|(.-)$") end if not class then class, specIndex, talentHash, cdListStr = message:match("^%a+|(%u+)|(%d+)|(.-)|(.-)$") end if class then self:RegisterPeerVersion(senderName, version, protocol, "SRS") self:RememberPeerProtocolVersion(senderName, protocol) self:SuppressRemoteTrackedSpellLogs(senderName, 1.5) self:StoreRemotePlayerInfo(senderName, class, specIndex, talentHash, knownSpellList) if cdListStr and cdListStr ~= "" then self.activeCDs[senderName] = self.activeCDs[senderName] or {} local knownTalents = self.playerData[senderName] and self.playerData[senderName].talents or {} local applied = 0 for entry in cdListStr:gmatch("([^;]+)") do local sid, rem, dur, cur, max = entry:match("(%d+):([%d%.]+):([%d%.]+):(%d+):(%d+)") if not sid then sid, rem, dur = entry:match("(%d+):([%d%.]+):([%d%.]+)") end if sid then sid, rem, dur = tonumber(sid), tonumber(rem), tonumber(dur) rem = math.max(0, math.min(dur, rem)) local remaining = rem if remaining > 0 then local spellEntry = HMGT_SpellData.CooldownLookup[sid] or HMGT_SpellData.InterruptLookup[sid] if spellEntry then local localStartTime = GetTime() - (dur - remaining) local curCharges = tonumber(cur) or 0 local maxChargeCount = tonumber(max) or 0 local chargeStart = nil local chargeDur = nil if maxChargeCount > 0 then curCharges = math.max(0, math.min(maxChargeCount, curCharges)) local missing = maxChargeCount - curCharges if missing > 0 and dur > 0 then chargeDur = dur / missing chargeStart = localStartTime end else local inferredMax, inferredDur = HMGT_SpellData.GetEffectiveChargeInfo( spellEntry, knownTalents, nil, HMGT_SpellData.GetEffectiveCooldown(spellEntry, knownTalents) ) if (tonumber(inferredMax) or 0) > 1 then maxChargeCount = inferredMax curCharges = math.max(0, inferredMax - 1) chargeDur = inferredDur chargeStart = localStartTime end end self.activeCDs[senderName][sid] = { startTime = localStartTime, duration = dur, spellEntry = spellEntry, currentCharges = (maxChargeCount > 0) and curCharges or nil, maxCharges = (maxChargeCount > 0) and maxChargeCount or nil, chargeStart = chargeStart, chargeDuration = chargeDur, } applied = applied + 1 end end end end self:DebugScoped("info", "TrackedSpells", "SyncResponse von %s: cdsApplied=%d", tostring(senderName), applied) end self:TriggerTrackerUpdate() end elseif msgType == MSG_RAID_TIMELINE then local encounterId, timeSec, spellId, leadTime, alertText = message:match("^%a+|(%d+)|(%d+)|([%-]?%d+)|(%d+)|(.*)$") if not encounterId then encounterId, timeSec, spellId, leadTime = message:match("^%a+|(%d+)|(%d+)|([%-]?%d+)|(%d+)$") alertText = "" end if encounterId and HMGT.RaidTimeline and HMGT.RaidTimeline.HandleAssignmentComm then HMGT.RaidTimeline:HandleAssignmentComm( senderName, tonumber(encounterId), tonumber(timeSec), tonumber(spellId), tonumber(leadTime), alertText ) end elseif msgType == MSG_RAID_TIMELINE_TEST then local encounterId, difficultyId, serverStartTime, duration = message:match("^%a+|(%d+)|(%d+)|(%d+)|(%d+)$") if encounterId and HMGT.RaidTimeline and HMGT.RaidTimeline.HandleTestStartComm then HMGT.RaidTimeline:HandleTestStartComm( senderName, tonumber(encounterId), tonumber(difficultyId), tonumber(serverStartTime), tonumber(duration) ) end end 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 function HMGT:IsPlayerInCurrentGroup(playerName) local target = self:NormalizePlayerName(playerName) if not target then return false end local own = self:NormalizePlayerName(UnitName("player")) if target == own then return true end if IsInRaid() then for i = 1, GetNumGroupMembers() do local n = self:NormalizePlayerName(UnitName("raid" .. i)) if n == target then return true end end return false end if IsInGroup() then for i = 1, GetNumSubgroupMembers() do local n = self:NormalizePlayerName(UnitName("party" .. i)) if n == target then return true end end end return false end function HMGT:HandleOwnSpellCast(spellId) local isInterrupt = HMGT_SpellData.InterruptLookup[spellId] ~= nil local isCooldown = HMGT_SpellData.CooldownLookup[spellId] ~= nil if not isInterrupt and not isCooldown then return end local spellEntry = HMGT_SpellData.InterruptLookup[spellId] or HMGT_SpellData.CooldownLookup[spellId] local name = self:NormalizePlayerName(UnitName("player")) local pData = self.playerData[name] local talents = pData and pData.talents or {} if self:IsAvailabilitySpell(spellEntry) then self:LogTrackedSpellCast(name, spellEntry, { stateKind = "availability", required = HMGT_SpellData.GetEffectiveAvailabilityRequired(spellEntry, talents), }) if self:RefreshOwnAvailabilitySpell(spellEntry) then self:PublishOwnSpellState(spellId, { sendLegacy = true }) end self:TriggerTrackerUpdate() return end local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) local now = GetTime() local inCombat = InCombatLockdown and InCombatLockdown() local cur, max, chargeStart, chargeDuration = nil, nil, nil, nil if not inCombat then cur, max, chargeStart, chargeDuration = GetSpellChargesInfo(spellId) end local cachedMaxCharges, cachedChargeDuration = self:GetKnownChargeInfo( spellEntry, talents, spellId, (not inCombat and tonumber(chargeDuration) and tonumber(chargeDuration) > 0) and tonumber(chargeDuration) or effectiveCd ) local inferredMaxCharges, inferredChargeDuration = HMGT_SpellData.GetEffectiveChargeInfo( spellEntry, talents, (not inCombat and tonumber(max) and tonumber(max) > 0) and tonumber(max) or ((cachedMaxCharges > 0) and cachedMaxCharges or nil), (not inCombat and tonumber(chargeDuration) and tonumber(chargeDuration) > 0) and tonumber(chargeDuration) or ((cachedChargeDuration > 0) and cachedChargeDuration or effectiveCd) ) local hasCharges = ((tonumber(max) or 0) > 1) or (tonumber(inferredMaxCharges) or 0) > 1 local currentCharges = 0 local maxCharges = 0 local chargeDur = 0 local chargeStartTime = nil local startTime = now local duration = effectiveCd local expiresIn = effectiveCd local existingCd = self.activeCDs[name] and self.activeCDs[name][spellId] if existingCd and (tonumber(existingCd.maxCharges) or 0) > 0 then self:ResolveChargeState(existingCd, now) end if hasCharges then maxCharges = math.max(1, tonumber(max) or cachedMaxCharges or tonumber(inferredMaxCharges) or 1) currentCharges = tonumber(cur) if currentCharges == nil then local prevCharges = existingCd and tonumber(existingCd.currentCharges) local prevMax = existingCd and tonumber(existingCd.maxCharges) if prevCharges and prevMax and prevMax == maxCharges then currentCharges = math.max(0, prevCharges - 1) else currentCharges = math.max(0, maxCharges - 1) end end currentCharges = math.max(0, math.min(maxCharges, currentCharges)) chargeDur = tonumber(chargeDuration) or cachedChargeDuration or tonumber(inferredChargeDuration) or tonumber(effectiveCd) or 0 chargeDur = math.max(0, chargeDur) self:StoreKnownChargeInfo(spellId, maxCharges, chargeDur) if currentCharges < maxCharges and chargeDur > 0 then chargeStartTime = tonumber(chargeStart) or now local missing = maxCharges - currentCharges startTime = chargeStartTime duration = missing * chargeDur expiresIn = math.max(0, duration - (now - startTime)) else startTime = now duration = 0 expiresIn = 0 end end self:Debug( "verbose", "HandleOwnSpellCast name=%s spellId=%s cd=%.2f charges=%s/%s", tostring(name), tostring(spellId), tonumber(effectiveCd) or 0, hasCharges and tostring(currentCharges) or "-", hasCharges and tostring(maxCharges) or "-" ) self._cdNonce = (self._cdNonce or 0) + 1 local nonce = self._cdNonce self.activeCDs[name] = self.activeCDs[name] or {} self.activeCDs[name][spellId] = { startTime = startTime, duration = duration, spellEntry = spellEntry, currentCharges = hasCharges and currentCharges or nil, maxCharges = hasCharges and maxCharges or nil, chargeStart = hasCharges and chargeStartTime or nil, chargeDuration = hasCharges and chargeDur or nil, _nonce = nonce, } self:LogTrackedSpellCast(name, spellEntry, { cooldown = effectiveCd, currentCharges = hasCharges and currentCharges or nil, maxCharges = hasCharges and maxCharges or nil, chargeCooldown = hasCharges and chargeDur or nil, }) if expiresIn > 0 then self:ScheduleTimer(function() local current = self.activeCDs[name] and self.activeCDs[name][spellId] if current and current._nonce == nonce then self.activeCDs[name][spellId] = nil self:PublishOwnSpellState(spellId) self:TriggerTrackerUpdate() end end, expiresIn) end self:PublishOwnSpellState(spellId, { sendLegacy = true }) self:TriggerTrackerUpdate() end function HMGT:RefreshCooldownExpiryTimer(playerName, spellId, cdData) if not cdData then return 0 end local now = GetTime() local duration = tonumber(cdData.duration) or 0 local startTime = tonumber(cdData.startTime) or now local expiresIn = math.max(0, duration - (now - startTime)) self._cdNonce = (self._cdNonce or 0) + 1 local nonce = self._cdNonce cdData._nonce = nonce if expiresIn > 0 then self:ScheduleTimer(function() local current = self.activeCDs[playerName] and self.activeCDs[playerName][spellId] if current and current._nonce == nonce then self.activeCDs[playerName][spellId] = nil if self.activeCDs[playerName] and not next(self.activeCDs[playerName]) then self.activeCDs[playerName] = nil end if playerName == self:NormalizePlayerName(UnitName("player")) then self:PublishOwnSpellState(spellId) end self:TriggerTrackerUpdate() end end, expiresIn) end return expiresIn 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:RefreshOwnCooldownStateFromGame(spellId) local sid = tonumber(spellId) if not sid then return false end if InCombatLockdown and InCombatLockdown() then return false end local ownName = self:NormalizePlayerName(UnitName("player")) if not ownName then return false end local spellEntry = HMGT_SpellData.InterruptLookup[sid] or HMGT_SpellData.CooldownLookup[sid] if not spellEntry or self:IsAvailabilitySpell(spellEntry) then return false end local existing = self.activeCDs[ownName] and self.activeCDs[ownName][sid] local before = BuildCooldownStateFingerprint(existing) local now = GetTime() local pData = self.playerData[ownName] local talents = pData and pData.talents or {} local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) local cur, max, chargeStart, chargeDuration = GetSpellChargesInfo(sid) local inferredMaxCharges, inferredChargeDuration = HMGT_SpellData.GetEffectiveChargeInfo( spellEntry, talents, (tonumber(max) and tonumber(max) > 0) and tonumber(max) or nil, (tonumber(chargeDuration) and tonumber(chargeDuration) > 0) and tonumber(chargeDuration) or effectiveCd ) local hasCharges = ((tonumber(max) or 0) > 1) or (tonumber(inferredMaxCharges) or 0) > 1 if hasCharges then local maxCharges = math.max(1, tonumber(max) or tonumber(inferredMaxCharges) or 1) local currentCharges = tonumber(cur) if currentCharges == nil then currentCharges = maxCharges end currentCharges = math.max(0, math.min(maxCharges, currentCharges)) local chargeDur = tonumber(chargeDuration) or tonumber(inferredChargeDuration) or tonumber(effectiveCd) or 0 chargeDur = math.max(0, chargeDur) if currentCharges < maxCharges and chargeDur > 0 then local chargeStartTime = tonumber(chargeStart) or now local missing = maxCharges - currentCharges self.activeCDs[ownName] = self.activeCDs[ownName] or {} self.activeCDs[ownName][sid] = { startTime = chargeStartTime, duration = missing * chargeDur, spellEntry = spellEntry, currentCharges = currentCharges, maxCharges = maxCharges, chargeStart = chargeStartTime, chargeDuration = chargeDur, } self:RefreshCooldownExpiryTimer(ownName, sid, self.activeCDs[ownName][sid]) else if self.activeCDs[ownName] then self.activeCDs[ownName][sid] = nil if not next(self.activeCDs[ownName]) then self.activeCDs[ownName] = nil end end end else local cooldownStart, cooldownDuration = GetSpellCooldownInfo(sid) cooldownStart = tonumber(cooldownStart) or 0 cooldownDuration = tonumber(cooldownDuration) or 0 local gcdStart, gcdDuration = GetGlobalCooldownInfo() gcdStart = tonumber(gcdStart) or 0 gcdDuration = tonumber(gcdDuration) or 0 local existingDuration = tonumber(existing and existing.duration) or 0 local existingStart = tonumber(existing and existing.startTime) or now local existingRemaining = math.max(0, existingDuration - (now - existingStart)) local isLikelyGlobalCooldown = cooldownDuration > 0 and gcdDuration > 0 and math.abs(cooldownDuration - gcdDuration) <= 0.15 and (tonumber(effectiveCd) or 0) > (gcdDuration + 1.0) local isSuspiciousShortRefresh = cooldownDuration > 0 and existingRemaining > 2.0 and existingDuration > 2.0 and cooldownDuration < math.max(2.0, existingDuration * 0.35) and cooldownDuration < math.max(2.0, (tonumber(effectiveCd) or 0) * 0.35) if isLikelyGlobalCooldown or isSuspiciousShortRefresh then self:DebugScoped( "verbose", "TrackedSpells", "Ignore suspicious refresh for %s: spellCD=%.3f gcd=%.3f existing=%.3f remaining=%.3f effective=%.3f", GetSpellDebugLabel(sid), cooldownDuration, gcdDuration, existingDuration, existingRemaining, tonumber(effectiveCd) or 0 ) return false end if cooldownDuration > 0 then self.activeCDs[ownName] = self.activeCDs[ownName] or {} self.activeCDs[ownName][sid] = { startTime = cooldownStart, duration = cooldownDuration, spellEntry = spellEntry, } self:RefreshCooldownExpiryTimer(ownName, sid, self.activeCDs[ownName][sid]) else if self.activeCDs[ownName] then self.activeCDs[ownName][sid] = nil if not next(self.activeCDs[ownName]) then self.activeCDs[ownName] = nil end end end end local after = BuildCooldownStateFingerprint(self.activeCDs[ownName] and self.activeCDs[ownName][sid]) return before ~= after 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.activeCDs[playerName] 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 self.activeCDs[playerName] and not next(self.activeCDs[playerName]) then self.activeCDs[playerName] = nil 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 function HMGT:DidOwnInterruptSucceed(triggerSpellId, talents) local sid = tonumber(triggerSpellId) if not sid then return false end local spellEntry = HMGT_SpellData and HMGT_SpellData.InterruptLookup and HMGT_SpellData.InterruptLookup[sid] if not spellEntry then return false end local _, observedDuration = GetSpellCooldownInfo(sid) observedDuration = tonumber(observedDuration) or 0 if observedDuration <= 0 then return false end local expectedDuration = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) expectedDuration = tonumber(expectedDuration) or 0 if expectedDuration <= 0 then return false end -- Successful kick reductions (e.g. Coldthirst) result in a shorter observed CD. return observedDuration < (expectedDuration - 0.05) end function HMGT:HandleOwnCooldownReductionTrigger(triggerSpellId) 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 reducers = HMGT_SpellData.GetCooldownReducersForCast(classToken, specIndex, triggerSpellId, talents) if not reducers or #reducers == 0 then return end local instantReducers = {} local observedInstantReducers = {} local successReducers = {} local observedSuccessReducers = {} for _, reducer in ipairs(reducers) do local observed = type(reducer.observe) == "table" if reducer.requireInterruptSuccess then if observed then observedSuccessReducers[#observedSuccessReducers + 1] = reducer else successReducers[#successReducers + 1] = reducer end else if observed then observedInstantReducers[#observedInstantReducers + 1] = reducer else instantReducers[#instantReducers + 1] = reducer end end end local castTs = GetServerTime() if #instantReducers > 0 then ApplyOwnCooldownReducers(self, ownName, triggerSpellId, instantReducers, castTs) end if #observedInstantReducers > 0 then ApplyObservedCooldownReducers(self, ownName, observedInstantReducers) end if #successReducers > 0 or #observedSuccessReducers > 0 then local function ApplySuccessReducers() if not self:DidOwnInterruptSucceed(triggerSpellId, talents) then return false end if #successReducers > 0 then ApplyOwnCooldownReducers(self, ownName, triggerSpellId, successReducers, castTs) end if #observedSuccessReducers > 0 then ApplyObservedCooldownReducers(self, ownName, observedSuccessReducers) end return true end if not ApplySuccessReducers() then C_Timer.After(0.12, function() if not self or not self.playerData or not self.playerData[ownName] then return end ApplySuccessReducers() end) end end end 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:HandleRemoteSpellCast(playerName, spellId, castTimestamp, curCharges, maxCharges, chargeRemaining, chargeDuration) local spellEntry = HMGT_SpellData.InterruptLookup[spellId] or HMGT_SpellData.CooldownLookup[spellId] if not spellEntry then return end if self:IsAvailabilitySpell(spellEntry) then return end local pData = self.playerData[playerName] local talents = pData and pData.talents or {} local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) castTimestamp = tonumber(castTimestamp) or GetServerTime() local existingEntry = self.activeCDs[playerName] and self.activeCDs[playerName][spellId] if (tonumber(maxCharges) or 0) <= 0 and existingEntry and existingEntry.lastCastTimestamp then local prevTs = tonumber(existingEntry.lastCastTimestamp) or 0 if math.abs(prevTs - castTimestamp) <= 1 then return end end local now = GetTime() local elapsed = math.max(0, GetServerTime() - castTimestamp) local incomingCur = tonumber(curCharges) or 0 local incomingMax = tonumber(maxCharges) or 0 local incomingChargeRemaining = tonumber(chargeRemaining) or 0 local incomingChargeDuration = tonumber(chargeDuration) or 0 local inferredMaxCharges, inferredChargeDuration = HMGT_SpellData.GetEffectiveChargeInfo( spellEntry, talents, (incomingMax > 0) and incomingMax or nil, (incomingChargeDuration > 0) and incomingChargeDuration or effectiveCd ) local hasCharges = (incomingMax > 1) or (tonumber(inferredMaxCharges) or 0) > 1 local currentCharges = 0 local maxChargeCount = 0 local chargeDur = 0 local nextChargeRemaining = 0 local chargeStartTime = nil local startTime, duration, expiresIn if hasCharges then maxChargeCount = math.max(1, (incomingMax > 0 and incomingMax) or tonumber(inferredMaxCharges) or 1) chargeDur = tonumber(incomingChargeDuration) or tonumber(inferredChargeDuration) or tonumber(effectiveCd) or 0 chargeDur = math.max(0, chargeDur) if chargeDur <= 0 then chargeDur = math.max(0, tonumber(effectiveCd) or 0) end if incomingMax > 0 then currentCharges = math.max(0, math.min(maxChargeCount, incomingCur)) nextChargeRemaining = math.max(0, math.min(chargeDur, incomingChargeRemaining - elapsed)) if currentCharges < maxChargeCount and chargeDur > 0 then chargeStartTime = now - math.max(0, chargeDur - nextChargeRemaining) end else local existing = self.activeCDs[playerName] and self.activeCDs[playerName][spellId] if existing and (tonumber(existing.maxCharges) or 0) == maxChargeCount then self:ResolveChargeState(existing, now) local prevCharges = tonumber(existing.currentCharges) or maxChargeCount local prevStart = tonumber(existing.chargeStart) local prevDur = tonumber(existing.chargeDuration) or chargeDur if prevDur > 0 then chargeDur = prevDur end currentCharges = math.max(0, prevCharges - 1) if currentCharges < maxChargeCount and chargeDur > 0 then if prevCharges >= maxChargeCount then chargeStartTime = now else chargeStartTime = prevStart or now end nextChargeRemaining = math.max(0, chargeDur - (now - chargeStartTime)) end else currentCharges = math.max(0, maxChargeCount - 1) if currentCharges < maxChargeCount and chargeDur > 0 then chargeStartTime = now nextChargeRemaining = chargeDur end end end if currentCharges >= maxChargeCount and maxChargeCount > 0 then currentCharges = math.max(0, maxChargeCount - 1) if chargeDur > 0 then chargeStartTime = now nextChargeRemaining = chargeDur end end if currentCharges < maxChargeCount and chargeDur > 0 then chargeStartTime = chargeStartTime or now local missing = maxChargeCount - currentCharges startTime = chargeStartTime duration = missing * chargeDur expiresIn = math.max(0, duration - (now - startTime)) else startTime = now duration = 0 expiresIn = 0 end else local remaining = effectiveCd - elapsed if remaining <= 0 then return end startTime = now - elapsed duration = effectiveCd expiresIn = remaining end self:Debug( "verbose", "HandleRemoteSpellCast name=%s spellId=%s elapsed=%.2f expiresIn=%.2f charges=%s/%s", tostring(playerName), tostring(spellId), tonumber(elapsed) or 0, tonumber(expiresIn) or 0, hasCharges and tostring(currentCharges) or "-", hasCharges and tostring(maxChargeCount) or "-" ) self._cdNonce = (self._cdNonce or 0) + 1 local nonce = self._cdNonce self.activeCDs[playerName] = self.activeCDs[playerName] or {} self.activeCDs[playerName][spellId] = { startTime = startTime, duration = duration, spellEntry = spellEntry, currentCharges = hasCharges and currentCharges or nil, maxCharges = hasCharges and maxChargeCount or nil, chargeStart = hasCharges and chargeStartTime or nil, chargeDuration = hasCharges and chargeDur or nil, lastCastTimestamp = castTimestamp, _nonce = nonce, } self:LogTrackedSpellCast(playerName, spellEntry, { cooldown = effectiveCd, currentCharges = hasCharges and currentCharges or nil, maxCharges = hasCharges and maxChargeCount or nil, chargeCooldown = hasCharges and chargeDur or nil, }) if expiresIn > 0 then self:ScheduleTimer(function() local current = self.activeCDs[playerName] and self.activeCDs[playerName][spellId] if current and current._nonce == nonce then self.activeCDs[playerName][spellId] = nil self:TriggerTrackerUpdate() end end, expiresIn) end self:TriggerTrackerUpdate() 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.activeCDs[name] = nil self.availabilityStates[name] = nil self.remoteSpellStateRevisions[name] = nil self.peerVersions[name] = nil self.versionWarnings[name] = nil end end local count = 0 for _ in pairs(validPlayers) do count = count + 1 end self:Debug("verbose", "OnGroupRosterUpdate validPlayers=%d", count) if HMGT.TrackerManager and HMGT.TrackerManager.InvalidateAnchorLayout then HMGT.TrackerManager:InvalidateAnchorLayout() end self:TriggerTrackerUpdate() end function HMGT:CleanupStaleCooldowns() local now = GetTime() local ownName = self:NormalizePlayerName(UnitName("player")) local removed = 0 for playerName, spells in pairs(self.activeCDs) do for spellId, cdInfo in pairs(spells) do local duration = tonumber(cdInfo.duration) or 0 local startTime = tonumber(cdInfo.startTime) or now local rem = duration - (now - startTime) local hasCharges = (tonumber(cdInfo.maxCharges) or 0) > 0 local currentCharges = tonumber(cdInfo.currentCharges) or 0 local maxCharges = tonumber(cdInfo.maxCharges) or 0 if hasCharges then local _, _, cur, max = self:ResolveChargeState(cdInfo, now) currentCharges = cur maxCharges = max end local shouldDrop = false if hasCharges then if currentCharges >= maxCharges then shouldDrop = true elseif (tonumber(cdInfo.chargeDuration) or 0) <= 0 and rem <= -2 then shouldDrop = true end elseif rem <= -2 then shouldDrop = true end if shouldDrop then spells[spellId] = nil if playerName == ownName then self:PublishOwnSpellState(spellId) end removed = removed + 1 end end if not next(spells) then self.activeCDs[playerName] = nil end end if removed > 0 then self:Debug("verbose", "CleanupStaleCooldowns removed=%d", removed) end 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 -- ═══════════════════════════════════════════════════════════════ function HMGT:TriggerTrackerUpdate(reason) local function normalizeReason(value) if value == true then return "trackers" elseif value == "trackers" or value == "layout" or value == "visual" then return value end return "full" end local function mergeReasons(current, incoming) local priority = { visual = 1, layout = 2, trackers = 3, full = 4, } current = normalizeReason(current) incoming = normalizeReason(incoming) if (priority[incoming] or 4) >= (priority[current] or 4) then return incoming end return current end self._trackerUpdateMinDelay = self._trackerUpdateMinDelay or 0.08 self._trackerUpdatePending = true self._trackerUpdateReason = mergeReasons(self._trackerUpdateReason, reason) if HMGT.TrackerManager then local normalizedReason = normalizeReason(reason) if normalizedReason == "trackers" then HMGT.TrackerManager:MarkTrackersDirty() elseif normalizedReason == "layout" then HMGT.TrackerManager:MarkLayoutDirty() end end if self._updateScheduled then return end local now = GetTime() local last = self._lastTrackerUpdateAt or 0 local delay = math.max(0, self._trackerUpdateMinDelay - (now - last)) self._updateScheduled = true self:ScheduleTimer(function() self._updateScheduled = nil if not self._trackerUpdatePending then return end self._trackerUpdatePending = nil self._lastTrackerUpdateAt = GetTime() local pendingReason = self._trackerUpdateReason self._trackerUpdateReason = nil local function profileModule(name, fn) if not fn then return end local t0 = debugprofilestop and debugprofilestop() or nil fn() local t1 = debugprofilestop and debugprofilestop() or nil if t0 and t1 then local mod = HMGT[name] local count = mod and mod.lastEntryCount or 0 self:Debug("verbose", "UIUpdate %s took %.2fms entries=%s", tostring(name), t1 - t0, tostring(count)) end end profileModule("TrackerManager", HMGT.TrackerManager and function() if pendingReason == "visual" and HMGT.TrackerManager.RefreshVisibleVisuals then HMGT.TrackerManager:RefreshVisibleVisuals() else HMGT.TrackerManager:UpdateDisplay() end end or nil) -- If events flooded in while rendering, schedule exactly one follow-up pass. if self._trackerUpdatePending then self:TriggerTrackerUpdate() end end, delay) end -- ==================================================================== -- 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 == "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:ResolveChargeState(cdData, now) if not cdData then return 0, 0, 0, 0 end local maxCharges = tonumber(cdData.maxCharges) or 0 if maxCharges <= 0 then return 0, 0, 0, 0 end now = tonumber(now) or GetTime() local charges = tonumber(cdData.currentCharges) or 0 charges = math.max(0, math.min(maxCharges, charges)) local chargeDuration = tonumber(cdData.chargeDuration) or tonumber(cdData.duration) or 0 local chargeStart = tonumber(cdData.chargeStart) if chargeDuration > 0 and charges < maxCharges then if not chargeStart then chargeStart = now end local elapsed = now - chargeStart if elapsed > 0 then local gained = math.floor(elapsed / chargeDuration) if gained > 0 then charges = math.min(maxCharges, charges + gained) chargeStart = chargeStart + (gained * chargeDuration) end end end local nextChargeRemaining = 0 if charges < maxCharges and chargeDuration > 0 and chargeStart then nextChargeRemaining = math.max(0, chargeDuration - (now - chargeStart)) end cdData.currentCharges = charges cdData.maxCharges = maxCharges cdData.chargeDuration = chargeDuration cdData.chargeStart = chargeStart return nextChargeRemaining, chargeDuration, charges, maxCharges end function HMGT:GetCooldownInfo(playerName, spellId, opts) opts = opts or {} local deferUntilEmpty = opts.deferChargeCooldownUntilEmpty and true or false local spellEntry = HMGT_SpellData.InterruptLookup[spellId] or HMGT_SpellData.CooldownLookup[spellId] local ownName = self:NormalizePlayerName(UnitName("player")) local isOwnPlayer = playerName == ownName local pData = isOwnPlayer and self.playerData[ownName] or nil local talents = pData and pData.talents or {} local effectiveCd = spellEntry and HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) or 0 local knownMaxCharges, knownChargeDuration = 0, 0 if spellEntry and isOwnPlayer then knownMaxCharges, knownChargeDuration = self:GetKnownChargeInfo(spellEntry, talents, spellId, effectiveCd) end if self:IsAvailabilitySpell(spellEntry) then local normalizedName = self:NormalizePlayerName(playerName) if normalizedName == ownName then local current, max = self:GetOwnAvailabilityProgress(spellEntry) if (tonumber(max) or 0) > 0 then self:StoreAvailabilityState(ownName, spellId, current, max, spellEntry) return 0, 0, current, max end else local current, max = self:GetAvailabilityState(normalizedName, spellId) if (tonumber(max) or 0) > 0 then return 0, 0, current, max end end return 0, 0, nil, nil end local cdData = self.activeCDs[playerName] and self.activeCDs[playerName][spellId] -- Fuer den eigenen Spieler bevorzugt echte Spell-Charge-Infos verwenden. -- So werden Talent-Stacks (z.B. 2 Charges) korrekt berechnet und angezeigt. if isOwnPlayer and not (InCombatLockdown and InCombatLockdown()) then local charges, maxCharges, chargeStart, chargeDuration = GetSpellChargesInfo(spellId) charges = SafeApiNumber(charges, 0) or 0 maxCharges = SafeApiNumber(maxCharges, 0) or 0 chargeStart = SafeApiNumber(chargeStart) chargeDuration = SafeApiNumber(chargeDuration, 0) or 0 if maxCharges > 0 then local tempChargeState = { currentCharges = charges, maxCharges = maxCharges, chargeStart = chargeStart, chargeDuration = chargeDuration, duration = chargeDuration, } local remaining, total, curCharges, maxChargeCount = self:ResolveChargeState(tempChargeState) self:StoreKnownChargeInfo(spellId, maxChargeCount, total > 0 and total or chargeDuration) -- API fallback: if charges are empty but charge timer is missing/zero, -- try classic spell cooldown so sweep can still render. if (curCharges or 0) < maxChargeCount and remaining <= 0 then local cdStart, cdDuration = GetSpellCooldownInfo(spellId) if cdDuration > 0 then remaining = math.max(0, cdDuration - (GetTime() - cdStart)) total = math.max(total or 0, cdDuration) end end if deferUntilEmpty and (curCharges or 0) > 0 then remaining = 0 end return remaining, total, curCharges, maxChargeCount end local cdStart, cdDuration = GetSpellCooldownInfo(spellId) cdStart = tonumber(cdStart) or 0 cdDuration = tonumber(cdDuration) or 0 if cdDuration > 0 then local remaining = math.max(0, cdDuration - (GetTime() - cdStart)) remaining = math.max(0, math.min(cdDuration, remaining)) if cdData and (tonumber(cdData.maxCharges) or 0) <= 0 then local cachedRemaining = (tonumber(cdData.duration) or 0) - (GetTime() - (tonumber(cdData.startTime) or GetTime())) cachedRemaining = math.max(0, math.min(tonumber(cdData.duration) or cachedRemaining, cachedRemaining)) local cachedDuration = math.max(0, tonumber(cdData.duration) or 0) if cachedDuration > 2.0 and cachedRemaining > 2.0 and cdDuration < math.max(2.0, cachedDuration * 0.35) then return cachedRemaining, cachedDuration, nil, nil end end return remaining, cdDuration, nil, nil end end if not cdData then if isOwnPlayer and knownMaxCharges > 1 then return 0, math.max(0, knownChargeDuration or effectiveCd or 0), knownMaxCharges, knownMaxCharges end return 0, 0, nil, nil end if (tonumber(cdData.maxCharges) or 0) > 0 then local remaining, chargeDur, charges, maxCharges = self:ResolveChargeState(cdData) self:StoreKnownChargeInfo(spellId, maxCharges, chargeDur) if deferUntilEmpty and charges > 0 then remaining = 0 end return remaining, chargeDur, charges, maxCharges end if isOwnPlayer and knownMaxCharges > 1 then local remaining = (tonumber(cdData.duration) or 0) - (GetTime() - (tonumber(cdData.startTime) or GetTime())) remaining = math.max(0, math.min(tonumber(cdData.duration) or remaining, remaining)) local currentCharges = knownMaxCharges if remaining > 0 then currentCharges = math.max(0, knownMaxCharges - 1) end if deferUntilEmpty and currentCharges > 0 then remaining = 0 end return remaining, math.max(0, knownChargeDuration or effectiveCd or 0), currentCharges, knownMaxCharges end local remaining = cdData.duration - (GetTime() - cdData.startTime) remaining = math.max(0, math.min(cdData.duration, remaining)) return remaining, cdData.duration, nil, nil end function HMGT:ShouldDisplayEntry(settings, remaining, currentCharges, maxCharges, spellEntry) local rem = tonumber(remaining) or 0 local cur = tonumber(currentCharges) or 0 local max = tonumber(maxCharges) or 0 local soon = tonumber(settings.readySoonSec) or 0 local isAvailabilitySpell = spellEntry and self:IsAvailabilitySpell(spellEntry) or false local isReady if isAvailabilitySpell then isReady = max > 0 and cur >= max else isReady = rem <= 0 or (max > 0 and cur > 0) end if settings.showOnlyReady then return isReady end if soon > 0 then if isAvailabilitySpell then return isReady end return isReady or rem <= soon end return true end local DEFAULT_CATEGORY_PRIORITY = { interrupt = 1, lust = 2, defensive = 3, tank = 4, healing = 5, offensive = 6, utility = 7, cc = 8, } local TRACKER_CATEGORY_PRIORITY = { interruptTracker = { interrupt = 1, defensive = 2, utility = 3, cc = 4, healing = 5, tank = 6, offensive = 7, lust = 8, }, raidCooldownTracker = { lust = 1, defensive = 2, healing = 3, tank = 4, utility = 5, offensive = 6, cc = 7, interrupt = 8, }, groupCooldownTracker = { tank = 1, defensive = 2, healing = 3, cc = 4, utility = 5, offensive = 6, lust = 7, interrupt = 8, }, } local function GetCategoryPriority(category, trackerKey) local cat = tostring(category or "utility") local trackerOrder = trackerKey and TRACKER_CATEGORY_PRIORITY[trackerKey] if trackerOrder and trackerOrder[cat] then return trackerOrder[cat] end local order = HMGT_SpellData and HMGT_SpellData.CategoryOrder if type(order) == "table" then for idx, key in ipairs(order) do if key == cat then return idx end end return #order + 10 end return DEFAULT_CATEGORY_PRIORITY[cat] or 99 end function HMGT:SortDisplayEntries(entries, trackerKey) if type(entries) ~= "table" then return end table.sort(entries, function(a, b) local aRemaining = tonumber(a and a.remaining) or 0 local bRemaining = tonumber(b and b.remaining) or 0 local aActive = aRemaining > 0 local bActive = bRemaining > 0 if aActive ~= bActive then return aActive end local aEntry = a and a.spellEntry local bEntry = b and b.spellEntry local aPriority = tonumber(aEntry and aEntry.priority) or GetCategoryPriority(aEntry and aEntry.category, trackerKey) local bPriority = tonumber(bEntry and bEntry.priority) or GetCategoryPriority(bEntry and bEntry.category, trackerKey) if aPriority ~= bPriority then return aPriority < bPriority end if aActive and aRemaining ~= bRemaining then return aRemaining < bRemaining end local aTotal = tonumber(a and a.total) or tonumber(aEntry and HMGT_SpellData.GetBaseCooldown and HMGT_SpellData.GetBaseCooldown(aEntry)) or tonumber(aEntry and aEntry.cooldown) or 0 local bTotal = tonumber(b and b.total) or tonumber(bEntry and HMGT_SpellData.GetBaseCooldown and HMGT_SpellData.GetBaseCooldown(bEntry)) or tonumber(bEntry and bEntry.cooldown) or 0 if (not aActive) and aTotal ~= bTotal then return aTotal > bTotal end if aRemaining ~= bRemaining then return aRemaining < bRemaining end local aName = tostring(a and a.playerName or "") local bName = tostring(b and b.playerName or "") if aName ~= bName then return aName < bName end local aSpell = tonumber(aEntry and aEntry.spellId) or 0 local bSpell = tonumber(bEntry and bEntry.spellId) or 0 return aSpell < bSpell end) 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