From f1d2a761e4dd0beb9311814c37eb4091c013babd Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Fri, 24 Apr 2026 23:43:55 +0200 Subject: [PATCH 1/2] initial commit v.2.1.0 --- HailMaryGuildTools.lua | 2565 +---------------- HailMaryGuildTools.toc | 14 +- .../GroupCooldownTracker.lua | 703 +---- Modules/Tracker/GroupTrackerFrames.lua | 68 +- .../InterruptTracker/InterruptTracker.lua | 45 +- Modules/Tracker/NormalTrackerFrames.lua | 57 +- .../RaidcooldownTracker.lua | 45 +- Modules/Tracker/SingleFrameTrackerBase.lua | 305 -- Modules/Tracker/TrackerAvailability.lua | 169 ++ Modules/Tracker/TrackerBridge.lua | 186 ++ Modules/Tracker/TrackerCore.lua | 404 +++ Modules/Tracker/TrackerDataProvider.lua | 268 ++ Modules/Tracker/TrackerDetection.lua | 524 ++++ Modules/Tracker/TrackerManager.lua | 274 +- Modules/Tracker/TrackerPlayerState.lua | 65 + Modules/Tracker/TrackerState.lua | 410 +++ Modules/Tracker/TrackerSync.lua | 1041 +++++++ 17 files changed, 3252 insertions(+), 3891 deletions(-) delete mode 100644 Modules/Tracker/SingleFrameTrackerBase.lua create mode 100644 Modules/Tracker/TrackerAvailability.lua create mode 100644 Modules/Tracker/TrackerBridge.lua create mode 100644 Modules/Tracker/TrackerCore.lua create mode 100644 Modules/Tracker/TrackerDataProvider.lua create mode 100644 Modules/Tracker/TrackerDetection.lua create mode 100644 Modules/Tracker/TrackerPlayerState.lua create mode 100644 Modules/Tracker/TrackerState.lua create mode 100644 Modules/Tracker/TrackerSync.lua diff --git a/HailMaryGuildTools.lua b/HailMaryGuildTools.lua index bbef89a..37eec2f 100644 --- a/HailMaryGuildTools.lua +++ b/HailMaryGuildTools.lua @@ -73,6 +73,16 @@ HMGT.ADDON_VERSION = ADDON_VERSION HMGT.BUILD_VERSION = BUILD_VERSION HMGT.RELEASE_CHANNEL = RELEASE_CHANNEL HMGT.PROTOCOL_VERSION = PROTOCOL_VERSION +HMGT.COMM_PREFIX = COMM_PREFIX +HMGT.MSG_SPELL_CAST = MSG_SPELL_CAST +HMGT.MSG_CD_REDUCE = MSG_CD_REDUCE +HMGT.MSG_SPELL_STATE = MSG_SPELL_STATE +HMGT.MSG_HELLO = MSG_HELLO +HMGT.MSG_PLAYER_INFO = MSG_PLAYER_INFO +HMGT.MSG_SYNC_REQUEST = MSG_SYNC_REQUEST +HMGT.MSG_SYNC_RESPONSE = MSG_SYNC_RESPONSE +HMGT.MSG_RELIABLE = MSG_RELIABLE +HMGT.MSG_ACK = MSG_ACK HMGT.MSG_RAID_TIMELINE = MSG_RAID_TIMELINE HMGT.MSG_RAID_TIMELINE_TEST = MSG_RAID_TIMELINE_TEST @@ -312,30 +322,6 @@ local DEBUG_LEVELS = { verbose = 3, } -function HMGT:SuppressRemoteTrackedSpellLogs(playerName, duration) - local normalizedName = self:NormalizePlayerName(playerName) - if not normalizedName then - return - end - - self._suppressTrackedSpellLogUntil = self._suppressTrackedSpellLogUntil or {} - self._suppressTrackedSpellLogUntil[normalizedName] = GetTime() + math.max(0, tonumber(duration) or 0) -end - -function HMGT:IsRemoteTrackedSpellLogSuppressed(playerName) - local normalizedName = self:NormalizePlayerName(playerName) - local suppression = self._suppressTrackedSpellLogUntil - local untilTime = suppression and suppression[normalizedName] - if not untilTime then - return false - end - if untilTime <= GetTime() then - suppression[normalizedName] = nil - return false - end - return true -end - function HMGT:IsDebugScopeEnabled(scope) local normalizedScope = tostring(scope or "General") local selectedScope = self.db and self.db.profile and self.db.profile.debugScope or DEBUG_SCOPE_ALL @@ -1114,52 +1100,6 @@ function HMGT:LogTrackedSpellCast(playerName, spellEntry, details) ) end -function HMGT:StoreKnownChargeInfo(spellId, maxCharges, chargeDuration) - local sid = tonumber(spellId) - local maxCount = tonumber(maxCharges) - if not sid or sid <= 0 or not maxCount or maxCount <= 1 then - return - end - - self.knownChargeInfo = self.knownChargeInfo or {} - self.knownChargeInfo[sid] = { - maxCharges = math.max(1, math.floor(maxCount + 0.5)), - chargeDuration = math.max(0, tonumber(chargeDuration) or 0), - updatedAt = GetTime(), - } -end - -function HMGT:GetKnownChargeInfo(spellEntry, talents, spellId, fallbackChargeDuration) - local sid = tonumber(spellId or (spellEntry and spellEntry.spellId)) - if not sid or sid <= 0 then - return 0, 0 - end - - local cached = self.knownChargeInfo and self.knownChargeInfo[sid] - local cachedMax = tonumber(cached and cached.maxCharges) or 0 - local cachedDuration = tonumber(cached and cached.chargeDuration) or 0 - - local inferredMax, inferredDuration = HMGT_SpellData.GetEffectiveChargeInfo( - spellEntry, - talents or {}, - (cachedMax > 0) and cachedMax or nil, - (cachedDuration > 0) and cachedDuration or fallbackChargeDuration - ) - - local maxCharges = math.max(cachedMax, tonumber(inferredMax) or 0) - local chargeDuration = math.max( - tonumber(inferredDuration) or 0, - cachedDuration, - tonumber(fallbackChargeDuration) or 0 - ) - - if maxCharges > 1 then - self:StoreKnownChargeInfo(sid, maxCharges, chargeDuration) - end - - return maxCharges, chargeDuration -end - local function IsSpellKnownLocally(spellId) local sid = tonumber(spellId) if not sid or sid <= 0 then return false end @@ -1172,6 +1112,16 @@ local function IsSpellKnownLocally(spellId) return false end +HMGT.TrackerInternals = HMGT.TrackerInternals or {} +HMGT.TrackerInternals.SafeApiNumber = SafeApiNumber +HMGT.TrackerInternals.GetSpellChargesInfo = GetSpellChargesInfo +HMGT.TrackerInternals.GetSpellCooldownInfo = GetSpellCooldownInfo +HMGT.TrackerInternals.IsSpellKnownLocally = IsSpellKnownLocally +HMGT.TrackerInternals.GetGlobalCooldownInfo = GetGlobalCooldownInfo +HMGT.TrackerInternals.GetPlayerAuraApplications = GetPlayerAuraApplications +HMGT.TrackerInternals.GetSpellCastCountInfo = GetSpellCastCountInfo +HMGT.TrackerInternals.GetSpellDebugLabel = GetSpellDebugLabel + HMGT.classColors = { WARRIOR = {0.78, 0.61, 0.43}, PALADIN = {0.96, 0.55, 0.73}, @@ -1806,6 +1756,9 @@ function HMGT:MigrateProfileSettings() end function HMGT:OnEnable() + if self.EnsureTrackerStateTables then + self:EnsureTrackerStateTables() + end self:RegisterComm(COMM_PREFIX, "OnCommReceived") -- UNIT_SPELLCAST_SUCCEEDED für unitTag "player" → eigene Casts @@ -2192,622 +2145,6 @@ function HMGT:GetAvailabilityRequiredCount(spellEntry) return math.max(1, math.floor(required + 0.5)) end -function HMGT:GetOwnAvailabilityProgress(spellEntry) - local availability = self:GetAvailabilityConfig(spellEntry) - if not availability then - return nil, nil - end - - local required = self:GetAvailabilityRequiredCount(spellEntry) - if required <= 0 then - return nil, nil - end - - local current = 0 - if availability.type == "auraStacks" then - current = GetPlayerAuraApplications(availability.auraSpellId) - if current <= 0 then - local fallbackSpellId = tonumber(availability.fallbackSpellCountId) - or tonumber(availability.progressSpellId) - or tonumber(spellEntry and spellEntry.spellId) - if fallbackSpellId and fallbackSpellId > 0 then - current = GetSpellCastCountInfo(fallbackSpellId) - end - end - else - return nil, nil - end - - current = math.max(0, math.min(required, tonumber(current) or 0)) - return current, required -end - -function HMGT:GetAvailabilityState(playerName, spellId) - local normalizedName = self:NormalizePlayerName(playerName) - local sid = tonumber(spellId) - local states = normalizedName and self.availabilityStates[normalizedName] - local state = states and sid and states[sid] - if not state then - return nil, nil - end - return tonumber(state.current) or 0, tonumber(state.max) or 0 -end - -function HMGT:HasAvailabilityState(playerName, spellId) - local _, max = self:GetAvailabilityState(playerName, spellId) - return (tonumber(max) or 0) > 0 -end - -function HMGT:StoreAvailabilityState(playerName, spellId, current, max, spellEntry) - local normalizedName = self:NormalizePlayerName(playerName) - local sid = tonumber(spellId) - if not normalizedName or not sid or sid <= 0 then - return false - end - - local maxCount = math.max(0, math.floor((tonumber(max) or 0) + 0.5)) - if maxCount <= 0 then - local states = self.availabilityStates[normalizedName] - if states and states[sid] then - states[sid] = nil - if not next(states) then - self.availabilityStates[normalizedName] = nil - end - return true - end - return false - end - - local currentCount = math.max(0, math.min(maxCount, math.floor((tonumber(current) or 0) + 0.5))) - self.availabilityStates[normalizedName] = self.availabilityStates[normalizedName] or {} - local previous = self.availabilityStates[normalizedName][sid] - local changed = (not previous) - or (tonumber(previous.current) or -1) ~= currentCount - or (tonumber(previous.max) or -1) ~= maxCount - - self.availabilityStates[normalizedName][sid] = { - current = currentCount, - max = maxCount, - spellEntry = spellEntry, - updatedAt = GetTime(), - } - - return changed -end - -function HMGT:PruneAvailabilityStates(playerName, knownSpells) - local normalizedName = self:NormalizePlayerName(playerName) - local states = normalizedName and self.availabilityStates[normalizedName] - if not states or type(knownSpells) ~= "table" then - return false - end - - local changed = false - for sid in pairs(states) do - if not knownSpells[tonumber(sid)] then - states[sid] = nil - changed = true - end - end - - if not next(states) then - self.availabilityStates[normalizedName] = nil - end - return changed -end - -function HMGT:BroadcastAvailabilityState(spellId, current, max, target) - local sid = tonumber(spellId) - local currentCount = math.max(0, math.floor((tonumber(current) or 0) + 0.5)) - local maxCount = math.max(0, math.floor((tonumber(max) or 0) + 0.5)) - if not sid or sid <= 0 or maxCount <= 0 then - return - end - - local payload = string.format("%s|%d|%d|%d|%s|%d", - MSG_SPELL_STATE, - sid, - currentCount, - maxCount, - ADDON_VERSION, - PROTOCOL_VERSION - ) - - if target and target ~= "" then - self:SendDirectMessage(payload, target, "ALERT") - else - self:SendGroupMessage(payload, "ALERT") - end -end - -function HMGT:RefreshOwnAvailabilitySpell(spellEntry) - if not self:IsAvailabilitySpell(spellEntry) then - return false - end - - local playerName = self:NormalizePlayerName(UnitName("player")) - if not playerName then - return false - end - - local current, max = self:GetOwnAvailabilityProgress(spellEntry) - if (tonumber(max) or 0) > 0 then - local pData = self.playerData[playerName] - if pData and type(pData.knownSpells) == "table" then - pData.knownSpells[tonumber(spellEntry.spellId)] = true - end - end - return self:StoreAvailabilityState(playerName, spellEntry.spellId, current, max, spellEntry) -end - -function HMGT:RefreshOwnAvailabilityStates() - local playerName = self:NormalizePlayerName(UnitName("player")) - local pData = playerName and self.playerData[playerName] - if not pData or not pData.class or not pData.specIndex then - return false - end - - local changed = false - local groupCooldowns = HMGT_SpellData.GetSpellsForSpec(pData.class, pData.specIndex, HMGT_SpellData.GroupCooldowns) - for _, spellEntry in ipairs(groupCooldowns or {}) do - if self:IsAvailabilitySpell(spellEntry) and self:RefreshOwnAvailabilitySpell(spellEntry) then - changed = true - end - end - - if self:PruneAvailabilityStates(playerName, pData.knownSpells or {}) then - changed = true - end - - return changed -end - -function HMGT:RefreshAndPublishOwnAvailabilityStates() - local playerName = self:NormalizePlayerName(UnitName("player")) - local pData = playerName and self.playerData[playerName] - if not pData or not pData.class or not pData.specIndex then - return false - end - - local changed = false - local groupCooldowns = HMGT_SpellData.GetSpellsForSpec(pData.class, pData.specIndex, HMGT_SpellData.GroupCooldowns) - for _, spellEntry in ipairs(groupCooldowns or {}) do - if self:IsAvailabilitySpell(spellEntry) and self:RefreshOwnAvailabilitySpell(spellEntry) then - self:PublishOwnSpellState(spellEntry.spellId, { sendLegacy = true }) - changed = true - end - end - - if self:PruneAvailabilityStates(playerName, pData.knownSpells or {}) then - changed = true - end - - return changed -end - -function HMGT:SendOwnAvailabilityStates(target) - local playerName = self:NormalizePlayerName(UnitName("player")) - local pData = playerName and self.playerData[playerName] - if not pData or not pData.class or not pData.specIndex then - return 0 - end - - self:RefreshOwnAvailabilityStates() - - local sent = 0 - local groupCooldowns = HMGT_SpellData.GetSpellsForSpec(pData.class, pData.specIndex, HMGT_SpellData.GroupCooldowns) - for _, spellEntry in ipairs(groupCooldowns or {}) do - if self:IsAvailabilitySpell(spellEntry) and self:IsTrackedSpellKnownForPlayer(playerName, spellEntry.spellId) then - local current, max = self:GetAvailabilityState(playerName, spellEntry.spellId) - if (tonumber(max) or 0) > 0 then - self:BroadcastAvailabilityState(spellEntry.spellId, current, max, target) - sent = sent + 1 - end - end - end - - return sent -end - -function HMGT:GetLocalSpellStateRevision(spellId) - local sid = tonumber(spellId) - if not sid or sid <= 0 then return 0 end - return tonumber(self.localSpellStateRevisions[sid]) or 0 -end - -function HMGT:EnsureLocalSpellStateRevision(spellId) - local sid = tonumber(spellId) - if not sid or sid <= 0 then return 0 end - local current = tonumber(self.localSpellStateRevisions[sid]) or 0 - if current <= 0 then - current = 1 - self.localSpellStateRevisions[sid] = current - end - return current -end - -function HMGT:NextLocalSpellStateRevision(spellId) - local sid = tonumber(spellId) - if not sid or sid <= 0 then return 0 end - local nextRevision = (tonumber(self.localSpellStateRevisions[sid]) or 0) + 1 - self.localSpellStateRevisions[sid] = nextRevision - return nextRevision -end - -function HMGT:GetRemoteSpellStateRevision(playerName, spellId) - local normalizedName = self:NormalizePlayerName(playerName) - local sid = tonumber(spellId) - local bySpell = normalizedName and self.remoteSpellStateRevisions[normalizedName] - return tonumber(bySpell and bySpell[sid]) or 0 -end - -function HMGT:SetRemoteSpellStateRevision(playerName, spellId, revision) - local normalizedName = self:NormalizePlayerName(playerName) - local sid = tonumber(spellId) - local rev = tonumber(revision) or 0 - if not normalizedName or not sid or sid <= 0 or rev <= 0 then - return - end - self.remoteSpellStateRevisions[normalizedName] = self.remoteSpellStateRevisions[normalizedName] or {} - self.remoteSpellStateRevisions[normalizedName][sid] = rev -end - -function HMGT:BuildClearSpellStateSnapshot(spellId, spellEntry) - return { - spellId = tonumber(spellId), - spellEntry = spellEntry, - kind = "clear", - a = 0, - b = 0, - c = 0, - d = 0, - } -end - -function HMGT:GetOwnSpellStateSnapshot(spellId) - local sid = tonumber(spellId) - if not sid or sid <= 0 then return nil end - - local spellEntry = HMGT_SpellData.InterruptLookup[sid] - or HMGT_SpellData.CooldownLookup[sid] - if not spellEntry then return nil end - - if self:IsAvailabilitySpell(spellEntry) then - local current, max = self:GetOwnAvailabilityProgress(spellEntry) - if (tonumber(max) or 0) > 0 then - self:StoreAvailabilityState(self:NormalizePlayerName(UnitName("player")), sid, current, max, spellEntry) - return { - spellId = sid, - spellEntry = spellEntry, - kind = "availability", - a = tonumber(current) or 0, - b = tonumber(max) or 0, - c = 0, - d = 0, - } - end - return self:BuildClearSpellStateSnapshot(sid, spellEntry) - end - - local ownName = self:NormalizePlayerName(UnitName("player")) - local pData = ownName and self.playerData and self.playerData[ownName] - local talents = pData and pData.talents or {} - local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) - local knownMaxCharges, knownChargeDuration = self:GetKnownChargeInfo(spellEntry, talents, sid, effectiveCd) - local cdData = ownName and self.activeCDs[ownName] and self.activeCDs[ownName][sid] - if cdData then - if (tonumber(cdData.maxCharges) or 0) > 0 then - local nextRemaining, chargeDuration, charges, maxCharges = self:ResolveChargeState(cdData) - self:StoreKnownChargeInfo(sid, maxCharges, chargeDuration) - if (tonumber(maxCharges) or 0) > 0 and (tonumber(charges) or 0) < (tonumber(maxCharges) or 0) then - return { - spellId = sid, - spellEntry = spellEntry, - kind = "charges", - a = tonumber(charges) or 0, - b = tonumber(maxCharges) or 0, - c = tonumber(nextRemaining) or 0, - d = tonumber(chargeDuration) or 0, - } - end - elseif knownMaxCharges > 1 then - local duration = tonumber(cdData.duration) or 0 - local startTime = tonumber(cdData.startTime) or GetTime() - local remaining = math.max(0, duration - (GetTime() - startTime)) - local currentCharges = knownMaxCharges - if remaining > 0 then - currentCharges = math.max(0, knownMaxCharges - 1) - return { - spellId = sid, - spellEntry = spellEntry, - kind = "charges", - a = tonumber(currentCharges) or 0, - b = tonumber(knownMaxCharges) or 0, - c = tonumber(remaining) or 0, - d = tonumber(knownChargeDuration) or tonumber(effectiveCd) or duration, - } - end - else - local duration = tonumber(cdData.duration) or 0 - local startTime = tonumber(cdData.startTime) or GetTime() - local remaining = math.max(0, duration - (GetTime() - startTime)) - if duration > 0 and remaining > 0 then - return { - spellId = sid, - spellEntry = spellEntry, - kind = "cooldown", - a = remaining, - b = duration, - c = 0, - d = 0, - } - end - end - end - - if InCombatLockdown and InCombatLockdown() then - if knownMaxCharges > 1 then - return { - spellId = sid, - spellEntry = spellEntry, - kind = "charges", - a = tonumber(knownMaxCharges) or 0, - b = tonumber(knownMaxCharges) or 0, - c = 0, - d = tonumber(knownChargeDuration) or tonumber(effectiveCd) or 0, - } - end - return self:BuildClearSpellStateSnapshot(sid, spellEntry) - end - - local remaining, total, currentCharges, maxCharges = self:GetCooldownInfo(ownName, sid) - - if (tonumber(maxCharges) or 0) > 0 then - local cur = math.max(0, math.floor((tonumber(currentCharges) or 0) + 0.5)) - local max = math.max(0, math.floor((tonumber(maxCharges) or 0) + 0.5)) - local nextRemaining = math.max(0, tonumber(remaining) or 0) - local chargeDuration = math.max(0, tonumber(total) or 0) - if max <= 0 or cur >= max then - return self:BuildClearSpellStateSnapshot(sid, spellEntry) - end - return { - spellId = sid, - spellEntry = spellEntry, - kind = "charges", - a = cur, - b = max, - c = nextRemaining, - d = chargeDuration, - } - end - - local duration = math.max(0, tonumber(total) or 0) - local cooldownRemaining = math.max(0, tonumber(remaining) or 0) - if duration <= 0 or cooldownRemaining <= 0 then - return self:BuildClearSpellStateSnapshot(sid, spellEntry) - end - - return { - spellId = sid, - spellEntry = spellEntry, - kind = "cooldown", - a = cooldownRemaining, - b = duration, - c = 0, - d = 0, - } -end - -function HMGT:SendSpellStateSnapshot(snapshot, target, revision) - if type(snapshot) ~= "table" then return false end - - local sid = tonumber(snapshot.spellId) - local kind = tostring(snapshot.kind or "") - local rev = tonumber(revision) or 0 - if not sid or sid <= 0 or kind == "" or rev <= 0 then - return false - end - - self:DebugScoped( - "verbose", - "TrackedSpells", - "SendSpellStateSnapshot target=%s spell=%s kind=%s rev=%d a=%.3f b=%.3f c=%.3f d=%.3f", - tostring(target and target ~= "" and target or "GROUP"), - GetSpellDebugLabel(sid), - tostring(kind), - rev, - tonumber(snapshot.a) or 0, - tonumber(snapshot.b) or 0, - tonumber(snapshot.c) or 0, - tonumber(snapshot.d) or 0 - ) - - local payload = string.format( - "%s|%d|%s|%d|%.3f|%.3f|%.3f|%.3f|%s|%d", - MSG_SPELL_STATE, - sid, - kind, - rev, - tonumber(snapshot.a) or 0, - tonumber(snapshot.b) or 0, - tonumber(snapshot.c) or 0, - tonumber(snapshot.d) or 0, - ADDON_VERSION, - PROTOCOL_VERSION - ) - - if target and target ~= "" then - self:SendDirectMessage(payload, target, "ALERT") - else - self:SendGroupMessage(payload, "ALERT") - end - - return true -end - -function HMGT:PublishOwnSpellState(spellId, opts) - opts = opts or {} - local sid = tonumber(spellId) - if not sid or sid <= 0 then return false end - - local snapshot = opts.snapshot or self:GetOwnSpellStateSnapshot(sid) - if not snapshot then return false end - - local revision = tonumber(opts.revision) or self:NextLocalSpellStateRevision(sid) - local sent = self:SendSpellStateSnapshot(snapshot, opts.target, revision) - if not sent then - return false - end - - if opts.sendLegacy then - if snapshot.kind == "availability" then - self:BroadcastAvailabilityState(sid, snapshot.a, snapshot.b, opts.target) - elseif snapshot.kind ~= "clear" then - self:BroadcastSpellCast(sid, snapshot) - end - end - - return true -end - -function HMGT:SendOwnTrackedSpellStates(target) - local ownName = self:NormalizePlayerName(UnitName("player")) - if not ownName then return 0 end - - self:RefreshOwnAvailabilityStates() - - local sent = 0 - local sentBySpell = {} - - local activeStates = self.activeCDs[ownName] - if type(activeStates) == "table" then - for sid in pairs(activeStates) do - sid = tonumber(sid) - if sid and sid > 0 and not sentBySpell[sid] then - local revision = self:EnsureLocalSpellStateRevision(sid) - if revision > 0 and self:SendSpellStateSnapshot(self:GetOwnSpellStateSnapshot(sid), target, revision) then - sent = sent + 1 - sentBySpell[sid] = true - end - end - end - end - - local availabilityStates = self.availabilityStates[ownName] - if type(availabilityStates) == "table" then - for sid in pairs(availabilityStates) do - sid = tonumber(sid) - if sid and sid > 0 and not sentBySpell[sid] then - local revision = self:EnsureLocalSpellStateRevision(sid) - if revision > 0 and self:SendSpellStateSnapshot(self:GetOwnSpellStateSnapshot(sid), target, revision) then - sent = sent + 1 - sentBySpell[sid] = true - end - end - end - end - - return sent -end - -function HMGT:BroadcastRepairSpellStates() - if not self:IsEnabled() then return end - local sent = self:SendOwnTrackedSpellStates() - if sent > 0 then - self:DebugScoped("verbose", "TrackedSpells", "RepairSpellStates sent=%d", sent) - end -end - -function HMGT:ReconcileOwnTrackedSpellStatesFromGame(publishChanges) - if InCombatLockdown and InCombatLockdown() then - return 0 - end - - local ownName = self:NormalizePlayerName(UnitName("player")) - local pData = ownName and self.playerData and self.playerData[ownName] - if not ownName or not pData or not pData.class or not pData.specIndex then - return 0 - end - - pData.knownSpells = self:CollectOwnAvailableTrackerSpells(pData.class, pData.specIndex) - - local changed = 0 - for sid in pairs(pData.knownSpells or {}) do - local spellEntry = HMGT_SpellData.InterruptLookup[sid] - or HMGT_SpellData.CooldownLookup[sid] - if spellEntry and not self:IsAvailabilitySpell(spellEntry) then - if self:RefreshOwnCooldownStateFromGame(sid) then - changed = changed + 1 - if publishChanges then - self:PublishOwnSpellState(sid, { sendLegacy = true }) - end - end - end - end - - if changed > 0 then - self:TriggerTrackerUpdate() - end - return changed -end - -function HMGT:CollectOwnAvailableTrackerSpells(classToken, specIndex) - local class = classToken or select(2, UnitClass("player")) - local spec = tonumber(specIndex) or tonumber(GetSpecialization()) - if not class or not spec or spec <= 0 then - return {} - end - if not HMGT_SpellData or type(HMGT_SpellData.GetSpellsForSpec) ~= "function" then - return {} - end - - local knownSpells = {} - for _, datasetName in ipairs({ "Interrupts", "RaidCooldowns", "GroupCooldowns" }) do - local dataset = HMGT_SpellData[datasetName] - if type(dataset) == "table" then - local spells = HMGT_SpellData.GetSpellsForSpec(class, spec, dataset) - for _, entry in ipairs(spells) do - local sid = tonumber(entry.spellId) - if sid and sid > 0 and IsSpellKnownLocally(sid) then - knownSpells[sid] = true - end - end - end - end - - local ownName = self:NormalizePlayerName(UnitName("player")) - local ownCDs = ownName and self.activeCDs[ownName] - if ownCDs then - for sid in pairs(ownCDs) do - sid = tonumber(sid) - if sid and sid > 0 then - knownSpells[sid] = true - end - end - end - return knownSpells -end - -function HMGT:IsTrackedSpellKnownForPlayer(playerName, spellId) - local sid = tonumber(spellId) - if not sid or sid <= 0 then - return false - end - - local normalizedName = self:NormalizePlayerName(playerName) - local ownName = self:NormalizePlayerName(UnitName("player")) - local pData = normalizedName and self.playerData[normalizedName] - if pData and type(pData.knownSpells) == "table" and pData.knownSpells[sid] == true then - return true - end - - if normalizedName and ownName and normalizedName == ownName then - return IsSpellKnownLocally(sid) - end - - return false -end - -- ═══════════════════════════════════════════════════════════════ -- KOMMUNIKATION -- ═══════════════════════════════════════════════════════════════ @@ -2866,866 +2203,6 @@ function HMGT:SendGroupMessage(msg, prio) self:SendCommMessage(COMM_PREFIX, msg, channel, nil, prio) end -function HMGT:SendHello(target) - local name = self:NormalizePlayerName(UnitName("player")) - local pData = self.playerData[name] - if not pData or not pData.class or not pData.specIndex then return end - - pData.knownSpells = self:CollectOwnAvailableTrackerSpells(pData.class, pData.specIndex) - self:RefreshOwnAvailabilityStates() - local knownSpellList = self:SerializeKnownSpellList(pData.knownSpells) - local knownCount = 0 - for _ in pairs(pData.knownSpells or {}) do - knownCount = knownCount + 1 - end - local payload = string.format("%s|%s|%d|%s|%d|%s|%s", - MSG_HELLO, - ADDON_VERSION, - PROTOCOL_VERSION, - pData.class, - pData.specIndex, - pData.talentHash or "", - knownSpellList - ) - - if target and target ~= "" then - self:DebugScoped("verbose", "Comm", "SendHello whisper target=%s class=%s spec=%s spells=%d", - tostring(target), tostring(pData.class), tostring(pData.specIndex), knownCount) - self:SendDirectMessage(payload, target) - self:SendOwnTrackedSpellStates(target) - self:SendOwnAvailabilityStates(target) - return - end - - self:DebugScoped("verbose", "Comm", "SendHello group class=%s spec=%s spells=%d", - tostring(pData.class), tostring(pData.specIndex), knownCount) - self:SendGroupMessage(payload) - self:SendOwnTrackedSpellStates() - self:SendOwnAvailabilityStates() -end - -function HMGT:BroadcastSpellCast(spellId, snapshot) - local cur, max, chargeRemaining, chargeDuration = 0, 0, 0, 0 - if type(snapshot) == "table" and tostring(snapshot.kind) == "charges" then - cur = math.max(0, math.floor((tonumber(snapshot.a) or 0) + 0.5)) - max = math.max(0, math.floor((tonumber(snapshot.b) or 0) + 0.5)) - chargeRemaining = math.max(0, tonumber(snapshot.c) or 0) - chargeDuration = math.max(0, tonumber(snapshot.d) or 0) - elseif not (InCombatLockdown and InCombatLockdown()) then - local c, m, cs, cd = GetSpellChargesInfo(spellId) - cur = tonumber(c) or 0 - max = tonumber(m) or 0 - chargeDuration = tonumber(cd) or 0 - if max > 0 and cur < max and cs and chargeDuration > 0 then - chargeRemaining = math.max(0, chargeDuration - (GetTime() - cs)) - end - else - local ownName = self:NormalizePlayerName(UnitName("player")) - local remaining, total, currentCharges, maxCharges = self:GetCooldownInfo(ownName, spellId, { - deferChargeCooldownUntilEmpty = false, - }) - cur = math.max(0, math.floor((tonumber(currentCharges) or 0) + 0.5)) - max = math.max(0, math.floor((tonumber(maxCharges) or 0) + 0.5)) - chargeRemaining = math.max(0, tonumber(remaining) or 0) - chargeDuration = math.max(0, tonumber(total) or 0) - end - self:DebugScoped("verbose", "TrackedSpells", "BroadcastSpellCast spell=%s serverTime=%s charges=%d/%d", GetSpellDebugLabel(spellId), tostring(GetServerTime()), cur, max) - self:SendGroupMessage(string.format("%s|%d|%d|%d|%d|%.3f|%.3f|%s|%d", - MSG_SPELL_CAST, spellId, GetServerTime(), cur, max, chargeRemaining, chargeDuration, ADDON_VERSION, PROTOCOL_VERSION)) -end - -function HMGT:BroadcastCooldownReduce(targetSpellId, amount, castTimestamp, triggerSpellId) - local sid = tonumber(targetSpellId) - local value = tonumber(amount) or 0 - if not sid or sid <= 0 or value <= 0 then return end - local ts = tonumber(castTimestamp) or GetServerTime() - local triggerId = tonumber(triggerSpellId) or 0 - self:Debug( - "verbose", - "BroadcastCooldownReduce target=%s amount=%.2f ts=%s trigger=%s", - tostring(sid), - value, - tostring(ts), - tostring(triggerId) - ) - self:SendGroupMessage(string.format( - "%s|%d|%.3f|%d|%d|%s|%d", - MSG_CD_REDUCE, - sid, - value, - ts, - triggerId, - ADDON_VERSION, - PROTOCOL_VERSION - )) -end - -function HMGT:RequestSync(reason) - self:DebugScoped("info", "Comm", "RequestSync(%s)", tostring(reason or "Hello")) - self:SendHello() -end - -function HMGT:QueueSyncRequest(delay, reason) - local wait = tonumber(delay) or 0.2 - if wait < 0 then wait = 0 end - if self._syncRequestTimer then - return - end - self._syncRequestTimer = self:ScheduleTimer(function() - self._syncRequestTimer = nil - self:RequestSync(reason or "Hello") - end, wait) -end - -function HMGT:QueueDeltaSyncBurst(reason, delays) - if not (IsInGroup() or IsInRaid()) then - return - end - - local now = GetTime() - local normalizedReason = tostring(reason or "delta") - self._deltaSyncBurstAt = self._deltaSyncBurstAt or {} - if (tonumber(self._deltaSyncBurstAt[normalizedReason]) or 0) > now - 2.5 then - return - end - self._deltaSyncBurstAt[normalizedReason] = now - - delays = type(delays) == "table" and delays or { 0.35, 1.25, 2.75 } - self._syncBurstTimers = self._syncBurstTimers or {} - for _, wait in ipairs(delays) do - local delay = math.max(0, tonumber(wait) or 0) - local timerHandle - timerHandle = self:ScheduleTimer(function() - if self._syncBurstTimers then - for index, handle in ipairs(self._syncBurstTimers) do - if handle == timerHandle then - table.remove(self._syncBurstTimers, index) - break - end - end - end - self:RequestSync(normalizedReason) - end, delay) - self._syncBurstTimers[#self._syncBurstTimers + 1] = timerHandle - end - self:DebugScoped("info", "Comm", "QueueDeltaSyncBurst reason=%s count=%d", normalizedReason, #delays) -end - -function HMGT:SendSyncResponse(target) - local name = self:NormalizePlayerName(UnitName("player")) - local pData = self.playerData[name] - if not pData then return end - - pData.knownSpells = self:CollectOwnAvailableTrackerSpells(pData.class, pData.specIndex) - self:RefreshOwnAvailabilityStates() - local knownSpellList = self:SerializeKnownSpellList(pData.knownSpells) - local cdList = {} - local now = GetTime() - if self.activeCDs[name] then - for spellId, cdInfo in pairs(self.activeCDs[name]) do - if (tonumber(cdInfo.maxCharges) or 0) > 0 then - self:ResolveChargeState(cdInfo, now) - end - local remaining = cdInfo.duration - (now - cdInfo.startTime) - remaining = math.max(0, math.min(cdInfo.duration, remaining)) - if remaining > 0 then - table.insert(cdList, string.format("%d:%.3f:%.3f:%d:%d", - spellId, remaining, cdInfo.duration, cdInfo.currentCharges or 0, cdInfo.maxCharges or 0)) - end - end - end - - self:SendDirectMessage( - string.format("%s|%s|%d|%s|%d|%s|%s|%s", - MSG_SYNC_RESPONSE, - ADDON_VERSION, - PROTOCOL_VERSION, - pData.class, - pData.specIndex, - pData.talentHash or "", - knownSpellList, - table.concat(cdList, ";")), - target) - local stateCount = self:SendOwnTrackedSpellStates(target) - local availabilityCount = self:SendOwnAvailabilityStates(target) - self:DebugScoped("verbose", "Comm", "SendSyncResponse target=%s entries=%d state=%d availability=%d", tostring(target), #cdList, stateCount, availabilityCount) -end - -function HMGT:StoreRemotePlayerInfo(playerName, class, specIndex, talentHash, knownSpellList) - if not playerName or not class then return end - - local previous = self.playerData[playerName] - local knownSpells = previous and previous.knownSpells - if knownSpellList ~= nil then - knownSpells = self:ParseKnownSpellList(knownSpellList) - end - - self.playerData[playerName] = { - class = class, - specIndex = tonumber(specIndex), - talentHash = talentHash, - talents = self:ParseTalentHash(talentHash), - knownSpells = knownSpells, - } - - if type(knownSpells) == "table" then - self:PruneAvailabilityStates(playerName, knownSpells) - end - - local knownCount = 0 - if type(knownSpells) == "table" then - for _ in pairs(knownSpells) do - knownCount = knownCount + 1 - end - end - self:DebugScoped( - "info", - "TrackedSpells", - "Spielerinfo von %s: class=%s spec=%s bekannteSpells=%d", - tostring(playerName), - tostring(class), - tostring(specIndex), - knownCount - ) -end - -function HMGT:RegisterExternalAddonSource(sourceName) - local source = tostring(sourceName or "") - if source == "" then - return false - end - self.externalAddonSources = self.externalAddonSources or {} - self.externalAddonSources[source] = true - return true -end - -function HMGT:GetCanonicalExternalSpellEntry(spellId) - local sid = tonumber(spellId) - if not sid or sid <= 0 or not HMGT_SpellData then - return nil, sid - end - - local spellEntry = HMGT_SpellData.InterruptLookup and HMGT_SpellData.InterruptLookup[sid] - or HMGT_SpellData.CooldownLookup and HMGT_SpellData.CooldownLookup[sid] - if not spellEntry then - return nil, sid - end - - return spellEntry, tonumber(spellEntry.spellId) or sid -end - -function HMGT:InferClassFromSpellEntry(spellEntry) - if type(spellEntry) ~= "table" or type(spellEntry.classes) ~= "table" then - return nil - end - - local foundClass - for key, value in pairs(spellEntry.classes) do - local classToken = type(value) == "string" and value or key - if foundClass and foundClass ~= classToken then - return nil - end - foundClass = classToken - end - return foundClass -end - -function HMGT:ApplyExternalKnownSpell(sourceName, playerName, spellId, class, cooldown) - local source = tostring(sourceName or "External") - local normalizedName = self:NormalizePlayerName(playerName) - local sid = tonumber(spellId) - if not normalizedName or normalizedName == "" or not sid or sid <= 0 then - return false, "invalid_args" - end - if not self:IsPlayerInCurrentGroup(normalizedName) then - return false, "not_in_group" - end - - local spellEntry, canonicalSid = self:GetCanonicalExternalSpellEntry(sid) - if not spellEntry or not canonicalSid or canonicalSid <= 0 then - return false, "unknown_spell" - end - sid = canonicalSid - - self:RegisterExternalAddonSource(source) - local previous = self.playerData[normalizedName] or {} - local knownSpells = previous.knownSpells - if type(knownSpells) ~= "table" then - knownSpells = {} - end - knownSpells[sid] = true - - local classToken = class or previous.class or self:InferClassFromSpellEntry(spellEntry) - - self.playerData[normalizedName] = { - class = classToken, - specIndex = previous.specIndex, - talentHash = previous.talentHash, - talents = previous.talents or {}, - knownSpells = knownSpells, - externalSource = source, - } - - if tonumber(cooldown) and tonumber(cooldown) > 0 then - spellEntry._hmgtExternalBaseCd = tonumber(cooldown) - end - - self:TriggerTrackerUpdate("trackers") - return true -end - -function HMGT:GetClassTokenForSpecId(specId) - local sid = tonumber(specId) - if not sid or sid <= 0 then - return nil - end - - if type(GetSpecializationInfoByID) == "function" then - local returns = { pcall(GetSpecializationInfoByID, sid) } - local ok = returns[1] - local classToken = returns[7] - if ok and type(classToken) == "string" and classToken ~= "" then - return classToken - end - end - - if type(GetSpecializationInfoForClassID) ~= "function" then - return nil - end - - for classID = 1, 20 do - local _, token = GetClassInfo(classID) - if token then - local count = 4 - if type(GetNumSpecializationsForClassID) == "function" then - count = tonumber(GetNumSpecializationsForClassID(classID)) or 4 - end - for index = 1, math.max(1, count) do - local foundSpecId = GetSpecializationInfoForClassID(classID, index) - if tonumber(foundSpecId) == sid then - return token - end - end - end - end - - return nil -end - -function HMGT:ApplyExternalSpecInfo(sourceName, playerName, class, specId, talentHash) - local source = tostring(sourceName or "External") - local normalizedName = self:NormalizePlayerName(playerName) - local spec = tonumber(specId) - local classToken = class and tostring(class) or self:GetClassTokenForSpecId(spec) - if not normalizedName or normalizedName == "" or not classToken or classToken == "" or not spec or spec <= 0 then - return false, "invalid_args" - end - if not self:IsPlayerInCurrentGroup(normalizedName) then - return false, "not_in_group" - end - - self:RegisterExternalAddonSource(source) - local previous = self.playerData[normalizedName] or {} - local knownSpells = previous.knownSpells - if type(knownSpells) ~= "table" then - knownSpells = {} - end - - if HMGT_SpellData and type(HMGT_SpellData.GetSpellsForSpec) == "function" then - for _, datasetName in ipairs({ "Interrupts", "RaidCooldowns", "GroupCooldowns" }) do - local dataset = HMGT_SpellData[datasetName] - for _, spellEntry in ipairs(HMGT_SpellData.GetSpellsForSpec(classToken, spec, dataset)) do - local sid = tonumber(spellEntry and spellEntry.spellId) - if sid and sid > 0 then - knownSpells[sid] = true - end - end - end - end - - self.playerData[normalizedName] = { - class = classToken, - specIndex = spec, - talentHash = talentHash or previous.talentHash, - talents = self:ParseTalentHash(talentHash or previous.talentHash), - knownSpells = knownSpells, - externalSource = source, - } - - self:PruneAvailabilityStates(normalizedName, knownSpells) - self:TriggerTrackerUpdate("trackers") - return true -end - -function HMGT:ApplyExternalCooldown(sourceName, playerName, spellId, cooldown) - local source = tostring(sourceName or "External") - local normalizedName = self:NormalizePlayerName(playerName) - local sid = tonumber(spellId) - local cd = tonumber(cooldown) - if not normalizedName or normalizedName == "" or not sid or sid <= 0 or not cd or cd <= 0 then - return false, "invalid_args" - end - if not self:IsPlayerInCurrentGroup(normalizedName) then - return false, "not_in_group" - end - - local spellEntry, canonicalSid = self:GetCanonicalExternalSpellEntry(sid) - if not spellEntry or not canonicalSid or canonicalSid <= 0 then - return false, "unknown_spell" - end - sid = canonicalSid - - self:RegisterExternalAddonSource(source) - self:ApplyExternalKnownSpell(source, normalizedName, sid, nil, cd) - self:HandleRemoteSpellCast(normalizedName, sid, GetServerTime(), nil, nil, nil, cd) - return true -end - -function HMGT:ClearRemoteSpellState(playerName, spellId) - local normalizedName = self:NormalizePlayerName(playerName) - local sid = tonumber(spellId) - if not normalizedName or not sid or sid <= 0 then - return false - end - - local changed = false - local playerCooldowns = self.activeCDs[normalizedName] - if playerCooldowns and playerCooldowns[sid] then - playerCooldowns[sid] = nil - if not next(playerCooldowns) then - self.activeCDs[normalizedName] = nil - end - changed = true - end - - local playerAvailability = self.availabilityStates[normalizedName] - if playerAvailability and playerAvailability[sid] then - playerAvailability[sid] = nil - if not next(playerAvailability) then - self.availabilityStates[normalizedName] = nil - end - changed = true - end - - return changed -end - -function HMGT:ApplyRemoteSpellState(playerName, spellId, kind, revision, a, b, c, d) - local normalizedName = self:NormalizePlayerName(playerName) - local sid = tonumber(spellId) - local rev = tonumber(revision) or 0 - if not normalizedName or not sid or sid <= 0 or rev <= 0 then - return false - end - if not self:IsPlayerInCurrentGroup(normalizedName) then - return false - end - - local currentRevision = self:GetRemoteSpellStateRevision(normalizedName, sid) - if currentRevision >= rev then - return false - end - - local spellEntry = HMGT_SpellData.CooldownLookup[sid] - or HMGT_SpellData.InterruptLookup[sid] - if not spellEntry then - return false - end - sid = tonumber(spellEntry.spellId) or sid - - local now = GetTime() - local stateKind = tostring(kind or "") - local changed = false - local shouldLogCast = false - local logDetails = nil - local previousEntry = self.activeCDs[normalizedName] and self.activeCDs[normalizedName][sid] - local isSuppressed = self:IsRemoteTrackedSpellLogSuppressed(normalizedName) - - if stateKind == "clear" then - changed = self:ClearRemoteSpellState(normalizedName, sid) - elseif stateKind == "availability" then - changed = self:StoreAvailabilityState(normalizedName, sid, tonumber(a) or 0, tonumber(b) or 0, spellEntry) - local playerCooldowns = self.activeCDs[normalizedName] - if playerCooldowns and playerCooldowns[sid] then - playerCooldowns[sid] = nil - if not next(playerCooldowns) then - self.activeCDs[normalizedName] = nil - end - changed = true - end - elseif stateKind == "cooldown" then - local duration = math.max(0, tonumber(b) or 0) - local remaining = math.max(0, math.min(duration, tonumber(a) or 0)) - if duration <= 0 or remaining <= 0 then - changed = self:ClearRemoteSpellState(normalizedName, sid) - else - local previousRemaining = 0 - if previousEntry then - previousRemaining = math.max( - 0, - (tonumber(previousEntry.duration) or 0) - (now - (tonumber(previousEntry.startTime) or now)) - ) - end - self.activeCDs[normalizedName] = self.activeCDs[normalizedName] or {} - self.activeCDs[normalizedName][sid] = { - startTime = now - (duration - remaining), - duration = duration, - spellEntry = spellEntry, - _stateRevision = rev, - _stateKind = stateKind, - } - changed = true - shouldLogCast = (not isSuppressed) and previousRemaining <= 0.05 - if shouldLogCast then - logDetails = { - cooldown = duration, - } - end - end - elseif stateKind == "charges" then - local maxCharges = math.max(0, math.floor((tonumber(b) or 0) + 0.5)) - local currentCharges = math.max(0, math.min(maxCharges, math.floor((tonumber(a) or 0) + 0.5))) - local nextRemaining = math.max(0, tonumber(c) or 0) - local chargeDuration = math.max(0, tonumber(d) or 0) - - if maxCharges <= 0 or currentCharges >= maxCharges then - changed = self:ClearRemoteSpellState(normalizedName, sid) - else - local previousCharges = nil - if previousEntry and (tonumber(previousEntry.maxCharges) or 0) > 0 then - self:ResolveChargeState(previousEntry, now) - previousCharges = tonumber(previousEntry.currentCharges) - end - local chargeStart = nil - local duration = 0 - local startTime = now - if chargeDuration > 0 then - nextRemaining = math.min(chargeDuration, nextRemaining) - chargeStart = now - math.max(0, chargeDuration - nextRemaining) - duration = (maxCharges - currentCharges) * chargeDuration - startTime = chargeStart - end - - self.activeCDs[normalizedName] = self.activeCDs[normalizedName] or {} - self.activeCDs[normalizedName][sid] = { - startTime = startTime, - duration = duration, - spellEntry = spellEntry, - currentCharges = currentCharges, - maxCharges = maxCharges, - chargeStart = chargeStart, - chargeDuration = chargeDuration, - _stateRevision = rev, - _stateKind = stateKind, - } - changed = true - shouldLogCast = (not isSuppressed) - and ( - (previousCharges ~= nil and currentCharges < previousCharges) - or (previousCharges == nil) - ) - if shouldLogCast then - logDetails = { - cooldown = chargeDuration, - currentCharges = currentCharges, - maxCharges = maxCharges, - chargeCooldown = chargeDuration, - } - end - end - else - return false - end - - self:SetRemoteSpellStateRevision(normalizedName, sid, rev) - if changed then - self:DebugScoped( - "info", - "TrackedSpells", - "Sync von %s: %s -> %s (rev=%d)", - tostring(normalizedName), - GetSpellDebugLabel(sid), - tostring(stateKind), - rev - ) - end - if changed and shouldLogCast and logDetails then - self:LogTrackedSpellCast(normalizedName, spellEntry, logDetails) - end - return changed -end - -function HMGT:OnCommReceived(prefix, message, distribution, sender) - if prefix ~= COMM_PREFIX then return end - local senderName = self:NormalizePlayerName(sender) - if senderName == self:NormalizePlayerName(UnitName("player")) then return end - - local msgType = message:match("^(%a+)") - self:DebugScoped("verbose", "Comm", "OnCommReceived type=%s from=%s dist=%s", tostring(msgType), tostring(senderName), tostring(distribution)) - - if msgType == MSG_ACK then - local messageId = message:match("^%a+|(.+)$") - if messageId then - self:HandleReliableAck(senderName, messageId) - end - return - elseif msgType == MSG_RELIABLE then - local messageId, innerPayload = message:match("^%a+|([^|]+)|(.+)$") - if not messageId or not innerPayload then - return - end - local dedupeKey = string.format("%s|%s", tostring(senderName or ""), tostring(messageId)) - self.receivedReliableMessages = self.receivedReliableMessages or {} - self:SendReliableAck(sender, messageId) - if self.receivedReliableMessages[dedupeKey] then - self:DebugScoped("verbose", "Comm", "Reliable duplicate sender=%s id=%s", tostring(senderName), tostring(messageId)) - return - end - self.receivedReliableMessages[dedupeKey] = GetTime() + 30 - message = innerPayload - msgType = message:match("^(%a+)") - self:DebugScoped("verbose", "Comm", "Reliable recv sender=%s id=%s inner=%s", tostring(senderName), tostring(messageId), tostring(msgType)) - end - - if msgType == MSG_SPELL_CAST then - local spellId, timestamp, cur, max, chargeRemaining, chargeDuration, version, protocol = - message:match("^%a+|(%d+)|([%d%.]+)|(%d+)|(%d+)|([%d%.]+)|([%d%.]+)|([^|]+)|(%d+)$") - if not spellId then - spellId, timestamp, version = message:match("^%a+|(%d+)|([%d%.]+)|(.+)$") - if not spellId then - spellId, timestamp = message:match("^%a+|(%d+)|([%d%.]+)$") - end - end - if spellId then - self:RegisterPeerVersion(senderName, version, protocol, "SC") - self:RememberPeerProtocolVersion(senderName, protocol) - if (tonumber(protocol) or 0) >= 5 then - return - end - self:DebugScoped("verbose", "TrackedSpells", "Legacy cast von %s: %s ts=%s", tostring(senderName), GetSpellDebugLabel(spellId), tostring(timestamp)) - self:HandleRemoteSpellCast( - senderName, - tonumber(spellId), - tonumber(timestamp), - tonumber(cur) or 0, - tonumber(max) or 0, - tonumber(chargeRemaining) or 0, - tonumber(chargeDuration) or 0 - ) - end - - elseif msgType == MSG_CD_REDUCE then - local targetSpellId, amount, timestamp, triggerSpellId, version, protocol = - message:match("^%a+|(%d+)|([%d%.]+)|([%d%.]+)|(%d+)|([^|]+)|(%d+)$") - if not targetSpellId then - targetSpellId, amount, timestamp, triggerSpellId = - message:match("^%a+|(%d+)|([%d%.]+)|([%d%.]+)|(%d+)$") - end - if targetSpellId then - self:RegisterPeerVersion(senderName, version, protocol, "CR") - self:RememberPeerProtocolVersion(senderName, protocol) - if (tonumber(protocol) or 0) >= 5 then - return - end - self:HandleRemoteCooldownReduce( - senderName, - tonumber(targetSpellId), - tonumber(amount) or 0, - tonumber(timestamp), - tonumber(triggerSpellId) or 0 - ) - end - - elseif msgType == MSG_SPELL_STATE then - local spellId, stateKind, revision, a, b, c, d, version, protocol = - message:match("^%a+|(%d+)|(%a+)|(%d+)|([%d%.%-]+)|([%d%.%-]+)|([%d%.%-]+)|([%d%.%-]+)|([^|]+)|(%d+)$") - if spellId then - self:RegisterPeerVersion(senderName, version, protocol, "STA") - self:RememberPeerProtocolVersion(senderName, protocol) - if self:ApplyRemoteSpellState(senderName, spellId, stateKind, revision, a, b, c, d) then - self:TriggerTrackerUpdate() - end - else - local current, max - spellId, current, max, version, protocol = - message:match("^%a+|(%d+)|(%d+)|(%d+)|([^|]+)|(%d+)$") - if not spellId then - spellId, current, max = message:match("^%a+|(%d+)|(%d+)|(%d+)$") - end - if spellId then - self:RegisterPeerVersion(senderName, version, protocol, "STA") - self:RememberPeerProtocolVersion(senderName, protocol) - if (tonumber(protocol) or 0) >= 5 then - return - end - local sid = tonumber(spellId) - local spellEntry = HMGT_SpellData.CooldownLookup[sid] - or HMGT_SpellData.InterruptLookup[sid] - if self:IsAvailabilitySpell(spellEntry) then - if self:StoreAvailabilityState(senderName, sid, tonumber(current) or 0, tonumber(max) or 0, spellEntry) then - self:TriggerTrackerUpdate() - end - end - end - end - - elseif msgType == MSG_HELLO then - local version, protocol, class, specIndex, talentHash, knownSpellList = - message:match("^%a+|([^|]+)|(%d+)|(%u+)|(%d+)|(.-)|(.-)$") - if class then - self:RegisterPeerVersion(senderName, version, protocol, "HEL") - self:RememberPeerProtocolVersion(senderName, protocol) - self.remoteSpellStateRevisions[senderName] = nil - self:StoreRemotePlayerInfo(senderName, class, specIndex, talentHash, knownSpellList) - self:DebugScoped("info", "TrackedSpells", "Hello von %s: class=%s spec=%s spells=%s", - tostring(senderName), tostring(class), tostring(specIndex), tostring(knownSpellList or "")) - self:SendSyncResponse(sender) - self:TriggerTrackerUpdate() - end - - elseif msgType == MSG_PLAYER_INFO then - local class, specIndex, talentHash, version, protocol = - message:match("^%a+|(%u+)|(%d+)|(.-)|([^|]+)|(%d+)$") - if not class then - class, specIndex, talentHash = message:match("^%a+|(%u+)|(%d+)|(.*)") - end - if class then - self:RegisterPeerVersion(senderName, version, protocol, "PI") - self:RememberPeerProtocolVersion(senderName, protocol) - self:StoreRemotePlayerInfo(senderName, class, specIndex, talentHash, nil) - self:TriggerTrackerUpdate() - end - - elseif msgType == MSG_SYNC_REQUEST then - local version, protocol, class, specIndex, talentHash, knownSpellList = - message:match("^%a+|([^|]+)|(%d+)|(%u+)|(%d+)|(.-)|(.-)$") - if class then - self:StoreRemotePlayerInfo(senderName, class, specIndex, talentHash, knownSpellList) - end - if not version then - version, protocol = message:match("^%a+|([^|]+)|(%d+)$") - end - if not version then - version = message:match("^%a+|(.+)$") - end - self:RegisterPeerVersion(senderName, version, protocol, "SRQ") - self:RememberPeerProtocolVersion(senderName, protocol) - self:DebugScoped("info", "Comm", "SyncRequest von %s", tostring(senderName)) - self:SendSyncResponse(sender) - self:TriggerTrackerUpdate() - - elseif msgType == MSG_SYNC_RESPONSE then - local version, protocol, class, specIndex, talentHash, knownSpellList, cdListStr = - message:match("^%a+|([^|]+)|(%d+)|(%u+)|(%d+)|(.-)|(.-)|(.-)$") - if not class then - version, protocol, class, specIndex, talentHash, cdListStr = - message:match("^%a+|([^|]+)|(%d+)|(%u+)|(%d+)|(.-)|(.-)$") - end - if not class then - class, specIndex, talentHash, cdListStr = - message:match("^%a+|(%u+)|(%d+)|(.-)|(.-)$") - end - if class then - self:RegisterPeerVersion(senderName, version, protocol, "SRS") - self:RememberPeerProtocolVersion(senderName, protocol) - self:SuppressRemoteTrackedSpellLogs(senderName, 1.5) - self:StoreRemotePlayerInfo(senderName, class, specIndex, talentHash, knownSpellList) - if cdListStr and cdListStr ~= "" then - self.activeCDs[senderName] = self.activeCDs[senderName] or {} - local knownTalents = self.playerData[senderName] and self.playerData[senderName].talents or {} - local applied = 0 - for entry in cdListStr:gmatch("([^;]+)") do - local sid, rem, dur, cur, max = entry:match("(%d+):([%d%.]+):([%d%.]+):(%d+):(%d+)") - if not sid then - sid, rem, dur = entry:match("(%d+):([%d%.]+):([%d%.]+)") - end - if sid then - sid, rem, dur = tonumber(sid), tonumber(rem), tonumber(dur) - rem = math.max(0, math.min(dur, rem)) - local remaining = rem - if remaining > 0 then - local spellEntry = HMGT_SpellData.CooldownLookup[sid] - or HMGT_SpellData.InterruptLookup[sid] - if spellEntry then - local localStartTime = GetTime() - (dur - remaining) - local curCharges = tonumber(cur) or 0 - local maxChargeCount = tonumber(max) or 0 - local chargeStart = nil - local chargeDur = nil - - if maxChargeCount > 0 then - curCharges = math.max(0, math.min(maxChargeCount, curCharges)) - local missing = maxChargeCount - curCharges - if missing > 0 and dur > 0 then - chargeDur = dur / missing - chargeStart = localStartTime - end - else - local inferredMax, inferredDur = HMGT_SpellData.GetEffectiveChargeInfo( - spellEntry, - knownTalents, - nil, - HMGT_SpellData.GetEffectiveCooldown(spellEntry, knownTalents) - ) - if (tonumber(inferredMax) or 0) > 1 then - maxChargeCount = inferredMax - curCharges = math.max(0, inferredMax - 1) - chargeDur = inferredDur - chargeStart = localStartTime - end - end - - self.activeCDs[senderName][sid] = { - startTime = localStartTime, - duration = dur, - spellEntry = spellEntry, - currentCharges = (maxChargeCount > 0) and curCharges or nil, - maxCharges = (maxChargeCount > 0) and maxChargeCount or nil, - chargeStart = chargeStart, - chargeDuration = chargeDur, - } - applied = applied + 1 - end - end - end - end - self:DebugScoped("info", "TrackedSpells", "SyncResponse von %s: cdsApplied=%d", tostring(senderName), applied) - end - self:TriggerTrackerUpdate() - end - elseif msgType == MSG_RAID_TIMELINE then - local encounterId, timeSec, spellId, leadTime, alertText = - message:match("^%a+|(%d+)|(%d+)|([%-]?%d+)|(%d+)|(.*)$") - if not encounterId then - encounterId, timeSec, spellId, leadTime = - message:match("^%a+|(%d+)|(%d+)|([%-]?%d+)|(%d+)$") - alertText = "" - end - if encounterId and HMGT.RaidTimeline and HMGT.RaidTimeline.HandleAssignmentComm then - HMGT.RaidTimeline:HandleAssignmentComm( - senderName, - tonumber(encounterId), - tonumber(timeSec), - tonumber(spellId), - tonumber(leadTime), - alertText - ) - end - elseif msgType == MSG_RAID_TIMELINE_TEST then - local encounterId, difficultyId, serverStartTime, duration = - message:match("^%a+|(%d+)|(%d+)|(%d+)|(%d+)$") - if encounterId and HMGT.RaidTimeline and HMGT.RaidTimeline.HandleTestStartComm then - HMGT.RaidTimeline:HandleTestStartComm( - senderName, - tonumber(encounterId), - tonumber(difficultyId), - tonumber(serverStartTime), - tonumber(duration) - ) - end - end -end - -- ═══════════════════════════════════════════════════════════════ -- EVENTS -- ═══════════════════════════════════════════════════════════════ @@ -3755,203 +2232,6 @@ function HMGT:OnPlayerRegenEnabled() self:RefreshAndPublishOwnAvailabilityStates() end -function HMGT:IsPlayerInCurrentGroup(playerName) - local target = self:NormalizePlayerName(playerName) - if not target then return false end - local own = self:NormalizePlayerName(UnitName("player")) - if target == own then return true end - - if IsInRaid() then - for i = 1, GetNumGroupMembers() do - local n = self:NormalizePlayerName(UnitName("raid" .. i)) - if n == target then - return true - end - end - return false - end - - if IsInGroup() then - for i = 1, GetNumSubgroupMembers() do - local n = self:NormalizePlayerName(UnitName("party" .. i)) - if n == target then - return true - end - end - end - return false -end - -function HMGT:HandleOwnSpellCast(spellId) - local isInterrupt = HMGT_SpellData.InterruptLookup[spellId] ~= nil - local isCooldown = HMGT_SpellData.CooldownLookup[spellId] ~= nil - if not isInterrupt and not isCooldown then return end - - local spellEntry = HMGT_SpellData.InterruptLookup[spellId] - or HMGT_SpellData.CooldownLookup[spellId] - spellId = tonumber(spellEntry and spellEntry.spellId) or spellId - local name = self:NormalizePlayerName(UnitName("player")) - local pData = self.playerData[name] - local talents = pData and pData.talents or {} - if self:IsAvailabilitySpell(spellEntry) then - self:LogTrackedSpellCast(name, spellEntry, { - stateKind = "availability", - required = HMGT_SpellData.GetEffectiveAvailabilityRequired(spellEntry, talents), - }) - if self:RefreshOwnAvailabilitySpell(spellEntry) then - self:PublishOwnSpellState(spellId, { sendLegacy = true }) - end - self:TriggerTrackerUpdate() - return - end - local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) - local now = GetTime() - - local inCombat = InCombatLockdown and InCombatLockdown() - local cur, max, chargeStart, chargeDuration = nil, nil, nil, nil - if not inCombat then - cur, max, chargeStart, chargeDuration = GetSpellChargesInfo(spellId) - end - local cachedMaxCharges, cachedChargeDuration = self:GetKnownChargeInfo( - spellEntry, - talents, - spellId, - (not inCombat and tonumber(chargeDuration) and tonumber(chargeDuration) > 0) and tonumber(chargeDuration) or effectiveCd - ) - local inferredMaxCharges, inferredChargeDuration = HMGT_SpellData.GetEffectiveChargeInfo( - spellEntry, - talents, - (not inCombat and tonumber(max) and tonumber(max) > 0) and tonumber(max) or ((cachedMaxCharges > 0) and cachedMaxCharges or nil), - (not inCombat and tonumber(chargeDuration) and tonumber(chargeDuration) > 0) and tonumber(chargeDuration) - or ((cachedChargeDuration > 0) and cachedChargeDuration or effectiveCd) - ) - - local hasCharges = ((tonumber(max) or 0) > 1) or (tonumber(inferredMaxCharges) or 0) > 1 - local currentCharges = 0 - local maxCharges = 0 - local chargeDur = 0 - local chargeStartTime = nil - - local startTime = now - local duration = effectiveCd - local expiresIn = effectiveCd - - local existingCd = self.activeCDs[name] and self.activeCDs[name][spellId] - if existingCd and (tonumber(existingCd.maxCharges) or 0) > 0 then - self:ResolveChargeState(existingCd, now) - end - - if hasCharges then - maxCharges = math.max(1, tonumber(max) or cachedMaxCharges or tonumber(inferredMaxCharges) or 1) - currentCharges = tonumber(cur) - if currentCharges == nil then - local prevCharges = existingCd and tonumber(existingCd.currentCharges) - local prevMax = existingCd and tonumber(existingCd.maxCharges) - if prevCharges and prevMax and prevMax == maxCharges then - currentCharges = math.max(0, prevCharges - 1) - else - currentCharges = math.max(0, maxCharges - 1) - end - end - currentCharges = math.max(0, math.min(maxCharges, currentCharges)) - - chargeDur = tonumber(chargeDuration) - or cachedChargeDuration - or tonumber(inferredChargeDuration) - or tonumber(effectiveCd) - or 0 - chargeDur = math.max(0, chargeDur) - self:StoreKnownChargeInfo(spellId, maxCharges, chargeDur) - - if currentCharges < maxCharges and chargeDur > 0 then - chargeStartTime = tonumber(chargeStart) or now - local missing = maxCharges - currentCharges - startTime = chargeStartTime - duration = missing * chargeDur - expiresIn = math.max(0, duration - (now - startTime)) - else - startTime = now - duration = 0 - expiresIn = 0 - end - end - - self:Debug( - "verbose", - "HandleOwnSpellCast name=%s spellId=%s cd=%.2f charges=%s/%s", - tostring(name), - tostring(spellId), - tonumber(effectiveCd) or 0, - hasCharges and tostring(currentCharges) or "-", - hasCharges and tostring(maxCharges) or "-" - ) - - self._cdNonce = (self._cdNonce or 0) + 1 - local nonce = self._cdNonce - - self.activeCDs[name] = self.activeCDs[name] or {} - self.activeCDs[name][spellId] = { - startTime = startTime, - duration = duration, - spellEntry = spellEntry, - currentCharges = hasCharges and currentCharges or nil, - maxCharges = hasCharges and maxCharges or nil, - chargeStart = hasCharges and chargeStartTime or nil, - chargeDuration = hasCharges and chargeDur or nil, - _nonce = nonce, - } - - self:LogTrackedSpellCast(name, spellEntry, { - cooldown = effectiveCd, - currentCharges = hasCharges and currentCharges or nil, - maxCharges = hasCharges and maxCharges or nil, - chargeCooldown = hasCharges and chargeDur or nil, - }) - - if expiresIn > 0 then - self:ScheduleTimer(function() - local current = self.activeCDs[name] and self.activeCDs[name][spellId] - if current and current._nonce == nonce then - self.activeCDs[name][spellId] = nil - self:PublishOwnSpellState(spellId) - self:TriggerTrackerUpdate() - end - end, expiresIn) - end - - self:PublishOwnSpellState(spellId, { sendLegacy = true }) - self:TriggerTrackerUpdate() -end - -function HMGT:RefreshCooldownExpiryTimer(playerName, spellId, cdData) - if not cdData then return 0 end - local now = GetTime() - local duration = tonumber(cdData.duration) or 0 - local startTime = tonumber(cdData.startTime) or now - local expiresIn = math.max(0, duration - (now - startTime)) - - self._cdNonce = (self._cdNonce or 0) + 1 - local nonce = self._cdNonce - cdData._nonce = nonce - - if expiresIn > 0 then - self:ScheduleTimer(function() - local current = self.activeCDs[playerName] and self.activeCDs[playerName][spellId] - if current and current._nonce == nonce then - self.activeCDs[playerName][spellId] = nil - if self.activeCDs[playerName] and not next(self.activeCDs[playerName]) then - self.activeCDs[playerName] = nil - end - if playerName == self:NormalizePlayerName(UnitName("player")) then - self:PublishOwnSpellState(spellId) - end - self:TriggerTrackerUpdate() - end - end, expiresIn) - end - return expiresIn -end - local function BuildCooldownStateFingerprint(cdData) if not cdData then return "nil" @@ -3966,138 +2246,13 @@ local function BuildCooldownStateFingerprint(cdData) }, "|") end -function HMGT:RefreshOwnCooldownStateFromGame(spellId) - local sid = tonumber(spellId) - if not sid then return false end - if InCombatLockdown and InCombatLockdown() then - return false - end - - local ownName = self:NormalizePlayerName(UnitName("player")) - if not ownName then return false end - - local spellEntry = HMGT_SpellData.InterruptLookup[sid] - or HMGT_SpellData.CooldownLookup[sid] - if not spellEntry or self:IsAvailabilitySpell(spellEntry) then - return false - end - sid = tonumber(spellEntry.spellId) or sid - - local existing = self.activeCDs[ownName] and self.activeCDs[ownName][sid] - local before = BuildCooldownStateFingerprint(existing) - local now = GetTime() - local pData = self.playerData[ownName] - local talents = pData and pData.talents or {} - local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) - local cur, max, chargeStart, chargeDuration = GetSpellChargesInfo(sid) - local inferredMaxCharges, inferredChargeDuration = HMGT_SpellData.GetEffectiveChargeInfo( - spellEntry, - talents, - (tonumber(max) and tonumber(max) > 0) and tonumber(max) or nil, - (tonumber(chargeDuration) and tonumber(chargeDuration) > 0) and tonumber(chargeDuration) or effectiveCd - ) - - local hasCharges = ((tonumber(max) or 0) > 1) or (tonumber(inferredMaxCharges) or 0) > 1 - - if hasCharges then - local maxCharges = math.max(1, tonumber(max) or tonumber(inferredMaxCharges) or 1) - local currentCharges = tonumber(cur) - if currentCharges == nil then - currentCharges = maxCharges - end - currentCharges = math.max(0, math.min(maxCharges, currentCharges)) - - local chargeDur = tonumber(chargeDuration) or tonumber(inferredChargeDuration) or tonumber(effectiveCd) or 0 - chargeDur = math.max(0, chargeDur) - - if currentCharges < maxCharges and chargeDur > 0 then - local chargeStartTime = tonumber(chargeStart) or now - local missing = maxCharges - currentCharges - self.activeCDs[ownName] = self.activeCDs[ownName] or {} - self.activeCDs[ownName][sid] = { - startTime = chargeStartTime, - duration = missing * chargeDur, - spellEntry = spellEntry, - currentCharges = currentCharges, - maxCharges = maxCharges, - chargeStart = chargeStartTime, - chargeDuration = chargeDur, - } - self:RefreshCooldownExpiryTimer(ownName, sid, self.activeCDs[ownName][sid]) - else - if self.activeCDs[ownName] then - self.activeCDs[ownName][sid] = nil - if not next(self.activeCDs[ownName]) then - self.activeCDs[ownName] = nil - end - end - end - else - local cooldownStart, cooldownDuration = GetSpellCooldownInfo(sid) - cooldownStart = tonumber(cooldownStart) or 0 - cooldownDuration = tonumber(cooldownDuration) or 0 - local gcdStart, gcdDuration = GetGlobalCooldownInfo() - gcdStart = tonumber(gcdStart) or 0 - gcdDuration = tonumber(gcdDuration) or 0 - local existingDuration = tonumber(existing and existing.duration) or 0 - local existingStart = tonumber(existing and existing.startTime) or now - local existingRemaining = math.max(0, existingDuration - (now - existingStart)) - - local isLikelyGlobalCooldown = cooldownDuration > 0 - and gcdDuration > 0 - and math.abs(cooldownDuration - gcdDuration) <= 0.15 - and (tonumber(effectiveCd) or 0) > (gcdDuration + 1.0) - - local isSuspiciousShortRefresh = cooldownDuration > 0 - and existingRemaining > 2.0 - and existingDuration > 2.0 - and cooldownDuration < math.max(2.0, existingDuration * 0.35) - and cooldownDuration < math.max(2.0, (tonumber(effectiveCd) or 0) * 0.35) - - if isLikelyGlobalCooldown or isSuspiciousShortRefresh then - self:DebugScoped( - "verbose", - "TrackedSpells", - "Ignore suspicious refresh for %s: spellCD=%.3f gcd=%.3f existing=%.3f remaining=%.3f effective=%.3f", - GetSpellDebugLabel(sid), - cooldownDuration, - gcdDuration, - existingDuration, - existingRemaining, - tonumber(effectiveCd) or 0 - ) - return false - end - - if cooldownDuration > 0 then - self.activeCDs[ownName] = self.activeCDs[ownName] or {} - self.activeCDs[ownName][sid] = { - startTime = cooldownStart, - duration = cooldownDuration, - spellEntry = spellEntry, - } - self:RefreshCooldownExpiryTimer(ownName, sid, self.activeCDs[ownName][sid]) - else - if self.activeCDs[ownName] then - self.activeCDs[ownName][sid] = nil - if not next(self.activeCDs[ownName]) then - self.activeCDs[ownName] = nil - end - end - end - end - - local after = BuildCooldownStateFingerprint(self.activeCDs[ownName] and self.activeCDs[ownName][sid]) - return before ~= after -end - function HMGT:ApplyCooldownReduction(playerName, targetSpellId, amount) local sid = tonumber(targetSpellId) local reduceBy = tonumber(amount) or 0 if not playerName or not sid or sid <= 0 or reduceBy <= 0 then return 0 end - local spells = self.activeCDs[playerName] + local spells = self:GetPlayerCooldownMap(playerName, false) if not spells then return 0 end local cdData = spells[sid] if not cdData then return 0 end @@ -4163,8 +2318,8 @@ function HMGT:ApplyCooldownReduction(playerName, targetSpellId, amount) end end - if self.activeCDs[playerName] and not next(self.activeCDs[playerName]) then - self.activeCDs[playerName] = nil + if spells and not next(spells) then + self:ClearPlayerCooldowns(playerName) end if playerName == self:NormalizePlayerName(UnitName("player")) then self:PublishOwnSpellState(sid) @@ -4247,92 +2402,9 @@ local function ApplyObservedCooldownReducers(self, ownName, reducers) end end -function HMGT:DidOwnInterruptSucceed(triggerSpellId, talents) - local sid = tonumber(triggerSpellId) - if not sid then return false end - - local spellEntry = HMGT_SpellData and HMGT_SpellData.InterruptLookup and HMGT_SpellData.InterruptLookup[sid] - if not spellEntry then return false end - sid = tonumber(spellEntry.spellId) or sid - - local _, observedDuration = GetSpellCooldownInfo(sid) - observedDuration = tonumber(observedDuration) or 0 - if observedDuration <= 0 then return false end - - local expectedDuration = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) - expectedDuration = tonumber(expectedDuration) or 0 - if expectedDuration <= 0 then return false end - - -- Successful kick reductions (e.g. Coldthirst) result in a shorter observed CD. - return observedDuration < (expectedDuration - 0.05) -end - -function HMGT:HandleOwnCooldownReductionTrigger(triggerSpellId) - local ownName = self:NormalizePlayerName(UnitName("player")) - if not ownName then return end - - local pData = self.playerData[ownName] - local classToken = pData and pData.class or select(2, UnitClass("player")) - local specIndex = pData and pData.specIndex or GetSpecialization() - local talents = pData and pData.talents or {} - if not classToken or not specIndex then return end - - local reducers = HMGT_SpellData.GetCooldownReducersForCast(classToken, specIndex, triggerSpellId, talents) - if not reducers or #reducers == 0 then return end - - local instantReducers = {} - local observedInstantReducers = {} - local successReducers = {} - local observedSuccessReducers = {} - for _, reducer in ipairs(reducers) do - local observed = type(reducer.observe) == "table" - if reducer.requireInterruptSuccess then - if observed then - observedSuccessReducers[#observedSuccessReducers + 1] = reducer - else - successReducers[#successReducers + 1] = reducer - end - else - if observed then - observedInstantReducers[#observedInstantReducers + 1] = reducer - else - instantReducers[#instantReducers + 1] = reducer - end - end - end - - local castTs = GetServerTime() - if #instantReducers > 0 then - ApplyOwnCooldownReducers(self, ownName, triggerSpellId, instantReducers, castTs) - end - if #observedInstantReducers > 0 then - ApplyObservedCooldownReducers(self, ownName, observedInstantReducers) - end - - if #successReducers > 0 or #observedSuccessReducers > 0 then - local function ApplySuccessReducers() - if not self:DidOwnInterruptSucceed(triggerSpellId, talents) then - return false - end - if #successReducers > 0 then - ApplyOwnCooldownReducers(self, ownName, triggerSpellId, successReducers, castTs) - end - if #observedSuccessReducers > 0 then - ApplyObservedCooldownReducers(self, ownName, observedSuccessReducers) - end - return true - end - - if not ApplySuccessReducers() then - C_Timer.After(0.12, function() - if not self or not self.playerData or not self.playerData[ownName] then - return - end - ApplySuccessReducers() - end) - end - end -end +HMGT.TrackerInternals.BuildCooldownStateFingerprint = BuildCooldownStateFingerprint +HMGT.TrackerInternals.ApplyOwnCooldownReducers = ApplyOwnCooldownReducers +HMGT.TrackerInternals.ApplyObservedCooldownReducers = ApplyObservedCooldownReducers function HMGT:CaptureOwnSpellPowerCosts(spellId) local sid = tonumber(spellId) @@ -4556,164 +2628,6 @@ function HMGT:HandleRemoteCooldownReduce(playerName, targetSpellId, amount, cast end end -function HMGT:HandleRemoteSpellCast(playerName, spellId, castTimestamp, curCharges, maxCharges, chargeRemaining, chargeDuration) - local spellEntry = HMGT_SpellData.InterruptLookup[spellId] - or HMGT_SpellData.CooldownLookup[spellId] - if not spellEntry then return end - spellId = tonumber(spellEntry.spellId) or spellId - if self:IsAvailabilitySpell(spellEntry) then return end - - local pData = self.playerData[playerName] - local talents = pData and pData.talents or {} - local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) - - castTimestamp = tonumber(castTimestamp) or GetServerTime() - local existingEntry = self.activeCDs[playerName] and self.activeCDs[playerName][spellId] - if (tonumber(maxCharges) or 0) <= 0 and existingEntry and existingEntry.lastCastTimestamp then - local prevTs = tonumber(existingEntry.lastCastTimestamp) or 0 - if math.abs(prevTs - castTimestamp) <= 1 then - return - end - end - local now = GetTime() - local elapsed = math.max(0, GetServerTime() - castTimestamp) - - local incomingCur = tonumber(curCharges) or 0 - local incomingMax = tonumber(maxCharges) or 0 - local incomingChargeRemaining = tonumber(chargeRemaining) or 0 - local incomingChargeDuration = tonumber(chargeDuration) or 0 - - local inferredMaxCharges, inferredChargeDuration = HMGT_SpellData.GetEffectiveChargeInfo( - spellEntry, - talents, - (incomingMax > 0) and incomingMax or nil, - (incomingChargeDuration > 0) and incomingChargeDuration or effectiveCd - ) - local hasCharges = (incomingMax > 1) or (tonumber(inferredMaxCharges) or 0) > 1 - - local currentCharges = 0 - local maxChargeCount = 0 - local chargeDur = 0 - local nextChargeRemaining = 0 - local chargeStartTime = nil - local startTime, duration, expiresIn - - if hasCharges then - maxChargeCount = math.max(1, (incomingMax > 0 and incomingMax) or tonumber(inferredMaxCharges) or 1) - chargeDur = tonumber(incomingChargeDuration) or tonumber(inferredChargeDuration) or tonumber(effectiveCd) or 0 - chargeDur = math.max(0, chargeDur) - if chargeDur <= 0 then - chargeDur = math.max(0, tonumber(effectiveCd) or 0) - end - - if incomingMax > 0 then - currentCharges = math.max(0, math.min(maxChargeCount, incomingCur)) - nextChargeRemaining = math.max(0, math.min(chargeDur, incomingChargeRemaining - elapsed)) - if currentCharges < maxChargeCount and chargeDur > 0 then - chargeStartTime = now - math.max(0, chargeDur - nextChargeRemaining) - end - else - local existing = self.activeCDs[playerName] and self.activeCDs[playerName][spellId] - if existing and (tonumber(existing.maxCharges) or 0) == maxChargeCount then - self:ResolveChargeState(existing, now) - local prevCharges = tonumber(existing.currentCharges) or maxChargeCount - local prevStart = tonumber(existing.chargeStart) - local prevDur = tonumber(existing.chargeDuration) or chargeDur - if prevDur > 0 then - chargeDur = prevDur - end - - currentCharges = math.max(0, prevCharges - 1) - if currentCharges < maxChargeCount and chargeDur > 0 then - if prevCharges >= maxChargeCount then - chargeStartTime = now - else - chargeStartTime = prevStart or now - end - nextChargeRemaining = math.max(0, chargeDur - (now - chargeStartTime)) - end - else - currentCharges = math.max(0, maxChargeCount - 1) - if currentCharges < maxChargeCount and chargeDur > 0 then - chargeStartTime = now - nextChargeRemaining = chargeDur - end - end - end - - if currentCharges >= maxChargeCount and maxChargeCount > 0 then - currentCharges = math.max(0, maxChargeCount - 1) - if chargeDur > 0 then - chargeStartTime = now - nextChargeRemaining = chargeDur - end - end - - if currentCharges < maxChargeCount and chargeDur > 0 then - chargeStartTime = chargeStartTime or now - local missing = maxChargeCount - currentCharges - startTime = chargeStartTime - duration = missing * chargeDur - expiresIn = math.max(0, duration - (now - startTime)) - else - startTime = now - duration = 0 - expiresIn = 0 - end - else - local remaining = effectiveCd - elapsed - if remaining <= 0 then return end - startTime = now - elapsed - duration = effectiveCd - expiresIn = remaining - end - - self:Debug( - "verbose", - "HandleRemoteSpellCast name=%s spellId=%s elapsed=%.2f expiresIn=%.2f charges=%s/%s", - tostring(playerName), - tostring(spellId), - tonumber(elapsed) or 0, - tonumber(expiresIn) or 0, - hasCharges and tostring(currentCharges) or "-", - hasCharges and tostring(maxChargeCount) or "-" - ) - - self._cdNonce = (self._cdNonce or 0) + 1 - local nonce = self._cdNonce - - self.activeCDs[playerName] = self.activeCDs[playerName] or {} - self.activeCDs[playerName][spellId] = { - startTime = startTime, - duration = duration, - spellEntry = spellEntry, - currentCharges = hasCharges and currentCharges or nil, - maxCharges = hasCharges and maxChargeCount or nil, - chargeStart = hasCharges and chargeStartTime or nil, - chargeDuration = hasCharges and chargeDur or nil, - lastCastTimestamp = castTimestamp, - _nonce = nonce, - } - - self:LogTrackedSpellCast(playerName, spellEntry, { - cooldown = effectiveCd, - currentCharges = hasCharges and currentCharges or nil, - maxCharges = hasCharges and maxChargeCount or nil, - chargeCooldown = hasCharges and chargeDur or nil, - }) - - if expiresIn > 0 then - self:ScheduleTimer(function() - local current = self.activeCDs[playerName] and self.activeCDs[playerName][spellId] - if current and current._nonce == nonce then - self.activeCDs[playerName][spellId] = nil - self:TriggerTrackerUpdate() - end - end, expiresIn) - end - - self:TriggerTrackerUpdate() -end function HMGT:OnGroupRosterUpdate() self:QueueSyncRequest(0.35, "roster") @@ -4733,9 +2647,7 @@ function HMGT:OnGroupRosterUpdate() for name in pairs(self.playerData) do if not validPlayers[name] then self.playerData[name] = nil - self.activeCDs[name] = nil - self.availabilityStates[name] = nil - self.remoteSpellStateRevisions[name] = nil + self:ClearTrackerStateForPlayer(name) self.peerVersions[name] = nil self.versionWarnings[name] = nil if self.peerProtocols then @@ -4755,50 +2667,6 @@ function HMGT:OnGroupRosterUpdate() self:TriggerTrackerUpdate() end -function HMGT:CleanupStaleCooldowns() - local now = GetTime() - local ownName = self:NormalizePlayerName(UnitName("player")) - local removed = 0 - for playerName, spells in pairs(self.activeCDs) do - for spellId, cdInfo in pairs(spells) do - local duration = tonumber(cdInfo.duration) or 0 - local startTime = tonumber(cdInfo.startTime) or now - local rem = duration - (now - startTime) - local hasCharges = (tonumber(cdInfo.maxCharges) or 0) > 0 - local currentCharges = tonumber(cdInfo.currentCharges) or 0 - local maxCharges = tonumber(cdInfo.maxCharges) or 0 - if hasCharges then - local _, _, cur, max = self:ResolveChargeState(cdInfo, now) - currentCharges = cur - maxCharges = max - end - local shouldDrop = false - if hasCharges then - if currentCharges >= maxCharges then - shouldDrop = true - elseif (tonumber(cdInfo.chargeDuration) or 0) <= 0 and rem <= -2 then - shouldDrop = true - end - elseif rem <= -2 then - shouldDrop = true - end - if shouldDrop then - spells[spellId] = nil - if playerName == ownName then - self:PublishOwnSpellState(spellId) - end - removed = removed + 1 - end - end - if not next(spells) then - self.activeCDs[playerName] = nil - end - end - if removed > 0 then - self:Debug("verbose", "CleanupStaleCooldowns removed=%d", removed) - end -end - function HMGT:OnPlayerLogin() -- Char vollständig geladen: Spec jetzt zuverlässig abfragen self:UpdateOwnPlayerInfo() @@ -4850,84 +2718,6 @@ end -- UI-UPDATE TRIGGER -- ═══════════════════════════════════════════════════════════════ -function HMGT:TriggerTrackerUpdate(reason) - local function normalizeReason(value) - if value == true then - return "trackers" - elseif value == "trackers" or value == "layout" or value == "visual" then - return value - end - return "full" - end - - local function mergeReasons(current, incoming) - local priority = { - visual = 1, - layout = 2, - trackers = 3, - full = 4, - } - current = normalizeReason(current) - incoming = normalizeReason(incoming) - if (priority[incoming] or 4) >= (priority[current] or 4) then - return incoming - end - return current - end - - self._trackerUpdateMinDelay = self._trackerUpdateMinDelay or 0.08 - self._trackerUpdatePending = true - self._trackerUpdateReason = mergeReasons(self._trackerUpdateReason, reason) - if HMGT.TrackerManager then - local normalizedReason = normalizeReason(reason) - if normalizedReason == "trackers" then - HMGT.TrackerManager:MarkTrackersDirty() - elseif normalizedReason == "layout" then - HMGT.TrackerManager:MarkLayoutDirty() - end - end - if self._updateScheduled then return end - - local now = GetTime() - local last = self._lastTrackerUpdateAt or 0 - local delay = math.max(0, self._trackerUpdateMinDelay - (now - last)) - self._updateScheduled = true - - self:ScheduleTimer(function() - self._updateScheduled = nil - if not self._trackerUpdatePending then return end - self._trackerUpdatePending = nil - self._lastTrackerUpdateAt = GetTime() - local pendingReason = self._trackerUpdateReason - self._trackerUpdateReason = nil - - local function profileModule(name, fn) - if not fn then return end - local t0 = debugprofilestop and debugprofilestop() or nil - fn() - local t1 = debugprofilestop and debugprofilestop() or nil - if t0 and t1 then - local mod = HMGT[name] - local count = mod and mod.lastEntryCount or 0 - self:Debug("verbose", "UIUpdate %s took %.2fms entries=%s", tostring(name), t1 - t0, tostring(count)) - end - end - - profileModule("TrackerManager", HMGT.TrackerManager and function() - if pendingReason == "visual" and HMGT.TrackerManager.RefreshVisibleVisuals then - HMGT.TrackerManager:RefreshVisibleVisuals() - else - HMGT.TrackerManager:UpdateDisplay() - end - end or nil) - - -- If events flooded in while rendering, schedule exactly one follow-up pass. - if self._trackerUpdatePending then - self:TriggerTrackerUpdate() - end - end, delay) -end - -- ==================================================================== -- MINIMAP BUTTON -- ==================================================================== @@ -5380,301 +3170,6 @@ function HMGT:GetClassColor(classToken) return c and c[1] or 1, c and c[2] or 1, c and c[3] or 1 end -function HMGT:ResolveChargeState(cdData, now) - if not cdData then return 0, 0, 0, 0 end - local maxCharges = tonumber(cdData.maxCharges) or 0 - if maxCharges <= 0 then return 0, 0, 0, 0 end - - now = tonumber(now) or GetTime() - local charges = tonumber(cdData.currentCharges) or 0 - charges = math.max(0, math.min(maxCharges, charges)) - - local chargeDuration = tonumber(cdData.chargeDuration) or tonumber(cdData.duration) or 0 - local chargeStart = tonumber(cdData.chargeStart) - - if chargeDuration > 0 and charges < maxCharges then - if not chargeStart then - chargeStart = now - end - local elapsed = now - chargeStart - if elapsed > 0 then - local gained = math.floor(elapsed / chargeDuration) - if gained > 0 then - charges = math.min(maxCharges, charges + gained) - chargeStart = chargeStart + (gained * chargeDuration) - end - end - end - - local nextChargeRemaining = 0 - if charges < maxCharges and chargeDuration > 0 and chargeStart then - nextChargeRemaining = math.max(0, chargeDuration - (now - chargeStart)) - end - - cdData.currentCharges = charges - cdData.maxCharges = maxCharges - cdData.chargeDuration = chargeDuration - cdData.chargeStart = chargeStart - - return nextChargeRemaining, chargeDuration, charges, maxCharges -end - -function HMGT:GetCooldownInfo(playerName, spellId, opts) - opts = opts or {} - local deferUntilEmpty = opts.deferChargeCooldownUntilEmpty and true or false - local spellEntry = HMGT_SpellData.InterruptLookup[spellId] - or HMGT_SpellData.CooldownLookup[spellId] - local ownName = self:NormalizePlayerName(UnitName("player")) - local isOwnPlayer = playerName == ownName - local pData = isOwnPlayer and self.playerData[ownName] or nil - local talents = pData and pData.talents or {} - local effectiveCd = spellEntry and HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) or 0 - local knownMaxCharges, knownChargeDuration = 0, 0 - if spellEntry and isOwnPlayer then - knownMaxCharges, knownChargeDuration = self:GetKnownChargeInfo(spellEntry, talents, spellId, effectiveCd) - end - - if self:IsAvailabilitySpell(spellEntry) then - local normalizedName = self:NormalizePlayerName(playerName) - if normalizedName == ownName then - local current, max = self:GetOwnAvailabilityProgress(spellEntry) - if (tonumber(max) or 0) > 0 then - self:StoreAvailabilityState(ownName, spellId, current, max, spellEntry) - return 0, 0, current, max - end - else - local current, max = self:GetAvailabilityState(normalizedName, spellId) - if (tonumber(max) or 0) > 0 then - return 0, 0, current, max - end - end - return 0, 0, nil, nil - end - - local cdData = self.activeCDs[playerName] and self.activeCDs[playerName][spellId] - - -- Fuer den eigenen Spieler bevorzugt echte Spell-Charge-Infos verwenden. - -- So werden Talent-Stacks (z.B. 2 Charges) korrekt berechnet und angezeigt. - if isOwnPlayer and not (InCombatLockdown and InCombatLockdown()) then - local charges, maxCharges, chargeStart, chargeDuration = GetSpellChargesInfo(spellId) - charges = SafeApiNumber(charges, 0) or 0 - maxCharges = SafeApiNumber(maxCharges, 0) or 0 - chargeStart = SafeApiNumber(chargeStart) - chargeDuration = SafeApiNumber(chargeDuration, 0) or 0 - - if maxCharges > 0 then - local tempChargeState = { - currentCharges = charges, - maxCharges = maxCharges, - chargeStart = chargeStart, - chargeDuration = chargeDuration, - duration = chargeDuration, - } - local remaining, total, curCharges, maxChargeCount = self:ResolveChargeState(tempChargeState) - self:StoreKnownChargeInfo(spellId, maxChargeCount, total > 0 and total or chargeDuration) - -- API fallback: if charges are empty but charge timer is missing/zero, - -- try classic spell cooldown so sweep can still render. - if (curCharges or 0) < maxChargeCount and remaining <= 0 then - local cdStart, cdDuration = GetSpellCooldownInfo(spellId) - if cdDuration > 0 then - remaining = math.max(0, cdDuration - (GetTime() - cdStart)) - total = math.max(total or 0, cdDuration) - end - end - if deferUntilEmpty and (curCharges or 0) > 0 then - remaining = 0 - end - return remaining, total, curCharges, maxChargeCount - end - - local cdStart, cdDuration = GetSpellCooldownInfo(spellId) - cdStart = tonumber(cdStart) or 0 - cdDuration = tonumber(cdDuration) or 0 - if cdDuration > 0 then - local remaining = math.max(0, cdDuration - (GetTime() - cdStart)) - remaining = math.max(0, math.min(cdDuration, remaining)) - if cdData and (tonumber(cdData.maxCharges) or 0) <= 0 then - local cachedRemaining = (tonumber(cdData.duration) or 0) - (GetTime() - (tonumber(cdData.startTime) or GetTime())) - cachedRemaining = math.max(0, math.min(tonumber(cdData.duration) or cachedRemaining, cachedRemaining)) - local cachedDuration = math.max(0, tonumber(cdData.duration) or 0) - if cachedDuration > 2.0 and cachedRemaining > 2.0 and cdDuration < math.max(2.0, cachedDuration * 0.35) then - return cachedRemaining, cachedDuration, nil, nil - end - end - return remaining, cdDuration, nil, nil - end - end - if not cdData then - if isOwnPlayer and knownMaxCharges > 1 then - return 0, math.max(0, knownChargeDuration or effectiveCd or 0), knownMaxCharges, knownMaxCharges - end - return 0, 0, nil, nil - end - if (tonumber(cdData.maxCharges) or 0) > 0 then - local remaining, chargeDur, charges, maxCharges = self:ResolveChargeState(cdData) - self:StoreKnownChargeInfo(spellId, maxCharges, chargeDur) - if deferUntilEmpty and charges > 0 then - remaining = 0 - end - return remaining, chargeDur, charges, maxCharges - end - if isOwnPlayer and knownMaxCharges > 1 then - local remaining = (tonumber(cdData.duration) or 0) - (GetTime() - (tonumber(cdData.startTime) or GetTime())) - remaining = math.max(0, math.min(tonumber(cdData.duration) or remaining, remaining)) - local currentCharges = knownMaxCharges - if remaining > 0 then - currentCharges = math.max(0, knownMaxCharges - 1) - end - if deferUntilEmpty and currentCharges > 0 then - remaining = 0 - end - return remaining, math.max(0, knownChargeDuration or effectiveCd or 0), currentCharges, knownMaxCharges - end - local remaining = cdData.duration - (GetTime() - cdData.startTime) - remaining = math.max(0, math.min(cdData.duration, remaining)) - return remaining, cdData.duration, nil, nil -end - -function HMGT:ShouldDisplayEntry(settings, remaining, currentCharges, maxCharges, spellEntry) - local rem = tonumber(remaining) or 0 - local cur = tonumber(currentCharges) or 0 - local max = tonumber(maxCharges) or 0 - local soon = tonumber(settings.readySoonSec) or 0 - local isAvailabilitySpell = spellEntry and self:IsAvailabilitySpell(spellEntry) or false - local isReady - - if isAvailabilitySpell then - isReady = max > 0 and cur >= max - else - isReady = rem <= 0 or (max > 0 and cur > 0) - end - - if settings.showOnlyReady then - return isReady - end - if soon > 0 then - if isAvailabilitySpell then - return isReady - end - return isReady or rem <= soon - end - return true -end - -local DEFAULT_CATEGORY_PRIORITY = { - interrupt = 1, - lust = 2, - defensive = 3, - tank = 4, - healing = 5, - offensive = 6, - utility = 7, - cc = 8, -} - -local TRACKER_CATEGORY_PRIORITY = { - interruptTracker = { - interrupt = 1, - defensive = 2, - utility = 3, - cc = 4, - healing = 5, - tank = 6, - offensive = 7, - lust = 8, - }, - raidCooldownTracker = { - lust = 1, - defensive = 2, - healing = 3, - tank = 4, - utility = 5, - offensive = 6, - cc = 7, - interrupt = 8, - }, - groupCooldownTracker = { - tank = 1, - defensive = 2, - healing = 3, - cc = 4, - utility = 5, - offensive = 6, - lust = 7, - interrupt = 8, - }, -} - -local function GetCategoryPriority(category, trackerKey) - local cat = tostring(category or "utility") - local trackerOrder = trackerKey and TRACKER_CATEGORY_PRIORITY[trackerKey] - if trackerOrder and trackerOrder[cat] then - return trackerOrder[cat] - end - local order = HMGT_SpellData and HMGT_SpellData.CategoryOrder - if type(order) == "table" then - for idx, key in ipairs(order) do - if key == cat then - return idx - end - end - return #order + 10 - end - return DEFAULT_CATEGORY_PRIORITY[cat] or 99 -end - -function HMGT:SortDisplayEntries(entries, trackerKey) - if type(entries) ~= "table" then return end - table.sort(entries, function(a, b) - local aRemaining = tonumber(a and a.remaining) or 0 - local bRemaining = tonumber(b and b.remaining) or 0 - local aActive = aRemaining > 0 - local bActive = bRemaining > 0 - if aActive ~= bActive then - return aActive - end - - local aEntry = a and a.spellEntry - local bEntry = b and b.spellEntry - - local aPriority = tonumber(aEntry and aEntry.priority) or GetCategoryPriority(aEntry and aEntry.category, trackerKey) - local bPriority = tonumber(bEntry and bEntry.priority) or GetCategoryPriority(bEntry and bEntry.category, trackerKey) - if aPriority ~= bPriority then - return aPriority < bPriority - end - - if aActive and aRemaining ~= bRemaining then - return aRemaining < bRemaining - end - - local aTotal = tonumber(a and a.total) - or tonumber(aEntry and HMGT_SpellData.GetBaseCooldown and HMGT_SpellData.GetBaseCooldown(aEntry)) - or tonumber(aEntry and aEntry.cooldown) - or 0 - local bTotal = tonumber(b and b.total) - or tonumber(bEntry and HMGT_SpellData.GetBaseCooldown and HMGT_SpellData.GetBaseCooldown(bEntry)) - or tonumber(bEntry and bEntry.cooldown) - or 0 - if (not aActive) and aTotal ~= bTotal then - return aTotal > bTotal - end - - if aRemaining ~= bRemaining then - return aRemaining < bRemaining - end - - local aName = tostring(a and a.playerName or "") - local bName = tostring(b and b.playerName or "") - if aName ~= bName then - return aName < bName - end - - local aSpell = tonumber(aEntry and aEntry.spellId) or 0 - local bSpell = tonumber(bEntry and bEntry.spellId) or 0 - return aSpell < bSpell - end) -end - function HMGT:GetUnitForPlayer(playerName) local target = self:NormalizePlayerName(playerName) if not target then return nil end diff --git a/HailMaryGuildTools.toc b/HailMaryGuildTools.toc index abc4837..5d46a58 100644 --- a/HailMaryGuildTools.toc +++ b/HailMaryGuildTools.toc @@ -31,7 +31,17 @@ HailMaryGuildToolsOptions.lua # ────── Tracker ────────────────────────────────────────────────────── Modules\Tracker\Frame.lua Modules\Tracker\SpellDatabase.lua -Modules\Tracker\SingleFrameTrackerBase.lua +Modules\Tracker\TrackerCore.lua +Modules\Tracker\TrackerState.lua +Modules\Tracker\TrackerPlayerState.lua +Modules\Tracker\TrackerBridge.lua +Modules\Tracker\TrackerDataProvider.lua +Modules\Tracker\TrackerSync.lua +Modules\Tracker\TrackerAvailability.lua +Modules\Tracker\TrackerDetection.lua +Modules\Tracker\InterruptTracker\InterruptTracker.lua +Modules\Tracker\RaidCooldownTracker\RaidCooldownTracker.lua +Modules\Tracker\GroupCooldownTracker\GroupCooldownTracker.lua Modules\Tracker\InterruptTracker\InterruptSpellDatabase.lua Modules\Tracker\RaidcooldownTracker\RaidCooldownSpellDatabase.lua @@ -56,4 +66,4 @@ Modules\RaidTimeline\RaidTimelineBossAbilityData.lua Modules\RaidTimeline\RaidTimeline.lua Modules\RaidTimeline\RaidTimelineBigWigs.lua Modules\RaidTimeline\RaidTimelineDBM.lua -Modules\RaidTimeline\RaidTimelineOptions.lua \ No newline at end of file +Modules\RaidTimeline\RaidTimelineOptions.lua diff --git a/Modules/Tracker/GroupCooldownTracker/GroupCooldownTracker.lua b/Modules/Tracker/GroupCooldownTracker/GroupCooldownTracker.lua index 7b479cf..dd91d9c 100644 --- a/Modules/Tracker/GroupCooldownTracker/GroupCooldownTracker.lua +++ b/Modules/Tracker/GroupCooldownTracker/GroupCooldownTracker.lua @@ -1,692 +1,65 @@ --- Modules/GroupCooldownTracker.lua --- Group-Cooldown-Tracker Modul (ein Frame pro Spieler in der Gruppe) - local ADDON_NAME = "HailMaryGuildTools" local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) if not HMGT then return end -local L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME) -local GCT = HMGT:NewModule("GroupCooldownTracker") -HMGT.GroupCooldownTracker = GCT +local L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME) -GCT.frame = nil -GCT.frames = {} +local module = HMGT:NewModule("GroupCooldownTracker") +HMGT.GroupCooldownTracker = module -local function SanitizeFrameToken(name) - if not name or name == "" then return "Unknown" end - return name:gsub("[^%w_]", "_") -end - -local function ShortName(name) - if not name then return "" end - local short = name:match("^[^-]+") - return short or name -end - -local function IsUsableAnchorFrame(frame) - return frame - and frame.IsObjectType - and (frame:IsObjectType("Frame") or frame:IsObjectType("Button")) -end - -local function GetFrameUnit(frame) - if not frame then return nil end - local unit = frame.unit - if not unit and frame.GetAttribute then - unit = frame:GetAttribute("unit") - end - return unit -end - -local function FrameMatchesUnit(frame, unitId) - if not IsUsableAnchorFrame(frame) then return false end - if not unitId then return true end - local unit = GetFrameUnit(frame) - return unit == unitId -end - -local PLAYER_FRAME_CANDIDATES = { - "PlayerFrame", - "ElvUF_Player", - "NephUI_PlayerFrame", - "NephUIPlayerFrame", - "oUF_NephUI_Player", - "SUFUnitplayer", +module.definition = { + moduleName = "GroupCooldownTracker", + dbKey = "groupCooldownTracker", + trackerType = "group", + trackerKey = "groupCooldownTracker", + title = function() + return L["GCD_TITLE"] + end, + categories = { "tank", "defensive", "healing", "cc", "utility", "offensive", "lust", "interrupt" }, } -local PARTY_FRAME_PATTERNS = { - "PartyMemberFrame%d", -- Blizzard alt - "CompactPartyFrameMember%d", -- Blizzard modern - "ElvUF_PartyGroup1UnitButton%d", -- ElvUI - "ElvUF_PartyUnitButton%d", -- ElvUI variant - "NephUI_PartyUnitButton%d", -- NephUI (common naming variants) - "NephUI_PartyFrame%d", - "NephUIPartyFrame%d", - "oUF_NephUI_PartyUnitButton%d", - "SUFUnitparty%d", -- Shadowed Unit Frames -} - -local unitFrameCache = {} - -local function EntryNeedsVisualTicker(entry) - if type(entry) ~= "table" then - return false - end - - local remaining = tonumber(entry.remaining) or 0 - if remaining > 0 then - return true - end - - local maxCharges = tonumber(entry.maxCharges) or 0 - local currentCharges = tonumber(entry.currentCharges) - if maxCharges > 0 and currentCharges ~= nil and currentCharges < maxCharges then - return true - end - - return false +function module:GetDefinition() + return self.definition end -local function BuildAnchorLayoutSignature(settings, ordered, unitByPlayer) - local parts = { - settings.attachToPartyFrame == true and "attach" or "stack", - tostring(settings.partyAttachSide or "RIGHT"), - tostring(tonumber(settings.partyAttachOffsetX) or 8), - tostring(tonumber(settings.partyAttachOffsetY) or 0), - tostring(settings.showBar and "bar" or "icon"), - tostring(settings.growDirection or "DOWN"), - tostring(settings.width or 250), - tostring(settings.barHeight or 20), - tostring(settings.iconSize or 32), - tostring(settings.iconCols or 6), - tostring(settings.barSpacing or 2), - tostring(settings.locked), - tostring(settings.anchorTo or "UIParent"), - tostring(settings.anchorPoint or "TOPLEFT"), - tostring(settings.anchorRelPoint or "TOPLEFT"), - tostring(settings.anchorX or settings.posX or 0), - tostring(settings.anchorY or settings.posY or 0), - } - - for _, playerName in ipairs(ordered or {}) do - parts[#parts + 1] = tostring(playerName) - parts[#parts + 1] = tostring(unitByPlayer and unitByPlayer[playerName] or "") - end - - return table.concat(parts, "|") +function module:GetSettings() + local profile = HMGT.db and HMGT.db.profile + return profile and profile[self.definition.dbKey] or nil end -local function ResolveNamedUnitFrame(unitId) - if unitId == "player" then - for _, frameName in ipairs(PLAYER_FRAME_CANDIDATES) do - local frame = _G[frameName] - if FrameMatchesUnit(frame, unitId) or (frameName == "PlayerFrame" and IsUsableAnchorFrame(frame)) then - return frame - end - end - return nil - end - - local idx = type(unitId) == "string" and unitId:match("^party(%d+)$") - if not idx then - return nil - end - - idx = tonumber(idx) - for _, pattern in ipairs(PARTY_FRAME_PATTERNS) do - local frame = _G[pattern:format(idx)] - if FrameMatchesUnit(frame, unitId) then - return frame - end - end - return nil -end - -local function ScanUnitFrame(unitId) - local frame = EnumerateFrames() - local scanned = 0 - while frame and scanned < 8000 do - if IsUsableAnchorFrame(frame) then - local unit = GetFrameUnit(frame) - if unit == unitId then - HMGT:DebugScoped("verbose", HMGT:GetTrackerDebugScope("Group Cooldowns"), "GroupAttach scan unit=%s scanned=%d found=true", tostring(unitId), scanned) - return frame - end - end - scanned = scanned + 1 - frame = EnumerateFrames(frame) - end - HMGT:DebugScoped("verbose", HMGT:GetTrackerDebugScope("Group Cooldowns"), "GroupAttach scan unit=%s scanned=%d found=false", tostring(unitId), scanned) - return nil -end - -local function ResolveUnitAnchorFrame(unitId) - if not unitId then return nil end - - local now = GetTime() - local cached = unitFrameCache[unitId] - if cached and now < (cached.expires or 0) then - if cached.frame and cached.frame:IsShown() then - return cached.frame - end - return nil - end - - local frame = ResolveNamedUnitFrame(unitId) - if not frame then - frame = ScanUnitFrame(unitId) - end - - local expiresIn = 1.0 - if frame and frame:IsShown() then - expiresIn = 10.0 - end - unitFrameCache[unitId] = { - frame = frame, - expires = now + expiresIn, - } - - if frame and frame:IsShown() then - return frame - end - return nil -end - -function GCT:GetFrameIdForPlayer(playerName) - return "GroupCooldownTracker_" .. SanitizeFrameToken(playerName) -end - -function GCT:GetPlayerFrame(playerName) - if not playerName then return nil end - return self.frames[playerName] -end - -function GCT:GetAnchorableFrames() - return self.frames -end - -function GCT:EnsurePlayerFrame(playerName) - local frame = self.frames[playerName] - local s = HMGT.db.profile.groupCooldownTracker - if frame then - return frame - end - - frame = HMGT.TrackerFrame:CreateTrackerFrame(self:GetFrameIdForPlayer(playerName), s) - frame._hmgtPlayerName = playerName - self.frames[playerName] = frame - return frame -end - -function GCT:HideAllFrames() - for _, frame in pairs(self.frames) do - frame:Hide() - end - self.activeOrder = nil - self.unitByPlayer = nil - self.frame = nil - self._lastAnchorLayoutSignature = nil - self._nextAnchorRetryAt = nil -end - -function GCT:SetLockedAll(locked) - for _, frame in pairs(self.frames) do - HMGT.TrackerFrame:SetLocked(frame, locked) +function module:Enable() + if HMGT.TrackerManager and HMGT.TrackerManager.Enable then + HMGT.TrackerManager:Enable() end end -function GCT:EnsureUpdateTicker() - if self.updateTicker then - return - end - self.updateTicker = C_Timer.NewTicker(0.1, function() - self:UpdateDisplay() - end) -end - -function GCT:StopUpdateTicker() - if self.updateTicker then - self.updateTicker:Cancel() - self.updateTicker = nil +function module:Disable() + if HMGT.TrackerManager and HMGT.TrackerManager.UpdateDisplay then + HMGT.TrackerManager:UpdateDisplay() end end -function GCT:SetUpdateTickerEnabled(enabled) - if enabled then - self:EnsureUpdateTicker() - else - self:StopUpdateTicker() +function module:SetLockedAll(locked) + if HMGT.TrackerManager and HMGT.TrackerManager.SetAllLocked then + HMGT.TrackerManager:SetAllLocked(locked) end end -function GCT:InvalidateAnchorLayout() - self._lastAnchorLayoutSignature = nil - self._nextAnchorRetryAt = nil +function module:RefreshAnchors(force) + if HMGT.TrackerManager and HMGT.TrackerManager.RefreshAnchors then + HMGT.TrackerManager:RefreshAnchors(force) + end end -function GCT:RefreshAnchors(force) - local s = HMGT.db.profile.groupCooldownTracker - if not s then return end - - local ordered = {} - for _, playerName in ipairs(self.activeOrder or {}) do - local frame = self.frames[playerName] - if frame and frame:IsShown() then - table.insert(ordered, playerName) - end +function module:InvalidateAnchorLayout() + if HMGT.TrackerManager and HMGT.TrackerManager.InvalidateAnchorLayout then + HMGT.TrackerManager:InvalidateAnchorLayout() end - - if #ordered == 0 then - self.frame = nil - self._lastAnchorLayoutSignature = nil - self._nextAnchorRetryAt = nil - return - end - - local now = GetTime() - local signature = BuildAnchorLayoutSignature(s, ordered, self.unitByPlayer) - if not force and self._lastAnchorLayoutSignature == signature then - local retryAt = tonumber(self._nextAnchorRetryAt) or 0 - if retryAt <= 0 or now < retryAt then - return - end - end - - -- Do not force anchor updates while user is dragging a tracker frame. - for _, playerName in ipairs(ordered) do - local frame = self.frames[playerName] - if frame and frame._hmgtDragging then - return - end - end - - local primaryName = ordered[1] - local primary = self.frames[primaryName] - self.frame = primary - - if s.attachToPartyFrame == true then - local side = s.partyAttachSide or "RIGHT" - local extraX = tonumber(s.partyAttachOffsetX) or 8 - local extraY = tonumber(s.partyAttachOffsetY) or 0 - local growsUp = s.showBar == true and s.growDirection == "UP" - local barHeight = tonumber(s.barHeight) or 20 - local growUpAttachOffset = barHeight + 20 - local prevPlaced = nil - local missingTargets = 0 - - for i = 1, #ordered do - local playerName = ordered[i] - local frame = self.frames[playerName] - local unitId = self.unitByPlayer and self.unitByPlayer[playerName] - local target = ResolveUnitAnchorFrame(unitId) - local contentTopInset = HMGT.TrackerFrame.GetContentTopInset and HMGT.TrackerFrame:GetContentTopInset(frame) or 0 - - frame:ClearAllPoints() - if target then - if side == "LEFT" then - if growsUp then - frame:SetPoint("BOTTOMRIGHT", target, "TOPLEFT", -extraX, extraY - growUpAttachOffset) - else - frame:SetPoint("TOPRIGHT", target, "TOPLEFT", -extraX, extraY + contentTopInset) - end - else - if growsUp then - frame:SetPoint("BOTTOMLEFT", target, "TOPRIGHT", extraX, extraY - growUpAttachOffset) - else - frame:SetPoint("TOPLEFT", target, "TOPRIGHT", extraX, extraY + contentTopInset) - end - end - elseif prevPlaced then - missingTargets = missingTargets + 1 - HMGT:DebugScoped("verbose", HMGT:GetTrackerDebugScope("Group Cooldowns"), "GroupAttach fallback-stack player=%s unit=%s", tostring(playerName), tostring(unitId)) - if growsUp then - frame:SetPoint("BOTTOMLEFT", prevPlaced, "TOPLEFT", 0, (s.barSpacing or 2) + 10) - else - frame:SetPoint("TOPLEFT", prevPlaced, "BOTTOMLEFT", 0, -((s.barSpacing or 2) + 10)) - end - else - missingTargets = missingTargets + 1 - HMGT:DebugScoped("info", HMGT:GetTrackerDebugScope("Group Cooldowns"), "GroupAttach fallback-anchor player=%s unit=%s (no party frame found)", tostring(playerName), tostring(unitId)) - HMGT.TrackerFrame:ApplyAnchor(frame) - end - - frame:EnableMouse(false) - prevPlaced = frame - end - if missingTargets > 0 then - self._lastAnchorLayoutSignature = nil - self._nextAnchorRetryAt = now + 1.0 - else - self._lastAnchorLayoutSignature = signature - self._nextAnchorRetryAt = nil - end - return - end - - HMGT.TrackerFrame:ApplyAnchor(primary) - primary:EnableMouse(not s.locked) - - local gap = (s.barSpacing or 2) + 10 - local growsUp = s.showBar == true and s.growDirection == "UP" - for i = 2, #ordered do - local prev = self.frames[ordered[i - 1]] - local frame = self.frames[ordered[i]] - frame:ClearAllPoints() - if growsUp then - frame:SetPoint("BOTTOMLEFT", prev, "TOPLEFT", 0, gap) - else - frame:SetPoint("TOPLEFT", prev, "BOTTOMLEFT", 0, -gap) - end - frame:EnableMouse(false) - end - self._lastAnchorLayoutSignature = signature - self._nextAnchorRetryAt = nil end --- ============================================================ --- ENABLE / DISABLE --- ============================================================ - -function GCT:Enable() - local s = HMGT.db.profile.groupCooldownTracker - if not s.enabled and not s.demoMode and not s.testMode then return end - - self:UpdateDisplay() +function module:GetAnchorableFrames() + if HMGT.TrackerManager and HMGT.TrackerManager.GetAnchorableFrames then + return HMGT.TrackerManager:GetAnchorableFrames() + end + return {} end - -function GCT:Disable() - self:StopUpdateTicker() - self:HideAllFrames() -end - --- ============================================================ --- DISPLAY UPDATE --- ============================================================ - -function GCT:UpdateDisplay() - local s = HMGT.db.profile.groupCooldownTracker - if not s then return end - - if s.testMode then - local entries, playerName = HMGT:GetOwnTestEntries(HMGT_SpellData.GroupCooldowns, s, { - deferChargeCooldownUntilEmpty = false, - }) - local byPlayer = { [playerName] = {} } - for _, entry in ipairs(entries) do - entry.playerName = playerName - table.insert(byPlayer[playerName], entry) - end - - self.activeOrder = { playerName } - self.unitByPlayer = { [playerName] = "player" } - self.lastEntryCount = 0 - local active = {} - local shownOrder = {} - local shouldTick = false - for _, pName in ipairs(self.activeOrder) do - local frame = self:EnsurePlayerFrame(pName) - HMGT.TrackerFrame:SetLocked(frame, s.locked) - HMGT.TrackerFrame:SetTitle(frame, string.format("%s - %s", L["GCD_TITLE"], ShortName(pName))) - local displayEntries = byPlayer[pName] - if HMGT.FilterDisplayEntries then - displayEntries = HMGT:FilterDisplayEntries(s, displayEntries) or displayEntries - end - if HMGT.SortDisplayEntries then - HMGT:SortDisplayEntries(displayEntries, "groupCooldownTracker") - end - if #displayEntries > 0 then - HMGT.TrackerFrame:UpdateFrame(frame, displayEntries, true) - self.lastEntryCount = self.lastEntryCount + #displayEntries - frame:Show() - active[pName] = true - shownOrder[#shownOrder + 1] = pName - for _, entry in ipairs(displayEntries) do - if EntryNeedsVisualTicker(entry) then - shouldTick = true - break - end - end - else - frame:Hide() - end - end - self.activeOrder = shownOrder - - for pn, frame in pairs(self.frames) do - if not active[pn] then - frame:Hide() - end - end - - self:RefreshAnchors() - self:SetUpdateTickerEnabled(shouldTick) - return - end - - if s.demoMode then - local entries = HMGT:GetDemoEntries("groupCooldownTracker", HMGT_SpellData.GroupCooldowns, s) - local playerName = HMGT:NormalizePlayerName(UnitName("player")) or "DemoPlayer" - local byPlayer = { [playerName] = {} } - for _, entry in ipairs(entries) do - entry.playerName = playerName - table.insert(byPlayer[playerName], entry) - end - - self.activeOrder = { playerName } - self.unitByPlayer = { [playerName] = "player" } - self.lastEntryCount = 0 - local active = {} - local shownOrder = {} - local shouldTick = false - for _, playerName in ipairs(self.activeOrder) do - local frame = self:EnsurePlayerFrame(playerName) - HMGT.TrackerFrame:SetLocked(frame, s.locked) - HMGT.TrackerFrame:SetTitle(frame, string.format("%s - %s", L["GCD_TITLE"], ShortName(playerName))) - local displayEntries = byPlayer[playerName] - if HMGT.FilterDisplayEntries then - displayEntries = HMGT:FilterDisplayEntries(s, displayEntries) or displayEntries - end - if HMGT.SortDisplayEntries then - HMGT:SortDisplayEntries(displayEntries, "groupCooldownTracker") - end - if #displayEntries > 0 then - HMGT.TrackerFrame:UpdateFrame(frame, displayEntries, true) - self.lastEntryCount = self.lastEntryCount + #displayEntries - frame:Show() - active[playerName] = true - shownOrder[#shownOrder + 1] = playerName - shouldTick = true - else - frame:Hide() - end - end - self.activeOrder = shownOrder - - for pn, frame in pairs(self.frames) do - if not active[pn] then - frame:Hide() - end - end - - self:RefreshAnchors() - self:SetUpdateTickerEnabled(shouldTick) - return - end - - if IsInRaid() or not IsInGroup() then - self.lastEntryCount = 0 - self:StopUpdateTicker() - self:HideAllFrames() - return - end - if not s.enabled then - self.lastEntryCount = 0 - self:StopUpdateTicker() - self:HideAllFrames() - return - end - if not HMGT:IsVisibleForCurrentGroup(s) then - self.lastEntryCount = 0 - self:StopUpdateTicker() - self:HideAllFrames() - return - end - - local entriesByPlayer, order, unitByPlayer = self:CollectEntriesByPlayer() - self.activeOrder = order - self.unitByPlayer = unitByPlayer - self.lastEntryCount = 0 - local active = {} - local shownOrder = {} - local shouldTick = false - - for _, playerName in ipairs(order) do - local frame = self:EnsurePlayerFrame(playerName) - HMGT.TrackerFrame:SetLocked(frame, s.locked) - HMGT.TrackerFrame:SetTitle(frame, string.format("%s - %s", L["GCD_TITLE"], ShortName(playerName))) - - local entries = entriesByPlayer[playerName] or {} - if HMGT.FilterDisplayEntries then - entries = HMGT:FilterDisplayEntries(s, entries) or entries - end - if HMGT.SortDisplayEntries then - HMGT:SortDisplayEntries(entries, "groupCooldownTracker") - end - if #entries > 0 then - HMGT.TrackerFrame:UpdateFrame(frame, entries, true) - self.lastEntryCount = self.lastEntryCount + #entries - frame:Show() - active[playerName] = true - shownOrder[#shownOrder + 1] = playerName - for _, entry in ipairs(entries) do - if EntryNeedsVisualTicker(entry) then - shouldTick = true - break - end - end - else - frame:Hide() - end - end - self.activeOrder = shownOrder - - for pn, frame in pairs(self.frames) do - if not active[pn] then - frame:Hide() - end - end - - self:RefreshAnchors() - self:SetUpdateTickerEnabled(shouldTick) -end - -function GCT:CollectEntriesByPlayer() - local s = HMGT.db.profile.groupCooldownTracker - local byPlayer = {} - local playerOrder = {} - local unitByPlayer = {} - local players = self:GetGroupPlayers() - - for _, playerInfo in ipairs(players) do - repeat - local name = playerInfo.name - if not name then break end - - local pData = HMGT.playerData[name] - local class = pData and pData.class or playerInfo.class - local specIdx - if playerInfo.isOwn then - specIdx = GetSpecialization() - if not specIdx or specIdx == 0 then break end - else - specIdx = pData and pData.specIndex or nil - if not specIdx or tonumber(specIdx) <= 0 then break end - end - local talents = pData and pData.talents or {} - if not class then break end - - local knownCDs = HMGT_SpellData.GetSpellsForSpec(class, specIdx, HMGT_SpellData.GroupCooldowns) - local entries = {} - for _, spellEntry in ipairs(knownCDs) do - if s.enabledSpells[spellEntry.spellId] ~= false then - local remaining, total, curCharges, maxCharges = HMGT:GetCooldownInfo(name, spellEntry.spellId, { - deferChargeCooldownUntilEmpty = false, - }) - local isAvailabilitySpell = HMGT.IsAvailabilitySpell and HMGT:IsAvailabilitySpell(spellEntry) - local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) - local hasChargeSpell = (tonumber(maxCharges) or 0) > 1 - local hasPartialCharges = (tonumber(maxCharges) or 0) > 0 and (tonumber(curCharges) or tonumber(maxCharges) or 0) < (tonumber(maxCharges) or 0) - local include = HMGT:ShouldDisplayEntry(s, remaining, curCharges, maxCharges, spellEntry) - local spellKnown = HMGT:IsTrackedSpellKnownForPlayer(name, spellEntry.spellId) - local hasActiveCd = ((remaining or 0) > 0) or hasPartialCharges - if not spellKnown and not hasActiveCd then - include = false - end - if isAvailabilitySpell and not spellKnown then - include = false - end - if not playerInfo.isOwn then - if isAvailabilitySpell and not HMGT:HasAvailabilityState(name, spellEntry.spellId) then - include = false - end - end - if include then - table.insert(entries, { - playerName = name, - class = class, - spellEntry = spellEntry, - remaining = remaining, - total = total > 0 and total or effectiveCd, - currentCharges = curCharges, - maxCharges = maxCharges, - }) - end - end - end - - if #entries > 0 then - byPlayer[name] = entries - table.insert(playerOrder, name) - unitByPlayer[name] = playerInfo.unitId - end - until true - end - - table.sort(playerOrder, function(a, b) - local own = HMGT:NormalizePlayerName(UnitName("player")) - if a == own and b ~= own then return true end - if b == own and a ~= own then return false end - return a < b - end) - - return byPlayer, playerOrder, unitByPlayer -end - -function GCT:GetGroupPlayers() - local players = {} - local ownName = HMGT:NormalizePlayerName(UnitName("player")) - local settings = HMGT.db and HMGT.db.profile and HMGT.db.profile.groupCooldownTracker - - if settings and settings.includeSelfFrame == true and ownName then - table.insert(players, { - name = ownName, - class = select(2, UnitClass("player")), - unitId = "player", - isOwn = true, - }) - end - - if IsInGroup() and not IsInRaid() then - for i = 1, GetNumGroupMembers() - 1 do - local unitId = "party" .. i - local name = HMGT:NormalizePlayerName(UnitName(unitId)) - local class = select(2, UnitClass(unitId)) - if name and name ~= ownName then - table.insert(players, {name = name, class = class, unitId = unitId}) - end - end - end - - return players -end - diff --git a/Modules/Tracker/GroupTrackerFrames.lua b/Modules/Tracker/GroupTrackerFrames.lua index ba304f9..975199a 100644 --- a/Modules/Tracker/GroupTrackerFrames.lua +++ b/Modules/Tracker/GroupTrackerFrames.lua @@ -42,54 +42,13 @@ function Manager:HidePlayerFrames(frameKey) end function Manager:BuildEntriesByPlayerForTracker(tracker) - local frameKey = S.GetTrackerFrameKey(tracker.id) - local ownName = HMGT:NormalizePlayerName(UnitName("player")) or "Player" - if tracker.testMode then - local entries = self:CollectTestEntries(tracker) - if S.IsGroupTracker(tracker) and tracker.attachToPartyFrame == true then - return S.BuildPartyPreviewEntries(entries) + return HMGT:BuildEntriesByPlayerForTracker( + tracker, + self:GetTrackerFrameKey(tracker), + function(unitId) + return S.ResolveUnitAnchorFrame(unitId) end - local byPlayer, order, unitByPlayer = {}, {}, {} - if #entries > 0 then - byPlayer[ownName] = entries - order[1] = ownName - unitByPlayer[ownName] = "player" - end - return byPlayer, order, unitByPlayer, true - end - if tracker.demoMode then - local entries = HMGT:GetDemoEntries(frameKey, S.GetTrackerSpellPool(tracker.categories), tracker) - if S.IsGroupTracker(tracker) and tracker.attachToPartyFrame == true then - return S.BuildPartyPreviewEntries(entries) - end - for _, entry in ipairs(entries) do - entry.playerName = ownName - end - local byPlayer, order, unitByPlayer = {}, {}, {} - if #entries > 0 then - byPlayer[ownName] = entries - order[1] = ownName - unitByPlayer[ownName] = "player" - end - return byPlayer, order, unitByPlayer, true - end - if not tracker.enabled or not HMGT:IsVisibleForCurrentGroup(tracker) then - return {}, {}, {}, false - end - if IsInRaid() or not IsInGroup() then - return {}, {}, {}, false - end - local byPlayer, order, unitByPlayer = {}, {}, {} - for _, playerInfo in ipairs(S.GetGroupPlayers(tracker)) do - local entries = S.CollectEntriesForPlayer(tracker, playerInfo) - if #entries > 0 then - local playerName = playerInfo.name - byPlayer[playerName] = entries - order[#order + 1] = playerName - unitByPlayer[playerName] = playerInfo.unitId - end - end - return byPlayer, order, unitByPlayer, true + ) end function Manager:RefreshPerGroupAnchors(tracker, force) @@ -206,11 +165,10 @@ function Manager:UpdatePerGroupMemberTracker(tracker) for _, playerName in ipairs(order) do local frame = self:EnsurePlayerFrame(tracker, playerName) local entries = byPlayer[playerName] or {} - if HMGT.FilterDisplayEntries then - entries = HMGT:FilterDisplayEntries(tracker, entries) or entries - end - if HMGT.SortDisplayEntries then - HMGT:SortDisplayEntries(entries) + local tickThis = false + entries, tickThis = HMGT:FinalizeTrackerEntries(tracker, entries, tracker.trackerKey or nil) + if tickThis then + shouldTick = true end if #entries > 0 then HMGT.TrackerFrame:UpdateFrame(frame, entries, true) @@ -219,12 +177,6 @@ function Manager:UpdatePerGroupMemberTracker(tracker) shownOrder[#shownOrder + 1] = playerName shownByPlayer[playerName] = entries entryCount = entryCount + #entries - for _, entry in ipairs(entries) do - if S.EntryNeedsVisualTicker(entry) then - shouldTick = true - break - end - end else frame:Hide() end diff --git a/Modules/Tracker/InterruptTracker/InterruptTracker.lua b/Modules/Tracker/InterruptTracker/InterruptTracker.lua index 36a6406..e9d3eea 100644 --- a/Modules/Tracker/InterruptTracker/InterruptTracker.lua +++ b/Modules/Tracker/InterruptTracker/InterruptTracker.lua @@ -1,21 +1,40 @@ --- Modules/InterruptTracker.lua --- Interrupt tracker based on the shared single-frame tracker base. - local ADDON_NAME = "HailMaryGuildTools" local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) +if not HMGT then return end + local L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME) -local Base = HMGT.SingleFrameTrackerBase -if not Base then return end +local module = HMGT:NewModule("InterruptTracker") +HMGT.InterruptTracker = module -Base:CreateModule("InterruptTracker", { - profileKey = "interruptTracker", - frameName = "InterruptTracker", +module.definition = { + moduleName = "InterruptTracker", + dbKey = "interruptTracker", + trackerType = "normal", + trackerKey = "interruptTracker", title = function() return L["IT_TITLE"] end, - demoKey = "interruptTracker", - database = function() - return HMGT_SpellData.Interrupts - end, -}) + categories = { "interrupt" }, +} + +function module:GetDefinition() + return self.definition +end + +function module:GetSettings() + local profile = HMGT.db and HMGT.db.profile + return profile and profile[self.definition.dbKey] or nil +end + +function module:Enable() + if HMGT.TrackerManager and HMGT.TrackerManager.Enable then + HMGT.TrackerManager:Enable() + end +end + +function module:Disable() + if HMGT.TrackerManager and HMGT.TrackerManager.UpdateDisplay then + HMGT.TrackerManager:UpdateDisplay() + end +end diff --git a/Modules/Tracker/NormalTrackerFrames.lua b/Modules/Tracker/NormalTrackerFrames.lua index b851bba..501c1ed 100644 --- a/Modules/Tracker/NormalTrackerFrames.lua +++ b/Modules/Tracker/NormalTrackerFrames.lua @@ -3,66 +3,15 @@ local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) if not HMGT or not HMGT.TrackerManager then return end local Manager = HMGT.TrackerManager -local S = Manager._shared or {} function Manager:CollectEntries(tracker) - local entries = {} - local players = S.GetGroupPlayers(tracker) - for _, playerInfo in ipairs(players) do - local playerEntries = S.CollectEntriesForPlayer(tracker, playerInfo) - for _, entry in ipairs(playerEntries) do - entries[#entries + 1] = entry - end - end - return entries + return HMGT:CollectTrackerEntries(tracker) end function Manager:CollectTestEntries(tracker) - local playerName = HMGT:NormalizePlayerName(UnitName("player")) or "Player" - local classToken = select(2, UnitClass("player")) - if not classToken then - return {} - end - - local entries = {} - local pData = HMGT.playerData[playerName] - local talents = pData and pData.talents or {} - local spells = S.GetTrackerSpellsForPlayer(classToken, GetSpecialization() or 0, tracker.categories) - for _, spellEntry in ipairs(spells) do - if tracker.enabledSpells[spellEntry.spellId] ~= false then - local remaining, total, currentCharges, maxCharges = HMGT:GetCooldownInfo(playerName, spellEntry.spellId) - local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) - local isAvailabilitySpell = HMGT:IsAvailabilitySpell(spellEntry) - local spellKnown = HMGT:IsTrackedSpellKnownForPlayer(playerName, spellEntry.spellId) - local hasPartialCharges = (tonumber(maxCharges) or 0) > 0 - and (tonumber(currentCharges) or tonumber(maxCharges) or 0) < (tonumber(maxCharges) or 0) - local hasActiveCd = ((remaining or 0) > 0) or hasPartialCharges - local hasAvailabilityState = isAvailabilitySpell and HMGT:HasAvailabilityState(playerName, spellEntry.spellId) - if spellKnown or hasActiveCd or hasAvailabilityState then - entries[#entries + 1] = { - playerName = playerName, - class = classToken, - spellEntry = spellEntry, - remaining = remaining, - total = total > 0 and total or effectiveCd, - currentCharges = currentCharges, - maxCharges = maxCharges, - } - end - end - end - return entries + return HMGT:CollectTrackerTestEntries(tracker) end function Manager:BuildEntriesForTracker(tracker) - if tracker.testMode then - return self:CollectTestEntries(tracker), true - end - if tracker.demoMode then - return HMGT:GetDemoEntries(S.GetTrackerFrameKey(tracker.id), S.GetTrackerSpellPool(tracker.categories), tracker), true - end - if not tracker.enabled or not HMGT:IsVisibleForCurrentGroup(tracker) then - return {}, false - end - return self:CollectEntries(tracker), true + return HMGT:BuildEntriesForTracker(tracker, self:GetTrackerFrameKey(tracker)) end diff --git a/Modules/Tracker/RaidcooldownTracker/RaidcooldownTracker.lua b/Modules/Tracker/RaidcooldownTracker/RaidcooldownTracker.lua index 3db516b..7191632 100644 --- a/Modules/Tracker/RaidcooldownTracker/RaidcooldownTracker.lua +++ b/Modules/Tracker/RaidcooldownTracker/RaidcooldownTracker.lua @@ -1,21 +1,40 @@ --- Modules/RaidCooldownTracker.lua --- Raid cooldown tracker based on the shared single-frame tracker base. - local ADDON_NAME = "HailMaryGuildTools" local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) +if not HMGT then return end + local L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME) -local Base = HMGT.SingleFrameTrackerBase -if not Base then return end +local module = HMGT:NewModule("RaidCooldownTracker") +HMGT.RaidCooldownTracker = module -Base:CreateModule("RaidCooldownTracker", { - profileKey = "raidCooldownTracker", - frameName = "RaidCooldownTracker", +module.definition = { + moduleName = "RaidCooldownTracker", + dbKey = "raidCooldownTracker", + trackerType = "normal", + trackerKey = "raidCooldownTracker", title = function() return L["RCD_TITLE"] end, - demoKey = "raidCooldownTracker", - database = function() - return HMGT_SpellData.RaidCooldowns - end, -}) + categories = { "lust", "defensive", "healing", "tank", "utility", "offensive", "cc", "interrupt" }, +} + +function module:GetDefinition() + return self.definition +end + +function module:GetSettings() + local profile = HMGT.db and HMGT.db.profile + return profile and profile[self.definition.dbKey] or nil +end + +function module:Enable() + if HMGT.TrackerManager and HMGT.TrackerManager.Enable then + HMGT.TrackerManager:Enable() + end +end + +function module:Disable() + if HMGT.TrackerManager and HMGT.TrackerManager.UpdateDisplay then + HMGT.TrackerManager:UpdateDisplay() + end +end diff --git a/Modules/Tracker/SingleFrameTrackerBase.lua b/Modules/Tracker/SingleFrameTrackerBase.lua deleted file mode 100644 index 53d43a8..0000000 --- a/Modules/Tracker/SingleFrameTrackerBase.lua +++ /dev/null @@ -1,305 +0,0 @@ --- Modules/Tracker/SingleFrameTrackerBase.lua --- Shared implementation for single-frame tracker modules. - -local ADDON_NAME = "HailMaryGuildTools" -local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) -if not HMGT then return end - -HMGT.SingleFrameTrackerBase = HMGT.SingleFrameTrackerBase or {} -local Base = HMGT.SingleFrameTrackerBase - -local function GetDefaultGroupPlayers() - local players = {} - - local ownName = HMGT:NormalizePlayerName(UnitName("player")) - local ownClass = select(2, UnitClass("player")) - table.insert(players, { name = ownName, class = ownClass, isOwn = true, unitId = "player" }) - - if IsInRaid() then - for i = 1, GetNumGroupMembers() do - local unitId = "raid" .. i - local name = HMGT:NormalizePlayerName(UnitName(unitId)) - local class = select(2, UnitClass(unitId)) - if name and name ~= ownName then - table.insert(players, { name = name, class = class, unitId = unitId }) - end - end - elseif IsInGroup() then - for i = 1, GetNumGroupMembers() - 1 do - local unitId = "party" .. i - local name = HMGT:NormalizePlayerName(UnitName(unitId)) - local class = select(2, UnitClass(unitId)) - if name and name ~= ownName then - table.insert(players, { name = name, class = class, unitId = unitId }) - end - end - end - - return players -end - -local function ResolveConfigValue(configValue, self) - if type(configValue) == "function" then - return configValue(self) - end - return configValue -end - -local function EntryNeedsVisualTicker(entry) - if type(entry) ~= "table" then - return false - end - - local remaining = tonumber(entry.remaining) or 0 - if remaining > 0 then - return true - end - - local maxCharges = tonumber(entry.maxCharges) or 0 - local currentCharges = tonumber(entry.currentCharges) - if maxCharges > 0 and currentCharges ~= nil and currentCharges < maxCharges then - return true - end - - return false -end - -function Base:Create(config) - local tracker = { - frame = nil, - updateTicker = nil, - lastEntryCount = 0, - } - - function tracker:GetSettings() - return HMGT.db.profile[config.profileKey] - end - - function tracker:GetDatabase() - return ResolveConfigValue(config.database, self) or {} - end - - function tracker:GetTitle() - return ResolveConfigValue(config.title, self) or config.frameName - end - - function tracker:GetDemoKey() - return ResolveConfigValue(config.demoKey, self) or config.profileKey - end - - function tracker:GetCooldownInfoOpts() - return ResolveConfigValue(config.cooldownInfoOpts, self) - end - - function tracker:GetGroupPlayers() - local custom = ResolveConfigValue(config.groupPlayersProvider, self) - if type(custom) == "table" then - return custom - end - return GetDefaultGroupPlayers() - end - - function tracker:EnsureUpdateTicker() - if self.updateTicker then - return - end - self.updateTicker = C_Timer.NewTicker(0.1, function() - self:UpdateDisplay() - end) - end - - function tracker:StopUpdateTicker() - if self.updateTicker then - self.updateTicker:Cancel() - self.updateTicker = nil - end - end - - function tracker:SetUpdateTickerEnabled(enabled) - if enabled then - self:EnsureUpdateTicker() - else - self:StopUpdateTicker() - end - end - - function tracker:Enable() - local s = self:GetSettings() - if not s.enabled and not s.demoMode and not s.testMode then return end - - if not self.frame then - self.frame = HMGT.TrackerFrame:CreateTrackerFrame(config.frameName, s) - HMGT.TrackerFrame:SetTitle(self.frame, self:GetTitle()) - end - - if HMGT:IsVisibleForCurrentGroup(s) then - self.frame:Show() - else - self.frame:Hide() - end - self:UpdateDisplay() - end - - function tracker:Disable() - self:StopUpdateTicker() - if self.frame then - self.frame:Hide() - end - end - - function tracker:UpdateDisplay() - if not self.frame then - self:StopUpdateTicker() - return - end - - local s = self:GetSettings() - local database = self:GetDatabase() - local cooldownInfoOpts = self:GetCooldownInfoOpts() - - if s.testMode then - HMGT.TrackerFrame:SetLocked(self.frame, s.locked) - local entries = HMGT:GetOwnTestEntries(database, s, cooldownInfoOpts) - self.lastEntryCount = #entries - HMGT.TrackerFrame:UpdateFrame(self.frame, entries) - self.frame:Show() - local shouldTick = false - for _, entry in ipairs(entries) do - if EntryNeedsVisualTicker(entry) then - shouldTick = true - break - end - end - self:SetUpdateTickerEnabled(shouldTick) - return - end - - if s.demoMode then - HMGT.TrackerFrame:SetLocked(self.frame, s.locked) - local entries = HMGT:GetDemoEntries(self:GetDemoKey(), database, s) - self.lastEntryCount = #entries - HMGT.TrackerFrame:UpdateFrame(self.frame, entries) - self.frame:Show() - self:SetUpdateTickerEnabled(#entries > 0) - return - end - - if not s.enabled then - self.lastEntryCount = 0 - self.frame:Hide() - self:StopUpdateTicker() - return - end - - if not HMGT:IsVisibleForCurrentGroup(s) then - self.lastEntryCount = 0 - self.frame:Hide() - self:StopUpdateTicker() - return - end - - HMGT.TrackerFrame:SetLocked(self.frame, s.locked) - - local entries = self:CollectEntries() - self.lastEntryCount = #entries - if HMGT.SortDisplayEntries then - HMGT:SortDisplayEntries(entries, config.profileKey) - end - - HMGT.TrackerFrame:UpdateFrame(self.frame, entries) - self.frame:Show() - - local shouldTick = false - for _, entry in ipairs(entries) do - if EntryNeedsVisualTicker(entry) then - shouldTick = true - break - end - end - self:SetUpdateTickerEnabled(shouldTick) - end - - function tracker:CollectEntries() - local entries = {} - local s = self:GetSettings() - local database = self:GetDatabase() - local cooldownInfoOpts = self:GetCooldownInfoOpts() - local players = self:GetGroupPlayers() - - for _, playerInfo in ipairs(players) do - repeat - local name = playerInfo.name - local pData = HMGT.playerData[name] - local class = pData and pData.class or playerInfo.class - local specIdx - if playerInfo.isOwn then - specIdx = GetSpecialization() - if not specIdx or specIdx == 0 then break end - else - specIdx = pData and pData.specIndex or nil - if not specIdx or tonumber(specIdx) <= 0 then break end - end - local talents = pData and pData.talents or {} - - if not class then break end - - local knownSpells = HMGT_SpellData.GetSpellsForSpec(class, specIdx, database) - - for _, spellEntry in ipairs(knownSpells) do - if s.enabledSpells[spellEntry.spellId] ~= false then - local remaining, total, curCharges, maxCharges = HMGT:GetCooldownInfo(name, spellEntry.spellId, cooldownInfoOpts) - local isAvailabilitySpell = HMGT.IsAvailabilitySpell and HMGT:IsAvailabilitySpell(spellEntry) - local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) - local include = HMGT:ShouldDisplayEntry(s, remaining, curCharges, maxCharges, spellEntry) - local spellKnown = HMGT:IsTrackedSpellKnownForPlayer(name, spellEntry.spellId) - local hasPartialCharges = (tonumber(maxCharges) or 0) > 0 - and (tonumber(curCharges) or tonumber(maxCharges) or 0) < (tonumber(maxCharges) or 0) - local hasActiveCd = ((remaining or 0) > 0) or hasPartialCharges - if not spellKnown and not hasActiveCd then - include = false - end - if isAvailabilitySpell and not spellKnown then - include = false - end - - if not playerInfo.isOwn then - if isAvailabilitySpell and not HMGT:HasAvailabilityState(name, spellEntry.spellId) then - include = false - end - end - - if include then - entries[#entries + 1] = { - playerName = name, - class = class, - spellEntry = spellEntry, - remaining = remaining, - total = total > 0 and total or effectiveCd, - currentCharges = curCharges, - maxCharges = maxCharges, - } - end - end - end - until true - end - - return entries - end - - return tracker -end - -function Base:CreateModule(moduleName, config, ...) - if type(moduleName) ~= "string" or moduleName == "" then - return self:Create(config) - end - - local module = HMGT:NewModule(moduleName, ...) - local tracker = self:Create(config) - for key, value in pairs(tracker) do - module[key] = value - end - HMGT[moduleName] = module - return module -end diff --git a/Modules/Tracker/TrackerAvailability.lua b/Modules/Tracker/TrackerAvailability.lua new file mode 100644 index 0000000..a2c3417 --- /dev/null +++ b/Modules/Tracker/TrackerAvailability.lua @@ -0,0 +1,169 @@ +local ADDON_NAME = "HailMaryGuildTools" +local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) +if not HMGT then return end + +HMGT.TrackerAvailability = HMGT.TrackerAvailability or {} + +local internals = HMGT.TrackerInternals or {} +local GetPlayerAuraApplications = internals.GetPlayerAuraApplications +local GetSpellCastCountInfo = internals.GetSpellCastCountInfo + +function HMGT:GetOwnAvailabilityProgress(spellEntry) + local availability = self:GetAvailabilityConfig(spellEntry) + if not availability then + return nil, nil + end + + local required = self:GetAvailabilityRequiredCount(spellEntry) + if required <= 0 then + return nil, nil + end + + local current = 0 + if availability.type == "auraStacks" then + current = GetPlayerAuraApplications and GetPlayerAuraApplications(availability.auraSpellId) or 0 + if current <= 0 then + local fallbackSpellId = tonumber(availability.fallbackSpellCountId) + or tonumber(availability.progressSpellId) + or tonumber(spellEntry and spellEntry.spellId) + if fallbackSpellId and fallbackSpellId > 0 and GetSpellCastCountInfo then + current = GetSpellCastCountInfo(fallbackSpellId) + end + end + else + return nil, nil + end + + current = math.max(0, math.min(required, tonumber(current) or 0)) + return current, required +end + +function HMGT:GetAvailabilityState(playerName, spellId) + local state = self:GetAvailabilityStateEntry(playerName, spellId) + if not state then + return nil, nil + end + return tonumber(state.current) or 0, tonumber(state.max) or 0 +end + +function HMGT:HasAvailabilityState(playerName, spellId) + local _, max = self:GetAvailabilityState(playerName, spellId) + return (tonumber(max) or 0) > 0 +end + +function HMGT:StoreAvailabilityState(playerName, spellId, current, max, spellEntry) + local normalizedName = self:NormalizePlayerName(playerName) + local sid = tonumber(spellId) + if not normalizedName or not sid or sid <= 0 then + return false + end + + local maxCount = math.max(0, math.floor((tonumber(max) or 0) + 0.5)) + if maxCount <= 0 then + return self:ClearAvailabilityState(normalizedName, sid) + end + + local currentCount = math.max(0, math.min(maxCount, math.floor((tonumber(current) or 0) + 0.5))) + local previous = self:GetAvailabilityStateEntry(normalizedName, sid) + local changed = (not previous) + or (tonumber(previous.current) or -1) ~= currentCount + or (tonumber(previous.max) or -1) ~= maxCount + + self:SetAvailabilityStateEntry(normalizedName, sid, { + current = currentCount, + max = maxCount, + spellEntry = spellEntry, + updatedAt = GetTime(), + }) + + return changed +end + +function HMGT:RefreshOwnAvailabilitySpell(spellEntry) + if not self:IsAvailabilitySpell(spellEntry) then + return false + end + + local playerName = self:NormalizePlayerName(UnitName("player")) + if not playerName then + return false + end + + local current, max = self:GetOwnAvailabilityProgress(spellEntry) + if (tonumber(max) or 0) > 0 then + local pData = self.playerData[playerName] + if pData and type(pData.knownSpells) == "table" then + pData.knownSpells[tonumber(spellEntry.spellId)] = true + end + end + return self:StoreAvailabilityState(playerName, spellEntry.spellId, current, max, spellEntry) +end + +function HMGT:RefreshOwnAvailabilityStates() + local playerName = self:NormalizePlayerName(UnitName("player")) + local pData = playerName and self.playerData[playerName] + if not pData or not pData.class or not pData.specIndex then + return false + end + + local changed = false + local groupCooldowns = HMGT_SpellData.GetSpellsForSpec(pData.class, pData.specIndex, HMGT_SpellData.GroupCooldowns) + for _, spellEntry in ipairs(groupCooldowns or {}) do + if self:IsAvailabilitySpell(spellEntry) and self:RefreshOwnAvailabilitySpell(spellEntry) then + changed = true + end + end + + if self:PruneAvailabilityStates(playerName, pData.knownSpells or {}) then + changed = true + end + + return changed +end + +function HMGT:RefreshAndPublishOwnAvailabilityStates() + local playerName = self:NormalizePlayerName(UnitName("player")) + local pData = playerName and self.playerData[playerName] + if not pData or not pData.class or not pData.specIndex then + return false + end + + local changed = false + local groupCooldowns = HMGT_SpellData.GetSpellsForSpec(pData.class, pData.specIndex, HMGT_SpellData.GroupCooldowns) + for _, spellEntry in ipairs(groupCooldowns or {}) do + if self:IsAvailabilitySpell(spellEntry) and self:RefreshOwnAvailabilitySpell(spellEntry) then + self:PublishOwnSpellState(spellEntry.spellId, { sendLegacy = true }) + changed = true + end + end + + if self:PruneAvailabilityStates(playerName, pData.knownSpells or {}) then + changed = true + end + + return changed +end + +function HMGT:SendOwnAvailabilityStates(target) + local playerName = self:NormalizePlayerName(UnitName("player")) + local pData = playerName and self.playerData[playerName] + if not pData or not pData.class or not pData.specIndex then + return 0 + end + + self:RefreshOwnAvailabilityStates() + + local sent = 0 + local groupCooldowns = HMGT_SpellData.GetSpellsForSpec(pData.class, pData.specIndex, HMGT_SpellData.GroupCooldowns) + for _, spellEntry in ipairs(groupCooldowns or {}) do + if self:IsAvailabilitySpell(spellEntry) and self:IsTrackedSpellKnownForPlayer(playerName, spellEntry.spellId) then + local current, max = self:GetAvailabilityState(playerName, spellEntry.spellId) + if (tonumber(max) or 0) > 0 then + self:BroadcastAvailabilityState(spellEntry.spellId, current, max, target) + sent = sent + 1 + end + end + end + + return sent +end diff --git a/Modules/Tracker/TrackerBridge.lua b/Modules/Tracker/TrackerBridge.lua new file mode 100644 index 0000000..d49c457 --- /dev/null +++ b/Modules/Tracker/TrackerBridge.lua @@ -0,0 +1,186 @@ +local ADDON_NAME = "HailMaryGuildTools" +local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) +if not HMGT then return end + +HMGT.TrackerBridge = HMGT.TrackerBridge or {} + +function HMGT:RegisterExternalAddonSource(sourceName) + local source = tostring(sourceName or "") + if source == "" then + return false + end + self.externalAddonSources = self.externalAddonSources or {} + self.externalAddonSources[source] = true + return true +end + +function HMGT:GetCanonicalExternalSpellEntry(spellId) + local sid = tonumber(spellId) + if not sid or sid <= 0 or not HMGT_SpellData then + return nil, sid + end + + local spellEntry = HMGT_SpellData.InterruptLookup and HMGT_SpellData.InterruptLookup[sid] + or HMGT_SpellData.CooldownLookup and HMGT_SpellData.CooldownLookup[sid] + if not spellEntry then + return nil, sid + end + + return spellEntry, tonumber(spellEntry.spellId) or sid +end + +function HMGT:InferClassFromSpellEntry(spellEntry) + if type(spellEntry) ~= "table" or type(spellEntry.classes) ~= "table" then + return nil + end + + local foundClass + for key, value in pairs(spellEntry.classes) do + local classToken = type(value) == "string" and value or key + if foundClass and foundClass ~= classToken then + return nil + end + foundClass = classToken + end + return foundClass +end + +function HMGT:ApplyExternalKnownSpell(sourceName, playerName, spellId, class, cooldown) + local source = tostring(sourceName or "External") + local normalizedName = self:NormalizePlayerName(playerName) + local sid = tonumber(spellId) + if not normalizedName or normalizedName == "" or not sid or sid <= 0 then + return false, "invalid_args" + end + if not self:IsPlayerInCurrentGroup(normalizedName) then + return false, "not_in_group" + end + + local spellEntry, canonicalSid = self:GetCanonicalExternalSpellEntry(sid) + if not spellEntry or not canonicalSid or canonicalSid <= 0 then + return false, "unknown_spell" + end + sid = canonicalSid + + self:RegisterExternalAddonSource(source) + local previous = self.playerData[normalizedName] or {} + local knownSpells = previous.knownSpells + if type(knownSpells) ~= "table" then + knownSpells = {} + end + knownSpells[sid] = true + + local classToken = class or previous.class or self:InferClassFromSpellEntry(spellEntry) + + self.playerData[normalizedName] = { + class = classToken, + specIndex = previous.specIndex, + talentHash = previous.talentHash, + talents = previous.talents or {}, + knownSpells = knownSpells, + externalSource = source, + } + + if tonumber(cooldown) and tonumber(cooldown) > 0 then + spellEntry._hmgtExternalBaseCd = tonumber(cooldown) + end + + self:TriggerTrackerUpdate("trackers") + return true +end + +function HMGT:ApplyExternalSpecInfo(sourceName, playerName, class, specId, talentHash) + local source = tostring(sourceName or "External") + local normalizedName = self:NormalizePlayerName(playerName) + local spec = tonumber(specId) + local classToken = class and tostring(class) or self:GetClassTokenForSpecId(spec) + if not normalizedName or normalizedName == "" or not classToken or classToken == "" or not spec or spec <= 0 then + return false, "invalid_args" + end + if not self:IsPlayerInCurrentGroup(normalizedName) then + return false, "not_in_group" + end + + self:RegisterExternalAddonSource(source) + local previous = self.playerData[normalizedName] or {} + local knownSpells = previous.knownSpells + if type(knownSpells) ~= "table" then + knownSpells = {} + end + + if HMGT_SpellData and type(HMGT_SpellData.GetSpellsForSpec) == "function" then + for _, datasetName in ipairs({ "Interrupts", "RaidCooldowns", "GroupCooldowns" }) do + local dataset = HMGT_SpellData[datasetName] + for _, spellEntry in ipairs(HMGT_SpellData.GetSpellsForSpec(classToken, spec, dataset)) do + local sid = tonumber(spellEntry and spellEntry.spellId) + if sid and sid > 0 then + knownSpells[sid] = true + end + end + end + end + + self.playerData[normalizedName] = { + class = classToken, + specIndex = spec, + talentHash = talentHash or previous.talentHash, + talents = self:ParseTalentHash(talentHash or previous.talentHash), + knownSpells = knownSpells, + externalSource = source, + } + + self:PruneAvailabilityStates(normalizedName, knownSpells) + self:TriggerTrackerUpdate("trackers") + return true +end + +function HMGT:ApplyExternalCooldown(sourceName, playerName, spellId, cooldown) + local source = tostring(sourceName or "External") + local normalizedName = self:NormalizePlayerName(playerName) + local sid = tonumber(spellId) + local cd = tonumber(cooldown) + if not normalizedName or normalizedName == "" or not sid or sid <= 0 or not cd or cd <= 0 then + return false, "invalid_args" + end + if not self:IsPlayerInCurrentGroup(normalizedName) then + return false, "not_in_group" + end + + local spellEntry, canonicalSid = self:GetCanonicalExternalSpellEntry(sid) + if not spellEntry or not canonicalSid or canonicalSid <= 0 then + return false, "unknown_spell" + end + sid = canonicalSid + + self:RegisterExternalAddonSource(source) + self:ApplyExternalKnownSpell(source, normalizedName, sid, nil, cd) + self:HandleRemoteSpellCast(normalizedName, sid, GetServerTime(), nil, nil, nil, cd) + return true +end + +function HMGT:IsPlayerInCurrentGroup(playerName) + local target = self:NormalizePlayerName(playerName) + if not target then return false end + local own = self:NormalizePlayerName(UnitName("player")) + if target == own then return true end + + if IsInRaid() then + for i = 1, GetNumGroupMembers() do + local n = self:NormalizePlayerName(UnitName("raid" .. i)) + if n == target then + return true + end + end + return false + end + + if IsInGroup() then + for i = 1, GetNumSubgroupMembers() do + local n = self:NormalizePlayerName(UnitName("party" .. i)) + if n == target then + return true + end + end + end + return false +end diff --git a/Modules/Tracker/TrackerCore.lua b/Modules/Tracker/TrackerCore.lua new file mode 100644 index 0000000..39fad12 --- /dev/null +++ b/Modules/Tracker/TrackerCore.lua @@ -0,0 +1,404 @@ +local ADDON_NAME = "HailMaryGuildTools" +local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) +if not HMGT then return end + +HMGT.TrackerCore = HMGT.TrackerCore or {} + +local function EntryNeedsVisualTicker(entry) + if type(entry) ~= "table" then + return false + end + + local remaining = tonumber(entry.remaining) or 0 + if remaining > 0 then + return true + end + + local maxCharges = tonumber(entry.maxCharges) or 0 + local currentCharges = tonumber(entry.currentCharges) + if maxCharges > 0 and currentCharges ~= nil and currentCharges < maxCharges then + return true + end + + return false +end + +function HMGT:IsGroupTrackerConfig(tracker) + return type(tracker) == "table" and tracker.trackerType == "group" +end + +function HMGT:GetTrackerSpellPool(categories) + if HMGT_SpellData and type(HMGT_SpellData.GetSpellPoolForCategories) == "function" then + return HMGT_SpellData.GetSpellPoolForCategories(categories) + end + return {} +end + +function HMGT:GetTrackerSpellsForPlayer(classToken, specIndex, categories) + if HMGT_SpellData and type(HMGT_SpellData.GetSpellsForCategories) == "function" then + return HMGT_SpellData.GetSpellsForCategories(classToken, specIndex, categories) + end + return {} +end + +function HMGT:GetTrackerPlayers(tracker) + local players = {} + + local ownName = self:NormalizePlayerName(UnitName("player")) + local ownClass = select(2, UnitClass("player")) + local includeOwnPlayer = true + if self:IsGroupTrackerConfig(tracker) then + includeOwnPlayer = tracker.includeSelfFrame == true + end + if includeOwnPlayer then + players[#players + 1] = { + name = ownName, + class = ownClass, + isOwn = true, + unitId = "player", + } + end + + if IsInRaid() then + for i = 1, GetNumGroupMembers() do + local unitId = "raid" .. i + local name = self:NormalizePlayerName(UnitName(unitId)) + local class = select(2, UnitClass(unitId)) + if name and name ~= ownName then + players[#players + 1] = { + name = name, + class = class, + unitId = unitId, + } + end + end + elseif IsInGroup() then + for i = 1, GetNumGroupMembers() - 1 do + local unitId = "party" .. i + local name = self:NormalizePlayerName(UnitName(unitId)) + local class = select(2, UnitClass(unitId)) + if name and name ~= ownName then + players[#players + 1] = { + name = name, + class = class, + unitId = unitId, + } + end + end + end + + return players +end + +function HMGT:CollectTrackerEntriesForPlayer(tracker, playerInfo) + local entries = {} + if type(tracker) ~= "table" or type(playerInfo) ~= "table" then + return entries + end + + local playerName = playerInfo.name + if not playerName then + return entries + end + + local pData = self.playerData[playerName] + local classToken = pData and pData.class or playerInfo.class + if not classToken then + return entries + end + + local specIndex + if playerInfo.isOwn then + specIndex = GetSpecialization() + if not specIndex or specIndex == 0 then + return entries + end + else + specIndex = pData and pData.specIndex or nil + if not specIndex or tonumber(specIndex) <= 0 then + return entries + end + end + + local talents = pData and pData.talents or {} + local spells = self:GetTrackerSpellsForPlayer(classToken, specIndex, tracker.categories) + for _, spellEntry in ipairs(spells) do + if tracker.enabledSpells[spellEntry.spellId] ~= false then + local remaining, total, currentCharges, maxCharges = self:GetCooldownInfo(playerName, spellEntry.spellId) + local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) + local isAvailabilitySpell = self:IsAvailabilitySpell(spellEntry) + local include = self:ShouldDisplayEntry(tracker, remaining, currentCharges, maxCharges, spellEntry) + local spellKnown = self:IsTrackedSpellKnownForPlayer(playerName, spellEntry.spellId) + local hasPartialCharges = (tonumber(maxCharges) or 0) > 0 + and (tonumber(currentCharges) or tonumber(maxCharges) or 0) < (tonumber(maxCharges) or 0) + local hasActiveCd = ((remaining or 0) > 0) or hasPartialCharges + + if not spellKnown and not hasActiveCd then + include = false + end + if isAvailabilitySpell and not spellKnown then + include = false + end + if not playerInfo.isOwn and isAvailabilitySpell and not self:HasAvailabilityState(playerName, spellEntry.spellId) then + include = false + end + + if include then + entries[#entries + 1] = { + playerName = playerName, + class = classToken, + spellEntry = spellEntry, + remaining = remaining, + total = total > 0 and total or effectiveCd, + currentCharges = currentCharges, + maxCharges = maxCharges, + } + end + end + end + + return entries +end + +local function CopyEntriesForPreview(entries, playerName) + local copies = {} + for _, entry in ipairs(entries or {}) do + local nextEntry = {} + for key, value in pairs(entry) do + nextEntry[key] = value + end + nextEntry.playerName = playerName + copies[#copies + 1] = nextEntry + end + return copies +end + +function HMGT:BuildPartyPreviewEntries(entries, resolveUnitAnchorFrame) + local byPlayer = {} + local order = {} + local unitByPlayer = {} + for index = 1, 4 do + local unitId = "party" .. index + if not resolveUnitAnchorFrame or resolveUnitAnchorFrame(unitId) then + local playerName = string.format("Party %d", index) + local playerEntries = CopyEntriesForPreview(entries, playerName) + if #playerEntries > 0 then + byPlayer[playerName] = playerEntries + order[#order + 1] = playerName + unitByPlayer[playerName] = unitId + end + end + end + return byPlayer, order, unitByPlayer, #order > 0 +end + +function HMGT:CollectTrackerEntries(tracker) + local entries = {} + local players = self:GetTrackerPlayers(tracker) + for _, playerInfo in ipairs(players) do + local playerEntries = self:CollectTrackerEntriesForPlayer(tracker, playerInfo) + for _, entry in ipairs(playerEntries) do + entries[#entries + 1] = entry + end + end + return entries +end + +function HMGT:CollectTrackerTestEntries(tracker) + local playerName = self:NormalizePlayerName(UnitName("player")) or "Player" + local classToken = select(2, UnitClass("player")) + if not classToken then + return {} + end + + local entries = {} + local pData = self.playerData[playerName] + local talents = pData and pData.talents or {} + local spells = self:GetTrackerSpellsForPlayer(classToken, GetSpecialization() or 0, tracker.categories) + for _, spellEntry in ipairs(spells) do + if tracker.enabledSpells[spellEntry.spellId] ~= false then + local remaining, total, currentCharges, maxCharges = self:GetCooldownInfo(playerName, spellEntry.spellId) + local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) + local isAvailabilitySpell = self:IsAvailabilitySpell(spellEntry) + local spellKnown = self:IsTrackedSpellKnownForPlayer(playerName, spellEntry.spellId) + local hasPartialCharges = (tonumber(maxCharges) or 0) > 0 + and (tonumber(currentCharges) or tonumber(maxCharges) or 0) < (tonumber(maxCharges) or 0) + local hasActiveCd = ((remaining or 0) > 0) or hasPartialCharges + local hasAvailabilityState = isAvailabilitySpell and self:HasAvailabilityState(playerName, spellEntry.spellId) + if spellKnown or hasActiveCd or hasAvailabilityState then + entries[#entries + 1] = { + playerName = playerName, + class = classToken, + spellEntry = spellEntry, + remaining = remaining, + total = total > 0 and total or effectiveCd, + currentCharges = currentCharges, + maxCharges = maxCharges, + } + end + end + end + return entries +end + +function HMGT:BuildEntriesForTracker(tracker, trackerKey) + local key = trackerKey or tostring(tonumber(tracker and tracker.id) or 0) + if tracker and tracker.testMode then + return self:CollectTrackerTestEntries(tracker), true + end + if tracker and tracker.demoMode then + return self:GetDemoEntries(key, self:GetTrackerSpellPool(tracker.categories), tracker), true + end + if not tracker or not tracker.enabled or not self:IsVisibleForCurrentGroup(tracker) then + return {}, false + end + return self:CollectTrackerEntries(tracker), true +end + +function HMGT:BuildEntriesByPlayerForTracker(tracker, trackerKey, resolveUnitAnchorFrame) + local key = trackerKey or tostring(tonumber(tracker and tracker.id) or 0) + local ownName = self:NormalizePlayerName(UnitName("player")) or "Player" + if tracker.testMode then + local entries = self:CollectTrackerTestEntries(tracker) + if self:IsGroupTrackerConfig(tracker) and tracker.attachToPartyFrame == true then + return self:BuildPartyPreviewEntries(entries, resolveUnitAnchorFrame) + end + local byPlayer, order, unitByPlayer = {}, {}, {} + if #entries > 0 then + byPlayer[ownName] = entries + order[1] = ownName + unitByPlayer[ownName] = "player" + end + return byPlayer, order, unitByPlayer, true + end + if tracker.demoMode then + local entries = self:GetDemoEntries(key, self:GetTrackerSpellPool(tracker.categories), tracker) + if self:IsGroupTrackerConfig(tracker) and tracker.attachToPartyFrame == true then + return self:BuildPartyPreviewEntries(entries, resolveUnitAnchorFrame) + end + for _, entry in ipairs(entries) do + entry.playerName = ownName + end + local byPlayer, order, unitByPlayer = {}, {}, {} + if #entries > 0 then + byPlayer[ownName] = entries + order[1] = ownName + unitByPlayer[ownName] = "player" + end + return byPlayer, order, unitByPlayer, true + end + if not tracker.enabled or not self:IsVisibleForCurrentGroup(tracker) then + return {}, {}, {}, false + end + if IsInRaid() or not IsInGroup() then + return {}, {}, {}, false + end + local byPlayer, order, unitByPlayer = {}, {}, {} + for _, playerInfo in ipairs(self:GetTrackerPlayers(tracker)) do + local entries = self:CollectTrackerEntriesForPlayer(tracker, playerInfo) + if #entries > 0 then + local playerName = playerInfo.name + byPlayer[playerName] = entries + order[#order + 1] = playerName + unitByPlayer[playerName] = playerInfo.unitId + end + end + return byPlayer, order, unitByPlayer, true +end + +function HMGT:FinalizeTrackerEntries(tracker, entries, trackerKey) + local result = entries or {} + if self.FilterDisplayEntries then + result = self:FilterDisplayEntries(tracker, result) or result + end + if self.SortDisplayEntries then + self:SortDisplayEntries(result, trackerKey) + end + + local shouldTick = false + for _, entry in ipairs(result) do + if EntryNeedsVisualTicker(entry) then + shouldTick = true + break + end + end + + return result, shouldTick +end + +function HMGT:TriggerTrackerUpdate(reason) + local function normalizeReason(value) + if value == true then + return "trackers" + elseif value == "trackers" or value == "layout" or value == "visual" then + return value + end + return "full" + end + + local function mergeReasons(current, incoming) + local priority = { + visual = 1, + layout = 2, + trackers = 3, + full = 4, + } + current = normalizeReason(current) + incoming = normalizeReason(incoming) + if (priority[incoming] or 4) >= (priority[current] or 4) then + return incoming + end + return current + end + + self._trackerUpdateMinDelay = self._trackerUpdateMinDelay or 0.08 + self._trackerUpdatePending = true + self._trackerUpdateReason = mergeReasons(self._trackerUpdateReason, reason) + if HMGT.TrackerManager then + local normalizedReason = normalizeReason(reason) + if normalizedReason == "trackers" then + HMGT.TrackerManager:MarkTrackersDirty() + elseif normalizedReason == "layout" then + HMGT.TrackerManager:MarkLayoutDirty() + end + end + if self._updateScheduled then return end + + local now = GetTime() + local last = self._lastTrackerUpdateAt or 0 + local delay = math.max(0, self._trackerUpdateMinDelay - (now - last)) + self._updateScheduled = true + + self:ScheduleTimer(function() + self._updateScheduled = nil + if not self._trackerUpdatePending then return end + self._trackerUpdatePending = nil + self._lastTrackerUpdateAt = GetTime() + local pendingReason = self._trackerUpdateReason + self._trackerUpdateReason = nil + + local function profileModule(name, fn) + if not fn then return end + local t0 = debugprofilestop and debugprofilestop() or nil + fn() + local t1 = debugprofilestop and debugprofilestop() or nil + if t0 and t1 then + local mod = HMGT[name] + local count = mod and mod.lastEntryCount or 0 + self:Debug("verbose", "UIUpdate %s took %.2fms entries=%s", tostring(name), t1 - t0, tostring(count)) + end + end + + profileModule("TrackerManager", HMGT.TrackerManager and function() + if pendingReason == "visual" and HMGT.TrackerManager.RefreshVisibleVisuals then + HMGT.TrackerManager:RefreshVisibleVisuals() + else + HMGT.TrackerManager:UpdateDisplay() + end + end or nil) + + if self._trackerUpdatePending then + self:TriggerTrackerUpdate() + end + end, delay) +end diff --git a/Modules/Tracker/TrackerDataProvider.lua b/Modules/Tracker/TrackerDataProvider.lua new file mode 100644 index 0000000..e5d58f2 --- /dev/null +++ b/Modules/Tracker/TrackerDataProvider.lua @@ -0,0 +1,268 @@ +local ADDON_NAME = "HailMaryGuildTools" +local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) +if not HMGT then return end + +HMGT.TrackerDataProvider = HMGT.TrackerDataProvider or {} + +local internals = HMGT.TrackerInternals or {} +local SafeApiNumber = internals.SafeApiNumber +local GetSpellChargesInfo = internals.GetSpellChargesInfo +local GetSpellCooldownInfo = internals.GetSpellCooldownInfo + +function HMGT:GetCooldownInfo(playerName, spellId, opts) + opts = opts or {} + local deferUntilEmpty = opts.deferChargeCooldownUntilEmpty and true or false + local spellEntry = HMGT_SpellData.InterruptLookup[spellId] + or HMGT_SpellData.CooldownLookup[spellId] + local ownName = self:NormalizePlayerName(UnitName("player")) + local isOwnPlayer = playerName == ownName + local pData = isOwnPlayer and self.playerData[ownName] or nil + local talents = pData and pData.talents or {} + local effectiveCd = spellEntry and HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) or 0 + local knownMaxCharges, knownChargeDuration = 0, 0 + if spellEntry and isOwnPlayer then + knownMaxCharges, knownChargeDuration = self:GetKnownChargeInfo(spellEntry, talents, spellId, effectiveCd) + end + + if self:IsAvailabilitySpell(spellEntry) then + local normalizedName = self:NormalizePlayerName(playerName) + if normalizedName == ownName then + local current, max = self:GetOwnAvailabilityProgress(spellEntry) + if (tonumber(max) or 0) > 0 then + self:StoreAvailabilityState(ownName, spellId, current, max, spellEntry) + return 0, 0, current, max + end + else + local current, max = self:GetAvailabilityState(normalizedName, spellId) + if (tonumber(max) or 0) > 0 then + return 0, 0, current, max + end + end + return 0, 0, nil, nil + end + + local cdData = self:GetActiveCooldown(playerName, spellId) + + if isOwnPlayer and not (InCombatLockdown and InCombatLockdown()) then + local charges, maxCharges, chargeStart, chargeDuration = nil, nil, nil, nil + if GetSpellChargesInfo then + charges, maxCharges, chargeStart, chargeDuration = GetSpellChargesInfo(spellId) + end + charges = SafeApiNumber and SafeApiNumber(charges, 0) or tonumber(charges) or 0 + maxCharges = SafeApiNumber and SafeApiNumber(maxCharges, 0) or tonumber(maxCharges) or 0 + chargeStart = SafeApiNumber and SafeApiNumber(chargeStart) or tonumber(chargeStart) + chargeDuration = SafeApiNumber and SafeApiNumber(chargeDuration, 0) or tonumber(chargeDuration) or 0 + + if maxCharges > 0 then + local tempChargeState = { + currentCharges = charges, + maxCharges = maxCharges, + chargeStart = chargeStart, + chargeDuration = chargeDuration, + duration = chargeDuration, + } + local remaining, total, curCharges, maxChargeCount = self:ResolveChargeState(tempChargeState) + self:StoreKnownChargeInfo(spellId, maxChargeCount, total > 0 and total or chargeDuration) + if (curCharges or 0) < maxChargeCount and remaining <= 0 and GetSpellCooldownInfo then + local cdStart, cdDuration = GetSpellCooldownInfo(spellId) + if cdDuration > 0 then + remaining = math.max(0, cdDuration - (GetTime() - cdStart)) + total = math.max(total or 0, cdDuration) + end + end + if deferUntilEmpty and (curCharges or 0) > 0 then + remaining = 0 + end + return remaining, total, curCharges, maxChargeCount + end + + if GetSpellCooldownInfo then + local cdStart, cdDuration = GetSpellCooldownInfo(spellId) + cdStart = tonumber(cdStart) or 0 + cdDuration = tonumber(cdDuration) or 0 + if cdDuration > 0 then + local remaining = math.max(0, cdDuration - (GetTime() - cdStart)) + remaining = math.max(0, math.min(cdDuration, remaining)) + if cdData and (tonumber(cdData.maxCharges) or 0) <= 0 then + local cachedRemaining = (tonumber(cdData.duration) or 0) - (GetTime() - (tonumber(cdData.startTime) or GetTime())) + cachedRemaining = math.max(0, math.min(tonumber(cdData.duration) or cachedRemaining, cachedRemaining)) + local cachedDuration = math.max(0, tonumber(cdData.duration) or 0) + if cachedDuration > 2.0 and cachedRemaining > 2.0 and cdDuration < math.max(2.0, cachedDuration * 0.35) then + return cachedRemaining, cachedDuration, nil, nil + end + end + return remaining, cdDuration, nil, nil + end + end + end + + if not cdData then + if isOwnPlayer and knownMaxCharges > 1 then + return 0, math.max(0, knownChargeDuration or effectiveCd or 0), knownMaxCharges, knownMaxCharges + end + return 0, 0, nil, nil + end + if (tonumber(cdData.maxCharges) or 0) > 0 then + local remaining, chargeDur, charges, maxCharges = self:ResolveChargeState(cdData) + self:StoreKnownChargeInfo(spellId, maxCharges, chargeDur) + if deferUntilEmpty and charges > 0 then + remaining = 0 + end + return remaining, chargeDur, charges, maxCharges + end + if isOwnPlayer and knownMaxCharges > 1 then + local remaining = (tonumber(cdData.duration) or 0) - (GetTime() - (tonumber(cdData.startTime) or GetTime())) + remaining = math.max(0, math.min(tonumber(cdData.duration) or remaining, remaining)) + local currentCharges = knownMaxCharges + if remaining > 0 then + currentCharges = math.max(0, knownMaxCharges - 1) + end + if deferUntilEmpty and currentCharges > 0 then + remaining = 0 + end + return remaining, math.max(0, knownChargeDuration or effectiveCd or 0), currentCharges, knownMaxCharges + end + local remaining = cdData.duration - (GetTime() - cdData.startTime) + remaining = math.max(0, math.min(cdData.duration, remaining)) + return remaining, cdData.duration, nil, nil +end + +function HMGT:ShouldDisplayEntry(settings, remaining, currentCharges, maxCharges, spellEntry) + local rem = tonumber(remaining) or 0 + local cur = tonumber(currentCharges) or 0 + local max = tonumber(maxCharges) or 0 + local soon = tonumber(settings.readySoonSec) or 0 + local isAvailabilitySpell = spellEntry and self:IsAvailabilitySpell(spellEntry) or false + local isReady + + if isAvailabilitySpell then + isReady = max > 0 and cur >= max + else + isReady = rem <= 0 or (max > 0 and cur > 0) + end + + if settings.showOnlyReady then + return isReady + end + if soon > 0 then + if isAvailabilitySpell then + return isReady + end + return isReady or rem <= soon + end + return true +end + +local DEFAULT_CATEGORY_PRIORITY = { + interrupt = 1, + lust = 2, + defensive = 3, + tank = 4, + healing = 5, + offensive = 6, + utility = 7, + cc = 8, +} + +local TRACKER_CATEGORY_PRIORITY = { + interruptTracker = { + interrupt = 1, + defensive = 2, + utility = 3, + cc = 4, + healing = 5, + tank = 6, + offensive = 7, + lust = 8, + }, + raidCooldownTracker = { + lust = 1, + defensive = 2, + healing = 3, + tank = 4, + utility = 5, + offensive = 6, + cc = 7, + interrupt = 8, + }, + groupCooldownTracker = { + tank = 1, + defensive = 2, + healing = 3, + cc = 4, + utility = 5, + offensive = 6, + lust = 7, + interrupt = 8, + }, +} + +local function GetCategoryPriority(category, trackerKey) + local cat = tostring(category or "utility") + local trackerOrder = trackerKey and TRACKER_CATEGORY_PRIORITY[trackerKey] + if trackerOrder and trackerOrder[cat] then + return trackerOrder[cat] + end + local order = HMGT_SpellData and HMGT_SpellData.CategoryOrder + if type(order) == "table" then + for idx, key in ipairs(order) do + if key == cat then + return idx + end + end + return #order + 10 + end + return DEFAULT_CATEGORY_PRIORITY[cat] or 99 +end + +function HMGT:SortDisplayEntries(entries, trackerKey) + if type(entries) ~= "table" then return end + table.sort(entries, function(a, b) + local aRemaining = tonumber(a and a.remaining) or 0 + local bRemaining = tonumber(b and b.remaining) or 0 + local aActive = aRemaining > 0 + local bActive = bRemaining > 0 + if aActive ~= bActive then + return aActive + end + + local aEntry = a and a.spellEntry + local bEntry = b and b.spellEntry + + local aPriority = tonumber(aEntry and aEntry.priority) or GetCategoryPriority(aEntry and aEntry.category, trackerKey) + local bPriority = tonumber(bEntry and bEntry.priority) or GetCategoryPriority(bEntry and bEntry.category, trackerKey) + if aPriority ~= bPriority then + return aPriority < bPriority + end + + if aActive and aRemaining ~= bRemaining then + return aRemaining < bRemaining + end + + local aTotal = tonumber(a and a.total) + or tonumber(aEntry and HMGT_SpellData.GetBaseCooldown and HMGT_SpellData.GetBaseCooldown(aEntry)) + or tonumber(aEntry and aEntry.cooldown) + or 0 + local bTotal = tonumber(b and b.total) + or tonumber(bEntry and HMGT_SpellData.GetBaseCooldown and HMGT_SpellData.GetBaseCooldown(bEntry)) + or tonumber(bEntry and bEntry.cooldown) + or 0 + if (not aActive) and aTotal ~= bTotal then + return aTotal > bTotal + end + + if aRemaining ~= bRemaining then + return aRemaining < bRemaining + end + + local aName = tostring(a and a.playerName or "") + local bName = tostring(b and b.playerName or "") + if aName ~= bName then + return aName < bName + end + + local aSpell = tonumber(aEntry and aEntry.spellId) or 0 + local bSpell = tonumber(bEntry and bEntry.spellId) or 0 + return aSpell < bSpell + end) +end diff --git a/Modules/Tracker/TrackerDetection.lua b/Modules/Tracker/TrackerDetection.lua new file mode 100644 index 0000000..11ec4ff --- /dev/null +++ b/Modules/Tracker/TrackerDetection.lua @@ -0,0 +1,524 @@ +local ADDON_NAME = "HailMaryGuildTools" +local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) +if not HMGT then return end + +HMGT.TrackerDetection = HMGT.TrackerDetection or {} + +local internals = HMGT.TrackerInternals or {} +local GetSpellChargesInfo = internals.GetSpellChargesInfo +local GetSpellCooldownInfo = internals.GetSpellCooldownInfo +local GetGlobalCooldownInfo = internals.GetGlobalCooldownInfo +local GetSpellDebugLabel = internals.GetSpellDebugLabel +local BuildCooldownStateFingerprint = internals.BuildCooldownStateFingerprint +local ApplyOwnCooldownReducers = internals.ApplyOwnCooldownReducers +local ApplyObservedCooldownReducers = internals.ApplyObservedCooldownReducers + +function HMGT:HandleOwnSpellCast(spellId) + local isInterrupt = HMGT_SpellData.InterruptLookup[spellId] ~= nil + local isCooldown = HMGT_SpellData.CooldownLookup[spellId] ~= nil + if not isInterrupt and not isCooldown then return end + + local spellEntry = HMGT_SpellData.InterruptLookup[spellId] + or HMGT_SpellData.CooldownLookup[spellId] + spellId = tonumber(spellEntry and spellEntry.spellId) or spellId + local name = self:NormalizePlayerName(UnitName("player")) + local pData = self.playerData[name] + local talents = pData and pData.talents or {} + if self:IsAvailabilitySpell(spellEntry) then + self:LogTrackedSpellCast(name, spellEntry, { + stateKind = "availability", + required = HMGT_SpellData.GetEffectiveAvailabilityRequired(spellEntry, talents), + }) + if self:RefreshOwnAvailabilitySpell(spellEntry) then + self:PublishOwnSpellState(spellId, { sendLegacy = true }) + end + self:TriggerTrackerUpdate() + return + end + + local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) + local now = GetTime() + local inCombat = InCombatLockdown and InCombatLockdown() + local cur, max, chargeStart, chargeDuration = nil, nil, nil, nil + if not inCombat and GetSpellChargesInfo then + cur, max, chargeStart, chargeDuration = GetSpellChargesInfo(spellId) + end + local cachedMaxCharges, cachedChargeDuration = self:GetKnownChargeInfo( + spellEntry, + talents, + spellId, + (not inCombat and tonumber(chargeDuration) and tonumber(chargeDuration) > 0) and tonumber(chargeDuration) or effectiveCd + ) + local inferredMaxCharges, inferredChargeDuration = HMGT_SpellData.GetEffectiveChargeInfo( + spellEntry, + talents, + (not inCombat and tonumber(max) and tonumber(max) > 0) and tonumber(max) or ((cachedMaxCharges > 0) and cachedMaxCharges or nil), + (not inCombat and tonumber(chargeDuration) and tonumber(chargeDuration) > 0) and tonumber(chargeDuration) + or ((cachedChargeDuration > 0) and cachedChargeDuration or effectiveCd) + ) + + local hasCharges = ((tonumber(max) or 0) > 1) or (tonumber(inferredMaxCharges) or 0) > 1 + local currentCharges = 0 + local maxCharges = 0 + local chargeDur = 0 + local chargeStartTime = nil + + local startTime = now + local duration = effectiveCd + local expiresIn = effectiveCd + + local existingCd = self:GetActiveCooldown(name, spellId) + if existingCd and (tonumber(existingCd.maxCharges) or 0) > 0 then + self:ResolveChargeState(existingCd, now) + end + + if hasCharges then + maxCharges = math.max(1, tonumber(max) or cachedMaxCharges or tonumber(inferredMaxCharges) or 1) + currentCharges = tonumber(cur) + if currentCharges == nil then + local prevCharges = existingCd and tonumber(existingCd.currentCharges) + local prevMax = existingCd and tonumber(existingCd.maxCharges) + if prevCharges and prevMax and prevMax == maxCharges then + currentCharges = math.max(0, prevCharges - 1) + else + currentCharges = math.max(0, maxCharges - 1) + end + end + currentCharges = math.max(0, math.min(maxCharges, currentCharges)) + + chargeDur = tonumber(chargeDuration) + or cachedChargeDuration + or tonumber(inferredChargeDuration) + or tonumber(effectiveCd) + or 0 + chargeDur = math.max(0, chargeDur) + self:StoreKnownChargeInfo(spellId, maxCharges, chargeDur) + + if currentCharges < maxCharges and chargeDur > 0 then + chargeStartTime = tonumber(chargeStart) or now + local missing = maxCharges - currentCharges + startTime = chargeStartTime + duration = missing * chargeDur + expiresIn = math.max(0, duration - (now - startTime)) + else + startTime = now + duration = 0 + expiresIn = 0 + end + end + + self:Debug( + "verbose", + "HandleOwnSpellCast name=%s spellId=%s cd=%.2f charges=%s/%s", + tostring(name), + tostring(spellId), + tonumber(effectiveCd) or 0, + hasCharges and tostring(currentCharges) or "-", + hasCharges and tostring(maxCharges) or "-" + ) + + self._cdNonce = (self._cdNonce or 0) + 1 + local nonce = self._cdNonce + + self:SetActiveCooldown(name, spellId, { + startTime = startTime, + duration = duration, + spellEntry = spellEntry, + currentCharges = hasCharges and currentCharges or nil, + maxCharges = hasCharges and maxCharges or nil, + chargeStart = hasCharges and chargeStartTime or nil, + chargeDuration = hasCharges and chargeDur or nil, + _nonce = nonce, + }) + + self:LogTrackedSpellCast(name, spellEntry, { + cooldown = effectiveCd, + currentCharges = hasCharges and currentCharges or nil, + maxCharges = hasCharges and maxCharges or nil, + chargeCooldown = hasCharges and chargeDur or nil, + }) + + if expiresIn > 0 then + self:ScheduleTimer(function() + local current = self:GetActiveCooldown(name, spellId) + if current and current._nonce == nonce then + self:ClearActiveCooldown(name, spellId) + self:PublishOwnSpellState(spellId) + self:TriggerTrackerUpdate() + end + end, expiresIn) + end + + self:PublishOwnSpellState(spellId, { sendLegacy = true }) + self:TriggerTrackerUpdate() +end + +function HMGT:RefreshOwnCooldownStateFromGame(spellId) + local sid = tonumber(spellId) + if not sid then return false end + if InCombatLockdown and InCombatLockdown() then + return false + end + + local ownName = self:NormalizePlayerName(UnitName("player")) + if not ownName then return false end + + local spellEntry = HMGT_SpellData.InterruptLookup[sid] + or HMGT_SpellData.CooldownLookup[sid] + if not spellEntry or self:IsAvailabilitySpell(spellEntry) then + return false + end + sid = tonumber(spellEntry.spellId) or sid + + local existing = self:GetActiveCooldown(ownName, sid) + local before = BuildCooldownStateFingerprint and BuildCooldownStateFingerprint(existing) or "nil" + local now = GetTime() + local pData = self.playerData[ownName] + local talents = pData and pData.talents or {} + local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) + local cur, max, chargeStart, chargeDuration = nil, nil, nil, nil + if GetSpellChargesInfo then + cur, max, chargeStart, chargeDuration = GetSpellChargesInfo(sid) + end + local inferredMaxCharges, inferredChargeDuration = HMGT_SpellData.GetEffectiveChargeInfo( + spellEntry, + talents, + (tonumber(max) and tonumber(max) > 0) and tonumber(max) or nil, + (tonumber(chargeDuration) and tonumber(chargeDuration) > 0) and tonumber(chargeDuration) or effectiveCd + ) + + local hasCharges = ((tonumber(max) or 0) > 1) or (tonumber(inferredMaxCharges) or 0) > 1 + + if hasCharges then + local maxCharges = math.max(1, tonumber(max) or tonumber(inferredMaxCharges) or 1) + local currentCharges = tonumber(cur) + if currentCharges == nil then + currentCharges = maxCharges + end + currentCharges = math.max(0, math.min(maxCharges, currentCharges)) + + local chargeDur = tonumber(chargeDuration) or tonumber(inferredChargeDuration) or tonumber(effectiveCd) or 0 + chargeDur = math.max(0, chargeDur) + + if currentCharges < maxCharges and chargeDur > 0 then + local chargeStartTime = tonumber(chargeStart) or now + local missing = maxCharges - currentCharges + local updatedEntry = self:SetActiveCooldown(ownName, sid, { + startTime = chargeStartTime, + duration = missing * chargeDur, + spellEntry = spellEntry, + currentCharges = currentCharges, + maxCharges = maxCharges, + chargeStart = chargeStartTime, + chargeDuration = chargeDur, + }) + self:RefreshCooldownExpiryTimer(ownName, sid, updatedEntry) + else + self:ClearActiveCooldown(ownName, sid) + end + else + local cooldownStart, cooldownDuration = 0, 0 + if GetSpellCooldownInfo then + cooldownStart, cooldownDuration = GetSpellCooldownInfo(sid) + end + cooldownStart = tonumber(cooldownStart) or 0 + cooldownDuration = tonumber(cooldownDuration) or 0 + local gcdStart, gcdDuration = 0, 0 + if GetGlobalCooldownInfo then + gcdStart, gcdDuration = GetGlobalCooldownInfo() + end + gcdStart = tonumber(gcdStart) or 0 + gcdDuration = tonumber(gcdDuration) or 0 + local existingDuration = tonumber(existing and existing.duration) or 0 + local existingStart = tonumber(existing and existing.startTime) or now + local existingRemaining = math.max(0, existingDuration - (now - existingStart)) + + local isLikelyGlobalCooldown = cooldownDuration > 0 + and gcdDuration > 0 + and math.abs(cooldownDuration - gcdDuration) <= 0.15 + and (tonumber(effectiveCd) or 0) > (gcdDuration + 1.0) + + local isSuspiciousShortRefresh = cooldownDuration > 0 + and existingRemaining > 2.0 + and existingDuration > 2.0 + and cooldownDuration < math.max(2.0, existingDuration * 0.35) + and cooldownDuration < math.max(2.0, (tonumber(effectiveCd) or 0) * 0.35) + + if isLikelyGlobalCooldown or isSuspiciousShortRefresh then + self:DebugScoped( + "verbose", + "TrackedSpells", + "Ignore suspicious refresh for %s: spellCD=%.3f gcd=%.3f existing=%.3f remaining=%.3f effective=%.3f", + GetSpellDebugLabel and GetSpellDebugLabel(sid) or tostring(sid), + cooldownDuration, + gcdDuration, + existingDuration, + existingRemaining, + tonumber(effectiveCd) or 0 + ) + return false + end + + if cooldownDuration > 0 then + local updatedEntry = self:SetActiveCooldown(ownName, sid, { + startTime = cooldownStart, + duration = cooldownDuration, + spellEntry = spellEntry, + }) + self:RefreshCooldownExpiryTimer(ownName, sid, updatedEntry) + else + self:ClearActiveCooldown(ownName, sid) + end + end + + local after = BuildCooldownStateFingerprint and BuildCooldownStateFingerprint(self:GetActiveCooldown(ownName, sid)) or "nil" + return before ~= after +end + +function HMGT:DidOwnInterruptSucceed(triggerSpellId, talents) + local sid = tonumber(triggerSpellId) + if not sid then return false end + + local spellEntry = HMGT_SpellData and HMGT_SpellData.InterruptLookup and HMGT_SpellData.InterruptLookup[sid] + if not spellEntry then return false end + sid = tonumber(spellEntry.spellId) or sid + + local observedDuration = 0 + if GetSpellCooldownInfo then + local _, duration = GetSpellCooldownInfo(sid) + observedDuration = duration + end + observedDuration = tonumber(observedDuration) or 0 + if observedDuration <= 0 then return false end + + local expectedDuration = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) + expectedDuration = tonumber(expectedDuration) or 0 + if expectedDuration <= 0 then return false end + + return observedDuration < (expectedDuration - 0.05) +end + +function HMGT:HandleOwnCooldownReductionTrigger(triggerSpellId) + local ownName = self:NormalizePlayerName(UnitName("player")) + if not ownName then return end + + local pData = self.playerData[ownName] + local classToken = pData and pData.class or select(2, UnitClass("player")) + local specIndex = pData and pData.specIndex or GetSpecialization() + local talents = pData and pData.talents or {} + if not classToken or not specIndex then return end + + local reducers = HMGT_SpellData.GetCooldownReducersForCast(classToken, specIndex, triggerSpellId, talents) + if not reducers or #reducers == 0 then return end + + local instantReducers = {} + local observedInstantReducers = {} + local successReducers = {} + local observedSuccessReducers = {} + for _, reducer in ipairs(reducers) do + local observed = type(reducer.observe) == "table" + if reducer.requireInterruptSuccess then + if observed then + observedSuccessReducers[#observedSuccessReducers + 1] = reducer + else + successReducers[#successReducers + 1] = reducer + end + else + if observed then + observedInstantReducers[#observedInstantReducers + 1] = reducer + else + instantReducers[#instantReducers + 1] = reducer + end + end + end + + local castTs = GetServerTime() + if #instantReducers > 0 and ApplyOwnCooldownReducers then + ApplyOwnCooldownReducers(self, ownName, triggerSpellId, instantReducers, castTs) + end + if #observedInstantReducers > 0 and ApplyObservedCooldownReducers then + ApplyObservedCooldownReducers(self, ownName, observedInstantReducers) + end + + if #successReducers > 0 or #observedSuccessReducers > 0 then + local function ApplySuccessReducers() + if not self:DidOwnInterruptSucceed(triggerSpellId, talents) then + return false + end + if #successReducers > 0 and ApplyOwnCooldownReducers then + ApplyOwnCooldownReducers(self, ownName, triggerSpellId, successReducers, castTs) + end + if #observedSuccessReducers > 0 and ApplyObservedCooldownReducers then + ApplyObservedCooldownReducers(self, ownName, observedSuccessReducers) + end + return true + end + + if not ApplySuccessReducers() then + C_Timer.After(0.12, function() + if not self or not self.playerData or not self.playerData[ownName] then + return + end + ApplySuccessReducers() + end) + end + end +end + +function HMGT:HandleRemoteSpellCast(playerName, spellId, castTimestamp, curCharges, maxCharges, chargeRemaining, chargeDuration) + local spellEntry = HMGT_SpellData.InterruptLookup[spellId] + or HMGT_SpellData.CooldownLookup[spellId] + if not spellEntry then return end + spellId = tonumber(spellEntry.spellId) or spellId + if self:IsAvailabilitySpell(spellEntry) then return end + + local pData = self.playerData[playerName] + local talents = pData and pData.talents or {} + local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) + + castTimestamp = tonumber(castTimestamp) or GetServerTime() + local existingEntry = self:GetActiveCooldown(playerName, spellId) + if (tonumber(maxCharges) or 0) <= 0 and existingEntry and existingEntry.lastCastTimestamp then + local prevTs = tonumber(existingEntry.lastCastTimestamp) or 0 + if math.abs(prevTs - castTimestamp) <= 1 then + return + end + end + local now = GetTime() + local elapsed = math.max(0, GetServerTime() - castTimestamp) + + local incomingCur = tonumber(curCharges) or 0 + local incomingMax = tonumber(maxCharges) or 0 + local incomingChargeRemaining = tonumber(chargeRemaining) or 0 + local incomingChargeDuration = tonumber(chargeDuration) or 0 + + local inferredMaxCharges, inferredChargeDuration = HMGT_SpellData.GetEffectiveChargeInfo( + spellEntry, + talents, + (incomingMax > 0) and incomingMax or nil, + (incomingChargeDuration > 0) and incomingChargeDuration or effectiveCd + ) + local hasCharges = (incomingMax > 1) or (tonumber(inferredMaxCharges) or 0) > 1 + + local currentCharges = 0 + local maxChargeCount = 0 + local chargeDur = 0 + local nextChargeRemaining = 0 + local chargeStartTime = nil + local startTime, duration, expiresIn + + if hasCharges then + maxChargeCount = math.max(1, (incomingMax > 0 and incomingMax) or tonumber(inferredMaxCharges) or 1) + chargeDur = tonumber(incomingChargeDuration) or tonumber(inferredChargeDuration) or tonumber(effectiveCd) or 0 + chargeDur = math.max(0, chargeDur) + if chargeDur <= 0 then + chargeDur = math.max(0, tonumber(effectiveCd) or 0) + end + + if incomingMax > 0 then + currentCharges = math.max(0, math.min(maxChargeCount, incomingCur)) + nextChargeRemaining = math.max(0, math.min(chargeDur, incomingChargeRemaining - elapsed)) + if currentCharges < maxChargeCount and chargeDur > 0 then + chargeStartTime = now - math.max(0, chargeDur - nextChargeRemaining) + end + else + local existing = self:GetActiveCooldown(playerName, spellId) + if existing and (tonumber(existing.maxCharges) or 0) == maxChargeCount then + self:ResolveChargeState(existing, now) + local prevCharges = tonumber(existing.currentCharges) or maxChargeCount + local prevStart = tonumber(existing.chargeStart) + local prevDur = tonumber(existing.chargeDuration) or chargeDur + if prevDur > 0 then + chargeDur = prevDur + end + + currentCharges = math.max(0, prevCharges - 1) + if currentCharges < maxChargeCount and chargeDur > 0 then + if prevCharges >= maxChargeCount then + chargeStartTime = now + else + chargeStartTime = prevStart or now + end + nextChargeRemaining = math.max(0, chargeDur - (now - chargeStartTime)) + end + else + currentCharges = math.max(0, maxChargeCount - 1) + if currentCharges < maxChargeCount and chargeDur > 0 then + chargeStartTime = now + nextChargeRemaining = chargeDur + end + end + end + + if currentCharges >= maxChargeCount and maxChargeCount > 0 then + currentCharges = math.max(0, maxChargeCount - 1) + if chargeDur > 0 then + chargeStartTime = now + nextChargeRemaining = chargeDur + end + end + + if currentCharges < maxChargeCount and chargeDur > 0 then + chargeStartTime = chargeStartTime or now + local missing = maxChargeCount - currentCharges + startTime = chargeStartTime + duration = missing * chargeDur + expiresIn = math.max(0, duration - (now - startTime)) + else + startTime = now + duration = 0 + expiresIn = 0 + end + else + local remaining = effectiveCd - elapsed + if remaining <= 0 then return end + startTime = now - elapsed + duration = effectiveCd + expiresIn = remaining + end + + self:Debug( + "verbose", + "HandleRemoteSpellCast name=%s spellId=%s elapsed=%.2f expiresIn=%.2f charges=%s/%s", + tostring(playerName), + tostring(spellId), + tonumber(elapsed) or 0, + tonumber(expiresIn) or 0, + hasCharges and tostring(currentCharges) or "-", + hasCharges and tostring(maxChargeCount) or "-" + ) + + self._cdNonce = (self._cdNonce or 0) + 1 + local nonce = self._cdNonce + + self:SetActiveCooldown(playerName, spellId, { + startTime = startTime, + duration = duration, + spellEntry = spellEntry, + currentCharges = hasCharges and currentCharges or nil, + maxCharges = hasCharges and maxChargeCount or nil, + chargeStart = hasCharges and chargeStartTime or nil, + chargeDuration = hasCharges and chargeDur or nil, + lastCastTimestamp = castTimestamp, + _nonce = nonce, + }) + + self:LogTrackedSpellCast(playerName, spellEntry, { + cooldown = effectiveCd, + currentCharges = hasCharges and currentCharges or nil, + maxCharges = hasCharges and maxChargeCount or nil, + chargeCooldown = hasCharges and chargeDur or nil, + }) + + if expiresIn > 0 then + self:ScheduleTimer(function() + local current = self:GetActiveCooldown(playerName, spellId) + if current and current._nonce == nonce then + self:ClearActiveCooldown(playerName, spellId) + self:TriggerTrackerUpdate() + end + end, expiresIn) + end + + self:TriggerTrackerUpdate() +end diff --git a/Modules/Tracker/TrackerManager.lua b/Modules/Tracker/TrackerManager.lua index cfdb467..5071cc3 100644 --- a/Modules/Tracker/TrackerManager.lua +++ b/Modules/Tracker/TrackerManager.lua @@ -94,25 +94,6 @@ local PARTY_FRAME_PATTERNS = { local unitFrameCache = {} -local function EntryNeedsVisualTicker(entry) - if type(entry) ~= "table" then - return false - end - - local remaining = tonumber(entry.remaining) or 0 - if remaining > 0 then - return true - end - - local maxCharges = tonumber(entry.maxCharges) or 0 - local currentCharges = tonumber(entry.currentCharges) - if maxCharges > 0 and currentCharges ~= nil and currentCharges < maxCharges then - return true - end - - return false -end - local function BuildAnchorLayoutSignature(settings, ordered, unitByPlayer) local parts = { settings.attachToPartyFrame == true and "attach" or "stack", @@ -222,55 +203,6 @@ local function ResolveUnitAnchorFrame(unitId) return nil end -local function GetGroupPlayers(tracker) - local players = {} - - local ownName = HMGT:NormalizePlayerName(UnitName("player")) - local ownClass = select(2, UnitClass("player")) - local includeOwnPlayer = true - if IsGroupTracker(tracker) then - includeOwnPlayer = tracker.includeSelfFrame == true - end - if includeOwnPlayer then - players[#players + 1] = { - name = ownName, - class = ownClass, - isOwn = true, - unitId = "player", - } - end - - if IsInRaid() then - for i = 1, GetNumGroupMembers() do - local unitId = "raid" .. i - local name = HMGT:NormalizePlayerName(UnitName(unitId)) - local class = select(2, UnitClass(unitId)) - if name and name ~= ownName then - players[#players + 1] = { - name = name, - class = class, - unitId = unitId, - } - end - end - elseif IsInGroup() then - for i = 1, GetNumGroupMembers() - 1 do - local unitId = "party" .. i - local name = HMGT:NormalizePlayerName(UnitName(unitId)) - local class = select(2, UnitClass(unitId)) - if name and name ~= ownName then - players[#players + 1] = { - name = name, - class = class, - unitId = unitId, - } - end - end - end - - return players -end - local function GetTrackerLabel(tracker) if type(tracker) ~= "table" then return "Tracker" @@ -287,136 +219,6 @@ local function GetTrackerLabel(tracker) return "Tracker" end -local function GetTrackerSpellPool(categories) - if HMGT_SpellData and type(HMGT_SpellData.GetSpellPoolForCategories) == "function" then - return HMGT_SpellData.GetSpellPoolForCategories(categories) - end - return {} -end - -local function GetTrackerSpellsForPlayer(classToken, specIndex, categories) - if HMGT_SpellData and type(HMGT_SpellData.GetSpellsForCategories) == "function" then - return HMGT_SpellData.GetSpellsForCategories(classToken, specIndex, categories) - end - return {} -end - -local function CollectEntriesForPlayer(tracker, playerInfo) - local entries = {} - if type(tracker) ~= "table" or type(playerInfo) ~= "table" then - return entries - end - - local playerName = playerInfo.name - if not playerName then - return entries - end - - local pData = HMGT.playerData[playerName] - local classToken = pData and pData.class or playerInfo.class - if not classToken then - return entries - end - - local specIndex - if playerInfo.isOwn then - specIndex = GetSpecialization() - if not specIndex or specIndex == 0 then - return entries - end - else - specIndex = pData and pData.specIndex or nil - if not specIndex or tonumber(specIndex) <= 0 then - return entries - end - end - - local talents = pData and pData.talents or {} - local spells = GetTrackerSpellsForPlayer(classToken, specIndex, tracker.categories) - for _, spellEntry in ipairs(spells) do - if tracker.enabledSpells[spellEntry.spellId] ~= false then - local remaining, total, currentCharges, maxCharges = HMGT:GetCooldownInfo(playerName, spellEntry.spellId) - local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) - local isAvailabilitySpell = HMGT:IsAvailabilitySpell(spellEntry) - local include = HMGT:ShouldDisplayEntry(tracker, remaining, currentCharges, maxCharges, spellEntry) - local spellKnown = HMGT:IsTrackedSpellKnownForPlayer(playerName, spellEntry.spellId) - local hasPartialCharges = (tonumber(maxCharges) or 0) > 0 - and (tonumber(currentCharges) or tonumber(maxCharges) or 0) < (tonumber(maxCharges) or 0) - local hasActiveCd = ((remaining or 0) > 0) or hasPartialCharges - - if not spellKnown and not hasActiveCd then - include = false - end - if isAvailabilitySpell and not spellKnown then - include = false - end - if not playerInfo.isOwn and isAvailabilitySpell and not HMGT:HasAvailabilityState(playerName, spellEntry.spellId) then - include = false - end - - if include then - entries[#entries + 1] = { - playerName = playerName, - class = classToken, - spellEntry = spellEntry, - remaining = remaining, - total = total > 0 and total or effectiveCd, - currentCharges = currentCharges, - maxCharges = maxCharges, - } - end - end - end - - return entries -end - -local function CopyEntriesForPreview(entries, playerName) - local copies = {} - for _, entry in ipairs(entries or {}) do - local nextEntry = {} - for key, value in pairs(entry) do - nextEntry[key] = value - end - nextEntry.playerName = playerName - copies[#copies + 1] = nextEntry - end - return copies -end - -local function GetAvailablePartyPreviewUnits() - local units = {} - for index = 1, 4 do - local unitId = "party" .. index - if ResolveUnitAnchorFrame(unitId) then - units[#units + 1] = { - playerName = string.format("Party %d", index), - unitId = unitId, - } - end - end - return units -end - -local function BuildPartyPreviewEntries(entries) - local byPlayer = {} - local order = {} - local unitByPlayer = {} - local previewUnits = GetAvailablePartyPreviewUnits() - - for _, previewUnit in ipairs(previewUnits) do - local playerName = previewUnit.playerName - local playerEntries = CopyEntriesForPreview(entries, playerName) - if #playerEntries > 0 then - byPlayer[playerName] = playerEntries - order[#order + 1] = playerName - unitByPlayer[playerName] = previewUnit.unitId - end - end - - return byPlayer, order, unitByPlayer, #order > 0 -end - local function SortTrackers(trackers) table.sort(trackers, function(a, b) local aId = tonumber(a and a.id) or 0 @@ -467,13 +269,7 @@ Manager._shared.ShortName = ShortName Manager._shared.BuildAnchorLayoutSignature = BuildAnchorLayoutSignature Manager._shared.IsGroupTracker = IsGroupTracker Manager._shared.ResolveUnitAnchorFrame = ResolveUnitAnchorFrame -Manager._shared.GetGroupPlayers = GetGroupPlayers Manager._shared.GetTrackerLabel = GetTrackerLabel -Manager._shared.GetTrackerSpellPool = GetTrackerSpellPool -Manager._shared.GetTrackerSpellsForPlayer = GetTrackerSpellsForPlayer -Manager._shared.CollectEntriesForPlayer = CollectEntriesForPlayer -Manager._shared.BuildPartyPreviewEntries = BuildPartyPreviewEntries -Manager._shared.EntryNeedsVisualTicker = EntryNeedsVisualTicker Manager._shared.BuildGroupDisplaySignature = BuildGroupDisplaySignature function Manager:GetTrackers() @@ -492,6 +288,13 @@ function Manager:GetTrackers() return self._trackerCache end +function Manager:GetTrackerFrameKey(tracker) + if type(tracker) == "table" then + return GetTrackerFrameKey(tracker.id) + end + return GetTrackerFrameKey(tracker) +end + function Manager:MarkTrackersDirty() self._trackerCache = nil self._trackerCacheSignature = nil @@ -648,12 +451,8 @@ function Manager:RefreshVisibleVisuals() break end local entries = byPlayer[playerName] or {} - if HMGT.FilterDisplayEntries then - entries = HMGT:FilterDisplayEntries(tracker, entries) or entries - end - if HMGT.SortDisplayEntries then - HMGT:SortDisplayEntries(entries) - end + local tickThis = false + entries, tickThis = HMGT:FinalizeTrackerEntries(tracker, entries, tracker.trackerKey or nil) if #entries == 0 then needsFullRefresh = true break @@ -666,11 +465,8 @@ function Manager:RefreshVisibleVisuals() HMGT.TrackerFrame:UpdateFrame(frame, entries, true) totalEntries = totalEntries + #entries byPlayerFiltered[playerName] = entries - for _, entry in ipairs(entries) do - if EntryNeedsVisualTicker(entry) then - shouldTick = true - break - end + if tickThis then + shouldTick = true end end local newSignature = BuildGroupDisplaySignature(currentOrder, byPlayerFiltered) @@ -680,36 +476,29 @@ function Manager:RefreshVisibleVisuals() end end else - local frame = self.frames[frameKey] - if frame and frame:IsShown() then - local entries, shouldShow = self:BuildEntriesForTracker(tracker) - if not shouldShow then - needsFullRefresh = true - else - if HMGT.FilterDisplayEntries then - entries = HMGT:FilterDisplayEntries(tracker, entries) or entries - end - if HMGT.SortDisplayEntries then - HMGT:SortDisplayEntries(entries) - end - if #entries == 0 then + local frame = self.frames[frameKey] + if frame and frame:IsShown() then + local entries, shouldShow = self:BuildEntriesForTracker(tracker) + if not shouldShow then needsFullRefresh = true else + local tickThis = false + entries, tickThis = HMGT:FinalizeTrackerEntries(tracker, entries, tracker.trackerKey or nil) + if #entries == 0 then + needsFullRefresh = true + else HMGT.TrackerFrame:UpdateFrame(frame, entries, true) totalEntries = totalEntries + #entries local newSignature = BuildNormalDisplaySignature(true, entries) - if self._displaySignatures[frameKey] ~= newSignature then - needsFullRefresh = true - end - for _, entry in ipairs(entries) do - if EntryNeedsVisualTicker(entry) then + if self._displaySignatures[frameKey] ~= newSignature then + needsFullRefresh = true + end + if tickThis then shouldTick = true - break end end end end - end end end @@ -751,12 +540,8 @@ function Manager:UpdateDisplay() local entries, shouldShow = self:BuildEntriesForTracker(tracker) if shouldShow then - if HMGT.FilterDisplayEntries then - entries = HMGT:FilterDisplayEntries(tracker, entries) or entries - end - if HMGT.SortDisplayEntries then - HMGT:SortDisplayEntries(entries) - end + local tickThis = false + entries, tickThis = HMGT:FinalizeTrackerEntries(tracker, entries, tracker.trackerKey or nil) HMGT.TrackerFrame:UpdateFrame(frame, entries, true) frame:Show() @@ -769,11 +554,8 @@ function Manager:UpdateDisplay() layoutDirty = true end - for _, entry in ipairs(entries) do - if EntryNeedsVisualTicker(entry) then - shouldTick = true - break - end + if tickThis then + shouldTick = true end else frame:Hide() diff --git a/Modules/Tracker/TrackerPlayerState.lua b/Modules/Tracker/TrackerPlayerState.lua new file mode 100644 index 0000000..cc22fe3 --- /dev/null +++ b/Modules/Tracker/TrackerPlayerState.lua @@ -0,0 +1,65 @@ +local ADDON_NAME = "HailMaryGuildTools" +local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) +if not HMGT then return end + +HMGT.TrackerPlayerState = HMGT.TrackerPlayerState or {} + +local internals = HMGT.TrackerInternals or {} +local IsSpellKnownLocally = internals.IsSpellKnownLocally + +function HMGT:CollectOwnAvailableTrackerSpells(classToken, specIndex) + local class = classToken or select(2, UnitClass("player")) + local spec = tonumber(specIndex) or tonumber(GetSpecialization()) + if not class or not spec or spec <= 0 then + return {} + end + if not HMGT_SpellData or type(HMGT_SpellData.GetSpellsForSpec) ~= "function" then + return {} + end + + local knownSpells = {} + for _, datasetName in ipairs({ "Interrupts", "RaidCooldowns", "GroupCooldowns" }) do + local dataset = HMGT_SpellData[datasetName] + if type(dataset) == "table" then + local spells = HMGT_SpellData.GetSpellsForSpec(class, spec, dataset) + for _, entry in ipairs(spells) do + local sid = tonumber(entry.spellId) + if sid and sid > 0 and IsSpellKnownLocally and IsSpellKnownLocally(sid) then + knownSpells[sid] = true + end + end + end + end + + local ownName = self:NormalizePlayerName(UnitName("player")) + local ownCDs = ownName and self:GetPlayerCooldownMap(ownName, false) + if ownCDs then + for sid in pairs(ownCDs) do + sid = tonumber(sid) + if sid and sid > 0 then + knownSpells[sid] = true + end + end + end + return knownSpells +end + +function HMGT:IsTrackedSpellKnownForPlayer(playerName, spellId) + local sid = tonumber(spellId) + if not sid or sid <= 0 then + return false + end + + local normalizedName = self:NormalizePlayerName(playerName) + local ownName = self:NormalizePlayerName(UnitName("player")) + local pData = normalizedName and self.playerData[normalizedName] + if pData and type(pData.knownSpells) == "table" and pData.knownSpells[sid] == true then + return true + end + + if normalizedName and ownName and normalizedName == ownName and IsSpellKnownLocally then + return IsSpellKnownLocally(sid) + end + + return false +end diff --git a/Modules/Tracker/TrackerState.lua b/Modules/Tracker/TrackerState.lua new file mode 100644 index 0000000..e3863e0 --- /dev/null +++ b/Modules/Tracker/TrackerState.lua @@ -0,0 +1,410 @@ +local ADDON_NAME = "HailMaryGuildTools" +local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) +if not HMGT then return end + +HMGT.TrackerState = HMGT.TrackerState or {} + +function HMGT:EnsureTrackerStateTables() + self.playerData = self.playerData or {} + self.activeCDs = self.activeCDs or {} + self.availabilityStates = self.availabilityStates or {} + self.localSpellStateRevisions = self.localSpellStateRevisions or {} + self.remoteSpellStateRevisions = self.remoteSpellStateRevisions or {} + self.knownChargeInfo = self.knownChargeInfo or {} +end + +function HMGT:ResetTrackerState() + self.playerData = {} + self.activeCDs = {} + self.availabilityStates = {} + self.localSpellStateRevisions = {} + self.remoteSpellStateRevisions = {} + self.knownChargeInfo = {} +end + +function HMGT:GetPlayerCooldownMap(playerName, create) + local normalizedName = self:NormalizePlayerName(playerName) + if not normalizedName then + return nil + end + self:EnsureTrackerStateTables() + if create then + self.activeCDs[normalizedName] = self.activeCDs[normalizedName] or {} + end + return self.activeCDs[normalizedName] +end + +function HMGT:GetAvailabilityStateMap(playerName, create) + local normalizedName = self:NormalizePlayerName(playerName) + if not normalizedName then + return nil + end + self:EnsureTrackerStateTables() + if create then + self.availabilityStates[normalizedName] = self.availabilityStates[normalizedName] or {} + end + return self.availabilityStates[normalizedName] +end + +function HMGT:GetAvailabilityStateEntry(playerName, spellId) + local sid = tonumber(spellId) + local states = self:GetAvailabilityStateMap(playerName, false) + return states and sid and states[sid] or nil +end + +function HMGT:SetAvailabilityStateEntry(playerName, spellId, stateData) + local sid = tonumber(spellId) + if not sid or sid <= 0 or type(stateData) ~= "table" then + return nil + end + local states = self:GetAvailabilityStateMap(playerName, true) + if not states then + return nil + end + states[sid] = stateData + return stateData +end + +function HMGT:ClearAvailabilityState(playerName, spellId) + local sid = tonumber(spellId) + local normalizedName = self:NormalizePlayerName(playerName) + if not normalizedName or not sid or sid <= 0 then + return false + end + + local states = self.availabilityStates and self.availabilityStates[normalizedName] + if not states or not states[sid] then + return false + end + + states[sid] = nil + if not next(states) then + self.availabilityStates[normalizedName] = nil + end + return true +end + +function HMGT:GetActiveCooldown(playerName, spellId) + local sid = tonumber(spellId) + local cooldowns = self:GetPlayerCooldownMap(playerName, false) + return cooldowns and sid and cooldowns[sid] or nil +end + +function HMGT:SetActiveCooldown(playerName, spellId, cdData) + local sid = tonumber(spellId) + if not sid or sid <= 0 or type(cdData) ~= "table" then + return nil + end + local cooldowns = self:GetPlayerCooldownMap(playerName, true) + if not cooldowns then + return nil + end + cooldowns[sid] = cdData + return cdData +end + +function HMGT:ClearActiveCooldown(playerName, spellId) + local sid = tonumber(spellId) + local normalizedName = self:NormalizePlayerName(playerName) + if not normalizedName or not sid or sid <= 0 then + return false + end + + local cooldowns = self.activeCDs and self.activeCDs[normalizedName] + if not cooldowns or not cooldowns[sid] then + return false + end + + cooldowns[sid] = nil + if not next(cooldowns) then + self.activeCDs[normalizedName] = nil + end + return true +end + +function HMGT:ClearPlayerCooldowns(playerName) + local normalizedName = self:NormalizePlayerName(playerName) + if not normalizedName then + return false + end + if self.activeCDs and self.activeCDs[normalizedName] then + self.activeCDs[normalizedName] = nil + return true + end + return false +end + +function HMGT:GetLocalSpellStateRevision(spellId) + local sid = tonumber(spellId) + if not sid or sid <= 0 then + return 0 + end + self:EnsureTrackerStateTables() + return tonumber(self.localSpellStateRevisions[sid]) or 0 +end + +function HMGT:EnsureLocalSpellStateRevision(spellId) + local sid = tonumber(spellId) + if not sid or sid <= 0 then + return 0 + end + self:EnsureTrackerStateTables() + local current = tonumber(self.localSpellStateRevisions[sid]) or 0 + if current <= 0 then + current = 1 + self.localSpellStateRevisions[sid] = current + end + return current +end + +function HMGT:NextLocalSpellStateRevision(spellId) + local sid = tonumber(spellId) + if not sid or sid <= 0 then + return 0 + end + self:EnsureTrackerStateTables() + local nextRevision = (tonumber(self.localSpellStateRevisions[sid]) or 0) + 1 + self.localSpellStateRevisions[sid] = nextRevision + return nextRevision +end + +function HMGT:GetRemoteSpellStateRevision(playerName, spellId) + local normalizedName = self:NormalizePlayerName(playerName) + local sid = tonumber(spellId) + local bySpell = normalizedName and self.remoteSpellStateRevisions[normalizedName] + return tonumber(bySpell and bySpell[sid]) or 0 +end + +function HMGT:SetRemoteSpellStateRevision(playerName, spellId, revision) + local normalizedName = self:NormalizePlayerName(playerName) + local sid = tonumber(spellId) + local rev = tonumber(revision) or 0 + if not normalizedName or not sid or sid <= 0 or rev <= 0 then + return + end + self:EnsureTrackerStateTables() + self.remoteSpellStateRevisions[normalizedName] = self.remoteSpellStateRevisions[normalizedName] or {} + self.remoteSpellStateRevisions[normalizedName][sid] = rev +end + +function HMGT:ClearRemoteSpellStateRevisions(playerName) + local normalizedName = self:NormalizePlayerName(playerName) + if not normalizedName then + return false + end + if self.remoteSpellStateRevisions and self.remoteSpellStateRevisions[normalizedName] then + self.remoteSpellStateRevisions[normalizedName] = nil + return true + end + return false +end + +function HMGT:ClearTrackerStateForPlayer(playerName) + local normalizedName = self:NormalizePlayerName(playerName) + if not normalizedName then + return false + end + + local changed = false + if self.activeCDs and self.activeCDs[normalizedName] then + self.activeCDs[normalizedName] = nil + changed = true + end + if self.availabilityStates and self.availabilityStates[normalizedName] then + self.availabilityStates[normalizedName] = nil + changed = true + end + if self.remoteSpellStateRevisions and self.remoteSpellStateRevisions[normalizedName] then + self.remoteSpellStateRevisions[normalizedName] = nil + changed = true + end + + return changed +end + +function HMGT:StoreKnownChargeInfo(spellId, maxCharges, chargeDuration) + local sid = tonumber(spellId) + local maxCount = tonumber(maxCharges) + if not sid or sid <= 0 or not maxCount or maxCount <= 1 then + return + end + + self:EnsureTrackerStateTables() + self.knownChargeInfo[sid] = { + maxCharges = math.max(1, math.floor(maxCount + 0.5)), + chargeDuration = math.max(0, tonumber(chargeDuration) or 0), + updatedAt = GetTime(), + } +end + +function HMGT:GetKnownChargeInfo(spellEntry, talents, spellId, fallbackChargeDuration) + local sid = tonumber(spellId or (spellEntry and spellEntry.spellId)) + if not sid or sid <= 0 then + return 0, 0 + end + + local cached = self.knownChargeInfo and self.knownChargeInfo[sid] + local cachedMax = tonumber(cached and cached.maxCharges) or 0 + local cachedDuration = tonumber(cached and cached.chargeDuration) or 0 + + local inferredMax, inferredDuration = HMGT_SpellData.GetEffectiveChargeInfo( + spellEntry, + talents or {}, + (cachedMax > 0) and cachedMax or nil, + (cachedDuration > 0) and cachedDuration or fallbackChargeDuration + ) + + local maxCharges = math.max(cachedMax, tonumber(inferredMax) or 0) + local chargeDuration = math.max( + tonumber(inferredDuration) or 0, + cachedDuration, + tonumber(fallbackChargeDuration) or 0 + ) + + if maxCharges > 1 then + self:StoreKnownChargeInfo(sid, maxCharges, chargeDuration) + end + + return maxCharges, chargeDuration +end + +function HMGT:PruneAvailabilityStates(playerName, knownSpells) + local normalizedName = self:NormalizePlayerName(playerName) + local states = normalizedName and self.availabilityStates[normalizedName] + if not states or type(knownSpells) ~= "table" then + return false + end + + local changed = false + for sid in pairs(states) do + if not knownSpells[tonumber(sid)] then + states[sid] = nil + changed = true + end + end + + if not next(states) then + self.availabilityStates[normalizedName] = nil + end + return changed +end + +function HMGT:ResolveChargeState(cdData, now) + if type(cdData) ~= "table" then + return 0, 0, 0, 0 + end + + now = tonumber(now) or GetTime() + local maxCharges = math.max(0, tonumber(cdData.maxCharges) or 0) + local currentCharges = math.max(0, tonumber(cdData.currentCharges) or 0) + local chargeDuration = math.max(0, tonumber(cdData.chargeDuration) or 0) + local chargeStart = tonumber(cdData.chargeStart) + + if maxCharges <= 0 then + return 0, chargeDuration, currentCharges, maxCharges + end + if currentCharges >= maxCharges or chargeDuration <= 0 or not chargeStart then + return 0, chargeDuration, math.min(currentCharges, maxCharges), maxCharges + end + + local elapsed = math.max(0, now - chargeStart) + local gainedCharges = math.floor(elapsed / chargeDuration) + local remaining = chargeDuration - (elapsed % chargeDuration) + + if gainedCharges > 0 then + currentCharges = math.min(maxCharges, currentCharges + gainedCharges) + if currentCharges >= maxCharges then + currentCharges = maxCharges + chargeStart = nil + remaining = 0 + else + chargeStart = now - (elapsed % chargeDuration) + end + + cdData.currentCharges = currentCharges + cdData.chargeStart = chargeStart + if currentCharges >= maxCharges then + cdData.startTime = now + cdData.duration = 0 + else + local missing = maxCharges - currentCharges + cdData.startTime = chargeStart + cdData.duration = missing * chargeDuration + end + end + + if currentCharges >= maxCharges then + return 0, chargeDuration, currentCharges, maxCharges + end + return math.max(0, remaining), chargeDuration, currentCharges, maxCharges +end + +function HMGT:RefreshCooldownExpiryTimer(playerName, spellId, cdData) + if not cdData then return 0 end + local now = GetTime() + local duration = tonumber(cdData.duration) or 0 + local startTime = tonumber(cdData.startTime) or now + local expiresIn = math.max(0, duration - (now - startTime)) + + self._cdNonce = (self._cdNonce or 0) + 1 + local nonce = self._cdNonce + cdData._nonce = nonce + + if expiresIn > 0 then + self:ScheduleTimer(function() + local current = self:GetActiveCooldown(playerName, spellId) + if current and current._nonce == nonce then + self:ClearActiveCooldown(playerName, spellId) + if playerName == self:NormalizePlayerName(UnitName("player")) then + self:PublishOwnSpellState(spellId) + end + self:TriggerTrackerUpdate() + end + end, expiresIn) + end + return expiresIn +end + +function HMGT:CleanupStaleCooldowns() + local now = GetTime() + local ownName = self:NormalizePlayerName(UnitName("player")) + local removed = 0 + for playerName, spells in pairs(self.activeCDs) do + for spellId, cdInfo in pairs(spells) do + local duration = tonumber(cdInfo.duration) or 0 + local startTime = tonumber(cdInfo.startTime) or now + local rem = duration - (now - startTime) + local hasCharges = (tonumber(cdInfo.maxCharges) or 0) > 0 + local currentCharges = tonumber(cdInfo.currentCharges) or 0 + local maxCharges = tonumber(cdInfo.maxCharges) or 0 + if hasCharges then + local _, _, cur, max = self:ResolveChargeState(cdInfo, now) + currentCharges = cur + maxCharges = max + end + local shouldDrop = false + if hasCharges then + if currentCharges >= maxCharges then + shouldDrop = true + elseif (tonumber(cdInfo.chargeDuration) or 0) <= 0 and rem <= -2 then + shouldDrop = true + end + elseif rem <= -2 then + shouldDrop = true + end + if shouldDrop then + spells[spellId] = nil + if playerName == ownName then + self:PublishOwnSpellState(spellId) + end + removed = removed + 1 + end + end + if not next(spells) then + self.activeCDs[playerName] = nil + end + end + if removed > 0 then + self:Debug("verbose", "CleanupStaleCooldowns removed=%d", removed) + end +end diff --git a/Modules/Tracker/TrackerSync.lua b/Modules/Tracker/TrackerSync.lua new file mode 100644 index 0000000..4823ff6 --- /dev/null +++ b/Modules/Tracker/TrackerSync.lua @@ -0,0 +1,1041 @@ +local ADDON_NAME = "HailMaryGuildTools" +local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) +if not HMGT then return end + +HMGT.TrackerSync = HMGT.TrackerSync or {} + +local internals = HMGT.TrackerInternals or {} +local GetSpellChargesInfo = internals.GetSpellChargesInfo +local GetSpellDebugLabel = internals.GetSpellDebugLabel + +local MSG_SPELL_CAST = HMGT.MSG_SPELL_CAST +local MSG_CD_REDUCE = HMGT.MSG_CD_REDUCE +local MSG_SPELL_STATE = HMGT.MSG_SPELL_STATE +local MSG_HELLO = HMGT.MSG_HELLO +local MSG_PLAYER_INFO = HMGT.MSG_PLAYER_INFO +local MSG_SYNC_REQUEST = HMGT.MSG_SYNC_REQUEST +local MSG_SYNC_RESPONSE = HMGT.MSG_SYNC_RESPONSE +local MSG_RELIABLE = HMGT.MSG_RELIABLE +local MSG_ACK = HMGT.MSG_ACK +local COMM_PREFIX = HMGT.COMM_PREFIX +local ADDON_VERSION = HMGT.ADDON_VERSION or "dev" +local PROTOCOL_VERSION = HMGT.PROTOCOL_VERSION or 0 + +function HMGT:SuppressRemoteTrackedSpellLogs(playerName, duration) + local normalizedName = self:NormalizePlayerName(playerName) + if not normalizedName then + return + end + + self._suppressTrackedSpellLogUntil = self._suppressTrackedSpellLogUntil or {} + self._suppressTrackedSpellLogUntil[normalizedName] = GetTime() + math.max(0, tonumber(duration) or 0) +end + +function HMGT:IsRemoteTrackedSpellLogSuppressed(playerName) + local normalizedName = self:NormalizePlayerName(playerName) + local suppression = self._suppressTrackedSpellLogUntil + local untilTime = suppression and suppression[normalizedName] + if not untilTime then + return false + end + if untilTime <= GetTime() then + suppression[normalizedName] = nil + return false + end + return true +end + +function HMGT:BuildClearSpellStateSnapshot(spellId, spellEntry) + return { + spellId = tonumber(spellId), + spellEntry = spellEntry, + kind = "clear", + a = 0, + b = 0, + c = 0, + d = 0, + } +end + +function HMGT:GetOwnSpellStateSnapshot(spellId) + local sid = tonumber(spellId) + if not sid or sid <= 0 then return nil end + + local spellEntry = HMGT_SpellData.InterruptLookup[sid] + or HMGT_SpellData.CooldownLookup[sid] + if not spellEntry then return nil end + + if self:IsAvailabilitySpell(spellEntry) then + local current, max = self:GetOwnAvailabilityProgress(spellEntry) + if (tonumber(max) or 0) > 0 then + self:StoreAvailabilityState(self:NormalizePlayerName(UnitName("player")), sid, current, max, spellEntry) + return { + spellId = sid, + spellEntry = spellEntry, + kind = "availability", + a = tonumber(current) or 0, + b = tonumber(max) or 0, + c = 0, + d = 0, + } + end + return self:BuildClearSpellStateSnapshot(sid, spellEntry) + end + + local ownName = self:NormalizePlayerName(UnitName("player")) + local pData = ownName and self.playerData and self.playerData[ownName] + local talents = pData and pData.talents or {} + local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) + local knownMaxCharges, knownChargeDuration = self:GetKnownChargeInfo(spellEntry, talents, sid, effectiveCd) + local cdData = ownName and self:GetActiveCooldown(ownName, sid) + if cdData then + if (tonumber(cdData.maxCharges) or 0) > 0 then + local nextRemaining, chargeDuration, charges, maxCharges = self:ResolveChargeState(cdData) + self:StoreKnownChargeInfo(sid, maxCharges, chargeDuration) + if (tonumber(maxCharges) or 0) > 0 and (tonumber(charges) or 0) < (tonumber(maxCharges) or 0) then + return { + spellId = sid, + spellEntry = spellEntry, + kind = "charges", + a = tonumber(charges) or 0, + b = tonumber(maxCharges) or 0, + c = tonumber(nextRemaining) or 0, + d = tonumber(chargeDuration) or 0, + } + end + elseif knownMaxCharges > 1 then + local duration = tonumber(cdData.duration) or 0 + local startTime = tonumber(cdData.startTime) or GetTime() + local remaining = math.max(0, duration - (GetTime() - startTime)) + local currentCharges = knownMaxCharges + if remaining > 0 then + currentCharges = math.max(0, knownMaxCharges - 1) + return { + spellId = sid, + spellEntry = spellEntry, + kind = "charges", + a = tonumber(currentCharges) or 0, + b = tonumber(knownMaxCharges) or 0, + c = tonumber(remaining) or 0, + d = tonumber(knownChargeDuration) or tonumber(effectiveCd) or duration, + } + end + else + local duration = tonumber(cdData.duration) or 0 + local startTime = tonumber(cdData.startTime) or GetTime() + local remaining = math.max(0, duration - (GetTime() - startTime)) + if duration > 0 and remaining > 0 then + return { + spellId = sid, + spellEntry = spellEntry, + kind = "cooldown", + a = remaining, + b = duration, + c = 0, + d = 0, + } + end + end + end + + if InCombatLockdown and InCombatLockdown() then + if knownMaxCharges > 1 then + return { + spellId = sid, + spellEntry = spellEntry, + kind = "charges", + a = tonumber(knownMaxCharges) or 0, + b = tonumber(knownMaxCharges) or 0, + c = 0, + d = tonumber(knownChargeDuration) or tonumber(effectiveCd) or 0, + } + end + return self:BuildClearSpellStateSnapshot(sid, spellEntry) + end + + local remaining, total, currentCharges, maxCharges = self:GetCooldownInfo(ownName, sid) + + if (tonumber(maxCharges) or 0) > 0 then + local cur = math.max(0, math.floor((tonumber(currentCharges) or 0) + 0.5)) + local max = math.max(0, math.floor((tonumber(maxCharges) or 0) + 0.5)) + local nextRemaining = math.max(0, tonumber(remaining) or 0) + local chargeDuration = math.max(0, tonumber(total) or 0) + if max <= 0 or cur >= max then + return self:BuildClearSpellStateSnapshot(sid, spellEntry) + end + return { + spellId = sid, + spellEntry = spellEntry, + kind = "charges", + a = cur, + b = max, + c = nextRemaining, + d = chargeDuration, + } + end + + local duration = math.max(0, tonumber(total) or 0) + local cooldownRemaining = math.max(0, tonumber(remaining) or 0) + if duration <= 0 or cooldownRemaining <= 0 then + return self:BuildClearSpellStateSnapshot(sid, spellEntry) + end + + return { + spellId = sid, + spellEntry = spellEntry, + kind = "cooldown", + a = cooldownRemaining, + b = duration, + c = 0, + d = 0, + } +end + +function HMGT:SendSpellStateSnapshot(snapshot, target, revision) + if type(snapshot) ~= "table" then return false end + + local sid = tonumber(snapshot.spellId) + local kind = tostring(snapshot.kind or "") + local rev = tonumber(revision) or 0 + if not sid or sid <= 0 or kind == "" or rev <= 0 then + return false + end + + self:DebugScoped( + "verbose", + "TrackedSpells", + "SendSpellStateSnapshot target=%s spell=%s kind=%s rev=%d a=%.3f b=%.3f c=%.3f d=%.3f", + tostring(target and target ~= "" and target or "GROUP"), + GetSpellDebugLabel and GetSpellDebugLabel(sid) or tostring(sid), + tostring(kind), + rev, + tonumber(snapshot.a) or 0, + tonumber(snapshot.b) or 0, + tonumber(snapshot.c) or 0, + tonumber(snapshot.d) or 0 + ) + + local payload = string.format( + "%s|%d|%s|%d|%.3f|%.3f|%.3f|%.3f|%s|%d", + MSG_SPELL_STATE, + sid, + kind, + rev, + tonumber(snapshot.a) or 0, + tonumber(snapshot.b) or 0, + tonumber(snapshot.c) or 0, + tonumber(snapshot.d) or 0, + ADDON_VERSION, + PROTOCOL_VERSION + ) + + if target and target ~= "" then + self:SendDirectMessage(payload, target, "ALERT") + else + self:SendGroupMessage(payload, "ALERT") + end + + return true +end + +function HMGT:PublishOwnSpellState(spellId, opts) + opts = opts or {} + local sid = tonumber(spellId) + if not sid or sid <= 0 then return false end + + local snapshot = opts.snapshot or self:GetOwnSpellStateSnapshot(sid) + if not snapshot then return false end + + local revision = tonumber(opts.revision) or self:NextLocalSpellStateRevision(sid) + local sent = self:SendSpellStateSnapshot(snapshot, opts.target, revision) + if not sent then + return false + end + + if opts.sendLegacy then + if snapshot.kind == "availability" then + self:BroadcastAvailabilityState(sid, snapshot.a, snapshot.b, opts.target) + elseif snapshot.kind ~= "clear" then + self:BroadcastSpellCast(sid, snapshot) + end + end + + return true +end + +function HMGT:SendOwnTrackedSpellStates(target) + local ownName = self:NormalizePlayerName(UnitName("player")) + if not ownName then return 0 end + + self:RefreshOwnAvailabilityStates() + + local sent = 0 + local sentBySpell = {} + + local activeStates = self:GetPlayerCooldownMap(ownName, false) + if type(activeStates) == "table" then + for sid in pairs(activeStates) do + sid = tonumber(sid) + if sid and sid > 0 and not sentBySpell[sid] then + local revision = self:EnsureLocalSpellStateRevision(sid) + if revision > 0 and self:SendSpellStateSnapshot(self:GetOwnSpellStateSnapshot(sid), target, revision) then + sent = sent + 1 + sentBySpell[sid] = true + end + end + end + end + + local availabilityStates = self:GetAvailabilityStateMap(ownName, false) + if type(availabilityStates) == "table" then + for sid in pairs(availabilityStates) do + sid = tonumber(sid) + if sid and sid > 0 and not sentBySpell[sid] then + local revision = self:EnsureLocalSpellStateRevision(sid) + if revision > 0 and self:SendSpellStateSnapshot(self:GetOwnSpellStateSnapshot(sid), target, revision) then + sent = sent + 1 + sentBySpell[sid] = true + end + end + end + end + + return sent +end + +function HMGT:BroadcastRepairSpellStates() + if not self:IsEnabled() then return end + local sent = self:SendOwnTrackedSpellStates() + if sent > 0 then + self:DebugScoped("verbose", "TrackedSpells", "RepairSpellStates sent=%d", sent) + end +end + +function HMGT:ReconcileOwnTrackedSpellStatesFromGame(publishChanges) + if InCombatLockdown and InCombatLockdown() then + return 0 + end + + local ownName = self:NormalizePlayerName(UnitName("player")) + local pData = ownName and self.playerData and self.playerData[ownName] + if not ownName or not pData or not pData.class or not pData.specIndex then + return 0 + end + + pData.knownSpells = self:CollectOwnAvailableTrackerSpells(pData.class, pData.specIndex) + + local changed = 0 + for sid in pairs(pData.knownSpells or {}) do + local spellEntry = HMGT_SpellData.InterruptLookup[sid] + or HMGT_SpellData.CooldownLookup[sid] + if spellEntry and not self:IsAvailabilitySpell(spellEntry) then + if self:RefreshOwnCooldownStateFromGame(sid) then + changed = changed + 1 + if publishChanges then + self:PublishOwnSpellState(sid, { sendLegacy = true }) + end + end + end + end + + if changed > 0 then + self:TriggerTrackerUpdate() + end + return changed +end + +function HMGT:SendHello(target) + local name = self:NormalizePlayerName(UnitName("player")) + local pData = self.playerData[name] + if not pData or not pData.class or not pData.specIndex then return end + + pData.knownSpells = self:CollectOwnAvailableTrackerSpells(pData.class, pData.specIndex) + self:RefreshOwnAvailabilityStates() + local knownSpellList = self:SerializeKnownSpellList(pData.knownSpells) + local knownCount = 0 + for _ in pairs(pData.knownSpells or {}) do + knownCount = knownCount + 1 + end + local payload = string.format("%s|%s|%d|%s|%d|%s|%s", + MSG_HELLO, + ADDON_VERSION, + PROTOCOL_VERSION, + pData.class, + pData.specIndex, + pData.talentHash or "", + knownSpellList + ) + + if target and target ~= "" then + self:DebugScoped("verbose", "Comm", "SendHello whisper target=%s class=%s spec=%s spells=%d", + tostring(target), tostring(pData.class), tostring(pData.specIndex), knownCount) + self:SendDirectMessage(payload, target) + self:SendOwnTrackedSpellStates(target) + self:SendOwnAvailabilityStates(target) + return + end + + self:DebugScoped("verbose", "Comm", "SendHello group class=%s spec=%s spells=%d", + tostring(pData.class), tostring(pData.specIndex), knownCount) + self:SendGroupMessage(payload) + self:SendOwnTrackedSpellStates() + self:SendOwnAvailabilityStates() +end + +function HMGT:BroadcastSpellCast(spellId, snapshot) + local cur, max, chargeRemaining, chargeDuration = 0, 0, 0, 0 + if type(snapshot) == "table" and tostring(snapshot.kind) == "charges" then + cur = math.max(0, math.floor((tonumber(snapshot.a) or 0) + 0.5)) + max = math.max(0, math.floor((tonumber(snapshot.b) or 0) + 0.5)) + chargeRemaining = math.max(0, tonumber(snapshot.c) or 0) + chargeDuration = math.max(0, tonumber(snapshot.d) or 0) + elseif not (InCombatLockdown and InCombatLockdown()) and GetSpellChargesInfo then + local c, m, cs, cd = GetSpellChargesInfo(spellId) + cur = tonumber(c) or 0 + max = tonumber(m) or 0 + chargeDuration = tonumber(cd) or 0 + if max > 0 and cur < max and cs and chargeDuration > 0 then + chargeRemaining = math.max(0, chargeDuration - (GetTime() - cs)) + end + else + local ownName = self:NormalizePlayerName(UnitName("player")) + local remaining, total, currentCharges, maxCharges = self:GetCooldownInfo(ownName, spellId, { + deferChargeCooldownUntilEmpty = false, + }) + cur = math.max(0, math.floor((tonumber(currentCharges) or 0) + 0.5)) + max = math.max(0, math.floor((tonumber(maxCharges) or 0) + 0.5)) + chargeRemaining = math.max(0, tonumber(remaining) or 0) + chargeDuration = math.max(0, tonumber(total) or 0) + end + self:DebugScoped("verbose", "TrackedSpells", "BroadcastSpellCast spell=%s serverTime=%s charges=%d/%d", + GetSpellDebugLabel and GetSpellDebugLabel(spellId) or tostring(spellId), + tostring(GetServerTime()), + cur, + max) + self:SendGroupMessage(string.format("%s|%d|%d|%d|%d|%.3f|%.3f|%s|%d", + MSG_SPELL_CAST, spellId, GetServerTime(), cur, max, chargeRemaining, chargeDuration, ADDON_VERSION, PROTOCOL_VERSION)) +end + +function HMGT:BroadcastCooldownReduce(targetSpellId, amount, castTimestamp, triggerSpellId) + local sid = tonumber(targetSpellId) + local value = tonumber(amount) or 0 + if not sid or sid <= 0 or value <= 0 then return end + local ts = tonumber(castTimestamp) or GetServerTime() + local triggerId = tonumber(triggerSpellId) or 0 + self:Debug( + "verbose", + "BroadcastCooldownReduce target=%s amount=%.2f ts=%s trigger=%s", + tostring(sid), + value, + tostring(ts), + tostring(triggerId) + ) + self:SendGroupMessage(string.format( + "%s|%d|%.3f|%d|%d|%s|%d", + MSG_CD_REDUCE, + sid, + value, + ts, + triggerId, + ADDON_VERSION, + PROTOCOL_VERSION + )) +end + +function HMGT:RequestSync(reason) + self:DebugScoped("info", "Comm", "RequestSync(%s)", tostring(reason or "Hello")) + self:SendHello() +end + +function HMGT:QueueSyncRequest(delay, reason) + local wait = tonumber(delay) or 0.2 + if wait < 0 then wait = 0 end + if self._syncRequestTimer then + return + end + self._syncRequestTimer = self:ScheduleTimer(function() + self._syncRequestTimer = nil + self:RequestSync(reason or "Hello") + end, wait) +end + +function HMGT:QueueDeltaSyncBurst(reason, delays) + if not (IsInGroup() or IsInRaid()) then + return + end + + local now = GetTime() + local normalizedReason = tostring(reason or "delta") + self._deltaSyncBurstAt = self._deltaSyncBurstAt or {} + if (tonumber(self._deltaSyncBurstAt[normalizedReason]) or 0) > now - 2.5 then + return + end + self._deltaSyncBurstAt[normalizedReason] = now + + delays = type(delays) == "table" and delays or { 0.35, 1.25, 2.75 } + self._syncBurstTimers = self._syncBurstTimers or {} + for _, wait in ipairs(delays) do + local delay = math.max(0, tonumber(wait) or 0) + local timerHandle + timerHandle = self:ScheduleTimer(function() + if self._syncBurstTimers then + for index, handle in ipairs(self._syncBurstTimers) do + if handle == timerHandle then + table.remove(self._syncBurstTimers, index) + break + end + end + end + self:RequestSync(normalizedReason) + end, delay) + self._syncBurstTimers[#self._syncBurstTimers + 1] = timerHandle + end + self:DebugScoped("info", "Comm", "QueueDeltaSyncBurst reason=%s count=%d", normalizedReason, #delays) +end + +function HMGT:SendSyncResponse(target) + local name = self:NormalizePlayerName(UnitName("player")) + local pData = self.playerData[name] + if not pData then return end + + pData.knownSpells = self:CollectOwnAvailableTrackerSpells(pData.class, pData.specIndex) + self:RefreshOwnAvailabilityStates() + local knownSpellList = self:SerializeKnownSpellList(pData.knownSpells) + local cdList = {} + local now = GetTime() + local ownCooldowns = self:GetPlayerCooldownMap(name, false) + if ownCooldowns then + for spellId, cdInfo in pairs(ownCooldowns) do + if (tonumber(cdInfo.maxCharges) or 0) > 0 then + self:ResolveChargeState(cdInfo, now) + end + local remaining = cdInfo.duration - (now - cdInfo.startTime) + remaining = math.max(0, math.min(cdInfo.duration, remaining)) + if remaining > 0 then + table.insert(cdList, string.format("%d:%.3f:%.3f:%d:%d", + spellId, remaining, cdInfo.duration, cdInfo.currentCharges or 0, cdInfo.maxCharges or 0)) + end + end + end + + self:SendDirectMessage( + string.format("%s|%s|%d|%s|%d|%s|%s|%s", + MSG_SYNC_RESPONSE, + ADDON_VERSION, + PROTOCOL_VERSION, + pData.class, + pData.specIndex, + pData.talentHash or "", + knownSpellList, + table.concat(cdList, ";")), + target) + local stateCount = self:SendOwnTrackedSpellStates(target) + local availabilityCount = self:SendOwnAvailabilityStates(target) + self:DebugScoped("verbose", "Comm", "SendSyncResponse target=%s entries=%d state=%d availability=%d", tostring(target), #cdList, stateCount, availabilityCount) +end + +function HMGT:StoreRemotePlayerInfo(playerName, class, specIndex, talentHash, knownSpellList) + if not playerName or not class then return end + + local previous = self.playerData[playerName] + local knownSpells = previous and previous.knownSpells + if knownSpellList ~= nil then + knownSpells = self:ParseKnownSpellList(knownSpellList) + end + + self.playerData[playerName] = { + class = class, + specIndex = tonumber(specIndex), + talentHash = talentHash, + talents = self:ParseTalentHash(talentHash), + knownSpells = knownSpells, + } + + if type(knownSpells) == "table" then + self:PruneAvailabilityStates(playerName, knownSpells) + end + + local knownCount = 0 + if type(knownSpells) == "table" then + for _ in pairs(knownSpells) do + knownCount = knownCount + 1 + end + end + self:DebugScoped( + "info", + "TrackedSpells", + "Spielerinfo von %s: class=%s spec=%s bekannteSpells=%d", + tostring(playerName), + tostring(class), + tostring(specIndex), + knownCount + ) +end + +function HMGT:GetClassTokenForSpecId(specId) + local sid = tonumber(specId) + if not sid or sid <= 0 then + return nil + end + + if type(GetSpecializationInfoByID) == "function" then + local returns = { pcall(GetSpecializationInfoByID, sid) } + local ok = returns[1] + local classToken = returns[7] + if ok and type(classToken) == "string" and classToken ~= "" then + return classToken + end + end + + if type(GetSpecializationInfoForClassID) ~= "function" then + return nil + end + + for classID = 1, 20 do + local _, token = GetClassInfo(classID) + if token then + local count = 4 + if type(GetNumSpecializationsForClassID) == "function" then + count = tonumber(GetNumSpecializationsForClassID(classID)) or 4 + end + for index = 1, math.max(1, count) do + local foundSpecId = GetSpecializationInfoForClassID(classID, index) + if tonumber(foundSpecId) == sid then + return token + end + end + end + end + + return nil +end + +function HMGT:ClearRemoteSpellState(playerName, spellId) + local normalizedName = self:NormalizePlayerName(playerName) + local sid = tonumber(spellId) + if not normalizedName or not sid or sid <= 0 then + return false + end + + local changed = false + if self:ClearActiveCooldown(normalizedName, sid) then + changed = true + end + + if self:ClearAvailabilityState(normalizedName, sid) then + changed = true + end + + return changed +end + +function HMGT:ApplyRemoteSpellState(playerName, spellId, kind, revision, a, b, c, d) + local normalizedName = self:NormalizePlayerName(playerName) + local sid = tonumber(spellId) + local rev = tonumber(revision) or 0 + if not normalizedName or not sid or sid <= 0 or rev <= 0 then + return false + end + if not self:IsPlayerInCurrentGroup(normalizedName) then + return false + end + + local currentRevision = self:GetRemoteSpellStateRevision(normalizedName, sid) + if currentRevision >= rev then + return false + end + + local spellEntry = HMGT_SpellData.CooldownLookup[sid] + or HMGT_SpellData.InterruptLookup[sid] + if not spellEntry then + return false + end + sid = tonumber(spellEntry.spellId) or sid + + local now = GetTime() + local stateKind = tostring(kind or "") + local changed = false + local shouldLogCast = false + local logDetails = nil + local previousEntry = self:GetActiveCooldown(normalizedName, sid) + local isSuppressed = self:IsRemoteTrackedSpellLogSuppressed(normalizedName) + + if stateKind == "clear" then + changed = self:ClearRemoteSpellState(normalizedName, sid) + elseif stateKind == "availability" then + changed = self:StoreAvailabilityState(normalizedName, sid, tonumber(a) or 0, tonumber(b) or 0, spellEntry) + if self:ClearActiveCooldown(normalizedName, sid) then + changed = true + end + elseif stateKind == "cooldown" then + local duration = math.max(0, tonumber(b) or 0) + local remaining = math.max(0, math.min(duration, tonumber(a) or 0)) + if duration <= 0 or remaining <= 0 then + changed = self:ClearRemoteSpellState(normalizedName, sid) + else + local previousRemaining = 0 + if previousEntry then + previousRemaining = math.max( + 0, + (tonumber(previousEntry.duration) or 0) - (now - (tonumber(previousEntry.startTime) or now)) + ) + end + self:SetActiveCooldown(normalizedName, sid, { + startTime = now - (duration - remaining), + duration = duration, + spellEntry = spellEntry, + _stateRevision = rev, + _stateKind = stateKind, + }) + changed = true + shouldLogCast = (not isSuppressed) and previousRemaining <= 0.05 + if shouldLogCast then + logDetails = { + cooldown = duration, + } + end + end + elseif stateKind == "charges" then + local maxCharges = math.max(0, math.floor((tonumber(b) or 0) + 0.5)) + local currentCharges = math.max(0, math.min(maxCharges, math.floor((tonumber(a) or 0) + 0.5))) + local nextRemaining = math.max(0, tonumber(c) or 0) + local chargeDuration = math.max(0, tonumber(d) or 0) + + if maxCharges <= 0 or currentCharges >= maxCharges then + changed = self:ClearRemoteSpellState(normalizedName, sid) + else + local previousCharges = nil + if previousEntry and (tonumber(previousEntry.maxCharges) or 0) > 0 then + self:ResolveChargeState(previousEntry, now) + previousCharges = tonumber(previousEntry.currentCharges) + end + local chargeStart = nil + local duration = 0 + local startTime = now + if chargeDuration > 0 then + nextRemaining = math.min(chargeDuration, nextRemaining) + chargeStart = now - math.max(0, chargeDuration - nextRemaining) + duration = (maxCharges - currentCharges) * chargeDuration + startTime = chargeStart + end + + self:SetActiveCooldown(normalizedName, sid, { + startTime = startTime, + duration = duration, + spellEntry = spellEntry, + currentCharges = currentCharges, + maxCharges = maxCharges, + chargeStart = chargeStart, + chargeDuration = chargeDuration, + _stateRevision = rev, + _stateKind = stateKind, + }) + changed = true + shouldLogCast = (not isSuppressed) + and ( + (previousCharges ~= nil and currentCharges < previousCharges) + or (previousCharges == nil) + ) + if shouldLogCast then + logDetails = { + cooldown = chargeDuration, + currentCharges = currentCharges, + maxCharges = maxCharges, + chargeCooldown = chargeDuration, + } + end + end + else + return false + end + + self:SetRemoteSpellStateRevision(normalizedName, sid, rev) + if changed then + self:DebugScoped( + "info", + "TrackedSpells", + "Sync von %s: %s -> %s (rev=%d)", + tostring(normalizedName), + GetSpellDebugLabel and GetSpellDebugLabel(sid) or tostring(sid), + tostring(stateKind), + rev + ) + end + if changed and shouldLogCast and logDetails then + self:LogTrackedSpellCast(normalizedName, spellEntry, logDetails) + end + return changed +end + +function HMGT:OnCommReceived(prefix, message, distribution, sender) + if prefix ~= COMM_PREFIX then return end + local senderName = self:NormalizePlayerName(sender) + if senderName == self:NormalizePlayerName(UnitName("player")) then return end + + local msgType = message:match("^(%a+)") + self:DebugScoped("verbose", "Comm", "OnCommReceived type=%s from=%s dist=%s", tostring(msgType), tostring(senderName), tostring(distribution)) + + if msgType == MSG_ACK then + local messageId = message:match("^%a+|(.+)$") + if messageId then + self:HandleReliableAck(senderName, messageId) + end + return + elseif msgType == MSG_RELIABLE then + local messageId, innerPayload = message:match("^%a+|([^|]+)|(.+)$") + if not messageId or not innerPayload then + return + end + local dedupeKey = string.format("%s|%s", tostring(senderName or ""), tostring(messageId)) + self.receivedReliableMessages = self.receivedReliableMessages or {} + self:SendReliableAck(sender, messageId) + if self.receivedReliableMessages[dedupeKey] then + self:DebugScoped("verbose", "Comm", "Reliable duplicate sender=%s id=%s", tostring(senderName), tostring(messageId)) + return + end + self.receivedReliableMessages[dedupeKey] = GetTime() + 30 + message = innerPayload + msgType = message:match("^(%a+)") + self:DebugScoped("verbose", "Comm", "Reliable recv sender=%s id=%s inner=%s", tostring(senderName), tostring(messageId), tostring(msgType)) + end + + if msgType == MSG_SPELL_CAST then + local spellId, timestamp, cur, max, chargeRemaining, chargeDuration, version, protocol = + message:match("^%a+|(%d+)|([%d%.]+)|(%d+)|(%d+)|([%d%.]+)|([%d%.]+)|([^|]+)|(%d+)$") + if not spellId then + spellId, timestamp, version = message:match("^%a+|(%d+)|([%d%.]+)|(.+)$") + if not spellId then + spellId, timestamp = message:match("^%a+|(%d+)|([%d%.]+)$") + end + end + if spellId then + self:RegisterPeerVersion(senderName, version, protocol, "SC") + self:RememberPeerProtocolVersion(senderName, protocol) + if (tonumber(protocol) or 0) >= 5 then + return + end + self:DebugScoped("verbose", "TrackedSpells", "Legacy cast von %s: %s ts=%s", + tostring(senderName), + GetSpellDebugLabel and GetSpellDebugLabel(spellId) or tostring(spellId), + tostring(timestamp)) + self:HandleRemoteSpellCast( + senderName, + tonumber(spellId), + tonumber(timestamp), + tonumber(cur) or 0, + tonumber(max) or 0, + tonumber(chargeRemaining) or 0, + tonumber(chargeDuration) or 0 + ) + end + + elseif msgType == MSG_CD_REDUCE then + local targetSpellId, amount, timestamp, triggerSpellId, version, protocol = + message:match("^%a+|(%d+)|([%d%.]+)|([%d%.]+)|(%d+)|([^|]+)|(%d+)$") + if not targetSpellId then + targetSpellId, amount, timestamp, triggerSpellId = + message:match("^%a+|(%d+)|([%d%.]+)|([%d%.]+)|(%d+)$") + end + if targetSpellId then + self:RegisterPeerVersion(senderName, version, protocol, "CR") + self:RememberPeerProtocolVersion(senderName, protocol) + if (tonumber(protocol) or 0) >= 5 then + return + end + self:HandleRemoteCooldownReduce( + senderName, + tonumber(targetSpellId), + tonumber(amount) or 0, + tonumber(timestamp), + tonumber(triggerSpellId) or 0 + ) + end + + elseif msgType == MSG_SPELL_STATE then + local spellId, stateKind, revision, a, b, c, d, version, protocol = + message:match("^%a+|(%d+)|(%a+)|(%d+)|([%d%.%-]+)|([%d%.%-]+)|([%d%.%-]+)|([%d%.%-]+)|([^|]+)|(%d+)$") + if spellId then + self:RegisterPeerVersion(senderName, version, protocol, "STA") + self:RememberPeerProtocolVersion(senderName, protocol) + if self:ApplyRemoteSpellState(senderName, spellId, stateKind, revision, a, b, c, d) then + self:TriggerTrackerUpdate() + end + else + local current, max + spellId, current, max, version, protocol = + message:match("^%a+|(%d+)|(%d+)|(%d+)|([^|]+)|(%d+)$") + if not spellId then + spellId, current, max = message:match("^%a+|(%d+)|(%d+)|(%d+)$") + end + if spellId then + self:RegisterPeerVersion(senderName, version, protocol, "STA") + self:RememberPeerProtocolVersion(senderName, protocol) + if (tonumber(protocol) or 0) >= 5 then + return + end + local sid = tonumber(spellId) + local spellEntry = HMGT_SpellData.CooldownLookup[sid] + or HMGT_SpellData.InterruptLookup[sid] + if self:IsAvailabilitySpell(spellEntry) then + if self:StoreAvailabilityState(senderName, sid, tonumber(current) or 0, tonumber(max) or 0, spellEntry) then + self:TriggerTrackerUpdate() + end + end + end + end + + elseif msgType == MSG_HELLO then + local version, protocol, class, specIndex, talentHash, knownSpellList = + message:match("^%a+|([^|]+)|(%d+)|(%u+)|(%d+)|(.-)|(.-)$") + if class then + self:RegisterPeerVersion(senderName, version, protocol, "HEL") + self:RememberPeerProtocolVersion(senderName, protocol) + self:ClearRemoteSpellStateRevisions(senderName) + self:StoreRemotePlayerInfo(senderName, class, specIndex, talentHash, knownSpellList) + self:DebugScoped("info", "TrackedSpells", "Hello von %s: class=%s spec=%s spells=%s", + tostring(senderName), tostring(class), tostring(specIndex), tostring(knownSpellList or "")) + self:SendSyncResponse(sender) + self:TriggerTrackerUpdate() + end + + elseif msgType == MSG_PLAYER_INFO then + local class, specIndex, talentHash, version, protocol = + message:match("^%a+|(%u+)|(%d+)|(.-)|([^|]+)|(%d+)$") + if not class then + class, specIndex, talentHash = message:match("^%a+|(%u+)|(%d+)|(.*)") + end + if class then + self:RegisterPeerVersion(senderName, version, protocol, "PI") + self:RememberPeerProtocolVersion(senderName, protocol) + self:StoreRemotePlayerInfo(senderName, class, specIndex, talentHash, nil) + self:TriggerTrackerUpdate() + end + + elseif msgType == MSG_SYNC_REQUEST then + local version, protocol, class, specIndex, talentHash, knownSpellList = + message:match("^%a+|([^|]+)|(%d+)|(%u+)|(%d+)|(.-)|(.-)$") + if class then + self:StoreRemotePlayerInfo(senderName, class, specIndex, talentHash, knownSpellList) + end + if not version then + version, protocol = message:match("^%a+|([^|]+)|(%d+)$") + end + if not version then + version = message:match("^%a+|(.+)$") + end + self:RegisterPeerVersion(senderName, version, protocol, "SRQ") + self:RememberPeerProtocolVersion(senderName, protocol) + self:DebugScoped("info", "Comm", "SyncRequest von %s", tostring(senderName)) + self:SendSyncResponse(sender) + self:TriggerTrackerUpdate() + + elseif msgType == MSG_SYNC_RESPONSE then + local version, protocol, class, specIndex, talentHash, knownSpellList, cdListStr = + message:match("^%a+|([^|]+)|(%d+)|(%u+)|(%d+)|(.-)|(.-)|(.-)$") + if not class then + version, protocol, class, specIndex, talentHash, cdListStr = + message:match("^%a+|([^|]+)|(%d+)|(%u+)|(%d+)|(.-)|(.-)$") + end + if not class then + class, specIndex, talentHash, cdListStr = + message:match("^%a+|(%u+)|(%d+)|(.-)|(.-)$") + end + if class then + self:RegisterPeerVersion(senderName, version, protocol, "SRS") + self:RememberPeerProtocolVersion(senderName, protocol) + self:SuppressRemoteTrackedSpellLogs(senderName, 1.5) + self:StoreRemotePlayerInfo(senderName, class, specIndex, talentHash, knownSpellList) + if cdListStr and cdListStr ~= "" then + local knownTalents = self.playerData[senderName] and self.playerData[senderName].talents or {} + local applied = 0 + for entry in cdListStr:gmatch("([^;]+)") do + local sid, rem, dur, cur, max = entry:match("(%d+):([%d%.]+):([%d%.]+):(%d+):(%d+)") + if not sid then + sid, rem, dur = entry:match("(%d+):([%d%.]+):([%d%.]+)") + end + if sid then + sid, rem, dur = tonumber(sid), tonumber(rem), tonumber(dur) + rem = math.max(0, math.min(dur, rem)) + local remaining = rem + if remaining > 0 then + local spellEntry = HMGT_SpellData.CooldownLookup[sid] + or HMGT_SpellData.InterruptLookup[sid] + if spellEntry then + local localStartTime = GetTime() - (dur - remaining) + local curCharges = tonumber(cur) or 0 + local maxChargeCount = tonumber(max) or 0 + local chargeStart = nil + local chargeDur = nil + + if maxChargeCount > 0 then + curCharges = math.max(0, math.min(maxChargeCount, curCharges)) + local missing = maxChargeCount - curCharges + if missing > 0 and dur > 0 then + chargeDur = dur / missing + chargeStart = localStartTime + end + else + local inferredMax, inferredDur = HMGT_SpellData.GetEffectiveChargeInfo( + spellEntry, + knownTalents, + nil, + HMGT_SpellData.GetEffectiveCooldown(spellEntry, knownTalents) + ) + if (tonumber(inferredMax) or 0) > 1 then + maxChargeCount = inferredMax + curCharges = math.max(0, inferredMax - 1) + chargeDur = inferredDur + chargeStart = localStartTime + end + end + + self:SetActiveCooldown(senderName, sid, { + startTime = localStartTime, + duration = dur, + spellEntry = spellEntry, + currentCharges = (maxChargeCount > 0) and curCharges or nil, + maxCharges = (maxChargeCount > 0) and maxChargeCount or nil, + chargeStart = chargeStart, + chargeDuration = chargeDur, + }) + applied = applied + 1 + end + end + end + end + self:DebugScoped("info", "TrackedSpells", "SyncResponse von %s: cdsApplied=%d", tostring(senderName), applied) + end + self:TriggerTrackerUpdate() + end + elseif msgType == HMGT.MSG_RAID_TIMELINE then + local encounterId, timeSec, spellId, leadTime, alertText = + message:match("^%a+|(%d+)|(%d+)|([%-]?%d+)|(%d+)|(.*)$") + if not encounterId then + encounterId, timeSec, spellId, leadTime = + message:match("^%a+|(%d+)|(%d+)|([%-]?%d+)|(%d+)$") + alertText = "" + end + if encounterId and HMGT.RaidTimeline and HMGT.RaidTimeline.HandleAssignmentComm then + HMGT.RaidTimeline:HandleAssignmentComm( + senderName, + tonumber(encounterId), + tonumber(timeSec), + tonumber(spellId), + tonumber(leadTime), + alertText + ) + end + elseif msgType == HMGT.MSG_RAID_TIMELINE_TEST then + local encounterId, difficultyId, serverStartTime, duration = + message:match("^%a+|(%d+)|(%d+)|(%d+)|(%d+)$") + if encounterId and HMGT.RaidTimeline and HMGT.RaidTimeline.HandleTestStartComm then + HMGT.RaidTimeline:HandleTestStartComm( + senderName, + tonumber(encounterId), + tonumber(difficultyId), + tonumber(serverStartTime), + tonumber(duration) + ) + end + end +end -- 2.39.5 From 02e062d66be196ac595207641222af27c3b59053 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Sat, 25 Apr 2026 17:33:32 +0200 Subject: [PATCH 2/2] delted old debug window, added new version notice window, added new features to tracker module, updated locales, and updated main addon files. --- Core/DebugWindow.lua | 486 ----------------------- Core/VersionNoticeWindow.lua | 18 +- HailMaryGuildTools.lua | 305 +++++++------- HailMaryGuildTools.toc | 3 +- Locales/deDE.lua | 3 +- Locales/enUS.lua | 3 +- Modules/RaidTimeline/RaidTimelineDBM.lua | 8 - Modules/Tracker/TrackerBridge.lua | 5 + Modules/Tracker/TrackerCore.lua | 174 +++++++- Modules/Tracker/TrackerDetection.lua | 2 +- Modules/Tracker/TrackerOptions.lua | 59 ++- Modules/Tracker/TrackerSync.lua | 16 +- 12 files changed, 401 insertions(+), 681 deletions(-) delete mode 100644 Core/DebugWindow.lua delete mode 100644 Modules/RaidTimeline/RaidTimelineDBM.lua diff --git a/Core/DebugWindow.lua b/Core/DebugWindow.lua deleted file mode 100644 index 59e7a77..0000000 --- a/Core/DebugWindow.lua +++ /dev/null @@ -1,486 +0,0 @@ -local ADDON_NAME = "HailMaryGuildTools" -local HMGT = _G[ADDON_NAME] -if not HMGT then return end - -local L = HMGT.L or LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME) -local AceGUI = LibStub("AceGUI-3.0", true) - -local function GetOrderedDebugLevels() - return { "error", "info", "verbose" } -end - -local function GetOrderedDebugScopes() - local values = HMGT:GetDebugScopeOptions() or {} - local names = { "ALL" } - for scope in pairs(values) do - if scope ~= "ALL" then - names[#names + 1] = scope - end - end - table.sort(names, function(a, b) - if a == "ALL" then return true end - if b == "ALL" then return false end - return tostring(values[a] or a) < tostring(values[b] or b) - end) - return names, values -end - -local function SetFilterButtonText(buttonWidget, prefix, valueLabel) - if not buttonWidget then - return - end - buttonWidget:SetText(string.format("%s: %s", tostring(prefix or ""), tostring(valueLabel or ""))) -end - -local function AdvanceDebugLevel(step) - local levels = GetOrderedDebugLevels() - local current = HMGT:GetConfiguredDebugLevel() - local nextIndex = 1 - for index, value in ipairs(levels) do - if value == current then - nextIndex = index + (step or 1) - break - end - end - if nextIndex < 1 then - nextIndex = #levels - elseif nextIndex > #levels then - nextIndex = 1 - end - HMGT.db.profile.debugLevel = levels[nextIndex] - HMGT:RefreshDebugWindow() -end - -local function AdvanceDebugScope(step) - local scopes, labels = GetOrderedDebugScopes() - local current = (HMGT.db and HMGT.db.profile and HMGT.db.profile.debugScope) or "ALL" - local nextIndex = 1 - for index, value in ipairs(scopes) do - if value == current then - nextIndex = index + (step or 1) - break - end - end - if nextIndex < 1 then - nextIndex = #scopes - elseif nextIndex > #scopes then - nextIndex = 1 - end - HMGT.db.profile.debugScope = scopes[nextIndex] - HMGT:RefreshDebugWindow() -end - -function HMGT:SetDebugWindowMinimized(minimized) - local frame = self.debugWindow - if not frame then - return - end - - minimized = minimized and true or false - self.debugWindowStatus = self.debugWindowStatus or { - width = 860, - height = 340, - } - self.debugWindowStatus.minimized = minimized - - local collapsedHeight = 64 - if minimized then - self.debugWindowStatus.restoreHeight = self.debugWindowStatus.height or frame:GetHeight() or 340 - end - - local targetHeight = minimized - and collapsedHeight - or (self.debugWindowStatus.restoreHeight or self.debugWindowStatus.height or 340) - - if frame.aceWidget then - frame.aceWidget:EnableResize(not minimized) - frame.aceWidget:SetHeight(targetHeight) - else - frame:SetHeight(targetHeight) - end - - if frame.minimizeButton then - frame.minimizeButton:SetText(minimized and "+" or "-") - end - if frame.clearButton then - local buttonFrame = frame.clearButton.frame or frame.clearButton - buttonFrame:SetShown(not minimized) - end - if frame.selectButton then - local buttonFrame = frame.selectButton.frame or frame.selectButton - buttonFrame:SetShown(not minimized) - end - if frame.levelFilter then - local filterFrame = frame.levelFilter.frame or frame.levelFilter - filterFrame:SetShown(not minimized) - end - if frame.scopeFilter then - local filterFrame = frame.scopeFilter.frame or frame.scopeFilter - filterFrame:SetShown(not minimized) - end - if frame.logWidget then - frame.logWidget.frame:SetShown(not minimized) - end - if frame.scrollBG then - frame.scrollBG:SetShown(not minimized) - end - - if not minimized then - self:RefreshDebugWindow() - end -end - -function HMGT:ToggleDebugWindowMinimized() - self:SetDebugWindowMinimized(not (self.debugWindowStatus and self.debugWindowStatus.minimized)) -end - -function HMGT:EnsureDebugWindow() - if self.debugWindow then - return self.debugWindow - end - - local frameWidget - if AceGUI then - frameWidget = AceGUI:Create("Frame") - self.debugWindowStatus = self.debugWindowStatus or { - width = 860, - height = 340, - } - frameWidget:SetTitle(L["DEBUG_WINDOW_TITLE"] or "HMGT Debug Console") - frameWidget:SetStatusText(L["DEBUG_WINDOW_HINT"] or "Mouse wheel scrolls, Ctrl+A selects all, Ctrl+C copies selected text") - frameWidget:SetStatusTable(self.debugWindowStatus) - frameWidget:SetWidth(self.debugWindowStatus.width or 860) - frameWidget:SetHeight(self.debugWindowStatus.height or 340) - frameWidget:EnableResize(true) - frameWidget.frame:SetClampedToScreen(true) - frameWidget.frame:SetToplevel(true) - frameWidget.frame:SetFrameStrata("FULLSCREEN_DIALOG") - frameWidget:Hide() - end - - local frame = frameWidget and frameWidget.frame or CreateFrame("Frame", "HMGT_DebugWindow", UIParent, "BackdropTemplate") - if not frameWidget then - frame:SetSize(860, 340) - frame:SetPoint("CENTER", UIParent, "CENTER", 0, 0) - frame:SetFrameStrata("DIALOG") - frame:SetClampedToScreen(true) - frame:SetMovable(true) - frame:EnableMouse(true) - frame:RegisterForDrag("LeftButton") - frame:SetScript("OnDragStart", function(selfFrame) selfFrame:StartMoving() end) - frame:SetScript("OnDragStop", function(selfFrame) selfFrame:StopMovingOrSizing() end) - frame:SetBackdrop({ - bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark", - edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", - edgeSize = 12, - insets = { left = 3, right = 3, top = 3, bottom = 3 }, - }) - frame:SetBackdropColor(0.05, 0.05, 0.06, 0.95) - frame:SetBackdropBorderColor(0.35, 0.55, 0.85, 1) - frame:Hide() - - local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") - title:SetPoint("TOPLEFT", frame, "TOPLEFT", 14, -12) - title:SetText(L["DEBUG_WINDOW_TITLE"] or "HMGT Debug Console") - frame.title = title - - local closeButton = CreateFrame("Button", nil, frame, "UIPanelCloseButton") - closeButton:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -5, -5) - frame.closeButton = closeButton - - local minimizeButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") - minimizeButton:SetSize(22, 20) - minimizeButton:SetPoint("TOPRIGHT", closeButton, "TOPLEFT", -2, 0) - minimizeButton:SetText((self.debugWindowStatus and self.debugWindowStatus.minimized) and "+" or "-") - minimizeButton:SetScript("OnClick", function() - HMGT:ToggleDebugWindowMinimized() - end) - frame.minimizeButton = minimizeButton - end - - frame.aceWidget = frameWidget - - if frameWidget and AceGUI then - local content = frameWidget.content - - local minimizeButton = AceGUI:Create("Button") - minimizeButton:SetText((self.debugWindowStatus and self.debugWindowStatus.minimized) and "+" or "-") - minimizeButton:SetWidth(24) - minimizeButton:SetCallback("OnClick", function() - HMGT:ToggleDebugWindowMinimized() - end) - minimizeButton.frame:SetParent(frame) - minimizeButton.frame:ClearAllPoints() - minimizeButton.frame:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -34, -4) - minimizeButton.frame:SetHeight(20) - minimizeButton.frame:Show() - frame.minimizeButton = minimizeButton - - local clearButton = AceGUI:Create("Button") - clearButton:SetText(L["OPT_DEBUG_CLEAR"] or "Clear log") - clearButton:SetWidth(120) - clearButton:SetCallback("OnClick", function() - HMGT:ClearDebugLog() - end) - clearButton.frame:SetParent(content) - clearButton.frame:ClearAllPoints() - clearButton.frame:SetPoint("TOPRIGHT", content, "TOPRIGHT", 0, -2) - clearButton.frame:Show() - frame.clearButton = clearButton - - local selectButton = AceGUI:Create("Button") - selectButton:SetText(L["OPT_DEBUG_SELECT_ALL"] or "Select all") - selectButton:SetWidth(120) - selectButton:SetCallback("OnClick", function() - if frame.editBox then - frame.editBox:SetFocus() - frame.editBox:HighlightText(0) - end - end) - selectButton.frame:SetParent(content) - selectButton.frame:ClearAllPoints() - selectButton.frame:SetPoint("TOPRIGHT", clearButton.frame, "TOPLEFT", -6, 0) - selectButton.frame:Show() - frame.selectButton = selectButton - - local levelFilter = AceGUI:Create("Button") - levelFilter:SetWidth(150) - levelFilter:SetCallback("OnClick", function() - AdvanceDebugLevel(1) - end) - levelFilter.frame:SetParent(content) - levelFilter.frame:ClearAllPoints() - levelFilter.frame:SetPoint("TOPLEFT", content, "TOPLEFT", 0, 0) - levelFilter.frame:Show() - frame.levelFilter = levelFilter - - local scopeFilter = AceGUI:Create("Button") - scopeFilter:SetWidth(180) - scopeFilter:SetCallback("OnClick", function() - AdvanceDebugScope(1) - end) - scopeFilter.frame:SetParent(content) - scopeFilter.frame:ClearAllPoints() - scopeFilter.frame:SetPoint("TOPLEFT", levelFilter.frame, "TOPRIGHT", 8, 0) - scopeFilter.frame:Show() - frame.scopeFilter = scopeFilter - - local logWidget = AceGUI:Create("MultiLineEditBox") - logWidget:SetLabel("") - logWidget:DisableButton(true) - logWidget:SetNumLines(18) - logWidget:SetText("") - logWidget.frame:SetParent(content) - logWidget.frame:ClearAllPoints() - logWidget.frame:SetPoint("TOPLEFT", content, "TOPLEFT", 0, -54) - logWidget.frame:SetPoint("BOTTOMRIGHT", content, "BOTTOMRIGHT", 0, 0) - logWidget.frame:Show() - logWidget:SetCallback("OnTextChanged", function() - HMGT:RefreshDebugWindow() - end) - logWidget.editBox:SetScript("OnKeyDown", function(selfBox, key) - if IsControlKeyDown() and (key == "A" or key == "a") then - selfBox:HighlightText(0) - end - end) - frame.logWidget = logWidget - frame.editBox = logWidget.editBox - frame.scrollFrame = logWidget.scrollFrame - - self.debugWindow = frame - self:SetDebugWindowMinimized(self.debugWindowStatus and self.debugWindowStatus.minimized) - return frame - end - - local clearButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") - clearButton:SetSize(90, 22) - clearButton:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -30, -6) - clearButton:SetText(L["OPT_DEBUG_CLEAR"] or "Clear log") - clearButton:SetScript("OnClick", function() - HMGT:ClearDebugLog() - end) - frame.clearButton = clearButton - - local selectButton = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") - selectButton:SetSize(90, 22) - selectButton:SetPoint("TOPRIGHT", clearButton, "TOPLEFT", -6, 0) - selectButton:SetText(L["OPT_DEBUG_SELECT_ALL"] or "Select all") - selectButton:SetScript("OnClick", function() - if frame.editBox then - frame.editBox:SetFocus() - frame.editBox:HighlightText(0) - end - end) - frame.selectButton = selectButton - - local scopeFilter = CreateFrame("Frame", nil, frame) - scopeFilter:SetSize(170, 22) - scopeFilter:SetPoint("TOPLEFT", frame, "TOPLEFT", 16, -8) - frame.scopeFilter = scopeFilter - - local scrollBG = CreateFrame("Frame", nil, frame, "BackdropTemplate") - scrollBG:SetBackdrop({ - bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", - edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", - edgeSize = 16, - insets = { left = 4, right = 3, top = 4, bottom = 3 }, - }) - scrollBG:SetBackdropColor(0, 0, 0, 0.95) - scrollBG:SetBackdropBorderColor(0.4, 0.4, 0.4, 1) - scrollBG:SetPoint("TOPLEFT", frame, "TOPLEFT", 14, -36) - scrollBG:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -30, 14) - frame.scrollBG = scrollBG - - local scrollFrame = CreateFrame("ScrollFrame", nil, scrollBG, "UIPanelScrollFrameTemplate") - scrollFrame:SetPoint("TOPLEFT", scrollBG, "TOPLEFT", 6, -6) - scrollFrame:SetPoint("BOTTOMRIGHT", scrollBG, "BOTTOMRIGHT", -27, 4) - scrollFrame:EnableMouseWheel(true) - scrollFrame:SetScript("OnMouseWheel", function(selfMsg, delta) - if delta > 0 then - selfMsg:SetVerticalScroll(math.max(0, selfMsg:GetVerticalScroll() - 42)) - else - selfMsg:SetVerticalScroll(selfMsg:GetVerticalScroll() + 42) - end - end) - frame.scrollFrame = scrollFrame - - local editBox = CreateFrame("EditBox", nil, scrollFrame) - editBox:SetMultiLine(true) - editBox:SetAutoFocus(false) - editBox:SetFontObject(ChatFontNormal) - editBox:SetWidth(780) - editBox:SetTextInsets(6, 6, 6, 6) - editBox:EnableMouse(true) - editBox:SetScript("OnEscapePressed", function(selfBox) - selfBox:ClearFocus() - end) - editBox:SetScript("OnKeyDown", function(selfBox, key) - if IsControlKeyDown() and (key == "A" or key == "a") then - selfBox:HighlightText(0) - end - end) - editBox:SetScript("OnTextChanged", function(selfBox, userInput) - if userInput then - HMGT:RefreshDebugWindow() - else - selfBox:SetCursorPosition(selfBox:GetNumLetters()) - selfBox:SetHeight(math.max(scrollFrame:GetHeight(), HMGT:GetDebugWindowTextHeight(frame, selfBox:GetText()) + 16)) - scrollFrame:UpdateScrollChildRect() - end - end) - editBox:SetScript("OnMouseUp", function(selfBox) - selfBox:SetFocus() - end) - scrollFrame:SetScrollChild(editBox) - frame.editBox = editBox - - local measureText = frame:CreateFontString(nil, "ARTWORK", "ChatFontNormal") - measureText:SetJustifyH("LEFT") - measureText:SetJustifyV("TOP") - if measureText.SetSpacing then - measureText:SetSpacing(2) - end - measureText:SetWidth(768) - frame.measureText = measureText - - self.debugWindow = frame - self:SetDebugWindowMinimized(self.debugWindowStatus and self.debugWindowStatus.minimized) - return frame -end - -function HMGT:GetDebugWindowTextHeight(frame, text) - if not frame or not frame.measureText then - return 0 - end - - local width = 768 - if frame.editBox then - width = math.max(1, (frame.editBox:GetWidth() or width) - 12) - end - frame.measureText:SetWidth(width) - frame.measureText:SetText(text or "") - return frame.measureText:GetStringHeight() -end - -function HMGT:RefreshDebugWindow() - local frame = self:EnsureDebugWindow() - if not frame then - return - end - - local filtered = self:GetFilteredDebugBuffer() or self.debugBuffer or {} - local text = table.concat(filtered, "\n") - if frame.logWidget and frame.editBox then - if frame.levelFilter then - local levelOptions = self:GetDebugLevelOptions() - SetFilterButtonText(frame.levelFilter, L["OPT_DEBUG_LEVEL"] or "Level", levelOptions[self:GetConfiguredDebugLevel()]) - end - if frame.scopeFilter then - local scopeOptions = self:GetDebugScopeOptions() - local currentScope = (self.db and self.db.profile and self.db.profile.debugScope) or "ALL" - SetFilterButtonText(frame.scopeFilter, L["OPT_DEBUG_SCOPE"] or "Module", scopeOptions[currentScope] or currentScope) - end - frame.logWidget:SetText(text) - frame.editBox:SetCursorPosition(frame.editBox:GetNumLetters()) - return - end - - if not frame.editBox then - return - end - - frame.editBox:SetText(text) - frame.editBox:SetCursorPosition(#text) - frame.editBox:SetHeight(math.max(frame.scrollFrame:GetHeight(), self:GetDebugWindowTextHeight(frame, text) + 16)) - frame.scrollFrame:SetVerticalScroll(math.max(0, frame.editBox:GetHeight() - frame.scrollFrame:GetHeight())) -end - -function HMGT:UpdateDebugWindowVisibility() - if self.db and self.db.profile then - self.db.profile.debug = false - end - local frame = self.debugWindow - if not frame then - return - end - local widget = frame.aceWidget - if widget then - widget:Hide() - else - frame:Hide() - end -end - -function HMGT:ClearDebugLog() - wipe(self.debugBuffer) - if self.debugWindow and self.debugWindow.logWidget then - self.debugWindow.logWidget:SetText("") - self.debugWindow.editBox:SetCursorPosition(0) - self.debugWindow.scrollFrame:SetVerticalScroll(0) - return - end - if self.debugWindow and self.debugWindow.editBox then - self.debugWindow.editBox:SetText("") - self.debugWindow.scrollFrame:SetVerticalScroll(0) - end -end - -function HMGT:ToggleDebugWindowShortcut() - if self.db and self.db.profile then - self.db.profile.debug = false - end - local frame = self.debugWindow - if not frame then - return - end - local widget = frame.aceWidget - if widget then - widget:Hide() - else - frame:Hide() - end -end - -function HMGT:DumpDebugLog(maxLines) - return -end diff --git a/Core/VersionNoticeWindow.lua b/Core/VersionNoticeWindow.lua index cb86ed4..e495d63 100644 --- a/Core/VersionNoticeWindow.lua +++ b/Core/VersionNoticeWindow.lua @@ -68,12 +68,14 @@ local function GetPlayerVersionText(name) return tostring(HMGT.ADDON_VERSION or "dev"), tonumber(HMGT.PROTOCOL_VERSION) or 0, true end - local version = HMGT.peerVersions and HMGT.peerVersions[normalized] or nil - local protocol = HMGT.GetPeerProtocolVersion and HMGT:GetPeerProtocolVersion(normalized) or 0 - if version and version ~= "" then - return tostring(version), tonumber(protocol) or 0, true + local addonStatus = HMGT.GetPlayerAddonStatus and HMGT:GetPlayerAddonStatus(normalized) or nil + if addonStatus and addonStatus.mode == "hmgt" and addonStatus.version and addonStatus.version ~= "" then + return tostring(addonStatus.version), tonumber(addonStatus.protocol) or 0, true end - return nil, tonumber(protocol) or 0, false + if addonStatus and addonStatus.mode == "bridge" then + return L["VERSION_WINDOW_BRIDGE_MODE"] or "Bridge Mode", 0, true + end + return nil, tonumber(addonStatus and addonStatus.protocol) or 0, false end local function ApplyClassIcon(texture, classTag) @@ -167,7 +169,11 @@ function HMGT:RefreshVersionNoticeWindow() local versionText, protocol, hasAddon = GetPlayerVersionText(info.name) if hasAddon then row.versionText:SetText(versionText or "?") - row.versionText:SetTextColor(0.9, 0.9, 0.9, 1) + if versionText == (L["VERSION_WINDOW_BRIDGE_MODE"] or "Bridge Mode") then + row.versionText:SetTextColor(0.55, 0.82, 1, 1) + else + row.versionText:SetTextColor(0.9, 0.9, 0.9, 1) + end row.protocolText:SetText(protocol > 0 and tostring(protocol) or "-") row.protocolText:SetTextColor(0.75, 0.75, 0.75, 1) else diff --git a/HailMaryGuildTools.lua b/HailMaryGuildTools.lua index 37eec2f..1c62c4d 100644 --- a/HailMaryGuildTools.lua +++ b/HailMaryGuildTools.lua @@ -288,16 +288,20 @@ HMGT.powerTracking = { } HMGT.pendingSpellPowerCosts = {} HMGT.demoModeData = {} -HMGT.peerVersions = {} HMGT.versionWarnings = {} HMGT.versionWhisperWarnings = {} +HMGT.playerStatus = {} HMGT.debugBuffer = {} HMGT.debugBufferMax = 500 HMGT.enabledDebugScopes = { General = true, Debug = true, Comm = true, - TrackedSpells = true, + TrackerCore = true, + TrackerSync = true, + TrackerUI = true, + TrackerBridge = true, + TrackerState = true, PowerSpend = true, } HMGT.pendingReliableMessages = HMGT.pendingReliableMessages or {} @@ -311,7 +315,11 @@ local DEBUG_SCOPE_LABELS = { General = "General", Debug = "Debug", Comm = "Communication", - TrackedSpells = "Tracked Spells", + TrackerCore = "Tracker Core", + TrackerSync = "Tracker Sync", + TrackerUI = "Tracker UI", + TrackerBridge = "Tracker Bridge", + TrackerState = "Tracker State", PowerSpend = "Power Spend", RaidTimeline = "Raid Timeline", Notes = "Notes", @@ -340,8 +348,12 @@ end function HMGT:GetTrackerDebugScope(tracker) local trackerName = nil + local trackerId = nil + local trackerType = nil if type(tracker) == "table" then trackerName = tracker.name + trackerId = tonumber(tracker.id) + trackerType = tracker.trackerType if (not trackerName or trackerName == "") and tracker.id then trackerName = string.format("Tracker %s", tostring(tracker.id)) end @@ -353,7 +365,106 @@ function HMGT:GetTrackerDebugScope(tracker) if trackerName == "" then trackerName = "Tracker" end - return "Tracker: " .. trackerName + local prefix = "Tracker" + if trackerType == "group" then + prefix = "Tracker Group" + elseif trackerType == "normal" then + prefix = "Tracker Normal" + end + if trackerId then + return string.format("%s #%d: %s", prefix, trackerId, trackerName) + end + return prefix .. ": " .. trackerName +end + +function HMGT:GetPlayerStatus(playerName, create) + local normalizedName = self:NormalizePlayerName(playerName) + if not normalizedName or normalizedName == "" then + return nil + end + self.playerStatus = self.playerStatus or {} + if create then + self.playerStatus[normalizedName] = self.playerStatus[normalizedName] or {} + end + return self.playerStatus[normalizedName] +end + +function HMGT:SetPlayerVersionStatus(playerName, version, protocol, sourceTag) + local status = self:GetPlayerStatus(playerName, true) + if not status then + return nil + end + if version and version ~= "" then + status.version = tostring(version) + end + if tonumber(protocol) then + status.protocol = tonumber(protocol) + end + if sourceTag and sourceTag ~= "" then + status.versionSource = tostring(sourceTag) + end + status.mode = "hmgt" + return status +end + +function HMGT:SetPlayerBridgeStatus(playerName, sourceName) + local source = tostring(sourceName or "") + if source == "" then + return nil + end + local status = self:GetPlayerStatus(playerName, true) + if not status then + return nil + end + status.bridgeSource = source + if not status.version or status.version == "" then + status.mode = "bridge" + end + return status +end + +function HMGT:GetPlayerAddonStatus(playerName) + local status = self:GetPlayerStatus(playerName, false) + if not status then + return { + mode = "missing", + version = nil, + protocol = 0, + bridgeSource = nil, + } + end + + local version = status.version + local protocol = tonumber(status.protocol) or 0 + local bridgeSource = status.bridgeSource + local mode = status.mode + + if version and version ~= "" then + mode = "hmgt" + elseif bridgeSource and bridgeSource ~= "" then + mode = "bridge" + else + mode = "missing" + end + + return { + mode = mode, + version = version, + protocol = protocol, + bridgeSource = bridgeSource, + } +end + +function HMGT:ClearPlayerStatus(playerName) + local normalizedName = self:NormalizePlayerName(playerName) + if not normalizedName or not self.playerStatus then + return false + end + if self.playerStatus[normalizedName] then + self.playerStatus[normalizedName] = nil + return true + end + return false end function HMGT:GetStaticDebugScopeOptions() @@ -456,6 +567,10 @@ function HMGT:IsReliableCommType(msgType) end function HMGT:GetPeerProtocolVersion(playerName) + local status = self:GetPlayerStatus(playerName, false) + if status and tonumber(status.protocol) then + return tonumber(status.protocol) or 0 + end local normalizedName = self:NormalizePlayerName(playerName) local peerProtocols = self.peerProtocols or {} return tonumber(normalizedName and peerProtocols[normalizedName]) or 0 @@ -469,6 +584,7 @@ function HMGT:RememberPeerProtocolVersion(playerName, protocol) end self.peerProtocols = self.peerProtocols or {} self.peerProtocols[normalizedName] = numeric + self:SetPlayerVersionStatus(normalizedName, nil, numeric, nil) end local function ParseVersionTokens(version) @@ -747,7 +863,32 @@ function HMGT:SendDirectMessage(payload, target, prio) end function HMGT:DebugScoped(level, scope, fmt, ...) - return + local normalizedLevel = tostring(level or "info"):lower() + if not DEBUG_LEVELS[normalizedLevel] then + normalizedLevel = "info" + end + + local normalizedScope = tostring(scope or "General"):match("^%s*(.-)%s*$") + if normalizedScope == "" then + normalizedScope = "General" + end + + local ok, message = pcall(string.format, tostring(fmt or ""), ...) + if not ok then + message = tostring(fmt or "") + end + local line = string.format("%s [%s][%s] %s", date("%H:%M:%S"), string.upper(normalizedLevel), normalizedScope, tostring(message or "")) + + self.debugBuffer = self.debugBuffer or {} + self.debugBuffer[#self.debugBuffer + 1] = line + local maxLines = tonumber(self.debugBufferMax) or 500 + while #self.debugBuffer > maxLines do + table.remove(self.debugBuffer, 1) + end + + if self.debugWindow and self.debugWindow.IsShown and self.debugWindow:IsShown() and self.RefreshDebugWindow then + self:RefreshDebugWindow() + end end function HMGT:Debug(fmt, ...) @@ -764,7 +905,7 @@ end function HMGT:RegisterPeerVersion(playerName, version, protocol, sourceTag) if not playerName then return end - self.peerVersions[playerName] = version + self:SetPlayerVersionStatus(playerName, version, protocol, sourceTag) self:RememberPeerProtocolVersion(playerName, protocol) if self.versionNoticeWindow and self.versionNoticeWindow.IsShown and self.versionNoticeWindow:IsShown() and self.RefreshVersionNoticeWindow then self:RefreshVersionNoticeWindow() @@ -798,7 +939,7 @@ function HMGT:RegisterPeerVersion(playerName, version, protocol, sourceTag) tostring(playerName), table.concat(details, " | ")) self:Print("|cffff5555HMGT|r " .. text) self:ShowVersionMismatchPopup(playerName, table.concat(details, " | "), sourceTag) - self:Debug("info", "Version mismatch %s via=%s %s", tostring(playerName), tostring(sourceTag or "?"), table.concat(details, " | ")) + self:DebugScoped("info", "TrackerCore", "Version mismatch %s via=%s %s", tostring(playerName), tostring(sourceTag or "?"), table.concat(details, " | ")) end end @@ -1091,7 +1232,7 @@ function HMGT:LogTrackedSpellCast(playerName, spellEntry, details) self:DebugScoped( "verbose", - "TrackedSpells", + "TrackerCore", "%s -> %s von %s, %s", GetTrackedSpellCategoryLabel(spellEntry), GetSpellDebugLabel(spellEntry.spellId), @@ -1699,22 +1840,9 @@ function HMGT:MigrateProfileSettings() if #p.trackers == 0 and p.trackerModelVersion ~= TRACKER_MODEL_VERSION then p.trackers = { - self:CreateTrackerConfig(1, CopyTrackerFields({ - name = L["IT_NAME"] or "Interrupts", - trackerType = "normal", - categories = { "interrupt" }, - }, p.interruptTracker or {})), - self:CreateTrackerConfig(2, CopyTrackerFields({ - name = L["RCD_NAME"] or "Raid Cooldowns", - trackerType = "normal", - categories = { "raid" }, - }, p.raidCooldownTracker or {})), - self:CreateTrackerConfig(3, CopyTrackerFields({ - name = L["GCD_NAME"] or "Cooldowns", - trackerType = "group", - categories = { "defensive", "offensive", "tank", "healing", "utility", "cc", "lust" }, - showChargesOnIcon = true, - }, p.groupCooldownTracker or {})), + self:BuildTrackerConfigFromPreset("interruptTracker", 1, CopyTrackerFields({}, p.interruptTracker or {})), + self:BuildTrackerConfigFromPreset("raidCooldownTracker", 2, CopyTrackerFields({}, p.raidCooldownTracker or {})), + self:BuildTrackerConfigFromPreset("groupCooldownTracker", 3, CopyTrackerFields({}, p.groupCooldownTracker or {})), } end @@ -1732,11 +1860,7 @@ function HMGT:MigrateProfileSettings() end end if #normalizedTrackers == 0 then - normalizedTrackers[1] = self:CreateTrackerConfig(1, { - name = L["IT_NAME"] or "Interrupts", - trackerType = "normal", - categories = { "interrupt" }, - }) + normalizedTrackers[1] = self:BuildTrackerConfigFromPreset("interruptTracker", 1) end p.trackers = normalizedTrackers p.trackerModelVersion = TRACKER_MODEL_VERSION @@ -2648,7 +2772,7 @@ function HMGT:OnGroupRosterUpdate() if not validPlayers[name] then self.playerData[name] = nil self:ClearTrackerStateForPlayer(name) - self.peerVersions[name] = nil + self:ClearPlayerStatus(name) self.versionWarnings[name] = nil if self.peerProtocols then self.peerProtocols[name] = nil @@ -3040,127 +3164,6 @@ function HMGT:TestMode() self:Print(L["TEST_MODE_ACTIVE"]) end -function HMGT:GetDemoEntries(trackerKey, database, settings) - local pool = {} - local poolByClass = {} - for _, entry in ipairs(database) do - if settings.enabledSpells[entry.spellId] ~= false then - pool[#pool + 1] = entry - for _, cls in ipairs(entry.classes or {}) do - poolByClass[cls] = poolByClass[cls] or {} - poolByClass[cls][#poolByClass[cls] + 1] = entry - end - end - end - if #pool == 0 then return {} end - - local classKeys = {} - for cls in pairs(poolByClass) do - classKeys[#classKeys + 1] = cls - end - if #classKeys == 0 then classKeys[1] = "WARRIOR" end - - local count = settings.showBar and math.min(8, #pool) or math.min(12, #pool) - local names = { "Alice", "Bob", "Clara", "Duke", "Elli", "Fynn", "Gina", "Hektor", "Ivo", "Jana", "Kira", "Lio" } - - local spellIds = {} - for _, e in ipairs(pool) do spellIds[#spellIds + 1] = tostring(e.spellId) end - table.sort(spellIds) - local signature = table.concat(spellIds, ",") .. "|" .. tostring(settings.showBar and 1 or 0) .. "|" .. tostring(count) - - local now = GetTime() - local cache = self.demoModeData[trackerKey] - if (not cache) or (cache.signature ~= signature) or (not cache.entries) or (#cache.entries ~= count) then - local entries = {} - for i = 1, count do - local cls = classKeys[math.random(1, #classKeys)] - local classPool = poolByClass[cls] - local spell = (classPool and classPool[math.random(1, #classPool)]) or pool[math.random(1, #pool)] - local duration = math.max( - 1, - tonumber(HMGT_SpellData.GetBaseCooldown and HMGT_SpellData.GetBaseCooldown(spell)) or tonumber(spell.cooldown) or 60 - ) - local playerName = names[((i - 1) % #names) + 1] - -- start offset so demo entries do not all tick in sync - local offset = math.random() * math.min(duration * 0.85, duration - 0.1) - entries[#entries + 1] = { - playerName = playerName, - class = cls or ((spell.classes and spell.classes[1]) or "WARRIOR"), - spellEntry = spell, - total = duration, - cycleStart = now - offset, - currentCharges = nil, - maxCharges = nil, - } - end - cache = { signature = signature, entries = entries } - self.demoModeData[trackerKey] = cache - end - - local out = {} - for _, e in ipairs(cache.entries) do - local total = math.max(1, tonumber(e.total) or 1) - local elapsed = math.max(0, now - (e.cycleStart or now)) - local phase = math.fmod(elapsed, total) - local rem = total - phase - -- show zero briefly at cycle boundary, then restart immediately - if elapsed > 0 and phase < 0.05 then rem = 0 end - out[#out + 1] = { - playerName = e.playerName, - class = e.class, - spellEntry = e.spellEntry, - remaining = rem, - total = total, - currentCharges = e.currentCharges, - maxCharges = e.maxCharges, - } - end - - return out -end - -function HMGT:GetOwnTestEntries(database, settings, cooldownInfoOpts) - local entries = {} - local enabledSpells = settings and settings.enabledSpells or {} - local playerName = self:NormalizePlayerName(UnitName("player")) or "Player" - local classToken = select(2, UnitClass("player")) - if not classToken then - return entries, playerName - end - - local specIdx = GetSpecialization() - local lookupSpec = (specIdx and specIdx > 0) and specIdx or 0 - local talents = (self.playerData[playerName] and self.playerData[playerName].talents) or {} - local spells = HMGT_SpellData.GetSpellsForSpec(classToken, lookupSpec, database or {}) - - for _, spellEntry in ipairs(spells) do - if enabledSpells[spellEntry.spellId] ~= false then - local remaining, total, curCharges, maxCharges = self:GetCooldownInfo(playerName, spellEntry.spellId, cooldownInfoOpts) - local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) - local isAvailabilitySpell = self:IsAvailabilitySpell(spellEntry) - local spellKnown = self:IsTrackedSpellKnownForPlayer(playerName, spellEntry.spellId) - local hasPartialCharges = (tonumber(maxCharges) or 0) > 0 - and (tonumber(curCharges) or tonumber(maxCharges) or 0) < (tonumber(maxCharges) or 0) - local hasActiveCd = ((remaining or 0) > 0) or hasPartialCharges - local hasAvailabilityState = isAvailabilitySpell and self:HasAvailabilityState(playerName, spellEntry.spellId) - - if spellKnown or hasActiveCd or hasAvailabilityState then - entries[#entries + 1] = { - playerName = playerName, - class = classToken, - spellEntry = spellEntry, - remaining = remaining, - total = total > 0 and total or effectiveCd, - currentCharges = curCharges, - maxCharges = maxCharges, - } - end - end - end - - return entries, playerName -end - -- ═══════════════════════════════════════════════════════════════ -- HILFSFUNKTIONEN -- ═══════════════════════════════════════════════════════════════ diff --git a/HailMaryGuildTools.toc b/HailMaryGuildTools.toc index 5d46a58..bef1e5e 100644 --- a/HailMaryGuildTools.toc +++ b/HailMaryGuildTools.toc @@ -44,7 +44,7 @@ Modules\Tracker\RaidCooldownTracker\RaidCooldownTracker.lua Modules\Tracker\GroupCooldownTracker\GroupCooldownTracker.lua Modules\Tracker\InterruptTracker\InterruptSpellDatabase.lua -Modules\Tracker\RaidcooldownTracker\RaidCooldownSpellDatabase.lua +Modules\Tracker\RaidCooldownTracker\RaidCooldownSpellDatabase.lua Modules\Tracker\GroupCooldownTracker\GroupCooldownSpellDatabase.lua Modules\Tracker\TrackerManager.lua Modules\Tracker\NormalTrackerFrames.lua @@ -65,5 +65,4 @@ Modules\MapOverlay\MapOverlay.xml Modules\RaidTimeline\RaidTimelineBossAbilityData.lua Modules\RaidTimeline\RaidTimeline.lua Modules\RaidTimeline\RaidTimelineBigWigs.lua -Modules\RaidTimeline\RaidTimelineDBM.lua Modules\RaidTimeline\RaidTimelineOptions.lua diff --git a/Locales/deDE.lua b/Locales/deDE.lua index db24be8..5679e7f 100644 --- a/Locales/deDE.lua +++ b/Locales/deDE.lua @@ -22,11 +22,12 @@ L["VERSION_WINDOW_MESSAGE"] = "Hail Mary Guild Tools Versionen in deiner aktuell L["VERSION_WINDOW_DETAIL"] = "Erkannt ueber %s von %s.\n%s" L["VERSION_WINDOW_NO_MISMATCH"] = "In deiner aktuellen Gruppe wurde keine neuere HMGT-Version erkannt." L["VERSION_WINDOW_CURRENT"] = "Aktuelle Version: %s | Protokoll: %s" -L["VERSION_WINDOW_STATUS"] = "HMGT bei %d/%d Spielern erkannt" +L["VERSION_WINDOW_STATUS"] = "Addon oder Bridge bei %d/%d Spielern erkannt" L["VERSION_WINDOW_REFRESH"] = "Aktualisieren" L["VERSION_WINDOW_COLUMN_PLAYER"] = "Spieler" L["VERSION_WINDOW_COLUMN_VERSION"] = "Version" L["VERSION_WINDOW_COLUMN_PROTOCOL"] = "Protokoll" +L["VERSION_WINDOW_BRIDGE_MODE"] = "Bridge Mode" L["VERSION_WINDOW_MISSING_ADDON"] = "Addon nicht vorhanden" L["VERSION_WINDOW_LEADER_TAG"] = "(Leiter)" L["VERSION_WINDOW_ASSISTANT_TAG"] = "(Assist)" diff --git a/Locales/enUS.lua b/Locales/enUS.lua index 89da42b..068ce49 100644 --- a/Locales/enUS.lua +++ b/Locales/enUS.lua @@ -22,11 +22,12 @@ L["VERSION_WINDOW_MESSAGE"] = "Hail Mary Guild Tools versions in your current gr L["VERSION_WINDOW_DETAIL"] = "Detected via %s from %s.\n%s" L["VERSION_WINDOW_NO_MISMATCH"] = "No newer HMGT version has been detected in your current group." L["VERSION_WINDOW_CURRENT"] = "Current version: %s | Protocol: %s" -L["VERSION_WINDOW_STATUS"] = "Detected HMGT on %d/%d players" +L["VERSION_WINDOW_STATUS"] = "Detected addon or bridge on %d/%d players" L["VERSION_WINDOW_REFRESH"] = "Refresh" L["VERSION_WINDOW_COLUMN_PLAYER"] = "Player" L["VERSION_WINDOW_COLUMN_VERSION"] = "Version" L["VERSION_WINDOW_COLUMN_PROTOCOL"] = "Protocol" +L["VERSION_WINDOW_BRIDGE_MODE"] = "Bridge Mode" L["VERSION_WINDOW_MISSING_ADDON"] = "Addon not installed" L["VERSION_WINDOW_LEADER_TAG"] = "(Leader)" L["VERSION_WINDOW_ASSISTANT_TAG"] = "(Assist)" diff --git a/Modules/RaidTimeline/RaidTimelineDBM.lua b/Modules/RaidTimeline/RaidTimelineDBM.lua deleted file mode 100644 index a55d6ef..0000000 --- a/Modules/RaidTimeline/RaidTimelineDBM.lua +++ /dev/null @@ -1,8 +0,0 @@ -local ADDON_NAME = "HailMaryGuildTools" -local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) -if not HMGT then return end - -local RT = HMGT.RaidTimeline -if not RT then return end - --- Placeholder for later DBM-specific raid timeline integration. diff --git a/Modules/Tracker/TrackerBridge.lua b/Modules/Tracker/TrackerBridge.lua index d49c457..3edf9c9 100644 --- a/Modules/Tracker/TrackerBridge.lua +++ b/Modules/Tracker/TrackerBridge.lua @@ -80,6 +80,8 @@ function HMGT:ApplyExternalKnownSpell(sourceName, playerName, spellId, class, co knownSpells = knownSpells, externalSource = source, } + self:SetPlayerBridgeStatus(normalizedName, source) + self:DebugScoped("verbose", "TrackerBridge", "Bridge known spell source=%s player=%s spellId=%s", tostring(source), tostring(normalizedName), tostring(sid)) if tonumber(cooldown) and tonumber(cooldown) > 0 then spellEntry._hmgtExternalBaseCd = tonumber(cooldown) @@ -128,6 +130,8 @@ function HMGT:ApplyExternalSpecInfo(sourceName, playerName, class, specId, talen knownSpells = knownSpells, externalSource = source, } + self:SetPlayerBridgeStatus(normalizedName, source) + self:DebugScoped("info", "TrackerBridge", "Bridge spec sync source=%s player=%s class=%s spec=%s", tostring(source), tostring(normalizedName), tostring(classToken), tostring(spec)) self:PruneAvailabilityStates(normalizedName, knownSpells) self:TriggerTrackerUpdate("trackers") @@ -154,6 +158,7 @@ function HMGT:ApplyExternalCooldown(sourceName, playerName, spellId, cooldown) self:RegisterExternalAddonSource(source) self:ApplyExternalKnownSpell(source, normalizedName, sid, nil, cd) + self:DebugScoped("info", "TrackerBridge", "Bridge cooldown source=%s player=%s spellId=%s cooldown=%.1f", tostring(source), tostring(normalizedName), tostring(sid), cd) self:HandleRemoteSpellCast(normalizedName, sid, GetServerTime(), nil, nil, nil, cd) return true end diff --git a/Modules/Tracker/TrackerCore.lua b/Modules/Tracker/TrackerCore.lua index 39fad12..c77fe3e 100644 --- a/Modules/Tracker/TrackerCore.lua +++ b/Modules/Tracker/TrackerCore.lua @@ -4,6 +4,91 @@ if not HMGT then return end HMGT.TrackerCore = HMGT.TrackerCore or {} +HMGT.TRACKER_PRESET_DEFINITIONS = HMGT.TRACKER_PRESET_DEFINITIONS or { + interruptTracker = { + moduleName = "InterruptTracker", + dbKey = "interruptTracker", + trackerType = "normal", + trackerKey = "interruptTracker", + categories = { "interrupt" }, + defaultName = function(L) + return (L and L["IT_NAME"]) or "Interrupts" + end, + }, + raidCooldownTracker = { + moduleName = "RaidCooldownTracker", + dbKey = "raidCooldownTracker", + trackerType = "normal", + trackerKey = "raidCooldownTracker", + categories = { "lust", "defensive", "healing", "tank", "utility", "offensive", "cc", "interrupt" }, + defaultName = function(L) + return (L and L["RCD_NAME"]) or "Raid Cooldowns" + end, + }, + groupCooldownTracker = { + moduleName = "GroupCooldownTracker", + dbKey = "groupCooldownTracker", + trackerType = "group", + trackerKey = "groupCooldownTracker", + categories = { "tank", "defensive", "healing", "cc", "utility", "offensive", "lust", "interrupt" }, + includeSelfFrame = false, + showChargesOnIcon = true, + defaultName = function(L) + return (L and L["GCD_NAME"]) or "Cooldowns" + end, + }, +} + +function HMGT:GetTrackerPresetDefinitions() + return self.TRACKER_PRESET_DEFINITIONS or {} +end + +function HMGT:GetTrackerPresetDefinition(key) + local definitions = self:GetTrackerPresetDefinitions() + return definitions and definitions[tostring(key or "")] +end + +function HMGT:GetTrackerPresetDefinitionByModule(moduleName) + local target = tostring(moduleName or "") + for _, definition in pairs(self:GetTrackerPresetDefinitions()) do + if tostring(definition.moduleName or "") == target then + return definition + end + end + return nil +end + +function HMGT:GetTrackerTypeOptions() + local L = self.L + return { + normal = (L and L["OPT_TRACKER_TYPE_NORMAL"]) or "Normal tracker", + group = (L and L["OPT_TRACKER_TYPE_GROUP"]) or "Group-based tracker", + } +end + +function HMGT:BuildTrackerConfigFromPreset(presetKey, trackerId, overrides) + local definition = self:GetTrackerPresetDefinition(presetKey) + local config = overrides or {} + if not definition then + return self:CreateTrackerConfig(trackerId, config) + end + + local base = { + name = type(definition.defaultName) == "function" and definition.defaultName(self.L) or tostring(definition.defaultName or ""), + trackerType = definition.trackerType, + trackerKey = definition.trackerKey, + categories = definition.categories, + includeSelfFrame = definition.includeSelfFrame, + showChargesOnIcon = definition.showChargesOnIcon, + } + + for key, value in pairs(config) do + base[key] = value + end + + return self:CreateTrackerConfig(trackerId, base) +end + local function EntryNeedsVisualTicker(entry) if type(entry) ~= "table" then return false @@ -204,6 +289,93 @@ function HMGT:CollectTrackerEntries(tracker) return entries end +function HMGT:GetDemoEntries(trackerKey, database, settings) + local pool = {} + local poolByClass = {} + for _, entry in ipairs(database or {}) do + if settings.enabledSpells[entry.spellId] ~= false then + pool[#pool + 1] = entry + for _, classToken in ipairs(entry.classes or {}) do + poolByClass[classToken] = poolByClass[classToken] or {} + poolByClass[classToken][#poolByClass[classToken] + 1] = entry + end + end + end + if #pool == 0 then + return {} + end + + local classKeys = {} + for classToken in pairs(poolByClass) do + classKeys[#classKeys + 1] = classToken + end + if #classKeys == 0 then + classKeys[1] = "WARRIOR" + end + + local count = settings.showBar and math.min(8, #pool) or math.min(12, #pool) + local names = { "Alice", "Bob", "Clara", "Duke", "Elli", "Fynn", "Gina", "Hektor", "Ivo", "Jana", "Kira", "Lio" } + + local spellIds = {} + for _, entry in ipairs(pool) do + spellIds[#spellIds + 1] = tostring(entry.spellId) + end + table.sort(spellIds) + local signature = table.concat(spellIds, ",") .. "|" .. tostring(settings.showBar and 1 or 0) .. "|" .. tostring(count) + + local now = GetTime() + local cache = self.demoModeData[trackerKey] + if (not cache) or cache.signature ~= signature or (not cache.entries) or #cache.entries ~= count then + local cachedEntries = {} + for index = 1, count do + local classToken = classKeys[math.random(1, #classKeys)] + local classPool = poolByClass[classToken] + local spellEntry = (classPool and classPool[math.random(1, #classPool)]) or pool[math.random(1, #pool)] + local duration = math.max( + 1, + tonumber(HMGT_SpellData.GetBaseCooldown and HMGT_SpellData.GetBaseCooldown(spellEntry)) or tonumber(spellEntry.cooldown) or 60 + ) + local offset = math.random() * math.min(duration * 0.85, duration - 0.1) + cachedEntries[#cachedEntries + 1] = { + playerName = names[((index - 1) % #names) + 1], + class = classToken or ((spellEntry.classes and spellEntry.classes[1]) or "WARRIOR"), + spellEntry = spellEntry, + total = duration, + cycleStart = now - offset, + currentCharges = nil, + maxCharges = nil, + } + end + cache = { + signature = signature, + entries = cachedEntries, + } + self.demoModeData[trackerKey] = cache + end + + local entries = {} + for _, entry in ipairs(cache.entries) do + local total = math.max(1, tonumber(entry.total) or 1) + local elapsed = math.max(0, now - (entry.cycleStart or now)) + local phase = math.fmod(elapsed, total) + local remaining = total - phase + if elapsed > 0 and phase < 0.05 then + remaining = 0 + end + entries[#entries + 1] = { + playerName = entry.playerName, + class = entry.class, + spellEntry = entry.spellEntry, + remaining = remaining, + total = total, + currentCharges = entry.currentCharges, + maxCharges = entry.maxCharges, + } + end + + return entries +end + function HMGT:CollectTrackerTestEntries(tracker) local playerName = self:NormalizePlayerName(UnitName("player")) or "Player" local classToken = select(2, UnitClass("player")) @@ -385,7 +557,7 @@ function HMGT:TriggerTrackerUpdate(reason) if t0 and t1 then local mod = HMGT[name] local count = mod and mod.lastEntryCount or 0 - self:Debug("verbose", "UIUpdate %s took %.2fms entries=%s", tostring(name), t1 - t0, tostring(count)) + self:DebugScoped("verbose", "TrackerUI", "UIUpdate %s took %.2fms entries=%s", tostring(name), t1 - t0, tostring(count)) end end diff --git a/Modules/Tracker/TrackerDetection.lua b/Modules/Tracker/TrackerDetection.lua index 11ec4ff..cab2eab 100644 --- a/Modules/Tracker/TrackerDetection.lua +++ b/Modules/Tracker/TrackerDetection.lua @@ -247,7 +247,7 @@ function HMGT:RefreshOwnCooldownStateFromGame(spellId) if isLikelyGlobalCooldown or isSuspiciousShortRefresh then self:DebugScoped( "verbose", - "TrackedSpells", + "TrackerState", "Ignore suspicious refresh for %s: spellCD=%.3f gcd=%.3f existing=%.3f remaining=%.3f effective=%.3f", GetSpellDebugLabel and GetSpellDebugLabel(sid) or tostring(sid), cooldownDuration, diff --git a/Modules/Tracker/TrackerOptions.lua b/Modules/Tracker/TrackerOptions.lua index 14b8d59..bb91c17 100644 --- a/Modules/Tracker/TrackerOptions.lua +++ b/Modules/Tracker/TrackerOptions.lua @@ -142,13 +142,26 @@ local function IsPartyAttachMode(tracker) end local function IsGroupTracker(tracker) - return type(tracker) == "table" and tracker.trackerType == "group" + return HMGT.IsGroupTrackerConfig and HMGT:IsGroupTrackerConfig(tracker) or (type(tracker) == "table" and tracker.trackerType == "group") end -local TRACKER_TYPE_VALUES = { - normal = L["OPT_TRACKER_TYPE_NORMAL"] or "Normal tracker", - group = L["OPT_TRACKER_TYPE_GROUP"] or "Group-based tracker", -} +local function GetTrackerTypeValues() + return HMGT.GetTrackerTypeOptions and HMGT:GetTrackerTypeOptions() or { + normal = L["OPT_TRACKER_TYPE_NORMAL"] or "Normal tracker", + group = L["OPT_TRACKER_TYPE_GROUP"] or "Group-based tracker", + } +end + +local function GetPresetLabel(presetKey) + local definition = HMGT.GetTrackerPresetDefinition and HMGT:GetTrackerPresetDefinition(presetKey) or nil + if not definition then + return tostring(presetKey or (L["OPT_TRACKER"] or "Tracker")) + end + if type(definition.defaultName) == "function" then + return tostring(definition.defaultName(L)) + end + return tostring(definition.defaultName or definition.moduleName or presetKey) +end local function GetTrackerVisibilitySummary(tracker) local parts = {} @@ -180,7 +193,7 @@ local function GetTrackerSummaryText(tracker) local display = tracker.showBar and (L["OPT_DISPLAY_BAR"] or "Progress bars") or (L["OPT_DISPLAY_ICON"] or "Icons") return table.concat({ - string.format("|cffffd100%s|r: %s", L["OPT_TRACKER_TYPE"] or "Tracker type", TRACKER_TYPE_VALUES[tracker.trackerType or "normal"] or (L["OPT_TRACKER_TYPE_NORMAL"] or "Normal tracker")), + string.format("|cffffd100%s|r: %s", L["OPT_TRACKER_TYPE"] or "Tracker type", GetTrackerTypeValues()[tracker.trackerType or "normal"] or (L["OPT_TRACKER_TYPE_NORMAL"] or "Normal tracker")), string.format("|cffffd100%s|r: %s", L["OPT_TRACKER_CATEGORIES"] or "Categories", GetTrackerCategoriesSummary(tracker)), string.format("|cffffd100%s|r: %s", L["OPT_STATUS_MODE"] or "Mode", modeLabel), string.format("|cffffd100%s|r: %s", L["OPT_STATUS_DISPLAY"] or "Display", display), @@ -814,7 +827,7 @@ local function BuildGlobalSpellBrowserArgs() end local function BuildTrackerOverviewArgs() - return { + local args = { description = { type = "description", order = 1, @@ -833,22 +846,36 @@ local function BuildTrackerOverviewArgs() return string.format("%s\n\n%s (%d): %s", body, L["OPT_TRACKERS"] or "Tracker Bars", #trackers, table.concat(names, ", ")) end, }, - addTracker = { + } + + local definitions = HMGT.GetTrackerPresetDefinitions and HMGT:GetTrackerPresetDefinitions() or {} + local presetKeys = {} + for presetKey in pairs(definitions) do + presetKeys[#presetKeys + 1] = presetKey + end + table.sort(presetKeys, function(a, b) + return GetPresetLabel(a) < GetPresetLabel(b) + end) + + for index, presetKey in ipairs(presetKeys) do + args["addPreset_" .. presetKey] = { type = "execute", - order = 2, + order = 2 + index, width = "full", - name = L["OPT_ADD_TRACKER"] or "Add tracker", + name = function() + return string.format("%s: %s", L["OPT_ADD_TRACKER"] or "Add tracker", GetPresetLabel(presetKey)) + end, func = function() local nextId = HMGT:GetNextTrackerId() - local tracker = HMGT:CreateTrackerConfig(nextId, { - name = string.format("%s %d", L["OPT_TRACKER"] or "Tracker", nextId), - }) + local tracker = HMGT:BuildTrackerConfigFromPreset(presetKey, nextId) HMGT.db.profile.trackers = HMGT.db.profile.trackers or {} HMGT.db.profile.trackers[#HMGT.db.profile.trackers + 1] = tracker TriggerTrackerUpdate(true) end, - }, - } + } + end + + return args end local function BuildTrackerGroup(trackerId, order) @@ -1008,7 +1035,7 @@ local function BuildTrackerGroup(trackerId, order) width = "full", name = L["OPT_TRACKER_TYPE"] or "Tracker type", desc = L["OPT_TRACKER_TYPE_DESC"] or "Choose whether this tracker uses one shared frame or separate frames per group member.", - values = TRACKER_TYPE_VALUES, + values = GetTrackerTypeValues, get = function() local tracker = s() return (tracker and tracker.trackerType) or "normal" diff --git a/Modules/Tracker/TrackerSync.lua b/Modules/Tracker/TrackerSync.lua index 4823ff6..7f7c7c5 100644 --- a/Modules/Tracker/TrackerSync.lua +++ b/Modules/Tracker/TrackerSync.lua @@ -203,7 +203,7 @@ function HMGT:SendSpellStateSnapshot(snapshot, target, revision) self:DebugScoped( "verbose", - "TrackedSpells", + "TrackerSync", "SendSpellStateSnapshot target=%s spell=%s kind=%s rev=%d a=%.3f b=%.3f c=%.3f d=%.3f", tostring(target and target ~= "" and target or "GROUP"), GetSpellDebugLabel and GetSpellDebugLabel(sid) or tostring(sid), @@ -307,7 +307,7 @@ function HMGT:BroadcastRepairSpellStates() if not self:IsEnabled() then return end local sent = self:SendOwnTrackedSpellStates() if sent > 0 then - self:DebugScoped("verbose", "TrackedSpells", "RepairSpellStates sent=%d", sent) + self:DebugScoped("verbose", "TrackerSync", "RepairSpellStates sent=%d", sent) end end @@ -407,7 +407,7 @@ function HMGT:BroadcastSpellCast(spellId, snapshot) chargeRemaining = math.max(0, tonumber(remaining) or 0) chargeDuration = math.max(0, tonumber(total) or 0) end - self:DebugScoped("verbose", "TrackedSpells", "BroadcastSpellCast spell=%s serverTime=%s charges=%d/%d", + self:DebugScoped("verbose", "TrackerSync", "BroadcastSpellCast spell=%s serverTime=%s charges=%d/%d", GetSpellDebugLabel and GetSpellDebugLabel(spellId) or tostring(spellId), tostring(GetServerTime()), cur, @@ -563,7 +563,7 @@ function HMGT:StoreRemotePlayerInfo(playerName, class, specIndex, talentHash, kn end self:DebugScoped( "info", - "TrackedSpells", + "TrackerSync", "Spielerinfo von %s: class=%s spec=%s bekannteSpells=%d", tostring(playerName), tostring(class), @@ -753,7 +753,7 @@ function HMGT:ApplyRemoteSpellState(playerName, spellId, kind, revision, a, b, c if changed then self:DebugScoped( "info", - "TrackedSpells", + "TrackerSync", "Sync von %s: %s -> %s (rev=%d)", tostring(normalizedName), GetSpellDebugLabel and GetSpellDebugLabel(sid) or tostring(sid), @@ -814,7 +814,7 @@ function HMGT:OnCommReceived(prefix, message, distribution, sender) if (tonumber(protocol) or 0) >= 5 then return end - self:DebugScoped("verbose", "TrackedSpells", "Legacy cast von %s: %s ts=%s", + self:DebugScoped("verbose", "TrackerSync", "Legacy cast von %s: %s ts=%s", tostring(senderName), GetSpellDebugLabel and GetSpellDebugLabel(spellId) or tostring(spellId), tostring(timestamp)) @@ -892,7 +892,7 @@ function HMGT:OnCommReceived(prefix, message, distribution, sender) self:RememberPeerProtocolVersion(senderName, protocol) self:ClearRemoteSpellStateRevisions(senderName) self:StoreRemotePlayerInfo(senderName, class, specIndex, talentHash, knownSpellList) - self:DebugScoped("info", "TrackedSpells", "Hello von %s: class=%s spec=%s spells=%s", + self:DebugScoped("info", "TrackerSync", "Hello von %s: class=%s spec=%s spells=%s", tostring(senderName), tostring(class), tostring(specIndex), tostring(knownSpellList or "")) self:SendSyncResponse(sender) self:TriggerTrackerUpdate() @@ -1003,7 +1003,7 @@ function HMGT:OnCommReceived(prefix, message, distribution, sender) end end end - self:DebugScoped("info", "TrackedSpells", "SyncResponse von %s: cdsApplied=%d", tostring(senderName), applied) + self:DebugScoped("info", "TrackerSync", "SyncResponse von %s: cdsApplied=%d", tostring(senderName), applied) end self:TriggerTrackerUpdate() end -- 2.39.5