Files

288 lines
12 KiB
Lua

-- ClassicAPI supplies cast timing, not incoming heal amounts. Learn our own
-- direct heals and receive explicit-duration vanilla HealComm announcements.
local _G = ShaguTweaks.GetGlobalEnv()
local API = ShaguTweaks.API
local P = { pending = {}, enabled = false }
ShaguTweaks.libpredict = P
local learned, driver, player, sentGUID, sentTarget, sentSpell, activeGUID
local elapsed = 0
local equipment, equipmentReady = {}, false
local sharedHooks = {}
local hotAmounts, hotUnits = {}, {}
local trackedUnits = {"player", "target"}
local function HotTotal(unit)
local state = hotUnits[unit]
if not state or state.guid ~= UnitGUID(unit) then return 0 end
local total, now = 0, GetTime()
for _, hot in ipairs(state.entries) do if hot.expires > now then total = total + hot.amount end end
return total
end
local function HotPending()
local now = GetTime()
for _, state in pairs(hotUnits) do
for _, hot in ipairs(state.entries) do if hot.expires > now then return true end end
end
return false
end
local function EquipmentChanged()
if not _G.GetInventoryItemLink then return false end
local changed = false
for slot = 1, 19 do
local link = GetInventoryItemLink("player", slot)
if equipment[slot] ~= link then changed = true end
equipment[slot] = link
end
local ready = equipmentReady
equipmentReady = true
return ready and changed
end
local function Notify(target) if P.changed then P.changed(target) end end
local function Expire()
elapsed = elapsed + (arg1 or 0)
if elapsed < .1 then return end
elapsed = 0
local now, any = GetTime(), false
for target, senders in pairs(P.pending) do
local changed, remaining = false, false
for sender, heal in pairs(senders) do
if heal.expires <= now then senders[sender] = nil; changed = true
else remaining = true; any = true end
end
if not remaining then P.pending[target] = nil end
if changed then Notify(target) end
end
for _, state in pairs(hotUnits) do
for i = table.getn(state.entries), 1, -1 do
if state.entries[i].expires <= now then
table.remove(state.entries, i)
Notify(state.name)
else any = true end
end
end
if not any then driver:SetScript("OnUpdate", nil) end
end
function P:HealStop(sender)
if not sender then return end
for target, senders in pairs(self.pending) do
if senders[sender] then
senders[sender] = nil
if not next(senders) then self.pending[target] = nil end
Notify(target)
end
end
if driver and not next(self.pending) and not HotPending() then driver:SetScript("OnUpdate", nil) end
end
function P:Heal(sender, target, amount, milliseconds)
amount, milliseconds = tonumber(amount), tonumber(milliseconds)
if not self.enabled or self.external or not sender or not target or target == "" or not amount or not milliseconds
or amount <= 0 or amount > 10000000 or milliseconds <= 0 or milliseconds > 600000 then return end
self:HealStop(sender)
local senders = self.pending[target]
if not senders then senders = {}; self.pending[target] = senders end
senders[sender] = { amount = amount, expires = GetTime() + milliseconds / 1000 }
Notify(target)
elapsed = 0
driver:SetScript("OnUpdate", Expire)
end
function P:UnitGetIncomingHeals(unit)
if not self.enabled or not UnitExists(unit) or UnitIsDeadOrGhost(unit) then return 0 end
if self.external then
local guid = UnitGUID(unit)
local amount = guid and self.external.GetIncomingHealing(guid)
return type(amount) == "number" and amount > 0 and amount or 0
end
local senders = self.pending[UnitName(unit)]
local sum, now = HotTotal(unit), GetTime()
if senders then
for _, heal in pairs(senders) do if heal.expires > now then sum = sum + heal.amount end end
end
return sum
end
local function RefreshHots(unit)
if unit ~= "player" and unit ~= "target" then return end
local state = hotUnits[unit]
if not state then state = {entries={}}; hotUnits[unit] = state end
state.guid, state.name = UnitGUID(unit), UnitName(unit)
local auras, count, now = _G.C_UnitAuras, 0, GetTime()
if auras and UnitExists(unit) and not UnitIsDeadOrGhost(unit) and UnitCanAssist("player", unit) then
for index = 1, 48 do
local name, duration, expires, source, spell, guid
if type(auras.UnitBuff) == "function" then
local n, _, _, _, d, e, s, _, _, id = auras.UnitBuff(unit, index)
name, duration, expires, source, spell = n, d, e, s, id
guid = source and UnitGUID(source)
end
if (not name or not guid) and type(auras.GetAuraDataByIndex) == "function" then
local aura = auras.GetAuraDataByIndex(unit, index, "HELPFUL")
if aura then
name, duration, expires, spell = aura.name, aura.duration, aura.expirationTime, aura.spellId
guid = aura.sourceGUID or (aura.sourceUnit and UnitGUID(aura.sourceUnit))
end
end
if not name then break end
local byCaster = guid and hotAmounts[guid]
local amount = byCaster and byCaster[spell]
if amount and expires and expires > now then
count = count + 1
local hot = state.entries[count] or {}
state.entries[count] = hot
hot.amount, hot.expires = amount, expires
end
end
end
for index = table.getn(state.entries), count+1, -1 do state.entries[index] = nil end
-- Lua 5.0 caches the length used by table.remove; direct slot reuse must
-- update it explicitly, or an expired/refreshed HoT can leave stale entries.
table.setn(state.entries, count)
Notify(state.name)
if count > 0 then elapsed = 0; driver:SetScript("OnUpdate", Expire) end
end
local function TryShared()
local shared = _G.PTHealPredict
if type(shared) ~= "table" or type(shared.GetIncomingHealing) ~= "function"
or type(shared.HookUpdates) ~= "function" then return false end
-- Puppeteer uses SuperWoW GUIDs; never hand it incompatible identifiers.
local _, guid = UnitExists("player")
if not guid or guid ~= UnitGUID("player") then return false end
P.external, P.source = shared, "Puppeteer"
learned, activeGUID, sentGUID, sentTarget, sentSpell = nil, nil, nil, nil, nil
if driver then driver:UnregisterAllEvents(); driver:SetScript("OnUpdate", nil) end
for name in pairs(P.pending) do P.pending[name] = nil end
hotUnits, hotAmounts = {}, {}
if not sharedHooks[shared] then
shared.HookUpdates(function(changedGUID)
if not P.enabled or P.external ~= shared then return end
for _, unit in ipairs(trackedUnits) do
if UnitGUID(unit) == changedGUID then Notify(UnitName(unit)) end
end
end)
sharedHooks[shared] = true
end
Notify(UnitName("player")); Notify(UnitName("target"))
return true
end
local function Broadcast(message)
if _G.AceLibrary and AceLibrary.HasInstance and AceLibrary:HasInstance("HealComm-1.0") then return end
local channel = GetNumRaidMembers() > 0 and "RAID" or GetNumPartyMembers() > 0 and "PARTY"
if channel then SendAddonMessage("HealComm", message, channel) end
end
local function StopOwn(guid)
if guid and activeGUID and guid ~= activeGUID then return end
P:HealStop(player)
if activeGUID then Broadcast("Healstop") end
activeGUID = nil
end
local function RefreshOwn()
if not sentSpell or not learned[sentSpell] then return end
local name, _, _, startTime, endTime = API.UnitCastingInfo("player")
if not name or not startTime or not endTime then return end
local remaining = endTime - GetTime() * 1000
if remaining <= 0 then return end
activeGUID = sentGUID
P:Heal(player, sentTarget or player, learned[sentSpell], remaining)
Broadcast("Heal/" .. (sentTarget or player) .. "/" .. learned[sentSpell] .. "/" .. math.floor(remaining) .. "/")
end
function P:ParseComm(sender, message)
if type(sender) ~= "string" or type(message) ~= "string" then return end
if message == "Healstop" or message == "GrpHealstop" or message == "001F" then self:HealStop(sender); return end
local _, _, target, amount, duration = string.find(message, "^Heal/([^/]+)/(%d+)/(%d+)/$")
if target then self:Heal(sender, target, amount, duration); return end
local _, _, delay = string.find(message, "^Healdelay/(%d+)/$")
delay = tonumber(delay)
if delay and delay <= 60000 then
for name, senders in pairs(self.pending) do
if senders[sender] then senders[sender].expires = senders[sender].expires + delay/1000; Notify(name) end
end
end
end
local function Reset()
for target in pairs(P.pending) do P.pending[target] = nil; Notify(target) end
for unit, state in pairs(hotUnits) do hotUnits[unit] = nil; Notify(state.name) end
if driver then driver:SetScript("OnUpdate", nil) end
activeGUID, sentGUID, sentTarget, sentSpell = nil, nil, nil, nil
end
local function OnEvent()
if event == "ADDON_LOADED" then
TryShared()
elseif event == "UNIT_AURA" then
RefreshHots(arg1)
elseif event == "PLAYER_TARGET_CHANGED" then
RefreshHots("target")
elseif event == "CHAT_MSG_ADDON" then
if arg1 == "HealComm" and (arg3 == "PARTY" or arg3 == "RAID" or arg3 == "BATTLEGROUND") then P:ParseComm(arg4, arg2) end
elseif event == "COMBAT_LOG_EVENT_UNFILTERED" then
if arg2 == "SPELL_PERIODIC_HEAL" and arg4 and arg8 and arg12 and tonumber(arg15)
and tonumber(arg15) > 0 and arg18 ~= true and arg18 ~= 1 then
-- Learn only ticks landing on one of the two displayed units. Keep
-- direct and periodic amounts separate (Regrowth uses both).
if arg8 == UnitGUID("player") or arg8 == UnitGUID("target") then
local amounts = hotAmounts[arg4] or {}
hotAmounts[arg4] = amounts
amounts[arg12] = tonumber(arg15)
if arg8 == UnitGUID("player") then RefreshHots("player") end
if arg8 == UnitGUID("target") then RefreshHots("target") end
end
return
end
if arg2 == "SPELL_HEAL" and arg4 == UnitGUID("player") and arg12 and tonumber(arg15)
and tonumber(arg15) > 0 and arg18 ~= true and arg18 ~= 1 then learned[arg12] = tonumber(arg15) end
elseif event == "PLAYER_ENTERING_WORLD" then
if TryShared() then return end
player = UnitName("player"); Reset()
if EquipmentChanged() then for spell in pairs(learned) do learned[spell] = nil end end
RefreshHots("player"); RefreshHots("target")
elseif event == "UNIT_INVENTORY_CHANGED" or event == "PLAYER_LEVEL_UP" or event == "CHARACTER_POINTS_CHANGED" then
if event ~= "UNIT_INVENTORY_CHANGED" or (arg1 == "player" and EquipmentChanged()) then
for spell in pairs(learned) do learned[spell] = nil end
local guid = UnitGUID("player")
if guid then hotAmounts[guid] = nil end
StopOwn()
RefreshHots("player"); RefreshHots("target")
end
elseif arg1 == "player" then
if event == "UNIT_SPELLCAST_SENT" then
sentTarget = arg2 and arg2 ~= "" and (UnitExists(arg2) and UnitName(arg2) or arg2) or player
sentGUID, sentSpell = arg3, arg4
elseif event == "UNIT_SPELLCAST_START" or event == "UNIT_SPELLCAST_DELAYED" then
if arg2 == sentGUID then RefreshOwn() end
elseif event == "UNIT_SPELLCAST_STOP" or event == "UNIT_SPELLCAST_FAILED"
or event == "UNIT_SPELLCAST_INTERRUPTED" or event == "UNIT_SPELLCAST_SUCCEEDED" then StopOwn(arg2) end
end
end
function P:Enable()
if self.enabled then return end
self.enabled = true
if TryShared() then return end
self.source = "ClassicAPI + HealComm"
player = UnitName("player")
learned = {} -- Session-only: actual gear/talent/level changes invalidate estimates.
if EquipmentChanged() then
local guid = UnitGUID("player")
if guid then hotAmounts[guid] = nil end
end
driver = driver or CreateFrame("Frame")
driver:SetScript("OnEvent", OnEvent)
driver:RegisterEvent("PLAYER_ENTERING_WORLD")
driver:RegisterEvent("ADDON_LOADED")
driver:RegisterEvent("PLAYER_TARGET_CHANGED")
API.RegisterUnitEvent(driver,"UNIT_AURA","player","target")
driver:RegisterEvent("CHAT_MSG_ADDON")
driver:RegisterEvent("UNIT_INVENTORY_CHANGED")
-- Form changes can produce SPELLS_CHANGED without changing any heal amount.
-- New spell ranks have separate spell IDs and learn independently.
driver:RegisterEvent("PLAYER_LEVEL_UP")
driver:RegisterEvent("CHARACTER_POINTS_CHANGED")
local native = {"UNIT_SPELLCAST_SENT", "UNIT_SPELLCAST_START", "UNIT_SPELLCAST_DELAYED",
"UNIT_SPELLCAST_STOP", "UNIT_SPELLCAST_SUCCEEDED", "UNIT_SPELLCAST_FAILED", "UNIT_SPELLCAST_INTERRUPTED",
"COMBAT_LOG_EVENT_UNFILTERED"}
for _, name in ipairs(native) do if _G.C_EventUtils.IsEventValid(name) then driver:RegisterEvent(name) end end
elapsed = 0
RefreshHots("player"); RefreshHots("target")
end
function P:Disable()
self.enabled = false
self.external = nil
if driver then driver:UnregisterAllEvents() end
Reset()
end