Files
HailMaryGuildTools/HailMaryGuildTools.lua
Torsten Brendgen 391e581d32 feat: Add Personal Auras module for tracking debuffs on the player
- Introduced a new module for Personal Auras that allows players to track selected debuffs on themselves in a movable frame.
- Implemented functionality to manage tracked debuffs, including adding and removing spells.
- Added options for configuring the appearance and behavior of the Personal Auras frame.
- Updated the readme to include information about the new Personal Auras feature.
2026-04-12 00:04:34 +02:00

5585 lines
209 KiB
Lua
Raw Permalink 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.MSG_RAID_TIMELINE = MSG_RAID_TIMELINE
HMGT.MSG_RAID_TIMELINE_TEST = MSG_RAID_TIMELINE_TEST
-- ── Standardwerte ─────────────────────────────────────────────
local defaults = {
profile = {
debug = false,
debugLevel = "info",
devTools = {
enabled = false,
level = "error",
scope = "ALL",
window = {
width = 920,
height = 420,
minimized = false,
},
},
syncRemoteCharges = true,
interruptTracker = {
enabled = true,
demoMode = false,
testMode = false,
showBar = true,
showSpellTooltip = true,
locked = false,
posX = 200,
posY = -200,
anchorTo = "UIParent",
anchorCustom = "",
anchorPoint = "TOPLEFT",
anchorRelPoint= "TOPLEFT",
anchorX = 200,
anchorY = -200,
width = 250,
barHeight = 20,
barSpacing = 2,
barTexture = "Blizzard",
borderEnabled = false,
borderColor = { r = 1, g = 1, b = 1, a = 1 },
iconSize = 32,
iconSpacing = 2,
iconCols = 6,
iconOverlay = "sweep", -- "sweep" | "timer"
textAnchor = "below", -- "onIcon" | "above" | "below"
fontSize = 12,
font = "Friz Quadrata TT",
fontOutline = "OUTLINE",
growDirection = "DOWN",
showInSolo = true,
showInGroup = true,
showInRaid = true,
enabledSpells = {},
showPlayerName= true,
colorByClass = true,
showChargesOnIcon = false,
showOnlyReady = false,
readySoonSec = 0,
},
raidCooldownTracker = {
enabled = true,
demoMode = false,
testMode = false,
showBar = true,
showSpellTooltip = true,
locked = false,
posX = 500,
posY = -200,
anchorTo = "UIParent",
anchorCustom = "",
anchorPoint = "TOPLEFT",
anchorRelPoint= "TOPLEFT",
anchorX = 500,
anchorY = -200,
width = 250,
barHeight = 20,
barSpacing = 2,
barTexture = "Blizzard",
borderEnabled = false,
borderColor = { r = 1, g = 1, b = 1, a = 1 },
iconSize = 32,
iconSpacing = 2,
iconCols = 6,
iconOverlay = "sweep", -- "sweep" | "timer"
textAnchor = "below", -- "onIcon" | "above" | "below"
fontSize = 12,
font = "Friz Quadrata TT",
fontOutline = "OUTLINE",
growDirection = "DOWN",
showInSolo = true,
showInGroup = true,
showInRaid = true,
enabledSpells = {},
showPlayerName= true,
colorByClass = true,
showChargesOnIcon = false,
showOnlyReady = false,
readySoonSec = 0,
},
groupCooldownTracker = {
enabled = true,
demoMode = false,
testMode = false,
showBar = true,
showSpellTooltip = true,
locked = false,
attachToPartyFrame = false,
partyAttachSide = "RIGHT",
partyAttachOffsetX = 8,
partyAttachOffsetY = 0,
posX = 800,
posY = -200,
anchorTo = "UIParent",
anchorCustom = "",
anchorPoint = "TOPLEFT",
anchorRelPoint= "TOPLEFT",
anchorX = 800,
anchorY = -200,
width = 250,
barHeight = 20,
barSpacing = 2,
barTexture = "Blizzard",
borderEnabled = false,
borderColor = { r = 1, g = 1, b = 1, a = 1 },
iconSize = 32,
iconSpacing = 2,
iconCols = 6,
iconOverlay = "sweep",
textAnchor = "below",
fontSize = 12,
font = "Friz Quadrata TT",
fontOutline = "OUTLINE",
growDirection = "DOWN",
showInSolo = false,
showInGroup = true,
showInRaid = false,
enabledSpells = {},
showPlayerName= true,
colorByClass = true,
showChargesOnIcon = true,
showOnlyReady = false,
readySoonSec = 0,
includeSelfFrame = false,
},
trackers = {},
buffEndingAnnouncer = {
enabled = true,
announceAtSec = 5,
trackedBuffs = {},
},
personalAuras = {
enabled = false,
unlocked = false,
posX = 0,
posY = 120,
width = 260,
rowHeight = 24,
iconSize = 20,
fontSize = 12,
trackedDebuffs = {},
},
raidTimeline = {
enabled = false,
leadTime = 5,
assignmentLeadTime = 5,
unlocked = false,
alertPosX = 0,
alertPosY = 180,
alertFont = "Friz Quadrata TT",
alertFontSize = 30,
alertFontOutline = "OUTLINE",
alertColor = { r = 1, g = 0.82, b = 0.15, a = 1 },
encounters = {},
},
notes = {
enabled = true,
mainText = "",
mainEncounterId = 0,
mainTitle = "",
personalText = "",
drafts = {},
window = {
width = 1080,
height = 700,
},
},
minimap = {
hide = false,
minimapPos = 220,
},
mapOverlay = {
enabled = true,
iconSize = 16,
alpha = 1,
showLabels = true,
categories = { custom = true },
pois = {},
},
},
}
-- ── Spieler-/CD-Daten ─────────────────────────────────────────
-- { [playerName] = { class, specIndex, talentHash, talents={[spellId]=true}, knownSpells={[spellId]=true} } }
HMGT.playerData = {}
-- { [playerName] = { [spellId] = { startTime, duration, spellEntry } } }
HMGT.activeCDs = {}
-- { [playerName] = { [spellId] = { current, max, spellEntry, updatedAt } } }
HMGT.availabilityStates = {}
HMGT.localSpellStateRevisions = {}
HMGT.remoteSpellStateRevisions = {}
HMGT.knownChargeInfo = {}
HMGT.powerTracking = {
accumulators = {},
}
HMGT.pendingSpellPowerCosts = {}
HMGT.demoModeData = {}
HMGT.peerVersions = {}
HMGT.versionWarnings = {}
HMGT.debugBuffer = {}
HMGT.debugBufferMax = 500
HMGT.enabledDebugScopes = {
General = true,
Debug = true,
Comm = true,
TrackedSpells = true,
PowerSpend = true,
}
HMGT.pendingReliableMessages = HMGT.pendingReliableMessages or {}
HMGT.receivedReliableMessages = HMGT.receivedReliableMessages or {}
HMGT.nextReliableMessageId = HMGT.nextReliableMessageId or 0
HMGT.pendingReliableBySupersede = HMGT.pendingReliableBySupersede or {}
HMGT.recentGroupMessages = HMGT.recentGroupMessages or {}
local DEBUG_SCOPE_ALL = "ALL"
local DEBUG_SCOPE_LABELS = {
General = "General",
Debug = "Debug",
Comm = "Communication",
TrackedSpells = "Tracked Spells",
PowerSpend = "Power Spend",
RaidTimeline = "Raid Timeline",
Notes = "Notes",
}
local DEBUG_LEVELS = {
error = 1,
info = 2,
verbose = 3,
}
function HMGT:SuppressRemoteTrackedSpellLogs(playerName, duration)
local normalizedName = self:NormalizePlayerName(playerName)
if not normalizedName then
return
end
self._suppressTrackedSpellLogUntil = self._suppressTrackedSpellLogUntil or {}
self._suppressTrackedSpellLogUntil[normalizedName] = GetTime() + math.max(0, tonumber(duration) or 0)
end
function HMGT:IsRemoteTrackedSpellLogSuppressed(playerName)
local normalizedName = self:NormalizePlayerName(playerName)
local suppression = self._suppressTrackedSpellLogUntil
local untilTime = suppression and suppression[normalizedName]
if not untilTime then
return false
end
if untilTime <= GetTime() then
suppression[normalizedName] = nil
return false
end
return true
end
function HMGT:IsDebugScopeEnabled(scope)
local normalizedScope = tostring(scope or "General")
local selectedScope = self.db and self.db.profile and self.db.profile.debugScope or DEBUG_SCOPE_ALL
if selectedScope and selectedScope ~= DEBUG_SCOPE_ALL and normalizedScope ~= selectedScope then
return false
end
local enabled = self.enabledDebugScopes
if type(enabled) ~= "table" then
return true
end
if enabled[normalizedScope] == nil then
return true
end
return enabled[normalizedScope] == true
end
function HMGT:GetTrackerDebugScope(tracker)
local trackerName = nil
if type(tracker) == "table" then
trackerName = tracker.name
if (not trackerName or trackerName == "") and tracker.id then
trackerName = string.format("Tracker %s", tostring(tracker.id))
end
else
trackerName = tostring(tracker or "")
end
trackerName = tostring(trackerName or ""):gsub("^%s+", ""):gsub("%s+$", "")
if trackerName == "" then
trackerName = "Tracker"
end
return "Tracker: " .. trackerName
end
function HMGT:GetStaticDebugScopeOptions()
local values = {
[DEBUG_SCOPE_ALL] = (self.L and self.L["OPT_DEBUG_SCOPE_ALL"]) or "All modules",
}
for scope, label in pairs(DEBUG_SCOPE_LABELS) do
values[scope] = label
end
local trackers = self.db and self.db.profile and self.db.profile.trackers
if type(trackers) == "table" then
for _, tracker in ipairs(trackers) do
local scope = self:GetTrackerDebugScope(tracker)
values[scope] = tostring(tracker.name or scope)
end
end
return values
end
function HMGT:GetDebugLevelOptions()
return {
error = (self.L and self.L["OPT_DEBUG_LEVEL_ERROR"]) or "Errors",
info = (self.L and self.L["OPT_DEBUG_LEVEL_INFO"]) or "Info",
verbose = (self.L and self.L["OPT_DEBUG_LEVEL_VERBOSE"]) or "Verbose",
}
end
function HMGT:GetConfiguredDebugLevel()
local configured = self.db and self.db.profile and self.db.profile.debugLevel or "info"
if DEBUG_LEVELS[configured] then
return configured
end
return "info"
end
function HMGT:ShouldIncludeDebugLine(levelToken)
local selectedLevel = self:GetConfiguredDebugLevel()
return (DEBUG_LEVELS[tostring(levelToken or "info"):lower()] or DEBUG_LEVELS.info)
<= (DEBUG_LEVELS[selectedLevel] or DEBUG_LEVELS.info)
end
function HMGT:GetDebugScopeOptions()
local values = self:GetStaticDebugScopeOptions()
local scopes = {}
local function addScope(scope)
local normalized = tostring(scope or ""):match("^%s*(.-)%s*$")
if normalized == "" or normalized == DEBUG_SCOPE_ALL then
return
end
scopes[normalized] = true
end
for scope in pairs(self.enabledDebugScopes or {}) do
addScope(scope)
end
for _, line in ipairs(self.debugBuffer or {}) do
local scope = tostring(line):match("^%d%d:%d%d:%d%d %[[^%]]+%]%[([^%]]+)%]")
addScope(scope)
end
local names = {}
for scope in pairs(scopes) do
names[#names + 1] = scope
end
table.sort(names)
for _, scope in ipairs(names) do
values[scope] = values[scope] or scope
end
return values
end
function HMGT:GetFilteredDebugBuffer()
local selectedLevel = self:GetConfiguredDebugLevel()
local selectedScope = self.db and self.db.profile and self.db.profile.debugScope or DEBUG_SCOPE_ALL
local filtered = {}
for _, line in ipairs(self.debugBuffer or {}) do
local level, scope = tostring(line):match("^%d%d:%d%d:%d%d %[([^%]]+)%]%[([^%]]+)%]")
local normalizedLevel = tostring(level or "INFO"):lower()
local scopeMatches = (not selectedScope or selectedScope == DEBUG_SCOPE_ALL or scope == selectedScope)
if scopeMatches and self:ShouldIncludeDebugLine(normalizedLevel) then
filtered[#filtered + 1] = line
end
end
return filtered
end
function HMGT:NextReliableMessageId()
self.nextReliableMessageId = (tonumber(self.nextReliableMessageId) or 0) + 1
return string.format("%s-%d-%d", tostring(GetServerTime() or 0), math.floor(GetTime() * 1000), self.nextReliableMessageId)
end
function HMGT:IsReliableCommType(msgType)
return msgType == MSG_SPELL_STATE
or msgType == MSG_SYNC_RESPONSE
or msgType == MSG_RAID_TIMELINE
end
function HMGT:GetPeerProtocolVersion(playerName)
local normalizedName = self:NormalizePlayerName(playerName)
local peerProtocols = self.peerProtocols or {}
return tonumber(normalizedName and peerProtocols[normalizedName]) or 0
end
function HMGT:RememberPeerProtocolVersion(playerName, protocol)
local normalizedName = self:NormalizePlayerName(playerName)
local numeric = tonumber(protocol)
if not normalizedName or not numeric then
return
end
self.peerProtocols = self.peerProtocols or {}
self.peerProtocols[normalizedName] = numeric
end
function HMGT:SendReliableAck(target, messageId)
if not target or target == "" or not messageId or messageId == "" then
return
end
self:SendCommMessage(COMM_PREFIX, string.format("%s|%s", MSG_ACK, tostring(messageId)), "WHISPER", target, "ALERT")
end
function HMGT:GetReliableSupersedeKey(target, msgType, payload)
local normalizedTarget = self:NormalizePlayerName(target) or tostring(target or "")
if msgType == MSG_SPELL_STATE then
local sid, kind = tostring(payload):match("^%a+|(%d+)|(%a+)|")
if sid and kind then
return table.concat({ normalizedTarget, msgType, sid, kind }, "|")
end
elseif msgType == MSG_SYNC_RESPONSE then
return table.concat({ normalizedTarget, msgType }, "|")
elseif msgType == MSG_RAID_TIMELINE then
local encounterId, timeSec, spellId, leadTime, alertText =
tostring(payload):match("^%a+|(%d+)|(%d+)|(%d+)|(%d+)|?(.*)$")
if not encounterId then
encounterId, timeSec, spellId, leadTime =
tostring(payload):match("^%a+|(%d+)|(%d+)|(%d+)|(%d+)$")
alertText = ""
end
return table.concat({
normalizedTarget,
msgType,
tostring(encounterId or ""),
tostring(timeSec or ""),
tostring(spellId or ""),
tostring(leadTime or ""),
tostring(alertText or ""),
}, "|")
end
return nil
end
function HMGT:GetNextReliableRetryDelay()
local earliest = nil
local now = GetTime()
for _, pending in pairs(self.pendingReliableMessages or {}) do
if pending then
local nextRetryAt = tonumber(pending.nextRetryAt) or now
if not earliest or nextRetryAt < earliest then
earliest = nextRetryAt
end
end
end
if not earliest then
return nil
end
return math.max(0.05, earliest - now)
end
function HMGT:EnsureReliableCommTicker()
local delay = self:GetNextReliableRetryDelay()
if not delay then
self:StopReliableCommTicker()
return
end
if self.reliableCommTicker then
self:CancelTimer(self.reliableCommTicker, true)
self.reliableCommTicker = nil
end
self.reliableCommTicker = self:ScheduleTimer(function()
self.reliableCommTicker = nil
self:ProcessReliableMessageQueue()
end, delay)
end
function HMGT:StopReliableCommTicker()
if self.reliableCommTicker then
self:CancelTimer(self.reliableCommTicker, true)
self.reliableCommTicker = nil
end
end
function HMGT:HandleReliableAck(senderName, messageId)
local key = string.format("%s|%s", tostring(self:NormalizePlayerName(senderName) or ""), tostring(messageId or ""))
local pending = self.pendingReliableMessages and self.pendingReliableMessages[key]
if pending then
if pending.supersedeKey and self.pendingReliableBySupersede then
self.pendingReliableBySupersede[pending.supersedeKey] = nil
end
self.pendingReliableMessages[key] = nil
self:DebugScoped("verbose", "Comm", "Reliable ACK sender=%s id=%s type=%s", tostring(senderName), tostring(messageId), tostring(pending.msgType))
self:EnsureReliableCommTicker()
end
end
function HMGT:TrackReliableMessage(target, messageId, payload, msgType, supersedeKey)
if not target or target == "" or not messageId or messageId == "" or not payload or payload == "" then
return
end
local normalizedTarget = self:NormalizePlayerName(target)
local key = string.format("%s|%s", tostring(normalizedTarget or target), tostring(messageId))
if supersedeKey and self.pendingReliableBySupersede and self.pendingReliableBySupersede[supersedeKey] then
local previousKey = self.pendingReliableBySupersede[supersedeKey]
local previousPending = self.pendingReliableMessages[previousKey]
if previousPending and previousPending.supersedeKey then
self.pendingReliableBySupersede[previousPending.supersedeKey] = nil
end
self.pendingReliableMessages[previousKey] = nil
end
self.pendingReliableMessages[key] = {
target = target,
normalizedTarget = normalizedTarget,
payload = payload,
msgType = msgType,
supersedeKey = supersedeKey,
sentAt = GetTime(),
retries = 0,
nextRetryAt = GetTime() + 0.75,
}
if supersedeKey then
self.pendingReliableBySupersede[supersedeKey] = key
end
self:EnsureReliableCommTicker()
end
function HMGT:PruneReliableCaches()
local now = GetTime()
for key, pending in pairs(self.pendingReliableMessages or {}) do
if not pending or (pending.retries or 0) >= 2 and now > ((pending.nextRetryAt or 0) + 5) then
if pending and pending.supersedeKey and self.pendingReliableBySupersede then
self.pendingReliableBySupersede[pending.supersedeKey] = nil
end
self.pendingReliableMessages[key] = nil
end
end
for key, expiresAt in pairs(self.receivedReliableMessages or {}) do
if (tonumber(expiresAt) or 0) <= now then
self.receivedReliableMessages[key] = nil
end
end
end
function HMGT:ProcessReliableMessageQueue()
local now = GetTime()
for key, pending in pairs(self.pendingReliableMessages or {}) do
if pending and now >= (tonumber(pending.nextRetryAt) or 0) then
if (tonumber(pending.retries) or 0) >= 2 then
self:DebugScoped("info", "Comm", "Reliable send expired target=%s type=%s id=%s", tostring(pending.target), tostring(pending.msgType), tostring(key:match("|(.+)$") or ""))
if pending.supersedeKey and self.pendingReliableBySupersede then
self.pendingReliableBySupersede[pending.supersedeKey] = nil
end
self.pendingReliableMessages[key] = nil
else
pending.retries = (tonumber(pending.retries) or 0) + 1
pending.nextRetryAt = now + 0.75
self:DebugScoped("verbose", "Comm", "Reliable retry target=%s type=%s try=%d", tostring(pending.target), tostring(pending.msgType), pending.retries)
self:SendCommMessage(COMM_PREFIX, pending.payload, "WHISPER", pending.target, "ALERT")
end
end
end
self:PruneReliableCaches()
self:EnsureReliableCommTicker()
end
function HMGT:SendDirectMessage(payload, target, prio)
if not target or target == "" or not payload or payload == "" then
return false
end
local msgType = tostring(payload):match("^(%a+)")
local peerProtocol = self:GetPeerProtocolVersion(target)
if peerProtocol >= 6 and self:IsReliableCommType(msgType) then
local messageId = self:NextReliableMessageId()
local wrapped = string.format("%s|%s|%s", MSG_RELIABLE, tostring(messageId), payload)
local supersedeKey = self:GetReliableSupersedeKey(target, msgType, payload)
self:TrackReliableMessage(target, messageId, wrapped, msgType, supersedeKey)
self:DebugScoped("verbose", "Comm", "Reliable send target=%s type=%s id=%s", tostring(target), tostring(msgType), tostring(messageId))
self:SendCommMessage(COMM_PREFIX, wrapped, "WHISPER", target, prio or "ALERT")
return true
end
self:SendCommMessage(COMM_PREFIX, payload, "WHISPER", target, prio)
return true
end
function HMGT:DebugScoped(level, scope, fmt, ...)
return
end
function HMGT:Debug(fmt, ...)
local args = { ... }
local level = "info"
if fmt == "error" or fmt == "info" or fmt == "verbose" then
level = fmt
fmt = args[1]
table.remove(args, 1)
if fmt == nil then return end
end
self:DebugScoped(level, "General", fmt, unpack(args))
end
function HMGT:RegisterPeerVersion(playerName, version, protocol, sourceTag)
if not playerName then return end
self.peerVersions[playerName] = version
self:RememberPeerProtocolVersion(playerName, protocol)
local mismatch = false
local details = {}
if version and version ~= "" and version ~= ADDON_VERSION then
mismatch = true
details[#details + 1] = string.format("addon local=%s remote=%s", tostring(ADDON_VERSION), tostring(version))
end
if protocol and tonumber(protocol) and tonumber(protocol) ~= PROTOCOL_VERSION then
mismatch = true
details[#details + 1] = string.format("protocol local=%s remote=%s", tostring(PROTOCOL_VERSION), tostring(protocol))
end
if mismatch and not self.versionWarnings[playerName] then
self.versionWarnings[playerName] = true
self.latestVersionMismatch = {
playerName = playerName,
detail = table.concat(details, " | "),
sourceTag = sourceTag,
}
self:DevTrace("Version", "mismatch_detected", {
player = playerName,
source = sourceTag,
detail = table.concat(details, " | "),
})
local text = string.format(L["VERSION_MISMATCH_CHAT"] or "HMGT mismatch with %s: %s",
tostring(playerName), table.concat(details, " | "))
self:Print("|cffff5555HMGT|r " .. text)
self:ShowVersionMismatchPopup(playerName, table.concat(details, " | "), sourceTag)
self:Debug("info", "Version mismatch %s via=%s %s", tostring(playerName), tostring(sourceTag or "?"), table.concat(details, " | "))
end
end
local function NormalizeLayoutValue(num, minv, maxv, fallback)
if type(num) ~= "number" then return fallback end
if num < minv then return minv end
if num > maxv then return maxv end
return num
end
local function DeepCopy(value)
if type(value) ~= "table" then return value end
local out = {}
for k, v in pairs(value) do
out[k] = DeepCopy(v)
end
return out
end
-- ── Klassenfarben ─────────────────────────────────────────────
local function SafeApiNumber(value, fallback)
if value == nil then
return fallback
end
local ok, num = pcall(function()
return tonumber(tostring(value))
end)
if ok and num ~= nil then
return num
end
return fallback
end
local function SafeSpellApiCall(fn, ...)
if type(fn) ~= "function" then
return nil
end
local ok, a, b, c, d = pcall(fn, ...)
if not ok then
return nil
end
return a, b, c, d
end
local function NormalizeSpellChargesResult(a, b, c, d)
if type(a) == "table" and b == nil then
local t = a
a = t.currentCharges or t.charges or t.current
b = t.maxCharges or t.max
c = t.cooldownStartTime or t.chargeStart or t.startTime or t.start
d = t.cooldownDuration or t.chargeDuration or t.duration
end
return SafeApiNumber(a), SafeApiNumber(b), SafeApiNumber(c), SafeApiNumber(d)
end
local function GetSpellChargesInfo(spellId)
local cur, max, chargeStart, chargeDuration = nil, nil, nil, nil
if type(GetSpellCharges) == "function" then
cur, max, chargeStart, chargeDuration = NormalizeSpellChargesResult(SafeSpellApiCall(GetSpellCharges, spellId))
end
if cur == nil and max == nil and C_Spell and type(C_Spell.GetSpellCharges) == "function" then
cur, max, chargeStart, chargeDuration = NormalizeSpellChargesResult(SafeSpellApiCall(C_Spell.GetSpellCharges, spellId))
end
return cur, max, chargeStart, chargeDuration
end
local function NormalizeSpellCooldownResult(a, b)
if type(a) == "table" and b == nil then
local t = a
a = t.startTime or t.cooldownStartTime or t.start
b = t.duration or t.cooldownDuration
end
return SafeApiNumber(a, 0) or 0, SafeApiNumber(b, 0) or 0
end
local function GetSpellCooldownInfo(spellId)
local startTime, duration = 0, 0
if type(GetSpellCooldown) == "function" then
startTime, duration = NormalizeSpellCooldownResult(SafeSpellApiCall(GetSpellCooldown, spellId))
end
if startTime <= 0 and duration <= 0 and C_Spell and type(C_Spell.GetSpellCooldown) == "function" then
startTime, duration = NormalizeSpellCooldownResult(SafeSpellApiCall(C_Spell.GetSpellCooldown, spellId))
end
return startTime, duration
end
local function GetGlobalCooldownInfo()
return GetSpellCooldownInfo(61304)
end
local function NormalizeAuraApplications(auraData)
if type(auraData) ~= "table" then
return 0
end
local count = tonumber(auraData.applications or auraData.stackCount or auraData.stacks or auraData.charges or auraData.count)
if count ~= nil then
return math.max(0, count)
end
return 1
end
local function GetPlayerAuraApplications(spellId)
local sid = tonumber(spellId)
if not sid or sid <= 0 then return 0 end
if C_UnitAuras and type(C_UnitAuras.GetPlayerAuraBySpellID) == "function" then
local auraData = C_UnitAuras.GetPlayerAuraBySpellID(sid)
if auraData then
return NormalizeAuraApplications(auraData)
end
end
if AuraUtil and type(AuraUtil.FindAuraBySpellID) == "function" then
local name, _, applications = AuraUtil.FindAuraBySpellID(sid, "player", "HELPFUL")
if name then
return math.max(0, tonumber(applications) or 1)
end
end
return 0
end
local function GetSpellCastCountInfo(spellId)
if C_Spell and type(C_Spell.GetSpellCastCount) == "function" then
return math.max(0, tonumber(C_Spell.GetSpellCastCount(spellId)) or 0)
end
return 0
end
local POWER_TYPE_IDS = {
MANA = (Enum and Enum.PowerType and Enum.PowerType.Mana) or _G.SPELL_POWER_MANA or 0,
RAGE = (Enum and Enum.PowerType and Enum.PowerType.Rage) or _G.SPELL_POWER_RAGE or 1,
FOCUS = (Enum and Enum.PowerType and Enum.PowerType.Focus) or _G.SPELL_POWER_FOCUS or 2,
ENERGY = (Enum and Enum.PowerType and Enum.PowerType.Energy) or _G.SPELL_POWER_ENERGY or 3,
COMBO_POINTS = (Enum and Enum.PowerType and Enum.PowerType.ComboPoints) or _G.SPELL_POWER_COMBO_POINTS or 4,
RUNES = (Enum and Enum.PowerType and Enum.PowerType.Runes) or _G.SPELL_POWER_RUNES or 5,
RUNIC_POWER = (Enum and Enum.PowerType and Enum.PowerType.RunicPower) or _G.SPELL_POWER_RUNIC_POWER or 6,
SOUL_SHARDS = (Enum and Enum.PowerType and Enum.PowerType.SoulShards) or _G.SPELL_POWER_SOUL_SHARDS or 7,
LUNAR_POWER = (Enum and Enum.PowerType and Enum.PowerType.LunarPower) or _G.SPELL_POWER_LUNAR_POWER or 8,
HOLY_POWER = (Enum and Enum.PowerType and Enum.PowerType.HolyPower) or _G.SPELL_POWER_HOLY_POWER or 9,
ALTERNATE = (Enum and Enum.PowerType and Enum.PowerType.Alternate) or _G.SPELL_POWER_ALTERNATE_POWER or 10,
MAELSTROM = (Enum and Enum.PowerType and Enum.PowerType.Maelstrom) or _G.SPELL_POWER_MAELSTROM or 11,
CHI = (Enum and Enum.PowerType and Enum.PowerType.Chi) or _G.SPELL_POWER_CHI or 12,
INSANITY = (Enum and Enum.PowerType and Enum.PowerType.Insanity) or _G.SPELL_POWER_INSANITY or 13,
ARCANE_CHARGES = (Enum and Enum.PowerType and Enum.PowerType.ArcaneCharges) or _G.SPELL_POWER_ARCANE_CHARGES or 16,
FURY = (Enum and Enum.PowerType and Enum.PowerType.Fury) or _G.SPELL_POWER_FURY or 17,
PAIN = (Enum and Enum.PowerType and Enum.PowerType.Pain) or _G.SPELL_POWER_PAIN or 18,
}
-- Sparse fallback only for spells where GetSpellPowerCost is unreliable in combat.
-- Current Protection Warrior Ignore Pain cost is 35 Rage.
local POWER_SPEND_OVERRIDES = {
WARRIOR = {
[3] = {
[190456] = {
RAGE = 35,
},
},
},
}
local POWER_TYPE_TOKENS = {}
for token, id in pairs(POWER_TYPE_IDS) do
POWER_TYPE_TOKENS[tonumber(id)] = token
end
local function NormalizePowerToken(powerType)
if type(powerType) == "number" then
return POWER_TYPE_TOKENS[math.floor(powerType + 0.5)]
end
local token = tostring(powerType or ""):upper()
if token == "" then
return nil
end
return token
end
local function GetSpellPowerCostByToken(spellId, powerType)
local token = NormalizePowerToken(powerType)
local sid = tonumber(spellId)
if not token or not sid or sid <= 0 then
return 0
end
local costs
if C_Spell and type(C_Spell.GetSpellPowerCost) == "function" then
costs = C_Spell.GetSpellPowerCost(sid)
elseif type(GetSpellPowerCost) == "function" then
costs = GetSpellPowerCost(sid)
end
if type(costs) ~= "table" then
return 0
end
local bestCost = 0
for _, costInfo in pairs(costs) do
if type(costInfo) == "table" then
local entryToken = NormalizePowerToken(costInfo.type or costInfo.powerType or costInfo.name)
local requiredAuraId = tonumber(costInfo.requiredAuraID) or 0
local auraAllowed = (requiredAuraId <= 0) or (costInfo.hasRequiredAura == true)
if entryToken == token and auraAllowed then
local entryCost = tonumber(costInfo.cost)
or tonumber(costInfo.amount)
or tonumber(costInfo.minCost)
or 0
if entryCost > bestCost then
bestCost = entryCost
end
end
end
end
return math.max(0, bestCost)
end
local function GetTrackedPowerSpendOverride(classToken, specIndex, spellId, powerType)
local classMap = POWER_SPEND_OVERRIDES[tostring(classToken or "")]
local specMap = classMap and classMap[tonumber(specIndex) or 0]
local spellMap = specMap and specMap[tonumber(spellId) or 0]
local token = NormalizePowerToken(powerType)
if not spellMap or not token then
return 0
end
return math.max(0, tonumber(spellMap[token]) or 0)
end
local function GetSpellDebugLabel(spellId)
local sid = tonumber(spellId)
if not sid or sid <= 0 then
return "Unknown"
end
local spellName = (C_Spell and C_Spell.GetSpellName and C_Spell.GetSpellName(sid))
or (GetSpellInfo and select(1, GetSpellInfo(sid)))
or ("Spell " .. sid)
return string.format("%s (%d)", tostring(spellName), sid)
end
local function GetTrackedSpellCategoryLabel(spellEntry)
local dataset = spellEntry and tostring(spellEntry._hmgtDataset or "")
if dataset == "Interrupts" then
return "Interrupt"
elseif dataset == "RaidCooldowns" then
return "Raid CD"
elseif dataset == "GroupCooldowns" then
return "Group CD"
end
return "Tracked Spell"
end
local function BuildTrackedSpellCastSummary(spellEntry, details)
details = type(details) == "table" and details or {}
local stateKind = tostring(details.stateKind or HMGT_SpellData.GetStateKind(spellEntry) or "")
local currentCharges = tonumber(details.currentCharges)
local maxCharges = math.max(0, math.floor((tonumber(details.maxCharges) or 0) + 0.5))
local chargeCooldown = math.max(0, tonumber(details.chargeCooldown) or tonumber(details.cooldown) or 0)
local cooldown = math.max(0, tonumber(details.cooldown) or 0)
if stateKind == "availability" then
local required = math.max(0, math.floor((tonumber(details.required) or 0) + 0.5))
if required > 0 then
return string.format("ohne Zeit-CD, aktiviert bei %d Stacks", required)
end
return "ohne Zeit-CD"
end
if maxCharges > 1 then
if currentCharges == nil then
currentCharges = math.max(0, maxCharges - 1)
end
currentCharges = math.max(0, math.min(maxCharges, math.floor(currentCharges + 0.5)))
return string.format("Charges=%d/%d, Charge-CD=%.1fs", currentCharges, maxCharges, chargeCooldown)
end
return string.format("CD=%.1fs", cooldown)
end
function HMGT:LogTrackedSpellCast(playerName, spellEntry, details)
if not spellEntry then
return
end
self:DebugScoped(
"verbose",
"TrackedSpells",
"%s -> %s von %s, %s",
GetTrackedSpellCategoryLabel(spellEntry),
GetSpellDebugLabel(spellEntry.spellId),
tostring(playerName or "?"),
BuildTrackedSpellCastSummary(spellEntry, details)
)
end
function HMGT:StoreKnownChargeInfo(spellId, maxCharges, chargeDuration)
local sid = tonumber(spellId)
local maxCount = tonumber(maxCharges)
if not sid or sid <= 0 or not maxCount or maxCount <= 1 then
return
end
self.knownChargeInfo = self.knownChargeInfo or {}
self.knownChargeInfo[sid] = {
maxCharges = math.max(1, math.floor(maxCount + 0.5)),
chargeDuration = math.max(0, tonumber(chargeDuration) or 0),
updatedAt = GetTime(),
}
end
function HMGT:GetKnownChargeInfo(spellEntry, talents, spellId, fallbackChargeDuration)
local sid = tonumber(spellId or (spellEntry and spellEntry.spellId))
if not sid or sid <= 0 then
return 0, 0
end
local cached = self.knownChargeInfo and self.knownChargeInfo[sid]
local cachedMax = tonumber(cached and cached.maxCharges) or 0
local cachedDuration = tonumber(cached and cached.chargeDuration) or 0
local inferredMax, inferredDuration = HMGT_SpellData.GetEffectiveChargeInfo(
spellEntry,
talents or {},
(cachedMax > 0) and cachedMax or nil,
(cachedDuration > 0) and cachedDuration or fallbackChargeDuration
)
local maxCharges = math.max(cachedMax, tonumber(inferredMax) or 0)
local chargeDuration = math.max(
tonumber(inferredDuration) or 0,
cachedDuration,
tonumber(fallbackChargeDuration) or 0
)
if maxCharges > 1 then
self:StoreKnownChargeInfo(sid, maxCharges, chargeDuration)
end
return maxCharges, chargeDuration
end
local function IsSpellKnownLocally(spellId)
local sid = tonumber(spellId)
if not sid or sid <= 0 then return false end
if type(IsPlayerSpell) == "function" and IsPlayerSpell(sid) then
return true
end
if C_SpellBook and type(C_SpellBook.IsSpellKnown) == "function" and C_SpellBook.IsSpellKnown(sid) then
return true
end
return false
end
HMGT.classColors = {
WARRIOR = {0.78, 0.61, 0.43},
PALADIN = {0.96, 0.55, 0.73},
HUNTER = {0.67, 0.83, 0.45},
ROGUE = {1.00, 0.96, 0.41},
PRIEST = {1.00, 1.00, 1.00},
DEATHKNIGHT = {0.77, 0.12, 0.23},
SHAMAN = {0.00, 0.44, 0.87},
MAGE = {0.41, 0.80, 0.94},
WARLOCK = {0.58, 0.51, 0.79},
MONK = {0.00, 1.00, 0.59},
DRUID = {1.00, 0.49, 0.04},
DEMONHUNTER = {0.64, 0.19, 0.79},
EVOKER = {0.20, 0.58, 0.50},
}
-- ═══════════════════════════════════════════════════════════════
-- INITIALISIERUNG
-- ═══════════════════════════════════════════════════════════════
function HMGT:OnInitialize()
self.db = LibStub("AceDB-3.0"):New("HailMaryGuildToolsDB", defaults, true)
self:MigrateProfileSettings()
self:InitDefaultSpellSettings()
self:ApplyCustomSpellsFromProfile()
if HMGT_Config then
HMGT_Config:Initialize()
end
self:RegisterChatCommand("hmgt", "SlashCommand")
self:RegisterChatCommand("hailmary", "SlashCommand")
end
local function NormalizeBorderSettings(settings)
if settings.borderEnabled == nil then
settings.borderEnabled = (settings.borderTexture and settings.borderTexture ~= "") and true or false
end
if type(settings.borderColor) ~= "table" then
settings.borderColor = { r = 1, g = 1, b = 1, a = 1 }
else
settings.borderColor.r = settings.borderColor.r or settings.borderColor[1] or 1
settings.borderColor.g = settings.borderColor.g or settings.borderColor[2] or 1
settings.borderColor.b = settings.borderColor.b or settings.borderColor[3] or 1
settings.borderColor.a = settings.borderColor.a or settings.borderColor[4] or 1
end
end
local IsKnownAnchorTargetKey
local LEGACY_TRACKER_ANCHOR_IDS = {
InterruptTracker = 1,
RaidCooldownTracker = 2,
GroupCooldownTracker = 3,
}
local function NormalizeTrackerAnchorKey(anchorTo)
local trackerId = LEGACY_TRACKER_ANCHOR_IDS[tostring(anchorTo or "")]
if trackerId then
return "TRACKER:" .. tostring(trackerId)
end
return anchorTo
end
local function NormalizeAnchorSettings(settings)
settings.anchorTo = NormalizeTrackerAnchorKey(settings.anchorTo or "UIParent")
settings.anchorCustom = settings.anchorCustom or ""
if settings.anchorTo ~= "UIParent" and not IsKnownAnchorTargetKey(settings.anchorTo) then
settings.anchorCustom = settings.anchorTo
settings.anchorTo = "CUSTOM"
end
settings.anchorPoint = settings.anchorPoint or "TOPLEFT"
settings.anchorRelPoint = settings.anchorRelPoint or "TOPLEFT"
if settings.anchorX == nil then
settings.anchorX = settings.posX or 0
end
if settings.anchorY == nil then
settings.anchorY = settings.posY or 0
end
end
local function NormalizeTrackerLayout(settings, defaultShowChargesOnIcon, defaultShowSpellTooltip)
local legacyTestWasDemo = (settings.demoMode == nil and settings.testMode == true)
if settings.demoMode == nil then
settings.demoMode = settings.testMode == true
end
if legacyTestWasDemo then
settings.testMode = false
elseif settings.testMode == nil then
settings.testMode = false
end
settings.width = NormalizeLayoutValue(settings.width, 100, 600, 250)
settings.barHeight = NormalizeLayoutValue(settings.barHeight, 10, 60, 20)
settings.barSpacing = NormalizeLayoutValue(settings.barSpacing, 0, 20, 2)
settings.iconSize = NormalizeLayoutValue(settings.iconSize, 12, 100, 32)
settings.iconSpacing = NormalizeLayoutValue(settings.iconSpacing, 0, 20, 2)
settings.iconCols = NormalizeLayoutValue(settings.iconCols, 1, 20, 6)
settings.fontSize = NormalizeLayoutValue(settings.fontSize, 6, 24, 12)
settings.readySoonSec = NormalizeLayoutValue(settings.readySoonSec, 0, 300, 0)
if settings.showOnlyReady == nil then
settings.showOnlyReady = false
end
local trackerType = tostring(settings.trackerType or ""):lower()
if trackerType ~= "normal" and trackerType ~= "group" then
if settings.perGroupMember == true or settings.attachToPartyFrame == true then
trackerType = "group"
else
trackerType = "normal"
end
end
settings.trackerType = trackerType
settings.perGroupMember = (trackerType == "group")
if settings.includeSelfFrame == nil then
settings.includeSelfFrame = false
end
if settings.attachToPartyFrame == nil then
settings.attachToPartyFrame = false
end
if settings.showChargesOnIcon == nil then
settings.showChargesOnIcon = defaultShowChargesOnIcon and true or false
end
settings.showSpellTooltip = true
if settings.roleFilter ~= "ALL" and settings.roleFilter ~= "TANK" and settings.roleFilter ~= "HEALER" and settings.roleFilter ~= "DAMAGER" then
settings.roleFilter = "ALL"
end
if settings.rangeCheck == nil then
settings.rangeCheck = false
end
if settings.hideOutOfRange == nil then
settings.hideOutOfRange = false
end
settings.outOfRangeAlpha = NormalizeLayoutValue(settings.outOfRangeAlpha, 0.1, 1, 0.4)
if settings.partyAttachSide ~= "LEFT" and settings.partyAttachSide ~= "RIGHT" then
settings.partyAttachSide = "RIGHT"
end
settings.partyAttachOffsetX = NormalizeLayoutValue(settings.partyAttachOffsetX, -200, 200, 8)
settings.partyAttachOffsetY = NormalizeLayoutValue(settings.partyAttachOffsetY, -200, 200, 0)
if settings.showReadyText == nil then
settings.showReadyText = true
else
settings.showReadyText = settings.showReadyText ~= false
end
settings.showRemainingOnIcon = settings.showRemainingOnIcon == true
settings.iconOverlay = "sweep"
end
local function NormalizeMapOverlaySettings(settings)
if type(settings) ~= "table" then return end
if settings.enabled == nil then settings.enabled = true end
settings.iconSize = NormalizeLayoutValue(settings.iconSize, 8, 48, 16)
settings.alpha = NormalizeLayoutValue(settings.alpha, 0.1, 1, 1)
if settings.showLabels == nil then settings.showLabels = true end
if type(settings.categories) ~= "table" then settings.categories = { custom = true } end
if settings.categories.custom == nil then settings.categories.custom = true end
if type(settings.pois) ~= "table" then settings.pois = {} end
end
local function NormalizeBuffEndingAnnouncerSettings(settings)
if type(settings) ~= "table" then return end
if settings.enabled == nil then settings.enabled = true end
settings.announceAtSec = math.floor(NormalizeLayoutValue(settings.announceAtSec, 1, 30, 5) + 0.5)
if type(settings.trackedBuffs) ~= "table" then
settings.trackedBuffs = {}
return
end
local normalized = {}
for sid, value in pairs(settings.trackedBuffs) do
local id = tonumber(sid)
if id and id > 0 then
local threshold
if type(value) == "number" then
threshold = value
elseif value == true then
threshold = settings.announceAtSec
elseif type(value) == "table" then
if value.enabled ~= false then
threshold = value.threshold or value.announceAtSec or value.value
end
end
if threshold ~= nil then
normalized[id] = math.floor(NormalizeLayoutValue(threshold, 1, 30, settings.announceAtSec) + 0.5)
end
end
end
settings.trackedBuffs = normalized
end
local function NormalizePersonalAurasSettings(settings)
if type(settings) ~= "table" then return end
if settings.enabled == nil then settings.enabled = false end
settings.unlocked = settings.unlocked == true
settings.posX = NormalizeLayoutValue(settings.posX, -2000, 2000, 0)
settings.posY = NormalizeLayoutValue(settings.posY, -2000, 2000, 120)
settings.width = math.floor(NormalizeLayoutValue(settings.width, 160, 420, 260) + 0.5)
settings.rowHeight = math.floor(NormalizeLayoutValue(settings.rowHeight, 18, 42, 24) + 0.5)
settings.iconSize = math.floor(NormalizeLayoutValue(settings.iconSize, 14, 32, 20) + 0.5)
settings.fontSize = math.floor(NormalizeLayoutValue(settings.fontSize, 8, 24, 12) + 0.5)
if type(settings.trackedDebuffs) ~= "table" then
settings.trackedDebuffs = {}
return
end
local normalized = {}
for sid, value in pairs(settings.trackedDebuffs) do
local id = tonumber(sid)
if id and id > 0 and value ~= false and value ~= nil then
normalized[id] = true
end
end
settings.trackedDebuffs = normalized
end
local function NormalizeRaidTimelineSettings(settings)
if type(settings) ~= "table" then return end
if settings.enabled == nil then settings.enabled = false end
settings.leadTime = math.floor(NormalizeLayoutValue(settings.leadTime, 1, 15, 5) + 0.5)
settings.assignmentLeadTime = math.floor(NormalizeLayoutValue(settings.assignmentLeadTime, 0, 60, settings.leadTime or 5) + 0.5)
settings.unlocked = settings.unlocked == true
settings.alertPosX = NormalizeLayoutValue(settings.alertPosX, -2000, 2000, 0)
settings.alertPosY = NormalizeLayoutValue(settings.alertPosY, -2000, 2000, 180)
settings.alertFont = tostring(settings.alertFont or "Friz Quadrata TT")
settings.alertFontSize = math.floor(NormalizeLayoutValue(settings.alertFontSize, 10, 72, 30) + 0.5)
settings.alertFontOutline = tostring(settings.alertFontOutline or "OUTLINE")
if type(settings.alertColor) ~= "table" then
settings.alertColor = { r = 1, g = 0.82, b = 0.15, a = 1 }
else
settings.alertColor.r = tonumber(settings.alertColor.r or settings.alertColor[1]) or 1
settings.alertColor.g = tonumber(settings.alertColor.g or settings.alertColor[2]) or 0.82
settings.alertColor.b = tonumber(settings.alertColor.b or settings.alertColor[3]) or 0.15
settings.alertColor.a = tonumber(settings.alertColor.a or settings.alertColor[4]) or 1
end
if type(settings.encounters) ~= "table" then
settings.encounters = {}
return
end
local normalizedEncounters = {}
for encounterId, encounter in pairs(settings.encounters) do
local eid = tonumber(encounterId)
if eid and eid > 0 and type(encounter) == "table" then
local normalizedEncounter = {
name = tostring(encounter.name or ""),
journalInstanceId = tonumber(encounter.journalInstanceId) or 0,
instanceName = tostring(encounter.instanceName or ""),
difficulties = type(encounter.difficulties) == "table" and {
lfr = encounter.difficulties.lfr ~= false,
normal = encounter.difficulties.normal ~= false,
heroic = encounter.difficulties.heroic ~= false,
mythic = encounter.difficulties.mythic ~= false,
} or {
lfr = true,
normal = true,
heroic = true,
mythic = true,
},
entries = {},
}
if type(encounter.entries) == "table" then
for _, entry in ipairs(encounter.entries) do
if type(entry) == "table" then
local triggerType = tostring(entry.triggerType or "")
local actionType = tostring(entry.actionType or "")
local entryType = tostring(entry.entryType or "")
local spellId = math.max(0, tonumber(entry.spellId) or 0)
local timeSec = tonumber(entry.time) or 0
local alertText = tostring(entry.alertText or "")
local playerName = tostring(entry.playerName or "")
local targetSpec = tostring(entry.targetSpec or "")
local bossAbilityId = tostring(entry.bossAbilityId or "")
local bossAbilityBarName = tostring(entry.bossAbilityBarName or "")
local castCount = tostring(entry.castCount or ""):gsub("^%s+", ""):gsub("%s+$", ""):lower()
if castCount == "" then
castCount = "1"
elseif castCount ~= "all" and castCount ~= "odd" and castCount ~= "even" then
castCount = tostring(math.max(1, math.floor((tonumber(castCount) or 1) + 0.5)))
end
local valid = false
if triggerType == "bossAbility" then
if actionType == "text" then
valid = bossAbilityBarName ~= "" and alertText ~= ""
elseif actionType == "raidCooldown" then
valid = bossAbilityBarName ~= "" and spellId > 0
end
timeSec = 0
else
triggerType = "time"
if actionType == "text" then
valid = timeSec >= 0 and alertText ~= ""
spellId = 0
bossAbilityId = ""
bossAbilityBarName = ""
castCount = "1"
else
actionType = "raidCooldown"
valid = timeSec >= 0 and spellId > 0
bossAbilityId = ""
bossAbilityBarName = ""
castCount = "1"
end
end
if valid then
normalizedEncounter.entries[#normalizedEncounter.entries + 1] = {
time = math.floor(timeSec + 0.5),
spellId = spellId,
playerName = playerName,
entryType = entryType,
triggerType = triggerType,
actionType = actionType,
targetSpec = targetSpec,
alertText = alertText,
bossAbilityId = bossAbilityId,
bossAbilityBarName = bossAbilityBarName,
castCount = castCount,
}
end
end
end
end
table.sort(normalizedEncounter.entries, function(a, b)
if a.time ~= b.time then
return a.time < b.time
end
if a.spellId ~= b.spellId then
return a.spellId < b.spellId
end
return tostring(a.playerName or "") < tostring(b.playerName or "")
end)
normalizedEncounters[eid] = normalizedEncounter
end
end
settings.encounters = normalizedEncounters
end
local function NormalizeNotesSettings(settings)
if type(settings) ~= "table" then return end
settings.enabled = settings.enabled ~= false
settings.mainText = tostring(settings.mainText or "")
settings.mainTitle = tostring(settings.mainTitle or "")
settings.mainEncounterId = math.max(0, tonumber(settings.mainEncounterId) or 0)
settings.personalText = tostring(settings.personalText or "")
settings.window = type(settings.window) == "table" and settings.window or {}
settings.window.width = math.floor(NormalizeLayoutValue(settings.window.width, 700, 1600, 1080) + 0.5)
settings.window.height = math.floor(NormalizeLayoutValue(settings.window.height, 500, 1000, 700) + 0.5)
local drafts = type(settings.drafts) == "table" and settings.drafts or {}
local normalizedDrafts = {}
local seenIds = {}
for index, draft in ipairs(drafts) do
if type(draft) == "table" then
local draftId = math.max(1, tonumber(draft.id) or index)
while seenIds[draftId] do
draftId = draftId + 1
end
seenIds[draftId] = true
normalizedDrafts[#normalizedDrafts + 1] = {
id = draftId,
title = tostring(draft.title or ""),
text = tostring(draft.text or ""),
encounterId = math.max(0, tonumber(draft.encounterId) or 0),
}
end
end
table.sort(normalizedDrafts, function(a, b)
return (tonumber(a.id) or 0) < (tonumber(b.id) or 0)
end)
settings.drafts = normalizedDrafts
end
local function NormalizeMinimapSettings(settings)
if type(settings) ~= "table" then return end
if settings.hide == nil then settings.hide = false end
local pos = tonumber(settings.minimapPos)
if not pos then
pos = tonumber(settings.angle)
end
if not pos then pos = 220 end
settings.minimapPos = math.fmod(pos, 360)
if settings.minimapPos < 0 then
settings.minimapPos = settings.minimapPos + 360
end
settings.angle = nil
end
local function NormalizeTrackerCategories(categories)
local normalized = {}
local seen = {}
if type(categories) == "table" then
for _, category in ipairs(categories) do
local value = tostring(category or ""):lower()
if value ~= "" and not seen[value] then
seen[value] = true
normalized[#normalized + 1] = value
end
end
end
if #normalized == 0 then
normalized[1] = "interrupt"
end
return normalized
end
local function CopyTrackerFields(target, source)
if type(target) ~= "table" or type(source) ~= "table" then
return target
end
local keys = {
"enabled", "demoMode", "testMode", "showBar", "showSpellTooltip", "locked",
"posX", "posY", "anchorTo", "anchorCustom", "anchorPoint", "anchorRelPoint",
"anchorX", "anchorY", "width", "barHeight", "barSpacing", "barTexture",
"borderEnabled", "borderColor", "iconSize", "iconSpacing", "iconCols",
"iconOverlay", "textAnchor", "fontSize", "font", "fontOutline",
"growDirection", "showInSolo", "showInGroup", "showInRaid", "enabledSpells",
"showPlayerName", "colorByClass", "showChargesOnIcon", "showOnlyReady",
"readySoonSec", "roleFilter", "rangeCheck", "hideOutOfRange",
"outOfRangeAlpha", "showReadyText", "showRemainingOnIcon",
"trackerType", "perGroupMember", "includeSelfFrame", "attachToPartyFrame", "partyAttachSide",
"partyAttachOffsetX", "partyAttachOffsetY",
}
for _, key in ipairs(keys) do
if source[key] ~= nil then
target[key] = DeepCopy(source[key])
end
end
return target
end
function HMGT:CreateTrackerConfig(id, overrides)
local trackerId = tonumber(id) or 1
local settings = {
id = trackerId,
name = string.format("Tracker %d", trackerId),
enabled = true,
demoMode = false,
testMode = false,
trackerType = "normal",
categories = { "interrupt" },
showBar = true,
showSpellTooltip = true,
perGroupMember = false,
includeSelfFrame = false,
locked = false,
attachToPartyFrame = false,
partyAttachSide = "RIGHT",
partyAttachOffsetX = 8,
partyAttachOffsetY = 0,
posX = 200 + ((trackerId - 1) * 300),
posY = -200,
anchorTo = "UIParent",
anchorCustom = "",
anchorPoint = "TOPLEFT",
anchorRelPoint = "TOPLEFT",
anchorX = 200 + ((trackerId - 1) * 300),
anchorY = -200,
width = 250,
barHeight = 20,
barSpacing = 2,
barTexture = "Blizzard",
borderEnabled = false,
borderColor = { r = 1, g = 1, b = 1, a = 1 },
iconSize = 32,
iconSpacing = 2,
iconCols = 6,
iconOverlay = "sweep",
textAnchor = "below",
fontSize = 12,
font = "Friz Quadrata TT",
fontOutline = "OUTLINE",
growDirection = "DOWN",
showInSolo = true,
showInGroup = true,
showInRaid = true,
enabledSpells = {},
showPlayerName = true,
colorByClass = true,
showChargesOnIcon = false,
showOnlyReady = false,
readySoonSec = 0,
roleFilter = "ALL",
rangeCheck = false,
hideOutOfRange = false,
outOfRangeAlpha = 0.4,
showReadyText = true,
showRemainingOnIcon = false,
}
if type(overrides) == "table" then
CopyTrackerFields(settings, overrides)
if overrides.name ~= nil then
settings.name = tostring(overrides.name)
end
if overrides.id ~= nil then
settings.id = tonumber(overrides.id) or trackerId
end
if overrides.categories ~= nil then
settings.categories = DeepCopy(overrides.categories)
end
end
settings.categories = NormalizeTrackerCategories(settings.categories)
settings.name = tostring(settings.name or ""):gsub("^%s+", ""):gsub("%s+$", "")
if settings.name == "" then
settings.name = string.format("Tracker %d", tonumber(settings.id) or trackerId)
end
settings.showReadyText = settings.showReadyText ~= false
settings.showRemainingOnIcon = settings.showRemainingOnIcon == true
if type(settings.enabledSpells) ~= "table" then
settings.enabledSpells = {}
end
NormalizeBorderSettings(settings)
NormalizeAnchorSettings(settings)
NormalizeTrackerLayout(settings, settings.showChargesOnIcon == true, true)
return settings
end
function HMGT:GetTrackerConfigs()
local profile = self.db and self.db.profile
if not profile or type(profile.trackers) ~= "table" then
return {}
end
return profile.trackers
end
function HMGT:GetTrackerConfigById(id)
local trackerId = tonumber(id)
if not trackerId then
return nil
end
for _, tracker in ipairs(self:GetTrackerConfigs()) do
if tonumber(tracker.id) == trackerId then
return tracker
end
end
return nil
end
function HMGT:GetNextTrackerId()
local nextId = 1
for _, tracker in ipairs(self:GetTrackerConfigs()) do
nextId = math.max(nextId, (tonumber(tracker.id) or 0) + 1)
end
return nextId
end
function HMGT:GetTrackerAnchorKey(id)
local trackerId = tonumber(id)
if not trackerId then
return nil
end
return "TRACKER:" .. tostring(trackerId)
end
function HMGT:MigrateProfileSettings()
local p = self.db and self.db.profile
if not p then return end
p.debug = false
if p.debugLevel ~= "error" and p.debugLevel ~= "info" and p.debugLevel ~= "verbose" then
p.debugLevel = "info"
end
if type(p.debugScope) ~= "string" or p.debugScope == "" then
p.debugScope = DEBUG_SCOPE_ALL
end
p.devTools = type(p.devTools) == "table" and p.devTools or {}
p.devTools.enabled = p.devTools.enabled == true
if p.devTools.level ~= "error" and p.devTools.level ~= "trace" then
p.devTools.level = "error"
end
if type(p.devTools.scope) ~= "string" or p.devTools.scope == "" then
p.devTools.scope = "ALL"
end
p.devTools.window = type(p.devTools.window) == "table" and p.devTools.window or {}
p.devTools.window.width = math.max(720, tonumber(p.devTools.window.width) or 920)
p.devTools.window.height = math.max(260, tonumber(p.devTools.window.height) or 420)
p.devTools.window.minimized = p.devTools.window.minimized == true
p.syncRemoteCharges = true
if p.interruptTracker then
NormalizeBorderSettings(p.interruptTracker)
NormalizeAnchorSettings(p.interruptTracker)
NormalizeTrackerLayout(p.interruptTracker, false, true)
end
if p.raidCooldownTracker then
NormalizeBorderSettings(p.raidCooldownTracker)
NormalizeAnchorSettings(p.raidCooldownTracker)
NormalizeTrackerLayout(p.raidCooldownTracker, false, true)
end
if p.groupCooldownTracker then
NormalizeBorderSettings(p.groupCooldownTracker)
NormalizeAnchorSettings(p.groupCooldownTracker)
NormalizeTrackerLayout(p.groupCooldownTracker, true, true)
end
if type(p.trackers) ~= "table" then
p.trackers = {}
end
if #p.trackers == 0 and p.trackerModelVersion ~= TRACKER_MODEL_VERSION then
p.trackers = {
self:CreateTrackerConfig(1, CopyTrackerFields({
name = L["IT_NAME"] or "Interrupts",
trackerType = "normal",
categories = { "interrupt" },
}, p.interruptTracker or {})),
self:CreateTrackerConfig(2, CopyTrackerFields({
name = L["RCD_NAME"] or "Raid Cooldowns",
trackerType = "normal",
categories = { "raid" },
}, p.raidCooldownTracker or {})),
self:CreateTrackerConfig(3, CopyTrackerFields({
name = L["GCD_NAME"] or "Cooldowns",
trackerType = "group",
categories = { "defensive", "offensive", "tank", "healing", "utility", "cc", "lust" },
showChargesOnIcon = true,
}, p.groupCooldownTracker or {})),
}
end
local normalizedTrackers = {}
local seenTrackerIds = {}
for index, tracker in ipairs(p.trackers) do
if type(tracker) == "table" then
local trackerId = tonumber(tracker.id) or index
while seenTrackerIds[trackerId] do
trackerId = trackerId + 1
end
tracker.id = trackerId
seenTrackerIds[trackerId] = true
normalizedTrackers[#normalizedTrackers + 1] = self:CreateTrackerConfig(trackerId, tracker)
end
end
if #normalizedTrackers == 0 then
normalizedTrackers[1] = self:CreateTrackerConfig(1, {
name = L["IT_NAME"] or "Interrupts",
trackerType = "normal",
categories = { "interrupt" },
})
end
p.trackers = normalizedTrackers
p.trackerModelVersion = TRACKER_MODEL_VERSION
p.mapOverlay = p.mapOverlay or {}
NormalizeMapOverlaySettings(p.mapOverlay)
p.buffEndingAnnouncer = p.buffEndingAnnouncer or {}
NormalizeBuffEndingAnnouncerSettings(p.buffEndingAnnouncer)
p.personalAuras = p.personalAuras or {}
NormalizePersonalAurasSettings(p.personalAuras)
p.raidTimeline = p.raidTimeline or {}
NormalizeRaidTimelineSettings(p.raidTimeline)
p.notes = p.notes or {}
NormalizeNotesSettings(p.notes)
p.minimap = p.minimap or {}
NormalizeMinimapSettings(p.minimap)
p.autoEnemyMarker = nil
end
function HMGT:OnEnable()
self:RegisterComm(COMM_PREFIX, "OnCommReceived")
-- UNIT_SPELLCAST_SUCCEEDED für unitTag "player" → eigene Casts
if not self.unitEventFrame then
self.unitEventFrame = CreateFrame("Frame")
self.unitEventFrame:SetScript("OnEvent", function(_, event, ...)
if event == "UNIT_SPELLCAST_SENT" then
self:OnUnitSpellCastSent(event, ...)
elseif event == "UNIT_SPELLCAST_SUCCEEDED" then
self:OnUnitSpellCastSucceeded(event, ...)
elseif event == "UNIT_AURA" then
self:OnUnitAura(event, ...)
end
end)
end
self.unitEventFrame:RegisterUnitEvent("UNIT_SPELLCAST_SENT", "player")
self.unitEventFrame:RegisterUnitEvent("UNIT_SPELLCAST_SUCCEEDED", "player")
self.unitEventFrame:RegisterUnitEvent("UNIT_AURA", "player")
self:RegisterEvent("PLAYER_REGEN_ENABLED", "OnPlayerRegenEnabled")
self:RegisterEvent("GROUP_ROSTER_UPDATE", "OnGroupRosterUpdate")
self:RegisterEvent("PLAYER_ENTERING_WORLD", "OnPlayerEnteringWorld")
self:RegisterEvent("LOADING_SCREEN_DISABLED", "OnLoadingScreenDisabled")
-- PLAYER_LOGIN feuert nachdem der Char vollständig geladen ist
-- erst dann liefert GetSpecialization() zuverlässig den richtigen Wert.
self:RegisterEvent("PLAYER_LOGIN", "OnPlayerLogin")
-- Spec-Wechsel im Spiel
self:RegisterEvent("PLAYER_TALENT_UPDATE", "OnPlayerTalentUpdate")
self:RegisterEvent("ACTIVE_TALENT_GROUP_CHANGED", "OnPlayerTalentUpdate")
self:RegisterEvent("PLAYER_SPECIALIZATION_CHANGED","OnPlayerTalentUpdate")
-- Gruppen-Sichtbarkeit neu auswerten wenn sich die Zusammensetzung ändert
self:RegisterEvent("RAID_ROSTER_UPDATE", "OnGroupRosterUpdate")
if not self.cleanupTicker then
self.cleanupTicker = C_Timer.NewTicker(15, function() self:CleanupStaleCooldowns() end)
end
if not self.stateRepairTicker then
self.stateRepairTicker = C_Timer.NewTicker(5, function() self:BroadcastRepairSpellStates() end)
end
self:UpdateOwnPlayerInfo()
if HMGT.TrackerManager then
HMGT.TrackerManager:Enable()
end
if HMGT.MapOverlay and not HMGT.MapOverlay:IsEnabled() then HMGT.MapOverlay:Enable() end
self:RefreshFrameAnchors(true)
self:UpdateDebugWindowVisibility()
-- Initialize minimap launcher (LibDBIcon with legacy fallback).
self:CreateMinimapButton()
self:Print(L["ADDON_LOADED"])
end
function HMGT:RefreshFrameAnchors(force)
if not HMGT.TrackerFrame or not HMGT.TrackerFrame.ApplyAnchor then return end
if HMGT.TrackerManager and HMGT.TrackerManager.RefreshAnchors then
HMGT.TrackerManager:RefreshAnchors(force)
end
end
local COMMON_ANCHOR_TARGETS = {
{ key = "PlayerFrame", label = "Player Frame (PlayerFrame)" },
{ key = "TargetFrame", label = "Target Frame (TargetFrame)" },
{ key = "FocusFrame", label = "Focus Frame (FocusFrame)" },
{ key = "PetFrame", label = "Pet Frame (PetFrame)" },
{ key = "PartyMemberFrame1", label = "Party 1 (PartyMemberFrame1)" },
{ key = "PartyMemberFrame2", label = "Party 2 (PartyMemberFrame2)" },
{ key = "PartyMemberFrame3", label = "Party 3 (PartyMemberFrame3)" },
{ key = "PartyMemberFrame4", label = "Party 4 (PartyMemberFrame4)" },
{ key = "CompactRaidFrameManager", label = "Raid Manager (CompactRaidFrameManager)" },
{ key = "ElvUF_Player", label = "ElvUI Player (ElvUF_Player)" },
{ key = "ElvUF_Target", label = "ElvUI Target (ElvUF_Target)" },
{ key = "ElvUF_Focus", label = "ElvUI Focus (ElvUF_Focus)" },
{ key = "ElvUF_Pet", label = "ElvUI Pet (ElvUF_Pet)" },
{ key = "ElvUF_PartyGroup1UnitButton1", label = "ElvUI Party 1 (ElvUF_PartyGroup1UnitButton1)" },
{ key = "ElvUF_PartyGroup1UnitButton2", label = "ElvUI Party 2 (ElvUF_PartyGroup1UnitButton2)" },
{ key = "ElvUF_PartyGroup1UnitButton3", label = "ElvUI Party 3 (ElvUF_PartyGroup1UnitButton3)" },
{ key = "ElvUF_PartyGroup1UnitButton4", label = "ElvUI Party 4 (ElvUF_PartyGroup1UnitButton4)" },
{ key = "ElvUF_PartyGroup1UnitButton5", label = "ElvUI Party 5 (ElvUF_PartyGroup1UnitButton5)" },
{ key = "ElvUF_Raid1UnitButton1", label = "ElvUI Raid 1 (ElvUF_Raid1UnitButton1)" },
}
local function ParseTrackerAnchorKey(anchorTo)
local trackerId = tostring(anchorTo or ""):match("^TRACKER:(%d+)$")
return tonumber(trackerId)
end
local function GetTrackerAnchorLabelById(trackerId)
local tracker = HMGT.GetTrackerConfigById and HMGT:GetTrackerConfigById(trackerId) or nil
if tracker then
local name = tostring(tracker.name or ""):gsub("^%s+", ""):gsub("%s+$", "")
if name ~= "" then
return name
end
end
if trackerId then
return string.format("%s %d", L["OPT_TRACKER"] or "Tracker", tonumber(trackerId) or 0)
end
return L["OPT_TRACKER"] or "Tracker"
end
IsKnownAnchorTargetKey = function(anchorTo)
anchorTo = NormalizeTrackerAnchorKey(anchorTo)
if anchorTo == "UIParent" or anchorTo == "CUSTOM" then return true end
if ParseTrackerAnchorKey(anchorTo) then return true end
for _, target in ipairs(COMMON_ANCHOR_TARGETS) do
if target.key == anchorTo then
return true
end
end
return false
end
--- Liefert die aktuell waehlbaren Anchor-Targets fuer das Config-Dropdown.
--- Enthalten sind Tracker-Frames sowie eine feste Liste sinnvoller UI-Frames.
function HMGT:GetAnchorTargetOptions(currentTrackerId, selectedValue)
local values = {
UIParent = L["OPT_ANCHOR_TARGET_UI"] or "UIParent",
CUSTOM = L["OPT_ANCHOR_TARGET_CUSTOM"] or "Custom frame name",
}
local selectedKey = NormalizeTrackerAnchorKey(selectedValue)
local activeTrackerId = tonumber(currentTrackerId) or ParseTrackerAnchorKey(currentTrackerId)
for _, tracker in ipairs(self:GetTrackerConfigs()) do
local trackerId = tonumber(tracker.id)
local anchorKey = self:GetTrackerAnchorKey(trackerId)
if trackerId and anchorKey and trackerId ~= activeTrackerId then
values[anchorKey] = GetTrackerAnchorLabelById(trackerId)
end
end
for _, target in ipairs(COMMON_ANCHOR_TARGETS) do
values[target.key] = target.label
end
if selectedKey and selectedKey ~= "" and not values[selectedKey] then
local trackerId = ParseTrackerAnchorKey(selectedKey)
if trackerId then
values[selectedKey] = GetTrackerAnchorLabelById(trackerId)
else
values[selectedKey] = selectedKey
end
end
return values
end
--- Loest ein gespeichertes Anchor-Ziel in ein echtes Frame-Objekt auf.
function HMGT:GetAnchorTargetFrame(anchorTo, anchorCustom)
anchorTo = NormalizeTrackerAnchorKey(anchorTo)
local trackerId = ParseTrackerAnchorKey(anchorTo)
if trackerId then
local tracker = self:GetTrackerConfigById(trackerId)
if tracker and HMGT.TrackerManager then
if type(HMGT.TrackerManager.GetAnchorFrame) == "function" then
return HMGT.TrackerManager:GetAnchorFrame(tracker)
end
if type(HMGT.TrackerManager.EnsureFrame) == "function" then
return HMGT.TrackerManager:EnsureFrame(tracker)
end
end
end
local resolved = anchorTo
if anchorTo == "CUSTOM" then
resolved = anchorCustom
end
if type(resolved) == "string" and resolved ~= "" then
local target = _G[resolved]
if target and target.IsObjectType and (target:IsObjectType("Frame") or target:IsObjectType("Button")) then
return target
end
end
return UIParent
end
function HMGT:OnDisable()
if self.unitEventFrame then
self.unitEventFrame:UnregisterAllEvents()
end
if HMGT.TrackerManager then HMGT.TrackerManager:Disable() end
self:StopReliableCommTicker()
self.pendingReliableMessages = {}
self.pendingReliableBySupersede = {}
self.recentGroupMessages = {}
if self.cleanupTicker then
self.cleanupTicker:Cancel()
self.cleanupTicker = nil
end
if self._syncRequestTimer then
self:CancelTimer(self._syncRequestTimer, true)
self._syncRequestTimer = nil
end
if self._syncBurstTimers then
for _, timerHandle in ipairs(self._syncBurstTimers) do
self:CancelTimer(timerHandle, true)
end
self._syncBurstTimers = nil
end
if LDBIcon and self._ldbIconRegistered then
LDBIcon:Hide(ADDON_NAME)
end
end
-- ═══════════════════════════════════════════════════════════════
-- SPELL-STANDARDS
-- ═══════════════════════════════════════════════════════════════
function HMGT:InitDefaultSpellSettings()
for _, tracker in ipairs(self:GetTrackerConfigs()) do
if type(tracker.enabledSpells) ~= "table" then
tracker.enabledSpells = {}
end
end
end
function HMGT:ApplyCustomSpellsFromProfile()
if HMGT_SpellData.RebuildLookups then
HMGT_SpellData.RebuildLookups()
end
self:InitDefaultSpellSettings()
end
function HMGT:AddCustomSpell(dbKey, spellId, cooldown, classToken, specText, category)
return false
end
function HMGT:RemoveCustomSpell(dbKey, spellId)
return false
end
-- ═══════════════════════════════════════════════════════════════
-- EIGENE SPIELER-INFO
-- ═══════════════════════════════════════════════════════════════
function HMGT:UpdateOwnPlayerInfo()
local name = self:NormalizePlayerName(UnitName("player"))
local class = select(2, UnitClass("player"))
local specIndex = GetSpecialization()
-- GetSpecialization() liefert 0 wenn der Char noch nicht vollstaendig geladen ist.
-- NIEMALS auf Spec 1 defaulten - das wuerde z.B. einem Prot-Krieger Pummel (Arms/Fury)
-- statt Schildschlag (Prot) anzeigen. Stattdessen: 0.5s warten und erneut versuchen.
if not specIndex or specIndex == 0 then
C_Timer.After(0.5, function() self:UpdateOwnPlayerInfo() end)
return
end
local talents = {}
local configID = C_ClassTalents and C_ClassTalents.GetActiveConfigID
and C_ClassTalents.GetActiveConfigID()
if configID then
local configInfo = C_Traits.GetConfigInfo(configID)
if configInfo then
for _, treeID in ipairs(configInfo.treeIDs or {}) do
for _, nodeID in ipairs(C_Traits.GetTreeNodes(treeID) or {}) do
local nodeInfo = C_Traits.GetNodeInfo(configID, nodeID)
if nodeInfo and nodeInfo.activeRank and nodeInfo.activeRank > 0 then
local entryID = nodeInfo.activeEntry and nodeInfo.activeEntry.entryID
if entryID then
local entryInfo = C_Traits.GetEntryInfo(configID, entryID)
if entryInfo and entryInfo.definitionID then
local defInfo = C_Traits.GetDefinitionInfo(entryInfo.definitionID)
if defInfo and defInfo.spellID then
talents[defInfo.spellID] = true
end
end
end
end
end
end
end
end
self.knownChargeInfo = self.knownChargeInfo or {}
wipe(self.knownChargeInfo)
self.playerData[name] = {
class = class,
specIndex = specIndex,
talentHash = self:HashTalents(talents),
talents = talents,
knownSpells = self:CollectOwnAvailableTrackerSpells(class, specIndex),
isOwn = true,
}
self:RefreshOwnAvailabilityStates()
self:ResetOwnPowerTracking()
-- Tracker sofort nach erfolgreicher Spec-Erkennung aktualisieren
self:TriggerTrackerUpdate()
self:QueueSyncRequest(0.20)
end
function HMGT:ResetOwnPowerTracking()
self.powerTracking = self.powerTracking or {}
self.powerTracking.accumulators = self.powerTracking.accumulators or {}
self.pendingSpellPowerCosts = self.pendingSpellPowerCosts or {}
wipe(self.powerTracking.accumulators)
wipe(self.pendingSpellPowerCosts)
end
function HMGT:HashTalents(talents)
local ids = {}
for id in pairs(talents) do table.insert(ids, id) end
table.sort(ids)
return table.concat(ids, ",")
end
function HMGT:NormalizePlayerName(name)
if not name or name == "" then return nil end
return Ambiguate(name, "short")
end
function HMGT:ParseTalentHash(hash)
local talents = {}
if hash and hash ~= "" then
for id in hash:gmatch("(%d+)") do
talents[tonumber(id)] = true
end
end
return talents
end
function HMGT:ParseKnownSpellList(listStr)
local knownSpells = {}
if type(listStr) == "string" and listStr ~= "" then
for id in listStr:gmatch("(%d+)") do
knownSpells[tonumber(id)] = true
end
end
return knownSpells
end
function HMGT:SerializeKnownSpellList(knownSpells)
local ids = {}
if type(knownSpells) == "table" then
for sid, known in pairs(knownSpells) do
if known then
ids[#ids + 1] = tonumber(sid)
end
end
end
table.sort(ids)
for i = 1, #ids do
ids[i] = tostring(ids[i])
end
return table.concat(ids, ",")
end
function HMGT:GetAvailabilityConfig(spellEntry)
if HMGT_SpellData and type(HMGT_SpellData.GetAvailabilityConfig) == "function" then
local availability = HMGT_SpellData.GetAvailabilityConfig(spellEntry)
if type(availability) == "table" and availability.type then
return availability
end
end
local availability = spellEntry and spellEntry.availability
if type(availability) ~= "table" or not availability.type then
return nil
end
return availability
end
function HMGT:IsAvailabilitySpell(spellEntry)
return self:GetAvailabilityConfig(spellEntry) ~= nil
end
function HMGT:GetAvailabilityRequiredCount(spellEntry)
local required = 0
if HMGT_SpellData and type(HMGT_SpellData.GetEffectiveAvailabilityRequired) == "function" then
local ownName = self:NormalizePlayerName(UnitName("player"))
local pData = ownName and self.playerData and self.playerData[ownName]
required = HMGT_SpellData.GetEffectiveAvailabilityRequired(spellEntry, pData and pData.talents or {})
end
if required <= 0 then
local availability = self:GetAvailabilityConfig(spellEntry)
required = tonumber(availability and availability.required) or 0
end
if required <= 0 then
return 0
end
return math.max(1, math.floor(required + 0.5))
end
function HMGT:GetOwnAvailabilityProgress(spellEntry)
local availability = self:GetAvailabilityConfig(spellEntry)
if not availability then
return nil, nil
end
local required = self:GetAvailabilityRequiredCount(spellEntry)
if required <= 0 then
return nil, nil
end
local current = 0
if availability.type == "auraStacks" then
current = GetPlayerAuraApplications(availability.auraSpellId)
if current <= 0 then
local fallbackSpellId = tonumber(availability.fallbackSpellCountId)
or tonumber(availability.progressSpellId)
or tonumber(spellEntry and spellEntry.spellId)
if fallbackSpellId and fallbackSpellId > 0 then
current = GetSpellCastCountInfo(fallbackSpellId)
end
end
else
return nil, nil
end
current = math.max(0, math.min(required, tonumber(current) or 0))
return current, required
end
function HMGT:GetAvailabilityState(playerName, spellId)
local normalizedName = self:NormalizePlayerName(playerName)
local sid = tonumber(spellId)
local states = normalizedName and self.availabilityStates[normalizedName]
local state = states and sid and states[sid]
if not state then
return nil, nil
end
return tonumber(state.current) or 0, tonumber(state.max) or 0
end
function HMGT:HasAvailabilityState(playerName, spellId)
local _, max = self:GetAvailabilityState(playerName, spellId)
return (tonumber(max) or 0) > 0
end
function HMGT:StoreAvailabilityState(playerName, spellId, current, max, spellEntry)
local normalizedName = self:NormalizePlayerName(playerName)
local sid = tonumber(spellId)
if not normalizedName or not sid or sid <= 0 then
return false
end
local maxCount = math.max(0, math.floor((tonumber(max) or 0) + 0.5))
if maxCount <= 0 then
local states = self.availabilityStates[normalizedName]
if states and states[sid] then
states[sid] = nil
if not next(states) then
self.availabilityStates[normalizedName] = nil
end
return true
end
return false
end
local currentCount = math.max(0, math.min(maxCount, math.floor((tonumber(current) or 0) + 0.5)))
self.availabilityStates[normalizedName] = self.availabilityStates[normalizedName] or {}
local previous = self.availabilityStates[normalizedName][sid]
local changed = (not previous)
or (tonumber(previous.current) or -1) ~= currentCount
or (tonumber(previous.max) or -1) ~= maxCount
self.availabilityStates[normalizedName][sid] = {
current = currentCount,
max = maxCount,
spellEntry = spellEntry,
updatedAt = GetTime(),
}
return changed
end
function HMGT:PruneAvailabilityStates(playerName, knownSpells)
local normalizedName = self:NormalizePlayerName(playerName)
local states = normalizedName and self.availabilityStates[normalizedName]
if not states or type(knownSpells) ~= "table" then
return false
end
local changed = false
for sid in pairs(states) do
if not knownSpells[tonumber(sid)] then
states[sid] = nil
changed = true
end
end
if not next(states) then
self.availabilityStates[normalizedName] = nil
end
return changed
end
function HMGT:BroadcastAvailabilityState(spellId, current, max, target)
local sid = tonumber(spellId)
local currentCount = math.max(0, math.floor((tonumber(current) or 0) + 0.5))
local maxCount = math.max(0, math.floor((tonumber(max) or 0) + 0.5))
if not sid or sid <= 0 or maxCount <= 0 then
return
end
local payload = string.format("%s|%d|%d|%d|%s|%d",
MSG_SPELL_STATE,
sid,
currentCount,
maxCount,
ADDON_VERSION,
PROTOCOL_VERSION
)
if target and target ~= "" then
self:SendDirectMessage(payload, target, "ALERT")
else
self:SendGroupMessage(payload, "ALERT")
end
end
function HMGT:RefreshOwnAvailabilitySpell(spellEntry)
if not self:IsAvailabilitySpell(spellEntry) then
return false
end
local playerName = self:NormalizePlayerName(UnitName("player"))
if not playerName then
return false
end
local current, max = self:GetOwnAvailabilityProgress(spellEntry)
if (tonumber(max) or 0) > 0 then
local pData = self.playerData[playerName]
if pData and type(pData.knownSpells) == "table" then
pData.knownSpells[tonumber(spellEntry.spellId)] = true
end
end
return self:StoreAvailabilityState(playerName, spellEntry.spellId, current, max, spellEntry)
end
function HMGT:RefreshOwnAvailabilityStates()
local playerName = self:NormalizePlayerName(UnitName("player"))
local pData = playerName and self.playerData[playerName]
if not pData or not pData.class or not pData.specIndex then
return false
end
local changed = false
local groupCooldowns = HMGT_SpellData.GetSpellsForSpec(pData.class, pData.specIndex, HMGT_SpellData.GroupCooldowns)
for _, spellEntry in ipairs(groupCooldowns or {}) do
if self:IsAvailabilitySpell(spellEntry) and self:RefreshOwnAvailabilitySpell(spellEntry) then
changed = true
end
end
if self:PruneAvailabilityStates(playerName, pData.knownSpells or {}) then
changed = true
end
return changed
end
function HMGT:RefreshAndPublishOwnAvailabilityStates()
local playerName = self:NormalizePlayerName(UnitName("player"))
local pData = playerName and self.playerData[playerName]
if not pData or not pData.class or not pData.specIndex then
return false
end
local changed = false
local groupCooldowns = HMGT_SpellData.GetSpellsForSpec(pData.class, pData.specIndex, HMGT_SpellData.GroupCooldowns)
for _, spellEntry in ipairs(groupCooldowns or {}) do
if self:IsAvailabilitySpell(spellEntry) and self:RefreshOwnAvailabilitySpell(spellEntry) then
self:PublishOwnSpellState(spellEntry.spellId, { sendLegacy = true })
changed = true
end
end
if self:PruneAvailabilityStates(playerName, pData.knownSpells or {}) then
changed = true
end
return changed
end
function HMGT:SendOwnAvailabilityStates(target)
local playerName = self:NormalizePlayerName(UnitName("player"))
local pData = playerName and self.playerData[playerName]
if not pData or not pData.class or not pData.specIndex then
return 0
end
self:RefreshOwnAvailabilityStates()
local sent = 0
local groupCooldowns = HMGT_SpellData.GetSpellsForSpec(pData.class, pData.specIndex, HMGT_SpellData.GroupCooldowns)
for _, spellEntry in ipairs(groupCooldowns or {}) do
if self:IsAvailabilitySpell(spellEntry) and self:IsTrackedSpellKnownForPlayer(playerName, spellEntry.spellId) then
local current, max = self:GetAvailabilityState(playerName, spellEntry.spellId)
if (tonumber(max) or 0) > 0 then
self:BroadcastAvailabilityState(spellEntry.spellId, current, max, target)
sent = sent + 1
end
end
end
return sent
end
function HMGT:GetLocalSpellStateRevision(spellId)
local sid = tonumber(spellId)
if not sid or sid <= 0 then return 0 end
return tonumber(self.localSpellStateRevisions[sid]) or 0
end
function HMGT:EnsureLocalSpellStateRevision(spellId)
local sid = tonumber(spellId)
if not sid or sid <= 0 then return 0 end
local current = tonumber(self.localSpellStateRevisions[sid]) or 0
if current <= 0 then
current = 1
self.localSpellStateRevisions[sid] = current
end
return current
end
function HMGT:NextLocalSpellStateRevision(spellId)
local sid = tonumber(spellId)
if not sid or sid <= 0 then return 0 end
local nextRevision = (tonumber(self.localSpellStateRevisions[sid]) or 0) + 1
self.localSpellStateRevisions[sid] = nextRevision
return nextRevision
end
function HMGT:GetRemoteSpellStateRevision(playerName, spellId)
local normalizedName = self:NormalizePlayerName(playerName)
local sid = tonumber(spellId)
local bySpell = normalizedName and self.remoteSpellStateRevisions[normalizedName]
return tonumber(bySpell and bySpell[sid]) or 0
end
function HMGT:SetRemoteSpellStateRevision(playerName, spellId, revision)
local normalizedName = self:NormalizePlayerName(playerName)
local sid = tonumber(spellId)
local rev = tonumber(revision) or 0
if not normalizedName or not sid or sid <= 0 or rev <= 0 then
return
end
self.remoteSpellStateRevisions[normalizedName] = self.remoteSpellStateRevisions[normalizedName] or {}
self.remoteSpellStateRevisions[normalizedName][sid] = rev
end
function HMGT:BuildClearSpellStateSnapshot(spellId, spellEntry)
return {
spellId = tonumber(spellId),
spellEntry = spellEntry,
kind = "clear",
a = 0,
b = 0,
c = 0,
d = 0,
}
end
function HMGT:GetOwnSpellStateSnapshot(spellId)
local sid = tonumber(spellId)
if not sid or sid <= 0 then return nil end
local spellEntry = HMGT_SpellData.InterruptLookup[sid]
or HMGT_SpellData.CooldownLookup[sid]
if not spellEntry then return nil end
if self:IsAvailabilitySpell(spellEntry) then
local current, max = self:GetOwnAvailabilityProgress(spellEntry)
if (tonumber(max) or 0) > 0 then
self:StoreAvailabilityState(self:NormalizePlayerName(UnitName("player")), sid, current, max, spellEntry)
return {
spellId = sid,
spellEntry = spellEntry,
kind = "availability",
a = tonumber(current) or 0,
b = tonumber(max) or 0,
c = 0,
d = 0,
}
end
return self:BuildClearSpellStateSnapshot(sid, spellEntry)
end
local ownName = self:NormalizePlayerName(UnitName("player"))
local pData = ownName and self.playerData and self.playerData[ownName]
local talents = pData and pData.talents or {}
local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents)
local knownMaxCharges, knownChargeDuration = self:GetKnownChargeInfo(spellEntry, talents, sid, effectiveCd)
local cdData = ownName and self.activeCDs[ownName] and self.activeCDs[ownName][sid]
if cdData then
if (tonumber(cdData.maxCharges) or 0) > 0 then
local nextRemaining, chargeDuration, charges, maxCharges = self:ResolveChargeState(cdData)
self:StoreKnownChargeInfo(sid, maxCharges, chargeDuration)
if (tonumber(maxCharges) or 0) > 0 and (tonumber(charges) or 0) < (tonumber(maxCharges) or 0) then
return {
spellId = sid,
spellEntry = spellEntry,
kind = "charges",
a = tonumber(charges) or 0,
b = tonumber(maxCharges) or 0,
c = tonumber(nextRemaining) or 0,
d = tonumber(chargeDuration) or 0,
}
end
elseif knownMaxCharges > 1 then
local duration = tonumber(cdData.duration) or 0
local startTime = tonumber(cdData.startTime) or GetTime()
local remaining = math.max(0, duration - (GetTime() - startTime))
local currentCharges = knownMaxCharges
if remaining > 0 then
currentCharges = math.max(0, knownMaxCharges - 1)
return {
spellId = sid,
spellEntry = spellEntry,
kind = "charges",
a = tonumber(currentCharges) or 0,
b = tonumber(knownMaxCharges) or 0,
c = tonumber(remaining) or 0,
d = tonumber(knownChargeDuration) or tonumber(effectiveCd) or duration,
}
end
else
local duration = tonumber(cdData.duration) or 0
local startTime = tonumber(cdData.startTime) or GetTime()
local remaining = math.max(0, duration - (GetTime() - startTime))
if duration > 0 and remaining > 0 then
return {
spellId = sid,
spellEntry = spellEntry,
kind = "cooldown",
a = remaining,
b = duration,
c = 0,
d = 0,
}
end
end
end
if InCombatLockdown and InCombatLockdown() then
if knownMaxCharges > 1 then
return {
spellId = sid,
spellEntry = spellEntry,
kind = "charges",
a = tonumber(knownMaxCharges) or 0,
b = tonumber(knownMaxCharges) or 0,
c = 0,
d = tonumber(knownChargeDuration) or tonumber(effectiveCd) or 0,
}
end
return self:BuildClearSpellStateSnapshot(sid, spellEntry)
end
local remaining, total, currentCharges, maxCharges = self:GetCooldownInfo(ownName, sid)
if (tonumber(maxCharges) or 0) > 0 then
local cur = math.max(0, math.floor((tonumber(currentCharges) or 0) + 0.5))
local max = math.max(0, math.floor((tonumber(maxCharges) or 0) + 0.5))
local nextRemaining = math.max(0, tonumber(remaining) or 0)
local chargeDuration = math.max(0, tonumber(total) or 0)
if max <= 0 or cur >= max then
return self:BuildClearSpellStateSnapshot(sid, spellEntry)
end
return {
spellId = sid,
spellEntry = spellEntry,
kind = "charges",
a = cur,
b = max,
c = nextRemaining,
d = chargeDuration,
}
end
local duration = math.max(0, tonumber(total) or 0)
local cooldownRemaining = math.max(0, tonumber(remaining) or 0)
if duration <= 0 or cooldownRemaining <= 0 then
return self:BuildClearSpellStateSnapshot(sid, spellEntry)
end
return {
spellId = sid,
spellEntry = spellEntry,
kind = "cooldown",
a = cooldownRemaining,
b = duration,
c = 0,
d = 0,
}
end
function HMGT:SendSpellStateSnapshot(snapshot, target, revision)
if type(snapshot) ~= "table" then return false end
local sid = tonumber(snapshot.spellId)
local kind = tostring(snapshot.kind or "")
local rev = tonumber(revision) or 0
if not sid or sid <= 0 or kind == "" or rev <= 0 then
return false
end
self:DebugScoped(
"verbose",
"TrackedSpells",
"SendSpellStateSnapshot target=%s spell=%s kind=%s rev=%d a=%.3f b=%.3f c=%.3f d=%.3f",
tostring(target and target ~= "" and target or "GROUP"),
GetSpellDebugLabel(sid),
tostring(kind),
rev,
tonumber(snapshot.a) or 0,
tonumber(snapshot.b) or 0,
tonumber(snapshot.c) or 0,
tonumber(snapshot.d) or 0
)
local payload = string.format(
"%s|%d|%s|%d|%.3f|%.3f|%.3f|%.3f|%s|%d",
MSG_SPELL_STATE,
sid,
kind,
rev,
tonumber(snapshot.a) or 0,
tonumber(snapshot.b) or 0,
tonumber(snapshot.c) or 0,
tonumber(snapshot.d) or 0,
ADDON_VERSION,
PROTOCOL_VERSION
)
if target and target ~= "" then
self:SendDirectMessage(payload, target, "ALERT")
else
self:SendGroupMessage(payload, "ALERT")
end
return true
end
function HMGT:PublishOwnSpellState(spellId, opts)
opts = opts or {}
local sid = tonumber(spellId)
if not sid or sid <= 0 then return false end
local snapshot = opts.snapshot or self:GetOwnSpellStateSnapshot(sid)
if not snapshot then return false end
local revision = tonumber(opts.revision) or self:NextLocalSpellStateRevision(sid)
local sent = self:SendSpellStateSnapshot(snapshot, opts.target, revision)
if not sent then
return false
end
if opts.sendLegacy then
if snapshot.kind == "availability" then
self:BroadcastAvailabilityState(sid, snapshot.a, snapshot.b, opts.target)
elseif snapshot.kind ~= "clear" then
self:BroadcastSpellCast(sid, snapshot)
end
end
return true
end
function HMGT:SendOwnTrackedSpellStates(target)
local ownName = self:NormalizePlayerName(UnitName("player"))
if not ownName then return 0 end
self:RefreshOwnAvailabilityStates()
local sent = 0
local sentBySpell = {}
local activeStates = self.activeCDs[ownName]
if type(activeStates) == "table" then
for sid in pairs(activeStates) do
sid = tonumber(sid)
if sid and sid > 0 and not sentBySpell[sid] then
local revision = self:EnsureLocalSpellStateRevision(sid)
if revision > 0 and self:SendSpellStateSnapshot(self:GetOwnSpellStateSnapshot(sid), target, revision) then
sent = sent + 1
sentBySpell[sid] = true
end
end
end
end
local availabilityStates = self.availabilityStates[ownName]
if type(availabilityStates) == "table" then
for sid in pairs(availabilityStates) do
sid = tonumber(sid)
if sid and sid > 0 and not sentBySpell[sid] then
local revision = self:EnsureLocalSpellStateRevision(sid)
if revision > 0 and self:SendSpellStateSnapshot(self:GetOwnSpellStateSnapshot(sid), target, revision) then
sent = sent + 1
sentBySpell[sid] = true
end
end
end
end
return sent
end
function HMGT:BroadcastRepairSpellStates()
if not self:IsEnabled() then return end
local sent = self:SendOwnTrackedSpellStates()
if sent > 0 then
self:DebugScoped("verbose", "TrackedSpells", "RepairSpellStates sent=%d", sent)
end
end
function HMGT:ReconcileOwnTrackedSpellStatesFromGame(publishChanges)
if InCombatLockdown and InCombatLockdown() then
return 0
end
local ownName = self:NormalizePlayerName(UnitName("player"))
local pData = ownName and self.playerData and self.playerData[ownName]
if not ownName or not pData or not pData.class or not pData.specIndex then
return 0
end
pData.knownSpells = self:CollectOwnAvailableTrackerSpells(pData.class, pData.specIndex)
local changed = 0
for sid in pairs(pData.knownSpells or {}) do
local spellEntry = HMGT_SpellData.InterruptLookup[sid]
or HMGT_SpellData.CooldownLookup[sid]
if spellEntry and not self:IsAvailabilitySpell(spellEntry) then
if self:RefreshOwnCooldownStateFromGame(sid) then
changed = changed + 1
if publishChanges then
self:PublishOwnSpellState(sid, { sendLegacy = true })
end
end
end
end
if changed > 0 then
self:TriggerTrackerUpdate()
end
return changed
end
function HMGT:CollectOwnAvailableTrackerSpells(classToken, specIndex)
local class = classToken or select(2, UnitClass("player"))
local spec = tonumber(specIndex) or tonumber(GetSpecialization())
if not class or not spec or spec <= 0 then
return {}
end
if not HMGT_SpellData or type(HMGT_SpellData.GetSpellsForSpec) ~= "function" then
return {}
end
local knownSpells = {}
for _, datasetName in ipairs({ "Interrupts", "RaidCooldowns", "GroupCooldowns" }) do
local dataset = HMGT_SpellData[datasetName]
if type(dataset) == "table" then
local spells = HMGT_SpellData.GetSpellsForSpec(class, spec, dataset)
for _, entry in ipairs(spells) do
local sid = tonumber(entry.spellId)
if sid and sid > 0 and IsSpellKnownLocally(sid) then
knownSpells[sid] = true
end
end
end
end
local ownName = self:NormalizePlayerName(UnitName("player"))
local ownCDs = ownName and self.activeCDs[ownName]
if ownCDs then
for sid in pairs(ownCDs) do
sid = tonumber(sid)
if sid and sid > 0 then
knownSpells[sid] = true
end
end
end
return knownSpells
end
function HMGT:IsTrackedSpellKnownForPlayer(playerName, spellId)
local sid = tonumber(spellId)
if not sid or sid <= 0 then
return false
end
local normalizedName = self:NormalizePlayerName(playerName)
local ownName = self:NormalizePlayerName(UnitName("player"))
local pData = normalizedName and self.playerData[normalizedName]
if pData and type(pData.knownSpells) == "table" and pData.knownSpells[sid] == true then
return true
end
if normalizedName and ownName and normalizedName == ownName then
return IsSpellKnownLocally(sid)
end
return false
end
-- ═══════════════════════════════════════════════════════════════
-- KOMMUNIKATION
-- ═══════════════════════════════════════════════════════════════
function HMGT:SendGroupMessage(msg, prio)
-- Nur senden, wenn mindestens ein echter Mitspieler in der Gruppe ist.
-- Das verhindert Fehlermeldungen in Follower-Dungeons (NPC-Begleiter, kein Party-Chat).
local hasPlayers = false
if IsInRaid() then
for i = 1, GetNumGroupMembers() do
local unit = "raid" .. i
if UnitExists(unit) and not UnitIsUnit(unit, "player") and UnitIsPlayer(unit) then
hasPlayers = true
break
end
end
elseif IsInGroup() then
for i = 1, GetNumSubgroupMembers() do
local unit = "party" .. i
if UnitExists(unit) and UnitIsPlayer(unit) then
hasPlayers = true
break
end
end
end
if not hasPlayers then return end
local channel
if IsInGroup(LE_PARTY_CATEGORY_INSTANCE) then
channel = "INSTANCE_CHAT"
elseif IsInRaid() then
channel = "RAID"
elseif IsInGroup(LE_PARTY_CATEGORY_HOME) or IsInGroup() then
channel = "PARTY"
else
return
end
local dedupeKey = string.format("%s|%s|%s", tostring(channel), tostring(prio or "NORMAL"), tostring(msg))
local now = GetTime()
local lastSentAt = tonumber(self.recentGroupMessages[dedupeKey]) or 0
if now - lastSentAt < 0.10 then
self:DebugScoped("verbose", "Comm", "SendGroupMessage deduped channel=%s prio=%s", tostring(channel), tostring(prio or "NORMAL"))
return
end
self.recentGroupMessages[dedupeKey] = now
if next(self.recentGroupMessages) then
local count = 0
for key, sentAt in pairs(self.recentGroupMessages) do
count = count + 1
if count > 200 or (now - (tonumber(sentAt) or 0)) > 5 then
self.recentGroupMessages[key] = nil
end
end
end
self:DebugScoped("verbose", "Comm", "SendGroupMessage channel=%s prio=%s payload=%s", tostring(channel), tostring(prio or "NORMAL"), tostring(msg))
self:SendCommMessage(COMM_PREFIX, msg, channel, nil, prio)
end
function HMGT:SendHello(target)
local name = self:NormalizePlayerName(UnitName("player"))
local pData = self.playerData[name]
if not pData or not pData.class or not pData.specIndex then return end
pData.knownSpells = self:CollectOwnAvailableTrackerSpells(pData.class, pData.specIndex)
self:RefreshOwnAvailabilityStates()
local knownSpellList = self:SerializeKnownSpellList(pData.knownSpells)
local knownCount = 0
for _ in pairs(pData.knownSpells or {}) do
knownCount = knownCount + 1
end
local payload = string.format("%s|%s|%d|%s|%d|%s|%s",
MSG_HELLO,
ADDON_VERSION,
PROTOCOL_VERSION,
pData.class,
pData.specIndex,
pData.talentHash or "",
knownSpellList
)
if target and target ~= "" then
self:DebugScoped("verbose", "Comm", "SendHello whisper target=%s class=%s spec=%s spells=%d",
tostring(target), tostring(pData.class), tostring(pData.specIndex), knownCount)
self:SendDirectMessage(payload, target)
self:SendOwnTrackedSpellStates(target)
self:SendOwnAvailabilityStates(target)
return
end
self:DebugScoped("verbose", "Comm", "SendHello group class=%s spec=%s spells=%d",
tostring(pData.class), tostring(pData.specIndex), knownCount)
self:SendGroupMessage(payload)
self:SendOwnTrackedSpellStates()
self:SendOwnAvailabilityStates()
end
function HMGT:BroadcastSpellCast(spellId, snapshot)
local cur, max, chargeRemaining, chargeDuration = 0, 0, 0, 0
if type(snapshot) == "table" and tostring(snapshot.kind) == "charges" then
cur = math.max(0, math.floor((tonumber(snapshot.a) or 0) + 0.5))
max = math.max(0, math.floor((tonumber(snapshot.b) or 0) + 0.5))
chargeRemaining = math.max(0, tonumber(snapshot.c) or 0)
chargeDuration = math.max(0, tonumber(snapshot.d) or 0)
elseif not (InCombatLockdown and InCombatLockdown()) then
local c, m, cs, cd = GetSpellChargesInfo(spellId)
cur = tonumber(c) or 0
max = tonumber(m) or 0
chargeDuration = tonumber(cd) or 0
if max > 0 and cur < max and cs and chargeDuration > 0 then
chargeRemaining = math.max(0, chargeDuration - (GetTime() - cs))
end
else
local ownName = self:NormalizePlayerName(UnitName("player"))
local remaining, total, currentCharges, maxCharges = self:GetCooldownInfo(ownName, spellId, {
deferChargeCooldownUntilEmpty = false,
})
cur = math.max(0, math.floor((tonumber(currentCharges) or 0) + 0.5))
max = math.max(0, math.floor((tonumber(maxCharges) or 0) + 0.5))
chargeRemaining = math.max(0, tonumber(remaining) or 0)
chargeDuration = math.max(0, tonumber(total) or 0)
end
self:DebugScoped("verbose", "TrackedSpells", "BroadcastSpellCast spell=%s serverTime=%s charges=%d/%d", GetSpellDebugLabel(spellId), tostring(GetServerTime()), cur, max)
self:SendGroupMessage(string.format("%s|%d|%d|%d|%d|%.3f|%.3f|%s|%d",
MSG_SPELL_CAST, spellId, GetServerTime(), cur, max, chargeRemaining, chargeDuration, ADDON_VERSION, PROTOCOL_VERSION))
end
function HMGT:BroadcastCooldownReduce(targetSpellId, amount, castTimestamp, triggerSpellId)
local sid = tonumber(targetSpellId)
local value = tonumber(amount) or 0
if not sid or sid <= 0 or value <= 0 then return end
local ts = tonumber(castTimestamp) or GetServerTime()
local triggerId = tonumber(triggerSpellId) or 0
self:Debug(
"verbose",
"BroadcastCooldownReduce target=%s amount=%.2f ts=%s trigger=%s",
tostring(sid),
value,
tostring(ts),
tostring(triggerId)
)
self:SendGroupMessage(string.format(
"%s|%d|%.3f|%d|%d|%s|%d",
MSG_CD_REDUCE,
sid,
value,
ts,
triggerId,
ADDON_VERSION,
PROTOCOL_VERSION
))
end
function HMGT:RequestSync(reason)
self:DebugScoped("info", "Comm", "RequestSync(%s)", tostring(reason or "Hello"))
self:SendHello()
end
function HMGT:QueueSyncRequest(delay, reason)
local wait = tonumber(delay) or 0.2
if wait < 0 then wait = 0 end
if self._syncRequestTimer then
return
end
self._syncRequestTimer = self:ScheduleTimer(function()
self._syncRequestTimer = nil
self:RequestSync(reason or "Hello")
end, wait)
end
function HMGT:QueueDeltaSyncBurst(reason, delays)
if not (IsInGroup() or IsInRaid()) then
return
end
local now = GetTime()
local normalizedReason = tostring(reason or "delta")
self._deltaSyncBurstAt = self._deltaSyncBurstAt or {}
if (tonumber(self._deltaSyncBurstAt[normalizedReason]) or 0) > now - 2.5 then
return
end
self._deltaSyncBurstAt[normalizedReason] = now
delays = type(delays) == "table" and delays or { 0.35, 1.25, 2.75 }
self._syncBurstTimers = self._syncBurstTimers or {}
for _, wait in ipairs(delays) do
local delay = math.max(0, tonumber(wait) or 0)
local timerHandle
timerHandle = self:ScheduleTimer(function()
if self._syncBurstTimers then
for index, handle in ipairs(self._syncBurstTimers) do
if handle == timerHandle then
table.remove(self._syncBurstTimers, index)
break
end
end
end
self:RequestSync(normalizedReason)
end, delay)
self._syncBurstTimers[#self._syncBurstTimers + 1] = timerHandle
end
self:DebugScoped("info", "Comm", "QueueDeltaSyncBurst reason=%s count=%d", normalizedReason, #delays)
end
function HMGT:SendSyncResponse(target)
local name = self:NormalizePlayerName(UnitName("player"))
local pData = self.playerData[name]
if not pData then return end
pData.knownSpells = self:CollectOwnAvailableTrackerSpells(pData.class, pData.specIndex)
self:RefreshOwnAvailabilityStates()
local knownSpellList = self:SerializeKnownSpellList(pData.knownSpells)
local cdList = {}
local now = GetTime()
if self.activeCDs[name] then
for spellId, cdInfo in pairs(self.activeCDs[name]) do
if (tonumber(cdInfo.maxCharges) or 0) > 0 then
self:ResolveChargeState(cdInfo, now)
end
local remaining = cdInfo.duration - (now - cdInfo.startTime)
remaining = math.max(0, math.min(cdInfo.duration, remaining))
if remaining > 0 then
table.insert(cdList, string.format("%d:%.3f:%.3f:%d:%d",
spellId, remaining, cdInfo.duration, cdInfo.currentCharges or 0, cdInfo.maxCharges or 0))
end
end
end
self:SendDirectMessage(
string.format("%s|%s|%d|%s|%d|%s|%s|%s",
MSG_SYNC_RESPONSE,
ADDON_VERSION,
PROTOCOL_VERSION,
pData.class,
pData.specIndex,
pData.talentHash or "",
knownSpellList,
table.concat(cdList, ";")),
target)
local stateCount = self:SendOwnTrackedSpellStates(target)
local availabilityCount = self:SendOwnAvailabilityStates(target)
self:DebugScoped("verbose", "Comm", "SendSyncResponse target=%s entries=%d state=%d availability=%d", tostring(target), #cdList, stateCount, availabilityCount)
end
function HMGT:StoreRemotePlayerInfo(playerName, class, specIndex, talentHash, knownSpellList)
if not playerName or not class then return end
local previous = self.playerData[playerName]
local knownSpells = previous and previous.knownSpells
if knownSpellList ~= nil then
knownSpells = self:ParseKnownSpellList(knownSpellList)
end
self.playerData[playerName] = {
class = class,
specIndex = tonumber(specIndex),
talentHash = talentHash,
talents = self:ParseTalentHash(talentHash),
knownSpells = knownSpells,
}
if type(knownSpells) == "table" then
self:PruneAvailabilityStates(playerName, knownSpells)
end
local knownCount = 0
if type(knownSpells) == "table" then
for _ in pairs(knownSpells) do
knownCount = knownCount + 1
end
end
self:DebugScoped(
"info",
"TrackedSpells",
"Spielerinfo von %s: class=%s spec=%s bekannteSpells=%d",
tostring(playerName),
tostring(class),
tostring(specIndex),
knownCount
)
end
function HMGT:ClearRemoteSpellState(playerName, spellId)
local normalizedName = self:NormalizePlayerName(playerName)
local sid = tonumber(spellId)
if not normalizedName or not sid or sid <= 0 then
return false
end
local changed = false
local playerCooldowns = self.activeCDs[normalizedName]
if playerCooldowns and playerCooldowns[sid] then
playerCooldowns[sid] = nil
if not next(playerCooldowns) then
self.activeCDs[normalizedName] = nil
end
changed = true
end
local playerAvailability = self.availabilityStates[normalizedName]
if playerAvailability and playerAvailability[sid] then
playerAvailability[sid] = nil
if not next(playerAvailability) then
self.availabilityStates[normalizedName] = nil
end
changed = true
end
return changed
end
function HMGT:ApplyRemoteSpellState(playerName, spellId, kind, revision, a, b, c, d)
local normalizedName = self:NormalizePlayerName(playerName)
local sid = tonumber(spellId)
local rev = tonumber(revision) or 0
if not normalizedName or not sid or sid <= 0 or rev <= 0 then
return false
end
if not self:IsPlayerInCurrentGroup(normalizedName) then
return false
end
local currentRevision = self:GetRemoteSpellStateRevision(normalizedName, sid)
if currentRevision >= rev then
return false
end
local spellEntry = HMGT_SpellData.CooldownLookup[sid]
or HMGT_SpellData.InterruptLookup[sid]
if not spellEntry then
return false
end
local now = GetTime()
local stateKind = tostring(kind or "")
local changed = false
local shouldLogCast = false
local logDetails = nil
local previousEntry = self.activeCDs[normalizedName] and self.activeCDs[normalizedName][sid]
local isSuppressed = self:IsRemoteTrackedSpellLogSuppressed(normalizedName)
if stateKind == "clear" then
changed = self:ClearRemoteSpellState(normalizedName, sid)
elseif stateKind == "availability" then
changed = self:StoreAvailabilityState(normalizedName, sid, tonumber(a) or 0, tonumber(b) or 0, spellEntry)
local playerCooldowns = self.activeCDs[normalizedName]
if playerCooldowns and playerCooldowns[sid] then
playerCooldowns[sid] = nil
if not next(playerCooldowns) then
self.activeCDs[normalizedName] = nil
end
changed = true
end
elseif stateKind == "cooldown" then
local duration = math.max(0, tonumber(b) or 0)
local remaining = math.max(0, math.min(duration, tonumber(a) or 0))
if duration <= 0 or remaining <= 0 then
changed = self:ClearRemoteSpellState(normalizedName, sid)
else
local previousRemaining = 0
if previousEntry then
previousRemaining = math.max(
0,
(tonumber(previousEntry.duration) or 0) - (now - (tonumber(previousEntry.startTime) or now))
)
end
self.activeCDs[normalizedName] = self.activeCDs[normalizedName] or {}
self.activeCDs[normalizedName][sid] = {
startTime = now - (duration - remaining),
duration = duration,
spellEntry = spellEntry,
_stateRevision = rev,
_stateKind = stateKind,
}
changed = true
shouldLogCast = (not isSuppressed) and previousRemaining <= 0.05
if shouldLogCast then
logDetails = {
cooldown = duration,
}
end
end
elseif stateKind == "charges" then
local maxCharges = math.max(0, math.floor((tonumber(b) or 0) + 0.5))
local currentCharges = math.max(0, math.min(maxCharges, math.floor((tonumber(a) or 0) + 0.5)))
local nextRemaining = math.max(0, tonumber(c) or 0)
local chargeDuration = math.max(0, tonumber(d) or 0)
if maxCharges <= 0 or currentCharges >= maxCharges then
changed = self:ClearRemoteSpellState(normalizedName, sid)
else
local previousCharges = nil
if previousEntry and (tonumber(previousEntry.maxCharges) or 0) > 0 then
self:ResolveChargeState(previousEntry, now)
previousCharges = tonumber(previousEntry.currentCharges)
end
local chargeStart = nil
local duration = 0
local startTime = now
if chargeDuration > 0 then
nextRemaining = math.min(chargeDuration, nextRemaining)
chargeStart = now - math.max(0, chargeDuration - nextRemaining)
duration = (maxCharges - currentCharges) * chargeDuration
startTime = chargeStart
end
self.activeCDs[normalizedName] = self.activeCDs[normalizedName] or {}
self.activeCDs[normalizedName][sid] = {
startTime = startTime,
duration = duration,
spellEntry = spellEntry,
currentCharges = currentCharges,
maxCharges = maxCharges,
chargeStart = chargeStart,
chargeDuration = chargeDuration,
_stateRevision = rev,
_stateKind = stateKind,
}
changed = true
shouldLogCast = (not isSuppressed)
and (
(previousCharges ~= nil and currentCharges < previousCharges)
or (previousCharges == nil)
)
if shouldLogCast then
logDetails = {
cooldown = chargeDuration,
currentCharges = currentCharges,
maxCharges = maxCharges,
chargeCooldown = chargeDuration,
}
end
end
else
return false
end
self:SetRemoteSpellStateRevision(normalizedName, sid, rev)
if changed then
self:DebugScoped(
"info",
"TrackedSpells",
"Sync von %s: %s -> %s (rev=%d)",
tostring(normalizedName),
GetSpellDebugLabel(sid),
tostring(stateKind),
rev
)
end
if changed and shouldLogCast and logDetails then
self:LogTrackedSpellCast(normalizedName, spellEntry, logDetails)
end
return changed
end
function HMGT:OnCommReceived(prefix, message, distribution, sender)
if prefix ~= COMM_PREFIX then return end
local senderName = self:NormalizePlayerName(sender)
if senderName == self:NormalizePlayerName(UnitName("player")) then return end
local msgType = message:match("^(%a+)")
self:DebugScoped("verbose", "Comm", "OnCommReceived type=%s from=%s dist=%s", tostring(msgType), tostring(senderName), tostring(distribution))
if msgType == MSG_ACK then
local messageId = message:match("^%a+|(.+)$")
if messageId then
self:HandleReliableAck(senderName, messageId)
end
return
elseif msgType == MSG_RELIABLE then
local messageId, innerPayload = message:match("^%a+|([^|]+)|(.+)$")
if not messageId or not innerPayload then
return
end
local dedupeKey = string.format("%s|%s", tostring(senderName or ""), tostring(messageId))
self.receivedReliableMessages = self.receivedReliableMessages or {}
self:SendReliableAck(sender, messageId)
if self.receivedReliableMessages[dedupeKey] then
self:DebugScoped("verbose", "Comm", "Reliable duplicate sender=%s id=%s", tostring(senderName), tostring(messageId))
return
end
self.receivedReliableMessages[dedupeKey] = GetTime() + 30
message = innerPayload
msgType = message:match("^(%a+)")
self:DebugScoped("verbose", "Comm", "Reliable recv sender=%s id=%s inner=%s", tostring(senderName), tostring(messageId), tostring(msgType))
end
if msgType == MSG_SPELL_CAST then
local spellId, timestamp, cur, max, chargeRemaining, chargeDuration, version, protocol =
message:match("^%a+|(%d+)|([%d%.]+)|(%d+)|(%d+)|([%d%.]+)|([%d%.]+)|([^|]+)|(%d+)$")
if not spellId then
spellId, timestamp, version = message:match("^%a+|(%d+)|([%d%.]+)|(.+)$")
if not spellId then
spellId, timestamp = message:match("^%a+|(%d+)|([%d%.]+)$")
end
end
if spellId then
self:RegisterPeerVersion(senderName, version, protocol, "SC")
self:RememberPeerProtocolVersion(senderName, protocol)
if (tonumber(protocol) or 0) >= 5 then
return
end
self:DebugScoped("verbose", "TrackedSpells", "Legacy cast von %s: %s ts=%s", tostring(senderName), GetSpellDebugLabel(spellId), tostring(timestamp))
self:HandleRemoteSpellCast(
senderName,
tonumber(spellId),
tonumber(timestamp),
tonumber(cur) or 0,
tonumber(max) or 0,
tonumber(chargeRemaining) or 0,
tonumber(chargeDuration) or 0
)
end
elseif msgType == MSG_CD_REDUCE then
local targetSpellId, amount, timestamp, triggerSpellId, version, protocol =
message:match("^%a+|(%d+)|([%d%.]+)|([%d%.]+)|(%d+)|([^|]+)|(%d+)$")
if not targetSpellId then
targetSpellId, amount, timestamp, triggerSpellId =
message:match("^%a+|(%d+)|([%d%.]+)|([%d%.]+)|(%d+)$")
end
if targetSpellId then
self:RegisterPeerVersion(senderName, version, protocol, "CR")
self:RememberPeerProtocolVersion(senderName, protocol)
if (tonumber(protocol) or 0) >= 5 then
return
end
self:HandleRemoteCooldownReduce(
senderName,
tonumber(targetSpellId),
tonumber(amount) or 0,
tonumber(timestamp),
tonumber(triggerSpellId) or 0
)
end
elseif msgType == MSG_SPELL_STATE then
local spellId, stateKind, revision, a, b, c, d, version, protocol =
message:match("^%a+|(%d+)|(%a+)|(%d+)|([%d%.%-]+)|([%d%.%-]+)|([%d%.%-]+)|([%d%.%-]+)|([^|]+)|(%d+)$")
if spellId then
self:RegisterPeerVersion(senderName, version, protocol, "STA")
self:RememberPeerProtocolVersion(senderName, protocol)
if self:ApplyRemoteSpellState(senderName, spellId, stateKind, revision, a, b, c, d) then
self:TriggerTrackerUpdate()
end
else
local current, max
spellId, current, max, version, protocol =
message:match("^%a+|(%d+)|(%d+)|(%d+)|([^|]+)|(%d+)$")
if not spellId then
spellId, current, max = message:match("^%a+|(%d+)|(%d+)|(%d+)$")
end
if spellId then
self:RegisterPeerVersion(senderName, version, protocol, "STA")
self:RememberPeerProtocolVersion(senderName, protocol)
if (tonumber(protocol) or 0) >= 5 then
return
end
local sid = tonumber(spellId)
local spellEntry = HMGT_SpellData.CooldownLookup[sid]
or HMGT_SpellData.InterruptLookup[sid]
if self:IsAvailabilitySpell(spellEntry) then
if self:StoreAvailabilityState(senderName, sid, tonumber(current) or 0, tonumber(max) or 0, spellEntry) then
self:TriggerTrackerUpdate()
end
end
end
end
elseif msgType == MSG_HELLO then
local version, protocol, class, specIndex, talentHash, knownSpellList =
message:match("^%a+|([^|]+)|(%d+)|(%u+)|(%d+)|(.-)|(.-)$")
if class then
self:RegisterPeerVersion(senderName, version, protocol, "HEL")
self:RememberPeerProtocolVersion(senderName, protocol)
self.remoteSpellStateRevisions[senderName] = nil
self:StoreRemotePlayerInfo(senderName, class, specIndex, talentHash, knownSpellList)
self:DebugScoped("info", "TrackedSpells", "Hello von %s: class=%s spec=%s spells=%s",
tostring(senderName), tostring(class), tostring(specIndex), tostring(knownSpellList or ""))
self:SendSyncResponse(sender)
self:TriggerTrackerUpdate()
end
elseif msgType == MSG_PLAYER_INFO then
local class, specIndex, talentHash, version, protocol =
message:match("^%a+|(%u+)|(%d+)|(.-)|([^|]+)|(%d+)$")
if not class then
class, specIndex, talentHash = message:match("^%a+|(%u+)|(%d+)|(.*)")
end
if class then
self:RegisterPeerVersion(senderName, version, protocol, "PI")
self:RememberPeerProtocolVersion(senderName, protocol)
self:StoreRemotePlayerInfo(senderName, class, specIndex, talentHash, nil)
self:TriggerTrackerUpdate()
end
elseif msgType == MSG_SYNC_REQUEST then
local version, protocol, class, specIndex, talentHash, knownSpellList =
message:match("^%a+|([^|]+)|(%d+)|(%u+)|(%d+)|(.-)|(.-)$")
if class then
self:StoreRemotePlayerInfo(senderName, class, specIndex, talentHash, knownSpellList)
end
if not version then
version, protocol = message:match("^%a+|([^|]+)|(%d+)$")
end
if not version then
version = message:match("^%a+|(.+)$")
end
self:RegisterPeerVersion(senderName, version, protocol, "SRQ")
self:RememberPeerProtocolVersion(senderName, protocol)
self:DebugScoped("info", "Comm", "SyncRequest von %s", tostring(senderName))
self:SendSyncResponse(sender)
self:TriggerTrackerUpdate()
elseif msgType == MSG_SYNC_RESPONSE then
local version, protocol, class, specIndex, talentHash, knownSpellList, cdListStr =
message:match("^%a+|([^|]+)|(%d+)|(%u+)|(%d+)|(.-)|(.-)|(.-)$")
if not class then
version, protocol, class, specIndex, talentHash, cdListStr =
message:match("^%a+|([^|]+)|(%d+)|(%u+)|(%d+)|(.-)|(.-)$")
end
if not class then
class, specIndex, talentHash, cdListStr =
message:match("^%a+|(%u+)|(%d+)|(.-)|(.-)$")
end
if class then
self:RegisterPeerVersion(senderName, version, protocol, "SRS")
self:RememberPeerProtocolVersion(senderName, protocol)
self:SuppressRemoteTrackedSpellLogs(senderName, 1.5)
self:StoreRemotePlayerInfo(senderName, class, specIndex, talentHash, knownSpellList)
if cdListStr and cdListStr ~= "" then
self.activeCDs[senderName] = self.activeCDs[senderName] or {}
local knownTalents = self.playerData[senderName] and self.playerData[senderName].talents or {}
local applied = 0
for entry in cdListStr:gmatch("([^;]+)") do
local sid, rem, dur, cur, max = entry:match("(%d+):([%d%.]+):([%d%.]+):(%d+):(%d+)")
if not sid then
sid, rem, dur = entry:match("(%d+):([%d%.]+):([%d%.]+)")
end
if sid then
sid, rem, dur = tonumber(sid), tonumber(rem), tonumber(dur)
rem = math.max(0, math.min(dur, rem))
local remaining = rem
if remaining > 0 then
local spellEntry = HMGT_SpellData.CooldownLookup[sid]
or HMGT_SpellData.InterruptLookup[sid]
if spellEntry then
local localStartTime = GetTime() - (dur - remaining)
local curCharges = tonumber(cur) or 0
local maxChargeCount = tonumber(max) or 0
local chargeStart = nil
local chargeDur = nil
if maxChargeCount > 0 then
curCharges = math.max(0, math.min(maxChargeCount, curCharges))
local missing = maxChargeCount - curCharges
if missing > 0 and dur > 0 then
chargeDur = dur / missing
chargeStart = localStartTime
end
else
local inferredMax, inferredDur = HMGT_SpellData.GetEffectiveChargeInfo(
spellEntry,
knownTalents,
nil,
HMGT_SpellData.GetEffectiveCooldown(spellEntry, knownTalents)
)
if (tonumber(inferredMax) or 0) > 1 then
maxChargeCount = inferredMax
curCharges = math.max(0, inferredMax - 1)
chargeDur = inferredDur
chargeStart = localStartTime
end
end
self.activeCDs[senderName][sid] = {
startTime = localStartTime,
duration = dur,
spellEntry = spellEntry,
currentCharges = (maxChargeCount > 0) and curCharges or nil,
maxCharges = (maxChargeCount > 0) and maxChargeCount or nil,
chargeStart = chargeStart,
chargeDuration = chargeDur,
}
applied = applied + 1
end
end
end
end
self:DebugScoped("info", "TrackedSpells", "SyncResponse von %s: cdsApplied=%d", tostring(senderName), applied)
end
self:TriggerTrackerUpdate()
end
elseif msgType == MSG_RAID_TIMELINE then
local encounterId, timeSec, spellId, leadTime, alertText =
message:match("^%a+|(%d+)|(%d+)|([%-]?%d+)|(%d+)|(.*)$")
if not encounterId then
encounterId, timeSec, spellId, leadTime =
message:match("^%a+|(%d+)|(%d+)|([%-]?%d+)|(%d+)$")
alertText = ""
end
if encounterId and HMGT.RaidTimeline and HMGT.RaidTimeline.HandleAssignmentComm then
HMGT.RaidTimeline:HandleAssignmentComm(
senderName,
tonumber(encounterId),
tonumber(timeSec),
tonumber(spellId),
tonumber(leadTime),
alertText
)
end
elseif msgType == MSG_RAID_TIMELINE_TEST then
local encounterId, difficultyId, serverStartTime, duration =
message:match("^%a+|(%d+)|(%d+)|(%d+)|(%d+)$")
if encounterId and HMGT.RaidTimeline and HMGT.RaidTimeline.HandleTestStartComm then
HMGT.RaidTimeline:HandleTestStartComm(
senderName,
tonumber(encounterId),
tonumber(difficultyId),
tonumber(serverStartTime),
tonumber(duration)
)
end
end
end
-- ═══════════════════════════════════════════════════════════════
-- EVENTS
-- ═══════════════════════════════════════════════════════════════
function HMGT:OnUnitSpellCastSent(event, unitTag, targetName, castGUID, spellId)
if unitTag == "player" then
self:CaptureOwnSpellPowerCosts(spellId)
end
end
function HMGT:OnUnitSpellCastSucceeded(event, unitTag, castGUID, spellId)
if unitTag == "player" then
self:HandleOwnSpellCast(spellId)
self:HandleOwnCooldownReductionTrigger(spellId)
self:HandleOwnPowerSpendFromSpell(spellId)
end
end
function HMGT:OnUnitAura(event, unitTag)
if unitTag ~= "player" then return end
if self:RefreshAndPublishOwnAvailabilityStates() then
self:TriggerTrackerUpdate()
end
end
function HMGT:OnPlayerRegenEnabled()
self:RefreshAndPublishOwnAvailabilityStates()
end
function HMGT:IsPlayerInCurrentGroup(playerName)
local target = self:NormalizePlayerName(playerName)
if not target then return false end
local own = self:NormalizePlayerName(UnitName("player"))
if target == own then return true end
if IsInRaid() then
for i = 1, GetNumGroupMembers() do
local n = self:NormalizePlayerName(UnitName("raid" .. i))
if n == target then
return true
end
end
return false
end
if IsInGroup() then
for i = 1, GetNumSubgroupMembers() do
local n = self:NormalizePlayerName(UnitName("party" .. i))
if n == target then
return true
end
end
end
return false
end
function HMGT:HandleOwnSpellCast(spellId)
local isInterrupt = HMGT_SpellData.InterruptLookup[spellId] ~= nil
local isCooldown = HMGT_SpellData.CooldownLookup[spellId] ~= nil
if not isInterrupt and not isCooldown then return end
local spellEntry = HMGT_SpellData.InterruptLookup[spellId]
or HMGT_SpellData.CooldownLookup[spellId]
local name = self:NormalizePlayerName(UnitName("player"))
local pData = self.playerData[name]
local talents = pData and pData.talents or {}
if self:IsAvailabilitySpell(spellEntry) then
self:LogTrackedSpellCast(name, spellEntry, {
stateKind = "availability",
required = HMGT_SpellData.GetEffectiveAvailabilityRequired(spellEntry, talents),
})
if self:RefreshOwnAvailabilitySpell(spellEntry) then
self:PublishOwnSpellState(spellId, { sendLegacy = true })
end
self:TriggerTrackerUpdate()
return
end
local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents)
local now = GetTime()
local inCombat = InCombatLockdown and InCombatLockdown()
local cur, max, chargeStart, chargeDuration = nil, nil, nil, nil
if not inCombat then
cur, max, chargeStart, chargeDuration = GetSpellChargesInfo(spellId)
end
local cachedMaxCharges, cachedChargeDuration = self:GetKnownChargeInfo(
spellEntry,
talents,
spellId,
(not inCombat and tonumber(chargeDuration) and tonumber(chargeDuration) > 0) and tonumber(chargeDuration) or effectiveCd
)
local inferredMaxCharges, inferredChargeDuration = HMGT_SpellData.GetEffectiveChargeInfo(
spellEntry,
talents,
(not inCombat and tonumber(max) and tonumber(max) > 0) and tonumber(max) or ((cachedMaxCharges > 0) and cachedMaxCharges or nil),
(not inCombat and tonumber(chargeDuration) and tonumber(chargeDuration) > 0) and tonumber(chargeDuration)
or ((cachedChargeDuration > 0) and cachedChargeDuration or effectiveCd)
)
local hasCharges = ((tonumber(max) or 0) > 1) or (tonumber(inferredMaxCharges) or 0) > 1
local currentCharges = 0
local maxCharges = 0
local chargeDur = 0
local chargeStartTime = nil
local startTime = now
local duration = effectiveCd
local expiresIn = effectiveCd
local existingCd = self.activeCDs[name] and self.activeCDs[name][spellId]
if existingCd and (tonumber(existingCd.maxCharges) or 0) > 0 then
self:ResolveChargeState(existingCd, now)
end
if hasCharges then
maxCharges = math.max(1, tonumber(max) or cachedMaxCharges or tonumber(inferredMaxCharges) or 1)
currentCharges = tonumber(cur)
if currentCharges == nil then
local prevCharges = existingCd and tonumber(existingCd.currentCharges)
local prevMax = existingCd and tonumber(existingCd.maxCharges)
if prevCharges and prevMax and prevMax == maxCharges then
currentCharges = math.max(0, prevCharges - 1)
else
currentCharges = math.max(0, maxCharges - 1)
end
end
currentCharges = math.max(0, math.min(maxCharges, currentCharges))
chargeDur = tonumber(chargeDuration)
or cachedChargeDuration
or tonumber(inferredChargeDuration)
or tonumber(effectiveCd)
or 0
chargeDur = math.max(0, chargeDur)
self:StoreKnownChargeInfo(spellId, maxCharges, chargeDur)
if currentCharges < maxCharges and chargeDur > 0 then
chargeStartTime = tonumber(chargeStart) or now
local missing = maxCharges - currentCharges
startTime = chargeStartTime
duration = missing * chargeDur
expiresIn = math.max(0, duration - (now - startTime))
else
startTime = now
duration = 0
expiresIn = 0
end
end
self:Debug(
"verbose",
"HandleOwnSpellCast name=%s spellId=%s cd=%.2f charges=%s/%s",
tostring(name),
tostring(spellId),
tonumber(effectiveCd) or 0,
hasCharges and tostring(currentCharges) or "-",
hasCharges and tostring(maxCharges) or "-"
)
self._cdNonce = (self._cdNonce or 0) + 1
local nonce = self._cdNonce
self.activeCDs[name] = self.activeCDs[name] or {}
self.activeCDs[name][spellId] = {
startTime = startTime,
duration = duration,
spellEntry = spellEntry,
currentCharges = hasCharges and currentCharges or nil,
maxCharges = hasCharges and maxCharges or nil,
chargeStart = hasCharges and chargeStartTime or nil,
chargeDuration = hasCharges and chargeDur or nil,
_nonce = nonce,
}
self:LogTrackedSpellCast(name, spellEntry, {
cooldown = effectiveCd,
currentCharges = hasCharges and currentCharges or nil,
maxCharges = hasCharges and maxCharges or nil,
chargeCooldown = hasCharges and chargeDur or nil,
})
if expiresIn > 0 then
self:ScheduleTimer(function()
local current = self.activeCDs[name] and self.activeCDs[name][spellId]
if current and current._nonce == nonce then
self.activeCDs[name][spellId] = nil
self:PublishOwnSpellState(spellId)
self:TriggerTrackerUpdate()
end
end, expiresIn)
end
self:PublishOwnSpellState(spellId, { sendLegacy = true })
self:TriggerTrackerUpdate()
end
function HMGT:RefreshCooldownExpiryTimer(playerName, spellId, cdData)
if not cdData then return 0 end
local now = GetTime()
local duration = tonumber(cdData.duration) or 0
local startTime = tonumber(cdData.startTime) or now
local expiresIn = math.max(0, duration - (now - startTime))
self._cdNonce = (self._cdNonce or 0) + 1
local nonce = self._cdNonce
cdData._nonce = nonce
if expiresIn > 0 then
self:ScheduleTimer(function()
local current = self.activeCDs[playerName] and self.activeCDs[playerName][spellId]
if current and current._nonce == nonce then
self.activeCDs[playerName][spellId] = nil
if self.activeCDs[playerName] and not next(self.activeCDs[playerName]) then
self.activeCDs[playerName] = nil
end
if playerName == self:NormalizePlayerName(UnitName("player")) then
self:PublishOwnSpellState(spellId)
end
self:TriggerTrackerUpdate()
end
end, expiresIn)
end
return expiresIn
end
local function BuildCooldownStateFingerprint(cdData)
if not cdData then
return "nil"
end
return table.concat({
string.format("%.3f", tonumber(cdData.startTime) or 0),
string.format("%.3f", tonumber(cdData.duration) or 0),
tostring(tonumber(cdData.currentCharges) or -1),
tostring(tonumber(cdData.maxCharges) or -1),
string.format("%.3f", tonumber(cdData.chargeStart) or 0),
string.format("%.3f", tonumber(cdData.chargeDuration) or 0),
}, "|")
end
function HMGT:RefreshOwnCooldownStateFromGame(spellId)
local sid = tonumber(spellId)
if not sid then return false end
if InCombatLockdown and InCombatLockdown() then
return false
end
local ownName = self:NormalizePlayerName(UnitName("player"))
if not ownName then return false end
local spellEntry = HMGT_SpellData.InterruptLookup[sid]
or HMGT_SpellData.CooldownLookup[sid]
if not spellEntry or self:IsAvailabilitySpell(spellEntry) then
return false
end
local existing = self.activeCDs[ownName] and self.activeCDs[ownName][sid]
local before = BuildCooldownStateFingerprint(existing)
local now = GetTime()
local pData = self.playerData[ownName]
local talents = pData and pData.talents or {}
local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents)
local cur, max, chargeStart, chargeDuration = GetSpellChargesInfo(sid)
local inferredMaxCharges, inferredChargeDuration = HMGT_SpellData.GetEffectiveChargeInfo(
spellEntry,
talents,
(tonumber(max) and tonumber(max) > 0) and tonumber(max) or nil,
(tonumber(chargeDuration) and tonumber(chargeDuration) > 0) and tonumber(chargeDuration) or effectiveCd
)
local hasCharges = ((tonumber(max) or 0) > 1) or (tonumber(inferredMaxCharges) or 0) > 1
if hasCharges then
local maxCharges = math.max(1, tonumber(max) or tonumber(inferredMaxCharges) or 1)
local currentCharges = tonumber(cur)
if currentCharges == nil then
currentCharges = maxCharges
end
currentCharges = math.max(0, math.min(maxCharges, currentCharges))
local chargeDur = tonumber(chargeDuration) or tonumber(inferredChargeDuration) or tonumber(effectiveCd) or 0
chargeDur = math.max(0, chargeDur)
if currentCharges < maxCharges and chargeDur > 0 then
local chargeStartTime = tonumber(chargeStart) or now
local missing = maxCharges - currentCharges
self.activeCDs[ownName] = self.activeCDs[ownName] or {}
self.activeCDs[ownName][sid] = {
startTime = chargeStartTime,
duration = missing * chargeDur,
spellEntry = spellEntry,
currentCharges = currentCharges,
maxCharges = maxCharges,
chargeStart = chargeStartTime,
chargeDuration = chargeDur,
}
self:RefreshCooldownExpiryTimer(ownName, sid, self.activeCDs[ownName][sid])
else
if self.activeCDs[ownName] then
self.activeCDs[ownName][sid] = nil
if not next(self.activeCDs[ownName]) then
self.activeCDs[ownName] = nil
end
end
end
else
local cooldownStart, cooldownDuration = GetSpellCooldownInfo(sid)
cooldownStart = tonumber(cooldownStart) or 0
cooldownDuration = tonumber(cooldownDuration) or 0
local gcdStart, gcdDuration = GetGlobalCooldownInfo()
gcdStart = tonumber(gcdStart) or 0
gcdDuration = tonumber(gcdDuration) or 0
local existingDuration = tonumber(existing and existing.duration) or 0
local existingStart = tonumber(existing and existing.startTime) or now
local existingRemaining = math.max(0, existingDuration - (now - existingStart))
local isLikelyGlobalCooldown = cooldownDuration > 0
and gcdDuration > 0
and math.abs(cooldownDuration - gcdDuration) <= 0.15
and (tonumber(effectiveCd) or 0) > (gcdDuration + 1.0)
local isSuspiciousShortRefresh = cooldownDuration > 0
and existingRemaining > 2.0
and existingDuration > 2.0
and cooldownDuration < math.max(2.0, existingDuration * 0.35)
and cooldownDuration < math.max(2.0, (tonumber(effectiveCd) or 0) * 0.35)
if isLikelyGlobalCooldown or isSuspiciousShortRefresh then
self:DebugScoped(
"verbose",
"TrackedSpells",
"Ignore suspicious refresh for %s: spellCD=%.3f gcd=%.3f existing=%.3f remaining=%.3f effective=%.3f",
GetSpellDebugLabel(sid),
cooldownDuration,
gcdDuration,
existingDuration,
existingRemaining,
tonumber(effectiveCd) or 0
)
return false
end
if cooldownDuration > 0 then
self.activeCDs[ownName] = self.activeCDs[ownName] or {}
self.activeCDs[ownName][sid] = {
startTime = cooldownStart,
duration = cooldownDuration,
spellEntry = spellEntry,
}
self:RefreshCooldownExpiryTimer(ownName, sid, self.activeCDs[ownName][sid])
else
if self.activeCDs[ownName] then
self.activeCDs[ownName][sid] = nil
if not next(self.activeCDs[ownName]) then
self.activeCDs[ownName] = nil
end
end
end
end
local after = BuildCooldownStateFingerprint(self.activeCDs[ownName] and self.activeCDs[ownName][sid])
return before ~= after
end
function HMGT:ApplyCooldownReduction(playerName, targetSpellId, amount)
local sid = tonumber(targetSpellId)
local reduceBy = tonumber(amount) or 0
if not playerName or not sid or sid <= 0 or reduceBy <= 0 then
return 0
end
local spells = self.activeCDs[playerName]
if not spells then return 0 end
local cdData = spells[sid]
if not cdData then return 0 end
local now = GetTime()
local applied = 0
local hasCharges = (tonumber(cdData.maxCharges) or 0) > 0
if hasCharges then
local nextRem, chargeDur, charges, maxCharges = self:ResolveChargeState(cdData, now)
if chargeDur <= 0 or charges >= maxCharges then
return 0
end
local left = reduceBy
local rem = math.max(0, nextRem)
while left > 0 and charges < maxCharges do
if rem <= 0 then rem = chargeDur end
if rem <= left then
left = left - rem
applied = applied + rem
charges = charges + 1
if charges < maxCharges then
rem = chargeDur
else
rem = 0
end
else
rem = rem - left
applied = applied + left
left = 0
end
end
if applied <= 0 then return 0 end
cdData.currentCharges = charges
cdData.maxCharges = maxCharges
cdData.chargeDuration = chargeDur
if charges < maxCharges then
cdData.chargeStart = now - math.max(0, chargeDur - rem)
local missing = maxCharges - charges
cdData.startTime = cdData.chargeStart
cdData.duration = missing * chargeDur
self:RefreshCooldownExpiryTimer(playerName, sid, cdData)
else
spells[sid] = nil
end
else
local duration = tonumber(cdData.duration) or 0
local startTime = tonumber(cdData.startTime) or now
local remaining = math.max(0, duration - (now - startTime))
if remaining <= 0 then return 0 end
applied = math.min(reduceBy, remaining)
local newRemaining = remaining - applied
if newRemaining <= 0 then
spells[sid] = nil
else
cdData.startTime = now - math.max(0, duration - newRemaining)
self:RefreshCooldownExpiryTimer(playerName, sid, cdData)
end
end
if self.activeCDs[playerName] and not next(self.activeCDs[playerName]) then
self.activeCDs[playerName] = nil
end
if playerName == self:NormalizePlayerName(UnitName("player")) then
self:PublishOwnSpellState(sid)
end
self:TriggerTrackerUpdate()
return applied
end
function HMGT:IsNewCooldownReduceEvent(playerName, targetSpellId, castTimestamp, triggerSpellId)
local ts = tonumber(castTimestamp) or 0
if ts <= 0 then return true end
self._recentReduceEvents = self._recentReduceEvents or {}
local key = string.format(
"%s:%d:%d:%d",
tostring(playerName or ""),
tonumber(targetSpellId) or 0,
tonumber(triggerSpellId) or 0,
ts
)
local now = GetTime()
local last = self._recentReduceEvents[key]
if last and (now - last) < 5 then
return false
end
self._recentReduceEvents[key] = now
if not self._recentReduceEventsGcAt or (now - self._recentReduceEventsGcAt) > 30 then
for k, seenAt in pairs(self._recentReduceEvents) do
if (now - seenAt) > 120 then
self._recentReduceEvents[k] = nil
end
end
self._recentReduceEventsGcAt = now
end
return true
end
local function ApplyOwnCooldownReducers(self, ownName, triggerSpellId, reducers, castTs)
for _, reducer in ipairs(reducers) do
local applied = self:ApplyCooldownReduction(ownName, reducer.targetSpellId, reducer.amount)
if applied > 0 then
self:Debug(
"verbose",
"LocalCooldownReduce trigger=%s target=%s amount=%.2f applied=%.2f",
tostring(triggerSpellId),
tostring(reducer.targetSpellId),
tonumber(reducer.amount) or 0,
tonumber(applied) or 0
)
self:BroadcastCooldownReduce(reducer.targetSpellId, applied, castTs, triggerSpellId)
end
end
end
local function ApplyObservedCooldownReducers(self, ownName, reducers)
local targetDelays = {}
for _, reducer in ipairs(reducers) do
local targetSpellId = tonumber(reducer.targetSpellId)
if targetSpellId and targetSpellId > 0 then
local observe = reducer.observe
local delay = tonumber(observe and observe.delay) or 0.12
targetDelays[targetSpellId] = math.max(targetDelays[targetSpellId] or 0, delay)
end
end
for targetSpellId, delay in pairs(targetDelays) do
local retryOffsets = { 0, 0.20, 0.55 }
for _, offset in ipairs(retryOffsets) do
C_Timer.After(delay + offset, function()
if not self or not self.playerData or not self.playerData[ownName] then
return
end
if self:RefreshOwnCooldownStateFromGame(targetSpellId) then
self:PublishOwnSpellState(targetSpellId, { sendLegacy = true })
self:TriggerTrackerUpdate()
end
end)
end
end
end
function HMGT:DidOwnInterruptSucceed(triggerSpellId, talents)
local sid = tonumber(triggerSpellId)
if not sid then return false end
local spellEntry = HMGT_SpellData and HMGT_SpellData.InterruptLookup and HMGT_SpellData.InterruptLookup[sid]
if not spellEntry then return false end
local _, observedDuration = GetSpellCooldownInfo(sid)
observedDuration = tonumber(observedDuration) or 0
if observedDuration <= 0 then return false end
local expectedDuration = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents)
expectedDuration = tonumber(expectedDuration) or 0
if expectedDuration <= 0 then return false end
-- Successful kick reductions (e.g. Coldthirst) result in a shorter observed CD.
return observedDuration < (expectedDuration - 0.05)
end
function HMGT:HandleOwnCooldownReductionTrigger(triggerSpellId)
local ownName = self:NormalizePlayerName(UnitName("player"))
if not ownName then return end
local pData = self.playerData[ownName]
local classToken = pData and pData.class or select(2, UnitClass("player"))
local specIndex = pData and pData.specIndex or GetSpecialization()
local talents = pData and pData.talents or {}
if not classToken or not specIndex then return end
local reducers = HMGT_SpellData.GetCooldownReducersForCast(classToken, specIndex, triggerSpellId, talents)
if not reducers or #reducers == 0 then return end
local instantReducers = {}
local observedInstantReducers = {}
local successReducers = {}
local observedSuccessReducers = {}
for _, reducer in ipairs(reducers) do
local observed = type(reducer.observe) == "table"
if reducer.requireInterruptSuccess then
if observed then
observedSuccessReducers[#observedSuccessReducers + 1] = reducer
else
successReducers[#successReducers + 1] = reducer
end
else
if observed then
observedInstantReducers[#observedInstantReducers + 1] = reducer
else
instantReducers[#instantReducers + 1] = reducer
end
end
end
local castTs = GetServerTime()
if #instantReducers > 0 then
ApplyOwnCooldownReducers(self, ownName, triggerSpellId, instantReducers, castTs)
end
if #observedInstantReducers > 0 then
ApplyObservedCooldownReducers(self, ownName, observedInstantReducers)
end
if #successReducers > 0 or #observedSuccessReducers > 0 then
local function ApplySuccessReducers()
if not self:DidOwnInterruptSucceed(triggerSpellId, talents) then
return false
end
if #successReducers > 0 then
ApplyOwnCooldownReducers(self, ownName, triggerSpellId, successReducers, castTs)
end
if #observedSuccessReducers > 0 then
ApplyObservedCooldownReducers(self, ownName, observedSuccessReducers)
end
return true
end
if not ApplySuccessReducers() then
C_Timer.After(0.12, function()
if not self or not self.playerData or not self.playerData[ownName] then
return
end
ApplySuccessReducers()
end)
end
end
end
function HMGT:CaptureOwnSpellPowerCosts(spellId)
local sid = tonumber(spellId)
if not sid or sid <= 0 then return end
local ownName = self:NormalizePlayerName(UnitName("player"))
if not ownName then return end
local pData = self.playerData[ownName]
local classToken = pData and pData.class or select(2, UnitClass("player"))
local specIndex = pData and pData.specIndex or GetSpecialization()
local talents = pData and pData.talents or {}
if not classToken or not specIndex then return end
local trackedPowerTypes = HMGT_SpellData
and type(HMGT_SpellData.GetTrackedPowerTypes) == "function"
and HMGT_SpellData.GetTrackedPowerTypes(classToken, specIndex, talents)
or nil
if type(trackedPowerTypes) ~= "table" then
return
end
local pending = nil
for powerType in pairs(trackedPowerTypes) do
local spent = GetSpellPowerCostByToken(sid, powerType)
if spent <= 0 then
spent = GetTrackedPowerSpendOverride(classToken, specIndex, sid, powerType)
end
if spent > 0 then
pending = pending or { capturedAt = GetTime() }
pending[NormalizePowerToken(powerType)] = spent
end
end
if pending then
self.pendingSpellPowerCosts = self.pendingSpellPowerCosts or {}
self.pendingSpellPowerCosts[sid] = pending
end
end
function HMGT:HandleOwnPowerSpendFromSpell(spellId)
local sid = tonumber(spellId)
if not sid or sid <= 0 then return end
local ownName = self:NormalizePlayerName(UnitName("player"))
if not ownName then return end
local pData = self.playerData[ownName]
local classToken = pData and pData.class or select(2, UnitClass("player"))
local specIndex = pData and pData.specIndex or GetSpecialization()
local talents = pData and pData.talents or {}
if not classToken or not specIndex then return end
local trackedPowerTypes = HMGT_SpellData
and type(HMGT_SpellData.GetTrackedPowerTypes) == "function"
and HMGT_SpellData.GetTrackedPowerTypes(classToken, specIndex, talents)
or nil
if type(trackedPowerTypes) ~= "table" then
return
end
self.pendingSpellPowerCosts = self.pendingSpellPowerCosts or {}
local pending = self.pendingSpellPowerCosts[sid]
local pendingFresh = type(pending) == "table"
and (GetTime() - (tonumber(pending.capturedAt) or 0)) <= 2
local debugLabel = GetSpellDebugLabel(sid)
local detectedAnySpend = false
for powerType in pairs(trackedPowerTypes) do
local token = NormalizePowerToken(powerType)
local spent = pendingFresh and tonumber(pending[token]) or 0
local source = pendingFresh and spent > 0 and "cache" or nil
if spent <= 0 then
spent = GetSpellPowerCostByToken(sid, powerType)
if spent > 0 then
source = "api"
end
end
if spent <= 0 then
spent = GetTrackedPowerSpendOverride(classToken, specIndex, sid, powerType)
if spent > 0 then
source = "override"
end
end
if spent > 0 then
detectedAnySpend = true
self:DebugScoped("verbose", "PowerSpend", "%s -> %s spend=%.0f via %s",
debugLabel,
tostring(token),
spent,
tostring(source or "unknown")
)
self:HandleOwnPowerSpent(powerType, spent, {
spellId = sid,
spellLabel = debugLabel,
source = source or "unknown",
})
end
end
if not detectedAnySpend then
self:DebugScoped("verbose", "PowerSpend", "%s -> kein getrackter Spend erkannt", debugLabel)
end
self.pendingSpellPowerCosts[sid] = nil
end
function HMGT:HandleOwnPowerSpent(powerType, amountSpent, context)
local token = NormalizePowerToken(powerType)
local spent = tonumber(amountSpent) or 0
if not token or spent <= 0 then return end
local ownName = self:NormalizePlayerName(UnitName("player"))
if not ownName then return end
local pData = self.playerData[ownName]
local classToken = pData and pData.class or select(2, UnitClass("player"))
local specIndex = pData and pData.specIndex or GetSpecialization()
local talents = pData and pData.talents or {}
if not classToken or not specIndex then return end
local sourceLabel = type(context) == "table" and tostring(context.source or "unknown") or "unknown"
local spellLabel = type(context) == "table" and tostring(context.spellLabel or GetSpellDebugLabel(context.spellId)) or "Unknown"
local reducers = HMGT_SpellData.GetCooldownReducersForPowerSpend(classToken, specIndex, token, talents)
if not reducers or #reducers == 0 then
self:DebugScoped("verbose", "PowerSpend", "%s -> %s spend=%.0f via %s, aber keine Power-Reducer aktiv",
spellLabel,
tostring(token),
spent,
sourceLabel
)
return
end
self.powerTracking = self.powerTracking or {}
self.powerTracking.accumulators = self.powerTracking.accumulators or {}
local reducerGroups = {}
for _, reducer in ipairs(reducers) do
local relationKey = tostring(reducer.relationKey or "")
local threshold = tonumber(reducer.amountPerTrigger) or 0
if relationKey ~= "" and threshold > 0 then
local group = reducerGroups[relationKey]
if not group then
group = {
threshold = threshold,
reducers = {},
}
reducerGroups[relationKey] = group
end
group.reducers[#group.reducers + 1] = reducer
end
end
local eventTimestamp = GetServerTime()
for relationKey, group in pairs(reducerGroups) do
local threshold = tonumber(group.threshold) or 0
if threshold > 0 then
local previousBucket = tonumber(self.powerTracking.accumulators[relationKey]) or 0
local bucket = previousBucket + spent
local triggerCount = math.floor(bucket / threshold)
self.powerTracking.accumulators[relationKey] = bucket - (triggerCount * threshold)
self:DebugScoped("verbose", "PowerSpend", "%s -> bucket %s: vorher=%.0f, spend=%.0f, nachher=%.0f, threshold=%.0f, triggers=%d",
spellLabel,
tostring(token),
previousBucket,
spent,
tonumber(self.powerTracking.accumulators[relationKey]) or 0,
threshold,
triggerCount
)
if triggerCount > 0 then
for _, reducer in ipairs(group.reducers) do
local totalReduction = (tonumber(reducer.amount) or 0) * triggerCount
local applied = self:ApplyCooldownReduction(ownName, reducer.targetSpellId, totalReduction)
self:DebugScoped("verbose", "PowerSpend", "%s -> target %s requested=%.2f applied=%.2f",
spellLabel,
GetSpellDebugLabel(reducer.targetSpellId),
tonumber(totalReduction) or 0,
tonumber(applied) or 0
)
if applied > 0 then
self:Debug(
"verbose",
"LocalPowerCooldownReduce power=%s target=%s spent=%.2f triggers=%d amount=%.2f applied=%.2f",
tostring(token),
tostring(reducer.targetSpellId),
spent,
triggerCount,
tonumber(totalReduction) or 0,
tonumber(applied) or 0
)
self:BroadcastCooldownReduce(reducer.targetSpellId, applied, eventTimestamp, reducer.triggerSpellId)
end
end
end
end
end
end
function HMGT:HandleRemoteCooldownReduce(playerName, targetSpellId, amount, castTimestamp, triggerSpellId)
if not playerName then return end
if not self:IsPlayerInCurrentGroup(playerName) then return end
if not self:IsNewCooldownReduceEvent(playerName, targetSpellId, castTimestamp, triggerSpellId) then
return
end
local applied = self:ApplyCooldownReduction(playerName, targetSpellId, amount)
if applied > 0 then
self:Debug(
"verbose",
"RemoteCooldownReduce player=%s trigger=%s target=%s amount=%.2f applied=%.2f",
tostring(playerName),
tostring(triggerSpellId),
tostring(targetSpellId),
tonumber(amount) or 0,
tonumber(applied) or 0
)
end
end
function HMGT:HandleRemoteSpellCast(playerName, spellId, castTimestamp, curCharges, maxCharges, chargeRemaining, chargeDuration)
local spellEntry = HMGT_SpellData.InterruptLookup[spellId]
or HMGT_SpellData.CooldownLookup[spellId]
if not spellEntry then return end
if self:IsAvailabilitySpell(spellEntry) then return end
local pData = self.playerData[playerName]
local talents = pData and pData.talents or {}
local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents)
castTimestamp = tonumber(castTimestamp) or GetServerTime()
local existingEntry = self.activeCDs[playerName] and self.activeCDs[playerName][spellId]
if (tonumber(maxCharges) or 0) <= 0 and existingEntry and existingEntry.lastCastTimestamp then
local prevTs = tonumber(existingEntry.lastCastTimestamp) or 0
if math.abs(prevTs - castTimestamp) <= 1 then
return
end
end
local now = GetTime()
local elapsed = math.max(0, GetServerTime() - castTimestamp)
local incomingCur = tonumber(curCharges) or 0
local incomingMax = tonumber(maxCharges) or 0
local incomingChargeRemaining = tonumber(chargeRemaining) or 0
local incomingChargeDuration = tonumber(chargeDuration) or 0
local inferredMaxCharges, inferredChargeDuration = HMGT_SpellData.GetEffectiveChargeInfo(
spellEntry,
talents,
(incomingMax > 0) and incomingMax or nil,
(incomingChargeDuration > 0) and incomingChargeDuration or effectiveCd
)
local hasCharges = (incomingMax > 1) or (tonumber(inferredMaxCharges) or 0) > 1
local currentCharges = 0
local maxChargeCount = 0
local chargeDur = 0
local nextChargeRemaining = 0
local chargeStartTime = nil
local startTime, duration, expiresIn
if hasCharges then
maxChargeCount = math.max(1, (incomingMax > 0 and incomingMax) or tonumber(inferredMaxCharges) or 1)
chargeDur = tonumber(incomingChargeDuration) or tonumber(inferredChargeDuration) or tonumber(effectiveCd) or 0
chargeDur = math.max(0, chargeDur)
if chargeDur <= 0 then
chargeDur = math.max(0, tonumber(effectiveCd) or 0)
end
if incomingMax > 0 then
currentCharges = math.max(0, math.min(maxChargeCount, incomingCur))
nextChargeRemaining = math.max(0, math.min(chargeDur, incomingChargeRemaining - elapsed))
if currentCharges < maxChargeCount and chargeDur > 0 then
chargeStartTime = now - math.max(0, chargeDur - nextChargeRemaining)
end
else
local existing = self.activeCDs[playerName] and self.activeCDs[playerName][spellId]
if existing and (tonumber(existing.maxCharges) or 0) == maxChargeCount then
self:ResolveChargeState(existing, now)
local prevCharges = tonumber(existing.currentCharges) or maxChargeCount
local prevStart = tonumber(existing.chargeStart)
local prevDur = tonumber(existing.chargeDuration) or chargeDur
if prevDur > 0 then
chargeDur = prevDur
end
currentCharges = math.max(0, prevCharges - 1)
if currentCharges < maxChargeCount and chargeDur > 0 then
if prevCharges >= maxChargeCount then
chargeStartTime = now
else
chargeStartTime = prevStart or now
end
nextChargeRemaining = math.max(0, chargeDur - (now - chargeStartTime))
end
else
currentCharges = math.max(0, maxChargeCount - 1)
if currentCharges < maxChargeCount and chargeDur > 0 then
chargeStartTime = now
nextChargeRemaining = chargeDur
end
end
end
if currentCharges >= maxChargeCount and maxChargeCount > 0 then
currentCharges = math.max(0, maxChargeCount - 1)
if chargeDur > 0 then
chargeStartTime = now
nextChargeRemaining = chargeDur
end
end
if currentCharges < maxChargeCount and chargeDur > 0 then
chargeStartTime = chargeStartTime or now
local missing = maxChargeCount - currentCharges
startTime = chargeStartTime
duration = missing * chargeDur
expiresIn = math.max(0, duration - (now - startTime))
else
startTime = now
duration = 0
expiresIn = 0
end
else
local remaining = effectiveCd - elapsed
if remaining <= 0 then return end
startTime = now - elapsed
duration = effectiveCd
expiresIn = remaining
end
self:Debug(
"verbose",
"HandleRemoteSpellCast name=%s spellId=%s elapsed=%.2f expiresIn=%.2f charges=%s/%s",
tostring(playerName),
tostring(spellId),
tonumber(elapsed) or 0,
tonumber(expiresIn) or 0,
hasCharges and tostring(currentCharges) or "-",
hasCharges and tostring(maxChargeCount) or "-"
)
self._cdNonce = (self._cdNonce or 0) + 1
local nonce = self._cdNonce
self.activeCDs[playerName] = self.activeCDs[playerName] or {}
self.activeCDs[playerName][spellId] = {
startTime = startTime,
duration = duration,
spellEntry = spellEntry,
currentCharges = hasCharges and currentCharges or nil,
maxCharges = hasCharges and maxChargeCount or nil,
chargeStart = hasCharges and chargeStartTime or nil,
chargeDuration = hasCharges and chargeDur or nil,
lastCastTimestamp = castTimestamp,
_nonce = nonce,
}
self:LogTrackedSpellCast(playerName, spellEntry, {
cooldown = effectiveCd,
currentCharges = hasCharges and currentCharges or nil,
maxCharges = hasCharges and maxChargeCount or nil,
chargeCooldown = hasCharges and chargeDur or nil,
})
if expiresIn > 0 then
self:ScheduleTimer(function()
local current = self.activeCDs[playerName] and self.activeCDs[playerName][spellId]
if current and current._nonce == nonce then
self.activeCDs[playerName][spellId] = nil
self:TriggerTrackerUpdate()
end
end, expiresIn)
end
self:TriggerTrackerUpdate()
end
function HMGT:OnGroupRosterUpdate()
self:QueueSyncRequest(0.35, "roster")
local validPlayers = { [self:NormalizePlayerName(UnitName("player"))] = true }
if IsInRaid() then
for i = 1, GetNumGroupMembers() do
local n = self:NormalizePlayerName(UnitName("raid"..i))
if n then validPlayers[n] = true end
end
elseif IsInGroup() then
for i = 1, GetNumSubgroupMembers() do
local n = self:NormalizePlayerName(UnitName("party"..i))
if n then validPlayers[n] = true end
end
end
for name in pairs(self.playerData) do
if not validPlayers[name] then
self.playerData[name] = nil
self.activeCDs[name] = nil
self.availabilityStates[name] = nil
self.remoteSpellStateRevisions[name] = nil
self.peerVersions[name] = nil
self.versionWarnings[name] = nil
end
end
local count = 0
for _ in pairs(validPlayers) do count = count + 1 end
self:Debug("verbose", "OnGroupRosterUpdate validPlayers=%d", count)
if HMGT.TrackerManager and HMGT.TrackerManager.InvalidateAnchorLayout then
HMGT.TrackerManager:InvalidateAnchorLayout()
end
self:TriggerTrackerUpdate()
end
function HMGT:CleanupStaleCooldowns()
local now = GetTime()
local ownName = self:NormalizePlayerName(UnitName("player"))
local removed = 0
for playerName, spells in pairs(self.activeCDs) do
for spellId, cdInfo in pairs(spells) do
local duration = tonumber(cdInfo.duration) or 0
local startTime = tonumber(cdInfo.startTime) or now
local rem = duration - (now - startTime)
local hasCharges = (tonumber(cdInfo.maxCharges) or 0) > 0
local currentCharges = tonumber(cdInfo.currentCharges) or 0
local maxCharges = tonumber(cdInfo.maxCharges) or 0
if hasCharges then
local _, _, cur, max = self:ResolveChargeState(cdInfo, now)
currentCharges = cur
maxCharges = max
end
local shouldDrop = false
if hasCharges then
if currentCharges >= maxCharges then
shouldDrop = true
elseif (tonumber(cdInfo.chargeDuration) or 0) <= 0 and rem <= -2 then
shouldDrop = true
end
elseif rem <= -2 then
shouldDrop = true
end
if shouldDrop then
spells[spellId] = nil
if playerName == ownName then
self:PublishOwnSpellState(spellId)
end
removed = removed + 1
end
end
if not next(spells) then
self.activeCDs[playerName] = nil
end
end
if removed > 0 then
self:Debug("verbose", "CleanupStaleCooldowns removed=%d", removed)
end
end
function HMGT:OnPlayerLogin()
-- Char vollständig geladen: Spec jetzt zuverlässig abfragen
self:UpdateOwnPlayerInfo()
end
function HMGT:OnPlayerEnteringWorld()
if HMGT.TrackerManager and HMGT.TrackerManager.InvalidateAnchorLayout then
HMGT.TrackerManager:InvalidateAnchorLayout()
end
self:UpdateOwnPlayerInfo()
self:RefreshFrameAnchors(true)
self:QueueDeltaSyncBurst("entering_world", { 0.40, 1.50, 3.00 })
end
function HMGT:OnLoadingScreenDisabled()
self:QueueDeltaSyncBurst("loading_screen", { 0.25, 1.00, 2.25 })
end
function HMGT:OnPlayerTalentUpdate()
self:UpdateOwnPlayerInfo()
end
-- ═══════════════════════════════════════════════════════════════
-- GRUPPEN-SICHTBARKEIT
-- ═══════════════════════════════════════════════════════════════
--- Gibt true zurück wenn ein Tracker laut seinen Einstellungen
--- im aktuellen Gruppen-Kontext angezeigt werden soll.
--- @param settings table db.profile.interruptTracker / raidCooldownTracker
function HMGT:IsVisibleForCurrentGroup(settings)
if not settings.enabled then return false end
-- IsInRaid() MUSS vor IsInGroup() geprüft werden:
-- IsInGroup() gibt auch innerhalb von Raids true zurück!
if IsInRaid() then
return settings.showInRaid == true
end
if IsInGroup() then
return settings.showInGroup == true
end
-- Solo (weder Raid noch Gruppe)
return settings.showInSolo == true
end
-- ═══════════════════════════════════════════════════════════════
-- UI-UPDATE TRIGGER
-- ═══════════════════════════════════════════════════════════════
function HMGT:TriggerTrackerUpdate(reason)
local function normalizeReason(value)
if value == true then
return "trackers"
elseif value == "trackers" or value == "layout" or value == "visual" then
return value
end
return "full"
end
local function mergeReasons(current, incoming)
local priority = {
visual = 1,
layout = 2,
trackers = 3,
full = 4,
}
current = normalizeReason(current)
incoming = normalizeReason(incoming)
if (priority[incoming] or 4) >= (priority[current] or 4) then
return incoming
end
return current
end
self._trackerUpdateMinDelay = self._trackerUpdateMinDelay or 0.08
self._trackerUpdatePending = true
self._trackerUpdateReason = mergeReasons(self._trackerUpdateReason, reason)
if HMGT.TrackerManager then
local normalizedReason = normalizeReason(reason)
if normalizedReason == "trackers" then
HMGT.TrackerManager:MarkTrackersDirty()
elseif normalizedReason == "layout" then
HMGT.TrackerManager:MarkLayoutDirty()
end
end
if self._updateScheduled then return end
local now = GetTime()
local last = self._lastTrackerUpdateAt or 0
local delay = math.max(0, self._trackerUpdateMinDelay - (now - last))
self._updateScheduled = true
self:ScheduleTimer(function()
self._updateScheduled = nil
if not self._trackerUpdatePending then return end
self._trackerUpdatePending = nil
self._lastTrackerUpdateAt = GetTime()
local pendingReason = self._trackerUpdateReason
self._trackerUpdateReason = nil
local function profileModule(name, fn)
if not fn then return end
local t0 = debugprofilestop and debugprofilestop() or nil
fn()
local t1 = debugprofilestop and debugprofilestop() or nil
if t0 and t1 then
local mod = HMGT[name]
local count = mod and mod.lastEntryCount or 0
self:Debug("verbose", "UIUpdate %s took %.2fms entries=%s", tostring(name), t1 - t0, tostring(count))
end
end
profileModule("TrackerManager", HMGT.TrackerManager and function()
if pendingReason == "visual" and HMGT.TrackerManager.RefreshVisibleVisuals then
HMGT.TrackerManager:RefreshVisibleVisuals()
else
HMGT.TrackerManager:UpdateDisplay()
end
end or nil)
-- If events flooded in while rendering, schedule exactly one follow-up pass.
if self._trackerUpdatePending then
self:TriggerTrackerUpdate()
end
end, delay)
end
-- ====================================================================
-- MINIMAP BUTTON
-- ====================================================================
local function OpenBlizzardSettingsCategory(categoryRef)
if not categoryRef then
return false
end
if Settings and type(Settings.OpenToCategory) == "function" then
local ok = pcall(Settings.OpenToCategory, categoryRef)
if ok then
return true
end
end
if type(InterfaceOptionsFrame_OpenToCategory) == "function" then
local ok = pcall(InterfaceOptionsFrame_OpenToCategory, categoryRef)
if ok then
pcall(InterfaceOptionsFrame_OpenToCategory, categoryRef)
return true
end
end
return false
end
function HMGT:OpenConfig()
local dialog = LibStub("AceConfigDialog-3.0")
local settingsCategory = HMGT_Config and HMGT_Config.GetSettingsCategory and HMGT_Config:GetSettingsCategory()
if settingsCategory then
if dialog and type(dialog.Close) == "function" then
dialog:Close(ADDON_NAME)
end
if OpenBlizzardSettingsCategory(settingsCategory) then
return
end
end
if dialog and type(dialog.GetStatusTable) == "function" and type(dialog.SetDefaultSize) == "function" then
local status = dialog:GetStatusTable(ADDON_NAME)
if type(status.width) ~= "number" or type(status.height) ~= "number" then
local uiWidth = (UIParent and UIParent:GetWidth()) or 1920
local uiHeight = (UIParent and UIParent:GetHeight()) or 1080
local defaultWidth = math.min(1200, math.max(980, math.floor((uiWidth * 0.72) + 0.5)))
local defaultHeight = math.min(860, math.max(680, math.floor((uiHeight * 0.78) + 0.5)))
dialog:SetDefaultSize(ADDON_NAME, defaultWidth, defaultHeight)
end
end
dialog:Open(ADDON_NAME)
end
local function GetMinimapSettings(self)
if not (self and self.db and self.db.profile) then
return nil
end
self.db.profile.minimap = self.db.profile.minimap or {}
local mm = self.db.profile.minimap
NormalizeMinimapSettings(mm)
return mm
end
function HMGT:EnsureMinimapLauncher()
if self._minimapLdbObject then
return self._minimapLdbObject
end
if not (LDB and LDBIcon) then
return nil
end
self._minimapLdbObject = LDB:NewDataObject(ADDON_NAME, {
type = "launcher",
icon = MINIMAP_ICON,
OnClick = function(_, mouseButton)
if mouseButton == "LeftButton" then
HMGT:OpenConfig()
elseif mouseButton == "MiddleButton" then
HMGT:ToggleDevToolsWindow()
end
end,
OnTooltipShow = function(tooltip)
if not tooltip or not tooltip.AddLine then return end
tooltip:AddLine(L["ADDON_TITLE"] or ADDON_NAME, 1, 1, 1)
tooltip:AddLine("Left Click: Open options", 0.8, 0.8, 0.8)
tooltip:AddLine("Middle Click: Toggle developer tools", 0.8, 0.8, 0.8)
end,
})
return self._minimapLdbObject
end
function HMGT:UpdateMinimapButtonPosition()
local mm = GetMinimapSettings(self)
if not mm then return end
if LDBIcon and self._ldbIconRegistered then
if mm.hide then
LDBIcon:Hide(ADDON_NAME)
else
LDBIcon:Show(ADDON_NAME)
end
return
end
if not self.minimapButton then return end
local angle = tonumber(mm.minimapPos) or 220
local radius = 80
local rad = math.rad(angle)
local x = math.cos(rad) * radius
local y = math.sin(rad) * radius
self.minimapButton:ClearAllPoints()
self.minimapButton:SetPoint("CENTER", Minimap, "CENTER", x, y)
end
function HMGT:CreateMinimapButton()
local mm = GetMinimapSettings(self)
if not mm then return end
local launcher = self:EnsureMinimapLauncher()
if launcher and LDBIcon then
if not self._ldbIconRegistered then
LDBIcon:Register(ADDON_NAME, launcher, mm)
self._ldbIconRegistered = true
end
if self.minimapButton then
self.minimapButton:Hide()
end
self:UpdateMinimapButtonPosition()
return
end
self:CreateLegacyMinimapButton()
end
function HMGT:CreateLegacyMinimapButton()
if self.minimapButton then
self:UpdateMinimapButtonPosition()
return
end
local mm = GetMinimapSettings(self)
local button = CreateFrame("Button", "HMGT_MinimapButton", Minimap)
button:SetSize(31, 31)
button:SetFrameStrata("MEDIUM")
button:SetFrameLevel(8)
button:RegisterForClicks("LeftButtonUp", "MiddleButtonUp")
button:RegisterForDrag("RightButton")
local bg = button:CreateTexture(nil, "BACKGROUND")
bg:SetTexture("Interface\\Minimap\\UI-Minimap-Background")
bg:SetSize(20, 20)
bg:SetPoint("CENTER", 0, 0)
local icon = button:CreateTexture(nil, "ARTWORK")
icon:SetTexture(MINIMAP_ICON)
icon:SetSize(18, 18)
icon:SetPoint("CENTER", 0, 0)
icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
button.icon = icon
local border = button:CreateTexture(nil, "OVERLAY")
border:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder")
border:SetSize(54, 54)
border:SetPoint("TOPLEFT")
button:SetHighlightTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight")
button:SetScript("OnEnter", function(selfBtn)
GameTooltip:SetOwner(selfBtn, "ANCHOR_LEFT")
GameTooltip:AddLine(L["ADDON_TITLE"], 1, 1, 1)
GameTooltip:AddLine("Left Click: Open options", 0.8, 0.8, 0.8)
GameTooltip:AddLine("Middle Click: Toggle developer tools", 0.8, 0.8, 0.8)
GameTooltip:AddLine("Right Drag: Move", 0.8, 0.8, 0.8)
self:SafeShowTooltip(GameTooltip)
end)
button:SetScript("OnLeave", function() GameTooltip:Hide() end)
button:SetScript("OnClick", function(_, mouseButton)
if mouseButton == "LeftButton" then
HMGT:OpenConfig()
elseif mouseButton == "MiddleButton" then
HMGT:ToggleDevToolsWindow()
end
end)
button:SetScript("OnDragStart", function(selfBtn)
selfBtn:SetScript("OnUpdate", function()
local mx, my = Minimap:GetCenter()
local cx, cy = GetCursorPosition()
local scale = Minimap:GetEffectiveScale()
cx, cy = cx / scale, cy / scale
local angle = math.deg(math.atan2(cy - my, cx - mx))
if angle < 0 then angle = angle + 360 end
local profile = GetMinimapSettings(HMGT)
if profile then
profile.minimapPos = angle
end
HMGT:UpdateMinimapButtonPosition()
end)
end)
button:SetScript("OnDragStop", function(selfBtn)
selfBtn:SetScript("OnUpdate", nil)
end)
self.minimapButton = button
if mm and mm.hide then
button:Hide()
else
button:Show()
self:UpdateMinimapButtonPosition()
end
end
function HMGT:SlashCommand(input)
input = input:trim():lower()
if input == "lock" then
for _, tracker in ipairs(self:GetTrackerConfigs()) do
tracker.locked = true
end
if HMGT.TrackerManager and HMGT.TrackerManager.SetAllLocked then
HMGT.TrackerManager:SetAllLocked(true)
end
self:Print(L["FRAMES_LOCKED"])
elseif input == "unlock" then
for _, tracker in ipairs(self:GetTrackerConfigs()) do
tracker.locked = false
end
if HMGT.TrackerManager and HMGT.TrackerManager.SetAllLocked then
HMGT.TrackerManager:SetAllLocked(false)
end
self:Print(L["FRAMES_UNLOCKED"])
elseif input == "demo" then
self:DemoMode()
elseif input == "test" then
self:TestMode()
elseif input == "version" then
if self.IsDevToolsEnabled and self:IsDevToolsEnabled() then
self:ShowVersionMismatchPopup()
else
self:Print(L["VERSION_WINDOW_DEVTOOLS_ONLY"] or "HMGT: /hmgt version is only available while developer tools are enabled.")
end
elseif input == "debug" then
if self.ToggleDevToolsWindow then
self:ToggleDevToolsWindow()
end
elseif input == "dev" or input == "devtools" then
if self.ToggleDevToolsWindow then
self:ToggleDevToolsWindow()
end
elseif input == "notes" then
if self.Notes and self.Notes.OpenWindow then
self.Notes:OpenWindow()
else
self:OpenConfig()
end
elseif input:find("^debugdump") == 1 then
local n = tonumber(input:match("^debugdump%s+(%d+)$"))
if self.DumpDevToolsLog then
self:DumpDevToolsLog(n or 60)
end
else
self:OpenConfig()
end
end
function HMGT:DemoMode()
local trackers = self:GetTrackerConfigs()
local enable = false
for _, tracker in ipairs(trackers) do
if tracker.demoMode ~= true then
enable = true
break
end
end
for _, tracker in ipairs(trackers) do
tracker.demoMode = enable
if enable then
tracker.testMode = false
end
end
if HMGT.TrackerManager and HMGT.TrackerManager.Enable then
HMGT.TrackerManager:Enable()
end
self:TriggerTrackerUpdate()
self:Print(L["DEMO_MODE_ACTIVE"])
end
function HMGT:TestMode()
local trackers = self:GetTrackerConfigs()
local enable = false
for _, tracker in ipairs(trackers) do
if tracker.testMode ~= true then
enable = true
break
end
end
for _, tracker in ipairs(trackers) do
tracker.testMode = enable
if enable then
tracker.demoMode = false
end
end
if HMGT.TrackerManager and HMGT.TrackerManager.Enable then
HMGT.TrackerManager:Enable()
end
self:TriggerTrackerUpdate()
self:Print(L["TEST_MODE_ACTIVE"])
end
function HMGT:GetDemoEntries(trackerKey, database, settings)
local pool = {}
local poolByClass = {}
for _, entry in ipairs(database) do
if settings.enabledSpells[entry.spellId] ~= false then
pool[#pool + 1] = entry
for _, cls in ipairs(entry.classes or {}) do
poolByClass[cls] = poolByClass[cls] or {}
poolByClass[cls][#poolByClass[cls] + 1] = entry
end
end
end
if #pool == 0 then return {} end
local classKeys = {}
for cls in pairs(poolByClass) do
classKeys[#classKeys + 1] = cls
end
if #classKeys == 0 then classKeys[1] = "WARRIOR" end
local count = settings.showBar and math.min(8, #pool) or math.min(12, #pool)
local names = { "Alice", "Bob", "Clara", "Duke", "Elli", "Fynn", "Gina", "Hektor", "Ivo", "Jana", "Kira", "Lio" }
local spellIds = {}
for _, e in ipairs(pool) do spellIds[#spellIds + 1] = tostring(e.spellId) end
table.sort(spellIds)
local signature = table.concat(spellIds, ",") .. "|" .. tostring(settings.showBar and 1 or 0) .. "|" .. tostring(count)
local now = GetTime()
local cache = self.demoModeData[trackerKey]
if (not cache) or (cache.signature ~= signature) or (not cache.entries) or (#cache.entries ~= count) then
local entries = {}
for i = 1, count do
local cls = classKeys[math.random(1, #classKeys)]
local classPool = poolByClass[cls]
local spell = (classPool and classPool[math.random(1, #classPool)]) or pool[math.random(1, #pool)]
local duration = math.max(
1,
tonumber(HMGT_SpellData.GetBaseCooldown and HMGT_SpellData.GetBaseCooldown(spell)) or tonumber(spell.cooldown) or 60
)
local playerName = names[((i - 1) % #names) + 1]
-- start offset so demo entries do not all tick in sync
local offset = math.random() * math.min(duration * 0.85, duration - 0.1)
entries[#entries + 1] = {
playerName = playerName,
class = cls or ((spell.classes and spell.classes[1]) or "WARRIOR"),
spellEntry = spell,
total = duration,
cycleStart = now - offset,
currentCharges = nil,
maxCharges = nil,
}
end
cache = { signature = signature, entries = entries }
self.demoModeData[trackerKey] = cache
end
local out = {}
for _, e in ipairs(cache.entries) do
local total = math.max(1, tonumber(e.total) or 1)
local elapsed = math.max(0, now - (e.cycleStart or now))
local phase = math.fmod(elapsed, total)
local rem = total - phase
-- show zero briefly at cycle boundary, then restart immediately
if elapsed > 0 and phase < 0.05 then rem = 0 end
out[#out + 1] = {
playerName = e.playerName,
class = e.class,
spellEntry = e.spellEntry,
remaining = rem,
total = total,
currentCharges = e.currentCharges,
maxCharges = e.maxCharges,
}
end
return out
end
function HMGT:GetOwnTestEntries(database, settings, cooldownInfoOpts)
local entries = {}
local enabledSpells = settings and settings.enabledSpells or {}
local playerName = self:NormalizePlayerName(UnitName("player")) or "Player"
local classToken = select(2, UnitClass("player"))
if not classToken then
return entries, playerName
end
local specIdx = GetSpecialization()
local lookupSpec = (specIdx and specIdx > 0) and specIdx or 0
local talents = (self.playerData[playerName] and self.playerData[playerName].talents) or {}
local spells = HMGT_SpellData.GetSpellsForSpec(classToken, lookupSpec, database or {})
for _, spellEntry in ipairs(spells) do
if enabledSpells[spellEntry.spellId] ~= false then
local remaining, total, curCharges, maxCharges = self:GetCooldownInfo(playerName, spellEntry.spellId, cooldownInfoOpts)
local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents)
local isAvailabilitySpell = self:IsAvailabilitySpell(spellEntry)
local spellKnown = self:IsTrackedSpellKnownForPlayer(playerName, spellEntry.spellId)
local hasPartialCharges = (tonumber(maxCharges) or 0) > 0
and (tonumber(curCharges) or tonumber(maxCharges) or 0) < (tonumber(maxCharges) or 0)
local hasActiveCd = ((remaining or 0) > 0) or hasPartialCharges
local hasAvailabilityState = isAvailabilitySpell and self:HasAvailabilityState(playerName, spellEntry.spellId)
if spellKnown or hasActiveCd or hasAvailabilityState then
entries[#entries + 1] = {
playerName = playerName,
class = classToken,
spellEntry = spellEntry,
remaining = remaining,
total = total > 0 and total or effectiveCd,
currentCharges = curCharges,
maxCharges = maxCharges,
}
end
end
end
return entries, playerName
end
-- ═══════════════════════════════════════════════════════════════
-- HILFSFUNKTIONEN
-- ═══════════════════════════════════════════════════════════════
function HMGT:GetClassColor(classToken)
local c = self.classColors[classToken]
return c and c[1] or 1, c and c[2] or 1, c and c[3] or 1
end
function HMGT:ResolveChargeState(cdData, now)
if not cdData then return 0, 0, 0, 0 end
local maxCharges = tonumber(cdData.maxCharges) or 0
if maxCharges <= 0 then return 0, 0, 0, 0 end
now = tonumber(now) or GetTime()
local charges = tonumber(cdData.currentCharges) or 0
charges = math.max(0, math.min(maxCharges, charges))
local chargeDuration = tonumber(cdData.chargeDuration) or tonumber(cdData.duration) or 0
local chargeStart = tonumber(cdData.chargeStart)
if chargeDuration > 0 and charges < maxCharges then
if not chargeStart then
chargeStart = now
end
local elapsed = now - chargeStart
if elapsed > 0 then
local gained = math.floor(elapsed / chargeDuration)
if gained > 0 then
charges = math.min(maxCharges, charges + gained)
chargeStart = chargeStart + (gained * chargeDuration)
end
end
end
local nextChargeRemaining = 0
if charges < maxCharges and chargeDuration > 0 and chargeStart then
nextChargeRemaining = math.max(0, chargeDuration - (now - chargeStart))
end
cdData.currentCharges = charges
cdData.maxCharges = maxCharges
cdData.chargeDuration = chargeDuration
cdData.chargeStart = chargeStart
return nextChargeRemaining, chargeDuration, charges, maxCharges
end
function HMGT:GetCooldownInfo(playerName, spellId, opts)
opts = opts or {}
local deferUntilEmpty = opts.deferChargeCooldownUntilEmpty and true or false
local spellEntry = HMGT_SpellData.InterruptLookup[spellId]
or HMGT_SpellData.CooldownLookup[spellId]
local ownName = self:NormalizePlayerName(UnitName("player"))
local isOwnPlayer = playerName == ownName
local pData = isOwnPlayer and self.playerData[ownName] or nil
local talents = pData and pData.talents or {}
local effectiveCd = spellEntry and HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) or 0
local knownMaxCharges, knownChargeDuration = 0, 0
if spellEntry and isOwnPlayer then
knownMaxCharges, knownChargeDuration = self:GetKnownChargeInfo(spellEntry, talents, spellId, effectiveCd)
end
if self:IsAvailabilitySpell(spellEntry) then
local normalizedName = self:NormalizePlayerName(playerName)
if normalizedName == ownName then
local current, max = self:GetOwnAvailabilityProgress(spellEntry)
if (tonumber(max) or 0) > 0 then
self:StoreAvailabilityState(ownName, spellId, current, max, spellEntry)
return 0, 0, current, max
end
else
local current, max = self:GetAvailabilityState(normalizedName, spellId)
if (tonumber(max) or 0) > 0 then
return 0, 0, current, max
end
end
return 0, 0, nil, nil
end
local cdData = self.activeCDs[playerName] and self.activeCDs[playerName][spellId]
-- Fuer den eigenen Spieler bevorzugt echte Spell-Charge-Infos verwenden.
-- So werden Talent-Stacks (z.B. 2 Charges) korrekt berechnet und angezeigt.
if isOwnPlayer and not (InCombatLockdown and InCombatLockdown()) then
local charges, maxCharges, chargeStart, chargeDuration = GetSpellChargesInfo(spellId)
charges = SafeApiNumber(charges, 0) or 0
maxCharges = SafeApiNumber(maxCharges, 0) or 0
chargeStart = SafeApiNumber(chargeStart)
chargeDuration = SafeApiNumber(chargeDuration, 0) or 0
if maxCharges > 0 then
local tempChargeState = {
currentCharges = charges,
maxCharges = maxCharges,
chargeStart = chargeStart,
chargeDuration = chargeDuration,
duration = chargeDuration,
}
local remaining, total, curCharges, maxChargeCount = self:ResolveChargeState(tempChargeState)
self:StoreKnownChargeInfo(spellId, maxChargeCount, total > 0 and total or chargeDuration)
-- API fallback: if charges are empty but charge timer is missing/zero,
-- try classic spell cooldown so sweep can still render.
if (curCharges or 0) < maxChargeCount and remaining <= 0 then
local cdStart, cdDuration = GetSpellCooldownInfo(spellId)
if cdDuration > 0 then
remaining = math.max(0, cdDuration - (GetTime() - cdStart))
total = math.max(total or 0, cdDuration)
end
end
if deferUntilEmpty and (curCharges or 0) > 0 then
remaining = 0
end
return remaining, total, curCharges, maxChargeCount
end
local cdStart, cdDuration = GetSpellCooldownInfo(spellId)
cdStart = tonumber(cdStart) or 0
cdDuration = tonumber(cdDuration) or 0
if cdDuration > 0 then
local remaining = math.max(0, cdDuration - (GetTime() - cdStart))
remaining = math.max(0, math.min(cdDuration, remaining))
if cdData and (tonumber(cdData.maxCharges) or 0) <= 0 then
local cachedRemaining = (tonumber(cdData.duration) or 0) - (GetTime() - (tonumber(cdData.startTime) or GetTime()))
cachedRemaining = math.max(0, math.min(tonumber(cdData.duration) or cachedRemaining, cachedRemaining))
local cachedDuration = math.max(0, tonumber(cdData.duration) or 0)
if cachedDuration > 2.0 and cachedRemaining > 2.0 and cdDuration < math.max(2.0, cachedDuration * 0.35) then
return cachedRemaining, cachedDuration, nil, nil
end
end
return remaining, cdDuration, nil, nil
end
end
if not cdData then
if isOwnPlayer and knownMaxCharges > 1 then
return 0, math.max(0, knownChargeDuration or effectiveCd or 0), knownMaxCharges, knownMaxCharges
end
return 0, 0, nil, nil
end
if (tonumber(cdData.maxCharges) or 0) > 0 then
local remaining, chargeDur, charges, maxCharges = self:ResolveChargeState(cdData)
self:StoreKnownChargeInfo(spellId, maxCharges, chargeDur)
if deferUntilEmpty and charges > 0 then
remaining = 0
end
return remaining, chargeDur, charges, maxCharges
end
if isOwnPlayer and knownMaxCharges > 1 then
local remaining = (tonumber(cdData.duration) or 0) - (GetTime() - (tonumber(cdData.startTime) or GetTime()))
remaining = math.max(0, math.min(tonumber(cdData.duration) or remaining, remaining))
local currentCharges = knownMaxCharges
if remaining > 0 then
currentCharges = math.max(0, knownMaxCharges - 1)
end
if deferUntilEmpty and currentCharges > 0 then
remaining = 0
end
return remaining, math.max(0, knownChargeDuration or effectiveCd or 0), currentCharges, knownMaxCharges
end
local remaining = cdData.duration - (GetTime() - cdData.startTime)
remaining = math.max(0, math.min(cdData.duration, remaining))
return remaining, cdData.duration, nil, nil
end
function HMGT:ShouldDisplayEntry(settings, remaining, currentCharges, maxCharges, spellEntry)
local rem = tonumber(remaining) or 0
local cur = tonumber(currentCharges) or 0
local max = tonumber(maxCharges) or 0
local soon = tonumber(settings.readySoonSec) or 0
local isAvailabilitySpell = spellEntry and self:IsAvailabilitySpell(spellEntry) or false
local isReady
if isAvailabilitySpell then
isReady = max > 0 and cur >= max
else
isReady = rem <= 0 or (max > 0 and cur > 0)
end
if settings.showOnlyReady then
return isReady
end
if soon > 0 then
if isAvailabilitySpell then
return isReady
end
return isReady or rem <= soon
end
return true
end
local DEFAULT_CATEGORY_PRIORITY = {
interrupt = 1,
lust = 2,
defensive = 3,
tank = 4,
healing = 5,
offensive = 6,
utility = 7,
cc = 8,
}
local TRACKER_CATEGORY_PRIORITY = {
interruptTracker = {
interrupt = 1,
defensive = 2,
utility = 3,
cc = 4,
healing = 5,
tank = 6,
offensive = 7,
lust = 8,
},
raidCooldownTracker = {
lust = 1,
defensive = 2,
healing = 3,
tank = 4,
utility = 5,
offensive = 6,
cc = 7,
interrupt = 8,
},
groupCooldownTracker = {
tank = 1,
defensive = 2,
healing = 3,
cc = 4,
utility = 5,
offensive = 6,
lust = 7,
interrupt = 8,
},
}
local function GetCategoryPriority(category, trackerKey)
local cat = tostring(category or "utility")
local trackerOrder = trackerKey and TRACKER_CATEGORY_PRIORITY[trackerKey]
if trackerOrder and trackerOrder[cat] then
return trackerOrder[cat]
end
local order = HMGT_SpellData and HMGT_SpellData.CategoryOrder
if type(order) == "table" then
for idx, key in ipairs(order) do
if key == cat then
return idx
end
end
return #order + 10
end
return DEFAULT_CATEGORY_PRIORITY[cat] or 99
end
function HMGT:SortDisplayEntries(entries, trackerKey)
if type(entries) ~= "table" then return end
table.sort(entries, function(a, b)
local aRemaining = tonumber(a and a.remaining) or 0
local bRemaining = tonumber(b and b.remaining) or 0
local aActive = aRemaining > 0
local bActive = bRemaining > 0
if aActive ~= bActive then
return aActive
end
local aEntry = a and a.spellEntry
local bEntry = b and b.spellEntry
local aPriority = tonumber(aEntry and aEntry.priority) or GetCategoryPriority(aEntry and aEntry.category, trackerKey)
local bPriority = tonumber(bEntry and bEntry.priority) or GetCategoryPriority(bEntry and bEntry.category, trackerKey)
if aPriority ~= bPriority then
return aPriority < bPriority
end
if aActive and aRemaining ~= bRemaining then
return aRemaining < bRemaining
end
local aTotal = tonumber(a and a.total)
or tonumber(aEntry and HMGT_SpellData.GetBaseCooldown and HMGT_SpellData.GetBaseCooldown(aEntry))
or tonumber(aEntry and aEntry.cooldown)
or 0
local bTotal = tonumber(b and b.total)
or tonumber(bEntry and HMGT_SpellData.GetBaseCooldown and HMGT_SpellData.GetBaseCooldown(bEntry))
or tonumber(bEntry and bEntry.cooldown)
or 0
if (not aActive) and aTotal ~= bTotal then
return aTotal > bTotal
end
if aRemaining ~= bRemaining then
return aRemaining < bRemaining
end
local aName = tostring(a and a.playerName or "")
local bName = tostring(b and b.playerName or "")
if aName ~= bName then
return aName < bName
end
local aSpell = tonumber(aEntry and aEntry.spellId) or 0
local bSpell = tonumber(bEntry and bEntry.spellId) or 0
return aSpell < bSpell
end)
end
function HMGT:GetUnitForPlayer(playerName)
local target = self:NormalizePlayerName(playerName)
if not target then return nil end
local ownName = self:NormalizePlayerName(UnitName("player"))
if target == ownName then
return "player"
end
if IsInRaid() then
for i = 1, GetNumGroupMembers() do
local unitId = "raid" .. i
local name = self:NormalizePlayerName(UnitName(unitId))
if name == target then
return unitId
end
end
elseif IsInGroup() then
for i = 1, GetNumSubgroupMembers() do
local unitId = "party" .. i
local name = self:NormalizePlayerName(UnitName(unitId))
if name == target then
return unitId
end
end
end
return nil
end
function HMGT:IsRaidTimelineEditor(playerName)
if playerName and playerName ~= "" then
local unitId = self:GetUnitForPlayer(playerName)
if not unitId then
return false
end
return UnitIsGroupLeader and UnitIsGroupLeader(unitId) or false
end
if not IsInGroup() and not IsInRaid() then
return true
end
return UnitIsGroupLeader and UnitIsGroupLeader("player") or false
end
function HMGT:IsUnitInRangeSafe(unitId)
if not unitId or not UnitExists(unitId) then return nil end
if UnitIsUnit(unitId, "player") then return true end
if UnitIsConnected and not UnitIsConnected(unitId) then
return false
end
if type(UnitInRange) == "function" then
local a, b = UnitInRange(unitId)
if type(a) == "boolean" and type(b) == "boolean" then
if b then
return a
end
elseif type(a) == "boolean" then
return a
elseif type(a) == "number" then
return a == 1
end
end
if type(CheckInteractDistance) == "function" then
local close = CheckInteractDistance(unitId, 4)
if type(close) == "boolean" then
return close
end
end
return nil
end
function HMGT:GetPlayerRole(playerName, unitId)
local role = nil
if unitId and type(UnitGroupRolesAssigned) == "function" then
role = UnitGroupRolesAssigned(unitId)
end
if role and role ~= "" and role ~= "NONE" then
return role
end
local ownName = self:NormalizePlayerName(UnitName("player"))
if self:NormalizePlayerName(playerName) == ownName and type(GetSpecializationRole) == "function" then
local spec = GetSpecialization()
if spec and spec > 0 then
local ownRole = GetSpecializationRole(spec)
if ownRole and ownRole ~= "NONE" then
return ownRole
end
end
end
return role
end
function HMGT:FilterDisplayEntries(settings, entries)
if type(entries) ~= "table" then return entries end
if type(settings) ~= "table" then return entries end
local desiredRole = settings.roleFilter or "ALL"
local useRoleFilter = desiredRole ~= "ALL"
local useRangeCheck = settings.rangeCheck == true
if not useRoleFilter and not useRangeCheck then
return entries
end
local hideOutOfRange = useRangeCheck and settings.hideOutOfRange == true
local outOfRangeAlpha = tonumber(settings.outOfRangeAlpha) or 0.4
if outOfRangeAlpha < 0.1 then outOfRangeAlpha = 0.1 end
if outOfRangeAlpha > 1 then outOfRangeAlpha = 1 end
local filtered = {}
local unitCache = {}
local roleCache = {}
local rangeCache = {}
for _, entry in ipairs(entries) do
local include = true
local key = self:NormalizePlayerName(entry.playerName) or tostring(entry.playerName or "")
local unitId = unitCache[key]
if unitId == nil and (useRoleFilter or useRangeCheck) then
unitId = self:GetUnitForPlayer(entry.playerName) or false
unitCache[key] = unitId
end
if unitId == false then
unitId = nil
end
if useRoleFilter then
local role = roleCache[key]
if role == nil then
role = self:GetPlayerRole(entry.playerName, unitId) or false
roleCache[key] = role
end
if role == false then
role = nil
end
if role and role ~= "NONE" and role ~= desiredRole then
include = false
end
end
if include and useRangeCheck then
local inRange = rangeCache[key]
if inRange == nil then
inRange = unitId and self:IsUnitInRangeSafe(unitId)
if inRange == nil then
inRange = false
end
rangeCache[key] = inRange
end
if inRange == false and not unitId then
inRange = nil
end
if inRange == false then
if hideOutOfRange then
include = false
else
entry.outOfRange = true
entry.outOfRangeAlpha = outOfRangeAlpha
end
else
entry.outOfRange = nil
entry.outOfRangeAlpha = nil
end
else
entry.outOfRange = nil
entry.outOfRangeAlpha = nil
end
if include then
filtered[#filtered + 1] = entry
end
end
return filtered
end
function HMGT:FormatTime(seconds)
if seconds >= 60 then
return string.format("%d:%02d", math.floor(seconds / 60), seconds % 60)
end
return string.format("%.0f", seconds)
end