Version 2.1.0-Beta #9

Open
Torsten wants to merge 8 commits from dev into main
23 changed files with 3643 additions and 4562 deletions
Showing only changes of commit feaa62309c - Show all commits

View File

@@ -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

View File

@@ -68,12 +68,14 @@ local function GetPlayerVersionText(name)
return tostring(HMGT.ADDON_VERSION or "dev"), tonumber(HMGT.PROTOCOL_VERSION) or 0, true return tostring(HMGT.ADDON_VERSION or "dev"), tonumber(HMGT.PROTOCOL_VERSION) or 0, true
end end
local version = HMGT.peerVersions and HMGT.peerVersions[normalized] or nil local addonStatus = HMGT.GetPlayerAddonStatus and HMGT:GetPlayerAddonStatus(normalized) or nil
local protocol = HMGT.GetPeerProtocolVersion and HMGT:GetPeerProtocolVersion(normalized) or 0 if addonStatus and addonStatus.mode == "hmgt" and addonStatus.version and addonStatus.version ~= "" then
if version and version ~= "" then return tostring(addonStatus.version), tonumber(addonStatus.protocol) or 0, true
return tostring(version), tonumber(protocol) or 0, true
end end
return nil, tonumber(protocol) or 0, false if addonStatus and addonStatus.mode == "bridge" then
return L["VERSION_WINDOW_BRIDGE_MODE"] or "Bridge Mode", 0, true
end
return nil, tonumber(addonStatus and addonStatus.protocol) or 0, false
end end
local function ApplyClassIcon(texture, classTag) local function ApplyClassIcon(texture, classTag)
@@ -167,7 +169,11 @@ function HMGT:RefreshVersionNoticeWindow()
local versionText, protocol, hasAddon = GetPlayerVersionText(info.name) local versionText, protocol, hasAddon = GetPlayerVersionText(info.name)
if hasAddon then if hasAddon then
row.versionText:SetText(versionText or "?") row.versionText:SetText(versionText or "?")
row.versionText:SetTextColor(0.9, 0.9, 0.9, 1) if versionText == (L["VERSION_WINDOW_BRIDGE_MODE"] or "Bridge Mode") then
row.versionText:SetTextColor(0.55, 0.82, 1, 1)
else
row.versionText:SetTextColor(0.9, 0.9, 0.9, 1)
end
row.protocolText:SetText(protocol > 0 and tostring(protocol) or "-") row.protocolText:SetText(protocol > 0 and tostring(protocol) or "-")
row.protocolText:SetTextColor(0.75, 0.75, 0.75, 1) row.protocolText:SetTextColor(0.75, 0.75, 0.75, 1)
else else

File diff suppressed because it is too large Load Diff

View File

@@ -31,10 +31,20 @@ HailMaryGuildToolsOptions.lua
# ────── Tracker ────────────────────────────────────────────────────── # ────── Tracker ──────────────────────────────────────────────────────
Modules\Tracker\Frame.lua Modules\Tracker\Frame.lua
Modules\Tracker\SpellDatabase.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\InterruptTracker\InterruptSpellDatabase.lua
Modules\Tracker\RaidcooldownTracker\RaidCooldownSpellDatabase.lua Modules\Tracker\RaidCooldownTracker\RaidCooldownSpellDatabase.lua
Modules\Tracker\GroupCooldownTracker\GroupCooldownSpellDatabase.lua Modules\Tracker\GroupCooldownTracker\GroupCooldownSpellDatabase.lua
Modules\Tracker\TrackerManager.lua Modules\Tracker\TrackerManager.lua
Modules\Tracker\NormalTrackerFrames.lua Modules\Tracker\NormalTrackerFrames.lua
@@ -55,5 +65,4 @@ Modules\MapOverlay\MapOverlay.xml
Modules\RaidTimeline\RaidTimelineBossAbilityData.lua Modules\RaidTimeline\RaidTimelineBossAbilityData.lua
Modules\RaidTimeline\RaidTimeline.lua Modules\RaidTimeline\RaidTimeline.lua
Modules\RaidTimeline\RaidTimelineBigWigs.lua Modules\RaidTimeline\RaidTimelineBigWigs.lua
Modules\RaidTimeline\RaidTimelineDBM.lua Modules\RaidTimeline\RaidTimelineOptions.lua
Modules\RaidTimeline\RaidTimelineOptions.lua

View File

@@ -22,11 +22,12 @@ L["VERSION_WINDOW_MESSAGE"] = "Hail Mary Guild Tools Versionen in deiner aktuell
L["VERSION_WINDOW_DETAIL"] = "Erkannt ueber %s von %s.\n%s" L["VERSION_WINDOW_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_NO_MISMATCH"] = "In deiner aktuellen Gruppe wurde keine neuere HMGT-Version erkannt."
L["VERSION_WINDOW_CURRENT"] = "Aktuelle Version: %s | Protokoll: %s" L["VERSION_WINDOW_CURRENT"] = "Aktuelle Version: %s | Protokoll: %s"
L["VERSION_WINDOW_STATUS"] = "HMGT bei %d/%d Spielern erkannt" L["VERSION_WINDOW_STATUS"] = "Addon oder Bridge bei %d/%d Spielern erkannt"
L["VERSION_WINDOW_REFRESH"] = "Aktualisieren" L["VERSION_WINDOW_REFRESH"] = "Aktualisieren"
L["VERSION_WINDOW_COLUMN_PLAYER"] = "Spieler" L["VERSION_WINDOW_COLUMN_PLAYER"] = "Spieler"
L["VERSION_WINDOW_COLUMN_VERSION"] = "Version" L["VERSION_WINDOW_COLUMN_VERSION"] = "Version"
L["VERSION_WINDOW_COLUMN_PROTOCOL"] = "Protokoll" L["VERSION_WINDOW_COLUMN_PROTOCOL"] = "Protokoll"
L["VERSION_WINDOW_BRIDGE_MODE"] = "Bridge Mode"
L["VERSION_WINDOW_MISSING_ADDON"] = "Addon nicht vorhanden" L["VERSION_WINDOW_MISSING_ADDON"] = "Addon nicht vorhanden"
L["VERSION_WINDOW_LEADER_TAG"] = "(Leiter)" L["VERSION_WINDOW_LEADER_TAG"] = "(Leiter)"
L["VERSION_WINDOW_ASSISTANT_TAG"] = "(Assist)" L["VERSION_WINDOW_ASSISTANT_TAG"] = "(Assist)"

View File

@@ -22,11 +22,12 @@ L["VERSION_WINDOW_MESSAGE"] = "Hail Mary Guild Tools versions in your current gr
L["VERSION_WINDOW_DETAIL"] = "Detected via %s from %s.\n%s" L["VERSION_WINDOW_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_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_CURRENT"] = "Current version: %s | Protocol: %s"
L["VERSION_WINDOW_STATUS"] = "Detected HMGT on %d/%d players" L["VERSION_WINDOW_STATUS"] = "Detected addon or bridge on %d/%d players"
L["VERSION_WINDOW_REFRESH"] = "Refresh" L["VERSION_WINDOW_REFRESH"] = "Refresh"
L["VERSION_WINDOW_COLUMN_PLAYER"] = "Player" L["VERSION_WINDOW_COLUMN_PLAYER"] = "Player"
L["VERSION_WINDOW_COLUMN_VERSION"] = "Version" L["VERSION_WINDOW_COLUMN_VERSION"] = "Version"
L["VERSION_WINDOW_COLUMN_PROTOCOL"] = "Protocol" L["VERSION_WINDOW_COLUMN_PROTOCOL"] = "Protocol"
L["VERSION_WINDOW_BRIDGE_MODE"] = "Bridge Mode"
L["VERSION_WINDOW_MISSING_ADDON"] = "Addon not installed" L["VERSION_WINDOW_MISSING_ADDON"] = "Addon not installed"
L["VERSION_WINDOW_LEADER_TAG"] = "(Leader)" L["VERSION_WINDOW_LEADER_TAG"] = "(Leader)"
L["VERSION_WINDOW_ASSISTANT_TAG"] = "(Assist)" L["VERSION_WINDOW_ASSISTANT_TAG"] = "(Assist)"

View File

@@ -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.

View File

@@ -1,692 +1,65 @@
-- Modules/GroupCooldownTracker.lua
-- Group-Cooldown-Tracker Modul (ein Frame pro Spieler in der Gruppe)
local ADDON_NAME = "HailMaryGuildTools" local ADDON_NAME = "HailMaryGuildTools"
local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME)
if not HMGT then return end if not HMGT then return end
local L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME)
local GCT = HMGT:NewModule("GroupCooldownTracker") local L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME)
HMGT.GroupCooldownTracker = GCT
GCT.frame = nil local module = HMGT:NewModule("GroupCooldownTracker")
GCT.frames = {} HMGT.GroupCooldownTracker = module
local function SanitizeFrameToken(name) module.definition = {
if not name or name == "" then return "Unknown" end moduleName = "GroupCooldownTracker",
return name:gsub("[^%w_]", "_") dbKey = "groupCooldownTracker",
end trackerType = "group",
trackerKey = "groupCooldownTracker",
local function ShortName(name) title = function()
if not name then return "" end return L["GCD_TITLE"]
local short = name:match("^[^-]+") end,
return short or name categories = { "tank", "defensive", "healing", "cc", "utility", "offensive", "lust", "interrupt" },
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",
} }
local PARTY_FRAME_PATTERNS = { function module:GetDefinition()
"PartyMemberFrame%d", -- Blizzard alt return self.definition
"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
end end
local function BuildAnchorLayoutSignature(settings, ordered, unitByPlayer) function module:GetSettings()
local parts = { local profile = HMGT.db and HMGT.db.profile
settings.attachToPartyFrame == true and "attach" or "stack", return profile and profile[self.definition.dbKey] or nil
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 end
local function ResolveNamedUnitFrame(unitId) function module:Enable()
if unitId == "player" then if HMGT.TrackerManager and HMGT.TrackerManager.Enable then
for _, frameName in ipairs(PLAYER_FRAME_CANDIDATES) do HMGT.TrackerManager:Enable()
local frame = _G[frameName]
if FrameMatchesUnit(frame, unitId) or (frameName == "PlayerFrame" and IsUsableAnchorFrame(frame)) then
return frame
end
end
return nil
end
local idx = type(unitId) == "string" and unitId:match("^party(%d+)$")
if not idx then
return nil
end
idx = tonumber(idx)
for _, pattern in ipairs(PARTY_FRAME_PATTERNS) do
local frame = _G[pattern:format(idx)]
if FrameMatchesUnit(frame, unitId) then
return frame
end
end
return nil
end
local function ScanUnitFrame(unitId)
local frame = EnumerateFrames()
local scanned = 0
while frame and scanned < 8000 do
if IsUsableAnchorFrame(frame) then
local unit = GetFrameUnit(frame)
if unit == unitId then
HMGT:DebugScoped("verbose", HMGT:GetTrackerDebugScope("Group Cooldowns"), "GroupAttach scan unit=%s scanned=%d found=true", tostring(unitId), scanned)
return frame
end
end
scanned = scanned + 1
frame = EnumerateFrames(frame)
end
HMGT:DebugScoped("verbose", HMGT:GetTrackerDebugScope("Group Cooldowns"), "GroupAttach scan unit=%s scanned=%d found=false", tostring(unitId), scanned)
return nil
end
local function ResolveUnitAnchorFrame(unitId)
if not unitId then return nil end
local now = GetTime()
local cached = unitFrameCache[unitId]
if cached and now < (cached.expires or 0) then
if cached.frame and cached.frame:IsShown() then
return cached.frame
end
return nil
end
local frame = ResolveNamedUnitFrame(unitId)
if not frame then
frame = ScanUnitFrame(unitId)
end
local expiresIn = 1.0
if frame and frame:IsShown() then
expiresIn = 10.0
end
unitFrameCache[unitId] = {
frame = frame,
expires = now + expiresIn,
}
if frame and frame:IsShown() then
return frame
end
return nil
end
function GCT:GetFrameIdForPlayer(playerName)
return "GroupCooldownTracker_" .. SanitizeFrameToken(playerName)
end
function GCT:GetPlayerFrame(playerName)
if not playerName then return nil end
return self.frames[playerName]
end
function GCT:GetAnchorableFrames()
return self.frames
end
function GCT:EnsurePlayerFrame(playerName)
local frame = self.frames[playerName]
local s = HMGT.db.profile.groupCooldownTracker
if frame then
return frame
end
frame = HMGT.TrackerFrame:CreateTrackerFrame(self:GetFrameIdForPlayer(playerName), s)
frame._hmgtPlayerName = playerName
self.frames[playerName] = frame
return frame
end
function GCT:HideAllFrames()
for _, frame in pairs(self.frames) do
frame:Hide()
end
self.activeOrder = nil
self.unitByPlayer = nil
self.frame = nil
self._lastAnchorLayoutSignature = nil
self._nextAnchorRetryAt = nil
end
function GCT:SetLockedAll(locked)
for _, frame in pairs(self.frames) do
HMGT.TrackerFrame:SetLocked(frame, locked)
end end
end end
function GCT:EnsureUpdateTicker() function module:Disable()
if self.updateTicker then if HMGT.TrackerManager and HMGT.TrackerManager.UpdateDisplay then
return HMGT.TrackerManager:UpdateDisplay()
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
end end
end end
function GCT:SetUpdateTickerEnabled(enabled) function module:SetLockedAll(locked)
if enabled then if HMGT.TrackerManager and HMGT.TrackerManager.SetAllLocked then
self:EnsureUpdateTicker() HMGT.TrackerManager:SetAllLocked(locked)
else
self:StopUpdateTicker()
end end
end end
function GCT:InvalidateAnchorLayout() function module:RefreshAnchors(force)
self._lastAnchorLayoutSignature = nil if HMGT.TrackerManager and HMGT.TrackerManager.RefreshAnchors then
self._nextAnchorRetryAt = nil HMGT.TrackerManager:RefreshAnchors(force)
end
end end
function GCT:RefreshAnchors(force) function module:InvalidateAnchorLayout()
local s = HMGT.db.profile.groupCooldownTracker if HMGT.TrackerManager and HMGT.TrackerManager.InvalidateAnchorLayout then
if not s then return end HMGT.TrackerManager:InvalidateAnchorLayout()
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
end 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 end
-- ============================================================ function module:GetAnchorableFrames()
-- ENABLE / DISABLE if HMGT.TrackerManager and HMGT.TrackerManager.GetAnchorableFrames then
-- ============================================================ return HMGT.TrackerManager:GetAnchorableFrames()
end
function GCT:Enable() return {}
local s = HMGT.db.profile.groupCooldownTracker
if not s.enabled and not s.demoMode and not s.testMode then return end
self:UpdateDisplay()
end 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

View File

@@ -42,54 +42,13 @@ function Manager:HidePlayerFrames(frameKey)
end end
function Manager:BuildEntriesByPlayerForTracker(tracker) function Manager:BuildEntriesByPlayerForTracker(tracker)
local frameKey = S.GetTrackerFrameKey(tracker.id) return HMGT:BuildEntriesByPlayerForTracker(
local ownName = HMGT:NormalizePlayerName(UnitName("player")) or "Player" tracker,
if tracker.testMode then self:GetTrackerFrameKey(tracker),
local entries = self:CollectTestEntries(tracker) function(unitId)
if S.IsGroupTracker(tracker) and tracker.attachToPartyFrame == true then return S.ResolveUnitAnchorFrame(unitId)
return S.BuildPartyPreviewEntries(entries)
end 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 end
function Manager:RefreshPerGroupAnchors(tracker, force) function Manager:RefreshPerGroupAnchors(tracker, force)
@@ -206,11 +165,10 @@ function Manager:UpdatePerGroupMemberTracker(tracker)
for _, playerName in ipairs(order) do for _, playerName in ipairs(order) do
local frame = self:EnsurePlayerFrame(tracker, playerName) local frame = self:EnsurePlayerFrame(tracker, playerName)
local entries = byPlayer[playerName] or {} local entries = byPlayer[playerName] or {}
if HMGT.FilterDisplayEntries then local tickThis = false
entries = HMGT:FilterDisplayEntries(tracker, entries) or entries entries, tickThis = HMGT:FinalizeTrackerEntries(tracker, entries, tracker.trackerKey or nil)
end if tickThis then
if HMGT.SortDisplayEntries then shouldTick = true
HMGT:SortDisplayEntries(entries)
end end
if #entries > 0 then if #entries > 0 then
HMGT.TrackerFrame:UpdateFrame(frame, entries, true) HMGT.TrackerFrame:UpdateFrame(frame, entries, true)
@@ -219,12 +177,6 @@ function Manager:UpdatePerGroupMemberTracker(tracker)
shownOrder[#shownOrder + 1] = playerName shownOrder[#shownOrder + 1] = playerName
shownByPlayer[playerName] = entries shownByPlayer[playerName] = entries
entryCount = entryCount + #entries entryCount = entryCount + #entries
for _, entry in ipairs(entries) do
if S.EntryNeedsVisualTicker(entry) then
shouldTick = true
break
end
end
else else
frame:Hide() frame:Hide()
end end

View File

@@ -1,21 +1,40 @@
-- Modules/InterruptTracker.lua
-- Interrupt tracker based on the shared single-frame tracker base.
local ADDON_NAME = "HailMaryGuildTools" local ADDON_NAME = "HailMaryGuildTools"
local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) 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 L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME)
local Base = HMGT.SingleFrameTrackerBase local module = HMGT:NewModule("InterruptTracker")
if not Base then return end HMGT.InterruptTracker = module
Base:CreateModule("InterruptTracker", { module.definition = {
profileKey = "interruptTracker", moduleName = "InterruptTracker",
frameName = "InterruptTracker", dbKey = "interruptTracker",
trackerType = "normal",
trackerKey = "interruptTracker",
title = function() title = function()
return L["IT_TITLE"] return L["IT_TITLE"]
end, end,
demoKey = "interruptTracker", categories = { "interrupt" },
database = function() }
return HMGT_SpellData.Interrupts
end, function module:GetDefinition()
}) return self.definition
end
function module:GetSettings()
local profile = HMGT.db and HMGT.db.profile
return profile and profile[self.definition.dbKey] or nil
end
function module:Enable()
if HMGT.TrackerManager and HMGT.TrackerManager.Enable then
HMGT.TrackerManager:Enable()
end
end
function module:Disable()
if HMGT.TrackerManager and HMGT.TrackerManager.UpdateDisplay then
HMGT.TrackerManager:UpdateDisplay()
end
end

View File

@@ -3,66 +3,15 @@ local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME)
if not HMGT or not HMGT.TrackerManager then return end if not HMGT or not HMGT.TrackerManager then return end
local Manager = HMGT.TrackerManager local Manager = HMGT.TrackerManager
local S = Manager._shared or {}
function Manager:CollectEntries(tracker) function Manager:CollectEntries(tracker)
local entries = {} return HMGT:CollectTrackerEntries(tracker)
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
end end
function Manager:CollectTestEntries(tracker) function Manager:CollectTestEntries(tracker)
local playerName = HMGT:NormalizePlayerName(UnitName("player")) or "Player" return HMGT:CollectTrackerTestEntries(tracker)
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
end end
function Manager:BuildEntriesForTracker(tracker) function Manager:BuildEntriesForTracker(tracker)
if tracker.testMode then return HMGT:BuildEntriesForTracker(tracker, self:GetTrackerFrameKey(tracker))
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
end end

View File

@@ -1,21 +1,40 @@
-- Modules/RaidCooldownTracker.lua
-- Raid cooldown tracker based on the shared single-frame tracker base.
local ADDON_NAME = "HailMaryGuildTools" local ADDON_NAME = "HailMaryGuildTools"
local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) 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 L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME)
local Base = HMGT.SingleFrameTrackerBase local module = HMGT:NewModule("RaidCooldownTracker")
if not Base then return end HMGT.RaidCooldownTracker = module
Base:CreateModule("RaidCooldownTracker", { module.definition = {
profileKey = "raidCooldownTracker", moduleName = "RaidCooldownTracker",
frameName = "RaidCooldownTracker", dbKey = "raidCooldownTracker",
trackerType = "normal",
trackerKey = "raidCooldownTracker",
title = function() title = function()
return L["RCD_TITLE"] return L["RCD_TITLE"]
end, end,
demoKey = "raidCooldownTracker", categories = { "lust", "defensive", "healing", "tank", "utility", "offensive", "cc", "interrupt" },
database = function() }
return HMGT_SpellData.RaidCooldowns
end, function module:GetDefinition()
}) return self.definition
end
function module:GetSettings()
local profile = HMGT.db and HMGT.db.profile
return profile and profile[self.definition.dbKey] or nil
end
function module:Enable()
if HMGT.TrackerManager and HMGT.TrackerManager.Enable then
HMGT.TrackerManager:Enable()
end
end
function module:Disable()
if HMGT.TrackerManager and HMGT.TrackerManager.UpdateDisplay then
HMGT.TrackerManager:UpdateDisplay()
end
end

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -94,25 +94,6 @@ local PARTY_FRAME_PATTERNS = {
local unitFrameCache = {} 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 function BuildAnchorLayoutSignature(settings, ordered, unitByPlayer)
local parts = { local parts = {
settings.attachToPartyFrame == true and "attach" or "stack", settings.attachToPartyFrame == true and "attach" or "stack",
@@ -222,55 +203,6 @@ local function ResolveUnitAnchorFrame(unitId)
return nil return nil
end 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) local function GetTrackerLabel(tracker)
if type(tracker) ~= "table" then if type(tracker) ~= "table" then
return "Tracker" return "Tracker"
@@ -287,136 +219,6 @@ local function GetTrackerLabel(tracker)
return "Tracker" return "Tracker"
end 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) local function SortTrackers(trackers)
table.sort(trackers, function(a, b) table.sort(trackers, function(a, b)
local aId = tonumber(a and a.id) or 0 local aId = tonumber(a and a.id) or 0
@@ -467,13 +269,7 @@ Manager._shared.ShortName = ShortName
Manager._shared.BuildAnchorLayoutSignature = BuildAnchorLayoutSignature Manager._shared.BuildAnchorLayoutSignature = BuildAnchorLayoutSignature
Manager._shared.IsGroupTracker = IsGroupTracker Manager._shared.IsGroupTracker = IsGroupTracker
Manager._shared.ResolveUnitAnchorFrame = ResolveUnitAnchorFrame Manager._shared.ResolveUnitAnchorFrame = ResolveUnitAnchorFrame
Manager._shared.GetGroupPlayers = GetGroupPlayers
Manager._shared.GetTrackerLabel = GetTrackerLabel 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 Manager._shared.BuildGroupDisplaySignature = BuildGroupDisplaySignature
function Manager:GetTrackers() function Manager:GetTrackers()
@@ -492,6 +288,13 @@ function Manager:GetTrackers()
return self._trackerCache return self._trackerCache
end end
function Manager:GetTrackerFrameKey(tracker)
if type(tracker) == "table" then
return GetTrackerFrameKey(tracker.id)
end
return GetTrackerFrameKey(tracker)
end
function Manager:MarkTrackersDirty() function Manager:MarkTrackersDirty()
self._trackerCache = nil self._trackerCache = nil
self._trackerCacheSignature = nil self._trackerCacheSignature = nil
@@ -648,12 +451,8 @@ function Manager:RefreshVisibleVisuals()
break break
end end
local entries = byPlayer[playerName] or {} local entries = byPlayer[playerName] or {}
if HMGT.FilterDisplayEntries then local tickThis = false
entries = HMGT:FilterDisplayEntries(tracker, entries) or entries entries, tickThis = HMGT:FinalizeTrackerEntries(tracker, entries, tracker.trackerKey or nil)
end
if HMGT.SortDisplayEntries then
HMGT:SortDisplayEntries(entries)
end
if #entries == 0 then if #entries == 0 then
needsFullRefresh = true needsFullRefresh = true
break break
@@ -666,11 +465,8 @@ function Manager:RefreshVisibleVisuals()
HMGT.TrackerFrame:UpdateFrame(frame, entries, true) HMGT.TrackerFrame:UpdateFrame(frame, entries, true)
totalEntries = totalEntries + #entries totalEntries = totalEntries + #entries
byPlayerFiltered[playerName] = entries byPlayerFiltered[playerName] = entries
for _, entry in ipairs(entries) do if tickThis then
if EntryNeedsVisualTicker(entry) then shouldTick = true
shouldTick = true
break
end
end end
end end
local newSignature = BuildGroupDisplaySignature(currentOrder, byPlayerFiltered) local newSignature = BuildGroupDisplaySignature(currentOrder, byPlayerFiltered)
@@ -680,36 +476,29 @@ function Manager:RefreshVisibleVisuals()
end end
end end
else else
local frame = self.frames[frameKey] local frame = self.frames[frameKey]
if frame and frame:IsShown() then if frame and frame:IsShown() then
local entries, shouldShow = self:BuildEntriesForTracker(tracker) local entries, shouldShow = self:BuildEntriesForTracker(tracker)
if not shouldShow then 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
needsFullRefresh = true needsFullRefresh = true
else 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) HMGT.TrackerFrame:UpdateFrame(frame, entries, true)
totalEntries = totalEntries + #entries totalEntries = totalEntries + #entries
local newSignature = BuildNormalDisplaySignature(true, entries) local newSignature = BuildNormalDisplaySignature(true, entries)
if self._displaySignatures[frameKey] ~= newSignature then if self._displaySignatures[frameKey] ~= newSignature then
needsFullRefresh = true needsFullRefresh = true
end end
for _, entry in ipairs(entries) do if tickThis then
if EntryNeedsVisualTicker(entry) then
shouldTick = true shouldTick = true
break
end end
end end
end end
end end
end
end end
end end
@@ -751,12 +540,8 @@ function Manager:UpdateDisplay()
local entries, shouldShow = self:BuildEntriesForTracker(tracker) local entries, shouldShow = self:BuildEntriesForTracker(tracker)
if shouldShow then if shouldShow then
if HMGT.FilterDisplayEntries then local tickThis = false
entries = HMGT:FilterDisplayEntries(tracker, entries) or entries entries, tickThis = HMGT:FinalizeTrackerEntries(tracker, entries, tracker.trackerKey or nil)
end
if HMGT.SortDisplayEntries then
HMGT:SortDisplayEntries(entries)
end
HMGT.TrackerFrame:UpdateFrame(frame, entries, true) HMGT.TrackerFrame:UpdateFrame(frame, entries, true)
frame:Show() frame:Show()
@@ -769,11 +554,8 @@ function Manager:UpdateDisplay()
layoutDirty = true layoutDirty = true
end end
for _, entry in ipairs(entries) do if tickThis then
if EntryNeedsVisualTicker(entry) then shouldTick = true
shouldTick = true
break
end
end end
else else
frame:Hide() frame:Hide()

View File

@@ -142,13 +142,26 @@ local function IsPartyAttachMode(tracker)
end end
local function IsGroupTracker(tracker) 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 end
local TRACKER_TYPE_VALUES = { local function GetTrackerTypeValues()
normal = L["OPT_TRACKER_TYPE_NORMAL"] or "Normal tracker", return HMGT.GetTrackerTypeOptions and HMGT:GetTrackerTypeOptions() or {
group = L["OPT_TRACKER_TYPE_GROUP"] or "Group-based tracker", 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 function GetTrackerVisibilitySummary(tracker)
local parts = {} 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") local display = tracker.showBar and (L["OPT_DISPLAY_BAR"] or "Progress bars") or (L["OPT_DISPLAY_ICON"] or "Icons")
return table.concat({ 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_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_MODE"] or "Mode", modeLabel),
string.format("|cffffd100%s|r: %s", L["OPT_STATUS_DISPLAY"] or "Display", display), string.format("|cffffd100%s|r: %s", L["OPT_STATUS_DISPLAY"] or "Display", display),
@@ -814,7 +827,7 @@ local function BuildGlobalSpellBrowserArgs()
end end
local function BuildTrackerOverviewArgs() local function BuildTrackerOverviewArgs()
return { local args = {
description = { description = {
type = "description", type = "description",
order = 1, 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, ", ")) return string.format("%s\n\n%s (%d): %s", body, L["OPT_TRACKERS"] or "Tracker Bars", #trackers, table.concat(names, ", "))
end, 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", type = "execute",
order = 2, order = 2 + index,
width = "full", 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() func = function()
local nextId = HMGT:GetNextTrackerId() local nextId = HMGT:GetNextTrackerId()
local tracker = HMGT:CreateTrackerConfig(nextId, { local tracker = HMGT:BuildTrackerConfigFromPreset(presetKey, nextId)
name = string.format("%s %d", L["OPT_TRACKER"] or "Tracker", nextId),
})
HMGT.db.profile.trackers = HMGT.db.profile.trackers or {} HMGT.db.profile.trackers = HMGT.db.profile.trackers or {}
HMGT.db.profile.trackers[#HMGT.db.profile.trackers + 1] = tracker HMGT.db.profile.trackers[#HMGT.db.profile.trackers + 1] = tracker
TriggerTrackerUpdate(true) TriggerTrackerUpdate(true)
end, end,
}, }
} end
return args
end end
local function BuildTrackerGroup(trackerId, order) local function BuildTrackerGroup(trackerId, order)
@@ -1008,7 +1035,7 @@ local function BuildTrackerGroup(trackerId, order)
width = "full", width = "full",
name = L["OPT_TRACKER_TYPE"] or "Tracker type", 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.", 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() get = function()
local tracker = s() local tracker = s()
return (tracker and tracker.trackerType) or "normal" return (tracker and tracker.trackerType) or "normal"

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff