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