From 6151b434b1422e86da32c8591a57cd4d0d4f8e19 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Thu, 16 Apr 2026 16:34:01 +0200 Subject: [PATCH 1/7] Enhance version management features and localization for HMGT --- Core/VersionNoticeWindow.lua | 308 ++++++++++++++++++++++++++++++----- HailMaryGuildTools.lua | 86 ++++++++++ Locales/deDE.lua | 12 +- Locales/enUS.lua | 12 +- 4 files changed, 374 insertions(+), 44 deletions(-) diff --git a/Core/VersionNoticeWindow.lua b/Core/VersionNoticeWindow.lua index c6bbf2b..cb86ed4 100644 --- a/Core/VersionNoticeWindow.lua +++ b/Core/VersionNoticeWindow.lua @@ -3,6 +3,212 @@ local HMGT = _G[ADDON_NAME] if not HMGT then return end local L = HMGT.L or LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME) +local AceGUI = LibStub("AceGUI-3.0", true) +local CLASS_ICON_TCOORDS = CLASS_ICON_TCOORDS or {} + +local function NormalizeName(name) + if HMGT.NormalizePlayerName then + return HMGT:NormalizePlayerName(name) + end + return tostring(name or "") +end + +local function GetRosterRows() + local rows = {} + local seen = {} + + local function addUnit(unitId) + if not unitId or not UnitExists(unitId) then + return + end + local name = NormalizeName(UnitName(unitId)) + if not name or name == "" or seen[name] then + return + end + seen[name] = true + rows[#rows + 1] = { + name = name, + class = select(2, UnitClass(unitId)), + isLeader = UnitIsGroupLeader and UnitIsGroupLeader(unitId) or false, + isAssistant = UnitIsGroupAssistant and UnitIsGroupAssistant(unitId) or false, + connected = UnitIsConnected and UnitIsConnected(unitId) ~= false or true, + isPlayer = UnitIsUnit and UnitIsUnit(unitId, "player") or unitId == "player", + } + end + + if IsInRaid() then + for i = 1, GetNumGroupMembers() do + addUnit("raid" .. i) + end + elseif IsInGroup() then + addUnit("player") + for i = 1, GetNumSubgroupMembers() do + addUnit("party" .. i) + end + else + addUnit("player") + end + + table.sort(rows, function(a, b) + if a.isLeader ~= b.isLeader then + return a.isLeader + end + if a.isPlayer ~= b.isPlayer then + return a.isPlayer + end + return tostring(a.name or "") < tostring(b.name or "") + end) + + return rows +end + +local function GetPlayerVersionText(name) + local normalized = NormalizeName(name) + if normalized == NormalizeName(UnitName("player")) then + return tostring(HMGT.ADDON_VERSION or "dev"), tonumber(HMGT.PROTOCOL_VERSION) or 0, true + end + + local version = HMGT.peerVersions and HMGT.peerVersions[normalized] or nil + local protocol = HMGT.GetPeerProtocolVersion and HMGT:GetPeerProtocolVersion(normalized) or 0 + if version and version ~= "" then + return tostring(version), tonumber(protocol) or 0, true + end + return nil, tonumber(protocol) or 0, false +end + +local function ApplyClassIcon(texture, classTag) + if not texture then + return + end + + local coords = classTag and CLASS_ICON_TCOORDS[classTag] + if coords then + texture:SetTexture("Interface\\GLUES\\CHARACTERCREATE\\UI-CHARACTERCREATE-CLASSES") + texture:SetTexCoord(coords[1], coords[2], coords[3], coords[4]) + texture:Show() + else + texture:SetTexture(nil) + texture:Hide() + end +end + +local function AcquireVersionRow(window, index) + window.versionRows = window.versionRows or {} + local row = window.versionRows[index] + if row then + return row + end + + local parent = window.scrollChild + row = CreateFrame("Frame", nil, parent) + row:SetHeight(22) + + row.background = row:CreateTexture(nil, "BACKGROUND") + row.background:SetAllPoints(row) + row.background:SetColorTexture(1, 1, 1, 0.03) + + row.classIcon = row:CreateTexture(nil, "ARTWORK") + row.classIcon:SetSize(16, 16) + row.classIcon:SetPoint("LEFT", row, "LEFT", 4, 0) + + row.nameText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight") + row.nameText:SetPoint("LEFT", row.classIcon, "RIGHT", 6, 0) + row.nameText:SetJustifyH("LEFT") + + row.versionText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight") + row.versionText:SetPoint("LEFT", row, "LEFT", 250, 0) + row.versionText:SetWidth(150) + row.versionText:SetJustifyH("LEFT") + + row.protocolText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight") + row.protocolText:SetPoint("LEFT", row, "LEFT", 410, 0) + row.protocolText:SetWidth(100) + row.protocolText:SetJustifyH("LEFT") + + window.versionRows[index] = row + return row +end + +function HMGT:RefreshVersionNoticeWindow() + local window = self.versionNoticeWindow + if not window then + return + end + + local roster = GetRosterRows() + local localName = NormalizeName(UnitName("player")) + + for index, info in ipairs(roster) do + local row = AcquireVersionRow(window, index) + row:ClearAllPoints() + if index == 1 then + row:SetPoint("TOPLEFT", window.scrollChild, "TOPLEFT", 0, 0) + row:SetPoint("TOPRIGHT", window.scrollChild, "TOPRIGHT", 0, 0) + else + row:SetPoint("TOPLEFT", window.versionRows[index - 1], "BOTTOMLEFT", 0, -2) + row:SetPoint("TOPRIGHT", window.versionRows[index - 1], "BOTTOMRIGHT", 0, -2) + end + + ApplyClassIcon(row.classIcon, info.class) + + local nameLabel = tostring(info.name or UNKNOWN) + if info.isLeader then + nameLabel = string.format("%s %s", nameLabel, L["VERSION_WINDOW_LEADER_TAG"] or "(Leader)") + elseif info.isAssistant then + nameLabel = string.format("%s %s", nameLabel, L["VERSION_WINDOW_ASSISTANT_TAG"] or "(Assist)") + end + if info.isPlayer or info.name == localName then + nameLabel = string.format("%s %s", nameLabel, L["VERSION_WINDOW_SELF_TAG"] or "(You)") + end + + row.nameText:SetText(nameLabel) + row.nameText:SetTextColor(1, 0.82, 0.1, 1) + + local versionText, protocol, hasAddon = GetPlayerVersionText(info.name) + if hasAddon then + row.versionText:SetText(versionText or "?") + row.versionText:SetTextColor(0.9, 0.9, 0.9, 1) + row.protocolText:SetText(protocol > 0 and tostring(protocol) or "-") + row.protocolText:SetTextColor(0.75, 0.75, 0.75, 1) + else + row.versionText:SetText(L["VERSION_WINDOW_MISSING_ADDON"] or "Addon not installed") + row.versionText:SetTextColor(1, 0.25, 0.25, 1) + row.protocolText:SetText("-") + row.protocolText:SetTextColor(1, 0.25, 0.25, 1) + end + + row:Show() + end + + if window.versionRows then + for index = #roster + 1, #window.versionRows do + window.versionRows[index]:Hide() + end + end + + local contentHeight = math.max(1, (#roster * 24)) + window.scrollChild:SetHeight(contentHeight) + + local known = 0 + for _, info in ipairs(roster) do + local _, _, hasAddon = GetPlayerVersionText(info.name) + if hasAddon then + known = known + 1 + end + end + + window.messageText:SetText(L["VERSION_WINDOW_MESSAGE"] or "Hail Mary Guild Tools versions in your current group") + window.detailText:SetText(string.format( + L["VERSION_WINDOW_CURRENT"] or "Current version: %s | Protocol: %s", + tostring(HMGT.ADDON_VERSION or "dev"), + tostring(HMGT.PROTOCOL_VERSION or "?") + )) + window:SetStatusText(string.format( + L["VERSION_WINDOW_STATUS"] or "Detected HMGT on %d/%d players", + tonumber(known) or 0, + tonumber(#roster) or 0 + )) +end function HMGT:EnsureVersionNoticeWindow() if self.versionNoticeWindow then @@ -10,21 +216,21 @@ function HMGT:EnsureVersionNoticeWindow() end self.versionNoticeWindowStatus = self.versionNoticeWindowStatus or { - width = 560, - height = 240, + width = 640, + height = 420, } local window = self:CreateAceWindow("versionNotice", { title = L["VERSION_WINDOW_TITLE"] or "HMGT Version Check", statusText = "", statusTable = self.versionNoticeWindowStatus, - width = self.versionNoticeWindowStatus.width or 560, - height = self.versionNoticeWindowStatus.height or 240, + width = self.versionNoticeWindowStatus.width or 640, + height = self.versionNoticeWindowStatus.height or 420, backgroundTexture = "Interface\\AddOns\\HailMaryGuildTools\\Media\\HailMaryLogo.png", backgroundWidth = 220, backgroundHeight = 120, backgroundOffsetY = -8, - backgroundAlpha = 0.12, + backgroundAlpha = 0.08, strata = "FULLSCREEN_DIALOG", }) if not window then @@ -32,26 +238,64 @@ function HMGT:EnsureVersionNoticeWindow() end local content = window:GetContent() + local messageText = content:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge") - messageText:SetPoint("TOPLEFT", content, "TOPLEFT", 28, -28) - messageText:SetPoint("TOPRIGHT", content, "TOPRIGHT", -28, -28) - messageText:SetJustifyH("CENTER") - messageText:SetJustifyV("MIDDLE") + messageText:SetPoint("TOPLEFT", content, "TOPLEFT", 24, -22) + messageText:SetPoint("TOPRIGHT", content, "TOPRIGHT", -24, -22) + messageText:SetJustifyH("LEFT") messageText:SetTextColor(1, 0.82, 0.1, 1) - messageText:SetText(L["VERSION_WINDOW_MESSAGE"] or "A new version of Hail Mary Guild Tools is available.") window.messageText = messageText local detailText = content:CreateFontString(nil, "OVERLAY", "GameFontHighlight") - detailText:SetPoint("TOPLEFT", messageText, "BOTTOMLEFT", 0, -18) - detailText:SetPoint("TOPRIGHT", messageText, "BOTTOMRIGHT", 0, -18) - detailText:SetJustifyH("CENTER") - detailText:SetJustifyV("TOP") - if detailText.SetSpacing then - detailText:SetSpacing(2) - end + detailText:SetPoint("TOPLEFT", messageText, "BOTTOMLEFT", 0, -8) + detailText:SetPoint("TOPRIGHT", messageText, "BOTTOMRIGHT", 0, -8) + detailText:SetJustifyH("LEFT") detailText:SetTextColor(0.9, 0.9, 0.9, 1) window.detailText = detailText + local refreshButton = AceGUI and AceGUI:Create("Button") or nil + if refreshButton then + refreshButton:SetText(L["VERSION_WINDOW_REFRESH"] or "Refresh") + refreshButton:SetWidth(120) + refreshButton:SetCallback("OnClick", function() + HMGT:RequestSync("VersionWindow") + HMGT:RefreshVersionNoticeWindow() + end) + refreshButton.frame:SetParent(window.frame) + refreshButton.frame:ClearAllPoints() + refreshButton.frame:SetPoint("TOPRIGHT", content, "TOPRIGHT", -24, -68) + refreshButton.frame:Show() + window.refreshButton = refreshButton + end + + local header = CreateFrame("Frame", nil, content) + header:SetPoint("TOPLEFT", detailText, "BOTTOMLEFT", 0, -14) + header:SetPoint("TOPRIGHT", content, "TOPRIGHT", -24, -96) + header:SetHeight(18) + window.header = header + + local nameHeader = header:CreateFontString(nil, "OVERLAY", "GameFontNormal") + nameHeader:SetPoint("LEFT", header, "LEFT", 24, 0) + nameHeader:SetText(L["VERSION_WINDOW_COLUMN_PLAYER"] or "Player") + + local versionHeader = header:CreateFontString(nil, "OVERLAY", "GameFontNormal") + versionHeader:SetPoint("LEFT", header, "LEFT", 250, 0) + versionHeader:SetText(L["VERSION_WINDOW_COLUMN_VERSION"] or "Version") + + local protocolHeader = header:CreateFontString(nil, "OVERLAY", "GameFontNormal") + protocolHeader:SetPoint("LEFT", header, "LEFT", 410, 0) + protocolHeader:SetText(L["VERSION_WINDOW_COLUMN_PROTOCOL"] or "Protocol") + + local scrollFrame = CreateFrame("ScrollFrame", nil, content, "UIPanelScrollFrameTemplate") + scrollFrame:SetPoint("TOPLEFT", header, "BOTTOMLEFT", 0, -6) + scrollFrame:SetPoint("BOTTOMRIGHT", content, "BOTTOMRIGHT", -28, 24) + window.scrollFrame = scrollFrame + + local scrollChild = CreateFrame("Frame", nil, scrollFrame) + scrollChild:SetSize(1, 1) + scrollFrame:SetScrollChild(scrollChild) + window.scrollChild = scrollChild + self.versionNoticeWindow = window return window end @@ -67,36 +311,16 @@ function HMGT:ShowVersionMismatchPopup(playerName, detail, sourceTag, opts) } end - local info = self.latestVersionMismatch or {} local window = self:EnsureVersionNoticeWindow() if not window then return end - local hasMismatch = info.playerName or info.detail - window:SetTitle(L["VERSION_WINDOW_TITLE"] or "HMGT Version Check") - - if hasMismatch then - window.messageText:SetText(L["VERSION_WINDOW_MESSAGE"] or "A new version of Hail Mary Guild Tools is available.") - window.detailText:SetText(string.format( - L["VERSION_WINDOW_DETAIL"] or "Detected via %s from %s.\n%s", - tostring(info.sourceTag or "?"), - tostring(info.playerName or UNKNOWN), - tostring(info.detail or "") - )) - else - window.messageText:SetText(L["VERSION_WINDOW_NO_MISMATCH"] or "No newer HMGT version has been detected in your current group.") - window.detailText:SetText(string.format( - L["VERSION_WINDOW_CURRENT"] or "Current version: %s | Protocol: %s", - tostring(HMGT.ADDON_VERSION or "dev"), - tostring(HMGT.PROTOCOL_VERSION or "?") - )) - end - - self:DevTrace("Version", hasMismatch and "window_show_mismatch" or "window_show_current", { - player = info.playerName, - source = info.sourceTag, - detail = info.detail, + self:RefreshVersionNoticeWindow() + self:DevTrace("Version", "window_show", { + player = playerName, + source = sourceTag, + detail = detail, }) window:Show() window:Raise() diff --git a/HailMaryGuildTools.lua b/HailMaryGuildTools.lua index 0ca96fe..282141d 100644 --- a/HailMaryGuildTools.lua +++ b/HailMaryGuildTools.lua @@ -280,6 +280,7 @@ HMGT.pendingSpellPowerCosts = {} HMGT.demoModeData = {} HMGT.peerVersions = {} HMGT.versionWarnings = {} +HMGT.versionWhisperWarnings = {} HMGT.debugBuffer = {} HMGT.debugBufferMax = 500 HMGT.enabledDebugScopes = { @@ -484,6 +485,79 @@ function HMGT:RememberPeerProtocolVersion(playerName, protocol) self.peerProtocols[normalizedName] = numeric end +local function ParseVersionTokens(version) + local tokens = {} + local text = tostring(version or "") + for number in string.gmatch(text, "(%d+)") do + tokens[#tokens + 1] = tonumber(number) or 0 + end + return tokens +end + +function HMGT:CompareAddonVersions(leftVersion, rightVersion) + local left = ParseVersionTokens(leftVersion) + local right = ParseVersionTokens(rightVersion) + local count = math.max(#left, #right) + for i = 1, count do + local a = tonumber(left[i]) or 0 + local b = tonumber(right[i]) or 0 + if a ~= b then + return (a < b) and -1 or 1 + end + end + + local leftText = tostring(leftVersion or "") + local rightText = tostring(rightVersion or "") + if leftText == rightText then + return 0 + end + if leftText < rightText then + return -1 + end + return 1 +end + +function HMGT:IsPlayerGroupLeader() + if not IsInGroup() and not IsInRaid() then + return true + end + return UnitIsGroupLeader and UnitIsGroupLeader("player") or false +end + +function HMGT:SendOutdatedVersionWhisper(playerName, remoteVersion) + local target = self:NormalizePlayerName(playerName) + local localVersion = tostring(self.ADDON_VERSION or "dev") + local remoteText = tostring(remoteVersion or "?") + if not target or target == "" or not self:IsPlayerGroupLeader() then + return false + end + if self:CompareAddonVersions(localVersion, remoteText) <= 0 then + return false + end + + local warningKey = string.format("%s|%s|%s", tostring(target), remoteText, localVersion) + if self.versionWhisperWarnings[warningKey] then + return false + end + self.versionWhisperWarnings[warningKey] = true + + local message = string.format( + L["VERSION_OUTDATED_WHISPER"] or "Your Hail Mary Guild Tools version is outdated. You have %s, the group leader has %s.", + remoteText, + localVersion + ) + + if C_ChatInfo and type(C_ChatInfo.SendChatMessage) == "function" then + C_ChatInfo.SendChatMessage(message, "WHISPER", nil, target) + elseif type(SendChatMessage) == "function" then + SendChatMessage(message, "WHISPER", nil, target) + else + return false + end + + return true +end + function HMGT:SendReliableAck(target, messageId) if not target or target == "" or not messageId or messageId == "" then return @@ -682,6 +756,12 @@ function HMGT:RegisterPeerVersion(playerName, version, protocol, sourceTag) if not playerName then return end self.peerVersions[playerName] = version self:RememberPeerProtocolVersion(playerName, protocol) + if self.versionNoticeWindow and self.versionNoticeWindow.IsShown and self.versionNoticeWindow:IsShown() and self.RefreshVersionNoticeWindow then + self:RefreshVersionNoticeWindow() + end + if version and version ~= "" then + self:SendOutdatedVersionWhisper(playerName, version) + end local mismatch = false local details = {} if version and version ~= "" and version ~= ADDON_VERSION then @@ -4436,11 +4516,17 @@ function HMGT:OnGroupRosterUpdate() self.remoteSpellStateRevisions[name] = nil self.peerVersions[name] = nil self.versionWarnings[name] = nil + if self.peerProtocols then + self.peerProtocols[name] = nil + end end end local count = 0 for _ in pairs(validPlayers) do count = count + 1 end self:Debug("verbose", "OnGroupRosterUpdate validPlayers=%d", count) + if self.versionNoticeWindow and self.versionNoticeWindow.IsShown and self.versionNoticeWindow:IsShown() and self.RefreshVersionNoticeWindow then + self:RefreshVersionNoticeWindow() + end if HMGT.TrackerManager and HMGT.TrackerManager.InvalidateAnchorLayout then HMGT.TrackerManager:InvalidateAnchorLayout() end diff --git a/Locales/deDE.lua b/Locales/deDE.lua index 9062b0c..db24be8 100644 --- a/Locales/deDE.lua +++ b/Locales/deDE.lua @@ -18,10 +18,20 @@ L["SLASH_HINT"] = "/hmgt – Optionen | /hmgt lock/unlock | /hmgt dem L["VERSION_MISMATCH_CHAT"] = "Versionskonflikt mit %s: %s" L["VERSION_MISMATCH_POPUP"] = "HailMaryGuildTools Konflikt mit %s.\n%s\nQuelle: %s" L["VERSION_WINDOW_TITLE"] = "HMGT Versionscheck" -L["VERSION_WINDOW_MESSAGE"] = "Es gibt eine neue Version von Hail Mary Guild Tools." +L["VERSION_WINDOW_MESSAGE"] = "Hail Mary Guild Tools Versionen in deiner aktuellen Gruppe" L["VERSION_WINDOW_DETAIL"] = "Erkannt ueber %s von %s.\n%s" L["VERSION_WINDOW_NO_MISMATCH"] = "In deiner aktuellen Gruppe wurde keine neuere HMGT-Version erkannt." L["VERSION_WINDOW_CURRENT"] = "Aktuelle Version: %s | Protokoll: %s" +L["VERSION_WINDOW_STATUS"] = "HMGT bei %d/%d Spielern erkannt" +L["VERSION_WINDOW_REFRESH"] = "Aktualisieren" +L["VERSION_WINDOW_COLUMN_PLAYER"] = "Spieler" +L["VERSION_WINDOW_COLUMN_VERSION"] = "Version" +L["VERSION_WINDOW_COLUMN_PROTOCOL"] = "Protokoll" +L["VERSION_WINDOW_MISSING_ADDON"] = "Addon nicht vorhanden" +L["VERSION_WINDOW_LEADER_TAG"] = "(Leiter)" +L["VERSION_WINDOW_ASSISTANT_TAG"] = "(Assist)" +L["VERSION_WINDOW_SELF_TAG"] = "(Du)" +L["VERSION_OUTDATED_WHISPER"] = "Deine Hail Mary Guild Tools Version ist veraltet. Du hast %s, der Gruppenleiter hat %s." L["VERSION_WINDOW_DEBUG_ONLY"] = "HMGT: /hmgt version ist nur bei aktiviertem Debugmodus verfuegbar." L["VERSION_WINDOW_DEVTOOLS_ONLY"] = "HMGT: /hmgt version ist nur bei aktivierten Entwicklerwerkzeugen verfuegbar." diff --git a/Locales/enUS.lua b/Locales/enUS.lua index 422735d..89da42b 100644 --- a/Locales/enUS.lua +++ b/Locales/enUS.lua @@ -18,10 +18,20 @@ L["SLASH_HINT"] = "/hmgt – options | /hmgt lock/unlock | /hmgt demo L["VERSION_MISMATCH_CHAT"] = "Version mismatch with %s: %s" L["VERSION_MISMATCH_POPUP"] = "HailMaryGuildTools mismatch with %s.\n%s\nSource: %s" L["VERSION_WINDOW_TITLE"] = "HMGT Version Check" -L["VERSION_WINDOW_MESSAGE"] = "A new version of Hail Mary Guild Tools is available." +L["VERSION_WINDOW_MESSAGE"] = "Hail Mary Guild Tools versions in your current group" L["VERSION_WINDOW_DETAIL"] = "Detected via %s from %s.\n%s" L["VERSION_WINDOW_NO_MISMATCH"] = "No newer HMGT version has been detected in your current group." L["VERSION_WINDOW_CURRENT"] = "Current version: %s | Protocol: %s" +L["VERSION_WINDOW_STATUS"] = "Detected HMGT on %d/%d players" +L["VERSION_WINDOW_REFRESH"] = "Refresh" +L["VERSION_WINDOW_COLUMN_PLAYER"] = "Player" +L["VERSION_WINDOW_COLUMN_VERSION"] = "Version" +L["VERSION_WINDOW_COLUMN_PROTOCOL"] = "Protocol" +L["VERSION_WINDOW_MISSING_ADDON"] = "Addon not installed" +L["VERSION_WINDOW_LEADER_TAG"] = "(Leader)" +L["VERSION_WINDOW_ASSISTANT_TAG"] = "(Assist)" +L["VERSION_WINDOW_SELF_TAG"] = "(You)" +L["VERSION_OUTDATED_WHISPER"] = "Your Hail Mary Guild Tools version is outdated. You have %s, the group leader has %s." L["VERSION_WINDOW_DEBUG_ONLY"] = "HMGT: /hmgt version is only available while debug mode is enabled." L["VERSION_WINDOW_DEVTOOLS_ONLY"] = "HMGT: /hmgt version is only available while developer tools are enabled." -- 2.39.5 From 8c37da2d389dda65fa841b8943eb1182c4e045a4 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Tue, 21 Apr 2026 18:26:12 +0200 Subject: [PATCH 2/7] Adding Events for Hail Mary Bridge Addon --- HailMaryGuildTools.lua | 68 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/HailMaryGuildTools.lua b/HailMaryGuildTools.lua index 282141d..3e0e2d7 100644 --- a/HailMaryGuildTools.lua +++ b/HailMaryGuildTools.lua @@ -3064,6 +3064,74 @@ function HMGT:StoreRemotePlayerInfo(playerName, class, specIndex, talentHash, kn ) end +function HMGT:RegisterExternalAddonSource(sourceName) + local source = tostring(sourceName or "") + if source == "" then + return false + end + self.externalAddonSources = self.externalAddonSources or {} + self.externalAddonSources[source] = true + return true +end + +function HMGT:ApplyExternalKnownSpell(sourceName, playerName, spellId, class, cooldown) + local source = tostring(sourceName or "External") + local normalizedName = self:NormalizePlayerName(playerName) + local sid = tonumber(spellId) + if not normalizedName or normalizedName == "" or not sid or sid <= 0 then + return false + end + if not self:IsPlayerInCurrentGroup(normalizedName) then + return false + end + + self:RegisterExternalAddonSource(source) + local previous = self.playerData[normalizedName] or {} + local knownSpells = previous.knownSpells + if type(knownSpells) ~= "table" then + knownSpells = {} + end + knownSpells[sid] = true + + self.playerData[normalizedName] = { + class = class or previous.class, + specIndex = previous.specIndex, + talentHash = previous.talentHash, + talents = previous.talents or {}, + knownSpells = knownSpells, + externalSource = source, + } + + if tonumber(cooldown) and tonumber(cooldown) > 0 and HMGT_SpellData then + local spellEntry = HMGT_SpellData.InterruptLookup and HMGT_SpellData.InterruptLookup[sid] + or HMGT_SpellData.CooldownLookup and HMGT_SpellData.CooldownLookup[sid] + if spellEntry then + spellEntry._hmgtExternalBaseCd = tonumber(cooldown) + end + end + + self:TriggerTrackerUpdate("trackers") + return true +end + +function HMGT:ApplyExternalCooldown(sourceName, playerName, spellId, cooldown) + local source = tostring(sourceName or "External") + local normalizedName = self:NormalizePlayerName(playerName) + local sid = tonumber(spellId) + local cd = tonumber(cooldown) + if not normalizedName or normalizedName == "" or not sid or sid <= 0 or not cd or cd <= 0 then + return false + end + if not self:IsPlayerInCurrentGroup(normalizedName) then + return false + end + + self:RegisterExternalAddonSource(source) + self:ApplyExternalKnownSpell(source, normalizedName, sid, nil, cd) + self:HandleRemoteSpellCast(normalizedName, sid, GetServerTime(), nil, nil, nil, cd) + return true +end + function HMGT:ClearRemoteSpellState(playerName, spellId) local normalizedName = self:NormalizePlayerName(playerName) local sid = tonumber(spellId) -- 2.39.5 From 258cadeba5c5df861eaddfa51cb554aefda6992b Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Wed, 22 Apr 2026 16:20:47 +0200 Subject: [PATCH 3/7] Dev Build 2.0.1 --- HailMaryGuildTools.lua | 182 +++++++++++++++++- HailMaryGuildTools.toc | 2 +- .../InterruptSpellDatabase.lua | 11 +- Modules/Tracker/SpellDatabase.lua | 12 ++ 4 files changed, 195 insertions(+), 12 deletions(-) diff --git a/HailMaryGuildTools.lua b/HailMaryGuildTools.lua index 3e0e2d7..bbef89a 100644 --- a/HailMaryGuildTools.lua +++ b/HailMaryGuildTools.lua @@ -558,6 +558,30 @@ function HMGT:SendOutdatedVersionWhisper(playerName, remoteVersion) return true end +function HMGT:RegisterLibSpecializationBridge() + if self._libSpecializationBridgeRegistered then + return true + end + if not LibStub then + return false + end + + local LibSpec = LibStub("LibSpecialization", true) + if not LibSpec or type(LibSpec.RegisterGroup) ~= "function" then + return false + end + + LibSpec.RegisterGroup(self, function(specId, role, position, playerName, talentString) + if not playerName or not specId then + return + end + local classToken = HMGT:GetClassTokenForSpecId(specId) + HMGT:ApplyExternalSpecInfo("LibSpecialization", playerName, classToken, specId, talentString) + end) + self._libSpecializationBridgeRegistered = true + return true +end + function HMGT:SendReliableAck(target, messageId) if not target or target == "" or not messageId or messageId == "" then return @@ -1814,6 +1838,7 @@ function HMGT:OnEnable() self:RegisterEvent("PLAYER_SPECIALIZATION_CHANGED","OnPlayerTalentUpdate") -- Gruppen-Sichtbarkeit neu auswerten wenn sich die Zusammensetzung ändert self:RegisterEvent("RAID_ROSTER_UPDATE", "OnGroupRosterUpdate") + self:RegisterLibSpecializationBridge() if not self.cleanupTicker then self.cleanupTicker = C_Timer.NewTicker(15, function() self:CleanupStaleCooldowns() end) end @@ -3074,17 +3099,54 @@ function HMGT:RegisterExternalAddonSource(sourceName) return true end +function HMGT:GetCanonicalExternalSpellEntry(spellId) + local sid = tonumber(spellId) + if not sid or sid <= 0 or not HMGT_SpellData then + return nil, sid + end + + local spellEntry = HMGT_SpellData.InterruptLookup and HMGT_SpellData.InterruptLookup[sid] + or HMGT_SpellData.CooldownLookup and HMGT_SpellData.CooldownLookup[sid] + if not spellEntry then + return nil, sid + end + + return spellEntry, tonumber(spellEntry.spellId) or sid +end + +function HMGT:InferClassFromSpellEntry(spellEntry) + if type(spellEntry) ~= "table" or type(spellEntry.classes) ~= "table" then + return nil + end + + local foundClass + for key, value in pairs(spellEntry.classes) do + local classToken = type(value) == "string" and value or key + if foundClass and foundClass ~= classToken then + return nil + end + foundClass = classToken + end + return foundClass +end + function HMGT:ApplyExternalKnownSpell(sourceName, playerName, spellId, class, cooldown) local source = tostring(sourceName or "External") local normalizedName = self:NormalizePlayerName(playerName) local sid = tonumber(spellId) if not normalizedName or normalizedName == "" or not sid or sid <= 0 then - return false + return false, "invalid_args" end if not self:IsPlayerInCurrentGroup(normalizedName) then - return false + return false, "not_in_group" end + local spellEntry, canonicalSid = self:GetCanonicalExternalSpellEntry(sid) + if not spellEntry or not canonicalSid or canonicalSid <= 0 then + return false, "unknown_spell" + end + sid = canonicalSid + self:RegisterExternalAddonSource(source) local previous = self.playerData[normalizedName] or {} local knownSpells = previous.knownSpells @@ -3093,8 +3155,10 @@ function HMGT:ApplyExternalKnownSpell(sourceName, playerName, spellId, class, co end knownSpells[sid] = true + local classToken = class or previous.class or self:InferClassFromSpellEntry(spellEntry) + self.playerData[normalizedName] = { - class = class or previous.class, + class = classToken, specIndex = previous.specIndex, talentHash = previous.talentHash, talents = previous.talents or {}, @@ -3102,14 +3166,93 @@ function HMGT:ApplyExternalKnownSpell(sourceName, playerName, spellId, class, co externalSource = source, } - if tonumber(cooldown) and tonumber(cooldown) > 0 and HMGT_SpellData then - local spellEntry = HMGT_SpellData.InterruptLookup and HMGT_SpellData.InterruptLookup[sid] - or HMGT_SpellData.CooldownLookup and HMGT_SpellData.CooldownLookup[sid] - if spellEntry then - spellEntry._hmgtExternalBaseCd = tonumber(cooldown) + if tonumber(cooldown) and tonumber(cooldown) > 0 then + spellEntry._hmgtExternalBaseCd = tonumber(cooldown) + end + + self:TriggerTrackerUpdate("trackers") + return true +end + +function HMGT:GetClassTokenForSpecId(specId) + local sid = tonumber(specId) + if not sid or sid <= 0 then + return nil + end + + if type(GetSpecializationInfoByID) == "function" then + local returns = { pcall(GetSpecializationInfoByID, sid) } + local ok = returns[1] + local classToken = returns[7] + if ok and type(classToken) == "string" and classToken ~= "" then + return classToken end end + if type(GetSpecializationInfoForClassID) ~= "function" then + return nil + end + + for classID = 1, 20 do + local _, token = GetClassInfo(classID) + if token then + local count = 4 + if type(GetNumSpecializationsForClassID) == "function" then + count = tonumber(GetNumSpecializationsForClassID(classID)) or 4 + end + for index = 1, math.max(1, count) do + local foundSpecId = GetSpecializationInfoForClassID(classID, index) + if tonumber(foundSpecId) == sid then + return token + end + end + end + end + + return nil +end + +function HMGT:ApplyExternalSpecInfo(sourceName, playerName, class, specId, talentHash) + local source = tostring(sourceName or "External") + local normalizedName = self:NormalizePlayerName(playerName) + local spec = tonumber(specId) + local classToken = class and tostring(class) or self:GetClassTokenForSpecId(spec) + if not normalizedName or normalizedName == "" or not classToken or classToken == "" or not spec or spec <= 0 then + return false, "invalid_args" + end + if not self:IsPlayerInCurrentGroup(normalizedName) then + return false, "not_in_group" + end + + self:RegisterExternalAddonSource(source) + local previous = self.playerData[normalizedName] or {} + local knownSpells = previous.knownSpells + if type(knownSpells) ~= "table" then + knownSpells = {} + end + + if HMGT_SpellData and type(HMGT_SpellData.GetSpellsForSpec) == "function" then + for _, datasetName in ipairs({ "Interrupts", "RaidCooldowns", "GroupCooldowns" }) do + local dataset = HMGT_SpellData[datasetName] + for _, spellEntry in ipairs(HMGT_SpellData.GetSpellsForSpec(classToken, spec, dataset)) do + local sid = tonumber(spellEntry and spellEntry.spellId) + if sid and sid > 0 then + knownSpells[sid] = true + end + end + end + end + + self.playerData[normalizedName] = { + class = classToken, + specIndex = spec, + talentHash = talentHash or previous.talentHash, + talents = self:ParseTalentHash(talentHash or previous.talentHash), + knownSpells = knownSpells, + externalSource = source, + } + + self:PruneAvailabilityStates(normalizedName, knownSpells) self:TriggerTrackerUpdate("trackers") return true end @@ -3120,12 +3263,18 @@ function HMGT:ApplyExternalCooldown(sourceName, playerName, spellId, cooldown) local sid = tonumber(spellId) local cd = tonumber(cooldown) if not normalizedName or normalizedName == "" or not sid or sid <= 0 or not cd or cd <= 0 then - return false + return false, "invalid_args" end if not self:IsPlayerInCurrentGroup(normalizedName) then - return false + return false, "not_in_group" end + local spellEntry, canonicalSid = self:GetCanonicalExternalSpellEntry(sid) + if not spellEntry or not canonicalSid or canonicalSid <= 0 then + return false, "unknown_spell" + end + sid = canonicalSid + self:RegisterExternalAddonSource(source) self:ApplyExternalKnownSpell(source, normalizedName, sid, nil, cd) self:HandleRemoteSpellCast(normalizedName, sid, GetServerTime(), nil, nil, nil, cd) @@ -3182,6 +3331,7 @@ function HMGT:ApplyRemoteSpellState(playerName, spellId, kind, revision, a, b, c if not spellEntry then return false end + sid = tonumber(spellEntry.spellId) or sid local now = GetTime() local stateKind = tostring(kind or "") @@ -3639,6 +3789,7 @@ function HMGT:HandleOwnSpellCast(spellId) local spellEntry = HMGT_SpellData.InterruptLookup[spellId] or HMGT_SpellData.CooldownLookup[spellId] + spellId = tonumber(spellEntry and spellEntry.spellId) or spellId local name = self:NormalizePlayerName(UnitName("player")) local pData = self.playerData[name] local talents = pData and pData.talents or {} @@ -3830,6 +3981,7 @@ function HMGT:RefreshOwnCooldownStateFromGame(spellId) if not spellEntry or self:IsAvailabilitySpell(spellEntry) then return false end + sid = tonumber(spellEntry.spellId) or sid local existing = self.activeCDs[ownName] and self.activeCDs[ownName][sid] local before = BuildCooldownStateFingerprint(existing) @@ -4101,6 +4253,7 @@ function HMGT:DidOwnInterruptSucceed(triggerSpellId, talents) local spellEntry = HMGT_SpellData and HMGT_SpellData.InterruptLookup and HMGT_SpellData.InterruptLookup[sid] if not spellEntry then return false end + sid = tonumber(spellEntry.spellId) or sid local _, observedDuration = GetSpellCooldownInfo(sid) observedDuration = tonumber(observedDuration) or 0 @@ -4407,6 +4560,7 @@ function HMGT:HandleRemoteSpellCast(playerName, spellId, castTimestamp, curCharg local spellEntry = HMGT_SpellData.InterruptLookup[spellId] or HMGT_SpellData.CooldownLookup[spellId] if not spellEntry then return end + spellId = tonumber(spellEntry.spellId) or spellId if self:IsAvailabilitySpell(spellEntry) then return end local pData = self.playerData[playerName] @@ -5016,6 +5170,14 @@ function HMGT:SlashCommand(input) else self:Print(L["VERSION_WINDOW_DEVTOOLS_ONLY"] or "HMGT: /hmgt version is only available while developer tools are enabled.") end + elseif input == "bridge" then + if _G.HMGT_Bridge and _G.HMGT_Bridge.GetStatusLines then + for _, line in ipairs(_G.HMGT_Bridge:GetStatusLines()) do + self:Print(line) + end + else + self:Print("HMGT Bridge is not loaded.") + end elseif input == "debug" then if self.ToggleDevToolsWindow then self:ToggleDevToolsWindow() diff --git a/HailMaryGuildTools.toc b/HailMaryGuildTools.toc index 9fa5a27..abc4837 100644 --- a/HailMaryGuildTools.toc +++ b/HailMaryGuildTools.toc @@ -1,4 +1,4 @@ -## Interface: 120000,120001 +## Interface: 120000,120001,120005 ## IconTexture: Interface\Addons\HailMaryGuildTools\Media\HailMaryIcon.png ## Author: Torsten Brendgen ## Title: Hail Mary Guild Tools diff --git a/Modules/Tracker/InterruptTracker/InterruptSpellDatabase.lua b/Modules/Tracker/InterruptTracker/InterruptSpellDatabase.lua index 644a3f4..79aba2b 100644 --- a/Modules/Tracker/InterruptTracker/InterruptSpellDatabase.lua +++ b/Modules/Tracker/InterruptTracker/InterruptSpellDatabase.lua @@ -97,14 +97,23 @@ HMGT_SpellData.Interrupts = { -- WARLOCK Spell(19647, "Spell Lock", { classes = {"WARLOCK"}, - specs = {2}, + specs = {1, 3}, category = "interrupt", state = { kind = "cooldown", cooldown = 24 }, }), + Spell(119914, "Axe Toss", { + classes = {"WARLOCK"}, + specs = {2}, + category = "interrupt", + aliases = { 89766 }, + petSpellId = 89766, + state = { kind = "cooldown", cooldown = 30 }, + }), Spell(132409, "Spell Lock (Grimoire)", { classes = {"WARLOCK"}, specs = {1, 3}, category = "interrupt", + aliases = { 1276467 }, state = { kind = "cooldown", cooldown = 24 }, }), diff --git a/Modules/Tracker/SpellDatabase.lua b/Modules/Tracker/SpellDatabase.lua index 28ffde6..33e8100 100644 --- a/Modules/Tracker/SpellDatabase.lua +++ b/Modules/Tracker/SpellDatabase.lua @@ -1077,6 +1077,18 @@ function HMGT_SpellData.RebuildLookups() for _, entry in ipairs(HMGT_SpellData.Interrupts or {}) do entry._hmgtDataset = "Interrupts" HMGT_SpellData.InterruptLookup[entry.spellId] = entry + if type(entry.aliases) == "table" then + for _, aliasId in ipairs(entry.aliases) do + local sid = tonumber(aliasId) + if sid and sid > 0 then + HMGT_SpellData.InterruptLookup[sid] = entry + end + end + end + local petSpellId = tonumber(entry.petSpellId) + if petSpellId and petSpellId > 0 then + HMGT_SpellData.InterruptLookup[petSpellId] = entry + end end HMGT_SpellData.CooldownLookup = {} -- 2.39.5 From f1d2a761e4dd0beb9311814c37eb4091c013babd Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Fri, 24 Apr 2026 23:43:55 +0200 Subject: [PATCH 4/7] initial commit v.2.1.0 --- HailMaryGuildTools.lua | 2565 +---------------- HailMaryGuildTools.toc | 14 +- .../GroupCooldownTracker.lua | 703 +---- Modules/Tracker/GroupTrackerFrames.lua | 68 +- .../InterruptTracker/InterruptTracker.lua | 45 +- Modules/Tracker/NormalTrackerFrames.lua | 57 +- .../RaidcooldownTracker.lua | 45 +- Modules/Tracker/SingleFrameTrackerBase.lua | 305 -- Modules/Tracker/TrackerAvailability.lua | 169 ++ Modules/Tracker/TrackerBridge.lua | 186 ++ Modules/Tracker/TrackerCore.lua | 404 +++ Modules/Tracker/TrackerDataProvider.lua | 268 ++ Modules/Tracker/TrackerDetection.lua | 524 ++++ Modules/Tracker/TrackerManager.lua | 274 +- Modules/Tracker/TrackerPlayerState.lua | 65 + Modules/Tracker/TrackerState.lua | 410 +++ Modules/Tracker/TrackerSync.lua | 1041 +++++++ 17 files changed, 3252 insertions(+), 3891 deletions(-) delete mode 100644 Modules/Tracker/SingleFrameTrackerBase.lua create mode 100644 Modules/Tracker/TrackerAvailability.lua create mode 100644 Modules/Tracker/TrackerBridge.lua create mode 100644 Modules/Tracker/TrackerCore.lua create mode 100644 Modules/Tracker/TrackerDataProvider.lua create mode 100644 Modules/Tracker/TrackerDetection.lua create mode 100644 Modules/Tracker/TrackerPlayerState.lua create mode 100644 Modules/Tracker/TrackerState.lua create mode 100644 Modules/Tracker/TrackerSync.lua diff --git a/HailMaryGuildTools.lua b/HailMaryGuildTools.lua index bbef89a..37eec2f 100644 --- a/HailMaryGuildTools.lua +++ b/HailMaryGuildTools.lua @@ -73,6 +73,16 @@ HMGT.ADDON_VERSION = ADDON_VERSION HMGT.BUILD_VERSION = BUILD_VERSION HMGT.RELEASE_CHANNEL = RELEASE_CHANNEL HMGT.PROTOCOL_VERSION = PROTOCOL_VERSION +HMGT.COMM_PREFIX = COMM_PREFIX +HMGT.MSG_SPELL_CAST = MSG_SPELL_CAST +HMGT.MSG_CD_REDUCE = MSG_CD_REDUCE +HMGT.MSG_SPELL_STATE = MSG_SPELL_STATE +HMGT.MSG_HELLO = MSG_HELLO +HMGT.MSG_PLAYER_INFO = MSG_PLAYER_INFO +HMGT.MSG_SYNC_REQUEST = MSG_SYNC_REQUEST +HMGT.MSG_SYNC_RESPONSE = MSG_SYNC_RESPONSE +HMGT.MSG_RELIABLE = MSG_RELIABLE +HMGT.MSG_ACK = MSG_ACK HMGT.MSG_RAID_TIMELINE = MSG_RAID_TIMELINE HMGT.MSG_RAID_TIMELINE_TEST = MSG_RAID_TIMELINE_TEST @@ -312,30 +322,6 @@ local DEBUG_LEVELS = { 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 @@ -1114,52 +1100,6 @@ function HMGT:LogTrackedSpellCast(playerName, 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 @@ -1172,6 +1112,16 @@ local function IsSpellKnownLocally(spellId) return false end +HMGT.TrackerInternals = HMGT.TrackerInternals or {} +HMGT.TrackerInternals.SafeApiNumber = SafeApiNumber +HMGT.TrackerInternals.GetSpellChargesInfo = GetSpellChargesInfo +HMGT.TrackerInternals.GetSpellCooldownInfo = GetSpellCooldownInfo +HMGT.TrackerInternals.IsSpellKnownLocally = IsSpellKnownLocally +HMGT.TrackerInternals.GetGlobalCooldownInfo = GetGlobalCooldownInfo +HMGT.TrackerInternals.GetPlayerAuraApplications = GetPlayerAuraApplications +HMGT.TrackerInternals.GetSpellCastCountInfo = GetSpellCastCountInfo +HMGT.TrackerInternals.GetSpellDebugLabel = GetSpellDebugLabel + HMGT.classColors = { WARRIOR = {0.78, 0.61, 0.43}, PALADIN = {0.96, 0.55, 0.73}, @@ -1806,6 +1756,9 @@ function HMGT:MigrateProfileSettings() end function HMGT:OnEnable() + if self.EnsureTrackerStateTables then + self:EnsureTrackerStateTables() + end self:RegisterComm(COMM_PREFIX, "OnCommReceived") -- UNIT_SPELLCAST_SUCCEEDED für unitTag "player" → eigene Casts @@ -2192,622 +2145,6 @@ function HMGT:GetAvailabilityRequiredCount(spellEntry) 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 -- ═══════════════════════════════════════════════════════════════ @@ -2866,866 +2203,6 @@ function HMGT:SendGroupMessage(msg, prio) 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:RegisterExternalAddonSource(sourceName) - local source = tostring(sourceName or "") - if source == "" then - return false - end - self.externalAddonSources = self.externalAddonSources or {} - self.externalAddonSources[source] = true - return true -end - -function HMGT:GetCanonicalExternalSpellEntry(spellId) - local sid = tonumber(spellId) - if not sid or sid <= 0 or not HMGT_SpellData then - return nil, sid - end - - local spellEntry = HMGT_SpellData.InterruptLookup and HMGT_SpellData.InterruptLookup[sid] - or HMGT_SpellData.CooldownLookup and HMGT_SpellData.CooldownLookup[sid] - if not spellEntry then - return nil, sid - end - - return spellEntry, tonumber(spellEntry.spellId) or sid -end - -function HMGT:InferClassFromSpellEntry(spellEntry) - if type(spellEntry) ~= "table" or type(spellEntry.classes) ~= "table" then - return nil - end - - local foundClass - for key, value in pairs(spellEntry.classes) do - local classToken = type(value) == "string" and value or key - if foundClass and foundClass ~= classToken then - return nil - end - foundClass = classToken - end - return foundClass -end - -function HMGT:ApplyExternalKnownSpell(sourceName, playerName, spellId, class, cooldown) - local source = tostring(sourceName or "External") - local normalizedName = self:NormalizePlayerName(playerName) - local sid = tonumber(spellId) - if not normalizedName or normalizedName == "" or not sid or sid <= 0 then - return false, "invalid_args" - end - if not self:IsPlayerInCurrentGroup(normalizedName) then - return false, "not_in_group" - end - - local spellEntry, canonicalSid = self:GetCanonicalExternalSpellEntry(sid) - if not spellEntry or not canonicalSid or canonicalSid <= 0 then - return false, "unknown_spell" - end - sid = canonicalSid - - self:RegisterExternalAddonSource(source) - local previous = self.playerData[normalizedName] or {} - local knownSpells = previous.knownSpells - if type(knownSpells) ~= "table" then - knownSpells = {} - end - knownSpells[sid] = true - - local classToken = class or previous.class or self:InferClassFromSpellEntry(spellEntry) - - self.playerData[normalizedName] = { - class = classToken, - specIndex = previous.specIndex, - talentHash = previous.talentHash, - talents = previous.talents or {}, - knownSpells = knownSpells, - externalSource = source, - } - - if tonumber(cooldown) and tonumber(cooldown) > 0 then - spellEntry._hmgtExternalBaseCd = tonumber(cooldown) - end - - self:TriggerTrackerUpdate("trackers") - return true -end - -function HMGT:GetClassTokenForSpecId(specId) - local sid = tonumber(specId) - if not sid or sid <= 0 then - return nil - end - - if type(GetSpecializationInfoByID) == "function" then - local returns = { pcall(GetSpecializationInfoByID, sid) } - local ok = returns[1] - local classToken = returns[7] - if ok and type(classToken) == "string" and classToken ~= "" then - return classToken - end - end - - if type(GetSpecializationInfoForClassID) ~= "function" then - return nil - end - - for classID = 1, 20 do - local _, token = GetClassInfo(classID) - if token then - local count = 4 - if type(GetNumSpecializationsForClassID) == "function" then - count = tonumber(GetNumSpecializationsForClassID(classID)) or 4 - end - for index = 1, math.max(1, count) do - local foundSpecId = GetSpecializationInfoForClassID(classID, index) - if tonumber(foundSpecId) == sid then - return token - end - end - end - end - - return nil -end - -function HMGT:ApplyExternalSpecInfo(sourceName, playerName, class, specId, talentHash) - local source = tostring(sourceName or "External") - local normalizedName = self:NormalizePlayerName(playerName) - local spec = tonumber(specId) - local classToken = class and tostring(class) or self:GetClassTokenForSpecId(spec) - if not normalizedName or normalizedName == "" or not classToken or classToken == "" or not spec or spec <= 0 then - return false, "invalid_args" - end - if not self:IsPlayerInCurrentGroup(normalizedName) then - return false, "not_in_group" - end - - self:RegisterExternalAddonSource(source) - local previous = self.playerData[normalizedName] or {} - local knownSpells = previous.knownSpells - if type(knownSpells) ~= "table" then - knownSpells = {} - end - - if HMGT_SpellData and type(HMGT_SpellData.GetSpellsForSpec) == "function" then - for _, datasetName in ipairs({ "Interrupts", "RaidCooldowns", "GroupCooldowns" }) do - local dataset = HMGT_SpellData[datasetName] - for _, spellEntry in ipairs(HMGT_SpellData.GetSpellsForSpec(classToken, spec, dataset)) do - local sid = tonumber(spellEntry and spellEntry.spellId) - if sid and sid > 0 then - knownSpells[sid] = true - end - end - end - end - - self.playerData[normalizedName] = { - class = classToken, - specIndex = spec, - talentHash = talentHash or previous.talentHash, - talents = self:ParseTalentHash(talentHash or previous.talentHash), - knownSpells = knownSpells, - externalSource = source, - } - - self:PruneAvailabilityStates(normalizedName, knownSpells) - self:TriggerTrackerUpdate("trackers") - return true -end - -function HMGT:ApplyExternalCooldown(sourceName, playerName, spellId, cooldown) - local source = tostring(sourceName or "External") - local normalizedName = self:NormalizePlayerName(playerName) - local sid = tonumber(spellId) - local cd = tonumber(cooldown) - if not normalizedName or normalizedName == "" or not sid or sid <= 0 or not cd or cd <= 0 then - return false, "invalid_args" - end - if not self:IsPlayerInCurrentGroup(normalizedName) then - return false, "not_in_group" - end - - local spellEntry, canonicalSid = self:GetCanonicalExternalSpellEntry(sid) - if not spellEntry or not canonicalSid or canonicalSid <= 0 then - return false, "unknown_spell" - end - sid = canonicalSid - - self:RegisterExternalAddonSource(source) - self:ApplyExternalKnownSpell(source, normalizedName, sid, nil, cd) - self:HandleRemoteSpellCast(normalizedName, sid, GetServerTime(), nil, nil, nil, cd) - return true -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 - sid = tonumber(spellEntry.spellId) or sid - - 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 -- ═══════════════════════════════════════════════════════════════ @@ -3755,203 +2232,6 @@ 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] - spellId = tonumber(spellEntry and spellEntry.spellId) or 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" @@ -3966,138 +2246,13 @@ local function BuildCooldownStateFingerprint(cdData) }, "|") 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 - sid = tonumber(spellEntry.spellId) or sid - - 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] + local spells = self:GetPlayerCooldownMap(playerName, false) if not spells then return 0 end local cdData = spells[sid] if not cdData then return 0 end @@ -4163,8 +2318,8 @@ function HMGT:ApplyCooldownReduction(playerName, targetSpellId, amount) end end - if self.activeCDs[playerName] and not next(self.activeCDs[playerName]) then - self.activeCDs[playerName] = nil + if spells and not next(spells) then + self:ClearPlayerCooldowns(playerName) end if playerName == self:NormalizePlayerName(UnitName("player")) then self:PublishOwnSpellState(sid) @@ -4247,92 +2402,9 @@ local function ApplyObservedCooldownReducers(self, ownName, reducers) 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 - sid = tonumber(spellEntry.spellId) or sid - - 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 +HMGT.TrackerInternals.BuildCooldownStateFingerprint = BuildCooldownStateFingerprint +HMGT.TrackerInternals.ApplyOwnCooldownReducers = ApplyOwnCooldownReducers +HMGT.TrackerInternals.ApplyObservedCooldownReducers = ApplyObservedCooldownReducers function HMGT:CaptureOwnSpellPowerCosts(spellId) local sid = tonumber(spellId) @@ -4556,164 +2628,6 @@ function HMGT:HandleRemoteCooldownReduce(playerName, targetSpellId, amount, cast 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 - spellId = tonumber(spellEntry.spellId) or spellId - 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") @@ -4733,9 +2647,7 @@ function HMGT:OnGroupRosterUpdate() 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:ClearTrackerStateForPlayer(name) self.peerVersions[name] = nil self.versionWarnings[name] = nil if self.peerProtocols then @@ -4755,50 +2667,6 @@ function HMGT:OnGroupRosterUpdate() 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() @@ -4850,84 +2718,6 @@ 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 -- ==================================================================== @@ -5380,301 +3170,6 @@ function HMGT:GetClassColor(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 diff --git a/HailMaryGuildTools.toc b/HailMaryGuildTools.toc index abc4837..5d46a58 100644 --- a/HailMaryGuildTools.toc +++ b/HailMaryGuildTools.toc @@ -31,7 +31,17 @@ HailMaryGuildToolsOptions.lua # ────── Tracker ────────────────────────────────────────────────────── Modules\Tracker\Frame.lua Modules\Tracker\SpellDatabase.lua -Modules\Tracker\SingleFrameTrackerBase.lua +Modules\Tracker\TrackerCore.lua +Modules\Tracker\TrackerState.lua +Modules\Tracker\TrackerPlayerState.lua +Modules\Tracker\TrackerBridge.lua +Modules\Tracker\TrackerDataProvider.lua +Modules\Tracker\TrackerSync.lua +Modules\Tracker\TrackerAvailability.lua +Modules\Tracker\TrackerDetection.lua +Modules\Tracker\InterruptTracker\InterruptTracker.lua +Modules\Tracker\RaidCooldownTracker\RaidCooldownTracker.lua +Modules\Tracker\GroupCooldownTracker\GroupCooldownTracker.lua Modules\Tracker\InterruptTracker\InterruptSpellDatabase.lua Modules\Tracker\RaidcooldownTracker\RaidCooldownSpellDatabase.lua @@ -56,4 +66,4 @@ Modules\RaidTimeline\RaidTimelineBossAbilityData.lua Modules\RaidTimeline\RaidTimeline.lua Modules\RaidTimeline\RaidTimelineBigWigs.lua Modules\RaidTimeline\RaidTimelineDBM.lua -Modules\RaidTimeline\RaidTimelineOptions.lua \ No newline at end of file +Modules\RaidTimeline\RaidTimelineOptions.lua diff --git a/Modules/Tracker/GroupCooldownTracker/GroupCooldownTracker.lua b/Modules/Tracker/GroupCooldownTracker/GroupCooldownTracker.lua index 7b479cf..dd91d9c 100644 --- a/Modules/Tracker/GroupCooldownTracker/GroupCooldownTracker.lua +++ b/Modules/Tracker/GroupCooldownTracker/GroupCooldownTracker.lua @@ -1,692 +1,65 @@ --- Modules/GroupCooldownTracker.lua --- Group-Cooldown-Tracker Modul (ein Frame pro Spieler in der Gruppe) - local ADDON_NAME = "HailMaryGuildTools" local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) if not HMGT then return end -local L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME) -local GCT = HMGT:NewModule("GroupCooldownTracker") -HMGT.GroupCooldownTracker = GCT +local L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME) -GCT.frame = nil -GCT.frames = {} +local module = HMGT:NewModule("GroupCooldownTracker") +HMGT.GroupCooldownTracker = module -local function SanitizeFrameToken(name) - if not name or name == "" then return "Unknown" end - return name:gsub("[^%w_]", "_") -end - -local function ShortName(name) - if not name then return "" end - local short = name:match("^[^-]+") - return short or name -end - -local function IsUsableAnchorFrame(frame) - return frame - and frame.IsObjectType - and (frame:IsObjectType("Frame") or frame:IsObjectType("Button")) -end - -local function GetFrameUnit(frame) - if not frame then return nil end - local unit = frame.unit - if not unit and frame.GetAttribute then - unit = frame:GetAttribute("unit") - end - return unit -end - -local function FrameMatchesUnit(frame, unitId) - if not IsUsableAnchorFrame(frame) then return false end - if not unitId then return true end - local unit = GetFrameUnit(frame) - return unit == unitId -end - -local PLAYER_FRAME_CANDIDATES = { - "PlayerFrame", - "ElvUF_Player", - "NephUI_PlayerFrame", - "NephUIPlayerFrame", - "oUF_NephUI_Player", - "SUFUnitplayer", +module.definition = { + moduleName = "GroupCooldownTracker", + dbKey = "groupCooldownTracker", + trackerType = "group", + trackerKey = "groupCooldownTracker", + title = function() + return L["GCD_TITLE"] + end, + categories = { "tank", "defensive", "healing", "cc", "utility", "offensive", "lust", "interrupt" }, } -local PARTY_FRAME_PATTERNS = { - "PartyMemberFrame%d", -- Blizzard alt - "CompactPartyFrameMember%d", -- Blizzard modern - "ElvUF_PartyGroup1UnitButton%d", -- ElvUI - "ElvUF_PartyUnitButton%d", -- ElvUI variant - "NephUI_PartyUnitButton%d", -- NephUI (common naming variants) - "NephUI_PartyFrame%d", - "NephUIPartyFrame%d", - "oUF_NephUI_PartyUnitButton%d", - "SUFUnitparty%d", -- Shadowed Unit Frames -} - -local unitFrameCache = {} - -local function EntryNeedsVisualTicker(entry) - if type(entry) ~= "table" then - return false - end - - local remaining = tonumber(entry.remaining) or 0 - if remaining > 0 then - return true - end - - local maxCharges = tonumber(entry.maxCharges) or 0 - local currentCharges = tonumber(entry.currentCharges) - if maxCharges > 0 and currentCharges ~= nil and currentCharges < maxCharges then - return true - end - - return false +function module:GetDefinition() + return self.definition end -local function BuildAnchorLayoutSignature(settings, ordered, unitByPlayer) - local parts = { - settings.attachToPartyFrame == true and "attach" or "stack", - tostring(settings.partyAttachSide or "RIGHT"), - tostring(tonumber(settings.partyAttachOffsetX) or 8), - tostring(tonumber(settings.partyAttachOffsetY) or 0), - tostring(settings.showBar and "bar" or "icon"), - tostring(settings.growDirection or "DOWN"), - tostring(settings.width or 250), - tostring(settings.barHeight or 20), - tostring(settings.iconSize or 32), - tostring(settings.iconCols or 6), - tostring(settings.barSpacing or 2), - tostring(settings.locked), - tostring(settings.anchorTo or "UIParent"), - tostring(settings.anchorPoint or "TOPLEFT"), - tostring(settings.anchorRelPoint or "TOPLEFT"), - tostring(settings.anchorX or settings.posX or 0), - tostring(settings.anchorY or settings.posY or 0), - } - - for _, playerName in ipairs(ordered or {}) do - parts[#parts + 1] = tostring(playerName) - parts[#parts + 1] = tostring(unitByPlayer and unitByPlayer[playerName] or "") - end - - return table.concat(parts, "|") +function module:GetSettings() + local profile = HMGT.db and HMGT.db.profile + return profile and profile[self.definition.dbKey] or nil end -local function ResolveNamedUnitFrame(unitId) - if unitId == "player" then - for _, frameName in ipairs(PLAYER_FRAME_CANDIDATES) do - local frame = _G[frameName] - if FrameMatchesUnit(frame, unitId) or (frameName == "PlayerFrame" and IsUsableAnchorFrame(frame)) then - return frame - end - end - return nil - end - - local idx = type(unitId) == "string" and unitId:match("^party(%d+)$") - if not idx then - return nil - end - - idx = tonumber(idx) - for _, pattern in ipairs(PARTY_FRAME_PATTERNS) do - local frame = _G[pattern:format(idx)] - if FrameMatchesUnit(frame, unitId) then - return frame - end - end - return nil -end - -local function ScanUnitFrame(unitId) - local frame = EnumerateFrames() - local scanned = 0 - while frame and scanned < 8000 do - if IsUsableAnchorFrame(frame) then - local unit = GetFrameUnit(frame) - if unit == unitId then - HMGT:DebugScoped("verbose", HMGT:GetTrackerDebugScope("Group Cooldowns"), "GroupAttach scan unit=%s scanned=%d found=true", tostring(unitId), scanned) - return frame - end - end - scanned = scanned + 1 - frame = EnumerateFrames(frame) - end - HMGT:DebugScoped("verbose", HMGT:GetTrackerDebugScope("Group Cooldowns"), "GroupAttach scan unit=%s scanned=%d found=false", tostring(unitId), scanned) - return nil -end - -local function ResolveUnitAnchorFrame(unitId) - if not unitId then return nil end - - local now = GetTime() - local cached = unitFrameCache[unitId] - if cached and now < (cached.expires or 0) then - if cached.frame and cached.frame:IsShown() then - return cached.frame - end - return nil - end - - local frame = ResolveNamedUnitFrame(unitId) - if not frame then - frame = ScanUnitFrame(unitId) - end - - local expiresIn = 1.0 - if frame and frame:IsShown() then - expiresIn = 10.0 - end - unitFrameCache[unitId] = { - frame = frame, - expires = now + expiresIn, - } - - if frame and frame:IsShown() then - return frame - end - return nil -end - -function GCT:GetFrameIdForPlayer(playerName) - return "GroupCooldownTracker_" .. SanitizeFrameToken(playerName) -end - -function GCT:GetPlayerFrame(playerName) - if not playerName then return nil end - return self.frames[playerName] -end - -function GCT:GetAnchorableFrames() - return self.frames -end - -function GCT:EnsurePlayerFrame(playerName) - local frame = self.frames[playerName] - local s = HMGT.db.profile.groupCooldownTracker - if frame then - return frame - end - - frame = HMGT.TrackerFrame:CreateTrackerFrame(self:GetFrameIdForPlayer(playerName), s) - frame._hmgtPlayerName = playerName - self.frames[playerName] = frame - return frame -end - -function GCT:HideAllFrames() - for _, frame in pairs(self.frames) do - frame:Hide() - end - self.activeOrder = nil - self.unitByPlayer = nil - self.frame = nil - self._lastAnchorLayoutSignature = nil - self._nextAnchorRetryAt = nil -end - -function GCT:SetLockedAll(locked) - for _, frame in pairs(self.frames) do - HMGT.TrackerFrame:SetLocked(frame, locked) +function module:Enable() + if HMGT.TrackerManager and HMGT.TrackerManager.Enable then + HMGT.TrackerManager:Enable() end end -function GCT:EnsureUpdateTicker() - if self.updateTicker then - return - end - self.updateTicker = C_Timer.NewTicker(0.1, function() - self:UpdateDisplay() - end) -end - -function GCT:StopUpdateTicker() - if self.updateTicker then - self.updateTicker:Cancel() - self.updateTicker = nil +function module:Disable() + if HMGT.TrackerManager and HMGT.TrackerManager.UpdateDisplay then + HMGT.TrackerManager:UpdateDisplay() end end -function GCT:SetUpdateTickerEnabled(enabled) - if enabled then - self:EnsureUpdateTicker() - else - self:StopUpdateTicker() +function module:SetLockedAll(locked) + if HMGT.TrackerManager and HMGT.TrackerManager.SetAllLocked then + HMGT.TrackerManager:SetAllLocked(locked) end end -function GCT:InvalidateAnchorLayout() - self._lastAnchorLayoutSignature = nil - self._nextAnchorRetryAt = nil +function module:RefreshAnchors(force) + if HMGT.TrackerManager and HMGT.TrackerManager.RefreshAnchors then + HMGT.TrackerManager:RefreshAnchors(force) + end end -function GCT:RefreshAnchors(force) - local s = HMGT.db.profile.groupCooldownTracker - if not s then return end - - local ordered = {} - for _, playerName in ipairs(self.activeOrder or {}) do - local frame = self.frames[playerName] - if frame and frame:IsShown() then - table.insert(ordered, playerName) - end +function module:InvalidateAnchorLayout() + if HMGT.TrackerManager and HMGT.TrackerManager.InvalidateAnchorLayout then + HMGT.TrackerManager:InvalidateAnchorLayout() end - - if #ordered == 0 then - self.frame = nil - self._lastAnchorLayoutSignature = nil - self._nextAnchorRetryAt = nil - return - end - - local now = GetTime() - local signature = BuildAnchorLayoutSignature(s, ordered, self.unitByPlayer) - if not force and self._lastAnchorLayoutSignature == signature then - local retryAt = tonumber(self._nextAnchorRetryAt) or 0 - if retryAt <= 0 or now < retryAt then - return - end - end - - -- Do not force anchor updates while user is dragging a tracker frame. - for _, playerName in ipairs(ordered) do - local frame = self.frames[playerName] - if frame and frame._hmgtDragging then - return - end - end - - local primaryName = ordered[1] - local primary = self.frames[primaryName] - self.frame = primary - - if s.attachToPartyFrame == true then - local side = s.partyAttachSide or "RIGHT" - local extraX = tonumber(s.partyAttachOffsetX) or 8 - local extraY = tonumber(s.partyAttachOffsetY) or 0 - local growsUp = s.showBar == true and s.growDirection == "UP" - local barHeight = tonumber(s.barHeight) or 20 - local growUpAttachOffset = barHeight + 20 - local prevPlaced = nil - local missingTargets = 0 - - for i = 1, #ordered do - local playerName = ordered[i] - local frame = self.frames[playerName] - local unitId = self.unitByPlayer and self.unitByPlayer[playerName] - local target = ResolveUnitAnchorFrame(unitId) - local contentTopInset = HMGT.TrackerFrame.GetContentTopInset and HMGT.TrackerFrame:GetContentTopInset(frame) or 0 - - frame:ClearAllPoints() - if target then - if side == "LEFT" then - if growsUp then - frame:SetPoint("BOTTOMRIGHT", target, "TOPLEFT", -extraX, extraY - growUpAttachOffset) - else - frame:SetPoint("TOPRIGHT", target, "TOPLEFT", -extraX, extraY + contentTopInset) - end - else - if growsUp then - frame:SetPoint("BOTTOMLEFT", target, "TOPRIGHT", extraX, extraY - growUpAttachOffset) - else - frame:SetPoint("TOPLEFT", target, "TOPRIGHT", extraX, extraY + contentTopInset) - end - end - elseif prevPlaced then - missingTargets = missingTargets + 1 - HMGT:DebugScoped("verbose", HMGT:GetTrackerDebugScope("Group Cooldowns"), "GroupAttach fallback-stack player=%s unit=%s", tostring(playerName), tostring(unitId)) - if growsUp then - frame:SetPoint("BOTTOMLEFT", prevPlaced, "TOPLEFT", 0, (s.barSpacing or 2) + 10) - else - frame:SetPoint("TOPLEFT", prevPlaced, "BOTTOMLEFT", 0, -((s.barSpacing or 2) + 10)) - end - else - missingTargets = missingTargets + 1 - HMGT:DebugScoped("info", HMGT:GetTrackerDebugScope("Group Cooldowns"), "GroupAttach fallback-anchor player=%s unit=%s (no party frame found)", tostring(playerName), tostring(unitId)) - HMGT.TrackerFrame:ApplyAnchor(frame) - end - - frame:EnableMouse(false) - prevPlaced = frame - end - if missingTargets > 0 then - self._lastAnchorLayoutSignature = nil - self._nextAnchorRetryAt = now + 1.0 - else - self._lastAnchorLayoutSignature = signature - self._nextAnchorRetryAt = nil - end - return - end - - HMGT.TrackerFrame:ApplyAnchor(primary) - primary:EnableMouse(not s.locked) - - local gap = (s.barSpacing or 2) + 10 - local growsUp = s.showBar == true and s.growDirection == "UP" - for i = 2, #ordered do - local prev = self.frames[ordered[i - 1]] - local frame = self.frames[ordered[i]] - frame:ClearAllPoints() - if growsUp then - frame:SetPoint("BOTTOMLEFT", prev, "TOPLEFT", 0, gap) - else - frame:SetPoint("TOPLEFT", prev, "BOTTOMLEFT", 0, -gap) - end - frame:EnableMouse(false) - end - self._lastAnchorLayoutSignature = signature - self._nextAnchorRetryAt = nil end --- ============================================================ --- ENABLE / DISABLE --- ============================================================ - -function GCT:Enable() - local s = HMGT.db.profile.groupCooldownTracker - if not s.enabled and not s.demoMode and not s.testMode then return end - - self:UpdateDisplay() +function module:GetAnchorableFrames() + if HMGT.TrackerManager and HMGT.TrackerManager.GetAnchorableFrames then + return HMGT.TrackerManager:GetAnchorableFrames() + end + return {} end - -function GCT:Disable() - self:StopUpdateTicker() - self:HideAllFrames() -end - --- ============================================================ --- DISPLAY UPDATE --- ============================================================ - -function GCT:UpdateDisplay() - local s = HMGT.db.profile.groupCooldownTracker - if not s then return end - - if s.testMode then - local entries, playerName = HMGT:GetOwnTestEntries(HMGT_SpellData.GroupCooldowns, s, { - deferChargeCooldownUntilEmpty = false, - }) - local byPlayer = { [playerName] = {} } - for _, entry in ipairs(entries) do - entry.playerName = playerName - table.insert(byPlayer[playerName], entry) - end - - self.activeOrder = { playerName } - self.unitByPlayer = { [playerName] = "player" } - self.lastEntryCount = 0 - local active = {} - local shownOrder = {} - local shouldTick = false - for _, pName in ipairs(self.activeOrder) do - local frame = self:EnsurePlayerFrame(pName) - HMGT.TrackerFrame:SetLocked(frame, s.locked) - HMGT.TrackerFrame:SetTitle(frame, string.format("%s - %s", L["GCD_TITLE"], ShortName(pName))) - local displayEntries = byPlayer[pName] - if HMGT.FilterDisplayEntries then - displayEntries = HMGT:FilterDisplayEntries(s, displayEntries) or displayEntries - end - if HMGT.SortDisplayEntries then - HMGT:SortDisplayEntries(displayEntries, "groupCooldownTracker") - end - if #displayEntries > 0 then - HMGT.TrackerFrame:UpdateFrame(frame, displayEntries, true) - self.lastEntryCount = self.lastEntryCount + #displayEntries - frame:Show() - active[pName] = true - shownOrder[#shownOrder + 1] = pName - for _, entry in ipairs(displayEntries) do - if EntryNeedsVisualTicker(entry) then - shouldTick = true - break - end - end - else - frame:Hide() - end - end - self.activeOrder = shownOrder - - for pn, frame in pairs(self.frames) do - if not active[pn] then - frame:Hide() - end - end - - self:RefreshAnchors() - self:SetUpdateTickerEnabled(shouldTick) - return - end - - if s.demoMode then - local entries = HMGT:GetDemoEntries("groupCooldownTracker", HMGT_SpellData.GroupCooldowns, s) - local playerName = HMGT:NormalizePlayerName(UnitName("player")) or "DemoPlayer" - local byPlayer = { [playerName] = {} } - for _, entry in ipairs(entries) do - entry.playerName = playerName - table.insert(byPlayer[playerName], entry) - end - - self.activeOrder = { playerName } - self.unitByPlayer = { [playerName] = "player" } - self.lastEntryCount = 0 - local active = {} - local shownOrder = {} - local shouldTick = false - for _, playerName in ipairs(self.activeOrder) do - local frame = self:EnsurePlayerFrame(playerName) - HMGT.TrackerFrame:SetLocked(frame, s.locked) - HMGT.TrackerFrame:SetTitle(frame, string.format("%s - %s", L["GCD_TITLE"], ShortName(playerName))) - local displayEntries = byPlayer[playerName] - if HMGT.FilterDisplayEntries then - displayEntries = HMGT:FilterDisplayEntries(s, displayEntries) or displayEntries - end - if HMGT.SortDisplayEntries then - HMGT:SortDisplayEntries(displayEntries, "groupCooldownTracker") - end - if #displayEntries > 0 then - HMGT.TrackerFrame:UpdateFrame(frame, displayEntries, true) - self.lastEntryCount = self.lastEntryCount + #displayEntries - frame:Show() - active[playerName] = true - shownOrder[#shownOrder + 1] = playerName - shouldTick = true - else - frame:Hide() - end - end - self.activeOrder = shownOrder - - for pn, frame in pairs(self.frames) do - if not active[pn] then - frame:Hide() - end - end - - self:RefreshAnchors() - self:SetUpdateTickerEnabled(shouldTick) - return - end - - if IsInRaid() or not IsInGroup() then - self.lastEntryCount = 0 - self:StopUpdateTicker() - self:HideAllFrames() - return - end - if not s.enabled then - self.lastEntryCount = 0 - self:StopUpdateTicker() - self:HideAllFrames() - return - end - if not HMGT:IsVisibleForCurrentGroup(s) then - self.lastEntryCount = 0 - self:StopUpdateTicker() - self:HideAllFrames() - return - end - - local entriesByPlayer, order, unitByPlayer = self:CollectEntriesByPlayer() - self.activeOrder = order - self.unitByPlayer = unitByPlayer - self.lastEntryCount = 0 - local active = {} - local shownOrder = {} - local shouldTick = false - - for _, playerName in ipairs(order) do - local frame = self:EnsurePlayerFrame(playerName) - HMGT.TrackerFrame:SetLocked(frame, s.locked) - HMGT.TrackerFrame:SetTitle(frame, string.format("%s - %s", L["GCD_TITLE"], ShortName(playerName))) - - local entries = entriesByPlayer[playerName] or {} - if HMGT.FilterDisplayEntries then - entries = HMGT:FilterDisplayEntries(s, entries) or entries - end - if HMGT.SortDisplayEntries then - HMGT:SortDisplayEntries(entries, "groupCooldownTracker") - end - if #entries > 0 then - HMGT.TrackerFrame:UpdateFrame(frame, entries, true) - self.lastEntryCount = self.lastEntryCount + #entries - frame:Show() - active[playerName] = true - shownOrder[#shownOrder + 1] = playerName - for _, entry in ipairs(entries) do - if EntryNeedsVisualTicker(entry) then - shouldTick = true - break - end - end - else - frame:Hide() - end - end - self.activeOrder = shownOrder - - for pn, frame in pairs(self.frames) do - if not active[pn] then - frame:Hide() - end - end - - self:RefreshAnchors() - self:SetUpdateTickerEnabled(shouldTick) -end - -function GCT:CollectEntriesByPlayer() - local s = HMGT.db.profile.groupCooldownTracker - local byPlayer = {} - local playerOrder = {} - local unitByPlayer = {} - local players = self:GetGroupPlayers() - - for _, playerInfo in ipairs(players) do - repeat - local name = playerInfo.name - if not name then break end - - local pData = HMGT.playerData[name] - local class = pData and pData.class or playerInfo.class - local specIdx - if playerInfo.isOwn then - specIdx = GetSpecialization() - if not specIdx or specIdx == 0 then break end - else - specIdx = pData and pData.specIndex or nil - if not specIdx or tonumber(specIdx) <= 0 then break end - end - local talents = pData and pData.talents or {} - if not class then break end - - local knownCDs = HMGT_SpellData.GetSpellsForSpec(class, specIdx, HMGT_SpellData.GroupCooldowns) - local entries = {} - for _, spellEntry in ipairs(knownCDs) do - if s.enabledSpells[spellEntry.spellId] ~= false then - local remaining, total, curCharges, maxCharges = HMGT:GetCooldownInfo(name, spellEntry.spellId, { - deferChargeCooldownUntilEmpty = false, - }) - local isAvailabilitySpell = HMGT.IsAvailabilitySpell and HMGT:IsAvailabilitySpell(spellEntry) - local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) - local hasChargeSpell = (tonumber(maxCharges) or 0) > 1 - local hasPartialCharges = (tonumber(maxCharges) or 0) > 0 and (tonumber(curCharges) or tonumber(maxCharges) or 0) < (tonumber(maxCharges) or 0) - local include = HMGT:ShouldDisplayEntry(s, remaining, curCharges, maxCharges, spellEntry) - local spellKnown = HMGT:IsTrackedSpellKnownForPlayer(name, spellEntry.spellId) - local hasActiveCd = ((remaining or 0) > 0) or hasPartialCharges - if not spellKnown and not hasActiveCd then - include = false - end - if isAvailabilitySpell and not spellKnown then - include = false - end - if not playerInfo.isOwn then - if isAvailabilitySpell and not HMGT:HasAvailabilityState(name, spellEntry.spellId) then - include = false - end - end - if include then - table.insert(entries, { - playerName = name, - class = class, - spellEntry = spellEntry, - remaining = remaining, - total = total > 0 and total or effectiveCd, - currentCharges = curCharges, - maxCharges = maxCharges, - }) - end - end - end - - if #entries > 0 then - byPlayer[name] = entries - table.insert(playerOrder, name) - unitByPlayer[name] = playerInfo.unitId - end - until true - end - - table.sort(playerOrder, function(a, b) - local own = HMGT:NormalizePlayerName(UnitName("player")) - if a == own and b ~= own then return true end - if b == own and a ~= own then return false end - return a < b - end) - - return byPlayer, playerOrder, unitByPlayer -end - -function GCT:GetGroupPlayers() - local players = {} - local ownName = HMGT:NormalizePlayerName(UnitName("player")) - local settings = HMGT.db and HMGT.db.profile and HMGT.db.profile.groupCooldownTracker - - if settings and settings.includeSelfFrame == true and ownName then - table.insert(players, { - name = ownName, - class = select(2, UnitClass("player")), - unitId = "player", - isOwn = true, - }) - end - - if IsInGroup() and not IsInRaid() then - for i = 1, GetNumGroupMembers() - 1 do - local unitId = "party" .. i - local name = HMGT:NormalizePlayerName(UnitName(unitId)) - local class = select(2, UnitClass(unitId)) - if name and name ~= ownName then - table.insert(players, {name = name, class = class, unitId = unitId}) - end - end - end - - return players -end - diff --git a/Modules/Tracker/GroupTrackerFrames.lua b/Modules/Tracker/GroupTrackerFrames.lua index ba304f9..975199a 100644 --- a/Modules/Tracker/GroupTrackerFrames.lua +++ b/Modules/Tracker/GroupTrackerFrames.lua @@ -42,54 +42,13 @@ function Manager:HidePlayerFrames(frameKey) end function Manager:BuildEntriesByPlayerForTracker(tracker) - local frameKey = S.GetTrackerFrameKey(tracker.id) - local ownName = HMGT:NormalizePlayerName(UnitName("player")) or "Player" - if tracker.testMode then - local entries = self:CollectTestEntries(tracker) - if S.IsGroupTracker(tracker) and tracker.attachToPartyFrame == true then - return S.BuildPartyPreviewEntries(entries) + return HMGT:BuildEntriesByPlayerForTracker( + tracker, + self:GetTrackerFrameKey(tracker), + function(unitId) + return S.ResolveUnitAnchorFrame(unitId) end - local byPlayer, order, unitByPlayer = {}, {}, {} - if #entries > 0 then - byPlayer[ownName] = entries - order[1] = ownName - unitByPlayer[ownName] = "player" - end - return byPlayer, order, unitByPlayer, true - end - if tracker.demoMode then - local entries = HMGT:GetDemoEntries(frameKey, S.GetTrackerSpellPool(tracker.categories), tracker) - if S.IsGroupTracker(tracker) and tracker.attachToPartyFrame == true then - return S.BuildPartyPreviewEntries(entries) - end - for _, entry in ipairs(entries) do - entry.playerName = ownName - end - local byPlayer, order, unitByPlayer = {}, {}, {} - if #entries > 0 then - byPlayer[ownName] = entries - order[1] = ownName - unitByPlayer[ownName] = "player" - end - return byPlayer, order, unitByPlayer, true - end - if not tracker.enabled or not HMGT:IsVisibleForCurrentGroup(tracker) then - return {}, {}, {}, false - end - if IsInRaid() or not IsInGroup() then - return {}, {}, {}, false - end - local byPlayer, order, unitByPlayer = {}, {}, {} - for _, playerInfo in ipairs(S.GetGroupPlayers(tracker)) do - local entries = S.CollectEntriesForPlayer(tracker, playerInfo) - if #entries > 0 then - local playerName = playerInfo.name - byPlayer[playerName] = entries - order[#order + 1] = playerName - unitByPlayer[playerName] = playerInfo.unitId - end - end - return byPlayer, order, unitByPlayer, true + ) end function Manager:RefreshPerGroupAnchors(tracker, force) @@ -206,11 +165,10 @@ function Manager:UpdatePerGroupMemberTracker(tracker) for _, playerName in ipairs(order) do local frame = self:EnsurePlayerFrame(tracker, playerName) local entries = byPlayer[playerName] or {} - if HMGT.FilterDisplayEntries then - entries = HMGT:FilterDisplayEntries(tracker, entries) or entries - end - if HMGT.SortDisplayEntries then - HMGT:SortDisplayEntries(entries) + local tickThis = false + entries, tickThis = HMGT:FinalizeTrackerEntries(tracker, entries, tracker.trackerKey or nil) + if tickThis then + shouldTick = true end if #entries > 0 then HMGT.TrackerFrame:UpdateFrame(frame, entries, true) @@ -219,12 +177,6 @@ function Manager:UpdatePerGroupMemberTracker(tracker) shownOrder[#shownOrder + 1] = playerName shownByPlayer[playerName] = entries entryCount = entryCount + #entries - for _, entry in ipairs(entries) do - if S.EntryNeedsVisualTicker(entry) then - shouldTick = true - break - end - end else frame:Hide() end diff --git a/Modules/Tracker/InterruptTracker/InterruptTracker.lua b/Modules/Tracker/InterruptTracker/InterruptTracker.lua index 36a6406..e9d3eea 100644 --- a/Modules/Tracker/InterruptTracker/InterruptTracker.lua +++ b/Modules/Tracker/InterruptTracker/InterruptTracker.lua @@ -1,21 +1,40 @@ --- Modules/InterruptTracker.lua --- Interrupt tracker based on the shared single-frame tracker base. - local ADDON_NAME = "HailMaryGuildTools" local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) +if not HMGT then return end + local L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME) -local Base = HMGT.SingleFrameTrackerBase -if not Base then return end +local module = HMGT:NewModule("InterruptTracker") +HMGT.InterruptTracker = module -Base:CreateModule("InterruptTracker", { - profileKey = "interruptTracker", - frameName = "InterruptTracker", +module.definition = { + moduleName = "InterruptTracker", + dbKey = "interruptTracker", + trackerType = "normal", + trackerKey = "interruptTracker", title = function() return L["IT_TITLE"] end, - demoKey = "interruptTracker", - database = function() - return HMGT_SpellData.Interrupts - end, -}) + categories = { "interrupt" }, +} + +function module:GetDefinition() + return self.definition +end + +function module:GetSettings() + local profile = HMGT.db and HMGT.db.profile + return profile and profile[self.definition.dbKey] or nil +end + +function module:Enable() + if HMGT.TrackerManager and HMGT.TrackerManager.Enable then + HMGT.TrackerManager:Enable() + end +end + +function module:Disable() + if HMGT.TrackerManager and HMGT.TrackerManager.UpdateDisplay then + HMGT.TrackerManager:UpdateDisplay() + end +end diff --git a/Modules/Tracker/NormalTrackerFrames.lua b/Modules/Tracker/NormalTrackerFrames.lua index b851bba..501c1ed 100644 --- a/Modules/Tracker/NormalTrackerFrames.lua +++ b/Modules/Tracker/NormalTrackerFrames.lua @@ -3,66 +3,15 @@ local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) if not HMGT or not HMGT.TrackerManager then return end local Manager = HMGT.TrackerManager -local S = Manager._shared or {} function Manager:CollectEntries(tracker) - local entries = {} - local players = S.GetGroupPlayers(tracker) - for _, playerInfo in ipairs(players) do - local playerEntries = S.CollectEntriesForPlayer(tracker, playerInfo) - for _, entry in ipairs(playerEntries) do - entries[#entries + 1] = entry - end - end - return entries + return HMGT:CollectTrackerEntries(tracker) end function Manager:CollectTestEntries(tracker) - local playerName = HMGT:NormalizePlayerName(UnitName("player")) or "Player" - local classToken = select(2, UnitClass("player")) - if not classToken then - return {} - end - - local entries = {} - local pData = HMGT.playerData[playerName] - local talents = pData and pData.talents or {} - local spells = S.GetTrackerSpellsForPlayer(classToken, GetSpecialization() or 0, tracker.categories) - for _, spellEntry in ipairs(spells) do - if tracker.enabledSpells[spellEntry.spellId] ~= false then - local remaining, total, currentCharges, maxCharges = HMGT:GetCooldownInfo(playerName, spellEntry.spellId) - local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) - local isAvailabilitySpell = HMGT:IsAvailabilitySpell(spellEntry) - local spellKnown = HMGT:IsTrackedSpellKnownForPlayer(playerName, spellEntry.spellId) - local hasPartialCharges = (tonumber(maxCharges) or 0) > 0 - and (tonumber(currentCharges) or tonumber(maxCharges) or 0) < (tonumber(maxCharges) or 0) - local hasActiveCd = ((remaining or 0) > 0) or hasPartialCharges - local hasAvailabilityState = isAvailabilitySpell and HMGT: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 = currentCharges, - maxCharges = maxCharges, - } - end - end - end - return entries + return HMGT:CollectTrackerTestEntries(tracker) end function Manager:BuildEntriesForTracker(tracker) - if tracker.testMode then - return self:CollectTestEntries(tracker), true - end - if tracker.demoMode then - return HMGT:GetDemoEntries(S.GetTrackerFrameKey(tracker.id), S.GetTrackerSpellPool(tracker.categories), tracker), true - end - if not tracker.enabled or not HMGT:IsVisibleForCurrentGroup(tracker) then - return {}, false - end - return self:CollectEntries(tracker), true + return HMGT:BuildEntriesForTracker(tracker, self:GetTrackerFrameKey(tracker)) end diff --git a/Modules/Tracker/RaidcooldownTracker/RaidcooldownTracker.lua b/Modules/Tracker/RaidcooldownTracker/RaidcooldownTracker.lua index 3db516b..7191632 100644 --- a/Modules/Tracker/RaidcooldownTracker/RaidcooldownTracker.lua +++ b/Modules/Tracker/RaidcooldownTracker/RaidcooldownTracker.lua @@ -1,21 +1,40 @@ --- Modules/RaidCooldownTracker.lua --- Raid cooldown tracker based on the shared single-frame tracker base. - local ADDON_NAME = "HailMaryGuildTools" local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) +if not HMGT then return end + local L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME) -local Base = HMGT.SingleFrameTrackerBase -if not Base then return end +local module = HMGT:NewModule("RaidCooldownTracker") +HMGT.RaidCooldownTracker = module -Base:CreateModule("RaidCooldownTracker", { - profileKey = "raidCooldownTracker", - frameName = "RaidCooldownTracker", +module.definition = { + moduleName = "RaidCooldownTracker", + dbKey = "raidCooldownTracker", + trackerType = "normal", + trackerKey = "raidCooldownTracker", title = function() return L["RCD_TITLE"] end, - demoKey = "raidCooldownTracker", - database = function() - return HMGT_SpellData.RaidCooldowns - end, -}) + categories = { "lust", "defensive", "healing", "tank", "utility", "offensive", "cc", "interrupt" }, +} + +function module:GetDefinition() + return self.definition +end + +function module:GetSettings() + local profile = HMGT.db and HMGT.db.profile + return profile and profile[self.definition.dbKey] or nil +end + +function module:Enable() + if HMGT.TrackerManager and HMGT.TrackerManager.Enable then + HMGT.TrackerManager:Enable() + end +end + +function module:Disable() + if HMGT.TrackerManager and HMGT.TrackerManager.UpdateDisplay then + HMGT.TrackerManager:UpdateDisplay() + end +end diff --git a/Modules/Tracker/SingleFrameTrackerBase.lua b/Modules/Tracker/SingleFrameTrackerBase.lua deleted file mode 100644 index 53d43a8..0000000 --- a/Modules/Tracker/SingleFrameTrackerBase.lua +++ /dev/null @@ -1,305 +0,0 @@ --- Modules/Tracker/SingleFrameTrackerBase.lua --- Shared implementation for single-frame tracker modules. - -local ADDON_NAME = "HailMaryGuildTools" -local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) -if not HMGT then return end - -HMGT.SingleFrameTrackerBase = HMGT.SingleFrameTrackerBase or {} -local Base = HMGT.SingleFrameTrackerBase - -local function GetDefaultGroupPlayers() - local players = {} - - local ownName = HMGT:NormalizePlayerName(UnitName("player")) - local ownClass = select(2, UnitClass("player")) - table.insert(players, { name = ownName, class = ownClass, isOwn = true, unitId = "player" }) - - if IsInRaid() then - for i = 1, GetNumGroupMembers() do - local unitId = "raid" .. i - local name = HMGT:NormalizePlayerName(UnitName(unitId)) - local class = select(2, UnitClass(unitId)) - if name and name ~= ownName then - table.insert(players, { name = name, class = class, unitId = unitId }) - end - end - elseif IsInGroup() then - for i = 1, GetNumGroupMembers() - 1 do - local unitId = "party" .. i - local name = HMGT:NormalizePlayerName(UnitName(unitId)) - local class = select(2, UnitClass(unitId)) - if name and name ~= ownName then - table.insert(players, { name = name, class = class, unitId = unitId }) - end - end - end - - return players -end - -local function ResolveConfigValue(configValue, self) - if type(configValue) == "function" then - return configValue(self) - end - return configValue -end - -local function EntryNeedsVisualTicker(entry) - if type(entry) ~= "table" then - return false - end - - local remaining = tonumber(entry.remaining) or 0 - if remaining > 0 then - return true - end - - local maxCharges = tonumber(entry.maxCharges) or 0 - local currentCharges = tonumber(entry.currentCharges) - if maxCharges > 0 and currentCharges ~= nil and currentCharges < maxCharges then - return true - end - - return false -end - -function Base:Create(config) - local tracker = { - frame = nil, - updateTicker = nil, - lastEntryCount = 0, - } - - function tracker:GetSettings() - return HMGT.db.profile[config.profileKey] - end - - function tracker:GetDatabase() - return ResolveConfigValue(config.database, self) or {} - end - - function tracker:GetTitle() - return ResolveConfigValue(config.title, self) or config.frameName - end - - function tracker:GetDemoKey() - return ResolveConfigValue(config.demoKey, self) or config.profileKey - end - - function tracker:GetCooldownInfoOpts() - return ResolveConfigValue(config.cooldownInfoOpts, self) - end - - function tracker:GetGroupPlayers() - local custom = ResolveConfigValue(config.groupPlayersProvider, self) - if type(custom) == "table" then - return custom - end - return GetDefaultGroupPlayers() - end - - function tracker:EnsureUpdateTicker() - if self.updateTicker then - return - end - self.updateTicker = C_Timer.NewTicker(0.1, function() - self:UpdateDisplay() - end) - end - - function tracker:StopUpdateTicker() - if self.updateTicker then - self.updateTicker:Cancel() - self.updateTicker = nil - end - end - - function tracker:SetUpdateTickerEnabled(enabled) - if enabled then - self:EnsureUpdateTicker() - else - self:StopUpdateTicker() - end - end - - function tracker:Enable() - local s = self:GetSettings() - if not s.enabled and not s.demoMode and not s.testMode then return end - - if not self.frame then - self.frame = HMGT.TrackerFrame:CreateTrackerFrame(config.frameName, s) - HMGT.TrackerFrame:SetTitle(self.frame, self:GetTitle()) - end - - if HMGT:IsVisibleForCurrentGroup(s) then - self.frame:Show() - else - self.frame:Hide() - end - self:UpdateDisplay() - end - - function tracker:Disable() - self:StopUpdateTicker() - if self.frame then - self.frame:Hide() - end - end - - function tracker:UpdateDisplay() - if not self.frame then - self:StopUpdateTicker() - return - end - - local s = self:GetSettings() - local database = self:GetDatabase() - local cooldownInfoOpts = self:GetCooldownInfoOpts() - - if s.testMode then - HMGT.TrackerFrame:SetLocked(self.frame, s.locked) - local entries = HMGT:GetOwnTestEntries(database, s, cooldownInfoOpts) - self.lastEntryCount = #entries - HMGT.TrackerFrame:UpdateFrame(self.frame, entries) - self.frame:Show() - local shouldTick = false - for _, entry in ipairs(entries) do - if EntryNeedsVisualTicker(entry) then - shouldTick = true - break - end - end - self:SetUpdateTickerEnabled(shouldTick) - return - end - - if s.demoMode then - HMGT.TrackerFrame:SetLocked(self.frame, s.locked) - local entries = HMGT:GetDemoEntries(self:GetDemoKey(), database, s) - self.lastEntryCount = #entries - HMGT.TrackerFrame:UpdateFrame(self.frame, entries) - self.frame:Show() - self:SetUpdateTickerEnabled(#entries > 0) - return - end - - if not s.enabled then - self.lastEntryCount = 0 - self.frame:Hide() - self:StopUpdateTicker() - return - end - - if not HMGT:IsVisibleForCurrentGroup(s) then - self.lastEntryCount = 0 - self.frame:Hide() - self:StopUpdateTicker() - return - end - - HMGT.TrackerFrame:SetLocked(self.frame, s.locked) - - local entries = self:CollectEntries() - self.lastEntryCount = #entries - if HMGT.SortDisplayEntries then - HMGT:SortDisplayEntries(entries, config.profileKey) - end - - HMGT.TrackerFrame:UpdateFrame(self.frame, entries) - self.frame:Show() - - local shouldTick = false - for _, entry in ipairs(entries) do - if EntryNeedsVisualTicker(entry) then - shouldTick = true - break - end - end - self:SetUpdateTickerEnabled(shouldTick) - end - - function tracker:CollectEntries() - local entries = {} - local s = self:GetSettings() - local database = self:GetDatabase() - local cooldownInfoOpts = self:GetCooldownInfoOpts() - local players = self:GetGroupPlayers() - - for _, playerInfo in ipairs(players) do - repeat - local name = playerInfo.name - local pData = HMGT.playerData[name] - local class = pData and pData.class or playerInfo.class - local specIdx - if playerInfo.isOwn then - specIdx = GetSpecialization() - if not specIdx or specIdx == 0 then break end - else - specIdx = pData and pData.specIndex or nil - if not specIdx or tonumber(specIdx) <= 0 then break end - end - local talents = pData and pData.talents or {} - - if not class then break end - - local knownSpells = HMGT_SpellData.GetSpellsForSpec(class, specIdx, database) - - for _, spellEntry in ipairs(knownSpells) do - if s.enabledSpells[spellEntry.spellId] ~= false then - local remaining, total, curCharges, maxCharges = HMGT:GetCooldownInfo(name, spellEntry.spellId, cooldownInfoOpts) - local isAvailabilitySpell = HMGT.IsAvailabilitySpell and HMGT:IsAvailabilitySpell(spellEntry) - local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) - local include = HMGT:ShouldDisplayEntry(s, remaining, curCharges, maxCharges, spellEntry) - local spellKnown = HMGT:IsTrackedSpellKnownForPlayer(name, 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 - if not spellKnown and not hasActiveCd then - include = false - end - if isAvailabilitySpell and not spellKnown then - include = false - end - - if not playerInfo.isOwn then - if isAvailabilitySpell and not HMGT:HasAvailabilityState(name, spellEntry.spellId) then - include = false - end - end - - if include then - entries[#entries + 1] = { - playerName = name, - class = class, - spellEntry = spellEntry, - remaining = remaining, - total = total > 0 and total or effectiveCd, - currentCharges = curCharges, - maxCharges = maxCharges, - } - end - end - end - until true - end - - return entries - end - - return tracker -end - -function Base:CreateModule(moduleName, config, ...) - if type(moduleName) ~= "string" or moduleName == "" then - return self:Create(config) - end - - local module = HMGT:NewModule(moduleName, ...) - local tracker = self:Create(config) - for key, value in pairs(tracker) do - module[key] = value - end - HMGT[moduleName] = module - return module -end diff --git a/Modules/Tracker/TrackerAvailability.lua b/Modules/Tracker/TrackerAvailability.lua new file mode 100644 index 0000000..a2c3417 --- /dev/null +++ b/Modules/Tracker/TrackerAvailability.lua @@ -0,0 +1,169 @@ +local ADDON_NAME = "HailMaryGuildTools" +local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) +if not HMGT then return end + +HMGT.TrackerAvailability = HMGT.TrackerAvailability or {} + +local internals = HMGT.TrackerInternals or {} +local GetPlayerAuraApplications = internals.GetPlayerAuraApplications +local GetSpellCastCountInfo = internals.GetSpellCastCountInfo + +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 and GetPlayerAuraApplications(availability.auraSpellId) or 0 + if current <= 0 then + local fallbackSpellId = tonumber(availability.fallbackSpellCountId) + or tonumber(availability.progressSpellId) + or tonumber(spellEntry and spellEntry.spellId) + if fallbackSpellId and fallbackSpellId > 0 and GetSpellCastCountInfo 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 state = self:GetAvailabilityStateEntry(playerName, spellId) + 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 + return self:ClearAvailabilityState(normalizedName, sid) + end + + local currentCount = math.max(0, math.min(maxCount, math.floor((tonumber(current) or 0) + 0.5))) + local previous = self:GetAvailabilityStateEntry(normalizedName, sid) + local changed = (not previous) + or (tonumber(previous.current) or -1) ~= currentCount + or (tonumber(previous.max) or -1) ~= maxCount + + self:SetAvailabilityStateEntry(normalizedName, sid, { + current = currentCount, + max = maxCount, + spellEntry = spellEntry, + updatedAt = GetTime(), + }) + + return changed +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 diff --git a/Modules/Tracker/TrackerBridge.lua b/Modules/Tracker/TrackerBridge.lua new file mode 100644 index 0000000..d49c457 --- /dev/null +++ b/Modules/Tracker/TrackerBridge.lua @@ -0,0 +1,186 @@ +local ADDON_NAME = "HailMaryGuildTools" +local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) +if not HMGT then return end + +HMGT.TrackerBridge = HMGT.TrackerBridge or {} + +function HMGT:RegisterExternalAddonSource(sourceName) + local source = tostring(sourceName or "") + if source == "" then + return false + end + self.externalAddonSources = self.externalAddonSources or {} + self.externalAddonSources[source] = true + return true +end + +function HMGT:GetCanonicalExternalSpellEntry(spellId) + local sid = tonumber(spellId) + if not sid or sid <= 0 or not HMGT_SpellData then + return nil, sid + end + + local spellEntry = HMGT_SpellData.InterruptLookup and HMGT_SpellData.InterruptLookup[sid] + or HMGT_SpellData.CooldownLookup and HMGT_SpellData.CooldownLookup[sid] + if not spellEntry then + return nil, sid + end + + return spellEntry, tonumber(spellEntry.spellId) or sid +end + +function HMGT:InferClassFromSpellEntry(spellEntry) + if type(spellEntry) ~= "table" or type(spellEntry.classes) ~= "table" then + return nil + end + + local foundClass + for key, value in pairs(spellEntry.classes) do + local classToken = type(value) == "string" and value or key + if foundClass and foundClass ~= classToken then + return nil + end + foundClass = classToken + end + return foundClass +end + +function HMGT:ApplyExternalKnownSpell(sourceName, playerName, spellId, class, cooldown) + local source = tostring(sourceName or "External") + local normalizedName = self:NormalizePlayerName(playerName) + local sid = tonumber(spellId) + if not normalizedName or normalizedName == "" or not sid or sid <= 0 then + return false, "invalid_args" + end + if not self:IsPlayerInCurrentGroup(normalizedName) then + return false, "not_in_group" + end + + local spellEntry, canonicalSid = self:GetCanonicalExternalSpellEntry(sid) + if not spellEntry or not canonicalSid or canonicalSid <= 0 then + return false, "unknown_spell" + end + sid = canonicalSid + + self:RegisterExternalAddonSource(source) + local previous = self.playerData[normalizedName] or {} + local knownSpells = previous.knownSpells + if type(knownSpells) ~= "table" then + knownSpells = {} + end + knownSpells[sid] = true + + local classToken = class or previous.class or self:InferClassFromSpellEntry(spellEntry) + + self.playerData[normalizedName] = { + class = classToken, + specIndex = previous.specIndex, + talentHash = previous.talentHash, + talents = previous.talents or {}, + knownSpells = knownSpells, + externalSource = source, + } + + if tonumber(cooldown) and tonumber(cooldown) > 0 then + spellEntry._hmgtExternalBaseCd = tonumber(cooldown) + end + + self:TriggerTrackerUpdate("trackers") + return true +end + +function HMGT:ApplyExternalSpecInfo(sourceName, playerName, class, specId, talentHash) + local source = tostring(sourceName or "External") + local normalizedName = self:NormalizePlayerName(playerName) + local spec = tonumber(specId) + local classToken = class and tostring(class) or self:GetClassTokenForSpecId(spec) + if not normalizedName or normalizedName == "" or not classToken or classToken == "" or not spec or spec <= 0 then + return false, "invalid_args" + end + if not self:IsPlayerInCurrentGroup(normalizedName) then + return false, "not_in_group" + end + + self:RegisterExternalAddonSource(source) + local previous = self.playerData[normalizedName] or {} + local knownSpells = previous.knownSpells + if type(knownSpells) ~= "table" then + knownSpells = {} + end + + if HMGT_SpellData and type(HMGT_SpellData.GetSpellsForSpec) == "function" then + for _, datasetName in ipairs({ "Interrupts", "RaidCooldowns", "GroupCooldowns" }) do + local dataset = HMGT_SpellData[datasetName] + for _, spellEntry in ipairs(HMGT_SpellData.GetSpellsForSpec(classToken, spec, dataset)) do + local sid = tonumber(spellEntry and spellEntry.spellId) + if sid and sid > 0 then + knownSpells[sid] = true + end + end + end + end + + self.playerData[normalizedName] = { + class = classToken, + specIndex = spec, + talentHash = talentHash or previous.talentHash, + talents = self:ParseTalentHash(talentHash or previous.talentHash), + knownSpells = knownSpells, + externalSource = source, + } + + self:PruneAvailabilityStates(normalizedName, knownSpells) + self:TriggerTrackerUpdate("trackers") + return true +end + +function HMGT:ApplyExternalCooldown(sourceName, playerName, spellId, cooldown) + local source = tostring(sourceName or "External") + local normalizedName = self:NormalizePlayerName(playerName) + local sid = tonumber(spellId) + local cd = tonumber(cooldown) + if not normalizedName or normalizedName == "" or not sid or sid <= 0 or not cd or cd <= 0 then + return false, "invalid_args" + end + if not self:IsPlayerInCurrentGroup(normalizedName) then + return false, "not_in_group" + end + + local spellEntry, canonicalSid = self:GetCanonicalExternalSpellEntry(sid) + if not spellEntry or not canonicalSid or canonicalSid <= 0 then + return false, "unknown_spell" + end + sid = canonicalSid + + self:RegisterExternalAddonSource(source) + self:ApplyExternalKnownSpell(source, normalizedName, sid, nil, cd) + self:HandleRemoteSpellCast(normalizedName, sid, GetServerTime(), nil, nil, nil, cd) + return true +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 diff --git a/Modules/Tracker/TrackerCore.lua b/Modules/Tracker/TrackerCore.lua new file mode 100644 index 0000000..39fad12 --- /dev/null +++ b/Modules/Tracker/TrackerCore.lua @@ -0,0 +1,404 @@ +local ADDON_NAME = "HailMaryGuildTools" +local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) +if not HMGT then return end + +HMGT.TrackerCore = HMGT.TrackerCore or {} + +local function EntryNeedsVisualTicker(entry) + if type(entry) ~= "table" then + return false + end + + local remaining = tonumber(entry.remaining) or 0 + if remaining > 0 then + return true + end + + local maxCharges = tonumber(entry.maxCharges) or 0 + local currentCharges = tonumber(entry.currentCharges) + if maxCharges > 0 and currentCharges ~= nil and currentCharges < maxCharges then + return true + end + + return false +end + +function HMGT:IsGroupTrackerConfig(tracker) + return type(tracker) == "table" and tracker.trackerType == "group" +end + +function HMGT:GetTrackerSpellPool(categories) + if HMGT_SpellData and type(HMGT_SpellData.GetSpellPoolForCategories) == "function" then + return HMGT_SpellData.GetSpellPoolForCategories(categories) + end + return {} +end + +function HMGT:GetTrackerSpellsForPlayer(classToken, specIndex, categories) + if HMGT_SpellData and type(HMGT_SpellData.GetSpellsForCategories) == "function" then + return HMGT_SpellData.GetSpellsForCategories(classToken, specIndex, categories) + end + return {} +end + +function HMGT:GetTrackerPlayers(tracker) + local players = {} + + local ownName = self:NormalizePlayerName(UnitName("player")) + local ownClass = select(2, UnitClass("player")) + local includeOwnPlayer = true + if self:IsGroupTrackerConfig(tracker) then + includeOwnPlayer = tracker.includeSelfFrame == true + end + if includeOwnPlayer then + players[#players + 1] = { + name = ownName, + class = ownClass, + isOwn = true, + unitId = "player", + } + end + + if IsInRaid() then + for i = 1, GetNumGroupMembers() do + local unitId = "raid" .. i + local name = self:NormalizePlayerName(UnitName(unitId)) + local class = select(2, UnitClass(unitId)) + if name and name ~= ownName then + players[#players + 1] = { + name = name, + class = class, + unitId = unitId, + } + end + end + elseif IsInGroup() then + for i = 1, GetNumGroupMembers() - 1 do + local unitId = "party" .. i + local name = self:NormalizePlayerName(UnitName(unitId)) + local class = select(2, UnitClass(unitId)) + if name and name ~= ownName then + players[#players + 1] = { + name = name, + class = class, + unitId = unitId, + } + end + end + end + + return players +end + +function HMGT:CollectTrackerEntriesForPlayer(tracker, playerInfo) + local entries = {} + if type(tracker) ~= "table" or type(playerInfo) ~= "table" then + return entries + end + + local playerName = playerInfo.name + if not playerName then + return entries + end + + local pData = self.playerData[playerName] + local classToken = pData and pData.class or playerInfo.class + if not classToken then + return entries + end + + local specIndex + if playerInfo.isOwn then + specIndex = GetSpecialization() + if not specIndex or specIndex == 0 then + return entries + end + else + specIndex = pData and pData.specIndex or nil + if not specIndex or tonumber(specIndex) <= 0 then + return entries + end + end + + local talents = pData and pData.talents or {} + local spells = self:GetTrackerSpellsForPlayer(classToken, specIndex, tracker.categories) + for _, spellEntry in ipairs(spells) do + if tracker.enabledSpells[spellEntry.spellId] ~= false then + local remaining, total, currentCharges, maxCharges = self:GetCooldownInfo(playerName, spellEntry.spellId) + local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) + local isAvailabilitySpell = self:IsAvailabilitySpell(spellEntry) + local include = self:ShouldDisplayEntry(tracker, remaining, currentCharges, maxCharges, spellEntry) + local spellKnown = self:IsTrackedSpellKnownForPlayer(playerName, spellEntry.spellId) + local hasPartialCharges = (tonumber(maxCharges) or 0) > 0 + and (tonumber(currentCharges) or tonumber(maxCharges) or 0) < (tonumber(maxCharges) or 0) + local hasActiveCd = ((remaining or 0) > 0) or hasPartialCharges + + if not spellKnown and not hasActiveCd then + include = false + end + if isAvailabilitySpell and not spellKnown then + include = false + end + if not playerInfo.isOwn and isAvailabilitySpell and not self:HasAvailabilityState(playerName, spellEntry.spellId) then + include = false + end + + if include then + entries[#entries + 1] = { + playerName = playerName, + class = classToken, + spellEntry = spellEntry, + remaining = remaining, + total = total > 0 and total or effectiveCd, + currentCharges = currentCharges, + maxCharges = maxCharges, + } + end + end + end + + return entries +end + +local function CopyEntriesForPreview(entries, playerName) + local copies = {} + for _, entry in ipairs(entries or {}) do + local nextEntry = {} + for key, value in pairs(entry) do + nextEntry[key] = value + end + nextEntry.playerName = playerName + copies[#copies + 1] = nextEntry + end + return copies +end + +function HMGT:BuildPartyPreviewEntries(entries, resolveUnitAnchorFrame) + local byPlayer = {} + local order = {} + local unitByPlayer = {} + for index = 1, 4 do + local unitId = "party" .. index + if not resolveUnitAnchorFrame or resolveUnitAnchorFrame(unitId) then + local playerName = string.format("Party %d", index) + local playerEntries = CopyEntriesForPreview(entries, playerName) + if #playerEntries > 0 then + byPlayer[playerName] = playerEntries + order[#order + 1] = playerName + unitByPlayer[playerName] = unitId + end + end + end + return byPlayer, order, unitByPlayer, #order > 0 +end + +function HMGT:CollectTrackerEntries(tracker) + local entries = {} + local players = self:GetTrackerPlayers(tracker) + for _, playerInfo in ipairs(players) do + local playerEntries = self:CollectTrackerEntriesForPlayer(tracker, playerInfo) + for _, entry in ipairs(playerEntries) do + entries[#entries + 1] = entry + end + end + return entries +end + +function HMGT:CollectTrackerTestEntries(tracker) + local playerName = self:NormalizePlayerName(UnitName("player")) or "Player" + local classToken = select(2, UnitClass("player")) + if not classToken then + return {} + end + + local entries = {} + local pData = self.playerData[playerName] + local talents = pData and pData.talents or {} + local spells = self:GetTrackerSpellsForPlayer(classToken, GetSpecialization() or 0, tracker.categories) + for _, spellEntry in ipairs(spells) do + if tracker.enabledSpells[spellEntry.spellId] ~= false then + local remaining, total, currentCharges, maxCharges = self:GetCooldownInfo(playerName, spellEntry.spellId) + 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(currentCharges) 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 = currentCharges, + maxCharges = maxCharges, + } + end + end + end + return entries +end + +function HMGT:BuildEntriesForTracker(tracker, trackerKey) + local key = trackerKey or tostring(tonumber(tracker and tracker.id) or 0) + if tracker and tracker.testMode then + return self:CollectTrackerTestEntries(tracker), true + end + if tracker and tracker.demoMode then + return self:GetDemoEntries(key, self:GetTrackerSpellPool(tracker.categories), tracker), true + end + if not tracker or not tracker.enabled or not self:IsVisibleForCurrentGroup(tracker) then + return {}, false + end + return self:CollectTrackerEntries(tracker), true +end + +function HMGT:BuildEntriesByPlayerForTracker(tracker, trackerKey, resolveUnitAnchorFrame) + local key = trackerKey or tostring(tonumber(tracker and tracker.id) or 0) + local ownName = self:NormalizePlayerName(UnitName("player")) or "Player" + if tracker.testMode then + local entries = self:CollectTrackerTestEntries(tracker) + if self:IsGroupTrackerConfig(tracker) and tracker.attachToPartyFrame == true then + return self:BuildPartyPreviewEntries(entries, resolveUnitAnchorFrame) + end + local byPlayer, order, unitByPlayer = {}, {}, {} + if #entries > 0 then + byPlayer[ownName] = entries + order[1] = ownName + unitByPlayer[ownName] = "player" + end + return byPlayer, order, unitByPlayer, true + end + if tracker.demoMode then + local entries = self:GetDemoEntries(key, self:GetTrackerSpellPool(tracker.categories), tracker) + if self:IsGroupTrackerConfig(tracker) and tracker.attachToPartyFrame == true then + return self:BuildPartyPreviewEntries(entries, resolveUnitAnchorFrame) + end + for _, entry in ipairs(entries) do + entry.playerName = ownName + end + local byPlayer, order, unitByPlayer = {}, {}, {} + if #entries > 0 then + byPlayer[ownName] = entries + order[1] = ownName + unitByPlayer[ownName] = "player" + end + return byPlayer, order, unitByPlayer, true + end + if not tracker.enabled or not self:IsVisibleForCurrentGroup(tracker) then + return {}, {}, {}, false + end + if IsInRaid() or not IsInGroup() then + return {}, {}, {}, false + end + local byPlayer, order, unitByPlayer = {}, {}, {} + for _, playerInfo in ipairs(self:GetTrackerPlayers(tracker)) do + local entries = self:CollectTrackerEntriesForPlayer(tracker, playerInfo) + if #entries > 0 then + local playerName = playerInfo.name + byPlayer[playerName] = entries + order[#order + 1] = playerName + unitByPlayer[playerName] = playerInfo.unitId + end + end + return byPlayer, order, unitByPlayer, true +end + +function HMGT:FinalizeTrackerEntries(tracker, entries, trackerKey) + local result = entries or {} + if self.FilterDisplayEntries then + result = self:FilterDisplayEntries(tracker, result) or result + end + if self.SortDisplayEntries then + self:SortDisplayEntries(result, trackerKey) + end + + local shouldTick = false + for _, entry in ipairs(result) do + if EntryNeedsVisualTicker(entry) then + shouldTick = true + break + end + end + + return result, shouldTick +end + +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 self._trackerUpdatePending then + self:TriggerTrackerUpdate() + end + end, delay) +end diff --git a/Modules/Tracker/TrackerDataProvider.lua b/Modules/Tracker/TrackerDataProvider.lua new file mode 100644 index 0000000..e5d58f2 --- /dev/null +++ b/Modules/Tracker/TrackerDataProvider.lua @@ -0,0 +1,268 @@ +local ADDON_NAME = "HailMaryGuildTools" +local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) +if not HMGT then return end + +HMGT.TrackerDataProvider = HMGT.TrackerDataProvider or {} + +local internals = HMGT.TrackerInternals or {} +local SafeApiNumber = internals.SafeApiNumber +local GetSpellChargesInfo = internals.GetSpellChargesInfo +local GetSpellCooldownInfo = internals.GetSpellCooldownInfo + +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:GetActiveCooldown(playerName, spellId) + + if isOwnPlayer and not (InCombatLockdown and InCombatLockdown()) then + local charges, maxCharges, chargeStart, chargeDuration = nil, nil, nil, nil + if GetSpellChargesInfo then + charges, maxCharges, chargeStart, chargeDuration = GetSpellChargesInfo(spellId) + end + charges = SafeApiNumber and SafeApiNumber(charges, 0) or tonumber(charges) or 0 + maxCharges = SafeApiNumber and SafeApiNumber(maxCharges, 0) or tonumber(maxCharges) or 0 + chargeStart = SafeApiNumber and SafeApiNumber(chargeStart) or tonumber(chargeStart) + chargeDuration = SafeApiNumber and SafeApiNumber(chargeDuration, 0) or tonumber(chargeDuration) 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) + if (curCharges or 0) < maxChargeCount and remaining <= 0 and GetSpellCooldownInfo 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 + + if GetSpellCooldownInfo then + 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 + 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 diff --git a/Modules/Tracker/TrackerDetection.lua b/Modules/Tracker/TrackerDetection.lua new file mode 100644 index 0000000..11ec4ff --- /dev/null +++ b/Modules/Tracker/TrackerDetection.lua @@ -0,0 +1,524 @@ +local ADDON_NAME = "HailMaryGuildTools" +local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) +if not HMGT then return end + +HMGT.TrackerDetection = HMGT.TrackerDetection or {} + +local internals = HMGT.TrackerInternals or {} +local GetSpellChargesInfo = internals.GetSpellChargesInfo +local GetSpellCooldownInfo = internals.GetSpellCooldownInfo +local GetGlobalCooldownInfo = internals.GetGlobalCooldownInfo +local GetSpellDebugLabel = internals.GetSpellDebugLabel +local BuildCooldownStateFingerprint = internals.BuildCooldownStateFingerprint +local ApplyOwnCooldownReducers = internals.ApplyOwnCooldownReducers +local ApplyObservedCooldownReducers = internals.ApplyObservedCooldownReducers + +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] + spellId = tonumber(spellEntry and spellEntry.spellId) or 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 and GetSpellChargesInfo 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:GetActiveCooldown(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:SetActiveCooldown(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:GetActiveCooldown(name, spellId) + if current and current._nonce == nonce then + self:ClearActiveCooldown(name, spellId) + self:PublishOwnSpellState(spellId) + self:TriggerTrackerUpdate() + end + end, expiresIn) + end + + self:PublishOwnSpellState(spellId, { sendLegacy = true }) + self:TriggerTrackerUpdate() +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 + sid = tonumber(spellEntry.spellId) or sid + + local existing = self:GetActiveCooldown(ownName, sid) + local before = BuildCooldownStateFingerprint and BuildCooldownStateFingerprint(existing) or "nil" + 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 = nil, nil, nil, nil + if GetSpellChargesInfo then + cur, max, chargeStart, chargeDuration = GetSpellChargesInfo(sid) + end + 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 + local updatedEntry = self:SetActiveCooldown(ownName, sid, { + startTime = chargeStartTime, + duration = missing * chargeDur, + spellEntry = spellEntry, + currentCharges = currentCharges, + maxCharges = maxCharges, + chargeStart = chargeStartTime, + chargeDuration = chargeDur, + }) + self:RefreshCooldownExpiryTimer(ownName, sid, updatedEntry) + else + self:ClearActiveCooldown(ownName, sid) + end + else + local cooldownStart, cooldownDuration = 0, 0 + if GetSpellCooldownInfo then + cooldownStart, cooldownDuration = GetSpellCooldownInfo(sid) + end + cooldownStart = tonumber(cooldownStart) or 0 + cooldownDuration = tonumber(cooldownDuration) or 0 + local gcdStart, gcdDuration = 0, 0 + if GetGlobalCooldownInfo then + gcdStart, gcdDuration = GetGlobalCooldownInfo() + end + 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 and GetSpellDebugLabel(sid) or tostring(sid), + cooldownDuration, + gcdDuration, + existingDuration, + existingRemaining, + tonumber(effectiveCd) or 0 + ) + return false + end + + if cooldownDuration > 0 then + local updatedEntry = self:SetActiveCooldown(ownName, sid, { + startTime = cooldownStart, + duration = cooldownDuration, + spellEntry = spellEntry, + }) + self:RefreshCooldownExpiryTimer(ownName, sid, updatedEntry) + else + self:ClearActiveCooldown(ownName, sid) + end + end + + local after = BuildCooldownStateFingerprint and BuildCooldownStateFingerprint(self:GetActiveCooldown(ownName, sid)) or "nil" + return before ~= after +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 + sid = tonumber(spellEntry.spellId) or sid + + local observedDuration = 0 + if GetSpellCooldownInfo then + local _, duration = GetSpellCooldownInfo(sid) + observedDuration = duration + end + 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 + + 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 and ApplyOwnCooldownReducers then + ApplyOwnCooldownReducers(self, ownName, triggerSpellId, instantReducers, castTs) + end + if #observedInstantReducers > 0 and ApplyObservedCooldownReducers 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 and ApplyOwnCooldownReducers then + ApplyOwnCooldownReducers(self, ownName, triggerSpellId, successReducers, castTs) + end + if #observedSuccessReducers > 0 and ApplyObservedCooldownReducers 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: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 + spellId = tonumber(spellEntry.spellId) or spellId + 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:GetActiveCooldown(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:GetActiveCooldown(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:SetActiveCooldown(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:GetActiveCooldown(playerName, spellId) + if current and current._nonce == nonce then + self:ClearActiveCooldown(playerName, spellId) + self:TriggerTrackerUpdate() + end + end, expiresIn) + end + + self:TriggerTrackerUpdate() +end diff --git a/Modules/Tracker/TrackerManager.lua b/Modules/Tracker/TrackerManager.lua index cfdb467..5071cc3 100644 --- a/Modules/Tracker/TrackerManager.lua +++ b/Modules/Tracker/TrackerManager.lua @@ -94,25 +94,6 @@ local PARTY_FRAME_PATTERNS = { local unitFrameCache = {} -local function EntryNeedsVisualTicker(entry) - if type(entry) ~= "table" then - return false - end - - local remaining = tonumber(entry.remaining) or 0 - if remaining > 0 then - return true - end - - local maxCharges = tonumber(entry.maxCharges) or 0 - local currentCharges = tonumber(entry.currentCharges) - if maxCharges > 0 and currentCharges ~= nil and currentCharges < maxCharges then - return true - end - - return false -end - local function BuildAnchorLayoutSignature(settings, ordered, unitByPlayer) local parts = { settings.attachToPartyFrame == true and "attach" or "stack", @@ -222,55 +203,6 @@ local function ResolveUnitAnchorFrame(unitId) return nil end -local function GetGroupPlayers(tracker) - local players = {} - - local ownName = HMGT:NormalizePlayerName(UnitName("player")) - local ownClass = select(2, UnitClass("player")) - local includeOwnPlayer = true - if IsGroupTracker(tracker) then - includeOwnPlayer = tracker.includeSelfFrame == true - end - if includeOwnPlayer then - players[#players + 1] = { - name = ownName, - class = ownClass, - isOwn = true, - unitId = "player", - } - end - - if IsInRaid() then - for i = 1, GetNumGroupMembers() do - local unitId = "raid" .. i - local name = HMGT:NormalizePlayerName(UnitName(unitId)) - local class = select(2, UnitClass(unitId)) - if name and name ~= ownName then - players[#players + 1] = { - name = name, - class = class, - unitId = unitId, - } - end - end - elseif IsInGroup() then - for i = 1, GetNumGroupMembers() - 1 do - local unitId = "party" .. i - local name = HMGT:NormalizePlayerName(UnitName(unitId)) - local class = select(2, UnitClass(unitId)) - if name and name ~= ownName then - players[#players + 1] = { - name = name, - class = class, - unitId = unitId, - } - end - end - end - - return players -end - local function GetTrackerLabel(tracker) if type(tracker) ~= "table" then return "Tracker" @@ -287,136 +219,6 @@ local function GetTrackerLabel(tracker) return "Tracker" end -local function GetTrackerSpellPool(categories) - if HMGT_SpellData and type(HMGT_SpellData.GetSpellPoolForCategories) == "function" then - return HMGT_SpellData.GetSpellPoolForCategories(categories) - end - return {} -end - -local function GetTrackerSpellsForPlayer(classToken, specIndex, categories) - if HMGT_SpellData and type(HMGT_SpellData.GetSpellsForCategories) == "function" then - return HMGT_SpellData.GetSpellsForCategories(classToken, specIndex, categories) - end - return {} -end - -local function CollectEntriesForPlayer(tracker, playerInfo) - local entries = {} - if type(tracker) ~= "table" or type(playerInfo) ~= "table" then - return entries - end - - local playerName = playerInfo.name - if not playerName then - return entries - end - - local pData = HMGT.playerData[playerName] - local classToken = pData and pData.class or playerInfo.class - if not classToken then - return entries - end - - local specIndex - if playerInfo.isOwn then - specIndex = GetSpecialization() - if not specIndex or specIndex == 0 then - return entries - end - else - specIndex = pData and pData.specIndex or nil - if not specIndex or tonumber(specIndex) <= 0 then - return entries - end - end - - local talents = pData and pData.talents or {} - local spells = GetTrackerSpellsForPlayer(classToken, specIndex, tracker.categories) - for _, spellEntry in ipairs(spells) do - if tracker.enabledSpells[spellEntry.spellId] ~= false then - local remaining, total, currentCharges, maxCharges = HMGT:GetCooldownInfo(playerName, spellEntry.spellId) - local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) - local isAvailabilitySpell = HMGT:IsAvailabilitySpell(spellEntry) - local include = HMGT:ShouldDisplayEntry(tracker, remaining, currentCharges, maxCharges, spellEntry) - local spellKnown = HMGT:IsTrackedSpellKnownForPlayer(playerName, spellEntry.spellId) - local hasPartialCharges = (tonumber(maxCharges) or 0) > 0 - and (tonumber(currentCharges) or tonumber(maxCharges) or 0) < (tonumber(maxCharges) or 0) - local hasActiveCd = ((remaining or 0) > 0) or hasPartialCharges - - if not spellKnown and not hasActiveCd then - include = false - end - if isAvailabilitySpell and not spellKnown then - include = false - end - if not playerInfo.isOwn and isAvailabilitySpell and not HMGT:HasAvailabilityState(playerName, spellEntry.spellId) then - include = false - end - - if include then - entries[#entries + 1] = { - playerName = playerName, - class = classToken, - spellEntry = spellEntry, - remaining = remaining, - total = total > 0 and total or effectiveCd, - currentCharges = currentCharges, - maxCharges = maxCharges, - } - end - end - end - - return entries -end - -local function CopyEntriesForPreview(entries, playerName) - local copies = {} - for _, entry in ipairs(entries or {}) do - local nextEntry = {} - for key, value in pairs(entry) do - nextEntry[key] = value - end - nextEntry.playerName = playerName - copies[#copies + 1] = nextEntry - end - return copies -end - -local function GetAvailablePartyPreviewUnits() - local units = {} - for index = 1, 4 do - local unitId = "party" .. index - if ResolveUnitAnchorFrame(unitId) then - units[#units + 1] = { - playerName = string.format("Party %d", index), - unitId = unitId, - } - end - end - return units -end - -local function BuildPartyPreviewEntries(entries) - local byPlayer = {} - local order = {} - local unitByPlayer = {} - local previewUnits = GetAvailablePartyPreviewUnits() - - for _, previewUnit in ipairs(previewUnits) do - local playerName = previewUnit.playerName - local playerEntries = CopyEntriesForPreview(entries, playerName) - if #playerEntries > 0 then - byPlayer[playerName] = playerEntries - order[#order + 1] = playerName - unitByPlayer[playerName] = previewUnit.unitId - end - end - - return byPlayer, order, unitByPlayer, #order > 0 -end - local function SortTrackers(trackers) table.sort(trackers, function(a, b) local aId = tonumber(a and a.id) or 0 @@ -467,13 +269,7 @@ Manager._shared.ShortName = ShortName Manager._shared.BuildAnchorLayoutSignature = BuildAnchorLayoutSignature Manager._shared.IsGroupTracker = IsGroupTracker Manager._shared.ResolveUnitAnchorFrame = ResolveUnitAnchorFrame -Manager._shared.GetGroupPlayers = GetGroupPlayers Manager._shared.GetTrackerLabel = GetTrackerLabel -Manager._shared.GetTrackerSpellPool = GetTrackerSpellPool -Manager._shared.GetTrackerSpellsForPlayer = GetTrackerSpellsForPlayer -Manager._shared.CollectEntriesForPlayer = CollectEntriesForPlayer -Manager._shared.BuildPartyPreviewEntries = BuildPartyPreviewEntries -Manager._shared.EntryNeedsVisualTicker = EntryNeedsVisualTicker Manager._shared.BuildGroupDisplaySignature = BuildGroupDisplaySignature function Manager:GetTrackers() @@ -492,6 +288,13 @@ function Manager:GetTrackers() return self._trackerCache end +function Manager:GetTrackerFrameKey(tracker) + if type(tracker) == "table" then + return GetTrackerFrameKey(tracker.id) + end + return GetTrackerFrameKey(tracker) +end + function Manager:MarkTrackersDirty() self._trackerCache = nil self._trackerCacheSignature = nil @@ -648,12 +451,8 @@ function Manager:RefreshVisibleVisuals() break end local entries = byPlayer[playerName] or {} - if HMGT.FilterDisplayEntries then - entries = HMGT:FilterDisplayEntries(tracker, entries) or entries - end - if HMGT.SortDisplayEntries then - HMGT:SortDisplayEntries(entries) - end + local tickThis = false + entries, tickThis = HMGT:FinalizeTrackerEntries(tracker, entries, tracker.trackerKey or nil) if #entries == 0 then needsFullRefresh = true break @@ -666,11 +465,8 @@ function Manager:RefreshVisibleVisuals() HMGT.TrackerFrame:UpdateFrame(frame, entries, true) totalEntries = totalEntries + #entries byPlayerFiltered[playerName] = entries - for _, entry in ipairs(entries) do - if EntryNeedsVisualTicker(entry) then - shouldTick = true - break - end + if tickThis then + shouldTick = true end end local newSignature = BuildGroupDisplaySignature(currentOrder, byPlayerFiltered) @@ -680,36 +476,29 @@ function Manager:RefreshVisibleVisuals() end end else - local frame = self.frames[frameKey] - if frame and frame:IsShown() then - local entries, shouldShow = self:BuildEntriesForTracker(tracker) - if not shouldShow then - needsFullRefresh = true - else - if HMGT.FilterDisplayEntries then - entries = HMGT:FilterDisplayEntries(tracker, entries) or entries - end - if HMGT.SortDisplayEntries then - HMGT:SortDisplayEntries(entries) - end - if #entries == 0 then + local frame = self.frames[frameKey] + if frame and frame:IsShown() then + local entries, shouldShow = self:BuildEntriesForTracker(tracker) + if not shouldShow then needsFullRefresh = true else + local tickThis = false + entries, tickThis = HMGT:FinalizeTrackerEntries(tracker, entries, tracker.trackerKey or nil) + if #entries == 0 then + needsFullRefresh = true + else HMGT.TrackerFrame:UpdateFrame(frame, entries, true) totalEntries = totalEntries + #entries local newSignature = BuildNormalDisplaySignature(true, entries) - if self._displaySignatures[frameKey] ~= newSignature then - needsFullRefresh = true - end - for _, entry in ipairs(entries) do - if EntryNeedsVisualTicker(entry) then + if self._displaySignatures[frameKey] ~= newSignature then + needsFullRefresh = true + end + if tickThis then shouldTick = true - break end end end end - end end end @@ -751,12 +540,8 @@ function Manager:UpdateDisplay() local entries, shouldShow = self:BuildEntriesForTracker(tracker) if shouldShow then - if HMGT.FilterDisplayEntries then - entries = HMGT:FilterDisplayEntries(tracker, entries) or entries - end - if HMGT.SortDisplayEntries then - HMGT:SortDisplayEntries(entries) - end + local tickThis = false + entries, tickThis = HMGT:FinalizeTrackerEntries(tracker, entries, tracker.trackerKey or nil) HMGT.TrackerFrame:UpdateFrame(frame, entries, true) frame:Show() @@ -769,11 +554,8 @@ function Manager:UpdateDisplay() layoutDirty = true end - for _, entry in ipairs(entries) do - if EntryNeedsVisualTicker(entry) then - shouldTick = true - break - end + if tickThis then + shouldTick = true end else frame:Hide() diff --git a/Modules/Tracker/TrackerPlayerState.lua b/Modules/Tracker/TrackerPlayerState.lua new file mode 100644 index 0000000..cc22fe3 --- /dev/null +++ b/Modules/Tracker/TrackerPlayerState.lua @@ -0,0 +1,65 @@ +local ADDON_NAME = "HailMaryGuildTools" +local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) +if not HMGT then return end + +HMGT.TrackerPlayerState = HMGT.TrackerPlayerState or {} + +local internals = HMGT.TrackerInternals or {} +local IsSpellKnownLocally = internals.IsSpellKnownLocally + +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 and IsSpellKnownLocally(sid) then + knownSpells[sid] = true + end + end + end + end + + local ownName = self:NormalizePlayerName(UnitName("player")) + local ownCDs = ownName and self:GetPlayerCooldownMap(ownName, false) + 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 and IsSpellKnownLocally then + return IsSpellKnownLocally(sid) + end + + return false +end diff --git a/Modules/Tracker/TrackerState.lua b/Modules/Tracker/TrackerState.lua new file mode 100644 index 0000000..e3863e0 --- /dev/null +++ b/Modules/Tracker/TrackerState.lua @@ -0,0 +1,410 @@ +local ADDON_NAME = "HailMaryGuildTools" +local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) +if not HMGT then return end + +HMGT.TrackerState = HMGT.TrackerState or {} + +function HMGT:EnsureTrackerStateTables() + self.playerData = self.playerData or {} + self.activeCDs = self.activeCDs or {} + self.availabilityStates = self.availabilityStates or {} + self.localSpellStateRevisions = self.localSpellStateRevisions or {} + self.remoteSpellStateRevisions = self.remoteSpellStateRevisions or {} + self.knownChargeInfo = self.knownChargeInfo or {} +end + +function HMGT:ResetTrackerState() + self.playerData = {} + self.activeCDs = {} + self.availabilityStates = {} + self.localSpellStateRevisions = {} + self.remoteSpellStateRevisions = {} + self.knownChargeInfo = {} +end + +function HMGT:GetPlayerCooldownMap(playerName, create) + local normalizedName = self:NormalizePlayerName(playerName) + if not normalizedName then + return nil + end + self:EnsureTrackerStateTables() + if create then + self.activeCDs[normalizedName] = self.activeCDs[normalizedName] or {} + end + return self.activeCDs[normalizedName] +end + +function HMGT:GetAvailabilityStateMap(playerName, create) + local normalizedName = self:NormalizePlayerName(playerName) + if not normalizedName then + return nil + end + self:EnsureTrackerStateTables() + if create then + self.availabilityStates[normalizedName] = self.availabilityStates[normalizedName] or {} + end + return self.availabilityStates[normalizedName] +end + +function HMGT:GetAvailabilityStateEntry(playerName, spellId) + local sid = tonumber(spellId) + local states = self:GetAvailabilityStateMap(playerName, false) + return states and sid and states[sid] or nil +end + +function HMGT:SetAvailabilityStateEntry(playerName, spellId, stateData) + local sid = tonumber(spellId) + if not sid or sid <= 0 or type(stateData) ~= "table" then + return nil + end + local states = self:GetAvailabilityStateMap(playerName, true) + if not states then + return nil + end + states[sid] = stateData + return stateData +end + +function HMGT:ClearAvailabilityState(playerName, spellId) + local sid = tonumber(spellId) + local normalizedName = self:NormalizePlayerName(playerName) + if not normalizedName or not sid or sid <= 0 then + return false + end + + local states = self.availabilityStates and self.availabilityStates[normalizedName] + if not states or not states[sid] then + return false + end + + states[sid] = nil + if not next(states) then + self.availabilityStates[normalizedName] = nil + end + return true +end + +function HMGT:GetActiveCooldown(playerName, spellId) + local sid = tonumber(spellId) + local cooldowns = self:GetPlayerCooldownMap(playerName, false) + return cooldowns and sid and cooldowns[sid] or nil +end + +function HMGT:SetActiveCooldown(playerName, spellId, cdData) + local sid = tonumber(spellId) + if not sid or sid <= 0 or type(cdData) ~= "table" then + return nil + end + local cooldowns = self:GetPlayerCooldownMap(playerName, true) + if not cooldowns then + return nil + end + cooldowns[sid] = cdData + return cdData +end + +function HMGT:ClearActiveCooldown(playerName, spellId) + local sid = tonumber(spellId) + local normalizedName = self:NormalizePlayerName(playerName) + if not normalizedName or not sid or sid <= 0 then + return false + end + + local cooldowns = self.activeCDs and self.activeCDs[normalizedName] + if not cooldowns or not cooldowns[sid] then + return false + end + + cooldowns[sid] = nil + if not next(cooldowns) then + self.activeCDs[normalizedName] = nil + end + return true +end + +function HMGT:ClearPlayerCooldowns(playerName) + local normalizedName = self:NormalizePlayerName(playerName) + if not normalizedName then + return false + end + if self.activeCDs and self.activeCDs[normalizedName] then + self.activeCDs[normalizedName] = nil + return true + end + return false +end + +function HMGT:GetLocalSpellStateRevision(spellId) + local sid = tonumber(spellId) + if not sid or sid <= 0 then + return 0 + end + self:EnsureTrackerStateTables() + 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 + self:EnsureTrackerStateTables() + 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 + self:EnsureTrackerStateTables() + 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:EnsureTrackerStateTables() + self.remoteSpellStateRevisions[normalizedName] = self.remoteSpellStateRevisions[normalizedName] or {} + self.remoteSpellStateRevisions[normalizedName][sid] = rev +end + +function HMGT:ClearRemoteSpellStateRevisions(playerName) + local normalizedName = self:NormalizePlayerName(playerName) + if not normalizedName then + return false + end + if self.remoteSpellStateRevisions and self.remoteSpellStateRevisions[normalizedName] then + self.remoteSpellStateRevisions[normalizedName] = nil + return true + end + return false +end + +function HMGT:ClearTrackerStateForPlayer(playerName) + local normalizedName = self:NormalizePlayerName(playerName) + if not normalizedName then + return false + end + + local changed = false + if self.activeCDs and self.activeCDs[normalizedName] then + self.activeCDs[normalizedName] = nil + changed = true + end + if self.availabilityStates and self.availabilityStates[normalizedName] then + self.availabilityStates[normalizedName] = nil + changed = true + end + if self.remoteSpellStateRevisions and self.remoteSpellStateRevisions[normalizedName] then + self.remoteSpellStateRevisions[normalizedName] = nil + changed = true + end + + return changed +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:EnsureTrackerStateTables() + 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 + +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:ResolveChargeState(cdData, now) + if type(cdData) ~= "table" then + return 0, 0, 0, 0 + end + + now = tonumber(now) or GetTime() + local maxCharges = math.max(0, tonumber(cdData.maxCharges) or 0) + local currentCharges = math.max(0, tonumber(cdData.currentCharges) or 0) + local chargeDuration = math.max(0, tonumber(cdData.chargeDuration) or 0) + local chargeStart = tonumber(cdData.chargeStart) + + if maxCharges <= 0 then + return 0, chargeDuration, currentCharges, maxCharges + end + if currentCharges >= maxCharges or chargeDuration <= 0 or not chargeStart then + return 0, chargeDuration, math.min(currentCharges, maxCharges), maxCharges + end + + local elapsed = math.max(0, now - chargeStart) + local gainedCharges = math.floor(elapsed / chargeDuration) + local remaining = chargeDuration - (elapsed % chargeDuration) + + if gainedCharges > 0 then + currentCharges = math.min(maxCharges, currentCharges + gainedCharges) + if currentCharges >= maxCharges then + currentCharges = maxCharges + chargeStart = nil + remaining = 0 + else + chargeStart = now - (elapsed % chargeDuration) + end + + cdData.currentCharges = currentCharges + cdData.chargeStart = chargeStart + if currentCharges >= maxCharges then + cdData.startTime = now + cdData.duration = 0 + else + local missing = maxCharges - currentCharges + cdData.startTime = chargeStart + cdData.duration = missing * chargeDuration + end + end + + if currentCharges >= maxCharges then + return 0, chargeDuration, currentCharges, maxCharges + end + return math.max(0, remaining), chargeDuration, currentCharges, maxCharges +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:GetActiveCooldown(playerName, spellId) + if current and current._nonce == nonce then + self:ClearActiveCooldown(playerName, spellId) + if playerName == self:NormalizePlayerName(UnitName("player")) then + self:PublishOwnSpellState(spellId) + end + self:TriggerTrackerUpdate() + end + end, expiresIn) + end + return expiresIn +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 diff --git a/Modules/Tracker/TrackerSync.lua b/Modules/Tracker/TrackerSync.lua new file mode 100644 index 0000000..4823ff6 --- /dev/null +++ b/Modules/Tracker/TrackerSync.lua @@ -0,0 +1,1041 @@ +local ADDON_NAME = "HailMaryGuildTools" +local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) +if not HMGT then return end + +HMGT.TrackerSync = HMGT.TrackerSync or {} + +local internals = HMGT.TrackerInternals or {} +local GetSpellChargesInfo = internals.GetSpellChargesInfo +local GetSpellDebugLabel = internals.GetSpellDebugLabel + +local MSG_SPELL_CAST = HMGT.MSG_SPELL_CAST +local MSG_CD_REDUCE = HMGT.MSG_CD_REDUCE +local MSG_SPELL_STATE = HMGT.MSG_SPELL_STATE +local MSG_HELLO = HMGT.MSG_HELLO +local MSG_PLAYER_INFO = HMGT.MSG_PLAYER_INFO +local MSG_SYNC_REQUEST = HMGT.MSG_SYNC_REQUEST +local MSG_SYNC_RESPONSE = HMGT.MSG_SYNC_RESPONSE +local MSG_RELIABLE = HMGT.MSG_RELIABLE +local MSG_ACK = HMGT.MSG_ACK +local COMM_PREFIX = HMGT.COMM_PREFIX +local ADDON_VERSION = HMGT.ADDON_VERSION or "dev" +local PROTOCOL_VERSION = HMGT.PROTOCOL_VERSION or 0 + +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: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:GetActiveCooldown(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 and GetSpellDebugLabel(sid) or tostring(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:GetPlayerCooldownMap(ownName, false) + 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:GetAvailabilityStateMap(ownName, false) + 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: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()) and GetSpellChargesInfo 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 and GetSpellDebugLabel(spellId) or tostring(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() + local ownCooldowns = self:GetPlayerCooldownMap(name, false) + if ownCooldowns then + for spellId, cdInfo in pairs(ownCooldowns) 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:GetClassTokenForSpecId(specId) + local sid = tonumber(specId) + if not sid or sid <= 0 then + return nil + end + + if type(GetSpecializationInfoByID) == "function" then + local returns = { pcall(GetSpecializationInfoByID, sid) } + local ok = returns[1] + local classToken = returns[7] + if ok and type(classToken) == "string" and classToken ~= "" then + return classToken + end + end + + if type(GetSpecializationInfoForClassID) ~= "function" then + return nil + end + + for classID = 1, 20 do + local _, token = GetClassInfo(classID) + if token then + local count = 4 + if type(GetNumSpecializationsForClassID) == "function" then + count = tonumber(GetNumSpecializationsForClassID(classID)) or 4 + end + for index = 1, math.max(1, count) do + local foundSpecId = GetSpecializationInfoForClassID(classID, index) + if tonumber(foundSpecId) == sid then + return token + end + end + end + end + + return nil +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 + if self:ClearActiveCooldown(normalizedName, sid) then + changed = true + end + + if self:ClearAvailabilityState(normalizedName, sid) then + 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 + sid = tonumber(spellEntry.spellId) or sid + + local now = GetTime() + local stateKind = tostring(kind or "") + local changed = false + local shouldLogCast = false + local logDetails = nil + local previousEntry = self:GetActiveCooldown(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) + if self:ClearActiveCooldown(normalizedName, sid) then + 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:SetActiveCooldown(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:SetActiveCooldown(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 and GetSpellDebugLabel(sid) or tostring(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 and GetSpellDebugLabel(spellId) or tostring(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:ClearRemoteSpellStateRevisions(senderName) + 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 + 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:SetActiveCooldown(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 == HMGT.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 == HMGT.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 -- 2.39.5 From 02e062d66be196ac595207641222af27c3b59053 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Sat, 25 Apr 2026 17:33:32 +0200 Subject: [PATCH 5/7] delted old debug window, added new version notice window, added new features to tracker module, updated locales, and updated main addon files. --- Core/DebugWindow.lua | 486 ----------------------- Core/VersionNoticeWindow.lua | 18 +- HailMaryGuildTools.lua | 305 +++++++------- HailMaryGuildTools.toc | 3 +- Locales/deDE.lua | 3 +- Locales/enUS.lua | 3 +- Modules/RaidTimeline/RaidTimelineDBM.lua | 8 - Modules/Tracker/TrackerBridge.lua | 5 + Modules/Tracker/TrackerCore.lua | 174 +++++++- Modules/Tracker/TrackerDetection.lua | 2 +- Modules/Tracker/TrackerOptions.lua | 59 ++- Modules/Tracker/TrackerSync.lua | 16 +- 12 files changed, 401 insertions(+), 681 deletions(-) delete mode 100644 Core/DebugWindow.lua delete mode 100644 Modules/RaidTimeline/RaidTimelineDBM.lua diff --git a/Core/DebugWindow.lua b/Core/DebugWindow.lua deleted file mode 100644 index 59e7a77..0000000 --- a/Core/DebugWindow.lua +++ /dev/null @@ -1,486 +0,0 @@ -local ADDON_NAME = "HailMaryGuildTools" -local HMGT = _G[ADDON_NAME] -if not HMGT then return end - -local L = HMGT.L or LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME) -local AceGUI = LibStub("AceGUI-3.0", true) - -local function GetOrderedDebugLevels() - return { "error", "info", "verbose" } -end - -local function GetOrderedDebugScopes() - local values = HMGT:GetDebugScopeOptions() or {} - local names = { "ALL" } - for scope in pairs(values) do - if scope ~= "ALL" then - names[#names + 1] = scope - end - end - table.sort(names, function(a, b) - if a == "ALL" then return true end - if b == "ALL" then return false end - return tostring(values[a] or a) < tostring(values[b] or b) - end) - return names, values -end - -local function SetFilterButtonText(buttonWidget, prefix, valueLabel) - if not buttonWidget then - return - end - buttonWidget:SetText(string.format("%s: %s", tostring(prefix or ""), tostring(valueLabel or ""))) -end - -local function AdvanceDebugLevel(step) - local levels = GetOrderedDebugLevels() - local current = HMGT:GetConfiguredDebugLevel() - local nextIndex = 1 - for index, value in ipairs(levels) do - if value == current then - nextIndex = index + (step or 1) - break - end - end - if nextIndex < 1 then - nextIndex = #levels - elseif nextIndex > #levels then - nextIndex = 1 - end - HMGT.db.profile.debugLevel = levels[nextIndex] - HMGT:RefreshDebugWindow() -end - -local function AdvanceDebugScope(step) - local scopes, labels = GetOrderedDebugScopes() - local current = (HMGT.db and HMGT.db.profile and HMGT.db.profile.debugScope) or "ALL" - local nextIndex = 1 - for index, value in ipairs(scopes) do - if value == current then - nextIndex = index + (step or 1) - break - end - end - if nextIndex < 1 then - nextIndex = #scopes - elseif nextIndex > #scopes then - nextIndex = 1 - end - HMGT.db.profile.debugScope = scopes[nextIndex] - HMGT:RefreshDebugWindow() -end - -function HMGT:SetDebugWindowMinimized(minimized) - local frame = self.debugWindow - if not frame then - return - end - - minimized = minimized and true or false - self.debugWindowStatus = self.debugWindowStatus or { - width = 860, - height = 340, - } - self.debugWindowStatus.minimized = minimized - - local collapsedHeight = 64 - if minimized then - self.debugWindowStatus.restoreHeight = self.debugWindowStatus.height or frame:GetHeight() or 340 - end - - local targetHeight = minimized - and collapsedHeight - or (self.debugWindowStatus.restoreHeight or self.debugWindowStatus.height or 340) - - if frame.aceWidget then - frame.aceWidget:EnableResize(not minimized) - frame.aceWidget:SetHeight(targetHeight) - else - frame:SetHeight(targetHeight) - end - - if frame.minimizeButton then - frame.minimizeButton:SetText(minimized and "+" or "-") - end - if frame.clearButton then - local buttonFrame = frame.clearButton.frame or frame.clearButton - buttonFrame:SetShown(not minimized) - end - if frame.selectButton then - local buttonFrame = frame.selectButton.frame or frame.selectButton - buttonFrame:SetShown(not minimized) - end - if frame.levelFilter then - local filterFrame = frame.levelFilter.frame or frame.levelFilter - filterFrame:SetShown(not minimized) - end - if frame.scopeFilter then - local filterFrame = frame.scopeFilter.frame or frame.scopeFilter - filterFrame:SetShown(not minimized) - end - if frame.logWidget then - frame.logWidget.frame:SetShown(not minimized) - end - if frame.scrollBG then - frame.scrollBG:SetShown(not minimized) - end - - if not minimized then - self:RefreshDebugWindow() - end -end - -function HMGT:ToggleDebugWindowMinimized() - self:SetDebugWindowMinimized(not (self.debugWindowStatus and self.debugWindowStatus.minimized)) -end - -function HMGT:EnsureDebugWindow() - if self.debugWindow then - return self.debugWindow - end - - local frameWidget - if AceGUI then - frameWidget = AceGUI:Create("Frame") - self.debugWindowStatus = self.debugWindowStatus or { - width = 860, - height = 340, - } - frameWidget:SetTitle(L["DEBUG_WINDOW_TITLE"] or "HMGT Debug Console") - frameWidget:SetStatusText(L["DEBUG_WINDOW_HINT"] or "Mouse wheel scrolls, Ctrl+A selects all, Ctrl+C copies selected text") - frameWidget:SetStatusTable(self.debugWindowStatus) - frameWidget:SetWidth(self.debugWindowStatus.width or 860) - frameWidget:SetHeight(self.debugWindowStatus.height or 340) - frameWidget:EnableResize(true) - frameWidget.frame:SetClampedToScreen(true) - frameWidget.frame:SetToplevel(true) - frameWidget.frame:SetFrameStrata("FULLSCREEN_DIALOG") - frameWidget:Hide() - end - - local frame = frameWidget and frameWidget.frame or CreateFrame("Frame", "HMGT_DebugWindow", UIParent, "BackdropTemplate") - if not frameWidget then - frame:SetSize(860, 340) - frame:SetPoint("CENTER", UIParent, "CENTER", 0, 0) - frame:SetFrameStrata("DIALOG") - frame:SetClampedToScreen(true) - frame:SetMovable(true) - frame:EnableMouse(true) - frame:RegisterForDrag("LeftButton") - frame:SetScript("OnDragStart", function(selfFrame) selfFrame:StartMoving() end) - frame:SetScript("OnDragStop", function(selfFrame) selfFrame:StopMovingOrSizing() end) - frame:SetBackdrop({ - bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark", - edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", - edgeSize = 12, - insets = { left = 3, right = 3, top = 3, bottom = 3 }, - }) - frame:SetBackdropColor(0.05, 0.05, 0.06, 0.95) - frame:SetBackdropBorderColor(0.35, 0.55, 0.85, 1) - frame:Hide() - - local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") - title:SetPoint("TOPLEFT", frame, "TOPLEFT", 14, -12) - title:SetText(L["DEBUG_WINDOW_TITLE"] or "HMGT Debug Console") - frame.title = title - - local closeButton = CreateFrame("Button", nil, frame, "UIPanelCloseButton") - closeButton:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -5, -5) - frame.closeButton = closeButton - - local minimizeButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") - minimizeButton:SetSize(22, 20) - minimizeButton:SetPoint("TOPRIGHT", closeButton, "TOPLEFT", -2, 0) - minimizeButton:SetText((self.debugWindowStatus and self.debugWindowStatus.minimized) and "+" or "-") - minimizeButton:SetScript("OnClick", function() - HMGT:ToggleDebugWindowMinimized() - end) - frame.minimizeButton = minimizeButton - end - - frame.aceWidget = frameWidget - - if frameWidget and AceGUI then - local content = frameWidget.content - - local minimizeButton = AceGUI:Create("Button") - minimizeButton:SetText((self.debugWindowStatus and self.debugWindowStatus.minimized) and "+" or "-") - minimizeButton:SetWidth(24) - minimizeButton:SetCallback("OnClick", function() - HMGT:ToggleDebugWindowMinimized() - end) - minimizeButton.frame:SetParent(frame) - minimizeButton.frame:ClearAllPoints() - minimizeButton.frame:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -34, -4) - minimizeButton.frame:SetHeight(20) - minimizeButton.frame:Show() - frame.minimizeButton = minimizeButton - - local clearButton = AceGUI:Create("Button") - clearButton:SetText(L["OPT_DEBUG_CLEAR"] or "Clear log") - clearButton:SetWidth(120) - clearButton:SetCallback("OnClick", function() - HMGT:ClearDebugLog() - end) - clearButton.frame:SetParent(content) - clearButton.frame:ClearAllPoints() - clearButton.frame:SetPoint("TOPRIGHT", content, "TOPRIGHT", 0, -2) - clearButton.frame:Show() - frame.clearButton = clearButton - - local selectButton = AceGUI:Create("Button") - selectButton:SetText(L["OPT_DEBUG_SELECT_ALL"] or "Select all") - selectButton:SetWidth(120) - selectButton:SetCallback("OnClick", function() - if frame.editBox then - frame.editBox:SetFocus() - frame.editBox:HighlightText(0) - end - end) - selectButton.frame:SetParent(content) - selectButton.frame:ClearAllPoints() - selectButton.frame:SetPoint("TOPRIGHT", clearButton.frame, "TOPLEFT", -6, 0) - selectButton.frame:Show() - frame.selectButton = selectButton - - local levelFilter = AceGUI:Create("Button") - levelFilter:SetWidth(150) - levelFilter:SetCallback("OnClick", function() - AdvanceDebugLevel(1) - end) - levelFilter.frame:SetParent(content) - levelFilter.frame:ClearAllPoints() - levelFilter.frame:SetPoint("TOPLEFT", content, "TOPLEFT", 0, 0) - levelFilter.frame:Show() - frame.levelFilter = levelFilter - - local scopeFilter = AceGUI:Create("Button") - scopeFilter:SetWidth(180) - scopeFilter:SetCallback("OnClick", function() - AdvanceDebugScope(1) - end) - scopeFilter.frame:SetParent(content) - scopeFilter.frame:ClearAllPoints() - scopeFilter.frame:SetPoint("TOPLEFT", levelFilter.frame, "TOPRIGHT", 8, 0) - scopeFilter.frame:Show() - frame.scopeFilter = scopeFilter - - local logWidget = AceGUI:Create("MultiLineEditBox") - logWidget:SetLabel("") - logWidget:DisableButton(true) - logWidget:SetNumLines(18) - logWidget:SetText("") - logWidget.frame:SetParent(content) - logWidget.frame:ClearAllPoints() - logWidget.frame:SetPoint("TOPLEFT", content, "TOPLEFT", 0, -54) - logWidget.frame:SetPoint("BOTTOMRIGHT", content, "BOTTOMRIGHT", 0, 0) - logWidget.frame:Show() - logWidget:SetCallback("OnTextChanged", function() - HMGT:RefreshDebugWindow() - end) - logWidget.editBox:SetScript("OnKeyDown", function(selfBox, key) - if IsControlKeyDown() and (key == "A" or key == "a") then - selfBox:HighlightText(0) - end - end) - frame.logWidget = logWidget - frame.editBox = logWidget.editBox - frame.scrollFrame = logWidget.scrollFrame - - self.debugWindow = frame - self:SetDebugWindowMinimized(self.debugWindowStatus and self.debugWindowStatus.minimized) - return frame - end - - local clearButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") - clearButton:SetSize(90, 22) - clearButton:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -30, -6) - clearButton:SetText(L["OPT_DEBUG_CLEAR"] or "Clear log") - clearButton:SetScript("OnClick", function() - HMGT:ClearDebugLog() - end) - frame.clearButton = clearButton - - local selectButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") - selectButton:SetSize(90, 22) - selectButton:SetPoint("TOPRIGHT", clearButton, "TOPLEFT", -6, 0) - selectButton:SetText(L["OPT_DEBUG_SELECT_ALL"] or "Select all") - selectButton:SetScript("OnClick", function() - if frame.editBox then - frame.editBox:SetFocus() - frame.editBox:HighlightText(0) - end - end) - frame.selectButton = selectButton - - local scopeFilter = CreateFrame("Frame", nil, frame) - scopeFilter:SetSize(170, 22) - scopeFilter:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -8) - frame.scopeFilter = scopeFilter - - local scrollBG = CreateFrame("Frame", nil, frame, "BackdropTemplate") - scrollBG:SetBackdrop({ - bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", - edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", - edgeSize = 16, - insets = { left = 4, right = 3, top = 4, bottom = 3 }, - }) - scrollBG:SetBackdropColor(0, 0, 0, 0.95) - scrollBG:SetBackdropBorderColor(0.4, 0.4, 0.4, 1) - scrollBG:SetPoint("TOPLEFT", frame, "TOPLEFT", 14, -36) - scrollBG:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -30, 14) - frame.scrollBG = scrollBG - - local scrollFrame = CreateFrame("ScrollFrame", nil, scrollBG, "UIPanelScrollFrameTemplate") - scrollFrame:SetPoint("TOPLEFT", scrollBG, "TOPLEFT", 6, -6) - scrollFrame:SetPoint("BOTTOMRIGHT", scrollBG, "BOTTOMRIGHT", -27, 4) - scrollFrame:EnableMouseWheel(true) - scrollFrame:SetScript("OnMouseWheel", function(selfMsg, delta) - if delta > 0 then - selfMsg:SetVerticalScroll(math.max(0, selfMsg:GetVerticalScroll() - 42)) - else - selfMsg:SetVerticalScroll(selfMsg:GetVerticalScroll() + 42) - end - end) - frame.scrollFrame = scrollFrame - - local editBox = CreateFrame("EditBox", nil, scrollFrame) - editBox:SetMultiLine(true) - editBox:SetAutoFocus(false) - editBox:SetFontObject(ChatFontNormal) - editBox:SetWidth(780) - editBox:SetTextInsets(6, 6, 6, 6) - editBox:EnableMouse(true) - editBox:SetScript("OnEscapePressed", function(selfBox) - selfBox:ClearFocus() - end) - editBox:SetScript("OnKeyDown", function(selfBox, key) - if IsControlKeyDown() and (key == "A" or key == "a") then - selfBox:HighlightText(0) - end - end) - editBox:SetScript("OnTextChanged", function(selfBox, userInput) - if userInput then - HMGT:RefreshDebugWindow() - else - selfBox:SetCursorPosition(selfBox:GetNumLetters()) - selfBox:SetHeight(math.max(scrollFrame:GetHeight(), HMGT:GetDebugWindowTextHeight(frame, selfBox:GetText()) + 16)) - scrollFrame:UpdateScrollChildRect() - end - end) - editBox:SetScript("OnMouseUp", function(selfBox) - selfBox:SetFocus() - end) - scrollFrame:SetScrollChild(editBox) - frame.editBox = editBox - - local measureText = frame:CreateFontString(nil, "ARTWORK", "ChatFontNormal") - measureText:SetJustifyH("LEFT") - measureText:SetJustifyV("TOP") - if measureText.SetSpacing then - measureText:SetSpacing(2) - end - measureText:SetWidth(768) - frame.measureText = measureText - - self.debugWindow = frame - self:SetDebugWindowMinimized(self.debugWindowStatus and self.debugWindowStatus.minimized) - return frame -end - -function HMGT:GetDebugWindowTextHeight(frame, text) - if not frame or not frame.measureText then - return 0 - end - - local width = 768 - if frame.editBox then - width = math.max(1, (frame.editBox:GetWidth() or width) - 12) - end - frame.measureText:SetWidth(width) - frame.measureText:SetText(text or "") - return frame.measureText:GetStringHeight() -end - -function HMGT:RefreshDebugWindow() - local frame = self:EnsureDebugWindow() - if not frame then - return - end - - local filtered = self:GetFilteredDebugBuffer() or self.debugBuffer or {} - local text = table.concat(filtered, "\n") - if frame.logWidget and frame.editBox then - if frame.levelFilter then - local levelOptions = self:GetDebugLevelOptions() - SetFilterButtonText(frame.levelFilter, L["OPT_DEBUG_LEVEL"] or "Level", levelOptions[self:GetConfiguredDebugLevel()]) - end - if frame.scopeFilter then - local scopeOptions = self:GetDebugScopeOptions() - local currentScope = (self.db and self.db.profile and self.db.profile.debugScope) or "ALL" - SetFilterButtonText(frame.scopeFilter, L["OPT_DEBUG_SCOPE"] or "Module", scopeOptions[currentScope] or currentScope) - end - frame.logWidget:SetText(text) - frame.editBox:SetCursorPosition(frame.editBox:GetNumLetters()) - return - end - - if not frame.editBox then - return - end - - frame.editBox:SetText(text) - frame.editBox:SetCursorPosition(#text) - frame.editBox:SetHeight(math.max(frame.scrollFrame:GetHeight(), self:GetDebugWindowTextHeight(frame, text) + 16)) - frame.scrollFrame:SetVerticalScroll(math.max(0, frame.editBox:GetHeight() - frame.scrollFrame:GetHeight())) -end - -function HMGT:UpdateDebugWindowVisibility() - if self.db and self.db.profile then - self.db.profile.debug = false - end - local frame = self.debugWindow - if not frame then - return - end - local widget = frame.aceWidget - if widget then - widget:Hide() - else - frame:Hide() - end -end - -function HMGT:ClearDebugLog() - wipe(self.debugBuffer) - if self.debugWindow and self.debugWindow.logWidget then - self.debugWindow.logWidget:SetText("") - self.debugWindow.editBox:SetCursorPosition(0) - self.debugWindow.scrollFrame:SetVerticalScroll(0) - return - end - if self.debugWindow and self.debugWindow.editBox then - self.debugWindow.editBox:SetText("") - self.debugWindow.scrollFrame:SetVerticalScroll(0) - end -end - -function HMGT:ToggleDebugWindowShortcut() - if self.db and self.db.profile then - self.db.profile.debug = false - end - local frame = self.debugWindow - if not frame then - return - end - local widget = frame.aceWidget - if widget then - widget:Hide() - else - frame:Hide() - end -end - -function HMGT:DumpDebugLog(maxLines) - return -end diff --git a/Core/VersionNoticeWindow.lua b/Core/VersionNoticeWindow.lua index cb86ed4..e495d63 100644 --- a/Core/VersionNoticeWindow.lua +++ b/Core/VersionNoticeWindow.lua @@ -68,12 +68,14 @@ local function GetPlayerVersionText(name) return tostring(HMGT.ADDON_VERSION or "dev"), tonumber(HMGT.PROTOCOL_VERSION) or 0, true end - local version = HMGT.peerVersions and HMGT.peerVersions[normalized] or nil - local protocol = HMGT.GetPeerProtocolVersion and HMGT:GetPeerProtocolVersion(normalized) or 0 - if version and version ~= "" then - return tostring(version), tonumber(protocol) or 0, true + local addonStatus = HMGT.GetPlayerAddonStatus and HMGT:GetPlayerAddonStatus(normalized) or nil + if addonStatus and addonStatus.mode == "hmgt" and addonStatus.version and addonStatus.version ~= "" then + return tostring(addonStatus.version), tonumber(addonStatus.protocol) or 0, true end - return nil, tonumber(protocol) or 0, false + if addonStatus and addonStatus.mode == "bridge" then + return L["VERSION_WINDOW_BRIDGE_MODE"] or "Bridge Mode", 0, true + end + return nil, tonumber(addonStatus and addonStatus.protocol) or 0, false end local function ApplyClassIcon(texture, classTag) @@ -167,7 +169,11 @@ function HMGT:RefreshVersionNoticeWindow() local versionText, protocol, hasAddon = GetPlayerVersionText(info.name) if hasAddon then row.versionText:SetText(versionText or "?") - row.versionText:SetTextColor(0.9, 0.9, 0.9, 1) + if versionText == (L["VERSION_WINDOW_BRIDGE_MODE"] or "Bridge Mode") then + row.versionText:SetTextColor(0.55, 0.82, 1, 1) + else + row.versionText:SetTextColor(0.9, 0.9, 0.9, 1) + end row.protocolText:SetText(protocol > 0 and tostring(protocol) or "-") row.protocolText:SetTextColor(0.75, 0.75, 0.75, 1) else diff --git a/HailMaryGuildTools.lua b/HailMaryGuildTools.lua index 37eec2f..1c62c4d 100644 --- a/HailMaryGuildTools.lua +++ b/HailMaryGuildTools.lua @@ -288,16 +288,20 @@ HMGT.powerTracking = { } HMGT.pendingSpellPowerCosts = {} HMGT.demoModeData = {} -HMGT.peerVersions = {} HMGT.versionWarnings = {} HMGT.versionWhisperWarnings = {} +HMGT.playerStatus = {} HMGT.debugBuffer = {} HMGT.debugBufferMax = 500 HMGT.enabledDebugScopes = { General = true, Debug = true, Comm = true, - TrackedSpells = true, + TrackerCore = true, + TrackerSync = true, + TrackerUI = true, + TrackerBridge = true, + TrackerState = true, PowerSpend = true, } HMGT.pendingReliableMessages = HMGT.pendingReliableMessages or {} @@ -311,7 +315,11 @@ local DEBUG_SCOPE_LABELS = { General = "General", Debug = "Debug", Comm = "Communication", - TrackedSpells = "Tracked Spells", + TrackerCore = "Tracker Core", + TrackerSync = "Tracker Sync", + TrackerUI = "Tracker UI", + TrackerBridge = "Tracker Bridge", + TrackerState = "Tracker State", PowerSpend = "Power Spend", RaidTimeline = "Raid Timeline", Notes = "Notes", @@ -340,8 +348,12 @@ end function HMGT:GetTrackerDebugScope(tracker) local trackerName = nil + local trackerId = nil + local trackerType = nil if type(tracker) == "table" then trackerName = tracker.name + trackerId = tonumber(tracker.id) + trackerType = tracker.trackerType if (not trackerName or trackerName == "") and tracker.id then trackerName = string.format("Tracker %s", tostring(tracker.id)) end @@ -353,7 +365,106 @@ function HMGT:GetTrackerDebugScope(tracker) if trackerName == "" then trackerName = "Tracker" end - return "Tracker: " .. trackerName + local prefix = "Tracker" + if trackerType == "group" then + prefix = "Tracker Group" + elseif trackerType == "normal" then + prefix = "Tracker Normal" + end + if trackerId then + return string.format("%s #%d: %s", prefix, trackerId, trackerName) + end + return prefix .. ": " .. trackerName +end + +function HMGT:GetPlayerStatus(playerName, create) + local normalizedName = self:NormalizePlayerName(playerName) + if not normalizedName or normalizedName == "" then + return nil + end + self.playerStatus = self.playerStatus or {} + if create then + self.playerStatus[normalizedName] = self.playerStatus[normalizedName] or {} + end + return self.playerStatus[normalizedName] +end + +function HMGT:SetPlayerVersionStatus(playerName, version, protocol, sourceTag) + local status = self:GetPlayerStatus(playerName, true) + if not status then + return nil + end + if version and version ~= "" then + status.version = tostring(version) + end + if tonumber(protocol) then + status.protocol = tonumber(protocol) + end + if sourceTag and sourceTag ~= "" then + status.versionSource = tostring(sourceTag) + end + status.mode = "hmgt" + return status +end + +function HMGT:SetPlayerBridgeStatus(playerName, sourceName) + local source = tostring(sourceName or "") + if source == "" then + return nil + end + local status = self:GetPlayerStatus(playerName, true) + if not status then + return nil + end + status.bridgeSource = source + if not status.version or status.version == "" then + status.mode = "bridge" + end + return status +end + +function HMGT:GetPlayerAddonStatus(playerName) + local status = self:GetPlayerStatus(playerName, false) + if not status then + return { + mode = "missing", + version = nil, + protocol = 0, + bridgeSource = nil, + } + end + + local version = status.version + local protocol = tonumber(status.protocol) or 0 + local bridgeSource = status.bridgeSource + local mode = status.mode + + if version and version ~= "" then + mode = "hmgt" + elseif bridgeSource and bridgeSource ~= "" then + mode = "bridge" + else + mode = "missing" + end + + return { + mode = mode, + version = version, + protocol = protocol, + bridgeSource = bridgeSource, + } +end + +function HMGT:ClearPlayerStatus(playerName) + local normalizedName = self:NormalizePlayerName(playerName) + if not normalizedName or not self.playerStatus then + return false + end + if self.playerStatus[normalizedName] then + self.playerStatus[normalizedName] = nil + return true + end + return false end function HMGT:GetStaticDebugScopeOptions() @@ -456,6 +567,10 @@ function HMGT:IsReliableCommType(msgType) end function HMGT:GetPeerProtocolVersion(playerName) + local status = self:GetPlayerStatus(playerName, false) + if status and tonumber(status.protocol) then + return tonumber(status.protocol) or 0 + end local normalizedName = self:NormalizePlayerName(playerName) local peerProtocols = self.peerProtocols or {} return tonumber(normalizedName and peerProtocols[normalizedName]) or 0 @@ -469,6 +584,7 @@ function HMGT:RememberPeerProtocolVersion(playerName, protocol) end self.peerProtocols = self.peerProtocols or {} self.peerProtocols[normalizedName] = numeric + self:SetPlayerVersionStatus(normalizedName, nil, numeric, nil) end local function ParseVersionTokens(version) @@ -747,7 +863,32 @@ function HMGT:SendDirectMessage(payload, target, prio) end function HMGT:DebugScoped(level, scope, fmt, ...) - return + local normalizedLevel = tostring(level or "info"):lower() + if not DEBUG_LEVELS[normalizedLevel] then + normalizedLevel = "info" + end + + local normalizedScope = tostring(scope or "General"):match("^%s*(.-)%s*$") + if normalizedScope == "" then + normalizedScope = "General" + end + + local ok, message = pcall(string.format, tostring(fmt or ""), ...) + if not ok then + message = tostring(fmt or "") + end + local line = string.format("%s [%s][%s] %s", date("%H:%M:%S"), string.upper(normalizedLevel), normalizedScope, tostring(message or "")) + + self.debugBuffer = self.debugBuffer or {} + self.debugBuffer[#self.debugBuffer + 1] = line + local maxLines = tonumber(self.debugBufferMax) or 500 + while #self.debugBuffer > maxLines do + table.remove(self.debugBuffer, 1) + end + + if self.debugWindow and self.debugWindow.IsShown and self.debugWindow:IsShown() and self.RefreshDebugWindow then + self:RefreshDebugWindow() + end end function HMGT:Debug(fmt, ...) @@ -764,7 +905,7 @@ end function HMGT:RegisterPeerVersion(playerName, version, protocol, sourceTag) if not playerName then return end - self.peerVersions[playerName] = version + self:SetPlayerVersionStatus(playerName, version, protocol, sourceTag) self:RememberPeerProtocolVersion(playerName, protocol) if self.versionNoticeWindow and self.versionNoticeWindow.IsShown and self.versionNoticeWindow:IsShown() and self.RefreshVersionNoticeWindow then self:RefreshVersionNoticeWindow() @@ -798,7 +939,7 @@ function HMGT:RegisterPeerVersion(playerName, version, protocol, sourceTag) 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, " | ")) + self:DebugScoped("info", "TrackerCore", "Version mismatch %s via=%s %s", tostring(playerName), tostring(sourceTag or "?"), table.concat(details, " | ")) end end @@ -1091,7 +1232,7 @@ function HMGT:LogTrackedSpellCast(playerName, spellEntry, details) self:DebugScoped( "verbose", - "TrackedSpells", + "TrackerCore", "%s -> %s von %s, %s", GetTrackedSpellCategoryLabel(spellEntry), GetSpellDebugLabel(spellEntry.spellId), @@ -1699,22 +1840,9 @@ function HMGT:MigrateProfileSettings() 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 {})), + self:BuildTrackerConfigFromPreset("interruptTracker", 1, CopyTrackerFields({}, p.interruptTracker or {})), + self:BuildTrackerConfigFromPreset("raidCooldownTracker", 2, CopyTrackerFields({}, p.raidCooldownTracker or {})), + self:BuildTrackerConfigFromPreset("groupCooldownTracker", 3, CopyTrackerFields({}, p.groupCooldownTracker or {})), } end @@ -1732,11 +1860,7 @@ function HMGT:MigrateProfileSettings() end end if #normalizedTrackers == 0 then - normalizedTrackers[1] = self:CreateTrackerConfig(1, { - name = L["IT_NAME"] or "Interrupts", - trackerType = "normal", - categories = { "interrupt" }, - }) + normalizedTrackers[1] = self:BuildTrackerConfigFromPreset("interruptTracker", 1) end p.trackers = normalizedTrackers p.trackerModelVersion = TRACKER_MODEL_VERSION @@ -2648,7 +2772,7 @@ function HMGT:OnGroupRosterUpdate() if not validPlayers[name] then self.playerData[name] = nil self:ClearTrackerStateForPlayer(name) - self.peerVersions[name] = nil + self:ClearPlayerStatus(name) self.versionWarnings[name] = nil if self.peerProtocols then self.peerProtocols[name] = nil @@ -3040,127 +3164,6 @@ function HMGT:TestMode() 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 -- ═══════════════════════════════════════════════════════════════ diff --git a/HailMaryGuildTools.toc b/HailMaryGuildTools.toc index 5d46a58..bef1e5e 100644 --- a/HailMaryGuildTools.toc +++ b/HailMaryGuildTools.toc @@ -44,7 +44,7 @@ Modules\Tracker\RaidCooldownTracker\RaidCooldownTracker.lua Modules\Tracker\GroupCooldownTracker\GroupCooldownTracker.lua Modules\Tracker\InterruptTracker\InterruptSpellDatabase.lua -Modules\Tracker\RaidcooldownTracker\RaidCooldownSpellDatabase.lua +Modules\Tracker\RaidCooldownTracker\RaidCooldownSpellDatabase.lua Modules\Tracker\GroupCooldownTracker\GroupCooldownSpellDatabase.lua Modules\Tracker\TrackerManager.lua Modules\Tracker\NormalTrackerFrames.lua @@ -65,5 +65,4 @@ Modules\MapOverlay\MapOverlay.xml Modules\RaidTimeline\RaidTimelineBossAbilityData.lua Modules\RaidTimeline\RaidTimeline.lua Modules\RaidTimeline\RaidTimelineBigWigs.lua -Modules\RaidTimeline\RaidTimelineDBM.lua Modules\RaidTimeline\RaidTimelineOptions.lua diff --git a/Locales/deDE.lua b/Locales/deDE.lua index db24be8..5679e7f 100644 --- a/Locales/deDE.lua +++ b/Locales/deDE.lua @@ -22,11 +22,12 @@ L["VERSION_WINDOW_MESSAGE"] = "Hail Mary Guild Tools Versionen in deiner aktuell L["VERSION_WINDOW_DETAIL"] = "Erkannt ueber %s von %s.\n%s" L["VERSION_WINDOW_NO_MISMATCH"] = "In deiner aktuellen Gruppe wurde keine neuere HMGT-Version erkannt." L["VERSION_WINDOW_CURRENT"] = "Aktuelle Version: %s | Protokoll: %s" -L["VERSION_WINDOW_STATUS"] = "HMGT bei %d/%d Spielern erkannt" +L["VERSION_WINDOW_STATUS"] = "Addon oder Bridge bei %d/%d Spielern erkannt" L["VERSION_WINDOW_REFRESH"] = "Aktualisieren" L["VERSION_WINDOW_COLUMN_PLAYER"] = "Spieler" L["VERSION_WINDOW_COLUMN_VERSION"] = "Version" L["VERSION_WINDOW_COLUMN_PROTOCOL"] = "Protokoll" +L["VERSION_WINDOW_BRIDGE_MODE"] = "Bridge Mode" L["VERSION_WINDOW_MISSING_ADDON"] = "Addon nicht vorhanden" L["VERSION_WINDOW_LEADER_TAG"] = "(Leiter)" L["VERSION_WINDOW_ASSISTANT_TAG"] = "(Assist)" diff --git a/Locales/enUS.lua b/Locales/enUS.lua index 89da42b..068ce49 100644 --- a/Locales/enUS.lua +++ b/Locales/enUS.lua @@ -22,11 +22,12 @@ L["VERSION_WINDOW_MESSAGE"] = "Hail Mary Guild Tools versions in your current gr L["VERSION_WINDOW_DETAIL"] = "Detected via %s from %s.\n%s" L["VERSION_WINDOW_NO_MISMATCH"] = "No newer HMGT version has been detected in your current group." L["VERSION_WINDOW_CURRENT"] = "Current version: %s | Protocol: %s" -L["VERSION_WINDOW_STATUS"] = "Detected HMGT on %d/%d players" +L["VERSION_WINDOW_STATUS"] = "Detected addon or bridge on %d/%d players" L["VERSION_WINDOW_REFRESH"] = "Refresh" L["VERSION_WINDOW_COLUMN_PLAYER"] = "Player" L["VERSION_WINDOW_COLUMN_VERSION"] = "Version" L["VERSION_WINDOW_COLUMN_PROTOCOL"] = "Protocol" +L["VERSION_WINDOW_BRIDGE_MODE"] = "Bridge Mode" L["VERSION_WINDOW_MISSING_ADDON"] = "Addon not installed" L["VERSION_WINDOW_LEADER_TAG"] = "(Leader)" L["VERSION_WINDOW_ASSISTANT_TAG"] = "(Assist)" diff --git a/Modules/RaidTimeline/RaidTimelineDBM.lua b/Modules/RaidTimeline/RaidTimelineDBM.lua deleted file mode 100644 index a55d6ef..0000000 --- a/Modules/RaidTimeline/RaidTimelineDBM.lua +++ /dev/null @@ -1,8 +0,0 @@ -local ADDON_NAME = "HailMaryGuildTools" -local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) -if not HMGT then return end - -local RT = HMGT.RaidTimeline -if not RT then return end - --- Placeholder for later DBM-specific raid timeline integration. diff --git a/Modules/Tracker/TrackerBridge.lua b/Modules/Tracker/TrackerBridge.lua index d49c457..3edf9c9 100644 --- a/Modules/Tracker/TrackerBridge.lua +++ b/Modules/Tracker/TrackerBridge.lua @@ -80,6 +80,8 @@ function HMGT:ApplyExternalKnownSpell(sourceName, playerName, spellId, class, co knownSpells = knownSpells, externalSource = source, } + self:SetPlayerBridgeStatus(normalizedName, source) + self:DebugScoped("verbose", "TrackerBridge", "Bridge known spell source=%s player=%s spellId=%s", tostring(source), tostring(normalizedName), tostring(sid)) if tonumber(cooldown) and tonumber(cooldown) > 0 then spellEntry._hmgtExternalBaseCd = tonumber(cooldown) @@ -128,6 +130,8 @@ function HMGT:ApplyExternalSpecInfo(sourceName, playerName, class, specId, talen knownSpells = knownSpells, externalSource = source, } + self:SetPlayerBridgeStatus(normalizedName, source) + self:DebugScoped("info", "TrackerBridge", "Bridge spec sync source=%s player=%s class=%s spec=%s", tostring(source), tostring(normalizedName), tostring(classToken), tostring(spec)) self:PruneAvailabilityStates(normalizedName, knownSpells) self:TriggerTrackerUpdate("trackers") @@ -154,6 +158,7 @@ function HMGT:ApplyExternalCooldown(sourceName, playerName, spellId, cooldown) self:RegisterExternalAddonSource(source) self:ApplyExternalKnownSpell(source, normalizedName, sid, nil, cd) + self:DebugScoped("info", "TrackerBridge", "Bridge cooldown source=%s player=%s spellId=%s cooldown=%.1f", tostring(source), tostring(normalizedName), tostring(sid), cd) self:HandleRemoteSpellCast(normalizedName, sid, GetServerTime(), nil, nil, nil, cd) return true end diff --git a/Modules/Tracker/TrackerCore.lua b/Modules/Tracker/TrackerCore.lua index 39fad12..c77fe3e 100644 --- a/Modules/Tracker/TrackerCore.lua +++ b/Modules/Tracker/TrackerCore.lua @@ -4,6 +4,91 @@ if not HMGT then return end HMGT.TrackerCore = HMGT.TrackerCore or {} +HMGT.TRACKER_PRESET_DEFINITIONS = HMGT.TRACKER_PRESET_DEFINITIONS or { + interruptTracker = { + moduleName = "InterruptTracker", + dbKey = "interruptTracker", + trackerType = "normal", + trackerKey = "interruptTracker", + categories = { "interrupt" }, + defaultName = function(L) + return (L and L["IT_NAME"]) or "Interrupts" + end, + }, + raidCooldownTracker = { + moduleName = "RaidCooldownTracker", + dbKey = "raidCooldownTracker", + trackerType = "normal", + trackerKey = "raidCooldownTracker", + categories = { "lust", "defensive", "healing", "tank", "utility", "offensive", "cc", "interrupt" }, + defaultName = function(L) + return (L and L["RCD_NAME"]) or "Raid Cooldowns" + end, + }, + groupCooldownTracker = { + moduleName = "GroupCooldownTracker", + dbKey = "groupCooldownTracker", + trackerType = "group", + trackerKey = "groupCooldownTracker", + categories = { "tank", "defensive", "healing", "cc", "utility", "offensive", "lust", "interrupt" }, + includeSelfFrame = false, + showChargesOnIcon = true, + defaultName = function(L) + return (L and L["GCD_NAME"]) or "Cooldowns" + end, + }, +} + +function HMGT:GetTrackerPresetDefinitions() + return self.TRACKER_PRESET_DEFINITIONS or {} +end + +function HMGT:GetTrackerPresetDefinition(key) + local definitions = self:GetTrackerPresetDefinitions() + return definitions and definitions[tostring(key or "")] +end + +function HMGT:GetTrackerPresetDefinitionByModule(moduleName) + local target = tostring(moduleName or "") + for _, definition in pairs(self:GetTrackerPresetDefinitions()) do + if tostring(definition.moduleName or "") == target then + return definition + end + end + return nil +end + +function HMGT:GetTrackerTypeOptions() + local L = self.L + return { + normal = (L and L["OPT_TRACKER_TYPE_NORMAL"]) or "Normal tracker", + group = (L and L["OPT_TRACKER_TYPE_GROUP"]) or "Group-based tracker", + } +end + +function HMGT:BuildTrackerConfigFromPreset(presetKey, trackerId, overrides) + local definition = self:GetTrackerPresetDefinition(presetKey) + local config = overrides or {} + if not definition then + return self:CreateTrackerConfig(trackerId, config) + end + + local base = { + name = type(definition.defaultName) == "function" and definition.defaultName(self.L) or tostring(definition.defaultName or ""), + trackerType = definition.trackerType, + trackerKey = definition.trackerKey, + categories = definition.categories, + includeSelfFrame = definition.includeSelfFrame, + showChargesOnIcon = definition.showChargesOnIcon, + } + + for key, value in pairs(config) do + base[key] = value + end + + return self:CreateTrackerConfig(trackerId, base) +end + local function EntryNeedsVisualTicker(entry) if type(entry) ~= "table" then return false @@ -204,6 +289,93 @@ function HMGT:CollectTrackerEntries(tracker) return entries end +function HMGT:GetDemoEntries(trackerKey, database, settings) + local pool = {} + local poolByClass = {} + for _, entry in ipairs(database or {}) do + if settings.enabledSpells[entry.spellId] ~= false then + pool[#pool + 1] = entry + for _, classToken in ipairs(entry.classes or {}) do + poolByClass[classToken] = poolByClass[classToken] or {} + poolByClass[classToken][#poolByClass[classToken] + 1] = entry + end + end + end + if #pool == 0 then + return {} + end + + local classKeys = {} + for classToken in pairs(poolByClass) do + classKeys[#classKeys + 1] = classToken + 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 _, entry in ipairs(pool) do + spellIds[#spellIds + 1] = tostring(entry.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 cachedEntries = {} + for index = 1, count do + local classToken = classKeys[math.random(1, #classKeys)] + local classPool = poolByClass[classToken] + local spellEntry = (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(spellEntry)) or tonumber(spellEntry.cooldown) or 60 + ) + local offset = math.random() * math.min(duration * 0.85, duration - 0.1) + cachedEntries[#cachedEntries + 1] = { + playerName = names[((index - 1) % #names) + 1], + class = classToken or ((spellEntry.classes and spellEntry.classes[1]) or "WARRIOR"), + spellEntry = spellEntry, + total = duration, + cycleStart = now - offset, + currentCharges = nil, + maxCharges = nil, + } + end + cache = { + signature = signature, + entries = cachedEntries, + } + self.demoModeData[trackerKey] = cache + end + + local entries = {} + for _, entry in ipairs(cache.entries) do + local total = math.max(1, tonumber(entry.total) or 1) + local elapsed = math.max(0, now - (entry.cycleStart or now)) + local phase = math.fmod(elapsed, total) + local remaining = total - phase + if elapsed > 0 and phase < 0.05 then + remaining = 0 + end + entries[#entries + 1] = { + playerName = entry.playerName, + class = entry.class, + spellEntry = entry.spellEntry, + remaining = remaining, + total = total, + currentCharges = entry.currentCharges, + maxCharges = entry.maxCharges, + } + end + + return entries +end + function HMGT:CollectTrackerTestEntries(tracker) local playerName = self:NormalizePlayerName(UnitName("player")) or "Player" local classToken = select(2, UnitClass("player")) @@ -385,7 +557,7 @@ function HMGT:TriggerTrackerUpdate(reason) 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)) + self:DebugScoped("verbose", "TrackerUI", "UIUpdate %s took %.2fms entries=%s", tostring(name), t1 - t0, tostring(count)) end end diff --git a/Modules/Tracker/TrackerDetection.lua b/Modules/Tracker/TrackerDetection.lua index 11ec4ff..cab2eab 100644 --- a/Modules/Tracker/TrackerDetection.lua +++ b/Modules/Tracker/TrackerDetection.lua @@ -247,7 +247,7 @@ function HMGT:RefreshOwnCooldownStateFromGame(spellId) if isLikelyGlobalCooldown or isSuspiciousShortRefresh then self:DebugScoped( "verbose", - "TrackedSpells", + "TrackerState", "Ignore suspicious refresh for %s: spellCD=%.3f gcd=%.3f existing=%.3f remaining=%.3f effective=%.3f", GetSpellDebugLabel and GetSpellDebugLabel(sid) or tostring(sid), cooldownDuration, diff --git a/Modules/Tracker/TrackerOptions.lua b/Modules/Tracker/TrackerOptions.lua index 14b8d59..bb91c17 100644 --- a/Modules/Tracker/TrackerOptions.lua +++ b/Modules/Tracker/TrackerOptions.lua @@ -142,13 +142,26 @@ local function IsPartyAttachMode(tracker) end local function IsGroupTracker(tracker) - return type(tracker) == "table" and tracker.trackerType == "group" + return HMGT.IsGroupTrackerConfig and HMGT:IsGroupTrackerConfig(tracker) or (type(tracker) == "table" and tracker.trackerType == "group") end -local TRACKER_TYPE_VALUES = { - normal = L["OPT_TRACKER_TYPE_NORMAL"] or "Normal tracker", - group = L["OPT_TRACKER_TYPE_GROUP"] or "Group-based tracker", -} +local function GetTrackerTypeValues() + return HMGT.GetTrackerTypeOptions and HMGT:GetTrackerTypeOptions() or { + normal = L["OPT_TRACKER_TYPE_NORMAL"] or "Normal tracker", + group = L["OPT_TRACKER_TYPE_GROUP"] or "Group-based tracker", + } +end + +local function GetPresetLabel(presetKey) + local definition = HMGT.GetTrackerPresetDefinition and HMGT:GetTrackerPresetDefinition(presetKey) or nil + if not definition then + return tostring(presetKey or (L["OPT_TRACKER"] or "Tracker")) + end + if type(definition.defaultName) == "function" then + return tostring(definition.defaultName(L)) + end + return tostring(definition.defaultName or definition.moduleName or presetKey) +end local function GetTrackerVisibilitySummary(tracker) local parts = {} @@ -180,7 +193,7 @@ local function GetTrackerSummaryText(tracker) local display = tracker.showBar and (L["OPT_DISPLAY_BAR"] or "Progress bars") or (L["OPT_DISPLAY_ICON"] or "Icons") return table.concat({ - string.format("|cffffd100%s|r: %s", L["OPT_TRACKER_TYPE"] or "Tracker type", TRACKER_TYPE_VALUES[tracker.trackerType or "normal"] or (L["OPT_TRACKER_TYPE_NORMAL"] or "Normal tracker")), + string.format("|cffffd100%s|r: %s", L["OPT_TRACKER_TYPE"] or "Tracker type", GetTrackerTypeValues()[tracker.trackerType or "normal"] or (L["OPT_TRACKER_TYPE_NORMAL"] or "Normal tracker")), string.format("|cffffd100%s|r: %s", L["OPT_TRACKER_CATEGORIES"] or "Categories", GetTrackerCategoriesSummary(tracker)), string.format("|cffffd100%s|r: %s", L["OPT_STATUS_MODE"] or "Mode", modeLabel), string.format("|cffffd100%s|r: %s", L["OPT_STATUS_DISPLAY"] or "Display", display), @@ -814,7 +827,7 @@ local function BuildGlobalSpellBrowserArgs() end local function BuildTrackerOverviewArgs() - return { + local args = { description = { type = "description", order = 1, @@ -833,22 +846,36 @@ local function BuildTrackerOverviewArgs() return string.format("%s\n\n%s (%d): %s", body, L["OPT_TRACKERS"] or "Tracker Bars", #trackers, table.concat(names, ", ")) end, }, - addTracker = { + } + + local definitions = HMGT.GetTrackerPresetDefinitions and HMGT:GetTrackerPresetDefinitions() or {} + local presetKeys = {} + for presetKey in pairs(definitions) do + presetKeys[#presetKeys + 1] = presetKey + end + table.sort(presetKeys, function(a, b) + return GetPresetLabel(a) < GetPresetLabel(b) + end) + + for index, presetKey in ipairs(presetKeys) do + args["addPreset_" .. presetKey] = { type = "execute", - order = 2, + order = 2 + index, width = "full", - name = L["OPT_ADD_TRACKER"] or "Add tracker", + name = function() + return string.format("%s: %s", L["OPT_ADD_TRACKER"] or "Add tracker", GetPresetLabel(presetKey)) + end, func = function() local nextId = HMGT:GetNextTrackerId() - local tracker = HMGT:CreateTrackerConfig(nextId, { - name = string.format("%s %d", L["OPT_TRACKER"] or "Tracker", nextId), - }) + local tracker = HMGT:BuildTrackerConfigFromPreset(presetKey, nextId) HMGT.db.profile.trackers = HMGT.db.profile.trackers or {} HMGT.db.profile.trackers[#HMGT.db.profile.trackers + 1] = tracker TriggerTrackerUpdate(true) end, - }, - } + } + end + + return args end local function BuildTrackerGroup(trackerId, order) @@ -1008,7 +1035,7 @@ local function BuildTrackerGroup(trackerId, order) width = "full", name = L["OPT_TRACKER_TYPE"] or "Tracker type", desc = L["OPT_TRACKER_TYPE_DESC"] or "Choose whether this tracker uses one shared frame or separate frames per group member.", - values = TRACKER_TYPE_VALUES, + values = GetTrackerTypeValues, get = function() local tracker = s() return (tracker and tracker.trackerType) or "normal" diff --git a/Modules/Tracker/TrackerSync.lua b/Modules/Tracker/TrackerSync.lua index 4823ff6..7f7c7c5 100644 --- a/Modules/Tracker/TrackerSync.lua +++ b/Modules/Tracker/TrackerSync.lua @@ -203,7 +203,7 @@ function HMGT:SendSpellStateSnapshot(snapshot, target, revision) self:DebugScoped( "verbose", - "TrackedSpells", + "TrackerSync", "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 and GetSpellDebugLabel(sid) or tostring(sid), @@ -307,7 +307,7 @@ 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) + self:DebugScoped("verbose", "TrackerSync", "RepairSpellStates sent=%d", sent) end end @@ -407,7 +407,7 @@ function HMGT:BroadcastSpellCast(spellId, snapshot) 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", + self:DebugScoped("verbose", "TrackerSync", "BroadcastSpellCast spell=%s serverTime=%s charges=%d/%d", GetSpellDebugLabel and GetSpellDebugLabel(spellId) or tostring(spellId), tostring(GetServerTime()), cur, @@ -563,7 +563,7 @@ function HMGT:StoreRemotePlayerInfo(playerName, class, specIndex, talentHash, kn end self:DebugScoped( "info", - "TrackedSpells", + "TrackerSync", "Spielerinfo von %s: class=%s spec=%s bekannteSpells=%d", tostring(playerName), tostring(class), @@ -753,7 +753,7 @@ function HMGT:ApplyRemoteSpellState(playerName, spellId, kind, revision, a, b, c if changed then self:DebugScoped( "info", - "TrackedSpells", + "TrackerSync", "Sync von %s: %s -> %s (rev=%d)", tostring(normalizedName), GetSpellDebugLabel and GetSpellDebugLabel(sid) or tostring(sid), @@ -814,7 +814,7 @@ function HMGT:OnCommReceived(prefix, message, distribution, sender) if (tonumber(protocol) or 0) >= 5 then return end - self:DebugScoped("verbose", "TrackedSpells", "Legacy cast von %s: %s ts=%s", + self:DebugScoped("verbose", "TrackerSync", "Legacy cast von %s: %s ts=%s", tostring(senderName), GetSpellDebugLabel and GetSpellDebugLabel(spellId) or tostring(spellId), tostring(timestamp)) @@ -892,7 +892,7 @@ function HMGT:OnCommReceived(prefix, message, distribution, sender) self:RememberPeerProtocolVersion(senderName, protocol) self:ClearRemoteSpellStateRevisions(senderName) self:StoreRemotePlayerInfo(senderName, class, specIndex, talentHash, knownSpellList) - self:DebugScoped("info", "TrackedSpells", "Hello von %s: class=%s spec=%s spells=%s", + self:DebugScoped("info", "TrackerSync", "Hello von %s: class=%s spec=%s spells=%s", tostring(senderName), tostring(class), tostring(specIndex), tostring(knownSpellList or "")) self:SendSyncResponse(sender) self:TriggerTrackerUpdate() @@ -1003,7 +1003,7 @@ function HMGT:OnCommReceived(prefix, message, distribution, sender) end end end - self:DebugScoped("info", "TrackedSpells", "SyncResponse von %s: cdsApplied=%d", tostring(senderName), applied) + self:DebugScoped("info", "TrackerSync", "SyncResponse von %s: cdsApplied=%d", tostring(senderName), applied) end self:TriggerTrackerUpdate() end -- 2.39.5 From cf784051481d9b0e024eb07f0bf101f7537f17bc Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Sat, 25 Apr 2026 22:49:22 +0200 Subject: [PATCH 6/7] nightly commit --- Core/DevTools.lua | 74 +++- Core/DevToolsWindow.lua | 12 +- HailMaryGuildTools.lua | 366 +++++++++--------- HailMaryGuildToolsOptions.lua | 83 ++++ Locales/deDE.lua | 16 +- Locales/enUS.lua | 16 +- .../GroupCooldownTracker.lua | 8 +- .../InterruptTracker/InterruptTracker.lua | 8 +- .../RaidcooldownTracker.lua | 8 +- readme.md | 3 +- 10 files changed, 368 insertions(+), 226 deletions(-) diff --git a/Core/DevTools.lua b/Core/DevTools.lua index 5df2382..fce4776 100644 --- a/Core/DevTools.lua +++ b/Core/DevTools.lua @@ -5,7 +5,7 @@ if not HMGT then return end local L = HMGT.L or LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME) HMGT.devToolsBuffer = HMGT.devToolsBuffer or {} -HMGT.devToolsBufferMax = HMGT.devToolsBufferMax or 300 +HMGT.devToolsBufferMax = HMGT.devToolsBufferMax or 500 local DEVTOOLS_SCOPE_ALL = "ALL" local DEVTOOLS_SCOPE_LABELS = { @@ -20,7 +20,8 @@ local DEVTOOLS_SCOPE_LABELS = { local DEVTOOLS_LEVELS = { error = 1, - trace = 2, + info = 2, + verbose = 3, } local function TrimText(value) @@ -76,8 +77,10 @@ function HMGT:GetDevToolsSettings() profile.devTools = type(profile.devTools) == "table" and profile.devTools or {} local settings = profile.devTools settings.enabled = settings.enabled == true - if settings.level ~= "error" and settings.level ~= "trace" then - settings.level = "error" + if settings.level == "trace" then + settings.level = "verbose" + elseif settings.level ~= "error" and settings.level ~= "info" and settings.level ~= "verbose" then + settings.level = "info" end if type(settings.scope) ~= "string" or settings.scope == "" then settings.scope = DEVTOOLS_SCOPE_ALL @@ -94,24 +97,25 @@ function HMGT:IsDevToolsEnabled() end function HMGT:GetDevToolsLevelOptions() - return { - error = L["OPT_DEVTOOLS_LEVEL_ERROR"] or "Errors", - trace = L["OPT_DEVTOOLS_LEVEL_TRACE"] or "Trace", - } + return self:GetDebugLevelOptions() end function HMGT:GetConfiguredDevToolsLevel() - return self:GetDevToolsSettings().level or "error" + return self:GetConfiguredDebugLevel() end function HMGT:ShouldIncludeDevToolsLevel(level) local configured = self:GetConfiguredDevToolsLevel() - return (DEVTOOLS_LEVELS[tostring(level or "error")] or DEVTOOLS_LEVELS.error) - <= (DEVTOOLS_LEVELS[configured] or DEVTOOLS_LEVELS.error) + local normalizedLevel = tostring(level or "info") + if normalizedLevel == "trace" then + normalizedLevel = "verbose" + end + return (DEVTOOLS_LEVELS[normalizedLevel] or DEVTOOLS_LEVELS.info) + <= (DEVTOOLS_LEVELS[configured] or DEVTOOLS_LEVELS.info) end function HMGT:GetDevToolsScopeOptions() - local values = { + local values = self:GetDebugScopeOptions() or { [DEVTOOLS_SCOPE_ALL] = L["OPT_DEVTOOLS_SCOPE_ALL"] or "All scopes", } for scope, label in pairs(DEVTOOLS_SCOPE_LABELS) do @@ -128,8 +132,11 @@ end function HMGT:FormatDevToolsEntry(entry) local stamp = tostring(entry and entry.stamp or date("%H:%M:%S")) - local level = string.upper(tostring(entry and entry.level or "error")) + local level = string.upper(tostring(entry and entry.level or "info")) local scope = tostring(entry and entry.scope or "System") + if entry and entry.kind == "debug" then + return string.format("%s [%s][%s] %s", stamp, level, scope, tostring(entry.message or "")) + end local eventName = tostring(entry and entry.event or "") local payload = TrimText(entry and entry.payload or "") if payload ~= "" then @@ -164,8 +171,10 @@ function HMGT:RecordDevEvent(level, scope, eventName, payload) end local normalizedLevel = tostring(level or "error") - if normalizedLevel ~= "error" and normalizedLevel ~= "trace" then - normalizedLevel = "trace" + if normalizedLevel == "trace" then + normalizedLevel = "verbose" + elseif normalizedLevel ~= "error" and normalizedLevel ~= "info" and normalizedLevel ~= "verbose" then + normalizedLevel = "verbose" end if not self:ShouldIncludeDevToolsLevel(normalizedLevel) then return @@ -182,6 +191,7 @@ function HMGT:RecordDevEvent(level, scope, eventName, payload) scope = normalizedScope, event = TrimText(eventName or "event"), payload = EncodePayloadValue(payload, 0), + kind = "event", } table.insert(self.devToolsBuffer, entry) @@ -194,6 +204,40 @@ function HMGT:RecordDevEvent(level, scope, eventName, payload) end end +function HMGT:RecordDebugEntry(level, scope, message) + if not self:IsDevToolsEnabled() then + return + end + + local normalizedLevel = tostring(level or "info") + if normalizedLevel == "trace" then + normalizedLevel = "verbose" + elseif normalizedLevel ~= "error" and normalizedLevel ~= "info" and normalizedLevel ~= "verbose" then + normalizedLevel = "info" + end + + local normalizedScope = TrimText(scope or "General") + if normalizedScope == "" then + normalizedScope = "General" + end + + self.devToolsBuffer = self.devToolsBuffer or {} + self.devToolsBuffer[#self.devToolsBuffer + 1] = { + stamp = date("%H:%M:%S"), + level = normalizedLevel, + scope = normalizedScope, + message = TrimText(message or ""), + kind = "debug", + } + while #self.devToolsBuffer > (tonumber(self.devToolsBufferMax) or 500) do + table.remove(self.devToolsBuffer, 1) + end + + if self.devToolsWindow and self.devToolsWindow:IsShown() and self.RefreshDevToolsWindow then + self:RefreshDevToolsWindow() + end +end + function HMGT:DevError(scope, eventName, payload) self:RecordDevEvent("error", scope, eventName, payload) end diff --git a/Core/DevToolsWindow.lua b/Core/DevToolsWindow.lua index 610a35e..58a3d5a 100644 --- a/Core/DevToolsWindow.lua +++ b/Core/DevToolsWindow.lua @@ -7,7 +7,7 @@ local AceGUI = LibStub("AceGUI-3.0", true) if not AceGUI then return end local function GetOrderedLevels() - return { "error", "trace" } + return { "error", "info", "verbose" } end local function GetOrderedScopes() @@ -78,8 +78,8 @@ function HMGT:EnsureDevToolsWindow() local settings = self:GetDevToolsSettings() local window = self:CreateAceWindow("devTools", { - title = L["DEVTOOLS_WINDOW_TITLE"] or "HMGT Developer Tools", - statusText = L["DEVTOOLS_WINDOW_HINT"] or "Structured developer events for the current session", + title = L["DEVTOOLS_WINDOW_TITLE"] or "HMGT Debug Console", + statusText = L["DEVTOOLS_WINDOW_HINT"] or "Debug and developer events for the current session", statusTable = settings.window, width = settings.window.width or 920, height = settings.window.height or 420, @@ -93,7 +93,7 @@ function HMGT:EnsureDevToolsWindow() local content = window:GetContent() local clearButton = AceGUI:Create("Button") - clearButton:SetText(L["OPT_DEVTOOLS_CLEAR"] or "Clear developer log") + clearButton:SetText(L["OPT_DEVTOOLS_CLEAR"] or L["OPT_DEBUG_CLEAR"] or "Clear log") clearButton:SetWidth(140) clearButton:SetCallback("OnClick", function() HMGT:ClearDevToolsLog() @@ -176,11 +176,11 @@ function HMGT:RefreshDevToolsWindow() end local levelOptions = self:GetDevToolsLevelOptions() - SetFilterButtonText(window.levelFilter, L["OPT_DEVTOOLS_LEVEL"] or "Capture level", levelOptions[self:GetConfiguredDevToolsLevel()]) + SetFilterButtonText(window.levelFilter, L["OPT_DEBUG_LEVEL"] or L["OPT_DEVTOOLS_LEVEL"] or "Level", levelOptions[self:GetConfiguredDevToolsLevel()]) local scopeValues = self:GetDevToolsScopeOptions() local currentScope = self:GetDevToolsSettings().scope or "ALL" - SetFilterButtonText(window.scopeFilter, L["OPT_DEVTOOLS_SCOPE"] or "Scope", scopeValues[currentScope] or currentScope) + SetFilterButtonText(window.scopeFilter, L["OPT_DEBUG_SCOPE"] or L["OPT_DEVTOOLS_SCOPE"] or "Module", scopeValues[currentScope] or currentScope) local text = table.concat(self:GetFilteredDevToolsLines(), "\n") window.logWidget:SetText(text) diff --git a/HailMaryGuildTools.lua b/HailMaryGuildTools.lua index 1c62c4d..e422c33 100644 --- a/HailMaryGuildTools.lua +++ b/HailMaryGuildTools.lua @@ -89,11 +89,9 @@ HMGT.MSG_RAID_TIMELINE_TEST = MSG_RAID_TIMELINE_TEST -- ── Standardwerte ───────────────────────────────────────────── local defaults = { profile = { - debug = false, - debugLevel = "info", devTools = { enabled = false, - level = "error", + level = "info", scope = "ALL", window = { width = 920, @@ -102,131 +100,6 @@ local defaults = { }, }, 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, @@ -291,8 +164,8 @@ HMGT.demoModeData = {} HMGT.versionWarnings = {} HMGT.versionWhisperWarnings = {} HMGT.playerStatus = {} -HMGT.debugBuffer = {} -HMGT.debugBufferMax = 500 +HMGT.devToolsBuffer = HMGT.devToolsBuffer or {} +HMGT.devToolsBufferMax = HMGT.devToolsBufferMax or 500 HMGT.enabledDebugScopes = { General = true, Debug = true, @@ -332,7 +205,8 @@ local DEBUG_LEVELS = { 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 + local settings = self.GetDevToolsSettings and self:GetDevToolsSettings() or nil + local selectedScope = settings and settings.scope or DEBUG_SCOPE_ALL if selectedScope and selectedScope ~= DEBUG_SCOPE_ALL and normalizedScope ~= selectedScope then return false end @@ -496,7 +370,11 @@ function HMGT:GetDebugLevelOptions() end function HMGT:GetConfiguredDebugLevel() - local configured = self.db and self.db.profile and self.db.profile.debugLevel or "info" + local settings = self.GetDevToolsSettings and self:GetDevToolsSettings() or nil + local configured = settings and settings.level or "info" + if configured == "trace" then + configured = "verbose" + end if DEBUG_LEVELS[configured] then return configured end @@ -524,9 +402,8 @@ function HMGT:GetDebugScopeOptions() 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) + for _, entry in ipairs(self.devToolsBuffer or {}) do + addScope(entry and entry.scope) end local names = {} @@ -541,15 +418,19 @@ function HMGT:GetDebugScopeOptions() 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 settings = self.GetDevToolsSettings and self:GetDevToolsSettings() or nil + local selectedScope = settings and settings.scope or DEBUG_SCOPE_ALL + for _, entry in ipairs(self.devToolsBuffer or {}) do + local scope = tostring(entry and entry.scope or "General") + local level = tostring(entry and entry.level or "info") local scopeMatches = (not selectedScope or selectedScope == DEBUG_SCOPE_ALL or scope == selectedScope) - if scopeMatches and self:ShouldIncludeDebugLine(normalizedLevel) then - filtered[#filtered + 1] = line + if scopeMatches and self:ShouldIncludeDebugLine(level) then + if self.FormatDevToolsEntry then + filtered[#filtered + 1] = self:FormatDevToolsEntry(entry) + else + filtered[#filtered + 1] = tostring(entry and entry.message or "") + end end end return filtered @@ -877,17 +758,22 @@ function HMGT:DebugScoped(level, scope, fmt, ...) if not ok then message = tostring(fmt or "") end - local line = string.format("%s [%s][%s] %s", date("%H:%M:%S"), string.upper(normalizedLevel), normalizedScope, tostring(message or "")) - - self.debugBuffer = self.debugBuffer or {} - self.debugBuffer[#self.debugBuffer + 1] = line - local maxLines = tonumber(self.debugBufferMax) or 500 - while #self.debugBuffer > maxLines do - table.remove(self.debugBuffer, 1) + if self.RecordDebugEntry then + self:RecordDebugEntry(normalizedLevel, normalizedScope, tostring(message or "")) + return end - if self.debugWindow and self.debugWindow.IsShown and self.debugWindow:IsShown() and self.RefreshDebugWindow then - self:RefreshDebugWindow() + self.devToolsBuffer = self.devToolsBuffer or {} + self.devToolsBuffer[#self.devToolsBuffer + 1] = { + stamp = date("%H:%M:%S"), + level = normalizedLevel, + scope = normalizedScope, + message = tostring(message or ""), + kind = "debug", + } + local maxLines = tonumber(self.devToolsBufferMax) or 500 + while #self.devToolsBuffer > maxLines do + table.remove(self.devToolsBuffer, 1) end end @@ -1797,52 +1683,53 @@ 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 + local oldDebugEnabled = p.debug == true + local oldDebugLevel = p.debugLevel + local oldDebugScope = p.debugScope p.devTools = type(p.devTools) == "table" and p.devTools or {} - p.devTools.enabled = p.devTools.enabled == true - if p.devTools.level ~= "error" and p.devTools.level ~= "trace" then - p.devTools.level = "error" + p.devTools.enabled = p.devTools.enabled == true or oldDebugEnabled + if p.devTools.level == "trace" then + p.devTools.level = "verbose" + elseif p.devTools.level ~= "error" and p.devTools.level ~= "info" and p.devTools.level ~= "verbose" then + p.devTools.level = (oldDebugLevel == "error" or oldDebugLevel == "info" or oldDebugLevel == "verbose") + and oldDebugLevel + or "info" end if type(p.devTools.scope) ~= "string" or p.devTools.scope == "" then - p.devTools.scope = "ALL" + p.devTools.scope = (type(oldDebugScope) == "string" and oldDebugScope ~= "") and oldDebugScope or "ALL" end p.devTools.window = type(p.devTools.window) == "table" and p.devTools.window or {} p.devTools.window.width = math.max(720, tonumber(p.devTools.window.width) or 920) p.devTools.window.height = math.max(260, tonumber(p.devTools.window.height) or 420) p.devTools.window.minimized = p.devTools.window.minimized == true + p.debug = nil + p.debugLevel = nil + p.debugScope = nil p.syncRemoteCharges = true - if 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 + local legacyInterrupt = type(p.interruptTracker) == "table" and p.interruptTracker or {} + local legacyRaid = type(p.raidCooldownTracker) == "table" and p.raidCooldownTracker or {} + local legacyGroup = type(p.groupCooldownTracker) == "table" and p.groupCooldownTracker or {} + + NormalizeBorderSettings(legacyInterrupt) + NormalizeAnchorSettings(legacyInterrupt) + NormalizeTrackerLayout(legacyInterrupt, false, true) + NormalizeBorderSettings(legacyRaid) + NormalizeAnchorSettings(legacyRaid) + NormalizeTrackerLayout(legacyRaid, false, true) + NormalizeBorderSettings(legacyGroup) + NormalizeAnchorSettings(legacyGroup) + NormalizeTrackerLayout(legacyGroup, true, true) + p.trackers = { - self:BuildTrackerConfigFromPreset("interruptTracker", 1, CopyTrackerFields({}, p.interruptTracker or {})), - self:BuildTrackerConfigFromPreset("raidCooldownTracker", 2, CopyTrackerFields({}, p.raidCooldownTracker or {})), - self:BuildTrackerConfigFromPreset("groupCooldownTracker", 3, CopyTrackerFields({}, p.groupCooldownTracker or {})), + self:BuildTrackerConfigFromPreset("interruptTracker", 1, CopyTrackerFields({}, legacyInterrupt)), + self:BuildTrackerConfigFromPreset("raidCooldownTracker", 2, CopyTrackerFields({}, legacyRaid)), + self:BuildTrackerConfigFromPreset("groupCooldownTracker", 3, CopyTrackerFields({}, legacyGroup)), } end @@ -1864,6 +1751,9 @@ function HMGT:MigrateProfileSettings() end p.trackers = normalizedTrackers p.trackerModelVersion = TRACKER_MODEL_VERSION + p.interruptTracker = nil + p.raidCooldownTracker = nil + p.groupCooldownTracker = nil p.mapOverlay = p.mapOverlay or {} NormalizeMapOverlaySettings(p.mapOverlay) @@ -2820,7 +2710,7 @@ end --- Gibt true zurück wenn ein Tracker laut seinen Einstellungen --- im aktuellen Gruppen-Kontext angezeigt werden soll. ---- @param settings table db.profile.interruptTracker / raidCooldownTracker +--- @param settings table tracker config from db.profile.trackers function HMGT:IsVisibleForCurrentGroup(settings) if not settings.enabled then return false end @@ -3056,6 +2946,116 @@ function HMGT:CreateLegacyMinimapButton() end end +local function CountTableEntries(tbl) + local count = 0 + for _ in pairs(tbl or {}) do + count = count + 1 + end + return count +end + +function HMGT:GetHealthStatusLines() + local lines = {} + lines[#lines + 1] = "HMGT status" + lines[#lines + 1] = string.format( + "Version: addon=%s build=%s channel=%s protocol=%s", + tostring(self.ADDON_VERSION or "dev"), + tostring(self.BUILD_VERSION or self.ADDON_VERSION or "dev"), + tostring(self.RELEASE_CHANNEL or "stable"), + tostring(self.PROTOCOL_VERSION or "?") + ) + + local groupType = "solo" + local groupMembers = 1 + if IsInRaid() then + groupType = "raid" + groupMembers = GetNumGroupMembers() + elseif IsInGroup() then + groupType = "party" + groupMembers = GetNumGroupMembers() + end + lines[#lines + 1] = string.format("Group: type=%s members=%d", groupType, tonumber(groupMembers) or 1) + + local trackers = self:GetTrackerConfigs() + local enabledTrackers = 0 + local normalTrackers = 0 + local groupTrackers = 0 + for _, tracker in ipairs(trackers) do + if tracker.enabled ~= false then + enabledTrackers = enabledTrackers + 1 + end + if self:IsGroupTrackerConfig(tracker) then + groupTrackers = groupTrackers + 1 + else + normalTrackers = normalTrackers + 1 + end + end + lines[#lines + 1] = string.format( + "Trackers: total=%d enabled=%d normal=%d group=%d model=%s", + #trackers, + enabledTrackers, + normalTrackers, + groupTrackers, + tostring(self.db and self.db.profile and self.db.profile.trackerModelVersion or "?") + ) + + local profile = self.db and self.db.profile or {} + local legacyCount = 0 + if profile.interruptTracker ~= nil then legacyCount = legacyCount + 1 end + if profile.raidCooldownTracker ~= nil then legacyCount = legacyCount + 1 end + if profile.groupCooldownTracker ~= nil then legacyCount = legacyCount + 1 end + lines[#lines + 1] = string.format("Legacy profile keys: %d", legacyCount) + + local devSettings = self.GetDevToolsSettings and self:GetDevToolsSettings() or {} + lines[#lines + 1] = string.format( + "Debug: enabled=%s level=%s scope=%s lines=%d", + tostring(devSettings.enabled == true), + tostring(devSettings.level or "info"), + tostring(devSettings.scope or "ALL"), + #(self.devToolsBuffer or {}) + ) + + local activeCooldownPlayers = CountTableEntries(self.activeCDs) + local playerDataCount = CountTableEntries(self.playerData) + lines[#lines + 1] = string.format( + "Tracker state: players=%d cooldownPlayers=%d pendingReliable=%d", + playerDataCount, + activeCooldownPlayers, + CountTableEntries(self.pendingReliableMessages) + ) + + local modules = { + Tracker = self.TrackerManager ~= nil, + AuraExpiry = self.AuraExpiry ~= nil, + MapOverlay = self.MapOverlay ~= nil, + RaidTimeline = self.RaidTimeline ~= nil, + Notes = self.Notes ~= nil, + } + local moduleParts = {} + for name, loaded in pairs(modules) do + moduleParts[#moduleParts + 1] = string.format("%s=%s", name, loaded and "loaded" or "missing") + end + table.sort(moduleParts) + lines[#lines + 1] = "Modules: " .. table.concat(moduleParts, ", ") + + local bridge = _G.HMGT_Bridge + lines[#lines + 1] = string.format("Bridge: %s", bridge and "loaded" or "not loaded") + if bridge and type(bridge.GetStatusLines) == "function" then + local statusLines = bridge:GetStatusLines() + for index = 1, math.min(3, #(statusLines or {})) do + lines[#lines + 1] = "Bridge " .. tostring(index) .. ": " .. tostring(statusLines[index]) + end + end + + return lines +end + +function HMGT:PrintHealthStatus() + for _, line in ipairs(self:GetHealthStatusLines()) do + self:Print(line) + end +end + function HMGT:SlashCommand(input) input = input:trim():lower() if input == "lock" then @@ -3092,6 +3092,8 @@ function HMGT:SlashCommand(input) else self:Print("HMGT Bridge is not loaded.") end + elseif input == "status" then + self:PrintHealthStatus() elseif input == "debug" then if self.ToggleDevToolsWindow then self:ToggleDevToolsWindow() diff --git a/HailMaryGuildToolsOptions.lua b/HailMaryGuildToolsOptions.lua index 8bc2d50..948bbef 100644 --- a/HailMaryGuildToolsOptions.lua +++ b/HailMaryGuildToolsOptions.lua @@ -1835,6 +1835,88 @@ function HMGT_Config:Initialize() end end, }, + devToolsEnabled = { + type = "toggle", + order = 2, + width = "full", + name = L["OPT_DEVTOOLS_MODE"] or L["OPT_DEBUG_MODE"] or "Debug console", + desc = L["OPT_DEVTOOLS_MODE_DESC"] or L["OPT_DEBUG_MODE_DESC"] or "Enable the debug console.", + get = function() + return HMGT.GetDevToolsSettings and HMGT:GetDevToolsSettings().enabled == true + end, + set = function(_, value) + if not HMGT.GetDevToolsSettings then + return + end + HMGT:GetDevToolsSettings().enabled = value == true + if HMGT.UpdateDevToolsWindowVisibility then + HMGT:UpdateDevToolsWindowVisibility() + end + end, + }, + debugLevel = { + type = "select", + order = 3, + width = "full", + name = L["OPT_DEBUG_LEVEL"] or "Debug level", + values = function() + return HMGT.GetDebugLevelOptions and HMGT:GetDebugLevelOptions() or {} + end, + get = function() + return HMGT.GetConfiguredDebugLevel and HMGT:GetConfiguredDebugLevel() or "info" + end, + set = function(_, value) + if HMGT.GetDevToolsSettings then + HMGT:GetDevToolsSettings().level = value or "info" + end + if HMGT.RefreshDevToolsWindow then + HMGT:RefreshDevToolsWindow() + end + end, + }, + debugScope = { + type = "select", + order = 4, + width = "full", + name = L["OPT_DEBUG_SCOPE"] or "Module filter", + values = function() + return HMGT.GetDebugScopeOptions and HMGT:GetDebugScopeOptions() or {} + end, + get = function() + local settings = HMGT.GetDevToolsSettings and HMGT:GetDevToolsSettings() or {} + return settings.scope or "ALL" + end, + set = function(_, value) + if HMGT.GetDevToolsSettings then + HMGT:GetDevToolsSettings().scope = value or "ALL" + end + if HMGT.RefreshDevToolsWindow then + HMGT:RefreshDevToolsWindow() + end + end, + }, + openDebug = { + type = "execute", + order = 5, + width = "half", + name = L["OPT_DEVTOOLS_OPEN"] or L["OPT_DEBUG_OPEN"] or "Open debug console", + func = function() + if HMGT.OpenDevToolsWindow then + HMGT:OpenDevToolsWindow() + end + end, + }, + clearDebug = { + type = "execute", + order = 6, + width = "half", + name = L["OPT_DEVTOOLS_CLEAR"] or L["OPT_DEBUG_CLEAR"] or "Clear debug log", + func = function() + if HMGT.ClearDevToolsLog then + HMGT:ClearDevToolsLog() + end + end, + }, }, }, commands = { @@ -1850,6 +1932,7 @@ function HMGT_Config:Initialize() name = table.concat({ "|cffffd100/hmgt|r", "|cffffd100/hmgt debug|r", + "|cffffd100/hmgt status|r", "|cffffd100/hmgt version|r", }, "\n"), }, diff --git a/Locales/deDE.lua b/Locales/deDE.lua index 5679e7f..6a6d0a8 100644 --- a/Locales/deDE.lua +++ b/Locales/deDE.lua @@ -64,15 +64,15 @@ L["OPT_DEBUG_CLEAR"] = "Debug-Log leeren" L["OPT_DEBUG_SELECT_ALL"] = "Alles markieren" L["DEBUG_WINDOW_TITLE"] = "HMGT Debug-Konsole" L["DEBUG_WINDOW_HINT"] = "Mit dem Mausrad scrollen, Strg+A markiert alles, Strg+C kopiert markierten Text" -L["OPT_DEVTOOLS_MODE"] = "Entwicklerwerkzeuge" -L["OPT_DEVTOOLS_MODE_DESC"] = "Aktiviert die strukturierte Entwickler-Konsole." -L["OPT_DEVTOOLS_LEVEL"] = "Erfassungsstufe" +L["OPT_DEVTOOLS_MODE"] = "Debug-Konsole" +L["OPT_DEVTOOLS_MODE_DESC"] = "Aktiviert das gemeinsame Debug- und Entwickler-Log." +L["OPT_DEVTOOLS_LEVEL"] = "Debug-Stufe" L["OPT_DEVTOOLS_LEVEL_ERROR"] = "Fehler" -L["OPT_DEVTOOLS_LEVEL_TRACE"] = "Trace" -L["OPT_DEVTOOLS_SCOPE"] = "Scope-Filter" -L["OPT_DEVTOOLS_SCOPE_ALL"] = "Alle Scopes" -L["OPT_DEVTOOLS_OPEN"] = "Entwickler-Konsole oeffnen" -L["OPT_DEVTOOLS_CLEAR"] = "Entwickler-Log leeren" +L["OPT_DEVTOOLS_LEVEL_TRACE"] = "Ausfuehrlich" +L["OPT_DEVTOOLS_SCOPE"] = "Modulfilter" +L["OPT_DEVTOOLS_SCOPE_ALL"] = "Alle Module" +L["OPT_DEVTOOLS_OPEN"] = "Debug-Konsole oeffnen" +L["OPT_DEVTOOLS_CLEAR"] = "Debug-Log leeren" L["OPT_DEVTOOLS_SELECT_ALL"] = "Alles markieren" L["OPT_DEVTOOLS_DISABLED"] = "HMGT: Entwicklerwerkzeuge sind nicht aktiviert." L["DEVTOOLS_WINDOW_TITLE"] = "HMGT Entwicklerwerkzeuge" diff --git a/Locales/enUS.lua b/Locales/enUS.lua index 068ce49..cad8f90 100644 --- a/Locales/enUS.lua +++ b/Locales/enUS.lua @@ -64,15 +64,15 @@ L["OPT_DEBUG_CLEAR"] = "Clear debug log" L["OPT_DEBUG_SELECT_ALL"] = "Select all" L["DEBUG_WINDOW_TITLE"] = "HMGT Debug Console" L["DEBUG_WINDOW_HINT"] = "Mouse wheel scrolls, Ctrl+A selects all, Ctrl+C copies selected text" -L["OPT_DEVTOOLS_MODE"] = "Developer tools" -L["OPT_DEVTOOLS_MODE_DESC"] = "Enable the structured developer event console." -L["OPT_DEVTOOLS_LEVEL"] = "Capture level" +L["OPT_DEVTOOLS_MODE"] = "Debug console" +L["OPT_DEVTOOLS_MODE_DESC"] = "Enable the shared debug and developer log." +L["OPT_DEVTOOLS_LEVEL"] = "Debug level" L["OPT_DEVTOOLS_LEVEL_ERROR"] = "Errors" -L["OPT_DEVTOOLS_LEVEL_TRACE"] = "Trace" -L["OPT_DEVTOOLS_SCOPE"] = "Scope filter" -L["OPT_DEVTOOLS_SCOPE_ALL"] = "All scopes" -L["OPT_DEVTOOLS_OPEN"] = "Open developer console" -L["OPT_DEVTOOLS_CLEAR"] = "Clear developer log" +L["OPT_DEVTOOLS_LEVEL_TRACE"] = "Verbose" +L["OPT_DEVTOOLS_SCOPE"] = "Module filter" +L["OPT_DEVTOOLS_SCOPE_ALL"] = "All modules" +L["OPT_DEVTOOLS_OPEN"] = "Open debug console" +L["OPT_DEVTOOLS_CLEAR"] = "Clear debug log" L["OPT_DEVTOOLS_SELECT_ALL"] = "Select all" L["OPT_DEVTOOLS_DISABLED"] = "HMGT: developer tools are not enabled." L["DEVTOOLS_WINDOW_TITLE"] = "HMGT Developer Tools" diff --git a/Modules/Tracker/GroupCooldownTracker/GroupCooldownTracker.lua b/Modules/Tracker/GroupCooldownTracker/GroupCooldownTracker.lua index dd91d9c..db5ad93 100644 --- a/Modules/Tracker/GroupCooldownTracker/GroupCooldownTracker.lua +++ b/Modules/Tracker/GroupCooldownTracker/GroupCooldownTracker.lua @@ -23,8 +23,12 @@ function module:GetDefinition() end function module:GetSettings() - local profile = HMGT.db and HMGT.db.profile - return profile and profile[self.definition.dbKey] or nil + for _, tracker in ipairs(HMGT:GetTrackerConfigs()) do + if tracker.trackerKey == self.definition.trackerKey then + return tracker + end + end + return nil end function module:Enable() diff --git a/Modules/Tracker/InterruptTracker/InterruptTracker.lua b/Modules/Tracker/InterruptTracker/InterruptTracker.lua index e9d3eea..81ff4b4 100644 --- a/Modules/Tracker/InterruptTracker/InterruptTracker.lua +++ b/Modules/Tracker/InterruptTracker/InterruptTracker.lua @@ -23,8 +23,12 @@ function module:GetDefinition() end function module:GetSettings() - local profile = HMGT.db and HMGT.db.profile - return profile and profile[self.definition.dbKey] or nil + for _, tracker in ipairs(HMGT:GetTrackerConfigs()) do + if tracker.trackerKey == self.definition.trackerKey then + return tracker + end + end + return nil end function module:Enable() diff --git a/Modules/Tracker/RaidcooldownTracker/RaidcooldownTracker.lua b/Modules/Tracker/RaidcooldownTracker/RaidcooldownTracker.lua index 7191632..4929241 100644 --- a/Modules/Tracker/RaidcooldownTracker/RaidcooldownTracker.lua +++ b/Modules/Tracker/RaidcooldownTracker/RaidcooldownTracker.lua @@ -23,8 +23,12 @@ function module:GetDefinition() end function module:GetSettings() - local profile = HMGT.db and HMGT.db.profile - return profile and profile[self.definition.dbKey] or nil + for _, tracker in ipairs(HMGT:GetTrackerConfigs()) do + if tracker.trackerKey == self.definition.trackerKey then + return tracker + end + end + return nil end function module:Enable() diff --git a/readme.md b/readme.md index 579c838..538809b 100644 --- a/readme.md +++ b/readme.md @@ -16,7 +16,6 @@ It combines cooldown tracking, encounter reminders, notes, and map utilities in - Per-tracker bar and icon layouts - Aura Expiry for selected buffs and channels - Raid Timeline for encounter-based text reminders and raid cooldown assignments -- Notes window for raid or personal note management - Map Overlay with custom world map POIs - Version mismatch detection inside groups and raids - Blizzard AddOn options integration with Ace3-based module configuration @@ -63,6 +62,8 @@ Provides a dedicated notes window for raid notes, personal notes, and drafts. Opens the developer tools window - `/hmgt dev` Alias for the developer tools window +- `/hmgt status` + Prints a compact addon health check - `/hmgt version` Opens the version window when developer tools are enabled -- 2.39.5 From f97b7556cdebfeff33268e9b46af1eb8ed75e6c9 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Tue, 28 Apr 2026 23:09:04 +0200 Subject: [PATCH 7/7] Refactor code structure for improved readability and maintainability --- HailMaryGuildTools.lua | 101 ++ HailMaryGuildTools.toc | 5 + HailMaryGuildToolsOptions.lua | 29 +- Locales/deDE.lua | 32 +- Locales/enUS.lua | 32 +- Modules/EncounterAlerts/EncounterAlerts.lua | 102 ++ .../EncounterAlertsOptions.lua | 414 ++++++ Modules/EncounterAlerts/LuraRunes.lua | 1113 +++++++++++++++++ .../EncounterAlerts/Media/LuraRunes/.gitkeep | 1 + .../Media/LuraRunes/Rune_Circle.tga | Bin 0 -> 65554 bytes .../Media/LuraRunes/Rune_Diamond.tga | Bin 0 -> 65554 bytes .../Media/LuraRunes/Rune_T.tga | Bin 0 -> 65554 bytes .../Media/LuraRunes/Rune_Triangle.tga | Bin 0 -> 65554 bytes .../Media/LuraRunes/Rune_X.tga | Bin 0 -> 65554 bytes Modules/Tracker/TrackerSync.lua | 5 + readme.md | 16 +- 16 files changed, 1841 insertions(+), 9 deletions(-) create mode 100644 Modules/EncounterAlerts/EncounterAlerts.lua create mode 100644 Modules/EncounterAlerts/EncounterAlertsOptions.lua create mode 100644 Modules/EncounterAlerts/LuraRunes.lua create mode 100644 Modules/EncounterAlerts/Media/LuraRunes/.gitkeep create mode 100644 Modules/EncounterAlerts/Media/LuraRunes/Rune_Circle.tga create mode 100644 Modules/EncounterAlerts/Media/LuraRunes/Rune_Diamond.tga create mode 100644 Modules/EncounterAlerts/Media/LuraRunes/Rune_T.tga create mode 100644 Modules/EncounterAlerts/Media/LuraRunes/Rune_Triangle.tga create mode 100644 Modules/EncounterAlerts/Media/LuraRunes/Rune_X.tga diff --git a/HailMaryGuildTools.lua b/HailMaryGuildTools.lua index e422c33..959d480 100644 --- a/HailMaryGuildTools.lua +++ b/HailMaryGuildTools.lua @@ -40,6 +40,7 @@ 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_LURA_RUNES = "LUR" -- LUR|slot1,slot2,slot3,slot4,slot5 local MSG_RELIABLE = "REL" -- REL|messageId|innerPayload local MSG_ACK = "ACK" -- ACK|messageId local COMM_PREFIX = "HMGT" @@ -85,6 +86,7 @@ HMGT.MSG_RELIABLE = MSG_RELIABLE HMGT.MSG_ACK = MSG_ACK HMGT.MSG_RAID_TIMELINE = MSG_RAID_TIMELINE HMGT.MSG_RAID_TIMELINE_TEST = MSG_RAID_TIMELINE_TEST +HMGT.MSG_LURA_RUNES = MSG_LURA_RUNES -- ── Standardwerte ───────────────────────────────────────────── local defaults = { @@ -119,6 +121,34 @@ local defaults = { alertColor = { r = 1, g = 0.82, b = 0.15, a = 1 }, encounters = {}, }, + encounterAlerts = { + enabled = false, + luraRunes = { + enabled = false, + unlocked = false, + posX = 0, + posY = -120, + iconSize = 44, + backgroundAlpha = 0.14, + showLabels = true, + actionBar = { + shown = false, + autoShow = true, + unlocked = false, + posX = 0, + posY = -300, + iconSize = 42, + iconSpacing = 8, + orientation = "horizontal", + border = { + enabled = false, + width = 2, + color = { r = 1, g = 0.82, b = 0.1, a = 0.9 }, + }, + }, + slots = {}, + }, + }, notes = { enabled = true, mainText = "", @@ -196,6 +226,7 @@ local DEBUG_SCOPE_LABELS = { PowerSpend = "Power Spend", RaidTimeline = "Raid Timeline", Notes = "Notes", + EncounterAlerts = "Encounter Alerts", } local DEBUG_LEVELS = { error = 1, @@ -1458,6 +1489,67 @@ local function NormalizeRaidTimelineSettings(settings) settings.encounters = normalizedEncounters end +local VALID_LURA_RUNE_KEYS = { + circle = true, + cross = true, + diamond = true, + t = true, + triangle = true, +} + +local function NormalizeLuraRuneKey(value) + local key = tostring(value or ""):lower() + if VALID_LURA_RUNE_KEYS[key] then + return key + end + return "" +end + +local function NormalizeLuraRunesSettings(settings) + if type(settings) ~= "table" then return end + settings.enabled = settings.enabled == true + settings.unlocked = settings.unlocked == true + settings.posX = math.floor(NormalizeLayoutValue(settings.posX, -1200, 1200, 0) + 0.5) + settings.posY = math.floor(NormalizeLayoutValue(settings.posY, -900, 900, -120) + 0.5) + settings.iconSize = math.floor(NormalizeLayoutValue(settings.iconSize, 28, 80, 44) + 0.5) + settings.backgroundAlpha = NormalizeLayoutValue(settings.backgroundAlpha, 0, 0.8, 0.14) + settings.showLabels = settings.showLabels ~= false + settings.actionBar = type(settings.actionBar) == "table" and settings.actionBar or {} + settings.actionBar.shown = settings.actionBar.shown == true + settings.actionBar.autoShow = settings.actionBar.autoShow ~= false + settings.actionBar.unlocked = settings.actionBar.unlocked == true + settings.actionBar.posX = math.floor(NormalizeLayoutValue(settings.actionBar.posX, -1200, 1200, 0) + 0.5) + settings.actionBar.posY = math.floor(NormalizeLayoutValue(settings.actionBar.posY, -900, 900, -300) + 0.5) + settings.actionBar.iconSize = math.floor(NormalizeLayoutValue(settings.actionBar.iconSize, 28, 80, 42) + 0.5) + settings.actionBar.iconSpacing = math.floor(NormalizeLayoutValue(settings.actionBar.iconSpacing, 0, 80, 8) + 0.5) + settings.actionBar.orientation = tostring(settings.actionBar.orientation or "horizontal") + if settings.actionBar.orientation ~= "vertical" then + settings.actionBar.orientation = "horizontal" + end + settings.actionBar.border = type(settings.actionBar.border) == "table" and settings.actionBar.border or {} + settings.actionBar.border.enabled = settings.actionBar.border.enabled == true + settings.actionBar.border.width = math.floor(NormalizeLayoutValue(settings.actionBar.border.width, 1, 12, 2) + 0.5) + settings.actionBar.border.color = type(settings.actionBar.border.color) == "table" and settings.actionBar.border.color or {} + settings.actionBar.border.color.r = NormalizeLayoutValue(settings.actionBar.border.color.r, 0, 1, 1) + settings.actionBar.border.color.g = NormalizeLayoutValue(settings.actionBar.border.color.g, 0, 1, 0.82) + settings.actionBar.border.color.b = NormalizeLayoutValue(settings.actionBar.border.color.b, 0, 1, 0.1) + settings.actionBar.border.color.a = NormalizeLayoutValue(settings.actionBar.border.color.a, 0, 1, 0.9) + + local slots = type(settings.slots) == "table" and settings.slots or {} + local normalizedSlots = {} + for slot = 1, 5 do + normalizedSlots[slot] = NormalizeLuraRuneKey(slots[slot]) + end + settings.slots = normalizedSlots +end + +local function NormalizeEncounterAlertsSettings(settings) + if type(settings) ~= "table" then return end + settings.enabled = settings.enabled == true + settings.luraRunes = type(settings.luraRunes) == "table" and settings.luraRunes or {} + NormalizeLuraRunesSettings(settings.luraRunes) +end + local function NormalizeNotesSettings(settings) if type(settings) ~= "table" then return end settings.enabled = settings.enabled ~= false @@ -1762,6 +1854,8 @@ function HMGT:MigrateProfileSettings() p.personalAuras = nil p.raidTimeline = p.raidTimeline or {} NormalizeRaidTimelineSettings(p.raidTimeline) + p.encounterAlerts = p.encounterAlerts or {} + NormalizeEncounterAlertsSettings(p.encounterAlerts) p.notes = p.notes or {} NormalizeNotesSettings(p.notes) p.minimap = p.minimap or {} @@ -3029,6 +3123,7 @@ function HMGT:GetHealthStatusLines() AuraExpiry = self.AuraExpiry ~= nil, MapOverlay = self.MapOverlay ~= nil, RaidTimeline = self.RaidTimeline ~= nil, + EncounterAlerts = self.EncounterAlerts ~= nil, Notes = self.Notes ~= nil, } local moduleParts = {} @@ -3108,6 +3203,12 @@ function HMGT:SlashCommand(input) else self:OpenConfig() end + elseif input:find("^lura") == 1 then + if self.EncounterAlerts and self.EncounterAlerts.HandleSlashCommand then + self.EncounterAlerts:HandleSlashCommand(input) + else + self:OpenConfig() + end elseif input:find("^debugdump") == 1 then local n = tonumber(input:match("^debugdump%s+(%d+)$")) if self.DumpDevToolsLog then diff --git a/HailMaryGuildTools.toc b/HailMaryGuildTools.toc index bef1e5e..4da4cc6 100644 --- a/HailMaryGuildTools.toc +++ b/HailMaryGuildTools.toc @@ -66,3 +66,8 @@ Modules\RaidTimeline\RaidTimelineBossAbilityData.lua Modules\RaidTimeline\RaidTimeline.lua Modules\RaidTimeline\RaidTimelineBigWigs.lua Modules\RaidTimeline\RaidTimelineOptions.lua + +# EncounterAlerts +Modules\EncounterAlerts\EncounterAlerts.lua +Modules\EncounterAlerts\LuraRunes.lua +Modules\EncounterAlerts\EncounterAlertsOptions.lua diff --git a/HailMaryGuildToolsOptions.lua b/HailMaryGuildToolsOptions.lua index 948bbef..fec4454 100644 --- a/HailMaryGuildToolsOptions.lua +++ b/HailMaryGuildToolsOptions.lua @@ -17,6 +17,9 @@ function HMGT_Config:RegisterOptionsProvider(id, provider) if type(id) ~= "string" or id == "" then return false end if type(provider) ~= "function" then return false end self._optionProviders[id] = provider + if type(self.RebuildRootOptions) == "function" then + self:RebuildRootOptions() + end return true end @@ -1933,6 +1936,7 @@ function HMGT_Config:Initialize() "|cffffd100/hmgt|r", "|cffffd100/hmgt debug|r", "|cffffd100/hmgt status|r", + "|cffffd100/hmgt lura|r", "|cffffd100/hmgt version|r", }, "\n"), }, @@ -2083,6 +2087,15 @@ function HMGT_Config:Initialize() modulesGroup.args.raidTimeline = raidTimelineGroup end + local encounterAlertsGroup = BuildNamedModuleGroup( + "encounterAlerts", + L["OPT_MODULE_ENCOUNTER_ALERTS"] or "Encounter Alerts", + 50 + ) + if encounterAlertsGroup then + modulesGroup.args.encounterAlerts = encounterAlertsGroup + end + if next(modulesGroup.args) == nil then return nil end @@ -2120,12 +2133,20 @@ function HMGT_Config:Initialize() }, } - local modulesGroup = BuildModulesGroup() - if modulesGroup then - rootOptions.args.modules = modulesGroup + function HMGT_Config:RebuildRootOptions() + local modulesGroup = BuildModulesGroup() + if modulesGroup then + rootOptions.args.modules = modulesGroup + else + rootOptions.args.modules = nil + end + NormalizeExecuteButtonWidths(rootOptions) + if AceConfigRegistry and type(AceConfigRegistry.NotifyChange) == "function" then + AceConfigRegistry:NotifyChange(ADDON_NAME) + end end - NormalizeExecuteButtonWidths(rootOptions) + HMGT_Config:RebuildRootOptions() local aceConfig = LibStub("AceConfig-3.0") local aceConfigDialog = LibStub("AceConfigDialog-3.0") diff --git a/Locales/deDE.lua b/Locales/deDE.lua index 6a6d0a8..b369276 100644 --- a/Locales/deDE.lua +++ b/Locales/deDE.lua @@ -34,7 +34,7 @@ L["VERSION_WINDOW_ASSISTANT_TAG"] = "(Assist)" L["VERSION_WINDOW_SELF_TAG"] = "(Du)" L["VERSION_OUTDATED_WHISPER"] = "Deine Hail Mary Guild Tools Version ist veraltet. Du hast %s, der Gruppenleiter hat %s." L["VERSION_WINDOW_DEBUG_ONLY"] = "HMGT: /hmgt version ist nur bei aktiviertem Debugmodus verfuegbar." -L["VERSION_WINDOW_DEVTOOLS_ONLY"] = "HMGT: /hmgt version ist nur bei aktivierten Entwicklerwerkzeugen verfuegbar." +L["VERSION_WINDOW_DEVTOOLS_ONLY"] = "HMGT: /hmgt version ist nur bei aktivierter Debug-Konsole verfuegbar." -- ── Options: general ───────────────────────────────────────── L["OPT_GENERAL"] = "Allgemein" @@ -75,6 +75,36 @@ L["OPT_DEVTOOLS_OPEN"] = "Debug-Konsole oeffnen" L["OPT_DEVTOOLS_CLEAR"] = "Debug-Log leeren" L["OPT_DEVTOOLS_SELECT_ALL"] = "Alles markieren" L["OPT_DEVTOOLS_DISABLED"] = "HMGT: Entwicklerwerkzeuge sind nicht aktiviert." +L["OPT_MODULE_ENCOUNTER_ALERTS"] = "Encounter Alerts" +L["OPT_ENCOUNTER_ALERTS_PLACEHOLDER"] = "Encounter-spezifische Helper-Frames und Warnungen." +L["OPT_EA_LURA_TITLE"] = "L'ura Runen" +L["OPT_EA_LURA_RUNE_WINDOW"] = "Runen-Fenster" +L["OPT_EA_LURA_ENABLED"] = "L'ura Runen aktivieren" +L["OPT_EA_LURA_UNLOCK"] = "Runen-Frame entsperren" +L["OPT_EA_LURA_HINT"] = "Erste Version: nur Normal/Heroisch Layout. Tank steht unten mittig zwischen Slot 1 und 5." +L["OPT_EA_LURA_SHOW"] = "Anzeigen" +L["OPT_EA_LURA_TEST"] = "Testmuster" +L["OPT_EA_LURA_CLEAR"] = "Leeren" +L["OPT_EA_LURA_BROADCAST"] = "Sequenz senden" +L["OPT_EA_LURA_ACTIONBAR"] = "Runen-Actionbar" +L["OPT_EA_LURA_ACTIONBAR_SHOW"] = "Leiste anzeigen" +L["OPT_EA_LURA_ACTIONBAR_UNLOCK"] = "Leiste entsperren" +L["OPT_EA_LURA_ACTIONBAR_AUTO_SHOW"] = "Automatisch im Bossraum anzeigen" +L["OPT_EA_LURA_ACTIONBAR_ORIENTATION"] = "Ausrichtung" +L["OPT_EA_LURA_ACTIONBAR_HORIZONTAL"] = "Horizontal" +L["OPT_EA_LURA_ACTIONBAR_VERTICAL"] = "Vertikal" +L["OPT_EA_LURA_ACTIONBAR_HINT"] = "Klicke die Runen in beobachteter Reihenfolge. Slot 5 sendet die Sequenz automatisch. Der rote Button leert die lokale Sequenz." +L["OPT_EA_LURA_ICON_SIZE"] = "Icongroesse" +L["OPT_EA_LURA_BACKGROUND_ALPHA"] = "Hintergrund-Alpha" +L["OPT_EA_LURA_ICON_SPACING"] = "Icon-Abstand" +L["OPT_EA_LURA_BORDER_ENABLED"] = "Rahmen anzeigen" +L["OPT_EA_LURA_BORDER_WIDTH"] = "Rahmenbreite" +L["OPT_EA_LURA_BORDER_COLOR"] = "Rahmenfarbe" +L["OPT_EA_LURA_SHOW_LABELS"] = "Labels anzeigen" +L["OPT_EA_LURA_RUNE_EMPTY"] = "Leer" +L["OPT_EA_LURA_DRAG_HINT"] = "Ziehen zum Verschieben" +L["OPT_EA_LURA_BOSS"] = "Boss" +L["OPT_EA_LURA_TANK"] = "Tank" L["DEVTOOLS_WINDOW_TITLE"] = "HMGT Entwicklerwerkzeuge" L["DEVTOOLS_WINDOW_HINT"] = "Strukturierte Entwickler-Ereignisse fuer die aktuelle Sitzung" L["OPT_SYNC_REMOTE_CHARGES"] = "Remote-Aufladungen synchronisieren" diff --git a/Locales/enUS.lua b/Locales/enUS.lua index cad8f90..f924893 100644 --- a/Locales/enUS.lua +++ b/Locales/enUS.lua @@ -34,7 +34,7 @@ L["VERSION_WINDOW_ASSISTANT_TAG"] = "(Assist)" L["VERSION_WINDOW_SELF_TAG"] = "(You)" L["VERSION_OUTDATED_WHISPER"] = "Your Hail Mary Guild Tools version is outdated. You have %s, the group leader has %s." L["VERSION_WINDOW_DEBUG_ONLY"] = "HMGT: /hmgt version is only available while debug mode is enabled." -L["VERSION_WINDOW_DEVTOOLS_ONLY"] = "HMGT: /hmgt version is only available while developer tools are enabled." +L["VERSION_WINDOW_DEVTOOLS_ONLY"] = "HMGT: /hmgt version is only available while the debug console is enabled." -- ── Options: general ───────────────────────────────────────── L["OPT_GENERAL"] = "General" @@ -75,6 +75,36 @@ L["OPT_DEVTOOLS_OPEN"] = "Open debug console" L["OPT_DEVTOOLS_CLEAR"] = "Clear debug log" L["OPT_DEVTOOLS_SELECT_ALL"] = "Select all" L["OPT_DEVTOOLS_DISABLED"] = "HMGT: developer tools are not enabled." +L["OPT_MODULE_ENCOUNTER_ALERTS"] = "Encounter Alerts" +L["OPT_ENCOUNTER_ALERTS_PLACEHOLDER"] = "Encounter-specific helper frames and alerts." +L["OPT_EA_LURA_TITLE"] = "L'ura Runes" +L["OPT_EA_LURA_RUNE_WINDOW"] = "Rune window" +L["OPT_EA_LURA_ENABLED"] = "Enable L'ura runes" +L["OPT_EA_LURA_UNLOCK"] = "Unlock rune frame" +L["OPT_EA_LURA_HINT"] = "First version: normal/heroic layout only. Tank reference is placed bottom-center between slot 1 and 5." +L["OPT_EA_LURA_SHOW"] = "Show" +L["OPT_EA_LURA_TEST"] = "Test pattern" +L["OPT_EA_LURA_CLEAR"] = "Clear" +L["OPT_EA_LURA_BROADCAST"] = "Send sequence" +L["OPT_EA_LURA_ACTIONBAR"] = "Rune action bar" +L["OPT_EA_LURA_ACTIONBAR_SHOW"] = "Show bar" +L["OPT_EA_LURA_ACTIONBAR_UNLOCK"] = "Unlock bar" +L["OPT_EA_LURA_ACTIONBAR_AUTO_SHOW"] = "Auto show in boss room" +L["OPT_EA_LURA_ACTIONBAR_ORIENTATION"] = "Orientation" +L["OPT_EA_LURA_ACTIONBAR_HORIZONTAL"] = "Horizontal" +L["OPT_EA_LURA_ACTIONBAR_VERTICAL"] = "Vertical" +L["OPT_EA_LURA_ACTIONBAR_HINT"] = "Click rune buttons in the observed order. Slot 5 sends the sequence automatically. The red button clears the local sequence." +L["OPT_EA_LURA_ICON_SIZE"] = "Icon size" +L["OPT_EA_LURA_BACKGROUND_ALPHA"] = "Background alpha" +L["OPT_EA_LURA_ICON_SPACING"] = "Icon spacing" +L["OPT_EA_LURA_BORDER_ENABLED"] = "Show border" +L["OPT_EA_LURA_BORDER_WIDTH"] = "Border width" +L["OPT_EA_LURA_BORDER_COLOR"] = "Border color" +L["OPT_EA_LURA_SHOW_LABELS"] = "Show labels" +L["OPT_EA_LURA_RUNE_EMPTY"] = "Empty" +L["OPT_EA_LURA_DRAG_HINT"] = "Drag to move" +L["OPT_EA_LURA_BOSS"] = "Boss" +L["OPT_EA_LURA_TANK"] = "Tank" L["DEVTOOLS_WINDOW_TITLE"] = "HMGT Developer Tools" L["DEVTOOLS_WINDOW_HINT"] = "Structured developer events for the current session" L["OPT_SYNC_REMOTE_CHARGES"] = "Sync remote charges" diff --git a/Modules/EncounterAlerts/EncounterAlerts.lua b/Modules/EncounterAlerts/EncounterAlerts.lua new file mode 100644 index 0000000..cde529a --- /dev/null +++ b/Modules/EncounterAlerts/EncounterAlerts.lua @@ -0,0 +1,102 @@ +local ADDON_NAME = "HailMaryGuildTools" +local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) +if not HMGT then return end + +local L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME, true) or {} + +local EA = HMGT:NewModule("EncounterAlerts", "AceEvent-3.0") +HMGT.EncounterAlerts = EA + +EA.runtimeEnabled = false + +function EA:GetSettings() + local profile = HMGT.db and HMGT.db.profile + profile = profile or {} + profile.encounterAlerts = type(profile.encounterAlerts) == "table" and profile.encounterAlerts or {} + return profile.encounterAlerts +end + +function EA:GetLuraRunesSettings() + local settings = self:GetSettings() + settings.luraRunes = type(settings.luraRunes) == "table" and settings.luraRunes or {} + return settings.luraRunes +end + +function EA:OnEnable() + local settings = self:GetSettings() + self.runtimeEnabled = settings.enabled == true + self:RegisterEvent("PLAYER_ENTERING_WORLD", "RefreshLuraRunesContext") + self:RegisterEvent("ZONE_CHANGED", "RefreshLuraRunesContext") + self:RegisterEvent("ZONE_CHANGED_INDOORS", "RefreshLuraRunesContext") + self:RegisterEvent("ZONE_CHANGED_NEW_AREA", "RefreshLuraRunesContext") + self:RegisterEvent("GROUP_ROSTER_UPDATE", "RefreshLuraRunesContext") + self:RegisterEvent("INSTANCE_ENCOUNTER_ENGAGE_UNIT", "RefreshLuraRunesContext") + self:RegisterEvent("PLAYER_TARGET_CHANGED", "RefreshLuraRunesContext") + self:RegisterEvent("ENCOUNTER_START", "HandleLuraEncounterStart") + self:RegisterEvent("ENCOUNTER_END", "HandleLuraEncounterEnd") + self:RegisterEvent("CHAT_MSG_RAID", "HandleLuraRaidChat") + self:RegisterEvent("CHAT_MSG_RAID_LEADER", "HandleLuraRaidChat") + if self.LuraRunes and self.LuraRunes.Refresh then + self.LuraRunes:Refresh() + end +end + +function EA:Enable() + local settings = self:GetSettings() + settings.enabled = true + self.runtimeEnabled = true + if self.LuraRunes and self.LuraRunes.Refresh then + self.LuraRunes:Refresh() + end +end + +function EA:Disable() + local settings = self:GetSettings() + settings.enabled = false + self.runtimeEnabled = false + if self.LuraRunes and self.LuraRunes.Hide then + self.LuraRunes:Hide() + end +end + +function EA:GetDisplayName() + return L["OPT_MODULE_ENCOUNTER_ALERTS"] or "Encounter Alerts" +end + +function EA:RefreshLuraRunesContext(event) + if self.LuraRunes and self.LuraRunes.RefreshContext then + self.LuraRunes:RefreshContext(event) + end +end + +function EA:HandleLuraEncounterStart(_, encounterId, encounterName) + if self.LuraRunes and self.LuraRunes.OnEncounterStart then + self.LuraRunes:OnEncounterStart(encounterId, encounterName) + end +end + +function EA:HandleLuraEncounterEnd(_, encounterId) + if self.LuraRunes and self.LuraRunes.OnEncounterEnd then + self.LuraRunes:OnEncounterEnd(encounterId) + end +end + +function EA:HandleLuraRunesComm(senderName, payload) + if self.LuraRunes and self.LuraRunes.HandleComm then + self.LuraRunes:HandleComm(senderName, payload) + end +end + +function EA:HandleLuraRaidChat(event, message, senderName) + if self.LuraRunes and self.LuraRunes.HandleRaidChatMessage then + self.LuraRunes:HandleRaidChatMessage(message, senderName, event) + end +end + +function EA:HandleSlashCommand(input) + if self.LuraRunes and self.LuraRunes.HandleSlashCommand then + self.LuraRunes:HandleSlashCommand(input) + else + HMGT:OpenConfig() + end +end diff --git a/Modules/EncounterAlerts/EncounterAlertsOptions.lua b/Modules/EncounterAlerts/EncounterAlertsOptions.lua new file mode 100644 index 0000000..8c9f480 --- /dev/null +++ b/Modules/EncounterAlerts/EncounterAlertsOptions.lua @@ -0,0 +1,414 @@ +local ADDON_NAME = "HailMaryGuildTools" +local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) +if not HMGT then return end +if not HMGT_Config or not HMGT_Config.RegisterOptionsProvider then return end + +local L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME, true) or {} +local AceConfigRegistry = LibStub("AceConfigRegistry-3.0", true) + +local function NotifyOptionsChanged() + if AceConfigRegistry and type(AceConfigRegistry.NotifyChange) == "function" then + AceConfigRegistry:NotifyChange(ADDON_NAME) + end +end + +local function GetSettings() + local profile = HMGT.db and HMGT.db.profile + if not profile then + return {} + end + profile.encounterAlerts = type(profile.encounterAlerts) == "table" and profile.encounterAlerts or {} + return profile.encounterAlerts +end + +local function GetLuraSettings() + local settings = GetSettings() + settings.luraRunes = type(settings.luraRunes) == "table" and settings.luraRunes or {} + settings.luraRunes.slots = type(settings.luraRunes.slots) == "table" and settings.luraRunes.slots or {} + settings.luraRunes.actionBar = type(settings.luraRunes.actionBar) == "table" and settings.luraRunes.actionBar or {} + return settings.luraRunes +end + +local function GetLuraActionBarSettings() + local settings = GetLuraSettings() + settings.actionBar.shown = settings.actionBar.shown == true + settings.actionBar.autoShow = settings.actionBar.autoShow ~= false + settings.actionBar.unlocked = settings.actionBar.unlocked == true + settings.actionBar.iconSize = tonumber(settings.actionBar.iconSize) or 42 + settings.actionBar.iconSpacing = tonumber(settings.actionBar.iconSpacing) or 8 + settings.actionBar.orientation = tostring(settings.actionBar.orientation or "horizontal") + if settings.actionBar.orientation ~= "vertical" then + settings.actionBar.orientation = "horizontal" + end + settings.actionBar.border = type(settings.actionBar.border) == "table" and settings.actionBar.border or {} + return settings.actionBar +end + +local function GetLuraBorderSettings() + local actionBar = GetLuraActionBarSettings() + actionBar.border.enabled = actionBar.border.enabled == true + actionBar.border.width = tonumber(actionBar.border.width) or 2 + actionBar.border.color = type(actionBar.border.color) == "table" and actionBar.border.color or {} + actionBar.border.color.r = tonumber(actionBar.border.color.r) or 1 + actionBar.border.color.g = tonumber(actionBar.border.color.g) or 0.82 + actionBar.border.color.b = tonumber(actionBar.border.color.b) or 0.1 + actionBar.border.color.a = tonumber(actionBar.border.color.a) or 0.9 + return actionBar.border +end + +local function GetActionBarOrientationValues() + return { + horizontal = L["OPT_EA_LURA_ACTIONBAR_HORIZONTAL"] or "Horizontal", + vertical = L["OPT_EA_LURA_ACTIONBAR_VERTICAL"] or "Vertical", + } +end + +local function GetLuraRunes() + return HMGT.EncounterAlerts and HMGT.EncounterAlerts.LuraRunes or nil +end + +local function RefreshEncounterAlerts() + if HMGT.EncounterAlerts then + local settings = GetSettings() + HMGT.EncounterAlerts.runtimeEnabled = settings.enabled == true + if HMGT.EncounterAlerts.LuraRunes and HMGT.EncounterAlerts.LuraRunes.Refresh then + HMGT.EncounterAlerts.LuraRunes:Refresh() + end + end + NotifyOptionsChanged() +end + +local function BuildRuneWindowOptions() + return { + type = "group", + inline = true, + order = 2, + name = L["OPT_EA_LURA_RUNE_WINDOW"] or "Rune window", + args = { + unlocked = { + type = "toggle", + order = 1, + width = "double", + name = L["OPT_EA_LURA_UNLOCK"] or "Unlock rune frame", + get = function() + return GetLuraSettings().unlocked == true + end, + set = function(_, value) + GetSettings().enabled = true + local settings = GetLuraSettings() + settings.enabled = true + settings.unlocked = value == true + RefreshEncounterAlerts() + end, + }, + hint = { + type = "description", + order = 2, + width = "full", + name = L["OPT_EA_LURA_HINT"] or "First version: normal/heroic layout only. Tank reference is placed bottom-center between slot 1 and 5.", + }, + show = { + type = "execute", + order = 3, + width = 0.8, + name = L["OPT_EA_LURA_SHOW"] or "Show", + func = function() + local lura = GetLuraRunes() + if lura and lura.Show then + lura:Show() + end + end, + }, + test = { + type = "execute", + order = 4, + width = 0.9, + name = L["OPT_EA_LURA_TEST"] or "Test pattern", + func = function() + local lura = GetLuraRunes() + if lura and lura.Show and lura.ApplyTestPattern then + lura:Show() + lura:ApplyTestPattern() + end + end, + }, + clear = { + type = "execute", + order = 5, + width = 0.8, + name = L["OPT_EA_LURA_CLEAR"] or "Clear", + func = function() + local lura = GetLuraRunes() + if lura and lura.ClearAssignments then + lura:ClearAssignments(false) + end + end, + }, + broadcast = { + type = "execute", + order = 6, + width = 1.2, + name = L["OPT_EA_LURA_BROADCAST"] or "Broadcast", + disabled = function() + local lura = GetLuraRunes() + return lura and lura.CanBroadcastSequence and not lura:CanBroadcastSequence() or false + end, + func = function() + local lura = GetLuraRunes() + if lura and lura.BroadcastAssignments then + lura:BroadcastAssignments() + end + end, + }, + iconSize = { + type = "range", + order = 7, + width = 1.1, + min = 28, + max = 80, + step = 1, + name = L["OPT_EA_LURA_ICON_SIZE"] or "Icon size", + get = function() + return tonumber(GetLuraSettings().iconSize) or 44 + end, + set = function(_, value) + GetLuraSettings().iconSize = tonumber(value) or 44 + RefreshEncounterAlerts() + end, + }, + backgroundAlpha = { + type = "range", + order = 8, + width = 1.1, + min = 0, + max = 0.8, + step = 0.01, + name = L["OPT_EA_LURA_BACKGROUND_ALPHA"] or "Background alpha", + get = function() + return tonumber(GetLuraSettings().backgroundAlpha) or 0.14 + end, + set = function(_, value) + GetLuraSettings().backgroundAlpha = tonumber(value) or 0.14 + RefreshEncounterAlerts() + end, + }, + }, + } +end + +local function BuildRuneActionBarOptions() + return { + type = "group", + inline = true, + order = 3, + name = L["OPT_EA_LURA_ACTIONBAR"] or "Rune action bar", + args = { + shown = { + type = "toggle", + order = 1, + width = 1.1, + name = L["OPT_EA_LURA_ACTIONBAR_SHOW"] or "Show bar", + get = function() + return GetLuraActionBarSettings().shown == true + end, + set = function(_, value) + GetSettings().enabled = true + local settings = GetLuraSettings() + settings.enabled = true + GetLuraActionBarSettings().shown = value == true + RefreshEncounterAlerts() + end, + }, + unlocked = { + type = "toggle", + order = 2, + width = 1.2, + name = L["OPT_EA_LURA_ACTIONBAR_UNLOCK"] or "Unlock bar", + get = function() + return GetLuraActionBarSettings().unlocked == true + end, + set = function(_, value) + GetSettings().enabled = true + local settings = GetLuraSettings() + settings.enabled = true + local actionBar = GetLuraActionBarSettings() + actionBar.shown = true + actionBar.unlocked = value == true + RefreshEncounterAlerts() + end, + }, + autoShow = { + type = "toggle", + order = 2.5, + width = 1.4, + name = L["OPT_EA_LURA_ACTIONBAR_AUTO_SHOW"] or "Auto show in boss room", + get = function() + return GetLuraActionBarSettings().autoShow == true + end, + set = function(_, value) + GetLuraActionBarSettings().autoShow = value == true + RefreshEncounterAlerts() + end, + }, + orientation = { + type = "select", + order = 3, + width = 1.2, + name = L["OPT_EA_LURA_ACTIONBAR_ORIENTATION"] or "Orientation", + values = GetActionBarOrientationValues, + get = function() + return GetLuraActionBarSettings().orientation + end, + set = function(_, value) + local actionBar = GetLuraActionBarSettings() + actionBar.orientation = tostring(value or "horizontal") + RefreshEncounterAlerts() + end, + }, + iconSize = { + type = "range", + order = 5, + width = 1.1, + min = 28, + max = 80, + step = 1, + name = L["OPT_EA_LURA_ICON_SIZE"] or "Icon size", + get = function() + return tonumber(GetLuraActionBarSettings().iconSize) or 42 + end, + set = function(_, value) + GetLuraActionBarSettings().iconSize = tonumber(value) or 42 + RefreshEncounterAlerts() + end, + }, + iconSpacing = { + type = "range", + order = 6, + width = 1.1, + min = 0, + max = 80, + step = 1, + name = L["OPT_EA_LURA_ICON_SPACING"] or "Icon spacing", + get = function() + return tonumber(GetLuraActionBarSettings().iconSpacing) or 8 + end, + set = function(_, value) + GetLuraActionBarSettings().iconSpacing = tonumber(value) or 8 + RefreshEncounterAlerts() + end, + }, + borderEnabled = { + type = "toggle", + order = 7, + width = 1.1, + name = L["OPT_EA_LURA_BORDER_ENABLED"] or "Show border", + get = function() + return GetLuraBorderSettings().enabled == true + end, + set = function(_, value) + GetLuraBorderSettings().enabled = value == true + RefreshEncounterAlerts() + end, + }, + borderWidth = { + type = "range", + order = 8, + width = 1.1, + min = 1, + max = 12, + step = 1, + name = L["OPT_EA_LURA_BORDER_WIDTH"] or "Border width", + disabled = function() + return GetLuraBorderSettings().enabled ~= true + end, + get = function() + return tonumber(GetLuraBorderSettings().width) or 2 + end, + set = function(_, value) + GetLuraBorderSettings().width = tonumber(value) or 2 + RefreshEncounterAlerts() + end, + }, + borderColor = { + type = "color", + order = 9, + width = 1.1, + hasAlpha = true, + name = L["OPT_EA_LURA_BORDER_COLOR"] or "Border color", + disabled = function() + return GetLuraBorderSettings().enabled ~= true + end, + get = function() + local color = GetLuraBorderSettings().color + return color.r or 1, color.g or 0.82, color.b or 0.1, color.a or 0.9 + end, + set = function(_, r, g, b, a) + local color = GetLuraBorderSettings().color + color.r = tonumber(r) or 1 + color.g = tonumber(g) or 0.82 + color.b = tonumber(b) or 0.1 + color.a = tonumber(a) or 0.9 + RefreshEncounterAlerts() + end, + }, + hint = { + type = "description", + order = 10, + width = "full", + name = L["OPT_EA_LURA_ACTIONBAR_HINT"] or "Click rune buttons in the observed order. Slot 5 sends the sequence automatically.", + }, + }, + } +end + +HMGT_Config:RegisterOptionsProvider("encounterAlerts", function() + return { + path = "encounterAlerts", + order = 50, + group = { + type = "group", + name = L["OPT_MODULE_ENCOUNTER_ALERTS"] or "Encounter Alerts", + order = 50, + childGroups = "tab", + args = { + general = { + type = "group", + name = L["OPT_GENERAL"] or "General", + order = 1, + args = { + description = { + type = "description", + order = 1, + width = "full", + name = L["OPT_ENCOUNTER_ALERTS_PLACEHOLDER"] or "Encounter-specific helper frames and alerts.", + }, + }, + }, + luraRunes = { + type = "group", + name = L["OPT_EA_LURA_TITLE"] or "L'ura Runes", + order = 2, + args = { + enabled = { + type = "toggle", + order = 1, + width = "double", + name = L["OPT_EA_LURA_ENABLED"] or "Enable L'ura runes", + get = function() + return GetLuraSettings().enabled == true + end, + set = function(_, value) + local enabled = value == true + GetSettings().enabled = enabled + GetLuraSettings().enabled = enabled + RefreshEncounterAlerts() + end, + }, + runeWindow = BuildRuneWindowOptions(), + actionBar = BuildRuneActionBarOptions(), + }, + }, + }, + }, + } +end) diff --git a/Modules/EncounterAlerts/LuraRunes.lua b/Modules/EncounterAlerts/LuraRunes.lua new file mode 100644 index 0000000..27f2100 --- /dev/null +++ b/Modules/EncounterAlerts/LuraRunes.lua @@ -0,0 +1,1113 @@ +local ADDON_NAME = "HailMaryGuildTools" +local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) +if not HMGT then return end + +local EA = HMGT.EncounterAlerts +if not EA then return end + +local L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME, true) or {} +local AceConfigRegistry = LibStub("AceConfigRegistry-3.0", true) + +local LR = {} +EA.LuraRunes = LR + +local MEDIA_DIR = "Interface\\AddOns\\HailMaryGuildTools\\Modules\\EncounterAlerts\\Media\\LuraRunes\\" +local FALLBACK_TEXTURE = "Interface\\AddOns\\HailMaryGuildTools\\Media\\HailMaryIcon.png" +local ROLE_ICON_TEXTURE = "Interface\\LFGFrame\\UI-LFG-ICON-ROLES" +local CLEAR_BUTTON_TEXTURE = "Interface\\Buttons\\UI-GroupLoot-Pass-Up" + +local RUNE_ORDER = { "circle", "cross", "diamond", "t", "triangle" } +local RUNE_DATA = { + circle = { + label = "Circle", + chatToken = "Rune_Circle", + texture = MEDIA_DIR .. "Rune_Circle.tga", + aliases = { "kreis", "round", "rund" }, + }, + cross = { + label = "Cross", + chatToken = "Rune_X", + texture = MEDIA_DIR .. "Rune_X.tga", + aliases = { "x", "kreuz" }, + }, + diamond = { + label = "Diamond", + chatToken = "Rune_Diamond", + texture = MEDIA_DIR .. "Rune_Diamond.tga", + aliases = { "diamant", "rhombus" }, + }, + t = { + label = "T", + chatToken = "Rune_T", + texture = MEDIA_DIR .. "Rune_T.tga", + aliases = { "tee" }, + }, + triangle = { + label = "Triangle", + chatToken = "Rune_Triangle", + texture = MEDIA_DIR .. "Rune_Triangle.tga", + aliases = { "dreieck" }, + }, +} + +local RUNE_ALIASES = {} +for key, data in pairs(RUNE_DATA) do + RUNE_ALIASES[key] = key + if data.chatToken then + RUNE_ALIASES[string.lower(data.chatToken)] = key + end + for _, alias in ipairs(data.aliases or {}) do + RUNE_ALIASES[alias] = key + end +end + +local DEFAULT_TEST_ASSIGNMENTS = { + "circle", + "cross", + "diamond", + "t", + "triangle", +} +local LURA_NAME_TOKENS = { "l'ura", "lura" } +local LURA_CONTEXT_MAP_IDS = {} +local LURA_ENCOUNTER_IDS = {} +local LURA_NPC_IDS = {} +local LURA_SCAN_UNITS = { "boss1", "boss2", "boss3", "boss4", "boss5", "target", "focus" } + +local function Debug(level, fmt, ...) + if HMGT and HMGT.DebugScoped then + HMGT:DebugScoped(level or "info", "EncounterAlerts", fmt, ...) + end +end + +local function NotifyOptionsChanged() + if AceConfigRegistry and type(AceConfigRegistry.NotifyChange) == "function" then + AceConfigRegistry:NotifyChange(ADDON_NAME) + end +end + +local function ClampNumber(value, minimum, maximum, fallback) + local number = tonumber(value) + if not number then + number = fallback + end + number = tonumber(number) or 0 + if number < minimum then return minimum end + if number > maximum then return maximum end + return number +end + +local function NormalizeRuneKey(value) + local key = tostring(value or ""):lower() + return RUNE_ALIASES[key] or "" +end + +local function ParseRuneRaidChatMessage(message) + local okText, text = pcall(tostring, message) + if not okText or type(text) ~= "string" then + return nil, nil + end + + local okMatch, slotText, token = pcall(string.match, text, "^HMGT:Rune([1-5]):([%w_%-]+)$") + if not okMatch or not slotText or not token then + return nil, nil + end + + local key = NormalizeRuneKey(token) + if key == "" then + return nil, nil + end + + return tonumber(slotText), key +end + +local function NormalizeActionBarOrientation(value) + if tostring(value or "") == "vertical" then + return "vertical" + end + return "horizontal" +end + +local function TextLooksLikeLura(text) + local value = tostring(text or ""):lower() + if value == "" then + return false + end + for _, token in ipairs(LURA_NAME_TOKENS) do + if string.find(value, token, 1, true) then + return true + end + end + if string.find((value:gsub("%W", "")), "lura", 1, true) then + return true + end + return false +end + +local function GetNPCIdFromGUID(guid) + local _, _, _, _, _, npcId = strsplit("-", tostring(guid or "")) + return tonumber(npcId) +end + +local function UnitLooksLikeLura(unitId) + if not unitId or not UnitExists(unitId) then + return false + end + + local npcId = GetNPCIdFromGUID(UnitGUID(unitId)) + if npcId and LURA_NPC_IDS[npcId] then + return true + end + + return TextLooksLikeLura(UnitName(unitId)) +end + +local function NormalizeColor(color, fallback) + color = type(color) == "table" and color or {} + fallback = type(fallback) == "table" and fallback or {} + return { + r = ClampNumber(color.r, 0, 1, fallback.r or 1), + g = ClampNumber(color.g, 0, 1, fallback.g or 0.82), + b = ClampNumber(color.b, 0, 1, fallback.b or 0.1), + a = ClampNumber(color.a, 0, 1, fallback.a or 0.9), + } +end + +local function ApplyIconBorder(frame, settings, backgroundColor) + if not frame or type(frame.SetBackdrop) ~= "function" then + return + end + + local border = settings and settings.border or {} + if border.enabled ~= true then + frame:SetBackdrop(nil) + return + end + + local width = math.max(1, tonumber(border.width) or 2) + frame:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + edgeSize = width, + insets = { left = width, right = width, top = width, bottom = width }, + }) + + local bg = backgroundColor or { r = 0, g = 0, b = 0, a = 0.35 } + local color = border.color or {} + frame:SetBackdropColor(bg.r or 0, bg.g or 0, bg.b or 0, bg.a or 0.35) + frame:SetBackdropBorderColor(color.r or 1, color.g or 0.82, color.b or 0.1, color.a or 0.9) +end + +local function SetTankIconTexture(texture) + if not texture then + return + end + + if type(texture.SetAtlas) == "function" then + local ok = pcall(texture.SetAtlas, texture, "roleicon-tiny-tank", true) + if ok then + return + end + end + + texture:SetTexture(ROLE_ICON_TEXTURE) + if type(GetTexCoordsForRoleSmallCircle) == "function" then + local left, right, top, bottom = GetTexCoordsForRoleSmallCircle("TANK") + if left and right and top and bottom then + texture:SetTexCoord(left, right, top, bottom) + return + end + elseif type(GetTexCoordsForRole) == "function" then + local left, right, top, bottom = GetTexCoordsForRole("TANK") + if left and right and top and bottom then + texture:SetTexCoord(left, right, top, bottom) + return + end + end + + texture:SetTexCoord(0, 0.26171875, 0.26171875, 0.5234375) +end + +local function HasAssignments(slots) + if type(slots) ~= "table" then + return false + end + for slot = 1, 5 do + if NormalizeRuneKey(slots[slot]) ~= "" then + return true + end + end + return false +end + +local function UnitCanSendSequence(unitId) + if not unitId or not UnitExists(unitId) then + return false + end + if UnitIsGroupLeader and UnitIsGroupLeader(unitId) then + return true + end + if UnitIsGroupAssistant and UnitIsGroupAssistant(unitId) then + return true + end + return false +end + +function LR:IsSequenceSenderAllowed(playerName) + if playerName and playerName ~= "" then + local unitId = HMGT and HMGT.GetUnitForPlayer and HMGT:GetUnitForPlayer(playerName) or nil + return UnitCanSendSequence(unitId) + end + if not IsInGroup() and not IsInRaid() then + return true + end + return UnitCanSendSequence("player") +end + +function LR:CanBroadcastSequence() + return self:IsSequenceSenderAllowed(nil) +end + +function LR:IsTestMode() + return self.testMode == true +end + +function LR:CanUseRuneInput() + return self:IsTestMode() or self:CanBroadcastSequence() +end + +function LR:IsLuraEncounter(encounterId, encounterName) + local id = tonumber(encounterId) or 0 + if LURA_ENCOUNTER_IDS[id] then + return true + end + return TextLooksLikeLura(encounterName) +end + +function LR:HasLuraUnit() + for _, unitId in ipairs(LURA_SCAN_UNITS) do + if UnitLooksLikeLura(unitId) then + return true + end + end + return false +end + +function LR:IsInLuraContext() + if self.luraEncounterActive == true then + return true + end + + local inInstance, instanceType = IsInInstance() + if inInstance ~= true or instanceType ~= "raid" then + return false + end + + local mapId = C_Map and C_Map.GetBestMapForUnit and C_Map.GetBestMapForUnit("player") or nil + if mapId and LURA_CONTEXT_MAP_IDS[mapId] then + return true + end + local mapInfo = mapId and C_Map and C_Map.GetMapInfo and C_Map.GetMapInfo(mapId) or nil + if mapInfo and TextLooksLikeLura(mapInfo.name) then + return true + end + + if TextLooksLikeLura(GetSubZoneText and GetSubZoneText() or nil) + or TextLooksLikeLura(GetMinimapZoneText and GetMinimapZoneText() or nil) + or TextLooksLikeLura(GetRealZoneText and GetRealZoneText() or nil) then + return true + end + + return self:HasLuraUnit() +end + +function LR:ShouldAutoShowActionBar() + local settings = self:GetSettings() + return settings.actionBar.autoShow == true + and self:CanBroadcastSequence() + and self:IsInLuraContext() +end + +function LR:RefreshContext(reason) + local contextActive = self:IsInLuraContext() + local canUse = self:CanUseRuneInput() + local autoShow = self:ShouldAutoShowActionBar() + if contextActive ~= self.lastContextActive or canUse ~= self.lastCanUse or autoShow ~= self.lastAutoShow then + Debug( + "verbose", + "Lura context reason=%s active=%s autoShow=%s canUse=%s", + tostring(reason or "refresh"), + tostring(contextActive), + tostring(autoShow), + tostring(canUse) + ) + self.lastContextActive = contextActive + self.lastCanUse = canUse + self.lastAutoShow = autoShow + end + self:RefreshActionBar() +end + +function LR:OnEncounterStart(encounterId, encounterName) + self.luraEncounterActive = self:IsLuraEncounter(encounterId, encounterName) + if self.luraEncounterActive then + Debug("info", "Lura encounter context started encounter=%s", tostring(encounterId or "?")) + end + self:RefreshContext("encounter_start") +end + +function LR:OnEncounterEnd(encounterId) + if self.luraEncounterActive then + Debug("info", "Lura encounter context ended encounter=%s", tostring(encounterId or "?")) + end + self.luraEncounterActive = false + self:RefreshContext("encounter_end") +end + +function LR:LogNotLeader(context) + Debug("info", "Lura rune %s blocked: only raid leader or raid assist can send", tostring(context or "sequence")) +end + +local function SplitAssignments(payload) + local slots = {} + local text = tostring(payload or "") + local index = 1 + for token in string.gmatch(text .. ",", "([^,]*),") do + if index > 5 then + break + end + slots[index] = NormalizeRuneKey(token) + index = index + 1 + end + for slot = 1, 5 do + slots[slot] = NormalizeRuneKey(slots[slot]) + end + return slots +end + +function LR:GetSettings() + local settings = EA:GetLuraRunesSettings() + settings.enabled = settings.enabled == true + settings.unlocked = settings.unlocked == true + settings.posX = math.floor(ClampNumber(settings.posX, -1200, 1200, 0) + 0.5) + settings.posY = math.floor(ClampNumber(settings.posY, -900, 900, -120) + 0.5) + settings.iconSize = math.floor(ClampNumber(settings.iconSize, 28, 80, 44) + 0.5) + settings.backgroundAlpha = ClampNumber(settings.backgroundAlpha, 0, 0.8, 0.14) + settings.showLabels = settings.showLabels ~= false + settings.actionBar = type(settings.actionBar) == "table" and settings.actionBar or {} + settings.actionBar.shown = settings.actionBar.shown == true + settings.actionBar.autoShow = settings.actionBar.autoShow ~= false + settings.actionBar.unlocked = settings.actionBar.unlocked == true + settings.actionBar.posX = math.floor(ClampNumber(settings.actionBar.posX, -1200, 1200, 0) + 0.5) + settings.actionBar.posY = math.floor(ClampNumber(settings.actionBar.posY, -900, 900, -300) + 0.5) + settings.actionBar.iconSize = math.floor(ClampNumber(settings.actionBar.iconSize, 28, 80, 42) + 0.5) + settings.actionBar.iconSpacing = math.floor(ClampNumber(settings.actionBar.iconSpacing, 0, 80, 8) + 0.5) + settings.actionBar.orientation = NormalizeActionBarOrientation(settings.actionBar.orientation) + settings.actionBar.border = type(settings.actionBar.border) == "table" and settings.actionBar.border or {} + settings.actionBar.border.enabled = settings.actionBar.border.enabled == true + settings.actionBar.border.width = math.floor(ClampNumber(settings.actionBar.border.width, 1, 12, 2) + 0.5) + settings.actionBar.border.color = NormalizeColor(settings.actionBar.border.color, { r = 1, g = 0.82, b = 0.1, a = 0.9 }) + settings.slots = type(settings.slots) == "table" and settings.slots or {} + for slot = 1, 5 do + settings.slots[slot] = NormalizeRuneKey(settings.slots[slot]) + end + return settings +end + +function LR:GetRuneLabel(key) + local normalized = NormalizeRuneKey(key) + local data = RUNE_DATA[normalized] + if data then + return data.label + end + return L["OPT_EA_LURA_RUNE_EMPTY"] or "Empty" +end + +function LR:GetRuneTexture(key) + local normalized = NormalizeRuneKey(key) + local data = RUNE_DATA[normalized] + return (data and data.texture) or nil +end + +function LR:GetRuneChatToken(key) + local normalized = NormalizeRuneKey(key) + local data = RUNE_DATA[normalized] + return (data and data.chatToken) or normalized +end + +function LR:GetAssignmentsSummary() + local settings = self:GetSettings() + local parts = {} + for slot = 1, 5 do + parts[#parts + 1] = string.format("%d=%s", slot, self:GetRuneLabel(settings.slots[slot])) + end + return table.concat(parts, ", ") +end + +function LR:SerializeAssignments() + local settings = self:GetSettings() + local parts = {} + for slot = 1, 5 do + parts[slot] = NormalizeRuneKey(settings.slots[slot]) + end + return table.concat(parts, ",") +end + +function LR:SavePosition() + local frame = self.frame + if not frame then + return + end + local frameCenterX, frameCenterY = frame:GetCenter() + local parentCenterX, parentCenterY = UIParent:GetCenter() + if not frameCenterX or not frameCenterY or not parentCenterX or not parentCenterY then + return + end + local settings = self:GetSettings() + settings.posX = math.floor(frameCenterX - parentCenterX + 0.5) + settings.posY = math.floor(frameCenterY - parentCenterY + 0.5) +end + +function LR:SaveActionBarPosition() + local frame = self.actionBarFrame + if not frame then + return + end + local frameCenterX, frameCenterY = frame:GetCenter() + local parentCenterX, parentCenterY = UIParent:GetCenter() + if not frameCenterX or not frameCenterY or not parentCenterX or not parentCenterY then + return + end + local settings = self:GetSettings() + settings.actionBar.posX = math.floor(frameCenterX - parentCenterX + 0.5) + settings.actionBar.posY = math.floor(frameCenterY - parentCenterY + 0.5) +end + +function LR:ApplyFrameStyle() + local frame = self.frame + if not frame then + return + end + + local settings = self:GetSettings() + frame:SetScale(1) + frame:ClearAllPoints() + frame:SetPoint("CENTER", UIParent, "CENTER", settings.posX or 0, settings.posY or -120) + frame:EnableMouse(settings.unlocked == true) + + if type(frame.SetBackdrop) == "function" then + local backgroundAlpha = settings.backgroundAlpha or 0.14 + if settings.unlocked == true then + frame:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + edgeSize = 1, + insets = { left = 1, right = 1, top = 1, bottom = 1 }, + }) + frame:SetBackdropColor(0, 0, 0, backgroundAlpha) + frame:SetBackdropBorderColor(0, 0, 0, 1) + elseif backgroundAlpha > 0 then + frame:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + }) + frame:SetBackdropColor(0, 0, 0, backgroundAlpha) + else + frame:SetBackdrop(nil) + end + end + if frame.dragHint then + frame.dragHint:SetShown(settings.unlocked == true) + end + + local iconSize = settings.iconSize or 44 + local iconSpacing = 8 + local slotSize = iconSize + local radiusX = math.max(116, (iconSize + iconSpacing) * 2.35) + local radiusY = math.max(104, (iconSize + iconSpacing) * 2.15) + frame:SetSize(math.max(340, radiusX * 2.25 + slotSize), math.max(300, radiusY * 2.1 + slotSize)) + local positions = { + [1] = { -radiusX * 0.56, -radiusY * 0.70 }, + [2] = { -radiusX, radiusY * 0.08 }, + [3] = { 0, radiusY }, + [4] = { radiusX, radiusY * 0.08 }, + [5] = { radiusX * 0.56, -radiusY * 0.70 }, + } + + for slot = 1, 5 do + local slotFrame = frame.slots and frame.slots[slot] + if slotFrame then + slotFrame:ClearAllPoints() + slotFrame:SetPoint("CENTER", frame, "CENTER", positions[slot][1], positions[slot][2]) + slotFrame:SetSize(slotSize, slotSize) + slotFrame.icon:SetSize(iconSize, iconSize) + if type(slotFrame.SetBackdrop) == "function" then + slotFrame:SetBackdrop(nil) + end + slotFrame.label:SetShown(false) + end + end + + if frame.tank then + frame.tank:ClearAllPoints() + frame.tank:SetPoint("CENTER", frame, "CENTER", 0, -radiusY * 0.98) + frame.tank:SetSize(math.max(28, iconSize * 0.72), math.max(28, iconSize * 0.72)) + frame.tank.icon:SetAllPoints(frame.tank) + end +end + +function LR:UpdateSlot(slot) + local frame = self.frame + local slotFrame = frame and frame.slots and frame.slots[slot] + if not slotFrame then + return + end + + local settings = self:GetSettings() + local key = NormalizeRuneKey(settings.slots[slot]) + local texture = self:GetRuneTexture(key) + if texture then + slotFrame.icon:SetTexture(texture) + slotFrame.icon:SetVertexColor(1, 1, 1, 1) + else + slotFrame.icon:SetTexture(FALLBACK_TEXTURE) + slotFrame.icon:SetVertexColor(0.18, 0.18, 0.18, 0.45) + end + slotFrame.label:SetText("") +end + +function LR:UpdateFrame() + if not self.frame then + return + end + self:ApplyFrameStyle() + for slot = 1, 5 do + self:UpdateSlot(slot) + end +end + +function LR:ApplyActionBarStyle() + local frame = self.actionBarFrame + if not frame then + return + end + + local settings = self:GetSettings() + local actionBar = settings.actionBar + local iconSpacing = actionBar.iconSpacing or 8 + local borderWidth = actionBar.border and actionBar.border.enabled and (actionBar.border.width or 2) or 0 + frame:SetScale(1) + frame:ClearAllPoints() + frame:SetPoint("CENTER", UIParent, "CENTER", actionBar.posX or 0, actionBar.posY or -300) + frame:EnableMouse(actionBar.unlocked == true) + if type(frame.SetBackdrop) == "function" then + if actionBar.unlocked == true then + frame:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + edgeSize = 1, + insets = { left = 1, right = 1, top = 1, bottom = 1 }, + }) + frame:SetBackdropColor(0, 0, 0, 0.28) + frame:SetBackdropBorderColor(0, 0, 0, 1) + else + frame:SetBackdrop(nil) + end + end + if frame.dragHint then + frame.dragHint:SetShown(actionBar.unlocked == true) + end + + local buttons = frame.buttons or {} + local iconSize = actionBar.iconSize or 42 + local buttonSize = iconSize + (borderWidth * 2) + local spacing = iconSpacing + local padding = 8 + local count = math.max(1, #buttons) + local orientation = NormalizeActionBarOrientation(actionBar.orientation) + if orientation == "vertical" then + frame:SetSize(buttonSize + padding, (count * buttonSize) + ((count - 1) * spacing) + padding) + else + frame:SetSize((count * buttonSize) + ((count - 1) * spacing) + padding, buttonSize + padding) + end + + local totalLength = (count * buttonSize) + ((count - 1) * spacing) + local start = -(totalLength - buttonSize) / 2 + for index, button in ipairs(buttons) do + button:SetSize(buttonSize, buttonSize) + if button.icon then + button.icon:SetSize(iconSize, iconSize) + end + ApplyIconBorder(button, actionBar, button.isClear and { r = 0.18, g = 0, b = 0, a = 0.48 } or nil) + button:ClearAllPoints() + if orientation == "vertical" then + button:SetPoint("CENTER", frame, "CENTER", 0, -start - ((index - 1) * (buttonSize + spacing))) + else + button:SetPoint("CENTER", frame, "CENTER", start + ((index - 1) * (buttonSize + spacing)), 0) + end + end + + local canUse = self:CanUseRuneInput() + for _, button in ipairs(buttons) do + if type(button.SetEnabled) == "function" then + button:SetEnabled(canUse) + end + button:SetAlpha(canUse and 1 or 0.45) + end +end + +function LR:ShouldShowActionBar() + local settings = self:GetSettings() + return EA.runtimeEnabled == true + and settings.enabled == true + and (settings.actionBar.shown == true or self:IsTestMode() or self:ShouldAutoShowActionBar()) +end + +function LR:RefreshActionBar() + local frame = self.actionBarFrame + if not frame and not self:ShouldShowActionBar() then + return + end + frame = self:EnsureActionBar() + self:ApplyActionBarStyle() + if self:ShouldShowActionBar() then + frame:Show() + else + frame:Hide() + end +end + +function LR:EnsureActionBar() + if self.actionBarFrame then + return self.actionBarFrame + end + + local frame = CreateFrame("Frame", "HMGT_LuraRuneActionBar", UIParent, "BackdropTemplate") + frame:SetSize(316, 58) + frame:SetFrameStrata("FULLSCREEN_DIALOG") + frame:SetFrameLevel(205) + frame:SetClampedToScreen(true) + frame:SetMovable(true) + frame:RegisterForDrag("LeftButton") + frame:SetScript("OnDragStart", function(selfFrame) + if LR:GetSettings().actionBar.unlocked == true then + selfFrame:StartMoving() + end + end) + frame:SetScript("OnDragStop", function(selfFrame) + selfFrame:StopMovingOrSizing() + LR:SaveActionBarPosition() + LR:ApplyActionBarStyle() + end) + frame:Hide() + + local dragHint = frame:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") + dragHint:SetPoint("TOP", frame, "BOTTOM", 0, -2) + dragHint:SetText(L["OPT_EA_LURA_DRAG_HINT"] or "Drag to move") + frame.dragHint = dragHint + + frame.buttons = {} + local size = 42 + for _, key in ipairs(RUNE_ORDER) do + local data = RUNE_DATA[key] or {} + local button = CreateFrame("Button", nil, frame, "BackdropTemplate") + button:SetSize(size, size) + + button.icon = button:CreateTexture(nil, "ARTWORK") + button.icon:SetPoint("CENTER") + button.icon:SetSize(size - 8, size - 8) + button.icon:SetTexture(data.texture or FALLBACK_TEXTURE) + button.icon:SetVertexColor(1, 1, 1, 1) + + button:SetScript("OnClick", function() + LR:AppendRuneToSequence(key) + LR:ApplyActionBarStyle() + end) + frame.buttons[#frame.buttons + 1] = button + end + + local clearButton = CreateFrame("Button", nil, frame, "BackdropTemplate") + clearButton:SetSize(size, size) + clearButton.isClear = true + clearButton.icon = clearButton:CreateTexture(nil, "ARTWORK") + clearButton.icon:SetPoint("CENTER") + clearButton.icon:SetSize(size - 8, size - 8) + clearButton.icon:SetTexture(CLEAR_BUTTON_TEXTURE) + clearButton.icon:SetVertexColor(1, 1, 1, 1) + clearButton:SetScript("OnClick", function() + LR:ClearAssignments(false) + LR:ApplyActionBarStyle() + end) + frame.clearButton = clearButton + frame.buttons[#frame.buttons + 1] = clearButton + + self.actionBarFrame = frame + self:ApplyActionBarStyle() + return frame +end + +function LR:EnsureFrame() + if self.frame then + return self.frame + end + + local frame = CreateFrame("Frame", "HMGT_LuraRunesFrame", UIParent, "BackdropTemplate") + frame:SetSize(340, 300) + frame:SetFrameStrata("FULLSCREEN_DIALOG") + frame:SetFrameLevel(190) + frame:SetClampedToScreen(true) + frame:SetMovable(true) + frame:RegisterForDrag("LeftButton") + frame:SetScript("OnDragStart", function(selfFrame) + if LR:GetSettings().unlocked == true then + selfFrame:StartMoving() + end + end) + frame:SetScript("OnDragStop", function(selfFrame) + selfFrame:StopMovingOrSizing() + LR:SavePosition() + LR:ApplyFrameStyle() + end) + frame:Hide() + + local dragHint = frame:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall") + dragHint:SetPoint("TOP", frame, "TOP", 0, -4) + dragHint:SetText(L["OPT_EA_LURA_DRAG_HINT"] or "Drag to move") + frame.dragHint = dragHint + + local boss = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge") + boss:SetPoint("CENTER", frame, "CENTER", 0, 0) + boss:SetText(L["OPT_EA_LURA_BOSS"] or "Boss") + frame.boss = boss + + local tank = CreateFrame("Frame", nil, frame) + tank:SetSize(32, 32) + tank.icon = tank:CreateTexture(nil, "ARTWORK") + tank.icon:SetAllPoints(tank) + SetTankIconTexture(tank.icon) + frame.tank = tank + + frame.slots = {} + for slot = 1, 5 do + local slotFrame = CreateFrame("Frame", nil, frame, "BackdropTemplate") + + slotFrame.icon = slotFrame:CreateTexture(nil, "ARTWORK") + slotFrame.icon:SetPoint("CENTER", slotFrame, "CENTER", 0, 0) + slotFrame.icon:SetTexture(FALLBACK_TEXTURE) + + slotFrame.number = slotFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") + slotFrame.number:SetPoint("TOPLEFT", slotFrame, "TOPLEFT", 5, -3) + slotFrame.number:SetText(tostring(slot)) + + slotFrame.label = slotFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + slotFrame.label:SetPoint("TOP", slotFrame.icon, "BOTTOM", 0, -2) + slotFrame.label:SetWidth(90) + slotFrame.label:SetJustifyH("CENTER") + slotFrame.label:SetText("") + slotFrame.label:Hide() + + frame.slots[slot] = slotFrame + end + + self.frame = frame + self:UpdateFrame() + return frame +end + +function LR:ShouldShow() + local settings = self:GetSettings() + return EA.runtimeEnabled == true + and settings.enabled == true + and (settings.unlocked == true or HasAssignments(settings.slots)) +end + +function LR:Refresh() + local frame = self:EnsureFrame() + self:UpdateFrame() + if self:ShouldShow() then + frame:Show() + else + frame:Hide() + end + self:RefreshActionBar() +end + +function LR:Show() + local settings = self:GetSettings() + EA:GetSettings().enabled = true + EA.runtimeEnabled = true + settings.enabled = true + self:EnsureFrame() + self:UpdateFrame() + self.frame:Show() + NotifyOptionsChanged() +end + +function LR:Hide() + self.testMode = false + if self.frame then + self.frame:Hide() + end + if self.actionBarFrame then + self.actionBarFrame:Hide() + end +end + +function LR:ApplyAssignments(slots, source) + local settings = self:GetSettings() + for slot = 1, 5 do + settings.slots[slot] = NormalizeRuneKey(slots and slots[slot]) + end + self:Refresh() + NotifyOptionsChanged() + Debug("info", "Lura runes updated source=%s %s", tostring(source or "local"), self:GetAssignmentsSummary()) +end + +function LR:ApplyTestPattern() + self.testMode = true + Debug("info", "Lura local test mode enabled") + self:ApplyAssignments(DEFAULT_TEST_ASSIGNMENTS, "test") +end + +function LR:ClearAssignments(broadcast) + self:ApplyAssignments({}, "clear") + if broadcast then + self:BroadcastAssignments() + end +end + +function LR:GetNextEmptySequenceSlot() + local settings = self:GetSettings() + for slot = 1, 5 do + if NormalizeRuneKey(settings.slots[slot]) == "" then + return slot + end + end + return nil +end + +function LR:LogSequenceProgress(slot, key) + local label = self:GetRuneLabel(key) + if slot >= 5 then + Debug("info", "Lura rune sequence complete %s", self:GetAssignmentsSummary()) + else + Debug("info", "Lura rune %s saved as slot %d; waiting for next rune", tostring(label or "?"), slot) + end +end + +function LR:SendRuneRaidChat(slot, key) + if self:IsTestMode() or not self:CanBroadcastSequence() then + return false + end + if not IsInRaid() then + Debug("verbose", "Lura raid chat skipped: player is not in raid") + return false + end + + local slotIndex = tonumber(slot) or 0 + if slotIndex < 1 or slotIndex > 5 then + return false + end + + local token = self:GetRuneChatToken(key) + if not token or token == "" then + return false + end + + local message = string.format("HMGT:Rune%d:%s", slotIndex, token) + local ok = false + if C_ChatInfo and type(C_ChatInfo.SendChatMessage) == "function" then + ok = pcall(C_ChatInfo.SendChatMessage, message, "RAID") + elseif type(SendChatMessage) == "function" then + ok = pcall(SendChatMessage, message, "RAID") + end + + if ok then + Debug("info", "Lura raid chat sent %s", message) + return true + end + + Debug("error", "Lura raid chat failed %s", message) + return false +end + +function LR:SendRuneRaidChatSequence() + local settings = self:GetSettings() + local sent = 0 + for slot = 1, 5 do + local key = NormalizeRuneKey(settings.slots[slot]) + if key ~= "" and self:SendRuneRaidChat(slot, key) then + sent = sent + 1 + end + end + return sent +end + +function LR:AppendRuneToSequence(key) + local runeKey = NormalizeRuneKey(key) + if runeKey == "" then + Debug("info", "Lura rune input ignored: unknown rune. Valid runes: circle, x, diamond, t, triangle") + return false + end + if not self:CanUseRuneInput() then + self:LogNotLeader("input") + return false + end + + local settings = self:GetSettings() + local slot = self:GetNextEmptySequenceSlot() + if not slot then + for index = 1, 5 do + settings.slots[index] = "" + end + slot = 1 + end + + EA:GetSettings().enabled = true + EA.runtimeEnabled = true + settings.enabled = true + settings.slots[slot] = runeKey + self:Refresh() + NotifyOptionsChanged() + self:LogSequenceProgress(slot, runeKey) + Debug("info", "Lura rune input slot=%d rune=%s %s", slot, tostring(runeKey), self:GetAssignmentsSummary()) + self:SendRuneRaidChat(slot, runeKey) + + if slot >= 5 and self:CanBroadcastSequence() then + self:BroadcastAssignments(false) + elseif slot >= 5 then + Debug("info", "Lura local test sequence complete; not sending to raid") + end + return true +end + +function LR:BroadcastAssignments(sendRaidChat) + if not self:CanBroadcastSequence() then + self:LogNotLeader("sequence send") + return false + end + + local prefix = HMGT.MSG_LURA_RUNES or "LUR" + local payload = self:SerializeAssignments() + if sendRaidChat ~= false then + self:SendRuneRaidChatSequence() + end + HMGT:SendGroupMessage(string.format("%s|%s", prefix, payload), "ALERT") + Debug("info", "Lura rune sequence sent %s", self:GetAssignmentsSummary()) + return true +end + +function LR:HandleComm(senderName, payload) + local settings = self:GetSettings() + if EA.runtimeEnabled ~= true or settings.enabled ~= true then + return + end + if not self:IsSequenceSenderAllowed(senderName) then + Debug("info", "Lura rune sequence ignored from non-leader/non-assist sender=%s", tostring(senderName or "?")) + return + end + self:ApplyAssignments(SplitAssignments(payload), senderName) +end + +function LR:HandleRaidChatMessage(message, senderName, event) + local slot, key = ParseRuneRaidChatMessage(message) + if not slot or not key then + return false + end + + local settings = self:GetSettings() + if EA.runtimeEnabled ~= true or settings.enabled ~= true then + Debug("verbose", "Lura raid chat ignored while disabled event=%s sender=%s", tostring(event or "?"), tostring(senderName or "?")) + return false + end + + if not self:IsSequenceSenderAllowed(senderName) then + Debug("info", "Lura raid chat ignored from non-leader/non-assist sender=%s", tostring(senderName or "?")) + return false + end + + settings.slots[slot] = key + self:Refresh() + NotifyOptionsChanged() + Debug("info", "Lura raid chat applied sender=%s slot=%d rune=%s", tostring(senderName or "?"), slot, tostring(key)) + return true +end + +function LR:HandleSlashCommand(input) + local rest = tostring(input or ""):match("^lura%s*(.*)$") or "" + rest = rest:gsub("^%s+", ""):gsub("%s+$", "") + if rest == "" or rest == "show" then + self:Show() + return + end + if rest == "hide" then + self:Hide() + return + end + if rest == "unlock" then + local settings = self:GetSettings() + settings.unlocked = not settings.unlocked + self:Show() + self:Refresh() + return + end + if rest == "bar" or rest == "buttons" or rest == "actionbar" then + local settings = self:GetSettings() + EA:GetSettings().enabled = true + EA.runtimeEnabled = true + settings.enabled = true + settings.actionBar.shown = not settings.actionBar.shown + self:Refresh() + NotifyOptionsChanged() + return + end + if rest == "bar unlock" or rest == "buttons unlock" or rest == "actionbar unlock" then + local settings = self:GetSettings() + EA:GetSettings().enabled = true + EA.runtimeEnabled = true + settings.enabled = true + settings.actionBar.shown = true + settings.actionBar.unlocked = not settings.actionBar.unlocked + self:Refresh() + NotifyOptionsChanged() + return + end + if rest == "test" then + self:Show() + self:ApplyTestPattern() + return + end + if rest == "send" or rest == "broadcast" then + self:BroadcastAssignments() + return + end + if rest == "clear" then + self:ClearAssignments(true) + return + end + if rest == "reset" or rest == "new" then + self.testMode = false + self:ClearAssignments(false) + return + end + + local slots = {} + local index = 1 + for token in rest:gmatch("[^,%s]+") do + if index > 5 then + break + end + slots[index] = NormalizeRuneKey(token) + index = index + 1 + end + if index == 2 then + self:AppendRuneToSequence(slots[1]) + return + end + if index > 1 then + self:Show() + self:ApplyAssignments(slots, "slash") + self:BroadcastAssignments() + else + Debug("info", "Lura slash usage: /hmgt lura test | reset | clear | unlock | send | circle x diamond t triangle") + end +end diff --git a/Modules/EncounterAlerts/Media/LuraRunes/.gitkeep b/Modules/EncounterAlerts/Media/LuraRunes/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/EncounterAlerts/Media/LuraRunes/.gitkeep @@ -0,0 +1 @@ + diff --git a/Modules/EncounterAlerts/Media/LuraRunes/Rune_Circle.tga b/Modules/EncounterAlerts/Media/LuraRunes/Rune_Circle.tga new file mode 100644 index 0000000000000000000000000000000000000000..5610e4c06b60f7be57a955687f37760db94f4b1f GIT binary patch literal 65554 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

oZCkaNe*w|9_g@b|RH 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 Lz-xh0ZGry=b;^(y literal 0 HcmV?d00001 diff --git a/Modules/EncounterAlerts/Media/LuraRunes/Rune_Triangle.tga b/Modules/EncounterAlerts/Media/LuraRunes/Rune_Triangle.tga new file mode 100644 index 0000000000000000000000000000000000000000..6773d791829f3413403f0eda3a07628399b2ee2d GIT binary patch literal 65554 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 literal 0 HcmV?d00001 diff --git a/Modules/EncounterAlerts/Media/LuraRunes/Rune_X.tga b/Modules/EncounterAlerts/Media/LuraRunes/Rune_X.tga new file mode 100644 index 0000000000000000000000000000000000000000..20f3537fd3464beede53cbccb7875fcae556be37 GIT binary patch literal 65554 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 L'ura Runes > Rune action bar` + Shows five clickable rune buttons plus a local clear button for building the sequence +- `/hmgt lura reset` + Clears the local L'ura sequence builder +- `/hmgt lura bar` + Toggles the L'ura rune action bar - `/hmgt debug` - Opens the developer tools window + Opens the debug console - `/hmgt dev` - Alias for the developer tools window + Alias for the debug console - `/hmgt status` Prints a compact addon health check - `/hmgt version` - Opens the version window when developer tools are enabled + Opens the version window when the debug console is enabled ## Installation -- 2.39.5