Files
HailMaryGuildTools/HailMaryGuildTools.lua
Torsten Brendgen cf78405148 nightly commit
2026-04-25 22:49:22 +02:00

3365 lines
123 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- Core.lua
-- Hail Mary Guild Tools Haupt-Addon-Logik
local ADDON_NAME = "HailMaryGuildTools"
-- ── AceLocale holen (Locales/*.lua wurden bereits geladen) ────
local L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME)
-- ── Addon erstellen ───────────────────────────────────────────
local HMGT = LibStub("AceAddon-3.0"):NewAddon(ADDON_NAME,
"AceConsole-3.0",
"AceEvent-3.0",
"AceComm-3.0",
"AceTimer-3.0"
)
_G[ADDON_NAME] = HMGT
HMGT.L = L -- für Module zugänglich
local AceGUI = LibStub("AceGUI-3.0", true)
local LDB = LibStub("LibDataBroker-1.1", true)
local LDBIcon = LibStub("LibDBIcon-1.0", true)
function HMGT:SafeShowTooltip(tooltip)
if not tooltip or type(tooltip.Show) ~= "function" then
return false
end
-- Shared Blizzard tooltips can carry secret money values; re-showing them can
-- explode inside MoneyFrame_Update, so keep the failure contained.
local ok = pcall(tooltip.Show, tooltip)
return ok
end
-- ── Kommunikationsprotokoll ───────────────────────────────────
local MSG_SPELL_CAST = "SC" -- Legacy: SC|spellId|serverTimestamp
local MSG_CD_REDUCE = "CR" -- Legacy: CR|targetSpellId|amount|serverTimestamp|triggerSpellId
local MSG_SPELL_STATE = "STA" -- STA|spellId|kind|revision|a|b|c|d|version|protocol
local MSG_HELLO = "HEL" -- HEL|version|protocol|class|specIndex|talentHash|knownSpellIds
local MSG_PLAYER_INFO = "PI" -- PI|class|specIndex|talentHash
local MSG_SYNC_REQUEST = "SRQ"
local MSG_SYNC_RESPONSE = "SRS" -- SRS|version|protocol|class|spec|talentHash|knownSpellIds|cd1:t1:d1;...
local MSG_RAID_TIMELINE = "RTL" -- RTL|encounterId|time|spellId|leadTime|alertText
local MSG_RAID_TIMELINE_TEST = "RTS" -- RTS|encounterId|difficultyId|serverStartTime|duration
local MSG_RELIABLE = "REL" -- REL|messageId|innerPayload
local MSG_ACK = "ACK" -- ACK|messageId
local COMM_PREFIX = "HMGT"
local MINIMAP_ICON = "Interface\\Addons\\HailMaryGuildTools\\Media\\HailMaryIcon.png"
local function GetAddonMetadataValue(field)
if C_AddOns and type(C_AddOns.GetAddOnMetadata) == "function" then
local value = C_AddOns.GetAddOnMetadata(ADDON_NAME, field)
if value ~= nil and value ~= "" then
return value
end
end
if type(GetAddOnMetadata) == "function" then
local value = GetAddOnMetadata(ADDON_NAME, field)
if value ~= nil and value ~= "" then
return value
end
end
return nil
end
local ADDON_VERSION = GetAddonMetadataValue("Version") or "dev"
local BUILD_VERSION = GetAddonMetadataValue("X-Build-Version")
or ADDON_VERSION
local RELEASE_CHANNEL = GetAddonMetadataValue("X-Release-Channel")
or "stable"
local PROTOCOL_VERSION = 6
local TRACKER_MODEL_VERSION = 3
HMGT.ADDON_VERSION = ADDON_VERSION
HMGT.BUILD_VERSION = BUILD_VERSION
HMGT.RELEASE_CHANNEL = RELEASE_CHANNEL
HMGT.PROTOCOL_VERSION = PROTOCOL_VERSION
HMGT.COMM_PREFIX = COMM_PREFIX
HMGT.MSG_SPELL_CAST = MSG_SPELL_CAST
HMGT.MSG_CD_REDUCE = MSG_CD_REDUCE
HMGT.MSG_SPELL_STATE = MSG_SPELL_STATE
HMGT.MSG_HELLO = MSG_HELLO
HMGT.MSG_PLAYER_INFO = MSG_PLAYER_INFO
HMGT.MSG_SYNC_REQUEST = MSG_SYNC_REQUEST
HMGT.MSG_SYNC_RESPONSE = MSG_SYNC_RESPONSE
HMGT.MSG_RELIABLE = MSG_RELIABLE
HMGT.MSG_ACK = MSG_ACK
HMGT.MSG_RAID_TIMELINE = MSG_RAID_TIMELINE
HMGT.MSG_RAID_TIMELINE_TEST = MSG_RAID_TIMELINE_TEST
-- ── Standardwerte ─────────────────────────────────────────────
local defaults = {
profile = {
devTools = {
enabled = false,
level = "info",
scope = "ALL",
window = {
width = 920,
height = 420,
minimized = false,
},
},
syncRemoteCharges = true,
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.versionWarnings = {}
HMGT.versionWhisperWarnings = {}
HMGT.playerStatus = {}
HMGT.devToolsBuffer = HMGT.devToolsBuffer or {}
HMGT.devToolsBufferMax = HMGT.devToolsBufferMax or 500
HMGT.enabledDebugScopes = {
General = true,
Debug = true,
Comm = true,
TrackerCore = true,
TrackerSync = true,
TrackerUI = true,
TrackerBridge = true,
TrackerState = 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",
TrackerCore = "Tracker Core",
TrackerSync = "Tracker Sync",
TrackerUI = "Tracker UI",
TrackerBridge = "Tracker Bridge",
TrackerState = "Tracker State",
PowerSpend = "Power Spend",
RaidTimeline = "Raid Timeline",
Notes = "Notes",
}
local DEBUG_LEVELS = {
error = 1,
info = 2,
verbose = 3,
}
function HMGT:IsDebugScopeEnabled(scope)
local normalizedScope = tostring(scope or "General")
local settings = self.GetDevToolsSettings and self:GetDevToolsSettings() or nil
local selectedScope = settings and settings.scope 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
local trackerId = nil
local trackerType = nil
if type(tracker) == "table" then
trackerName = tracker.name
trackerId = tonumber(tracker.id)
trackerType = tracker.trackerType
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
local prefix = "Tracker"
if trackerType == "group" then
prefix = "Tracker Group"
elseif trackerType == "normal" then
prefix = "Tracker Normal"
end
if trackerId then
return string.format("%s #%d: %s", prefix, trackerId, trackerName)
end
return prefix .. ": " .. trackerName
end
function HMGT:GetPlayerStatus(playerName, create)
local normalizedName = self:NormalizePlayerName(playerName)
if not normalizedName or normalizedName == "" then
return nil
end
self.playerStatus = self.playerStatus or {}
if create then
self.playerStatus[normalizedName] = self.playerStatus[normalizedName] or {}
end
return self.playerStatus[normalizedName]
end
function HMGT:SetPlayerVersionStatus(playerName, version, protocol, sourceTag)
local status = self:GetPlayerStatus(playerName, true)
if not status then
return nil
end
if version and version ~= "" then
status.version = tostring(version)
end
if tonumber(protocol) then
status.protocol = tonumber(protocol)
end
if sourceTag and sourceTag ~= "" then
status.versionSource = tostring(sourceTag)
end
status.mode = "hmgt"
return status
end
function HMGT:SetPlayerBridgeStatus(playerName, sourceName)
local source = tostring(sourceName or "")
if source == "" then
return nil
end
local status = self:GetPlayerStatus(playerName, true)
if not status then
return nil
end
status.bridgeSource = source
if not status.version or status.version == "" then
status.mode = "bridge"
end
return status
end
function HMGT:GetPlayerAddonStatus(playerName)
local status = self:GetPlayerStatus(playerName, false)
if not status then
return {
mode = "missing",
version = nil,
protocol = 0,
bridgeSource = nil,
}
end
local version = status.version
local protocol = tonumber(status.protocol) or 0
local bridgeSource = status.bridgeSource
local mode = status.mode
if version and version ~= "" then
mode = "hmgt"
elseif bridgeSource and bridgeSource ~= "" then
mode = "bridge"
else
mode = "missing"
end
return {
mode = mode,
version = version,
protocol = protocol,
bridgeSource = bridgeSource,
}
end
function HMGT:ClearPlayerStatus(playerName)
local normalizedName = self:NormalizePlayerName(playerName)
if not normalizedName or not self.playerStatus then
return false
end
if self.playerStatus[normalizedName] then
self.playerStatus[normalizedName] = nil
return true
end
return false
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 settings = self.GetDevToolsSettings and self:GetDevToolsSettings() or nil
local configured = settings and settings.level or "info"
if configured == "trace" then
configured = "verbose"
end
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 _, entry in ipairs(self.devToolsBuffer or {}) do
addScope(entry and entry.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 filtered = {}
local settings = self.GetDevToolsSettings and self:GetDevToolsSettings() or nil
local selectedScope = settings and settings.scope or DEBUG_SCOPE_ALL
for _, entry in ipairs(self.devToolsBuffer or {}) do
local scope = tostring(entry and entry.scope or "General")
local level = tostring(entry and entry.level or "info")
local scopeMatches = (not selectedScope or selectedScope == DEBUG_SCOPE_ALL or scope == selectedScope)
if scopeMatches and self:ShouldIncludeDebugLine(level) then
if self.FormatDevToolsEntry then
filtered[#filtered + 1] = self:FormatDevToolsEntry(entry)
else
filtered[#filtered + 1] = tostring(entry and entry.message or "")
end
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 status = self:GetPlayerStatus(playerName, false)
if status and tonumber(status.protocol) then
return tonumber(status.protocol) or 0
end
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
self:SetPlayerVersionStatus(normalizedName, nil, numeric, nil)
end
local function ParseVersionTokens(version)
local tokens = {}
local text = tostring(version or "")
for number in string.gmatch(text, "(%d+)") do
tokens[#tokens + 1] = tonumber(number) or 0
end
return tokens
end
function HMGT:CompareAddonVersions(leftVersion, rightVersion)
local left = ParseVersionTokens(leftVersion)
local right = ParseVersionTokens(rightVersion)
local count = math.max(#left, #right)
for i = 1, count do
local a = tonumber(left[i]) or 0
local b = tonumber(right[i]) or 0
if a ~= b then
return (a < b) and -1 or 1
end
end
local leftText = tostring(leftVersion or "")
local rightText = tostring(rightVersion or "")
if leftText == rightText then
return 0
end
if leftText < rightText then
return -1
end
return 1
end
function HMGT:IsPlayerGroupLeader()
if not IsInGroup() and not IsInRaid() then
return true
end
return UnitIsGroupLeader and UnitIsGroupLeader("player") or false
end
function HMGT:SendOutdatedVersionWhisper(playerName, remoteVersion)
local target = self:NormalizePlayerName(playerName)
local localVersion = tostring(self.ADDON_VERSION or "dev")
local remoteText = tostring(remoteVersion or "?")
if not target or target == "" or not self:IsPlayerGroupLeader() then
return false
end
if self:CompareAddonVersions(localVersion, remoteText) <= 0 then
return false
end
local warningKey = string.format("%s|%s|%s", tostring(target), remoteText, localVersion)
if self.versionWhisperWarnings[warningKey] then
return false
end
self.versionWhisperWarnings[warningKey] = true
local message = string.format(
L["VERSION_OUTDATED_WHISPER"] or "Your Hail Mary Guild Tools version is outdated. You have %s, the group leader has %s.",
remoteText,
localVersion
)
if C_ChatInfo and type(C_ChatInfo.SendChatMessage) == "function" then
C_ChatInfo.SendChatMessage(message, "WHISPER", nil, target)
elseif type(SendChatMessage) == "function" then
SendChatMessage(message, "WHISPER", nil, target)
else
return false
end
return true
end
function HMGT:RegisterLibSpecializationBridge()
if self._libSpecializationBridgeRegistered then
return true
end
if not LibStub then
return false
end
local LibSpec = LibStub("LibSpecialization", true)
if not LibSpec or type(LibSpec.RegisterGroup) ~= "function" then
return false
end
LibSpec.RegisterGroup(self, function(specId, role, position, playerName, talentString)
if not playerName or not specId then
return
end
local classToken = HMGT:GetClassTokenForSpecId(specId)
HMGT:ApplyExternalSpecInfo("LibSpecialization", playerName, classToken, specId, talentString)
end)
self._libSpecializationBridgeRegistered = true
return true
end
function HMGT:SendReliableAck(target, messageId)
if not target or target == "" or not messageId or messageId == "" then
return
end
self:SendCommMessage(COMM_PREFIX, string.format("%s|%s", MSG_ACK, tostring(messageId)), "WHISPER", target, "ALERT")
end
function HMGT:GetReliableSupersedeKey(target, msgType, payload)
local normalizedTarget = self:NormalizePlayerName(target) or tostring(target or "")
if msgType == MSG_SPELL_STATE then
local sid, kind = tostring(payload):match("^%a+|(%d+)|(%a+)|")
if sid and kind then
return table.concat({ normalizedTarget, msgType, sid, kind }, "|")
end
elseif msgType == MSG_SYNC_RESPONSE then
return table.concat({ normalizedTarget, msgType }, "|")
elseif msgType == MSG_RAID_TIMELINE then
local encounterId, timeSec, spellId, leadTime, alertText =
tostring(payload):match("^%a+|(%d+)|(%d+)|(%d+)|(%d+)|?(.*)$")
if not encounterId then
encounterId, timeSec, spellId, leadTime =
tostring(payload):match("^%a+|(%d+)|(%d+)|(%d+)|(%d+)$")
alertText = ""
end
return table.concat({
normalizedTarget,
msgType,
tostring(encounterId or ""),
tostring(timeSec or ""),
tostring(spellId or ""),
tostring(leadTime or ""),
tostring(alertText or ""),
}, "|")
end
return nil
end
function HMGT:GetNextReliableRetryDelay()
local earliest = nil
local now = GetTime()
for _, pending in pairs(self.pendingReliableMessages or {}) do
if pending then
local nextRetryAt = tonumber(pending.nextRetryAt) or now
if not earliest or nextRetryAt < earliest then
earliest = nextRetryAt
end
end
end
if not earliest then
return nil
end
return math.max(0.05, earliest - now)
end
function HMGT:EnsureReliableCommTicker()
local delay = self:GetNextReliableRetryDelay()
if not delay then
self:StopReliableCommTicker()
return
end
if self.reliableCommTicker then
self:CancelTimer(self.reliableCommTicker, true)
self.reliableCommTicker = nil
end
self.reliableCommTicker = self:ScheduleTimer(function()
self.reliableCommTicker = nil
self:ProcessReliableMessageQueue()
end, delay)
end
function HMGT:StopReliableCommTicker()
if self.reliableCommTicker then
self:CancelTimer(self.reliableCommTicker, true)
self.reliableCommTicker = nil
end
end
function HMGT:HandleReliableAck(senderName, messageId)
local key = string.format("%s|%s", tostring(self:NormalizePlayerName(senderName) or ""), tostring(messageId or ""))
local pending = self.pendingReliableMessages and self.pendingReliableMessages[key]
if pending then
if pending.supersedeKey and self.pendingReliableBySupersede then
self.pendingReliableBySupersede[pending.supersedeKey] = nil
end
self.pendingReliableMessages[key] = nil
self:DebugScoped("verbose", "Comm", "Reliable ACK sender=%s id=%s type=%s", tostring(senderName), tostring(messageId), tostring(pending.msgType))
self:EnsureReliableCommTicker()
end
end
function HMGT:TrackReliableMessage(target, messageId, payload, msgType, supersedeKey)
if not target or target == "" or not messageId or messageId == "" or not payload or payload == "" then
return
end
local normalizedTarget = self:NormalizePlayerName(target)
local key = string.format("%s|%s", tostring(normalizedTarget or target), tostring(messageId))
if supersedeKey and self.pendingReliableBySupersede and self.pendingReliableBySupersede[supersedeKey] then
local previousKey = self.pendingReliableBySupersede[supersedeKey]
local previousPending = self.pendingReliableMessages[previousKey]
if previousPending and previousPending.supersedeKey then
self.pendingReliableBySupersede[previousPending.supersedeKey] = nil
end
self.pendingReliableMessages[previousKey] = nil
end
self.pendingReliableMessages[key] = {
target = target,
normalizedTarget = normalizedTarget,
payload = payload,
msgType = msgType,
supersedeKey = supersedeKey,
sentAt = GetTime(),
retries = 0,
nextRetryAt = GetTime() + 0.75,
}
if supersedeKey then
self.pendingReliableBySupersede[supersedeKey] = key
end
self:EnsureReliableCommTicker()
end
function HMGT:PruneReliableCaches()
local now = GetTime()
for key, pending in pairs(self.pendingReliableMessages or {}) do
if not pending or (pending.retries or 0) >= 2 and now > ((pending.nextRetryAt or 0) + 5) then
if pending and pending.supersedeKey and self.pendingReliableBySupersede then
self.pendingReliableBySupersede[pending.supersedeKey] = nil
end
self.pendingReliableMessages[key] = nil
end
end
for key, expiresAt in pairs(self.receivedReliableMessages or {}) do
if (tonumber(expiresAt) or 0) <= now then
self.receivedReliableMessages[key] = nil
end
end
end
function HMGT:ProcessReliableMessageQueue()
local now = GetTime()
for key, pending in pairs(self.pendingReliableMessages or {}) do
if pending and now >= (tonumber(pending.nextRetryAt) or 0) then
if (tonumber(pending.retries) or 0) >= 2 then
self:DebugScoped("info", "Comm", "Reliable send expired target=%s type=%s id=%s", tostring(pending.target), tostring(pending.msgType), tostring(key:match("|(.+)$") or ""))
if pending.supersedeKey and self.pendingReliableBySupersede then
self.pendingReliableBySupersede[pending.supersedeKey] = nil
end
self.pendingReliableMessages[key] = nil
else
pending.retries = (tonumber(pending.retries) or 0) + 1
pending.nextRetryAt = now + 0.75
self:DebugScoped("verbose", "Comm", "Reliable retry target=%s type=%s try=%d", tostring(pending.target), tostring(pending.msgType), pending.retries)
self:SendCommMessage(COMM_PREFIX, pending.payload, "WHISPER", pending.target, "ALERT")
end
end
end
self:PruneReliableCaches()
self:EnsureReliableCommTicker()
end
function HMGT:SendDirectMessage(payload, target, prio)
if not target or target == "" or not payload or payload == "" then
return false
end
local msgType = tostring(payload):match("^(%a+)")
local peerProtocol = self:GetPeerProtocolVersion(target)
if peerProtocol >= 6 and self:IsReliableCommType(msgType) then
local messageId = self:NextReliableMessageId()
local wrapped = string.format("%s|%s|%s", MSG_RELIABLE, tostring(messageId), payload)
local supersedeKey = self:GetReliableSupersedeKey(target, msgType, payload)
self:TrackReliableMessage(target, messageId, wrapped, msgType, supersedeKey)
self:DebugScoped("verbose", "Comm", "Reliable send target=%s type=%s id=%s", tostring(target), tostring(msgType), tostring(messageId))
self:SendCommMessage(COMM_PREFIX, wrapped, "WHISPER", target, prio or "ALERT")
return true
end
self:SendCommMessage(COMM_PREFIX, payload, "WHISPER", target, prio)
return true
end
function HMGT:DebugScoped(level, scope, fmt, ...)
local normalizedLevel = tostring(level or "info"):lower()
if not DEBUG_LEVELS[normalizedLevel] then
normalizedLevel = "info"
end
local normalizedScope = tostring(scope or "General"):match("^%s*(.-)%s*$")
if normalizedScope == "" then
normalizedScope = "General"
end
local ok, message = pcall(string.format, tostring(fmt or ""), ...)
if not ok then
message = tostring(fmt or "")
end
if self.RecordDebugEntry then
self:RecordDebugEntry(normalizedLevel, normalizedScope, tostring(message or ""))
return
end
self.devToolsBuffer = self.devToolsBuffer or {}
self.devToolsBuffer[#self.devToolsBuffer + 1] = {
stamp = date("%H:%M:%S"),
level = normalizedLevel,
scope = normalizedScope,
message = tostring(message or ""),
kind = "debug",
}
local maxLines = tonumber(self.devToolsBufferMax) or 500
while #self.devToolsBuffer > maxLines do
table.remove(self.devToolsBuffer, 1)
end
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:SetPlayerVersionStatus(playerName, version, protocol, sourceTag)
self:RememberPeerProtocolVersion(playerName, protocol)
if self.versionNoticeWindow and self.versionNoticeWindow.IsShown and self.versionNoticeWindow:IsShown() and self.RefreshVersionNoticeWindow then
self:RefreshVersionNoticeWindow()
end
if version and version ~= "" then
self:SendOutdatedVersionWhisper(playerName, version)
end
local mismatch = false
local details = {}
if version and version ~= "" and version ~= ADDON_VERSION then
mismatch = true
details[#details + 1] = string.format("addon local=%s remote=%s", tostring(ADDON_VERSION), tostring(version))
end
if protocol and tonumber(protocol) and tonumber(protocol) ~= PROTOCOL_VERSION then
mismatch = true
details[#details + 1] = string.format("protocol local=%s remote=%s", tostring(PROTOCOL_VERSION), tostring(protocol))
end
if mismatch and not self.versionWarnings[playerName] then
self.versionWarnings[playerName] = true
self.latestVersionMismatch = {
playerName = playerName,
detail = table.concat(details, " | "),
sourceTag = sourceTag,
}
self:DevTrace("Version", "mismatch_detected", {
player = playerName,
source = sourceTag,
detail = table.concat(details, " | "),
})
local text = string.format(L["VERSION_MISMATCH_CHAT"] or "HMGT mismatch with %s: %s",
tostring(playerName), table.concat(details, " | "))
self:Print("|cffff5555HMGT|r " .. text)
self:ShowVersionMismatchPopup(playerName, table.concat(details, " | "), sourceTag)
self:DebugScoped("info", "TrackerCore", "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",
"TrackerCore",
"%s -> %s von %s, %s",
GetTrackedSpellCategoryLabel(spellEntry),
GetSpellDebugLabel(spellEntry.spellId),
tostring(playerName or "?"),
BuildTrackedSpellCastSummary(spellEntry, details)
)
end
local function IsSpellKnownLocally(spellId)
local sid = tonumber(spellId)
if not sid or sid <= 0 then return false end
if type(IsPlayerSpell) == "function" and IsPlayerSpell(sid) then
return true
end
if C_SpellBook and type(C_SpellBook.IsSpellKnown) == "function" and C_SpellBook.IsSpellKnown(sid) then
return true
end
return false
end
HMGT.TrackerInternals = HMGT.TrackerInternals or {}
HMGT.TrackerInternals.SafeApiNumber = SafeApiNumber
HMGT.TrackerInternals.GetSpellChargesInfo = GetSpellChargesInfo
HMGT.TrackerInternals.GetSpellCooldownInfo = GetSpellCooldownInfo
HMGT.TrackerInternals.IsSpellKnownLocally = IsSpellKnownLocally
HMGT.TrackerInternals.GetGlobalCooldownInfo = GetGlobalCooldownInfo
HMGT.TrackerInternals.GetPlayerAuraApplications = GetPlayerAuraApplications
HMGT.TrackerInternals.GetSpellCastCountInfo = GetSpellCastCountInfo
HMGT.TrackerInternals.GetSpellDebugLabel = GetSpellDebugLabel
HMGT.classColors = {
WARRIOR = {0.78, 0.61, 0.43},
PALADIN = {0.96, 0.55, 0.73},
HUNTER = {0.67, 0.83, 0.45},
ROGUE = {1.00, 0.96, 0.41},
PRIEST = {1.00, 1.00, 1.00},
DEATHKNIGHT = {0.77, 0.12, 0.23},
SHAMAN = {0.00, 0.44, 0.87},
MAGE = {0.41, 0.80, 0.94},
WARLOCK = {0.58, 0.51, 0.79},
MONK = {0.00, 1.00, 0.59},
DRUID = {1.00, 0.49, 0.04},
DEMONHUNTER = {0.64, 0.19, 0.79},
EVOKER = {0.20, 0.58, 0.50},
}
-- ═══════════════════════════════════════════════════════════════
-- INITIALISIERUNG
-- ═══════════════════════════════════════════════════════════════
function HMGT:OnInitialize()
self.db = LibStub("AceDB-3.0"):New("HailMaryGuildToolsDB", defaults, true)
self:MigrateProfileSettings()
self:InitDefaultSpellSettings()
self:ApplyCustomSpellsFromProfile()
if HMGT_Config then
HMGT_Config:Initialize()
end
self:RegisterChatCommand("hmgt", "SlashCommand")
self:RegisterChatCommand("hailmary", "SlashCommand")
end
local function NormalizeBorderSettings(settings)
if settings.borderEnabled == nil then
settings.borderEnabled = (settings.borderTexture and settings.borderTexture ~= "") and true or false
end
if type(settings.borderColor) ~= "table" then
settings.borderColor = { r = 1, g = 1, b = 1, a = 1 }
else
settings.borderColor.r = settings.borderColor.r or settings.borderColor[1] or 1
settings.borderColor.g = settings.borderColor.g or settings.borderColor[2] or 1
settings.borderColor.b = settings.borderColor.b or settings.borderColor[3] or 1
settings.borderColor.a = settings.borderColor.a or settings.borderColor[4] or 1
end
end
local IsKnownAnchorTargetKey
local LEGACY_TRACKER_ANCHOR_IDS = {
InterruptTracker = 1,
RaidCooldownTracker = 2,
GroupCooldownTracker = 3,
}
local function NormalizeTrackerAnchorKey(anchorTo)
local trackerId = LEGACY_TRACKER_ANCHOR_IDS[tostring(anchorTo or "")]
if trackerId then
return "TRACKER:" .. tostring(trackerId)
end
return anchorTo
end
local function NormalizeAnchorSettings(settings)
settings.anchorTo = NormalizeTrackerAnchorKey(settings.anchorTo or "UIParent")
settings.anchorCustom = settings.anchorCustom or ""
if settings.anchorTo ~= "UIParent" and not IsKnownAnchorTargetKey(settings.anchorTo) then
settings.anchorCustom = settings.anchorTo
settings.anchorTo = "CUSTOM"
end
settings.anchorPoint = settings.anchorPoint or "TOPLEFT"
settings.anchorRelPoint = settings.anchorRelPoint or "TOPLEFT"
if settings.anchorX == nil then
settings.anchorX = settings.posX or 0
end
if settings.anchorY == nil then
settings.anchorY = settings.posY or 0
end
end
local function NormalizeTrackerLayout(settings, defaultShowChargesOnIcon, defaultShowSpellTooltip)
local legacyTestWasDemo = (settings.demoMode == nil and settings.testMode == true)
if settings.demoMode == nil then
settings.demoMode = settings.testMode == true
end
if legacyTestWasDemo then
settings.testMode = false
elseif settings.testMode == nil then
settings.testMode = false
end
settings.width = NormalizeLayoutValue(settings.width, 100, 600, 250)
settings.barHeight = NormalizeLayoutValue(settings.barHeight, 10, 60, 20)
settings.barSpacing = NormalizeLayoutValue(settings.barSpacing, 0, 20, 2)
settings.iconSize = NormalizeLayoutValue(settings.iconSize, 12, 100, 32)
settings.iconSpacing = NormalizeLayoutValue(settings.iconSpacing, 0, 20, 2)
settings.iconCols = NormalizeLayoutValue(settings.iconCols, 1, 20, 6)
settings.fontSize = NormalizeLayoutValue(settings.fontSize, 6, 24, 12)
settings.readySoonSec = NormalizeLayoutValue(settings.readySoonSec, 0, 300, 0)
if settings.showOnlyReady == nil then
settings.showOnlyReady = false
end
local trackerType = tostring(settings.trackerType or ""):lower()
if trackerType ~= "normal" and trackerType ~= "group" then
if settings.perGroupMember == true or settings.attachToPartyFrame == true then
trackerType = "group"
else
trackerType = "normal"
end
end
settings.trackerType = trackerType
settings.perGroupMember = (trackerType == "group")
if settings.includeSelfFrame == nil then
settings.includeSelfFrame = false
end
if settings.attachToPartyFrame == nil then
settings.attachToPartyFrame = false
end
if settings.showChargesOnIcon == nil then
settings.showChargesOnIcon = defaultShowChargesOnIcon and true or false
end
settings.showSpellTooltip = true
if settings.roleFilter ~= "ALL" and settings.roleFilter ~= "TANK" and settings.roleFilter ~= "HEALER" and settings.roleFilter ~= "DAMAGER" then
settings.roleFilter = "ALL"
end
if settings.rangeCheck == nil then
settings.rangeCheck = false
end
if settings.hideOutOfRange == nil then
settings.hideOutOfRange = false
end
settings.outOfRangeAlpha = NormalizeLayoutValue(settings.outOfRangeAlpha, 0.1, 1, 0.4)
if settings.partyAttachSide ~= "LEFT" and settings.partyAttachSide ~= "RIGHT" then
settings.partyAttachSide = "RIGHT"
end
settings.partyAttachOffsetX = NormalizeLayoutValue(settings.partyAttachOffsetX, -200, 200, 8)
settings.partyAttachOffsetY = NormalizeLayoutValue(settings.partyAttachOffsetY, -200, 200, 0)
if settings.showReadyText == nil then
settings.showReadyText = true
else
settings.showReadyText = settings.showReadyText ~= false
end
settings.showRemainingOnIcon = settings.showRemainingOnIcon == true
settings.iconOverlay = "sweep"
end
local function NormalizeMapOverlaySettings(settings)
if type(settings) ~= "table" then return end
if settings.enabled == nil then settings.enabled = true end
settings.iconSize = NormalizeLayoutValue(settings.iconSize, 8, 48, 16)
settings.alpha = NormalizeLayoutValue(settings.alpha, 0.1, 1, 1)
if settings.showLabels == nil then settings.showLabels = true end
if type(settings.categories) ~= "table" then settings.categories = { custom = true } end
if settings.categories.custom == nil then settings.categories.custom = true end
if type(settings.pois) ~= "table" then settings.pois = {} end
end
local function NormalizeBuffEndingAnnouncerSettings(settings)
if type(settings) ~= "table" then return end
if settings.enabled == nil then settings.enabled = true end
settings.announceAtSec = math.floor(NormalizeLayoutValue(settings.announceAtSec, 1, 30, 5) + 0.5)
if type(settings.trackedBuffs) ~= "table" then
settings.trackedBuffs = {}
return
end
local normalized = {}
for sid, value in pairs(settings.trackedBuffs) do
local id = tonumber(sid)
if id and id > 0 then
local threshold
if type(value) == "number" then
threshold = value
elseif value == true then
threshold = settings.announceAtSec
elseif type(value) == "table" then
if value.enabled ~= false then
threshold = value.threshold or value.announceAtSec or value.value
end
end
if threshold ~= nil then
normalized[id] = math.floor(NormalizeLayoutValue(threshold, 1, 30, settings.announceAtSec) + 0.5)
end
end
end
settings.trackedBuffs = normalized
end
local function NormalizeRaidTimelineSettings(settings)
if type(settings) ~= "table" then return end
if settings.enabled == nil then settings.enabled = false end
settings.leadTime = math.floor(NormalizeLayoutValue(settings.leadTime, 1, 15, 5) + 0.5)
settings.assignmentLeadTime = math.floor(NormalizeLayoutValue(settings.assignmentLeadTime, 0, 60, settings.leadTime or 5) + 0.5)
settings.unlocked = settings.unlocked == true
settings.alertPosX = NormalizeLayoutValue(settings.alertPosX, -2000, 2000, 0)
settings.alertPosY = NormalizeLayoutValue(settings.alertPosY, -2000, 2000, 180)
settings.alertFont = tostring(settings.alertFont or "Friz Quadrata TT")
settings.alertFontSize = math.floor(NormalizeLayoutValue(settings.alertFontSize, 10, 72, 30) + 0.5)
settings.alertFontOutline = tostring(settings.alertFontOutline or "OUTLINE")
if type(settings.alertColor) ~= "table" then
settings.alertColor = { r = 1, g = 0.82, b = 0.15, a = 1 }
else
settings.alertColor.r = tonumber(settings.alertColor.r or settings.alertColor[1]) or 1
settings.alertColor.g = tonumber(settings.alertColor.g or settings.alertColor[2]) or 0.82
settings.alertColor.b = tonumber(settings.alertColor.b or settings.alertColor[3]) or 0.15
settings.alertColor.a = tonumber(settings.alertColor.a or settings.alertColor[4]) or 1
end
if type(settings.encounters) ~= "table" then
settings.encounters = {}
return
end
local normalizedEncounters = {}
for encounterId, encounter in pairs(settings.encounters) do
local eid = tonumber(encounterId)
if eid and eid > 0 and type(encounter) == "table" then
local normalizedEncounter = {
name = tostring(encounter.name or ""),
journalInstanceId = tonumber(encounter.journalInstanceId) or 0,
instanceName = tostring(encounter.instanceName or ""),
difficulties = type(encounter.difficulties) == "table" and {
lfr = encounter.difficulties.lfr ~= false,
normal = encounter.difficulties.normal ~= false,
heroic = encounter.difficulties.heroic ~= false,
mythic = encounter.difficulties.mythic ~= false,
} or {
lfr = true,
normal = true,
heroic = true,
mythic = true,
},
entries = {},
}
if type(encounter.entries) == "table" then
for _, entry in ipairs(encounter.entries) do
if type(entry) == "table" then
local triggerType = tostring(entry.triggerType or "")
local actionType = tostring(entry.actionType or "")
local entryType = tostring(entry.entryType or "")
local spellId = math.max(0, tonumber(entry.spellId) or 0)
local timeSec = tonumber(entry.time) or 0
local alertText = tostring(entry.alertText or "")
local playerName = tostring(entry.playerName or "")
local targetSpec = tostring(entry.targetSpec or "")
local bossAbilityId = tostring(entry.bossAbilityId or "")
local bossAbilityBarName = tostring(entry.bossAbilityBarName or "")
local castCount = tostring(entry.castCount or ""):gsub("^%s+", ""):gsub("%s+$", ""):lower()
if castCount == "" then
castCount = "1"
elseif castCount ~= "all" and castCount ~= "odd" and castCount ~= "even" then
castCount = tostring(math.max(1, math.floor((tonumber(castCount) or 1) + 0.5)))
end
local valid = false
if triggerType == "bossAbility" then
if actionType == "text" then
valid = bossAbilityBarName ~= "" and alertText ~= ""
elseif actionType == "raidCooldown" then
valid = bossAbilityBarName ~= "" and spellId > 0
end
timeSec = 0
else
triggerType = "time"
if actionType == "text" then
valid = timeSec >= 0 and alertText ~= ""
spellId = 0
bossAbilityId = ""
bossAbilityBarName = ""
castCount = "1"
else
actionType = "raidCooldown"
valid = timeSec >= 0 and spellId > 0
bossAbilityId = ""
bossAbilityBarName = ""
castCount = "1"
end
end
if valid then
normalizedEncounter.entries[#normalizedEncounter.entries + 1] = {
time = math.floor(timeSec + 0.5),
spellId = spellId,
playerName = playerName,
entryType = entryType,
triggerType = triggerType,
actionType = actionType,
targetSpec = targetSpec,
alertText = alertText,
bossAbilityId = bossAbilityId,
bossAbilityBarName = bossAbilityBarName,
castCount = castCount,
}
end
end
end
end
table.sort(normalizedEncounter.entries, function(a, b)
if a.time ~= b.time then
return a.time < b.time
end
if a.spellId ~= b.spellId then
return a.spellId < b.spellId
end
return tostring(a.playerName or "") < tostring(b.playerName or "")
end)
normalizedEncounters[eid] = normalizedEncounter
end
end
settings.encounters = normalizedEncounters
end
local function NormalizeNotesSettings(settings)
if type(settings) ~= "table" then return end
settings.enabled = settings.enabled ~= false
settings.mainText = tostring(settings.mainText or "")
settings.mainTitle = tostring(settings.mainTitle or "")
settings.mainEncounterId = math.max(0, tonumber(settings.mainEncounterId) or 0)
settings.personalText = tostring(settings.personalText or "")
settings.window = type(settings.window) == "table" and settings.window or {}
settings.window.width = math.floor(NormalizeLayoutValue(settings.window.width, 700, 1600, 1080) + 0.5)
settings.window.height = math.floor(NormalizeLayoutValue(settings.window.height, 500, 1000, 700) + 0.5)
local drafts = type(settings.drafts) == "table" and settings.drafts or {}
local normalizedDrafts = {}
local seenIds = {}
for index, draft in ipairs(drafts) do
if type(draft) == "table" then
local draftId = math.max(1, tonumber(draft.id) or index)
while seenIds[draftId] do
draftId = draftId + 1
end
seenIds[draftId] = true
normalizedDrafts[#normalizedDrafts + 1] = {
id = draftId,
title = tostring(draft.title or ""),
text = tostring(draft.text or ""),
encounterId = math.max(0, tonumber(draft.encounterId) or 0),
}
end
end
table.sort(normalizedDrafts, function(a, b)
return (tonumber(a.id) or 0) < (tonumber(b.id) or 0)
end)
settings.drafts = normalizedDrafts
end
local function NormalizeMinimapSettings(settings)
if type(settings) ~= "table" then return end
if settings.hide == nil then settings.hide = false end
local pos = tonumber(settings.minimapPos)
if not pos then
pos = tonumber(settings.angle)
end
if not pos then pos = 220 end
settings.minimapPos = math.fmod(pos, 360)
if settings.minimapPos < 0 then
settings.minimapPos = settings.minimapPos + 360
end
settings.angle = nil
end
local function NormalizeTrackerCategories(categories)
local normalized = {}
local seen = {}
if type(categories) == "table" then
for _, category in ipairs(categories) do
local value = tostring(category or ""):lower()
if value ~= "" and not seen[value] then
seen[value] = true
normalized[#normalized + 1] = value
end
end
end
if #normalized == 0 then
normalized[1] = "interrupt"
end
return normalized
end
local function CopyTrackerFields(target, source)
if type(target) ~= "table" or type(source) ~= "table" then
return target
end
local keys = {
"enabled", "demoMode", "testMode", "showBar", "showSpellTooltip", "locked",
"posX", "posY", "anchorTo", "anchorCustom", "anchorPoint", "anchorRelPoint",
"anchorX", "anchorY", "width", "barHeight", "barSpacing", "barTexture",
"borderEnabled", "borderColor", "iconSize", "iconSpacing", "iconCols",
"iconOverlay", "textAnchor", "fontSize", "font", "fontOutline",
"growDirection", "showInSolo", "showInGroup", "showInRaid", "enabledSpells",
"showPlayerName", "colorByClass", "showChargesOnIcon", "showOnlyReady",
"readySoonSec", "roleFilter", "rangeCheck", "hideOutOfRange",
"outOfRangeAlpha", "showReadyText", "showRemainingOnIcon",
"trackerType", "perGroupMember", "includeSelfFrame", "attachToPartyFrame", "partyAttachSide",
"partyAttachOffsetX", "partyAttachOffsetY",
}
for _, key in ipairs(keys) do
if source[key] ~= nil then
target[key] = DeepCopy(source[key])
end
end
return target
end
function HMGT:CreateTrackerConfig(id, overrides)
local trackerId = tonumber(id) or 1
local settings = {
id = trackerId,
name = string.format("Tracker %d", trackerId),
enabled = true,
demoMode = false,
testMode = false,
trackerType = "normal",
categories = { "interrupt" },
showBar = true,
showSpellTooltip = true,
perGroupMember = false,
includeSelfFrame = false,
locked = false,
attachToPartyFrame = false,
partyAttachSide = "RIGHT",
partyAttachOffsetX = 8,
partyAttachOffsetY = 0,
posX = 200 + ((trackerId - 1) * 300),
posY = -200,
anchorTo = "UIParent",
anchorCustom = "",
anchorPoint = "TOPLEFT",
anchorRelPoint = "TOPLEFT",
anchorX = 200 + ((trackerId - 1) * 300),
anchorY = -200,
width = 250,
barHeight = 20,
barSpacing = 2,
barTexture = "Blizzard",
borderEnabled = false,
borderColor = { r = 1, g = 1, b = 1, a = 1 },
iconSize = 32,
iconSpacing = 2,
iconCols = 6,
iconOverlay = "sweep",
textAnchor = "below",
fontSize = 12,
font = "Friz Quadrata TT",
fontOutline = "OUTLINE",
growDirection = "DOWN",
showInSolo = true,
showInGroup = true,
showInRaid = true,
enabledSpells = {},
showPlayerName = true,
colorByClass = true,
showChargesOnIcon = false,
showOnlyReady = false,
readySoonSec = 0,
roleFilter = "ALL",
rangeCheck = false,
hideOutOfRange = false,
outOfRangeAlpha = 0.4,
showReadyText = true,
showRemainingOnIcon = false,
}
if type(overrides) == "table" then
CopyTrackerFields(settings, overrides)
if overrides.name ~= nil then
settings.name = tostring(overrides.name)
end
if overrides.id ~= nil then
settings.id = tonumber(overrides.id) or trackerId
end
if overrides.categories ~= nil then
settings.categories = DeepCopy(overrides.categories)
end
end
settings.categories = NormalizeTrackerCategories(settings.categories)
settings.name = tostring(settings.name or ""):gsub("^%s+", ""):gsub("%s+$", "")
if settings.name == "" then
settings.name = string.format("Tracker %d", tonumber(settings.id) or trackerId)
end
settings.showReadyText = settings.showReadyText ~= false
settings.showRemainingOnIcon = settings.showRemainingOnIcon == true
if type(settings.enabledSpells) ~= "table" then
settings.enabledSpells = {}
end
NormalizeBorderSettings(settings)
NormalizeAnchorSettings(settings)
NormalizeTrackerLayout(settings, settings.showChargesOnIcon == true, true)
return settings
end
function HMGT:GetTrackerConfigs()
local profile = self.db and self.db.profile
if not profile or type(profile.trackers) ~= "table" then
return {}
end
return profile.trackers
end
function HMGT:GetTrackerConfigById(id)
local trackerId = tonumber(id)
if not trackerId then
return nil
end
for _, tracker in ipairs(self:GetTrackerConfigs()) do
if tonumber(tracker.id) == trackerId then
return tracker
end
end
return nil
end
function HMGT:GetNextTrackerId()
local nextId = 1
for _, tracker in ipairs(self:GetTrackerConfigs()) do
nextId = math.max(nextId, (tonumber(tracker.id) or 0) + 1)
end
return nextId
end
function HMGT:GetTrackerAnchorKey(id)
local trackerId = tonumber(id)
if not trackerId then
return nil
end
return "TRACKER:" .. tostring(trackerId)
end
function HMGT:MigrateProfileSettings()
local p = self.db and self.db.profile
if not p then return end
local oldDebugEnabled = p.debug == true
local oldDebugLevel = p.debugLevel
local oldDebugScope = p.debugScope
p.devTools = type(p.devTools) == "table" and p.devTools or {}
p.devTools.enabled = p.devTools.enabled == true or oldDebugEnabled
if p.devTools.level == "trace" then
p.devTools.level = "verbose"
elseif p.devTools.level ~= "error" and p.devTools.level ~= "info" and p.devTools.level ~= "verbose" then
p.devTools.level = (oldDebugLevel == "error" or oldDebugLevel == "info" or oldDebugLevel == "verbose")
and oldDebugLevel
or "info"
end
if type(p.devTools.scope) ~= "string" or p.devTools.scope == "" then
p.devTools.scope = (type(oldDebugScope) == "string" and oldDebugScope ~= "") and oldDebugScope or "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.debug = nil
p.debugLevel = nil
p.debugScope = nil
p.syncRemoteCharges = true
if type(p.trackers) ~= "table" then
p.trackers = {}
end
if #p.trackers == 0 and p.trackerModelVersion ~= TRACKER_MODEL_VERSION then
local legacyInterrupt = type(p.interruptTracker) == "table" and p.interruptTracker or {}
local legacyRaid = type(p.raidCooldownTracker) == "table" and p.raidCooldownTracker or {}
local legacyGroup = type(p.groupCooldownTracker) == "table" and p.groupCooldownTracker or {}
NormalizeBorderSettings(legacyInterrupt)
NormalizeAnchorSettings(legacyInterrupt)
NormalizeTrackerLayout(legacyInterrupt, false, true)
NormalizeBorderSettings(legacyRaid)
NormalizeAnchorSettings(legacyRaid)
NormalizeTrackerLayout(legacyRaid, false, true)
NormalizeBorderSettings(legacyGroup)
NormalizeAnchorSettings(legacyGroup)
NormalizeTrackerLayout(legacyGroup, true, true)
p.trackers = {
self:BuildTrackerConfigFromPreset("interruptTracker", 1, CopyTrackerFields({}, legacyInterrupt)),
self:BuildTrackerConfigFromPreset("raidCooldownTracker", 2, CopyTrackerFields({}, legacyRaid)),
self:BuildTrackerConfigFromPreset("groupCooldownTracker", 3, CopyTrackerFields({}, legacyGroup)),
}
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:BuildTrackerConfigFromPreset("interruptTracker", 1)
end
p.trackers = normalizedTrackers
p.trackerModelVersion = TRACKER_MODEL_VERSION
p.interruptTracker = nil
p.raidCooldownTracker = nil
p.groupCooldownTracker = nil
p.mapOverlay = p.mapOverlay or {}
NormalizeMapOverlaySettings(p.mapOverlay)
p.buffEndingAnnouncer = p.buffEndingAnnouncer or {}
NormalizeBuffEndingAnnouncerSettings(p.buffEndingAnnouncer)
p.personalAuras = nil
p.raidTimeline = p.raidTimeline or {}
NormalizeRaidTimelineSettings(p.raidTimeline)
p.notes = p.notes or {}
NormalizeNotesSettings(p.notes)
p.minimap = p.minimap or {}
NormalizeMinimapSettings(p.minimap)
p.autoEnemyMarker = nil
end
function HMGT:OnEnable()
if self.EnsureTrackerStateTables then
self:EnsureTrackerStateTables()
end
self:RegisterComm(COMM_PREFIX, "OnCommReceived")
-- UNIT_SPELLCAST_SUCCEEDED für unitTag "player" → eigene Casts
if not self.unitEventFrame then
self.unitEventFrame = CreateFrame("Frame")
self.unitEventFrame:SetScript("OnEvent", function(_, event, ...)
if event == "UNIT_SPELLCAST_SENT" then
self:OnUnitSpellCastSent(event, ...)
elseif event == "UNIT_SPELLCAST_SUCCEEDED" then
self:OnUnitSpellCastSucceeded(event, ...)
elseif event == "UNIT_AURA" then
self:OnUnitAura(event, ...)
end
end)
end
self.unitEventFrame:RegisterUnitEvent("UNIT_SPELLCAST_SENT", "player")
self.unitEventFrame:RegisterUnitEvent("UNIT_SPELLCAST_SUCCEEDED", "player")
self.unitEventFrame:RegisterUnitEvent("UNIT_AURA", "player")
self:RegisterEvent("PLAYER_REGEN_ENABLED", "OnPlayerRegenEnabled")
self:RegisterEvent("GROUP_ROSTER_UPDATE", "OnGroupRosterUpdate")
self:RegisterEvent("PLAYER_ENTERING_WORLD", "OnPlayerEnteringWorld")
self:RegisterEvent("LOADING_SCREEN_DISABLED", "OnLoadingScreenDisabled")
-- PLAYER_LOGIN feuert nachdem der Char vollständig geladen ist
-- erst dann liefert GetSpecialization() zuverlässig den richtigen Wert.
self:RegisterEvent("PLAYER_LOGIN", "OnPlayerLogin")
-- Spec-Wechsel im Spiel
self:RegisterEvent("PLAYER_TALENT_UPDATE", "OnPlayerTalentUpdate")
self:RegisterEvent("ACTIVE_TALENT_GROUP_CHANGED", "OnPlayerTalentUpdate")
self:RegisterEvent("PLAYER_SPECIALIZATION_CHANGED","OnPlayerTalentUpdate")
-- Gruppen-Sichtbarkeit neu auswerten wenn sich die Zusammensetzung ändert
self:RegisterEvent("RAID_ROSTER_UPDATE", "OnGroupRosterUpdate")
self:RegisterLibSpecializationBridge()
if not self.cleanupTicker then
self.cleanupTicker = C_Timer.NewTicker(15, function() self:CleanupStaleCooldowns() end)
end
if not self.stateRepairTicker then
self.stateRepairTicker = C_Timer.NewTicker(5, function() self:BroadcastRepairSpellStates() end)
end
self:UpdateOwnPlayerInfo()
if HMGT.TrackerManager then
HMGT.TrackerManager:Enable()
end
if HMGT.MapOverlay and not HMGT.MapOverlay:IsEnabled() then HMGT.MapOverlay:Enable() end
self:RefreshFrameAnchors(true)
self:UpdateDebugWindowVisibility()
-- Initialize minimap launcher (LibDBIcon with legacy fallback).
self:CreateMinimapButton()
self:Print(L["ADDON_LOADED"])
end
function HMGT:RefreshFrameAnchors(force)
if not HMGT.TrackerFrame or not HMGT.TrackerFrame.ApplyAnchor then return end
if HMGT.TrackerManager and HMGT.TrackerManager.RefreshAnchors then
HMGT.TrackerManager:RefreshAnchors(force)
end
end
local COMMON_ANCHOR_TARGETS = {
{ key = "PlayerFrame", label = "Player Frame (PlayerFrame)" },
{ key = "TargetFrame", label = "Target Frame (TargetFrame)" },
{ key = "FocusFrame", label = "Focus Frame (FocusFrame)" },
{ key = "PetFrame", label = "Pet Frame (PetFrame)" },
{ key = "PartyMemberFrame1", label = "Party 1 (PartyMemberFrame1)" },
{ key = "PartyMemberFrame2", label = "Party 2 (PartyMemberFrame2)" },
{ key = "PartyMemberFrame3", label = "Party 3 (PartyMemberFrame3)" },
{ key = "PartyMemberFrame4", label = "Party 4 (PartyMemberFrame4)" },
{ key = "CompactRaidFrameManager", label = "Raid Manager (CompactRaidFrameManager)" },
{ key = "ElvUF_Player", label = "ElvUI Player (ElvUF_Player)" },
{ key = "ElvUF_Target", label = "ElvUI Target (ElvUF_Target)" },
{ key = "ElvUF_Focus", label = "ElvUI Focus (ElvUF_Focus)" },
{ key = "ElvUF_Pet", label = "ElvUI Pet (ElvUF_Pet)" },
{ key = "ElvUF_PartyGroup1UnitButton1", label = "ElvUI Party 1 (ElvUF_PartyGroup1UnitButton1)" },
{ key = "ElvUF_PartyGroup1UnitButton2", label = "ElvUI Party 2 (ElvUF_PartyGroup1UnitButton2)" },
{ key = "ElvUF_PartyGroup1UnitButton3", label = "ElvUI Party 3 (ElvUF_PartyGroup1UnitButton3)" },
{ key = "ElvUF_PartyGroup1UnitButton4", label = "ElvUI Party 4 (ElvUF_PartyGroup1UnitButton4)" },
{ key = "ElvUF_PartyGroup1UnitButton5", label = "ElvUI Party 5 (ElvUF_PartyGroup1UnitButton5)" },
{ key = "ElvUF_Raid1UnitButton1", label = "ElvUI Raid 1 (ElvUF_Raid1UnitButton1)" },
}
local function ParseTrackerAnchorKey(anchorTo)
local trackerId = tostring(anchorTo or ""):match("^TRACKER:(%d+)$")
return tonumber(trackerId)
end
local function GetTrackerAnchorLabelById(trackerId)
local tracker = HMGT.GetTrackerConfigById and HMGT:GetTrackerConfigById(trackerId) or nil
if tracker then
local name = tostring(tracker.name or ""):gsub("^%s+", ""):gsub("%s+$", "")
if name ~= "" then
return name
end
end
if trackerId then
return string.format("%s %d", L["OPT_TRACKER"] or "Tracker", tonumber(trackerId) or 0)
end
return L["OPT_TRACKER"] or "Tracker"
end
IsKnownAnchorTargetKey = function(anchorTo)
anchorTo = NormalizeTrackerAnchorKey(anchorTo)
if anchorTo == "UIParent" or anchorTo == "CUSTOM" then return true end
if ParseTrackerAnchorKey(anchorTo) then return true end
for _, target in ipairs(COMMON_ANCHOR_TARGETS) do
if target.key == anchorTo then
return true
end
end
return false
end
--- Liefert die aktuell waehlbaren Anchor-Targets fuer das Config-Dropdown.
--- Enthalten sind Tracker-Frames sowie eine feste Liste sinnvoller UI-Frames.
function HMGT:GetAnchorTargetOptions(currentTrackerId, selectedValue)
local values = {
UIParent = L["OPT_ANCHOR_TARGET_UI"] or "UIParent",
CUSTOM = L["OPT_ANCHOR_TARGET_CUSTOM"] or "Custom frame name",
}
local selectedKey = NormalizeTrackerAnchorKey(selectedValue)
local activeTrackerId = tonumber(currentTrackerId) or ParseTrackerAnchorKey(currentTrackerId)
for _, tracker in ipairs(self:GetTrackerConfigs()) do
local trackerId = tonumber(tracker.id)
local anchorKey = self:GetTrackerAnchorKey(trackerId)
if trackerId and anchorKey and trackerId ~= activeTrackerId then
values[anchorKey] = GetTrackerAnchorLabelById(trackerId)
end
end
for _, target in ipairs(COMMON_ANCHOR_TARGETS) do
values[target.key] = target.label
end
if selectedKey and selectedKey ~= "" and not values[selectedKey] then
local trackerId = ParseTrackerAnchorKey(selectedKey)
if trackerId then
values[selectedKey] = GetTrackerAnchorLabelById(trackerId)
else
values[selectedKey] = selectedKey
end
end
return values
end
--- Loest ein gespeichertes Anchor-Ziel in ein echtes Frame-Objekt auf.
function HMGT:GetAnchorTargetFrame(anchorTo, anchorCustom)
anchorTo = NormalizeTrackerAnchorKey(anchorTo)
local trackerId = ParseTrackerAnchorKey(anchorTo)
if trackerId then
local tracker = self:GetTrackerConfigById(trackerId)
if tracker and HMGT.TrackerManager then
if type(HMGT.TrackerManager.GetAnchorFrame) == "function" then
return HMGT.TrackerManager:GetAnchorFrame(tracker)
end
if type(HMGT.TrackerManager.EnsureFrame) == "function" then
return HMGT.TrackerManager:EnsureFrame(tracker)
end
end
end
local resolved = anchorTo
if anchorTo == "CUSTOM" then
resolved = anchorCustom
end
if type(resolved) == "string" and resolved ~= "" then
local target = _G[resolved]
if target and target.IsObjectType and (target:IsObjectType("Frame") or target:IsObjectType("Button")) then
return target
end
end
return UIParent
end
function HMGT:OnDisable()
if self.unitEventFrame then
self.unitEventFrame:UnregisterAllEvents()
end
if HMGT.TrackerManager then HMGT.TrackerManager:Disable() end
self:StopReliableCommTicker()
self.pendingReliableMessages = {}
self.pendingReliableBySupersede = {}
self.recentGroupMessages = {}
if self.cleanupTicker then
self.cleanupTicker:Cancel()
self.cleanupTicker = nil
end
if self._syncRequestTimer then
self:CancelTimer(self._syncRequestTimer, true)
self._syncRequestTimer = nil
end
if self._syncBurstTimers then
for _, timerHandle in ipairs(self._syncBurstTimers) do
self:CancelTimer(timerHandle, true)
end
self._syncBurstTimers = nil
end
if LDBIcon and self._ldbIconRegistered then
LDBIcon:Hide(ADDON_NAME)
end
end
-- ═══════════════════════════════════════════════════════════════
-- SPELL-STANDARDS
-- ═══════════════════════════════════════════════════════════════
function HMGT:InitDefaultSpellSettings()
for _, tracker in ipairs(self:GetTrackerConfigs()) do
if type(tracker.enabledSpells) ~= "table" then
tracker.enabledSpells = {}
end
end
end
function HMGT:ApplyCustomSpellsFromProfile()
if HMGT_SpellData.RebuildLookups then
HMGT_SpellData.RebuildLookups()
end
self:InitDefaultSpellSettings()
end
function HMGT:AddCustomSpell(dbKey, spellId, cooldown, classToken, specText, category)
return false
end
function HMGT:RemoveCustomSpell(dbKey, spellId)
return false
end
-- ═══════════════════════════════════════════════════════════════
-- EIGENE SPIELER-INFO
-- ═══════════════════════════════════════════════════════════════
function HMGT:UpdateOwnPlayerInfo()
local name = self:NormalizePlayerName(UnitName("player"))
local class = select(2, UnitClass("player"))
local specIndex = GetSpecialization()
-- GetSpecialization() liefert 0 wenn der Char noch nicht vollstaendig geladen ist.
-- NIEMALS auf Spec 1 defaulten - das wuerde z.B. einem Prot-Krieger Pummel (Arms/Fury)
-- statt Schildschlag (Prot) anzeigen. Stattdessen: 0.5s warten und erneut versuchen.
if not specIndex or specIndex == 0 then
C_Timer.After(0.5, function() self:UpdateOwnPlayerInfo() end)
return
end
local talents = {}
local configID = C_ClassTalents and C_ClassTalents.GetActiveConfigID
and C_ClassTalents.GetActiveConfigID()
if configID then
local configInfo = C_Traits.GetConfigInfo(configID)
if configInfo then
for _, treeID in ipairs(configInfo.treeIDs or {}) do
for _, nodeID in ipairs(C_Traits.GetTreeNodes(treeID) or {}) do
local nodeInfo = C_Traits.GetNodeInfo(configID, nodeID)
if nodeInfo and nodeInfo.activeRank and nodeInfo.activeRank > 0 then
local entryID = nodeInfo.activeEntry and nodeInfo.activeEntry.entryID
if entryID then
local entryInfo = C_Traits.GetEntryInfo(configID, entryID)
if entryInfo and entryInfo.definitionID then
local defInfo = C_Traits.GetDefinitionInfo(entryInfo.definitionID)
if defInfo and defInfo.spellID then
talents[defInfo.spellID] = true
end
end
end
end
end
end
end
end
self.knownChargeInfo = self.knownChargeInfo or {}
wipe(self.knownChargeInfo)
self.playerData[name] = {
class = class,
specIndex = specIndex,
talentHash = self:HashTalents(talents),
talents = talents,
knownSpells = self:CollectOwnAvailableTrackerSpells(class, specIndex),
isOwn = true,
}
self:RefreshOwnAvailabilityStates()
self:ResetOwnPowerTracking()
-- Tracker sofort nach erfolgreicher Spec-Erkennung aktualisieren
self:TriggerTrackerUpdate()
self:QueueSyncRequest(0.20)
end
function HMGT:ResetOwnPowerTracking()
self.powerTracking = self.powerTracking or {}
self.powerTracking.accumulators = self.powerTracking.accumulators or {}
self.pendingSpellPowerCosts = self.pendingSpellPowerCosts or {}
wipe(self.powerTracking.accumulators)
wipe(self.pendingSpellPowerCosts)
end
function HMGT:HashTalents(talents)
local ids = {}
for id in pairs(talents) do table.insert(ids, id) end
table.sort(ids)
return table.concat(ids, ",")
end
function HMGT:NormalizePlayerName(name)
if not name or name == "" then return nil end
return Ambiguate(name, "short")
end
function HMGT:ParseTalentHash(hash)
local talents = {}
if hash and hash ~= "" then
for id in hash:gmatch("(%d+)") do
talents[tonumber(id)] = true
end
end
return talents
end
function HMGT:ParseKnownSpellList(listStr)
local knownSpells = {}
if type(listStr) == "string" and listStr ~= "" then
for id in listStr:gmatch("(%d+)") do
knownSpells[tonumber(id)] = true
end
end
return knownSpells
end
function HMGT:SerializeKnownSpellList(knownSpells)
local ids = {}
if type(knownSpells) == "table" then
for sid, known in pairs(knownSpells) do
if known then
ids[#ids + 1] = tonumber(sid)
end
end
end
table.sort(ids)
for i = 1, #ids do
ids[i] = tostring(ids[i])
end
return table.concat(ids, ",")
end
function HMGT:GetAvailabilityConfig(spellEntry)
if HMGT_SpellData and type(HMGT_SpellData.GetAvailabilityConfig) == "function" then
local availability = HMGT_SpellData.GetAvailabilityConfig(spellEntry)
if type(availability) == "table" and availability.type then
return availability
end
end
local availability = spellEntry and spellEntry.availability
if type(availability) ~= "table" or not availability.type then
return nil
end
return availability
end
function HMGT:IsAvailabilitySpell(spellEntry)
return self:GetAvailabilityConfig(spellEntry) ~= nil
end
function HMGT:GetAvailabilityRequiredCount(spellEntry)
local required = 0
if HMGT_SpellData and type(HMGT_SpellData.GetEffectiveAvailabilityRequired) == "function" then
local ownName = self:NormalizePlayerName(UnitName("player"))
local pData = ownName and self.playerData and self.playerData[ownName]
required = HMGT_SpellData.GetEffectiveAvailabilityRequired(spellEntry, pData and pData.talents or {})
end
if required <= 0 then
local availability = self:GetAvailabilityConfig(spellEntry)
required = tonumber(availability and availability.required) or 0
end
if required <= 0 then
return 0
end
return math.max(1, math.floor(required + 0.5))
end
-- ═══════════════════════════════════════════════════════════════
-- KOMMUNIKATION
-- ═══════════════════════════════════════════════════════════════
function HMGT:SendGroupMessage(msg, prio)
-- Nur senden, wenn mindestens ein echter Mitspieler in der Gruppe ist.
-- Das verhindert Fehlermeldungen in Follower-Dungeons (NPC-Begleiter, kein Party-Chat).
local hasPlayers = false
if IsInRaid() then
for i = 1, GetNumGroupMembers() do
local unit = "raid" .. i
if UnitExists(unit) and not UnitIsUnit(unit, "player") and UnitIsPlayer(unit) then
hasPlayers = true
break
end
end
elseif IsInGroup() then
for i = 1, GetNumSubgroupMembers() do
local unit = "party" .. i
if UnitExists(unit) and UnitIsPlayer(unit) then
hasPlayers = true
break
end
end
end
if not hasPlayers then return end
local channel
if IsInGroup(LE_PARTY_CATEGORY_INSTANCE) then
channel = "INSTANCE_CHAT"
elseif IsInRaid() then
channel = "RAID"
elseif IsInGroup(LE_PARTY_CATEGORY_HOME) or IsInGroup() then
channel = "PARTY"
else
return
end
local dedupeKey = string.format("%s|%s|%s", tostring(channel), tostring(prio or "NORMAL"), tostring(msg))
local now = GetTime()
local lastSentAt = tonumber(self.recentGroupMessages[dedupeKey]) or 0
if now - lastSentAt < 0.10 then
self:DebugScoped("verbose", "Comm", "SendGroupMessage deduped channel=%s prio=%s", tostring(channel), tostring(prio or "NORMAL"))
return
end
self.recentGroupMessages[dedupeKey] = now
if next(self.recentGroupMessages) then
local count = 0
for key, sentAt in pairs(self.recentGroupMessages) do
count = count + 1
if count > 200 or (now - (tonumber(sentAt) or 0)) > 5 then
self.recentGroupMessages[key] = nil
end
end
end
self:DebugScoped("verbose", "Comm", "SendGroupMessage channel=%s prio=%s payload=%s", tostring(channel), tostring(prio or "NORMAL"), tostring(msg))
self:SendCommMessage(COMM_PREFIX, msg, channel, nil, prio)
end
-- ═══════════════════════════════════════════════════════════════
-- EVENTS
-- ═══════════════════════════════════════════════════════════════
function HMGT:OnUnitSpellCastSent(event, unitTag, targetName, castGUID, spellId)
if unitTag == "player" then
self:CaptureOwnSpellPowerCosts(spellId)
end
end
function HMGT:OnUnitSpellCastSucceeded(event, unitTag, castGUID, spellId)
if unitTag == "player" then
self:HandleOwnSpellCast(spellId)
self:HandleOwnCooldownReductionTrigger(spellId)
self:HandleOwnPowerSpendFromSpell(spellId)
end
end
function HMGT:OnUnitAura(event, unitTag)
if unitTag ~= "player" then return end
if self:RefreshAndPublishOwnAvailabilityStates() then
self:TriggerTrackerUpdate()
end
end
function HMGT:OnPlayerRegenEnabled()
self:RefreshAndPublishOwnAvailabilityStates()
end
local function BuildCooldownStateFingerprint(cdData)
if not cdData then
return "nil"
end
return table.concat({
string.format("%.3f", tonumber(cdData.startTime) or 0),
string.format("%.3f", tonumber(cdData.duration) or 0),
tostring(tonumber(cdData.currentCharges) or -1),
tostring(tonumber(cdData.maxCharges) or -1),
string.format("%.3f", tonumber(cdData.chargeStart) or 0),
string.format("%.3f", tonumber(cdData.chargeDuration) or 0),
}, "|")
end
function HMGT:ApplyCooldownReduction(playerName, targetSpellId, amount)
local sid = tonumber(targetSpellId)
local reduceBy = tonumber(amount) or 0
if not playerName or not sid or sid <= 0 or reduceBy <= 0 then
return 0
end
local spells = self:GetPlayerCooldownMap(playerName, false)
if not spells then return 0 end
local cdData = spells[sid]
if not cdData then return 0 end
local now = GetTime()
local applied = 0
local hasCharges = (tonumber(cdData.maxCharges) or 0) > 0
if hasCharges then
local nextRem, chargeDur, charges, maxCharges = self:ResolveChargeState(cdData, now)
if chargeDur <= 0 or charges >= maxCharges then
return 0
end
local left = reduceBy
local rem = math.max(0, nextRem)
while left > 0 and charges < maxCharges do
if rem <= 0 then rem = chargeDur end
if rem <= left then
left = left - rem
applied = applied + rem
charges = charges + 1
if charges < maxCharges then
rem = chargeDur
else
rem = 0
end
else
rem = rem - left
applied = applied + left
left = 0
end
end
if applied <= 0 then return 0 end
cdData.currentCharges = charges
cdData.maxCharges = maxCharges
cdData.chargeDuration = chargeDur
if charges < maxCharges then
cdData.chargeStart = now - math.max(0, chargeDur - rem)
local missing = maxCharges - charges
cdData.startTime = cdData.chargeStart
cdData.duration = missing * chargeDur
self:RefreshCooldownExpiryTimer(playerName, sid, cdData)
else
spells[sid] = nil
end
else
local duration = tonumber(cdData.duration) or 0
local startTime = tonumber(cdData.startTime) or now
local remaining = math.max(0, duration - (now - startTime))
if remaining <= 0 then return 0 end
applied = math.min(reduceBy, remaining)
local newRemaining = remaining - applied
if newRemaining <= 0 then
spells[sid] = nil
else
cdData.startTime = now - math.max(0, duration - newRemaining)
self:RefreshCooldownExpiryTimer(playerName, sid, cdData)
end
end
if spells and not next(spells) then
self:ClearPlayerCooldowns(playerName)
end
if playerName == self:NormalizePlayerName(UnitName("player")) then
self:PublishOwnSpellState(sid)
end
self:TriggerTrackerUpdate()
return applied
end
function HMGT:IsNewCooldownReduceEvent(playerName, targetSpellId, castTimestamp, triggerSpellId)
local ts = tonumber(castTimestamp) or 0
if ts <= 0 then return true end
self._recentReduceEvents = self._recentReduceEvents or {}
local key = string.format(
"%s:%d:%d:%d",
tostring(playerName or ""),
tonumber(targetSpellId) or 0,
tonumber(triggerSpellId) or 0,
ts
)
local now = GetTime()
local last = self._recentReduceEvents[key]
if last and (now - last) < 5 then
return false
end
self._recentReduceEvents[key] = now
if not self._recentReduceEventsGcAt or (now - self._recentReduceEventsGcAt) > 30 then
for k, seenAt in pairs(self._recentReduceEvents) do
if (now - seenAt) > 120 then
self._recentReduceEvents[k] = nil
end
end
self._recentReduceEventsGcAt = now
end
return true
end
local function ApplyOwnCooldownReducers(self, ownName, triggerSpellId, reducers, castTs)
for _, reducer in ipairs(reducers) do
local applied = self:ApplyCooldownReduction(ownName, reducer.targetSpellId, reducer.amount)
if applied > 0 then
self:Debug(
"verbose",
"LocalCooldownReduce trigger=%s target=%s amount=%.2f applied=%.2f",
tostring(triggerSpellId),
tostring(reducer.targetSpellId),
tonumber(reducer.amount) or 0,
tonumber(applied) or 0
)
self:BroadcastCooldownReduce(reducer.targetSpellId, applied, castTs, triggerSpellId)
end
end
end
local function ApplyObservedCooldownReducers(self, ownName, reducers)
local targetDelays = {}
for _, reducer in ipairs(reducers) do
local targetSpellId = tonumber(reducer.targetSpellId)
if targetSpellId and targetSpellId > 0 then
local observe = reducer.observe
local delay = tonumber(observe and observe.delay) or 0.12
targetDelays[targetSpellId] = math.max(targetDelays[targetSpellId] or 0, delay)
end
end
for targetSpellId, delay in pairs(targetDelays) do
local retryOffsets = { 0, 0.20, 0.55 }
for _, offset in ipairs(retryOffsets) do
C_Timer.After(delay + offset, function()
if not self or not self.playerData or not self.playerData[ownName] then
return
end
if self:RefreshOwnCooldownStateFromGame(targetSpellId) then
self:PublishOwnSpellState(targetSpellId, { sendLegacy = true })
self:TriggerTrackerUpdate()
end
end)
end
end
end
HMGT.TrackerInternals.BuildCooldownStateFingerprint = BuildCooldownStateFingerprint
HMGT.TrackerInternals.ApplyOwnCooldownReducers = ApplyOwnCooldownReducers
HMGT.TrackerInternals.ApplyObservedCooldownReducers = ApplyObservedCooldownReducers
function HMGT:CaptureOwnSpellPowerCosts(spellId)
local sid = tonumber(spellId)
if not sid or sid <= 0 then return end
local ownName = self:NormalizePlayerName(UnitName("player"))
if not ownName then return end
local pData = self.playerData[ownName]
local classToken = pData and pData.class or select(2, UnitClass("player"))
local specIndex = pData and pData.specIndex or GetSpecialization()
local talents = pData and pData.talents or {}
if not classToken or not specIndex then return end
local trackedPowerTypes = HMGT_SpellData
and type(HMGT_SpellData.GetTrackedPowerTypes) == "function"
and HMGT_SpellData.GetTrackedPowerTypes(classToken, specIndex, talents)
or nil
if type(trackedPowerTypes) ~= "table" then
return
end
local pending = nil
for powerType in pairs(trackedPowerTypes) do
local spent = GetSpellPowerCostByToken(sid, powerType)
if spent <= 0 then
spent = GetTrackedPowerSpendOverride(classToken, specIndex, sid, powerType)
end
if spent > 0 then
pending = pending or { capturedAt = GetTime() }
pending[NormalizePowerToken(powerType)] = spent
end
end
if pending then
self.pendingSpellPowerCosts = self.pendingSpellPowerCosts or {}
self.pendingSpellPowerCosts[sid] = pending
end
end
function HMGT:HandleOwnPowerSpendFromSpell(spellId)
local sid = tonumber(spellId)
if not sid or sid <= 0 then return end
local ownName = self:NormalizePlayerName(UnitName("player"))
if not ownName then return end
local pData = self.playerData[ownName]
local classToken = pData and pData.class or select(2, UnitClass("player"))
local specIndex = pData and pData.specIndex or GetSpecialization()
local talents = pData and pData.talents or {}
if not classToken or not specIndex then return end
local trackedPowerTypes = HMGT_SpellData
and type(HMGT_SpellData.GetTrackedPowerTypes) == "function"
and HMGT_SpellData.GetTrackedPowerTypes(classToken, specIndex, talents)
or nil
if type(trackedPowerTypes) ~= "table" then
return
end
self.pendingSpellPowerCosts = self.pendingSpellPowerCosts or {}
local pending = self.pendingSpellPowerCosts[sid]
local pendingFresh = type(pending) == "table"
and (GetTime() - (tonumber(pending.capturedAt) or 0)) <= 2
local debugLabel = GetSpellDebugLabel(sid)
local detectedAnySpend = false
for powerType in pairs(trackedPowerTypes) do
local token = NormalizePowerToken(powerType)
local spent = pendingFresh and tonumber(pending[token]) or 0
local source = pendingFresh and spent > 0 and "cache" or nil
if spent <= 0 then
spent = GetSpellPowerCostByToken(sid, powerType)
if spent > 0 then
source = "api"
end
end
if spent <= 0 then
spent = GetTrackedPowerSpendOverride(classToken, specIndex, sid, powerType)
if spent > 0 then
source = "override"
end
end
if spent > 0 then
detectedAnySpend = true
self:DebugScoped("verbose", "PowerSpend", "%s -> %s spend=%.0f via %s",
debugLabel,
tostring(token),
spent,
tostring(source or "unknown")
)
self:HandleOwnPowerSpent(powerType, spent, {
spellId = sid,
spellLabel = debugLabel,
source = source or "unknown",
})
end
end
if not detectedAnySpend then
self:DebugScoped("verbose", "PowerSpend", "%s -> kein getrackter Spend erkannt", debugLabel)
end
self.pendingSpellPowerCosts[sid] = nil
end
function HMGT:HandleOwnPowerSpent(powerType, amountSpent, context)
local token = NormalizePowerToken(powerType)
local spent = tonumber(amountSpent) or 0
if not token or spent <= 0 then return end
local ownName = self:NormalizePlayerName(UnitName("player"))
if not ownName then return end
local pData = self.playerData[ownName]
local classToken = pData and pData.class or select(2, UnitClass("player"))
local specIndex = pData and pData.specIndex or GetSpecialization()
local talents = pData and pData.talents or {}
if not classToken or not specIndex then return end
local sourceLabel = type(context) == "table" and tostring(context.source or "unknown") or "unknown"
local spellLabel = type(context) == "table" and tostring(context.spellLabel or GetSpellDebugLabel(context.spellId)) or "Unknown"
local reducers = HMGT_SpellData.GetCooldownReducersForPowerSpend(classToken, specIndex, token, talents)
if not reducers or #reducers == 0 then
self:DebugScoped("verbose", "PowerSpend", "%s -> %s spend=%.0f via %s, aber keine Power-Reducer aktiv",
spellLabel,
tostring(token),
spent,
sourceLabel
)
return
end
self.powerTracking = self.powerTracking or {}
self.powerTracking.accumulators = self.powerTracking.accumulators or {}
local reducerGroups = {}
for _, reducer in ipairs(reducers) do
local relationKey = tostring(reducer.relationKey or "")
local threshold = tonumber(reducer.amountPerTrigger) or 0
if relationKey ~= "" and threshold > 0 then
local group = reducerGroups[relationKey]
if not group then
group = {
threshold = threshold,
reducers = {},
}
reducerGroups[relationKey] = group
end
group.reducers[#group.reducers + 1] = reducer
end
end
local eventTimestamp = GetServerTime()
for relationKey, group in pairs(reducerGroups) do
local threshold = tonumber(group.threshold) or 0
if threshold > 0 then
local previousBucket = tonumber(self.powerTracking.accumulators[relationKey]) or 0
local bucket = previousBucket + spent
local triggerCount = math.floor(bucket / threshold)
self.powerTracking.accumulators[relationKey] = bucket - (triggerCount * threshold)
self:DebugScoped("verbose", "PowerSpend", "%s -> bucket %s: vorher=%.0f, spend=%.0f, nachher=%.0f, threshold=%.0f, triggers=%d",
spellLabel,
tostring(token),
previousBucket,
spent,
tonumber(self.powerTracking.accumulators[relationKey]) or 0,
threshold,
triggerCount
)
if triggerCount > 0 then
for _, reducer in ipairs(group.reducers) do
local totalReduction = (tonumber(reducer.amount) or 0) * triggerCount
local applied = self:ApplyCooldownReduction(ownName, reducer.targetSpellId, totalReduction)
self:DebugScoped("verbose", "PowerSpend", "%s -> target %s requested=%.2f applied=%.2f",
spellLabel,
GetSpellDebugLabel(reducer.targetSpellId),
tonumber(totalReduction) or 0,
tonumber(applied) or 0
)
if applied > 0 then
self:Debug(
"verbose",
"LocalPowerCooldownReduce power=%s target=%s spent=%.2f triggers=%d amount=%.2f applied=%.2f",
tostring(token),
tostring(reducer.targetSpellId),
spent,
triggerCount,
tonumber(totalReduction) or 0,
tonumber(applied) or 0
)
self:BroadcastCooldownReduce(reducer.targetSpellId, applied, eventTimestamp, reducer.triggerSpellId)
end
end
end
end
end
end
function HMGT:HandleRemoteCooldownReduce(playerName, targetSpellId, amount, castTimestamp, triggerSpellId)
if not playerName then return end
if not self:IsPlayerInCurrentGroup(playerName) then return end
if not self:IsNewCooldownReduceEvent(playerName, targetSpellId, castTimestamp, triggerSpellId) then
return
end
local applied = self:ApplyCooldownReduction(playerName, targetSpellId, amount)
if applied > 0 then
self:Debug(
"verbose",
"RemoteCooldownReduce player=%s trigger=%s target=%s amount=%.2f applied=%.2f",
tostring(playerName),
tostring(triggerSpellId),
tostring(targetSpellId),
tonumber(amount) or 0,
tonumber(applied) or 0
)
end
end
function HMGT:OnGroupRosterUpdate()
self:QueueSyncRequest(0.35, "roster")
local validPlayers = { [self:NormalizePlayerName(UnitName("player"))] = true }
if IsInRaid() then
for i = 1, GetNumGroupMembers() do
local n = self:NormalizePlayerName(UnitName("raid"..i))
if n then validPlayers[n] = true end
end
elseif IsInGroup() then
for i = 1, GetNumSubgroupMembers() do
local n = self:NormalizePlayerName(UnitName("party"..i))
if n then validPlayers[n] = true end
end
end
for name in pairs(self.playerData) do
if not validPlayers[name] then
self.playerData[name] = nil
self:ClearTrackerStateForPlayer(name)
self:ClearPlayerStatus(name)
self.versionWarnings[name] = nil
if self.peerProtocols then
self.peerProtocols[name] = nil
end
end
end
local count = 0
for _ in pairs(validPlayers) do count = count + 1 end
self:Debug("verbose", "OnGroupRosterUpdate validPlayers=%d", count)
if self.versionNoticeWindow and self.versionNoticeWindow.IsShown and self.versionNoticeWindow:IsShown() and self.RefreshVersionNoticeWindow then
self:RefreshVersionNoticeWindow()
end
if HMGT.TrackerManager and HMGT.TrackerManager.InvalidateAnchorLayout then
HMGT.TrackerManager:InvalidateAnchorLayout()
end
self:TriggerTrackerUpdate()
end
function HMGT:OnPlayerLogin()
-- Char vollständig geladen: Spec jetzt zuverlässig abfragen
self:UpdateOwnPlayerInfo()
end
function HMGT:OnPlayerEnteringWorld()
if HMGT.TrackerManager and HMGT.TrackerManager.InvalidateAnchorLayout then
HMGT.TrackerManager:InvalidateAnchorLayout()
end
self:UpdateOwnPlayerInfo()
self:RefreshFrameAnchors(true)
self:QueueDeltaSyncBurst("entering_world", { 0.40, 1.50, 3.00 })
end
function HMGT:OnLoadingScreenDisabled()
self:QueueDeltaSyncBurst("loading_screen", { 0.25, 1.00, 2.25 })
end
function HMGT:OnPlayerTalentUpdate()
self:UpdateOwnPlayerInfo()
end
-- ═══════════════════════════════════════════════════════════════
-- GRUPPEN-SICHTBARKEIT
-- ═══════════════════════════════════════════════════════════════
--- Gibt true zurück wenn ein Tracker laut seinen Einstellungen
--- im aktuellen Gruppen-Kontext angezeigt werden soll.
--- @param settings table tracker config from db.profile.trackers
function HMGT:IsVisibleForCurrentGroup(settings)
if not settings.enabled then return false end
-- IsInRaid() MUSS vor IsInGroup() geprüft werden:
-- IsInGroup() gibt auch innerhalb von Raids true zurück!
if IsInRaid() then
return settings.showInRaid == true
end
if IsInGroup() then
return settings.showInGroup == true
end
-- Solo (weder Raid noch Gruppe)
return settings.showInSolo == true
end
-- ═══════════════════════════════════════════════════════════════
-- UI-UPDATE TRIGGER
-- ═══════════════════════════════════════════════════════════════
-- ====================================================================
-- MINIMAP BUTTON
-- ====================================================================
local function OpenBlizzardSettingsCategory(categoryRef)
if not categoryRef then
return false
end
if Settings and type(Settings.OpenToCategory) == "function" then
local ok = pcall(Settings.OpenToCategory, categoryRef)
if ok then
return true
end
end
if type(InterfaceOptionsFrame_OpenToCategory) == "function" then
local ok = pcall(InterfaceOptionsFrame_OpenToCategory, categoryRef)
if ok then
pcall(InterfaceOptionsFrame_OpenToCategory, categoryRef)
return true
end
end
return false
end
function HMGT:OpenConfig()
local dialog = LibStub("AceConfigDialog-3.0")
local settingsCategory = HMGT_Config and HMGT_Config.GetSettingsCategory and HMGT_Config:GetSettingsCategory()
if settingsCategory then
if dialog and type(dialog.Close) == "function" then
dialog:Close(ADDON_NAME)
end
if OpenBlizzardSettingsCategory(settingsCategory) then
return
end
end
if dialog and type(dialog.GetStatusTable) == "function" and type(dialog.SetDefaultSize) == "function" then
local status = dialog:GetStatusTable(ADDON_NAME)
if type(status.width) ~= "number" or type(status.height) ~= "number" then
local uiWidth = (UIParent and UIParent:GetWidth()) or 1920
local uiHeight = (UIParent and UIParent:GetHeight()) or 1080
local defaultWidth = math.min(1200, math.max(980, math.floor((uiWidth * 0.72) + 0.5)))
local defaultHeight = math.min(860, math.max(680, math.floor((uiHeight * 0.78) + 0.5)))
dialog:SetDefaultSize(ADDON_NAME, defaultWidth, defaultHeight)
end
end
dialog:Open(ADDON_NAME)
end
local function GetMinimapSettings(self)
if not (self and self.db and self.db.profile) then
return nil
end
self.db.profile.minimap = self.db.profile.minimap or {}
local mm = self.db.profile.minimap
NormalizeMinimapSettings(mm)
return mm
end
function HMGT:EnsureMinimapLauncher()
if self._minimapLdbObject then
return self._minimapLdbObject
end
if not (LDB and LDBIcon) then
return nil
end
self._minimapLdbObject = LDB:NewDataObject(ADDON_NAME, {
type = "launcher",
icon = MINIMAP_ICON,
OnClick = function(_, mouseButton)
if mouseButton == "LeftButton" then
HMGT:OpenConfig()
elseif mouseButton == "MiddleButton" then
HMGT:ToggleDevToolsWindow()
end
end,
OnTooltipShow = function(tooltip)
if not tooltip or not tooltip.AddLine then return end
tooltip:AddLine(L["ADDON_TITLE"] or ADDON_NAME, 1, 1, 1)
tooltip:AddLine("Left Click: Open options", 0.8, 0.8, 0.8)
tooltip:AddLine("Middle Click: Toggle developer tools", 0.8, 0.8, 0.8)
end,
})
return self._minimapLdbObject
end
function HMGT:UpdateMinimapButtonPosition()
local mm = GetMinimapSettings(self)
if not mm then return end
if LDBIcon and self._ldbIconRegistered then
if mm.hide then
LDBIcon:Hide(ADDON_NAME)
else
LDBIcon:Show(ADDON_NAME)
end
return
end
if not self.minimapButton then return end
local angle = tonumber(mm.minimapPos) or 220
local radius = 80
local rad = math.rad(angle)
local x = math.cos(rad) * radius
local y = math.sin(rad) * radius
self.minimapButton:ClearAllPoints()
self.minimapButton:SetPoint("CENTER", Minimap, "CENTER", x, y)
end
function HMGT:CreateMinimapButton()
local mm = GetMinimapSettings(self)
if not mm then return end
local launcher = self:EnsureMinimapLauncher()
if launcher and LDBIcon then
if not self._ldbIconRegistered then
LDBIcon:Register(ADDON_NAME, launcher, mm)
self._ldbIconRegistered = true
end
if self.minimapButton then
self.minimapButton:Hide()
end
self:UpdateMinimapButtonPosition()
return
end
self:CreateLegacyMinimapButton()
end
function HMGT:CreateLegacyMinimapButton()
if self.minimapButton then
self:UpdateMinimapButtonPosition()
return
end
local mm = GetMinimapSettings(self)
local button = CreateFrame("Button", "HMGT_MinimapButton", Minimap)
button:SetSize(31, 31)
button:SetFrameStrata("MEDIUM")
button:SetFrameLevel(8)
button:RegisterForClicks("LeftButtonUp", "MiddleButtonUp")
button:RegisterForDrag("RightButton")
local bg = button:CreateTexture(nil, "BACKGROUND")
bg:SetTexture("Interface\\Minimap\\UI-Minimap-Background")
bg:SetSize(20, 20)
bg:SetPoint("CENTER", 0, 0)
local icon = button:CreateTexture(nil, "ARTWORK")
icon:SetTexture(MINIMAP_ICON)
icon:SetSize(18, 18)
icon:SetPoint("CENTER", 0, 0)
icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
button.icon = icon
local border = button:CreateTexture(nil, "OVERLAY")
border:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder")
border:SetSize(54, 54)
border:SetPoint("TOPLEFT")
button:SetHighlightTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight")
button:SetScript("OnEnter", function(selfBtn)
GameTooltip:SetOwner(selfBtn, "ANCHOR_LEFT")
GameTooltip:AddLine(L["ADDON_TITLE"], 1, 1, 1)
GameTooltip:AddLine("Left Click: Open options", 0.8, 0.8, 0.8)
GameTooltip:AddLine("Middle Click: Toggle developer tools", 0.8, 0.8, 0.8)
GameTooltip:AddLine("Right Drag: Move", 0.8, 0.8, 0.8)
self:SafeShowTooltip(GameTooltip)
end)
button:SetScript("OnLeave", function() GameTooltip:Hide() end)
button:SetScript("OnClick", function(_, mouseButton)
if mouseButton == "LeftButton" then
HMGT:OpenConfig()
elseif mouseButton == "MiddleButton" then
HMGT:ToggleDevToolsWindow()
end
end)
button:SetScript("OnDragStart", function(selfBtn)
selfBtn:SetScript("OnUpdate", function()
local mx, my = Minimap:GetCenter()
local cx, cy = GetCursorPosition()
local scale = Minimap:GetEffectiveScale()
cx, cy = cx / scale, cy / scale
local angle = math.deg(math.atan2(cy - my, cx - mx))
if angle < 0 then angle = angle + 360 end
local profile = GetMinimapSettings(HMGT)
if profile then
profile.minimapPos = angle
end
HMGT:UpdateMinimapButtonPosition()
end)
end)
button:SetScript("OnDragStop", function(selfBtn)
selfBtn:SetScript("OnUpdate", nil)
end)
self.minimapButton = button
if mm and mm.hide then
button:Hide()
else
button:Show()
self:UpdateMinimapButtonPosition()
end
end
local function CountTableEntries(tbl)
local count = 0
for _ in pairs(tbl or {}) do
count = count + 1
end
return count
end
function HMGT:GetHealthStatusLines()
local lines = {}
lines[#lines + 1] = "HMGT status"
lines[#lines + 1] = string.format(
"Version: addon=%s build=%s channel=%s protocol=%s",
tostring(self.ADDON_VERSION or "dev"),
tostring(self.BUILD_VERSION or self.ADDON_VERSION or "dev"),
tostring(self.RELEASE_CHANNEL or "stable"),
tostring(self.PROTOCOL_VERSION or "?")
)
local groupType = "solo"
local groupMembers = 1
if IsInRaid() then
groupType = "raid"
groupMembers = GetNumGroupMembers()
elseif IsInGroup() then
groupType = "party"
groupMembers = GetNumGroupMembers()
end
lines[#lines + 1] = string.format("Group: type=%s members=%d", groupType, tonumber(groupMembers) or 1)
local trackers = self:GetTrackerConfigs()
local enabledTrackers = 0
local normalTrackers = 0
local groupTrackers = 0
for _, tracker in ipairs(trackers) do
if tracker.enabled ~= false then
enabledTrackers = enabledTrackers + 1
end
if self:IsGroupTrackerConfig(tracker) then
groupTrackers = groupTrackers + 1
else
normalTrackers = normalTrackers + 1
end
end
lines[#lines + 1] = string.format(
"Trackers: total=%d enabled=%d normal=%d group=%d model=%s",
#trackers,
enabledTrackers,
normalTrackers,
groupTrackers,
tostring(self.db and self.db.profile and self.db.profile.trackerModelVersion or "?")
)
local profile = self.db and self.db.profile or {}
local legacyCount = 0
if profile.interruptTracker ~= nil then legacyCount = legacyCount + 1 end
if profile.raidCooldownTracker ~= nil then legacyCount = legacyCount + 1 end
if profile.groupCooldownTracker ~= nil then legacyCount = legacyCount + 1 end
lines[#lines + 1] = string.format("Legacy profile keys: %d", legacyCount)
local devSettings = self.GetDevToolsSettings and self:GetDevToolsSettings() or {}
lines[#lines + 1] = string.format(
"Debug: enabled=%s level=%s scope=%s lines=%d",
tostring(devSettings.enabled == true),
tostring(devSettings.level or "info"),
tostring(devSettings.scope or "ALL"),
#(self.devToolsBuffer or {})
)
local activeCooldownPlayers = CountTableEntries(self.activeCDs)
local playerDataCount = CountTableEntries(self.playerData)
lines[#lines + 1] = string.format(
"Tracker state: players=%d cooldownPlayers=%d pendingReliable=%d",
playerDataCount,
activeCooldownPlayers,
CountTableEntries(self.pendingReliableMessages)
)
local modules = {
Tracker = self.TrackerManager ~= nil,
AuraExpiry = self.AuraExpiry ~= nil,
MapOverlay = self.MapOverlay ~= nil,
RaidTimeline = self.RaidTimeline ~= nil,
Notes = self.Notes ~= nil,
}
local moduleParts = {}
for name, loaded in pairs(modules) do
moduleParts[#moduleParts + 1] = string.format("%s=%s", name, loaded and "loaded" or "missing")
end
table.sort(moduleParts)
lines[#lines + 1] = "Modules: " .. table.concat(moduleParts, ", ")
local bridge = _G.HMGT_Bridge
lines[#lines + 1] = string.format("Bridge: %s", bridge and "loaded" or "not loaded")
if bridge and type(bridge.GetStatusLines) == "function" then
local statusLines = bridge:GetStatusLines()
for index = 1, math.min(3, #(statusLines or {})) do
lines[#lines + 1] = "Bridge " .. tostring(index) .. ": " .. tostring(statusLines[index])
end
end
return lines
end
function HMGT:PrintHealthStatus()
for _, line in ipairs(self:GetHealthStatusLines()) do
self:Print(line)
end
end
function HMGT:SlashCommand(input)
input = input:trim():lower()
if input == "lock" then
for _, tracker in ipairs(self:GetTrackerConfigs()) do
tracker.locked = true
end
if HMGT.TrackerManager and HMGT.TrackerManager.SetAllLocked then
HMGT.TrackerManager:SetAllLocked(true)
end
self:Print(L["FRAMES_LOCKED"])
elseif input == "unlock" then
for _, tracker in ipairs(self:GetTrackerConfigs()) do
tracker.locked = false
end
if HMGT.TrackerManager and HMGT.TrackerManager.SetAllLocked then
HMGT.TrackerManager:SetAllLocked(false)
end
self:Print(L["FRAMES_UNLOCKED"])
elseif input == "demo" then
self:DemoMode()
elseif input == "test" then
self:TestMode()
elseif input == "version" then
if self.IsDevToolsEnabled and self:IsDevToolsEnabled() then
self:ShowVersionMismatchPopup()
else
self:Print(L["VERSION_WINDOW_DEVTOOLS_ONLY"] or "HMGT: /hmgt version is only available while developer tools are enabled.")
end
elseif input == "bridge" then
if _G.HMGT_Bridge and _G.HMGT_Bridge.GetStatusLines then
for _, line in ipairs(_G.HMGT_Bridge:GetStatusLines()) do
self:Print(line)
end
else
self:Print("HMGT Bridge is not loaded.")
end
elseif input == "status" then
self:PrintHealthStatus()
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
-- ═══════════════════════════════════════════════════════════════
-- HILFSFUNKTIONEN
-- ═══════════════════════════════════════════════════════════════
function HMGT:GetClassColor(classToken)
local c = self.classColors[classToken]
return c and c[1] or 1, c and c[2] or 1, c and c[3] or 1
end
function HMGT:GetUnitForPlayer(playerName)
local target = self:NormalizePlayerName(playerName)
if not target then return nil end
local ownName = self:NormalizePlayerName(UnitName("player"))
if target == ownName then
return "player"
end
if IsInRaid() then
for i = 1, GetNumGroupMembers() do
local unitId = "raid" .. i
local name = self:NormalizePlayerName(UnitName(unitId))
if name == target then
return unitId
end
end
elseif IsInGroup() then
for i = 1, GetNumSubgroupMembers() do
local unitId = "party" .. i
local name = self:NormalizePlayerName(UnitName(unitId))
if name == target then
return unitId
end
end
end
return nil
end
function HMGT:IsRaidTimelineEditor(playerName)
if playerName and playerName ~= "" then
local unitId = self:GetUnitForPlayer(playerName)
if not unitId then
return false
end
return UnitIsGroupLeader and UnitIsGroupLeader(unitId) or false
end
if not IsInGroup() and not IsInRaid() then
return true
end
return UnitIsGroupLeader and UnitIsGroupLeader("player") or false
end
function HMGT:IsUnitInRangeSafe(unitId)
if not unitId or not UnitExists(unitId) then return nil end
if UnitIsUnit(unitId, "player") then return true end
if UnitIsConnected and not UnitIsConnected(unitId) then
return false
end
if type(UnitInRange) == "function" then
local a, b = UnitInRange(unitId)
if type(a) == "boolean" and type(b) == "boolean" then
if b then
return a
end
elseif type(a) == "boolean" then
return a
elseif type(a) == "number" then
return a == 1
end
end
if type(CheckInteractDistance) == "function" then
local close = CheckInteractDistance(unitId, 4)
if type(close) == "boolean" then
return close
end
end
return nil
end
function HMGT:GetPlayerRole(playerName, unitId)
local role = nil
if unitId and type(UnitGroupRolesAssigned) == "function" then
role = UnitGroupRolesAssigned(unitId)
end
if role and role ~= "" and role ~= "NONE" then
return role
end
local ownName = self:NormalizePlayerName(UnitName("player"))
if self:NormalizePlayerName(playerName) == ownName and type(GetSpecializationRole) == "function" then
local spec = GetSpecialization()
if spec and spec > 0 then
local ownRole = GetSpecializationRole(spec)
if ownRole and ownRole ~= "NONE" then
return ownRole
end
end
end
return role
end
function HMGT:FilterDisplayEntries(settings, entries)
if type(entries) ~= "table" then return entries end
if type(settings) ~= "table" then return entries end
local desiredRole = settings.roleFilter or "ALL"
local useRoleFilter = desiredRole ~= "ALL"
local useRangeCheck = settings.rangeCheck == true
if not useRoleFilter and not useRangeCheck then
return entries
end
local hideOutOfRange = useRangeCheck and settings.hideOutOfRange == true
local outOfRangeAlpha = tonumber(settings.outOfRangeAlpha) or 0.4
if outOfRangeAlpha < 0.1 then outOfRangeAlpha = 0.1 end
if outOfRangeAlpha > 1 then outOfRangeAlpha = 1 end
local filtered = {}
local unitCache = {}
local roleCache = {}
local rangeCache = {}
for _, entry in ipairs(entries) do
local include = true
local key = self:NormalizePlayerName(entry.playerName) or tostring(entry.playerName or "")
local unitId = unitCache[key]
if unitId == nil and (useRoleFilter or useRangeCheck) then
unitId = self:GetUnitForPlayer(entry.playerName) or false
unitCache[key] = unitId
end
if unitId == false then
unitId = nil
end
if useRoleFilter then
local role = roleCache[key]
if role == nil then
role = self:GetPlayerRole(entry.playerName, unitId) or false
roleCache[key] = role
end
if role == false then
role = nil
end
if role and role ~= "NONE" and role ~= desiredRole then
include = false
end
end
if include and useRangeCheck then
local inRange = rangeCache[key]
if inRange == nil then
inRange = unitId and self:IsUnitInRangeSafe(unitId)
if inRange == nil then
inRange = false
end
rangeCache[key] = inRange
end
if inRange == false and not unitId then
inRange = nil
end
if inRange == false then
if hideOutOfRange then
include = false
else
entry.outOfRange = true
entry.outOfRangeAlpha = outOfRangeAlpha
end
else
entry.outOfRange = nil
entry.outOfRangeAlpha = nil
end
else
entry.outOfRange = nil
entry.outOfRangeAlpha = nil
end
if include then
filtered[#filtered + 1] = entry
end
end
return filtered
end
function HMGT:FormatTime(seconds)
if seconds >= 60 then
return string.format("%d:%02d", math.floor(seconds / 60), seconds % 60)
end
return string.format("%.0f", seconds)
end