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