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