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