feat: Add Personal Auras module for tracking debuffs on the player

- Introduced a new module for Personal Auras that allows players to track selected debuffs on themselves in a movable frame.
- Implemented functionality to manage tracked debuffs, including adding and removing spells.
- Added options for configuring the appearance and behavior of the Personal Auras frame.
- Updated the readme to include information about the new Personal Auras feature.
This commit is contained in:
Torsten Brendgen
2026-04-12 00:04:34 +02:00
parent 01eeae9603
commit 391e581d32
11 changed files with 1034 additions and 5 deletions

View File

@@ -0,0 +1,474 @@
-- 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