-- Modules/AuraExpiry/AuraExpiry.lua -- Announces tracked buffs in SAY shortly before they expire. local ADDON_NAME = "HailMaryGuildTools" local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) if not HMGT then return end local L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME, true) or {} local BEA = HMGT:NewModule("AuraExpiry") HMGT.AuraExpiry = BEA HMGT.BuffEndingAnnouncer = BEA BEA.runtimeEnabled = false BEA.eventFrame = nil BEA.ticker = nil BEA.lastAnnouncedSecond = {} BEA.recentOwnCastAt = {} local function NormalizeThreshold(value, fallback) local threshold = tonumber(value) if not threshold then threshold = tonumber(fallback) or 5 end threshold = math.floor(threshold + 0.5) if threshold < 1 then threshold = 1 end if threshold > 30 then threshold = 30 end return threshold end local function GetSpellName(spellId) local sid = tonumber(spellId) if not sid or sid <= 0 then return nil end if C_Spell and type(C_Spell.GetSpellName) == "function" then local name = C_Spell.GetSpellName(sid) if type(name) == "string" and name ~= "" then return name end end if type(GetSpellInfo) == "function" then local name = GetSpellInfo(sid) if type(name) == "string" and name ~= "" then return name end end return nil end local function IsAuraSourcePlayer(sourceUnit) if not sourceUnit then return nil end if sourceUnit == "player" then return true end if type(UnitIsUnit) == "function" and type(UnitExists) == "function" and UnitExists(sourceUnit) then return UnitIsUnit(sourceUnit, "player") and true or false end return false end local function GetPlayerBuffExpiration(spellId) local sid = tonumber(spellId) if not sid or sid <= 0 then return nil, nil, nil, nil end if C_UnitAuras and type(C_UnitAuras.GetPlayerAuraBySpellID) == "function" then local aura = C_UnitAuras.GetPlayerAuraBySpellID(sid) if aura then local exp = tonumber(aura.expirationTime) or 0 if exp > 0 then local isOwnCaster = IsAuraSourcePlayer(aura.sourceUnit) if isOwnCaster == nil and aura.isFromPlayerOrPlayerPet ~= nil then isOwnCaster = aura.isFromPlayerOrPlayerPet and true or false end return exp, tonumber(aura.duration) or 0, aura.name, isOwnCaster end end end if AuraUtil and type(AuraUtil.FindAuraBySpellID) == "function" then local name, _, _, _, duration, expirationTime, sourceUnit, _, _, _, _, _, castByPlayer = AuraUtil.FindAuraBySpellID(sid, "player", "HELPFUL") local exp = tonumber(expirationTime) or 0 if name and exp > 0 then local isOwnCaster = IsAuraSourcePlayer(sourceUnit) if isOwnCaster == nil and castByPlayer ~= nil then isOwnCaster = castByPlayer and true or false end return exp, tonumber(duration) or 0, name, isOwnCaster end end return nil, nil, nil, nil end local function GetPlayerChannelExpiration(spellId) local sid = tonumber(spellId) if not sid or sid <= 0 then return nil, nil, nil, nil end if type(UnitChannelInfo) ~= "function" then return nil, nil, nil, nil end local name, _, _, startTimeMS, endTimeMS, _, _, channelSpellId = UnitChannelInfo("player") local activeSpellId = tonumber(channelSpellId) if activeSpellId ~= sid then return nil, nil, nil, nil end local startTime = tonumber(startTimeMS) or 0 local endTime = tonumber(endTimeMS) or 0 if endTime <= 0 or endTime <= startTime then return nil, nil, nil, nil end return endTime / 1000, (endTime - startTime) / 1000, name, true end local function GetTrackedSpellExpiration(spellId) local expirationTime, duration, name, isOwnCaster = GetPlayerBuffExpiration(spellId) if expirationTime and expirationTime > 0 then return expirationTime, duration, name, isOwnCaster, "aura" end expirationTime, duration, name, isOwnCaster = GetPlayerChannelExpiration(spellId) if expirationTime and expirationTime > 0 then return expirationTime, duration, name, isOwnCaster, "channel" end return nil, nil, nil, nil, nil end function BEA:GetSettings() local p = HMGT.db and HMGT.db.profile if not p then return nil end p.buffEndingAnnouncer = p.buffEndingAnnouncer or {} p.buffEndingAnnouncer.announceAtSec = NormalizeThreshold(p.buffEndingAnnouncer.announceAtSec, 5) p.buffEndingAnnouncer.trackedBuffs = p.buffEndingAnnouncer.trackedBuffs or {} return p.buffEndingAnnouncer end function BEA:GetDefaultThreshold() local s = self:GetSettings() return NormalizeThreshold(s and s.announceAtSec, 5) end function BEA:GetTrackedBuffThreshold(spellId) local sid = tonumber(spellId) if not sid or sid <= 0 then return nil end local settings = self:GetSettings() local tracked = settings and settings.trackedBuffs local value = tracked and tracked[sid] if value == nil then return nil end if type(value) == "table" then value = value.threshold end if value == true then value = self:GetDefaultThreshold() end return NormalizeThreshold(value, self:GetDefaultThreshold()) end function BEA:SetTrackedBuffThreshold(spellId, threshold) local sid = tonumber(spellId) if not sid or sid <= 0 then return false end local s = self:GetSettings() s.trackedBuffs[sid] = NormalizeThreshold(threshold, self:GetDefaultThreshold()) self.lastAnnouncedSecond[sid] = nil self:Refresh() return true end function BEA:GetTrackedBuffEntries() local entries = {} for _, sid in ipairs(self:GetTrackedBuffSpellIds()) do entries[#entries + 1] = { spellId = sid, name = GetSpellName(sid) or ("Spell " .. tostring(sid)), threshold = self:GetTrackedBuffThreshold(sid) or self:GetDefaultThreshold(), } end return entries end function BEA:GetTrackedBuffSpellIds() local ids = {} local settings = self:GetSettings() local tracked = (settings and settings.trackedBuffs) or {} for sid, value in pairs(tracked) do local id = tonumber(sid) if id and id > 0 and value ~= nil and value ~= false then ids[#ids + 1] = id end end table.sort(ids, function(a, b) local nameA = tostring(GetSpellName(a) or a):lower() local nameB = tostring(GetSpellName(b) or b):lower() if nameA == nameB then return a < b end return nameA < nameB end) return ids end function BEA:ResetAnnouncements() for sid in pairs(self.lastAnnouncedSecond) do self.lastAnnouncedSecond[sid] = nil end end function BEA:ResetRecentOwnCasts() for sid in pairs(self.recentOwnCastAt) do self.recentOwnCastAt[sid] = nil end end function BEA:StopTicker() if self.ticker then self.ticker:Cancel() self.ticker = nil end end function BEA:HasTrackedBuffsConfigured() return #self:GetTrackedBuffSpellIds() > 0 end function BEA:UpdateRuntimeEventRegistrations() if not self.eventFrame then return end self.eventFrame:UnregisterEvent("UNIT_AURA") self.eventFrame:UnregisterEvent("UNIT_SPELLCAST_SUCCEEDED") self.eventFrame:UnregisterEvent("UNIT_SPELLCAST_CHANNEL_START") self.eventFrame:UnregisterEvent("UNIT_SPELLCAST_CHANNEL_UPDATE") self.eventFrame:UnregisterEvent("UNIT_SPELLCAST_CHANNEL_STOP") if not self.runtimeEnabled or not self:HasTrackedBuffsConfigured() then return end self.eventFrame:RegisterUnitEvent("UNIT_AURA", "player") self.eventFrame:RegisterUnitEvent("UNIT_SPELLCAST_SUCCEEDED", "player") self.eventFrame:RegisterUnitEvent("UNIT_SPELLCAST_CHANNEL_START", "player") self.eventFrame:RegisterUnitEvent("UNIT_SPELLCAST_CHANNEL_UPDATE", "player") self.eventFrame:RegisterUnitEvent("UNIT_SPELLCAST_CHANNEL_STOP", "player") end function BEA:EnsureTicker() if self.ticker then return end self.ticker = C_Timer.NewTicker(0.1, function() self:OnTicker() end) end function BEA:IsProfileEnabled() local s = self:GetSettings() return s and s.enabled == true end function BEA:BuildAnnouncement(spellName, secondsLeft) local template = L["BEA_MSG_TEMPLATE"] or "%s ending in %d" return string.format(template, tostring(spellName or "?"), tonumber(secondsLeft) or 0) end function BEA:IsOwnBuffCaster(spellId, isOwnCaster, duration, now) if isOwnCaster == true then return true end if isOwnCaster == false then return false end -- Fallback when aura source information is missing: -- only trust a very recent own cast of the tracked spell. local castAt = self.recentOwnCastAt[tonumber(spellId)] if not castAt then return false end local maxAge = tonumber(duration) or 0 if maxAge < 3 then maxAge = 3 end if maxAge > 12 then maxAge = 12 end return (tonumber(now) or GetTime()) - castAt <= maxAge end function BEA:EvaluateTrackedBuffs() if not self:IsProfileEnabled() then self:ResetAnnouncements() return false end local ids = self:GetTrackedBuffSpellIds() if #ids == 0 then self:ResetAnnouncements() return false end local now = GetTime() local hasAnyTrackedBuff = false for _, sid in ipairs(ids) do local threshold = self:GetTrackedBuffThreshold(sid) local expirationTime, duration, auraName, isOwnCaster, stateKind = GetTrackedSpellExpiration(sid) if expirationTime and expirationTime > now then local isOwnSource = (stateKind == "channel") or self:IsOwnBuffCaster(sid, isOwnCaster, duration, now) if isOwnSource then hasAnyTrackedBuff = true local remaining = expirationTime - now if threshold and remaining > 0 and remaining <= threshold then local second = math.ceil(remaining - 0.0001) if second < 1 then second = 1 end if self.lastAnnouncedSecond[sid] ~= second then local msg = self:BuildAnnouncement(auraName or GetSpellName(sid) or ("Spell " .. tostring(sid)), second) SendChatMessage(msg, "SAY") self.lastAnnouncedSecond[sid] = second end else self.lastAnnouncedSecond[sid] = nil end else self.lastAnnouncedSecond[sid] = nil end else self.lastAnnouncedSecond[sid] = nil end local castAt = self.recentOwnCastAt[sid] if castAt and (now - castAt) > 30 then self.recentOwnCastAt[sid] = nil end end return hasAnyTrackedBuff end function BEA:Refresh() if not self.runtimeEnabled then return end self:UpdateRuntimeEventRegistrations() local active = self:EvaluateTrackedBuffs() if active then self:EnsureTicker() else self:StopTicker() end end function BEA:OnTicker() if not self.runtimeEnabled then self:StopTicker() return end local active = self:EvaluateTrackedBuffs() if not active then self:StopTicker() end end function BEA:OnEvent(event, ...) if event == "UNIT_AURA" then local unit = ... if unit ~= "player" then return end elseif event == "UNIT_SPELLCAST_SUCCEEDED" then local unit, _, spellId = ... if unit == "player" then local sid = tonumber(spellId) if sid and sid > 0 then local s = self:GetSettings() if s and s.trackedBuffs and s.trackedBuffs[sid] then self.recentOwnCastAt[sid] = GetTime() end end end elseif event == "UNIT_SPELLCAST_CHANNEL_START" or event == "UNIT_SPELLCAST_CHANNEL_UPDATE" or event == "UNIT_SPELLCAST_CHANNEL_STOP" then local unit = ... if unit ~= "player" then return end elseif event == "PLAYER_ENTERING_WORLD" or event == "PLAYER_DEAD" then self:ResetAnnouncements() self:ResetRecentOwnCasts() end self:Refresh() end function BEA:StartRuntime() if not self:IsProfileEnabled() then return end if self.runtimeEnabled then self:Refresh() return end self.runtimeEnabled = true if not self.eventFrame then self.eventFrame = CreateFrame("Frame") self.eventFrame:SetScript("OnEvent", function(_, event, ...) self:OnEvent(event, ...) end) end self.eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD") self.eventFrame:RegisterEvent("PLAYER_DEAD") self:Refresh() end function BEA:StopRuntime() if self.eventFrame then self.eventFrame:UnregisterEvent("UNIT_AURA") self.eventFrame:UnregisterEvent("UNIT_SPELLCAST_SUCCEEDED") self.eventFrame:UnregisterEvent("UNIT_SPELLCAST_CHANNEL_START") self.eventFrame:UnregisterEvent("UNIT_SPELLCAST_CHANNEL_UPDATE") self.eventFrame:UnregisterEvent("UNIT_SPELLCAST_CHANNEL_STOP") self.eventFrame:UnregisterEvent("PLAYER_ENTERING_WORLD") self.eventFrame:UnregisterEvent("PLAYER_DEAD") end self.runtimeEnabled = false self:StopTicker() self:ResetAnnouncements() self:ResetRecentOwnCasts() end function BEA:OnInitialize() HMGT.AuraExpiry = self HMGT.BuffEndingAnnouncer = self end function BEA:OnEnable() self:StartRuntime() end function BEA:OnDisable() self:StopRuntime() end function BEA:AddTrackedBuff(spellId, threshold) local sid = tonumber(spellId) if not sid or sid <= 0 then return false, "invalid" end local name = GetSpellName(sid) if not name then return false, "invalid" end local s = self:GetSettings() s.trackedBuffs[sid] = NormalizeThreshold(threshold, self:GetDefaultThreshold()) self.lastAnnouncedSecond[sid] = nil self:Refresh() return true, name end function BEA:RemoveTrackedBuff(spellId) local sid = tonumber(spellId) if not sid or sid <= 0 then return false, "invalid" end local s = self:GetSettings() if not s.trackedBuffs[sid] then return false, "missing" end s.trackedBuffs[sid] = nil self.lastAnnouncedSecond[sid] = nil self:Refresh() return true, GetSpellName(sid) or tostring(sid) end