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/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/Core/VersionNoticeWindow.lua b/Core/VersionNoticeWindow.lua index c6bbf2b..e495d63 100644 --- a/Core/VersionNoticeWindow.lua +++ b/Core/VersionNoticeWindow.lua @@ -3,6 +3,218 @@ 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 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 + 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) + 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 "?") + 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 + 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 +222,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 +244,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 +317,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..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" @@ -73,17 +74,26 @@ 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 +HMGT.MSG_LURA_RUNES = MSG_LURA_RUNES -- ── Standardwerte ───────────────────────────────────────────── local defaults = { profile = { - debug = false, - debugLevel = "info", devTools = { enabled = false, - level = "error", + level = "info", scope = "ALL", window = { width = 920, @@ -92,131 +102,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, @@ -236,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 = "", @@ -278,15 +191,20 @@ HMGT.powerTracking = { } HMGT.pendingSpellPowerCosts = {} HMGT.demoModeData = {} -HMGT.peerVersions = {} HMGT.versionWarnings = {} -HMGT.debugBuffer = {} -HMGT.debugBufferMax = 500 +HMGT.versionWhisperWarnings = {} +HMGT.playerStatus = {} +HMGT.devToolsBuffer = HMGT.devToolsBuffer or {} +HMGT.devToolsBufferMax = HMGT.devToolsBufferMax or 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 {} @@ -300,10 +218,15 @@ 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", + EncounterAlerts = "Encounter Alerts", } local DEBUG_LEVELS = { error = 1, @@ -311,33 +234,10 @@ 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 + 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 @@ -353,8 +253,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 @@ -366,7 +270,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() @@ -398,7 +401,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 @@ -426,9 +433,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 = {} @@ -443,15 +449,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 @@ -469,6 +479,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 @@ -482,6 +496,104 @@ 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) + local tokens = {} + local text = tostring(version or "") + for number in string.gmatch(text, "(%d+)") do + tokens[#tokens + 1] = tonumber(number) or 0 + end + return tokens +end + +function HMGT:CompareAddonVersions(leftVersion, rightVersion) + local left = ParseVersionTokens(leftVersion) + local right = ParseVersionTokens(rightVersion) + local count = math.max(#left, #right) + for i = 1, count do + local a = tonumber(left[i]) or 0 + local b = tonumber(right[i]) or 0 + if a ~= b then + return (a < b) and -1 or 1 + end + end + + local leftText = tostring(leftVersion or "") + local rightText = tostring(rightVersion or "") + if leftText == rightText then + return 0 + end + if leftText < rightText then + return -1 + end + return 1 +end + +function HMGT:IsPlayerGroupLeader() + if not IsInGroup() and not IsInRaid() then + return true + end + return UnitIsGroupLeader and UnitIsGroupLeader("player") or false +end + +function HMGT:SendOutdatedVersionWhisper(playerName, remoteVersion) + local target = self:NormalizePlayerName(playerName) + local localVersion = tostring(self.ADDON_VERSION or "dev") + local remoteText = tostring(remoteVersion or "?") + if not target or target == "" or not self:IsPlayerGroupLeader() then + return false + end + if self:CompareAddonVersions(localVersion, remoteText) <= 0 then + return false + end + + local warningKey = string.format("%s|%s|%s", tostring(target), remoteText, localVersion) + if self.versionWhisperWarnings[warningKey] then + return false + end + self.versionWhisperWarnings[warningKey] = true + + local message = string.format( + L["VERSION_OUTDATED_WHISPER"] or "Your Hail Mary Guild Tools version is outdated. You have %s, the group leader has %s.", + remoteText, + localVersion + ) + + if C_ChatInfo and type(C_ChatInfo.SendChatMessage) == "function" then + C_ChatInfo.SendChatMessage(message, "WHISPER", nil, target) + elseif type(SendChatMessage) == "function" then + SendChatMessage(message, "WHISPER", nil, target) + else + return false + end + + return true +end + +function HMGT:RegisterLibSpecializationBridge() + if self._libSpecializationBridgeRegistered then + return true + end + if not LibStub then + return false + end + + local LibSpec = LibStub("LibSpecialization", true) + if not LibSpec or type(LibSpec.RegisterGroup) ~= "function" then + return false + end + + LibSpec.RegisterGroup(self, function(specId, role, position, playerName, talentString) + if not playerName or not specId then + return + end + local classToken = HMGT:GetClassTokenForSpecId(specId) + HMGT:ApplyExternalSpecInfo("LibSpecialization", playerName, classToken, specId, talentString) + end) + self._libSpecializationBridgeRegistered = true + return true end function HMGT:SendReliableAck(target, messageId) @@ -663,7 +775,37 @@ 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 + if self.RecordDebugEntry then + self:RecordDebugEntry(normalizedLevel, normalizedScope, tostring(message or "")) + return + end + + self.devToolsBuffer = self.devToolsBuffer or {} + self.devToolsBuffer[#self.devToolsBuffer + 1] = { + stamp = date("%H:%M:%S"), + level = normalizedLevel, + scope = normalizedScope, + message = tostring(message or ""), + kind = "debug", + } + local maxLines = tonumber(self.devToolsBufferMax) or 500 + while #self.devToolsBuffer > maxLines do + table.remove(self.devToolsBuffer, 1) + end end function HMGT:Debug(fmt, ...) @@ -680,8 +822,14 @@ 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() + 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 @@ -708,7 +856,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 @@ -1001,7 +1149,7 @@ function HMGT:LogTrackedSpellCast(playerName, spellEntry, details) self:DebugScoped( "verbose", - "TrackedSpells", + "TrackerCore", "%s -> %s von %s, %s", GetTrackedSpellCategoryLabel(spellEntry), GetSpellDebugLabel(spellEntry.spellId), @@ -1010,52 +1158,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 @@ -1068,6 +1170,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}, @@ -1377,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 @@ -1602,65 +1775,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: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({}, legacyInterrupt)), + self:BuildTrackerConfigFromPreset("raidCooldownTracker", 2, CopyTrackerFields({}, legacyRaid)), + self:BuildTrackerConfigFromPreset("groupCooldownTracker", 3, CopyTrackerFields({}, legacyGroup)), } end @@ -1678,14 +1839,13 @@ 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 + p.interruptTracker = nil + p.raidCooldownTracker = nil + p.groupCooldownTracker = nil p.mapOverlay = p.mapOverlay or {} NormalizeMapOverlaySettings(p.mapOverlay) @@ -1694,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 {} @@ -1702,6 +1864,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 @@ -1734,6 +1899,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 @@ -2087,622 +2253,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 -- ═══════════════════════════════════════════════════════════════ @@ -2761,673 +2311,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:ClearRemoteSpellState(playerName, spellId) - local normalizedName = self:NormalizePlayerName(playerName) - local sid = tonumber(spellId) - if not normalizedName or not sid or sid <= 0 then - return false - end - - local changed = false - local playerCooldowns = self.activeCDs[normalizedName] - if playerCooldowns and playerCooldowns[sid] then - playerCooldowns[sid] = nil - if not next(playerCooldowns) then - self.activeCDs[normalizedName] = nil - end - changed = true - end - - local playerAvailability = self.availabilityStates[normalizedName] - if playerAvailability and playerAvailability[sid] then - playerAvailability[sid] = nil - if not next(playerAvailability) then - self.availabilityStates[normalizedName] = nil - end - changed = true - end - - return changed -end - -function HMGT:ApplyRemoteSpellState(playerName, spellId, kind, revision, a, b, c, d) - local normalizedName = self:NormalizePlayerName(playerName) - local sid = tonumber(spellId) - local rev = tonumber(revision) or 0 - if not normalizedName or not sid or sid <= 0 or rev <= 0 then - return false - end - if not self:IsPlayerInCurrentGroup(normalizedName) then - return false - end - - local currentRevision = self:GetRemoteSpellStateRevision(normalizedName, sid) - if currentRevision >= rev then - return false - end - - local spellEntry = HMGT_SpellData.CooldownLookup[sid] - or HMGT_SpellData.InterruptLookup[sid] - if not spellEntry then - return false - end - - local now = GetTime() - local stateKind = tostring(kind or "") - local changed = false - local shouldLogCast = false - local logDetails = nil - local previousEntry = self.activeCDs[normalizedName] and self.activeCDs[normalizedName][sid] - local isSuppressed = self:IsRemoteTrackedSpellLogSuppressed(normalizedName) - - if stateKind == "clear" then - changed = self:ClearRemoteSpellState(normalizedName, sid) - elseif stateKind == "availability" then - changed = self:StoreAvailabilityState(normalizedName, sid, tonumber(a) or 0, tonumber(b) or 0, spellEntry) - local playerCooldowns = self.activeCDs[normalizedName] - if playerCooldowns and playerCooldowns[sid] then - playerCooldowns[sid] = nil - if not next(playerCooldowns) then - self.activeCDs[normalizedName] = nil - end - changed = true - end - elseif stateKind == "cooldown" then - local duration = math.max(0, tonumber(b) or 0) - local remaining = math.max(0, math.min(duration, tonumber(a) or 0)) - if duration <= 0 or remaining <= 0 then - changed = self:ClearRemoteSpellState(normalizedName, sid) - else - local previousRemaining = 0 - if previousEntry then - previousRemaining = math.max( - 0, - (tonumber(previousEntry.duration) or 0) - (now - (tonumber(previousEntry.startTime) or now)) - ) - end - self.activeCDs[normalizedName] = self.activeCDs[normalizedName] or {} - self.activeCDs[normalizedName][sid] = { - startTime = now - (duration - remaining), - duration = duration, - spellEntry = spellEntry, - _stateRevision = rev, - _stateKind = stateKind, - } - changed = true - shouldLogCast = (not isSuppressed) and previousRemaining <= 0.05 - if shouldLogCast then - logDetails = { - cooldown = duration, - } - end - end - elseif stateKind == "charges" then - local maxCharges = math.max(0, math.floor((tonumber(b) or 0) + 0.5)) - local currentCharges = math.max(0, math.min(maxCharges, math.floor((tonumber(a) or 0) + 0.5))) - local nextRemaining = math.max(0, tonumber(c) or 0) - local chargeDuration = math.max(0, tonumber(d) or 0) - - if maxCharges <= 0 or currentCharges >= maxCharges then - changed = self:ClearRemoteSpellState(normalizedName, sid) - else - local previousCharges = nil - if previousEntry and (tonumber(previousEntry.maxCharges) or 0) > 0 then - self:ResolveChargeState(previousEntry, now) - previousCharges = tonumber(previousEntry.currentCharges) - end - local chargeStart = nil - local duration = 0 - local startTime = now - if chargeDuration > 0 then - nextRemaining = math.min(chargeDuration, nextRemaining) - chargeStart = now - math.max(0, chargeDuration - nextRemaining) - duration = (maxCharges - currentCharges) * chargeDuration - startTime = chargeStart - end - - self.activeCDs[normalizedName] = self.activeCDs[normalizedName] or {} - self.activeCDs[normalizedName][sid] = { - startTime = startTime, - duration = duration, - spellEntry = spellEntry, - currentCharges = currentCharges, - maxCharges = maxCharges, - chargeStart = chargeStart, - chargeDuration = chargeDuration, - _stateRevision = rev, - _stateKind = stateKind, - } - changed = true - shouldLogCast = (not isSuppressed) - and ( - (previousCharges ~= nil and currentCharges < previousCharges) - or (previousCharges == nil) - ) - if shouldLogCast then - logDetails = { - cooldown = chargeDuration, - currentCharges = currentCharges, - maxCharges = maxCharges, - chargeCooldown = chargeDuration, - } - end - end - else - return false - end - - self:SetRemoteSpellStateRevision(normalizedName, sid, rev) - if changed then - self:DebugScoped( - "info", - "TrackedSpells", - "Sync von %s: %s -> %s (rev=%d)", - tostring(normalizedName), - GetSpellDebugLabel(sid), - tostring(stateKind), - rev - ) - end - if changed and shouldLogCast and logDetails then - self:LogTrackedSpellCast(normalizedName, spellEntry, logDetails) - end - return changed -end - -function HMGT:OnCommReceived(prefix, message, distribution, sender) - if prefix ~= COMM_PREFIX then return end - local senderName = self:NormalizePlayerName(sender) - if senderName == self:NormalizePlayerName(UnitName("player")) then return end - - local msgType = message:match("^(%a+)") - self:DebugScoped("verbose", "Comm", "OnCommReceived type=%s from=%s dist=%s", tostring(msgType), tostring(senderName), tostring(distribution)) - - if msgType == MSG_ACK then - local messageId = message:match("^%a+|(.+)$") - if messageId then - self:HandleReliableAck(senderName, messageId) - end - return - elseif msgType == MSG_RELIABLE then - local messageId, innerPayload = message:match("^%a+|([^|]+)|(.+)$") - if not messageId or not innerPayload then - return - end - local dedupeKey = string.format("%s|%s", tostring(senderName or ""), tostring(messageId)) - self.receivedReliableMessages = self.receivedReliableMessages or {} - self:SendReliableAck(sender, messageId) - if self.receivedReliableMessages[dedupeKey] then - self:DebugScoped("verbose", "Comm", "Reliable duplicate sender=%s id=%s", tostring(senderName), tostring(messageId)) - return - end - self.receivedReliableMessages[dedupeKey] = GetTime() + 30 - message = innerPayload - msgType = message:match("^(%a+)") - self:DebugScoped("verbose", "Comm", "Reliable recv sender=%s id=%s inner=%s", tostring(senderName), tostring(messageId), tostring(msgType)) - end - - if msgType == MSG_SPELL_CAST then - local spellId, timestamp, cur, max, chargeRemaining, chargeDuration, version, protocol = - message:match("^%a+|(%d+)|([%d%.]+)|(%d+)|(%d+)|([%d%.]+)|([%d%.]+)|([^|]+)|(%d+)$") - if not spellId then - spellId, timestamp, version = message:match("^%a+|(%d+)|([%d%.]+)|(.+)$") - if not spellId then - spellId, timestamp = message:match("^%a+|(%d+)|([%d%.]+)$") - end - end - if spellId then - self:RegisterPeerVersion(senderName, version, protocol, "SC") - self:RememberPeerProtocolVersion(senderName, protocol) - if (tonumber(protocol) or 0) >= 5 then - return - end - self:DebugScoped("verbose", "TrackedSpells", "Legacy cast von %s: %s ts=%s", tostring(senderName), GetSpellDebugLabel(spellId), tostring(timestamp)) - self:HandleRemoteSpellCast( - senderName, - tonumber(spellId), - tonumber(timestamp), - tonumber(cur) or 0, - tonumber(max) or 0, - tonumber(chargeRemaining) or 0, - tonumber(chargeDuration) or 0 - ) - end - - elseif msgType == MSG_CD_REDUCE then - local targetSpellId, amount, timestamp, triggerSpellId, version, protocol = - message:match("^%a+|(%d+)|([%d%.]+)|([%d%.]+)|(%d+)|([^|]+)|(%d+)$") - if not targetSpellId then - targetSpellId, amount, timestamp, triggerSpellId = - message:match("^%a+|(%d+)|([%d%.]+)|([%d%.]+)|(%d+)$") - end - if targetSpellId then - self:RegisterPeerVersion(senderName, version, protocol, "CR") - self:RememberPeerProtocolVersion(senderName, protocol) - if (tonumber(protocol) or 0) >= 5 then - return - end - self:HandleRemoteCooldownReduce( - senderName, - tonumber(targetSpellId), - tonumber(amount) or 0, - tonumber(timestamp), - tonumber(triggerSpellId) or 0 - ) - end - - elseif msgType == MSG_SPELL_STATE then - local spellId, stateKind, revision, a, b, c, d, version, protocol = - message:match("^%a+|(%d+)|(%a+)|(%d+)|([%d%.%-]+)|([%d%.%-]+)|([%d%.%-]+)|([%d%.%-]+)|([^|]+)|(%d+)$") - if spellId then - self:RegisterPeerVersion(senderName, version, protocol, "STA") - self:RememberPeerProtocolVersion(senderName, protocol) - if self:ApplyRemoteSpellState(senderName, spellId, stateKind, revision, a, b, c, d) then - self:TriggerTrackerUpdate() - end - else - local current, max - spellId, current, max, version, protocol = - message:match("^%a+|(%d+)|(%d+)|(%d+)|([^|]+)|(%d+)$") - if not spellId then - spellId, current, max = message:match("^%a+|(%d+)|(%d+)|(%d+)$") - end - if spellId then - self:RegisterPeerVersion(senderName, version, protocol, "STA") - self:RememberPeerProtocolVersion(senderName, protocol) - if (tonumber(protocol) or 0) >= 5 then - return - end - local sid = tonumber(spellId) - local spellEntry = HMGT_SpellData.CooldownLookup[sid] - or HMGT_SpellData.InterruptLookup[sid] - if self:IsAvailabilitySpell(spellEntry) then - if self:StoreAvailabilityState(senderName, sid, tonumber(current) or 0, tonumber(max) or 0, spellEntry) then - self:TriggerTrackerUpdate() - end - end - end - end - - elseif msgType == MSG_HELLO then - local version, protocol, class, specIndex, talentHash, knownSpellList = - message:match("^%a+|([^|]+)|(%d+)|(%u+)|(%d+)|(.-)|(.-)$") - if class then - self:RegisterPeerVersion(senderName, version, protocol, "HEL") - self:RememberPeerProtocolVersion(senderName, protocol) - self.remoteSpellStateRevisions[senderName] = nil - self:StoreRemotePlayerInfo(senderName, class, specIndex, talentHash, knownSpellList) - self:DebugScoped("info", "TrackedSpells", "Hello von %s: class=%s spec=%s spells=%s", - tostring(senderName), tostring(class), tostring(specIndex), tostring(knownSpellList or "")) - self:SendSyncResponse(sender) - self:TriggerTrackerUpdate() - end - - elseif msgType == MSG_PLAYER_INFO then - local class, specIndex, talentHash, version, protocol = - message:match("^%a+|(%u+)|(%d+)|(.-)|([^|]+)|(%d+)$") - if not class then - class, specIndex, talentHash = message:match("^%a+|(%u+)|(%d+)|(.*)") - end - if class then - self:RegisterPeerVersion(senderName, version, protocol, "PI") - self:RememberPeerProtocolVersion(senderName, protocol) - self:StoreRemotePlayerInfo(senderName, class, specIndex, talentHash, nil) - self:TriggerTrackerUpdate() - end - - elseif msgType == MSG_SYNC_REQUEST then - local version, protocol, class, specIndex, talentHash, knownSpellList = - message:match("^%a+|([^|]+)|(%d+)|(%u+)|(%d+)|(.-)|(.-)$") - if class then - self:StoreRemotePlayerInfo(senderName, class, specIndex, talentHash, knownSpellList) - end - if not version then - version, protocol = message:match("^%a+|([^|]+)|(%d+)$") - end - if not version then - version = message:match("^%a+|(.+)$") - end - self:RegisterPeerVersion(senderName, version, protocol, "SRQ") - self:RememberPeerProtocolVersion(senderName, protocol) - self:DebugScoped("info", "Comm", "SyncRequest von %s", tostring(senderName)) - self:SendSyncResponse(sender) - self:TriggerTrackerUpdate() - - elseif msgType == MSG_SYNC_RESPONSE then - local version, protocol, class, specIndex, talentHash, knownSpellList, cdListStr = - message:match("^%a+|([^|]+)|(%d+)|(%u+)|(%d+)|(.-)|(.-)|(.-)$") - if not class then - version, protocol, class, specIndex, talentHash, cdListStr = - message:match("^%a+|([^|]+)|(%d+)|(%u+)|(%d+)|(.-)|(.-)$") - end - if not class then - class, specIndex, talentHash, cdListStr = - message:match("^%a+|(%u+)|(%d+)|(.-)|(.-)$") - end - if class then - self:RegisterPeerVersion(senderName, version, protocol, "SRS") - self:RememberPeerProtocolVersion(senderName, protocol) - self:SuppressRemoteTrackedSpellLogs(senderName, 1.5) - self:StoreRemotePlayerInfo(senderName, class, specIndex, talentHash, knownSpellList) - if cdListStr and cdListStr ~= "" then - self.activeCDs[senderName] = self.activeCDs[senderName] or {} - local knownTalents = self.playerData[senderName] and self.playerData[senderName].talents or {} - local applied = 0 - for entry in cdListStr:gmatch("([^;]+)") do - local sid, rem, dur, cur, max = entry:match("(%d+):([%d%.]+):([%d%.]+):(%d+):(%d+)") - if not sid then - sid, rem, dur = entry:match("(%d+):([%d%.]+):([%d%.]+)") - end - if sid then - sid, rem, dur = tonumber(sid), tonumber(rem), tonumber(dur) - rem = math.max(0, math.min(dur, rem)) - local remaining = rem - if remaining > 0 then - local spellEntry = HMGT_SpellData.CooldownLookup[sid] - or HMGT_SpellData.InterruptLookup[sid] - if spellEntry then - local localStartTime = GetTime() - (dur - remaining) - local curCharges = tonumber(cur) or 0 - local maxChargeCount = tonumber(max) or 0 - local chargeStart = nil - local chargeDur = nil - - if maxChargeCount > 0 then - curCharges = math.max(0, math.min(maxChargeCount, curCharges)) - local missing = maxChargeCount - curCharges - if missing > 0 and dur > 0 then - chargeDur = dur / missing - chargeStart = localStartTime - end - else - local inferredMax, inferredDur = HMGT_SpellData.GetEffectiveChargeInfo( - spellEntry, - knownTalents, - nil, - HMGT_SpellData.GetEffectiveCooldown(spellEntry, knownTalents) - ) - if (tonumber(inferredMax) or 0) > 1 then - maxChargeCount = inferredMax - curCharges = math.max(0, inferredMax - 1) - chargeDur = inferredDur - chargeStart = localStartTime - end - end - - self.activeCDs[senderName][sid] = { - startTime = localStartTime, - duration = dur, - spellEntry = spellEntry, - currentCharges = (maxChargeCount > 0) and curCharges or nil, - maxCharges = (maxChargeCount > 0) and maxChargeCount or nil, - chargeStart = chargeStart, - chargeDuration = chargeDur, - } - applied = applied + 1 - end - end - end - end - self:DebugScoped("info", "TrackedSpells", "SyncResponse von %s: cdsApplied=%d", tostring(senderName), applied) - end - self:TriggerTrackerUpdate() - end - elseif msgType == MSG_RAID_TIMELINE then - local encounterId, timeSec, spellId, leadTime, alertText = - message:match("^%a+|(%d+)|(%d+)|([%-]?%d+)|(%d+)|(.*)$") - if not encounterId then - encounterId, timeSec, spellId, leadTime = - message:match("^%a+|(%d+)|(%d+)|([%-]?%d+)|(%d+)$") - alertText = "" - end - if encounterId and HMGT.RaidTimeline and HMGT.RaidTimeline.HandleAssignmentComm then - HMGT.RaidTimeline:HandleAssignmentComm( - senderName, - tonumber(encounterId), - tonumber(timeSec), - tonumber(spellId), - tonumber(leadTime), - alertText - ) - end - elseif msgType == MSG_RAID_TIMELINE_TEST then - local encounterId, difficultyId, serverStartTime, duration = - message:match("^%a+|(%d+)|(%d+)|(%d+)|(%d+)$") - if encounterId and HMGT.RaidTimeline and HMGT.RaidTimeline.HandleTestStartComm then - HMGT.RaidTimeline:HandleTestStartComm( - senderName, - tonumber(encounterId), - tonumber(difficultyId), - tonumber(serverStartTime), - tonumber(duration) - ) - end - end -end - -- ═══════════════════════════════════════════════════════════════ -- EVENTS -- ═══════════════════════════════════════════════════════════════ @@ -3457,202 +2340,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] - 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" @@ -3667,137 +2354,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 - - 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 @@ -3863,8 +2426,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) @@ -3947,91 +2510,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 - - 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) @@ -4255,163 +2736,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 - 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") @@ -4431,66 +2755,26 @@ 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.peerVersions[name] = nil + self:ClearTrackerStateForPlayer(name) + self:ClearPlayerStatus(name) self.versionWarnings[name] = nil + if self.peerProtocols then + self.peerProtocols[name] = nil + end end end local count = 0 for _ in pairs(validPlayers) do count = count + 1 end self:Debug("verbose", "OnGroupRosterUpdate validPlayers=%d", count) + if self.versionNoticeWindow and self.versionNoticeWindow.IsShown and self.versionNoticeWindow:IsShown() and self.RefreshVersionNoticeWindow then + self:RefreshVersionNoticeWindow() + end if HMGT.TrackerManager and HMGT.TrackerManager.InvalidateAnchorLayout then HMGT.TrackerManager:InvalidateAnchorLayout() end self:TriggerTrackerUpdate() end -function HMGT: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() @@ -4520,7 +2804,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 @@ -4542,84 +2826,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 -- ==================================================================== @@ -4834,6 +3040,117 @@ 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, + EncounterAlerts = self.EncounterAlerts ~= 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 @@ -4862,6 +3179,16 @@ 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 == "status" then + self:PrintHealthStatus() elseif input == "debug" then if self.ToggleDevToolsWindow then self:ToggleDevToolsWindow() @@ -4876,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 @@ -4934,127 +3267,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 -- ═══════════════════════════════════════════════════════════════ @@ -5064,301 +3276,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 9fa5a27..4da4cc6 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 @@ -31,10 +31,20 @@ 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 +Modules\Tracker\RaidCooldownTracker\RaidCooldownSpellDatabase.lua Modules\Tracker\GroupCooldownTracker\GroupCooldownSpellDatabase.lua Modules\Tracker\TrackerManager.lua Modules\Tracker\NormalTrackerFrames.lua @@ -55,5 +65,9 @@ Modules\MapOverlay\MapOverlay.xml 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 + +# EncounterAlerts +Modules\EncounterAlerts\EncounterAlerts.lua +Modules\EncounterAlerts\LuraRunes.lua +Modules\EncounterAlerts\EncounterAlertsOptions.lua diff --git a/HailMaryGuildToolsOptions.lua b/HailMaryGuildToolsOptions.lua index 8bc2d50..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 @@ -1835,6 +1838,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 +1935,8 @@ function HMGT_Config:Initialize() name = table.concat({ "|cffffd100/hmgt|r", "|cffffd100/hmgt debug|r", + "|cffffd100/hmgt status|r", + "|cffffd100/hmgt lura|r", "|cffffd100/hmgt version|r", }, "\n"), }, @@ -2000,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 @@ -2037,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 9062b0c..b369276 100644 --- a/Locales/deDE.lua +++ b/Locales/deDE.lua @@ -18,12 +18,23 @@ 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"] = "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)" +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" @@ -53,17 +64,47 @@ 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["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 422735d..f924893 100644 --- a/Locales/enUS.lua +++ b/Locales/enUS.lua @@ -18,12 +18,23 @@ 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 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)" +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" @@ -53,17 +64,47 @@ 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["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 0000000..5610e4c Binary files /dev/null and b/Modules/EncounterAlerts/Media/LuraRunes/Rune_Circle.tga differ diff --git a/Modules/EncounterAlerts/Media/LuraRunes/Rune_Diamond.tga b/Modules/EncounterAlerts/Media/LuraRunes/Rune_Diamond.tga new file mode 100644 index 0000000..87e0be5 Binary files /dev/null and b/Modules/EncounterAlerts/Media/LuraRunes/Rune_Diamond.tga differ diff --git a/Modules/EncounterAlerts/Media/LuraRunes/Rune_T.tga b/Modules/EncounterAlerts/Media/LuraRunes/Rune_T.tga new file mode 100644 index 0000000..47382b3 Binary files /dev/null and b/Modules/EncounterAlerts/Media/LuraRunes/Rune_T.tga differ diff --git a/Modules/EncounterAlerts/Media/LuraRunes/Rune_Triangle.tga b/Modules/EncounterAlerts/Media/LuraRunes/Rune_Triangle.tga new file mode 100644 index 0000000..6773d79 Binary files /dev/null and b/Modules/EncounterAlerts/Media/LuraRunes/Rune_Triangle.tga differ diff --git a/Modules/EncounterAlerts/Media/LuraRunes/Rune_X.tga b/Modules/EncounterAlerts/Media/LuraRunes/Rune_X.tga new file mode 100644 index 0000000..20f3537 Binary files /dev/null and b/Modules/EncounterAlerts/Media/LuraRunes/Rune_X.tga differ 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/GroupCooldownTracker/GroupCooldownTracker.lua b/Modules/Tracker/GroupCooldownTracker/GroupCooldownTracker.lua index 7b479cf..db5ad93 100644 --- a/Modules/Tracker/GroupCooldownTracker/GroupCooldownTracker.lua +++ b/Modules/Tracker/GroupCooldownTracker/GroupCooldownTracker.lua @@ -1,692 +1,69 @@ --- 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, "|") -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 +function module:GetSettings() + for _, tracker in ipairs(HMGT:GetTrackerConfigs()) do + if tracker.trackerKey == self.definition.trackerKey then + return tracker 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/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/InterruptTracker/InterruptTracker.lua b/Modules/Tracker/InterruptTracker/InterruptTracker.lua index 36a6406..81ff4b4 100644 --- a/Modules/Tracker/InterruptTracker/InterruptTracker.lua +++ b/Modules/Tracker/InterruptTracker/InterruptTracker.lua @@ -1,21 +1,44 @@ --- 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() + for _, tracker in ipairs(HMGT:GetTrackerConfigs()) do + if tracker.trackerKey == self.definition.trackerKey then + return tracker + end + end + return 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..4929241 100644 --- a/Modules/Tracker/RaidcooldownTracker/RaidcooldownTracker.lua +++ b/Modules/Tracker/RaidcooldownTracker/RaidcooldownTracker.lua @@ -1,21 +1,44 @@ --- 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() + for _, tracker in ipairs(HMGT:GetTrackerConfigs()) do + if tracker.trackerKey == self.definition.trackerKey then + return tracker + end + end + return 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/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 = {} 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..3edf9c9 --- /dev/null +++ b/Modules/Tracker/TrackerBridge.lua @@ -0,0 +1,191 @@ +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, + } + 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) + 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: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") + 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: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 + +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..c77fe3e --- /dev/null +++ b/Modules/Tracker/TrackerCore.lua @@ -0,0 +1,576 @@ +local ADDON_NAME = "HailMaryGuildTools" +local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) +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 + 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: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")) + 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:DebugScoped("verbose", "TrackerUI", "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..cab2eab --- /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", + "TrackerState", + "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/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/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..370a9eb --- /dev/null +++ b/Modules/Tracker/TrackerSync.lua @@ -0,0 +1,1046 @@ +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", + "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), + 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", "TrackerSync", "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", "TrackerSync", "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", + "TrackerSync", + "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", + "TrackerSync", + "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", "TrackerSync", "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", "TrackerSync", "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", "TrackerSync", "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 + elseif msgType == HMGT.MSG_LURA_RUNES then + local payload = message:match("^%a+|(.+)$") or "" + if HMGT.EncounterAlerts and HMGT.EncounterAlerts.HandleLuraRunesComm then + HMGT.EncounterAlerts:HandleLuraRunesComm(senderName, payload) + end + end +end diff --git a/readme.md b/readme.md index 579c838..c9d7375 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 @@ -59,12 +58,24 @@ Provides a dedicated notes window for raid notes, personal notes, and drafts. Toggles tracker test mode - `/hmgt notes` Opens the notes window +- `/hmgt lura` + Opens the L'ura rune helper +- `/hmgt lura circle|x|diamond|t|triangle` + Adds one rune to the next L'ura sequence slot; slot 5 sends the sequence for raid leader/assist +- `Encounter Alerts > 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