mirror of
https://github.com/brues-code/pfUI.git
synced 2026-09-22 07:36:56 +00:00
f497c71523
Eight commits off classicapi_next. ClassicAPI's RegisterUnitEvent registers for an event but only delivers it when arg1 is one of the given units, so a handler for one unit stops waking for every other one in the world. The 26 registrations whose unit set is fixed at registration time now name it. The rule throughout is register the superset and keep the handler's own check -- the filter narrows what arrives, it does not decide what to act on. Guards that look unreachable stay put: the filter applies only when arg1 is a string, so an event that fires with a number or no argument is delivered as if plainly registered. Frames whose unit changes at runtime own their subscriptions instead of sorting events out per event. unitframes points them at the unitstr UpdateVisibility already computes -- replacing a string concat, and on a miss a second concat plus a UnitGUID call, for every frame on every unit event in the world -- and a frame that is not in use drops its unit events entirely. nameplates registers per plate against the plate's own token, which is also the only workable shape: slots have no cap, so any nameplate1..N list would have been a guess that fails in exactly the crowded scenes where plates matter. marktracking names mark1 through mark8. A registration keeps its kind, so none of these can be plain-registered first. Both teardown paths PLAYER_LOGOUT guards -- the crash 132 -- now cover the per-frame subscriptions: plates tear down rather than dispatching through logout, and a unit frame takes itself off the visibility scan so it cannot re-register what it just dropped. marktracking also drops its once-a-second full rebuild, which ran for the whole session whether or not a marker existed anywhere. The ticker is created and cancelled with group membership. It is deliberately not keyed on a mark being visible -- a marker on an out-of-range unit shows no row, and that is the case the poll exists to catch. nameplates gates the per-plate update against the floor across all four throttle categories before classifying it, instead of running a GetAlpha, a castbar IsShown, a cast lookup and up to two libthrottle:Get resolutions on plates throttled to 10fps that were going to return anyway. Nothing that would have updated can be turned away by a floor. The four throttles resolve in CacheConfig, where config changes already land. energytick sweeps the clock the server actually runs. There is one regen timer for every power, re-armed every 2s by Player::RegenerateAll and never touched by casting; the five-second rule changes what a tick pays, not when it lands. The sweep is a free-running phase lock on that clock, so Illumination refunds, potions and a Mana Spring totem on its own phase no longer snap the spark mid-cycle, and an 80ms band keeps a correct tick from hitching it at the wrap. The FSR window shades rather than predicting a share of spirit the client cannot compute -- the Casting Regen item ladder is equip auras absent from the buff list. The energy period is summed from SPELL_AURA_MOD_ENERGY_REGEN_TIME across the spellbook and buffs, so Blade Rush is found without GetTalentInfo(2, 16), an ordinal that does not fail when the tree changes but reads another talent's rank. macrotweak is gone -- ClassicAPI 1.15 covers it -- with its config entry, its GUI block, its translations in all eight locales, and actionbar's ButtonMacroScan, the #showtooltip scanner that fed it.
1322 lines
48 KiB
Lua
1322 lines
48 KiB
Lua
-- load pfUI environment
|
|
setfenv(1, pfUI:GetEnvironment())
|
|
|
|
--[[ libpredict ]]--
|
|
-- A pfUI library that detects, receives and sends heal and resurrection predictions.
|
|
-- Healing predictions are done by caching the last known "normal" heal value of the
|
|
-- spell when last being used. Those chaches are cleared when new talents are detected.
|
|
-- The API provides function calls similar to later WoW expansions such as:
|
|
-- UnitGetIncomingHeals(unit)
|
|
-- UnitHasIncomingResurrection(unit)
|
|
--
|
|
-- The library is able to receive and send compatible messages to HealComm
|
|
-- including resurrections. It has an option to disable the sending of those
|
|
-- messages in case HealComm is already active.
|
|
--
|
|
-- HOT TRACKING INTEGRATION (NEW):
|
|
-- With Nampower enabled, HoT tracking now primarily uses libdebuff's AURA_CAST
|
|
-- event system for accurate server-side buff/debuff tracking with full rank
|
|
-- protection. GetHotDuration() first checks libdebuff, then falls back to the
|
|
-- legacy prediction system for backwards compatibility with non-Nampower clients.
|
|
-- This provides:
|
|
-- - Accurate duration from server (no prediction needed)
|
|
-- - Automatic rank protection (lower ranks won't overwrite higher ranks)
|
|
-- - Support for multiple casters of same HoT on one target
|
|
-- - Zero event overhead (libdebuff already tracks all auras)
|
|
|
|
-- return instantly when another libpredict is already active
|
|
if pfUI.api.libpredict then return end
|
|
|
|
-- Check if libdebuff integration is available
|
|
local libdebuff_available = (pfUI.api.libdebuff and pfUI.api.libdebuff.GetBestAuraCast) and true or false
|
|
|
|
-- Check if Nampower is available for SPELL_FAILED events
|
|
|
|
local senttarget
|
|
local heals, ress, events, hots = {}, {}, {}, {}
|
|
local spell_queue = { "DUMMY", "DUMMYRank 9", "TARGET" }
|
|
local player = UnitName("player")
|
|
local cache, gear_string = {}, ""
|
|
local foreignCache = {} -- [casterName][spellKey] = amount, in-memory cache for other healers
|
|
local rejuvDuration, renewDuration = 12, 15 --default durations
|
|
local ressGuidToName = {} -- [casterGuid] = casterName, for SPELL_FAILED_OTHER cleanup
|
|
local healGuidToName = {} -- [casterGuid] = casterName, for SPELL_FAILED_OTHER cleanup
|
|
local ress_timers = {} -- [target][sender] = expiry_timestamp (60s rez window)
|
|
local RESS_TIMEOUT = 60 -- Vanilla: rez offer expires after 60s
|
|
|
|
-- Localized spell names resolved once from canonical rank-1 spellIDs.
|
|
-- Every rank shares the same name, so per-rank comparisons elsewhere can
|
|
-- be done against these constants without per-locale or per-rank tables.
|
|
local PRAYER_OF_HEALING = C_Spell.GetSpellName(596) -- Prayer of Healing (Rank 1)
|
|
local REJUVENATION = C_Spell.GetSpellName(774) -- Rejuvenation (Rank 1)
|
|
local RENEW = C_Spell.GetSpellName(139) -- Renew (Rank 1)
|
|
local REGROWTH = C_Spell.GetSpellName(8936) -- Regrowth (Rank 1)
|
|
|
|
local libpredict = CreateFrame("Frame")
|
|
libpredict:RegisterEvent("UNIT_HEALTH")
|
|
libpredict:RegisterEvent("CHAT_MSG_ADDON")
|
|
libpredict:RegisterEvent("PLAYER_TARGET_CHANGED")
|
|
libpredict:RegisterEvent("PLAYER_LOGOUT")
|
|
|
|
libpredict:SetScript("OnEvent", function()
|
|
-- Handle shutdown to prevent crash 132
|
|
if event == "PLAYER_LOGOUT" then
|
|
this:UnregisterAllEvents()
|
|
this:SetScript("OnEvent", nil)
|
|
return
|
|
end
|
|
|
|
if event == "CHAT_MSG_ADDON" and (arg1 == "HealComm" or arg1 == "CTRA") then
|
|
this:ParseChatMessage(arg4, arg2, arg1)
|
|
elseif event == "UNIT_HEALTH" then
|
|
local name = UnitName(arg1)
|
|
if name and ress[name] and not UnitIsDeadOrGhost(arg1) then
|
|
ress[name] = nil
|
|
end
|
|
end
|
|
end)
|
|
|
|
-- GUID->Name cache for dead players (UnitExists/UnitGUID return nil for corpses)
|
|
local guidNameCache = {} -- [guid] = name
|
|
|
|
local function resolveNameFromGuid(guid)
|
|
if not guid then return nil end
|
|
if guidNameCache[guid] then return guidNameCache[guid] end
|
|
-- UnitExists(guid) reverse lookup not reliable; cache is primary source
|
|
return nil
|
|
end
|
|
|
|
-- Register with libdebuff hooks
|
|
local function cacheRaidNames()
|
|
-- Cache all raid/party members while they are still reachable
|
|
for i = 1, GetNumRaidMembers() do
|
|
local unit = "raid" .. i
|
|
local guid = UnitGUID(unit)
|
|
local name = UnitName(unit)
|
|
if guid and name then guidNameCache[guid] = name end
|
|
end
|
|
for i = 1, GetNumPartyMembers() do
|
|
local unit = "party" .. i
|
|
local guid = UnitGUID(unit)
|
|
local name = UnitName(unit)
|
|
if guid and name then guidNameCache[guid] = name end
|
|
end
|
|
-- player self
|
|
local pguid = UnitGUID("player")
|
|
if pguid then guidNameCache[pguid] = UnitName("player") end
|
|
end
|
|
|
|
local function isRezSpell(spellId)
|
|
if not L["resurrections"] then return false end
|
|
local spellName = C_Spell.GetSpellName(spellId)
|
|
return spellName and L["resurrections"][spellName]
|
|
end
|
|
|
|
-- Build cache on world enter
|
|
libpredict:RegisterEvent("PLAYER_ENTERING_WORLD")
|
|
libpredict:RegisterEvent("RAID_ROSTER_UPDATE")
|
|
libpredict:RegisterEvent("PARTY_MEMBERS_CHANGED")
|
|
local origOnEvent = libpredict:GetScript("OnEvent")
|
|
libpredict:SetScript("OnEvent", function()
|
|
if event == "PLAYER_ENTERING_WORLD" or event == "RAID_ROSTER_UPDATE" or event == "PARTY_MEMBERS_CHANGED" then
|
|
cacheRaidNames()
|
|
end
|
|
if origOnEvent then origOnEvent() end
|
|
end)
|
|
|
|
-- SPELL_START_SELF: own cast started (heals + rez)
|
|
pfUI.libdebuff_spell_start_self_hooks = pfUI.libdebuff_spell_start_self_hooks or {}
|
|
pfUI.libdebuff_spell_start_self_hooks["libpredict"] = function(spellId, casterGuid, targetGuid, castTime)
|
|
local spellName = C_Spell.GetSpellName(spellId)
|
|
if not spellName then return end
|
|
|
|
local pendingTarget = nil
|
|
local pendingTargetGuid = nil
|
|
local pending = pfUI.libpredict_pending_cast
|
|
if pending and pending.spellName == spellName and pending.targetGuid
|
|
and pending.time and (GetTime() - pending.time) < 1 then
|
|
-- If SPELL_CAST_EVENT target differs from SPELL_START_SELF target,
|
|
-- the client redirected the cast (e.g. offline target → selfcast).
|
|
-- Discard pending and trust the actual targetGuid from SPELL_START_SELF.
|
|
if pending.targetGuid == targetGuid then
|
|
local name = UnitName(pending.targetGuid)
|
|
if name and name ~= UNKNOWNOBJECT and name ~= UKNOWNBEING then
|
|
pendingTarget = name
|
|
pendingTargetGuid = pending.targetGuid
|
|
end
|
|
end
|
|
pending.spellId = nil
|
|
pending.spellName = nil
|
|
pending.targetGuid = nil
|
|
pending.time = nil
|
|
end
|
|
local target = pendingTarget or resolveNameFromGuid(targetGuid) or senttarget or spell_queue[3]
|
|
-- resolved GUID: prefer pendingTargetGuid (mouseover/click-to-cast), fall back to targetGuid
|
|
local resolvedTargetGuid = pendingTargetGuid or targetGuid
|
|
|
|
|
|
libpredict.sender.current_cast = spellName
|
|
libpredict.sender.current_cast_target = target
|
|
|
|
if L["resurrections"][spellName] then
|
|
if target then
|
|
libpredict:Ress(player, target, casterGuid)
|
|
libpredict.sender:SendHealCommMsg("Resurrection/" .. target .. "/start/")
|
|
libpredict.sender:SendResCommMsg("RES " .. target)
|
|
libpredict.sender.resurrecting = true
|
|
end
|
|
return
|
|
end
|
|
|
|
spell_queue[1] = spellName
|
|
spell_queue[2] = spellName .. (C_Spell.GetSpellSubtext(spellId) or "")
|
|
|
|
if spell_queue[1] == spellName and cache[spell_queue[2]] then
|
|
local amount = cache[spell_queue[2]][1]
|
|
local casttime = castTime
|
|
|
|
if spellName == REGROWTH then
|
|
local fullSpell = spell_queue[2]
|
|
local _, _, rankStr = fullSpell and string.find(fullSpell, "Rank (%d+)")
|
|
local rankNum = rankStr and tonumber(rankStr) or nil
|
|
if libpredict.sender.regrowth_timer then
|
|
libpredict.sender.regrowth_target_next = target
|
|
libpredict.sender.regrowth_rank_next = rankNum
|
|
else
|
|
libpredict.sender.regrowth_target = target
|
|
libpredict.sender.regrowth_rank = rankNum
|
|
end
|
|
end
|
|
|
|
if spellName == PRAYER_OF_HEALING then
|
|
-- target is already correctly set from spell_queue[3]:
|
|
-- selfcast (ALT) = player, otherwise = current target
|
|
-- Use this to find the correct group to heal
|
|
local pohTarget = target or player
|
|
if IsInRaid() then
|
|
-- Raid: find pohTarget's subgroup and heal only those members
|
|
-- (Turtle WoW changed PoH to heal the target's group, not the caster's group)
|
|
local targetGroup
|
|
for i = 1, GetNumRaidMembers() do
|
|
local name, _, subgroup = GetRaidRosterInfo(i)
|
|
if name == pohTarget then targetGroup = subgroup break end
|
|
end
|
|
-- Fallback to caster's own group if target not in raid
|
|
if not targetGroup then
|
|
for i = 1, GetNumRaidMembers() do
|
|
local name, _, subgroup = GetRaidRosterInfo(i)
|
|
if name == player then targetGroup = subgroup break end
|
|
end
|
|
end
|
|
if targetGroup then
|
|
for i = 1, GetNumRaidMembers() do
|
|
local name, _, subgroup = GetRaidRosterInfo(i)
|
|
if subgroup == targetGroup then
|
|
libpredict:Heal(player, name, amount, casttime)
|
|
libpredict.sender:SendHealCommMsg("Heal/" .. name .. "/" .. amount .. "/" .. casttime .. "/")
|
|
libpredict.sender.healing = true
|
|
end
|
|
end
|
|
end
|
|
else
|
|
-- Party: heal all party members including self
|
|
libpredict:Heal(player, player, amount, casttime)
|
|
libpredict.sender:SendHealCommMsg("Heal/" .. player .. "/" .. amount .. "/" .. casttime .. "/")
|
|
libpredict.sender.healing = true
|
|
for i = 1, 4 do
|
|
if CheckInteractDistance("party"..i, 4) then
|
|
local pname = UnitName("party"..i)
|
|
libpredict:Heal(player, pname, amount, casttime)
|
|
libpredict.sender:SendHealCommMsg("Heal/" .. pname .. "/" .. amount .. "/" .. casttime .. "/")
|
|
libpredict.sender.healing = true
|
|
end
|
|
end
|
|
end
|
|
return -- skip the generic Heal call below
|
|
end
|
|
|
|
-- If the resolved heal target is itself hostile (e.g. self-cast heal while targeting an enemy),
|
|
-- store the heal under the player so the player frame shows the prediction.
|
|
-- Use resolvedTargetGuid (prefers pendingTargetGuid from mouseover/click-to-cast) so we
|
|
-- don't wrongly fall back to player when healing a friend via mouseover with a foe in target.
|
|
local healTarget = target
|
|
local unitTargetGuid = UnitGUID and UnitGUID("target")
|
|
local healTargetIsHostile = resolvedTargetGuid and unitTargetGuid
|
|
and resolvedTargetGuid == unitTargetGuid
|
|
and UnitExists("target")
|
|
and (UnitCanAttack("player", "target") or not UnitIsFriend("player", "target"))
|
|
if healTargetIsHostile then
|
|
healTarget = player
|
|
end
|
|
libpredict:Heal(player, healTarget, amount, casttime)
|
|
libpredict.sender:SendHealCommMsg("Heal/" .. (healTarget or "") .. "/" .. amount .. "/" .. casttime .. "/")
|
|
libpredict.sender.healing = true
|
|
end
|
|
end
|
|
|
|
-- SPELL_GO_SELF: own cast landed (HealStop + Regrowth timer)
|
|
pfUI.libdebuff_spell_go_hooks["libpredict_sender"] = function(spellId)
|
|
libpredict:HealStop(player)
|
|
local spellName = C_Spell.GetSpellName(spellId)
|
|
if spellName == REGROWTH then
|
|
local now = pfUI.uf.now or GetTime()
|
|
if libpredict.sender.regrowth_timer then
|
|
libpredict.sender.regrowth_start_next = now
|
|
else
|
|
libpredict.sender.regrowth_start = now
|
|
end
|
|
libpredict.sender.regrowth_timer = now + 0.1
|
|
end
|
|
libpredict.sender.current_cast = nil
|
|
libpredict.sender.current_cast_target = nil
|
|
end
|
|
|
|
-- SPELL_START_OTHER: foreign cast started (heals + rez)
|
|
-- Signature: fn(spellId, casterGuid, targetGuid, castTime)
|
|
pfUI.libdebuff_spell_start_other_hooks = pfUI.libdebuff_spell_start_other_hooks or {}
|
|
pfUI.libdebuff_spell_start_other_hooks["libpredict"] = function(spellId, casterGuid, targetGuid, castTime)
|
|
local spellName = C_Spell.GetSpellName(spellId)
|
|
if not spellName then return end
|
|
|
|
local casterName = resolveNameFromGuid(casterGuid)
|
|
if not casterName then return end
|
|
|
|
-- Resurrection cast
|
|
if L["resurrections"][spellName] then
|
|
local targetName = resolveNameFromGuid(targetGuid)
|
|
if targetName then
|
|
libpredict:Ress(casterName, targetName, casterGuid)
|
|
end
|
|
return
|
|
end
|
|
|
|
-- Heal cast: look up cached amount from a previous cast by this healer
|
|
local targetName = resolveNameFromGuid(targetGuid)
|
|
if not targetName then return end
|
|
|
|
local rankStr = C_Spell.GetSpellSubtext(spellId) or ""
|
|
local spellKey = spellName .. (rankStr or "")
|
|
|
|
local amount = foreignCache[casterName] and foreignCache[casterName][spellKey]
|
|
if not amount then return end -- no data yet, skip until we have a real value
|
|
|
|
-- Prayer of Healing: heal entire subgroup of the target
|
|
if spellName == PRAYER_OF_HEALING then
|
|
if IsInRaid() then
|
|
local targetGroup
|
|
for i = 1, GetNumRaidMembers() do
|
|
local rname, _, subgroup = GetRaidRosterInfo(i)
|
|
if rname == targetName then targetGroup = subgroup break end
|
|
end
|
|
if not targetGroup then
|
|
for i = 1, GetNumRaidMembers() do
|
|
local rname, _, subgroup = GetRaidRosterInfo(i)
|
|
if rname == casterName then targetGroup = subgroup break end
|
|
end
|
|
end
|
|
if targetGroup then
|
|
for i = 1, GetNumRaidMembers() do
|
|
local rname, _, subgroup = GetRaidRosterInfo(i)
|
|
if subgroup == targetGroup then
|
|
libpredict:Heal(casterName, rname, amount, castTime, casterGuid)
|
|
end
|
|
end
|
|
end
|
|
else
|
|
-- Party: heal self + all members
|
|
libpredict:Heal(casterName, casterName, amount, castTime, casterGuid)
|
|
for i = 1, 4 do
|
|
local pname = UnitName("party" .. i)
|
|
if pname then
|
|
libpredict:Heal(casterName, pname, amount, castTime, casterGuid)
|
|
end
|
|
end
|
|
end
|
|
return
|
|
end
|
|
|
|
libpredict:Heal(casterName, targetName, amount, castTime, casterGuid)
|
|
end
|
|
|
|
-- SPELL_GO_SELF: own cast landed - HoTs + own rez timer
|
|
-- Signature: fn(spellId, arg1, arg2, arg3, arg4, arg5, arg6, arg7)
|
|
pfUI.libdebuff_spell_go_hooks = pfUI.libdebuff_spell_go_hooks or {}
|
|
pfUI.libdebuff_spell_go_hooks["libpredict"] = function(spellId, a1, a2, a3, a4, a5, a6, a7)
|
|
-- Instant HoTs — classify by name (rank-independent) instead of a
|
|
-- hardcoded per-rank ID table.
|
|
local spellName = C_Spell.GetSpellName(spellId)
|
|
local hotType
|
|
if spellName == REJUVENATION then hotType = "Reju"
|
|
elseif spellName == RENEW then hotType = "Renew"
|
|
end
|
|
if hotType then
|
|
local targetGuid = a4
|
|
local targetName = resolveNameFromGuid(targetGuid)
|
|
if targetName then
|
|
local duration
|
|
if hotType == "Reju" then duration = rejuvDuration or 12
|
|
elseif hotType == "Renew" then duration = renewDuration or 15
|
|
end
|
|
local rank = 0
|
|
local rankSub = C_Spell.GetSpellSubtext(spellId)
|
|
if rankSub and rankSub ~= "" then
|
|
rank = tonumber((string.gsub(rankSub, "Rank ", ""))) or 0
|
|
end
|
|
local playerName = UnitName("player")
|
|
libpredict:Hot(playerName, targetName, hotType, duration, nil, "SPELL_GO_SELF", rank)
|
|
local rankStr = tostring(rank)
|
|
if libpredict.sender and libpredict.sender.SendHealCommMsg then
|
|
libpredict.sender:SendHealCommMsg(hotType .. "/" .. targetName .. "/" .. duration .. "/" .. rankStr .. "/")
|
|
elseif IsInRaid() then
|
|
SendAddonMessage("HealComm", hotType .. "/" .. targetName .. "/" .. duration .. "/" .. rankStr .. "/", "RAID")
|
|
elseif IsInGroup() then
|
|
SendAddonMessage("HealComm", hotType .. "/" .. targetName .. "/" .. duration .. "/" .. rankStr .. "/", "PARTY")
|
|
end
|
|
end
|
|
end
|
|
-- Own rez landed: set timer
|
|
if isRezSpell(spellId) then
|
|
local targetGuid = a4
|
|
local targetName = resolveNameFromGuid(targetGuid)
|
|
local playerName = UnitName("player")
|
|
if playerName and targetName then
|
|
libpredict:RessSetTimer(playerName, targetName)
|
|
end
|
|
end
|
|
|
|
end
|
|
|
|
-- SPELL_GO_OTHER: foreign cast landed - rez timer + HealStop
|
|
-- Signature: fn(spellId, casterGuid, targetGuid)
|
|
pfUI.libdebuff_spell_go_other_hooks = pfUI.libdebuff_spell_go_other_hooks or {}
|
|
pfUI.libdebuff_spell_go_other_hooks["libpredict"] = function(spellId, casterGuid, targetGuid)
|
|
local casterName = resolveNameFromGuid(casterGuid)
|
|
if not casterName then return end
|
|
|
|
if isRezSpell(spellId) then
|
|
local targetName = resolveNameFromGuid(targetGuid)
|
|
if targetName then
|
|
libpredict:RessSetTimer(casterName, targetName)
|
|
end
|
|
return
|
|
end
|
|
|
|
-- Heal landed → prediction fulfilled, clean up
|
|
libpredict:HealStop(casterName)
|
|
end
|
|
|
|
-- SPELL_FAILED_OTHER: remove cancelled resses/heals
|
|
-- Signature: fn(casterGuid, spellId)
|
|
pfUI.libdebuff_spell_failed_other_hooks = pfUI.libdebuff_spell_failed_other_hooks or {}
|
|
pfUI.libdebuff_spell_failed_other_hooks["libpredict"] = function(casterGuid, spellId)
|
|
if not casterGuid then return end
|
|
local ressName = ressGuidToName[casterGuid]
|
|
if ressName then
|
|
libpredict:RessStop(ressName)
|
|
end
|
|
local healName = healGuidToName[casterGuid]
|
|
if healName then libpredict:HealStop(healName) end
|
|
end
|
|
|
|
libpredict:SetScript("OnUpdate", function()
|
|
-- throttle cleanup - no need to check every frame
|
|
local now = pfUI.uf.now or GetTime()
|
|
if (this.tick or 0) > now then return end
|
|
this.tick = now + pfUI.throttle:Get("libpredict") -- Default: Normal (10 FPS)
|
|
|
|
-- update on timeout events
|
|
for timestamp, targets in pairs(events) do
|
|
if now >= timestamp then
|
|
events[timestamp] = nil
|
|
end
|
|
end
|
|
|
|
-- Rez timeout: remove offer after 60s if player doesn't accept
|
|
for target, senders in pairs(ress_timers) do
|
|
for sender, expiry in pairs(senders) do
|
|
if now >= expiry then
|
|
senders[sender] = nil
|
|
if ress[target] then ress[target][sender] = nil end
|
|
end
|
|
end
|
|
end
|
|
end)
|
|
|
|
function libpredict:ParseComm(sender, msg)
|
|
local msgtype, target, heal, time, rank
|
|
|
|
if msg == "HealStop" or msg == "Healstop" or msg == "GrpHealstop" then
|
|
msgtype = "Stop"
|
|
-- DEBUG: Log when HealStop received
|
|
if libpredict.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff[libpredict RX]|r HealStop from " .. tostring(sender))
|
|
end
|
|
elseif msg == "Resurrection/stop/" then
|
|
msgtype = "RessStop"
|
|
elseif msg then
|
|
local msgobj = {strsplit("/", msg)}
|
|
|
|
if msgobj and msgobj[1] and msgobj[2] then
|
|
-- legacy healcomm object
|
|
if msgobj[1] == "GrpHealdelay" or msgobj[1] == "Healdelay" then
|
|
msgtype, time = "Delay", msgobj[2]
|
|
end
|
|
|
|
if msgobj[1] and msgobj[1] == "Resurrection" and msgobj[2] then
|
|
msgtype, target = "Ress", msgobj[2]
|
|
end
|
|
|
|
if msgobj[1] == "Heal" and msgobj[2] then
|
|
msgtype, target, heal, time = "Heal", msgobj[2], msgobj[3], msgobj[4]
|
|
end
|
|
|
|
if msgobj[1] == "GrpHeal" and msgobj[2] then
|
|
msgtype, target, heal, time = "Heal", {}, msgobj[2], msgobj[3]
|
|
for i=4,8 do
|
|
if msgobj[i] then table.insert(target, msgobj[i]) end
|
|
end
|
|
end
|
|
|
|
if msgobj[1] == "Reju" or msgobj[1] == "Renew" or msgobj[1] == "Regr" then --hots
|
|
msgtype, target, heal, time = "Hot", msgobj[2], msgobj[1], msgobj[3]
|
|
-- NEW: Parse rank (optional, backwards compatible)
|
|
-- Format: "Reju/Target/12/10/" where msgobj[3]=duration, msgobj[4]=rank
|
|
-- "0" = unknown rank (for clients without rank extraction)
|
|
local rankStr = msgobj[4]
|
|
if rankStr and rankStr ~= "" and rankStr ~= "/" and rankStr ~= "0" then
|
|
rank = tonumber(rankStr)
|
|
end
|
|
end
|
|
elseif select then
|
|
-- latest healcomm
|
|
msgtype = tonumber(string.sub(msg, 1, 3))
|
|
if not msgtype then return end
|
|
|
|
-- Resolve sender's name to a group unit token so C_Spell can query
|
|
-- the cast. Group rosters are tiny (44 slots max) so the walk is cheap
|
|
-- relative to the cost of receiving a HealComm message.
|
|
local function senderUnit()
|
|
if UnitName("player") == sender then return "player" end
|
|
for i = 1, GetNumPartyMembers() do
|
|
if UnitName("party"..i) == sender then return "party"..i end
|
|
end
|
|
for i = 1, GetNumRaidMembers() do
|
|
if UnitName("raid"..i) == sender then return "raid"..i end
|
|
end
|
|
end
|
|
|
|
if msgtype == 0 then
|
|
msgtype = "Heal"
|
|
heal = tonumber(string.sub(msg, 4, 8))
|
|
target = string.sub(msg,9, -1)
|
|
|
|
local unit = senderUnit()
|
|
if not unit then return end
|
|
local _, _, _, startMs, endMs = C_Spell.UnitCastingInfo(unit)
|
|
if not startMs or not endMs then return end
|
|
time = (endMs - startMs) / 1000
|
|
elseif msgtype == 1 then
|
|
msgtype = "Stop"
|
|
elseif msgtype == 2 then
|
|
msgtype = "Heal"
|
|
heal = tonumber(string.sub(msg,4, 8))
|
|
target = {strsplit(":", string.sub(msg,9, -1))}
|
|
local unit = senderUnit()
|
|
if not unit then return end
|
|
local _, _, _, startMs, endMs = C_Spell.UnitCastingInfo(unit)
|
|
if not startMs or not endMs then return end
|
|
time = (endMs - startMs) / 1000
|
|
end
|
|
end
|
|
end
|
|
|
|
return msgtype, target, heal, time, rank
|
|
end
|
|
|
|
-- Duplicate detection for HoT messages
|
|
local recentHots = {}
|
|
local DUPLICATE_WINDOW = 0.5 -- Ignoriere gleiche Nachricht innerhalb 0.5s
|
|
|
|
function libpredict:ParseChatMessage(sender, msg, comm)
|
|
local msgtype, target, heal, time, rank
|
|
|
|
if comm == "HealComm" then
|
|
msgtype, target, heal, time, rank = libpredict:ParseComm(sender, msg)
|
|
elseif comm == "CTRA" then
|
|
local _, _, cmd, ctratarget = string.find(msg, "(%a+)%s?([^#]*)")
|
|
if cmd and ctratarget and cmd == "RES" and ctratarget ~= "" and ctratarget ~= UNKNOWN then
|
|
msgtype = "Ress"
|
|
target = ctratarget
|
|
end
|
|
end
|
|
|
|
if msgtype == "Stop" and sender then
|
|
libpredict:HealStop(sender)
|
|
return
|
|
elseif ( msg == "RessStop" or msg == "RESNO" ) and sender then
|
|
libpredict:RessStop(sender)
|
|
return
|
|
elseif msgtype == "Delay" and time then
|
|
libpredict:HealDelay(sender, time)
|
|
elseif msgtype == "Heal" and target and heal and time then
|
|
if type(target) == "table" then
|
|
for _, name in pairs(target) do
|
|
libpredict:Heal(sender, name, heal, time)
|
|
end
|
|
else
|
|
libpredict:Heal(sender, target, heal, time)
|
|
end
|
|
elseif msgtype == "Ress" then
|
|
-- HealComm: only for other players (own resses tracked via SPELL_START_SELF hook)
|
|
local playerName = UnitName("player")
|
|
if sender ~= playerName then
|
|
local senderGuid
|
|
for i = 1, GetNumRaidMembers() do
|
|
local unit = "raid" .. i
|
|
if UnitName(unit) == sender then
|
|
senderGuid = UnitGUID(unit)
|
|
break
|
|
end
|
|
end
|
|
if not senderGuid then
|
|
for i = 1, GetNumPartyMembers() do
|
|
local unit = "party" .. i
|
|
if UnitName(unit) == sender then
|
|
senderGuid = UnitGUID(unit)
|
|
break
|
|
end
|
|
end
|
|
end
|
|
libpredict:Ress(sender, target, senderGuid)
|
|
end
|
|
elseif msgtype == "Hot" then
|
|
-- Duplicate check: ignore same sender+target+spell within DUPLICATE_WINDOW
|
|
local now = pfUI.uf.now or GetTime()
|
|
local key = sender .. target .. heal
|
|
if recentHots[key] and (now - recentHots[key]) < DUPLICATE_WINDOW then
|
|
if libpredict.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[DUPLICATE IGNORED]|r " .. key)
|
|
end
|
|
return
|
|
end
|
|
recentHots[key] = now
|
|
|
|
-- Cleanup old entries (every 10s)
|
|
if not libpredict.lastCleanup or (now - libpredict.lastCleanup) > 10 then
|
|
for k, v in pairs(recentHots) do
|
|
if (now - v) > DUPLICATE_WINDOW then
|
|
recentHots[k] = nil
|
|
end
|
|
end
|
|
libpredict.lastCleanup = now
|
|
end
|
|
|
|
-- For own HoTs: correct the startTime
|
|
if sender == UnitName("player") then
|
|
local existing = hots[target] and hots[target][heal]
|
|
|
|
-- Do not overwrite if an active timer already exists
|
|
if existing and (existing.start + existing.duration) > now then
|
|
return
|
|
end
|
|
|
|
-- Compensate for HealComm delay
|
|
local delay = (heal == "Regr") and 0.3 or 0
|
|
local correctedStart = now - delay
|
|
|
|
libpredict:Hot(sender, target, heal, time, correctedStart, "ParseComm-Self", rank)
|
|
return
|
|
end
|
|
libpredict:Hot(sender, target, heal, time, nil, "ParseComm", rank)
|
|
end
|
|
end
|
|
|
|
function libpredict:AddEvent(time, target)
|
|
events[time] = events[time] or {}
|
|
table.insert(events[time], target)
|
|
end
|
|
|
|
function libpredict:Heal(sender, target, amount, duration, senderGuid)
|
|
if not sender or not target or not amount or not duration then
|
|
return
|
|
end
|
|
|
|
local now = pfUI.uf.now or GetTime()
|
|
local timeout = duration/1000 + now
|
|
heals[target] = heals[target] or {}
|
|
heals[target][sender] = { amount, timeout }
|
|
if senderGuid then healGuidToName[senderGuid] = sender end
|
|
libpredict:AddEvent(timeout, target)
|
|
end
|
|
|
|
-- Debug flag
|
|
libpredict.debug = false
|
|
|
|
function libpredict:Hot(sender, target, spell, duration, startTime, source, rank)
|
|
hots[target] = hots[target] or {}
|
|
hots[target][spell] = hots[target][spell] or {}
|
|
|
|
-- Correct Regrowth duration (server returns 21, should be 20)
|
|
if spell == "Regr" then
|
|
duration = 20
|
|
end
|
|
|
|
-- Sicherstellen dass duration eine Zahl ist
|
|
duration = tonumber(duration) or duration
|
|
|
|
-- Rank protection: Don't overwrite higher rank HoT with lower rank
|
|
local existing = hots[target][spell]
|
|
if existing and existing.rank and rank then
|
|
local existingRank = tonumber(existing.rank) or 0
|
|
local newRank = tonumber(rank) or 0
|
|
|
|
local now = pfUI.uf.now or GetTime()
|
|
local existingTimeleft = (existing.start + existing.duration) - now
|
|
|
|
-- If existing HoT is still active and has higher rank, don't overwrite
|
|
if existingTimeleft > 0 and newRank > 0 and newRank < existingRank then
|
|
if libpredict.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffff0000[Hot RANK BLOCK]|r %s Rank %d cannot overwrite Rank %d on %s",
|
|
spell, newRank, existingRank, target))
|
|
end
|
|
return -- Don't overwrite!
|
|
end
|
|
end
|
|
|
|
local now = pfUI.uf.now or GetTime()
|
|
hots[target][spell].duration = duration
|
|
hots[target][spell].start = startTime or now
|
|
hots[target][spell].rank = rank -- Store rank for protection
|
|
|
|
-- Debug
|
|
if libpredict.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff33ffcc[Hot]|r src=" .. (source or "?") ..
|
|
" | sender=" .. (sender or "nil") ..
|
|
" | target=" .. (target or "nil") ..
|
|
" | spell=" .. (spell or "nil") ..
|
|
" | dur=" .. tostring(duration) .. " (" .. type(duration) .. ")" ..
|
|
" | rank=" .. tostring(rank or "?"))
|
|
end
|
|
|
|
-- update aura events of relevant unitframes
|
|
if pfUI and pfUI.uf and pfUI.uf.frames then
|
|
for _, frame in pairs(pfUI.uf.frames) do
|
|
if frame.namecache == target then
|
|
frame.update_aura = true
|
|
if libpredict.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(" |cff00ff00-> Frame update triggered for " .. (frame:GetName() or "?") .. "|r")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
function libpredict:HealStop(sender)
|
|
for ttarget, t in pairs(heals) do
|
|
for tsender in pairs(heals[ttarget]) do
|
|
if sender == tsender then
|
|
heals[ttarget][tsender] = nil
|
|
end
|
|
end
|
|
end
|
|
-- cleanup reverse lookup
|
|
for guid, name in pairs(healGuidToName) do
|
|
if name == sender then healGuidToName[guid] = nil end
|
|
end
|
|
end
|
|
|
|
function libpredict:HealDelay(sender, delay)
|
|
local delay = delay/1000
|
|
for target, t in pairs(heals) do
|
|
for tsender, amount in pairs(heals[target]) do
|
|
if sender == tsender then
|
|
amount[2] = amount[2] + delay
|
|
libpredict:AddEvent(amount[2], target)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
function libpredict:Ress(sender, target, senderGuid)
|
|
ress[target] = ress[target] or {}
|
|
ress[target][sender] = true
|
|
if senderGuid then ressGuidToName[senderGuid] = sender end
|
|
-- no timer here - timer is only set once the cast actually completes
|
|
end
|
|
|
|
function libpredict:RessSetTimer(sender, target)
|
|
ress_timers[target] = ress_timers[target] or {}
|
|
local existing = ress_timers[target][sender]
|
|
if not existing or GetTime() >= existing then
|
|
ress_timers[target][sender] = GetTime() + RESS_TIMEOUT
|
|
else
|
|
end
|
|
end
|
|
|
|
function libpredict:RessStop(sender)
|
|
local now = GetTime()
|
|
for ttarget, t in pairs(ress) do
|
|
for tsender in pairs(ress[ttarget]) do
|
|
if sender == tsender then
|
|
local expiry = ress_timers[ttarget] and ress_timers[ttarget][tsender]
|
|
if not expiry or now >= expiry then
|
|
ress[ttarget][tsender] = nil
|
|
if ress_timers[ttarget] then ress_timers[ttarget][tsender] = nil end
|
|
else
|
|
end
|
|
end
|
|
end
|
|
end
|
|
for guid, name in pairs(ressGuidToName) do
|
|
if name == sender then ressGuidToName[guid] = nil end
|
|
end
|
|
end
|
|
|
|
function libpredict:UnitGetIncomingHeals(unit)
|
|
if not unit then return 0 end
|
|
local name = UnitName(unit)
|
|
if not name then return 0 end
|
|
if UnitIsDeadOrGhost(unit) then return 0 end
|
|
|
|
local sumheal = 0
|
|
if not heals[name] then
|
|
return sumheal
|
|
else
|
|
local now = pfUI.uf.now or GetTime()
|
|
for sender, amount in pairs(heals[name]) do
|
|
if amount[2] <= now then
|
|
heals[name][sender] = nil
|
|
else
|
|
sumheal = sumheal + amount[1]
|
|
end
|
|
end
|
|
end
|
|
return sumheal
|
|
end
|
|
|
|
function libpredict:UnitHasIncomingResurrection(unit)
|
|
if not unit then return nil end
|
|
local name = UnitName(unit)
|
|
if not name then return nil end
|
|
|
|
if not ress[name] then
|
|
return nil
|
|
else
|
|
for sender, val in pairs(ress[name]) do
|
|
if val == true then
|
|
return val
|
|
end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local realm = GetRealmName()
|
|
local resetcache = CreateFrame("Frame")
|
|
local hotsetbonus = libtipscan:GetScanner("hotsetbonus")
|
|
resetcache:RegisterEvent("PLAYER_ENTERING_WORLD")
|
|
resetcache:RegisterEvent("LEARNED_SPELL_IN_TAB")
|
|
resetcache:RegisterEvent("CHARACTER_POINTS_CHANGED")
|
|
resetcache:RegisterEvent("PLAYER_EQUIPMENT_CHANGED")
|
|
resetcache:SetScript("OnEvent", function()
|
|
if event == "PLAYER_ENTERING_WORLD" then
|
|
-- load and initialize previous caches of spell amounts
|
|
pfUI_cache["prediction"] = pfUI_cache["prediction"] or {}
|
|
pfUI_cache["prediction"][realm] = pfUI_cache["prediction"][realm] or {}
|
|
pfUI_cache["prediction"][realm][player] = pfUI_cache["prediction"][realm][player] or {}
|
|
pfUI_cache["prediction"][realm][player]["heals"] = pfUI_cache["prediction"][realm][player]["heals"] or {}
|
|
cache = pfUI_cache["prediction"][realm][player]["heals"]
|
|
end
|
|
|
|
if event == "PLAYER_EQUIPMENT_CHANGED" or event == "PLAYER_ENTERING_WORLD" then
|
|
local gear = ""
|
|
for id = 1, 18 do
|
|
gear = gear .. (GetInventoryItemLink("player",id) or "")
|
|
end
|
|
|
|
-- abort when inventory didn't change
|
|
if gear == gear_string then return end
|
|
gear_string = gear
|
|
|
|
local setBonusCounter
|
|
setBonusCounter = 0
|
|
for i=1,10 do --there is no need to check slots above 10
|
|
hotsetbonus:SetInventoryItem("player", i)
|
|
if hotsetbonus:Find(L["healduration"]["Rejuvenation"]) then setBonusCounter = setBonusCounter + 1 end
|
|
end
|
|
rejuvDuration = setBonusCounter == 8 and 15 or 12
|
|
setBonusCounter = 0
|
|
for i =1,10 do
|
|
hotsetbonus:SetInventoryItem("player", i)
|
|
if hotsetbonus:Find(L["healduration"]["Renew"]) then setBonusCounter = setBonusCounter + 1 end
|
|
end
|
|
renewDuration = setBonusCounter == 5 and 18 or 15
|
|
end
|
|
|
|
-- flag all cached heals for renewal
|
|
for k in pairs(cache) do
|
|
if type(cache[k]) == "number" or type(cache[k]) == "string" then
|
|
-- migrate old data
|
|
local oldval = cache[k]
|
|
cache[k] = { [1] = oldval }
|
|
end
|
|
|
|
-- flag for reset
|
|
cache[k][2] = true
|
|
end
|
|
end)
|
|
|
|
local function UpdateCache(spell, heal, crit)
|
|
local heal = heal and tonumber(heal)
|
|
if not spell or not heal then return end
|
|
|
|
if not cache[spell] then
|
|
-- no cache yet: save whatever we get
|
|
cache[spell] = {}
|
|
cache[spell][1] = crit and heal*2/3 or heal
|
|
cache[spell][2] = crit
|
|
elseif cache[spell][2] == true then
|
|
-- flagged as stale (gear/skill change): always overwrite
|
|
cache[spell][1] = crit and heal*2/3 or heal
|
|
cache[spell][2] = crit
|
|
elseif crit then
|
|
-- crit: don't overwrite existing non-crit value
|
|
else
|
|
-- non-crit: save best value
|
|
if cache[spell][1] < heal then
|
|
cache[spell][1] = heal
|
|
end
|
|
cache[spell][2] = false
|
|
end
|
|
end
|
|
|
|
-- Cooldown for local instant HoT hooks (prevents spam on click-to-cast)
|
|
local instantHotCooldown = {}
|
|
local INSTANT_HOT_COOLDOWN = 1.0 -- 1 Sekunde Cooldown (GCD ist 1.5s)
|
|
|
|
-- Pending HoTs Queue - wird nach Delay verifiziert
|
|
local pendingHots = {}
|
|
|
|
-- Gather Data by User Actions
|
|
hooksecurefunc("CastSpell", function(id, bookType)
|
|
if not libpredict.sender.enabled then return end
|
|
local effect, rank = libspell.GetSpellInfo(id, bookType)
|
|
if not effect then return end
|
|
spell_queue[1] = effect
|
|
spell_queue[2] = effect.. ( rank or "" )
|
|
spell_queue[3] = UnitName("target") and UnitCanAssist("player", "target") and UnitName("target") or UnitName("player")
|
|
|
|
-- Extract rank number
|
|
local rankNum = nil
|
|
if rank and rank ~= "" then
|
|
rankNum = tonumber((string.gsub(rank, "Rank ", ""))) or nil
|
|
end
|
|
|
|
-- Instant HoTs: libdebuff/Nampower via GetHotDuration, hook method as fallback
|
|
|
|
if effect == REJUVENATION then
|
|
local target = spell_queue[3]
|
|
local now = pfUI.uf.now or GetTime()
|
|
local key = "Reju" .. target
|
|
|
|
-- Cooldown-Check
|
|
if instantHotCooldown[key] and (now - instantHotCooldown[key]) < INSTANT_HOT_COOLDOWN then
|
|
return
|
|
end
|
|
instantHotCooldown[key] = now
|
|
|
|
if libpredict.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[CastSpell REJU INSTANT]|r target=%s rank=%s (Fallback)", target, tostring(rankNum or "?")))
|
|
end
|
|
libpredict:Hot(player, target, "Reju", rejuvDuration, nil, "CastSpell-Instant", rankNum)
|
|
local rankStr = rankNum and tostring(rankNum) or "0"
|
|
libpredict.sender:SendHealCommMsg("Reju/"..target.."/"..rejuvDuration.."/"..rankStr.."/")
|
|
elseif effect == RENEW then
|
|
local target = spell_queue[3]
|
|
local now = pfUI.uf.now or GetTime()
|
|
local key = "Renew" .. target
|
|
|
|
-- Cooldown-Check
|
|
if instantHotCooldown[key] and (now - instantHotCooldown[key]) < INSTANT_HOT_COOLDOWN then
|
|
return
|
|
end
|
|
instantHotCooldown[key] = now
|
|
|
|
if libpredict.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[CastSpell RENEW INSTANT]|r target=%s rank=%s (Fallback)", target, tostring(rankNum or "?")))
|
|
end
|
|
libpredict:Hot(player, target, "Renew", renewDuration, nil, "CastSpell-Instant", rankNum)
|
|
local rankStr = rankNum and tostring(rankNum) or "0"
|
|
libpredict.sender:SendHealCommMsg("Renew/"..target.."/"..renewDuration.."/"..rankStr.."/")
|
|
end
|
|
end)
|
|
|
|
hooksecurefunc("CastSpellByName", function(effect, target)
|
|
if not libpredict.sender.enabled then return end
|
|
local effect, rank = libspell.GetSpellInfo(effect)
|
|
if not effect then return end
|
|
|
|
local default = UnitName("target") and UnitCanAssist("player", "target") and UnitName("target") or UnitName("player")
|
|
|
|
target = target and type(target) == "string" and UnitName(target) or target
|
|
target = target and target == true and UnitName("player") or target
|
|
target = target and target == 1 and UnitName("player") or target
|
|
|
|
-- Extract rank number
|
|
local rankNum = nil
|
|
if rank and rank ~= "" then
|
|
rankNum = tonumber((string.gsub(rank, "Rank ", ""))) or nil
|
|
end
|
|
|
|
-- Only overwrite spell_queue if no cast is in progress
|
|
-- (prevents instant spam from destroying the queue during a Regrowth cast)
|
|
if not libpredict.sender.current_cast then
|
|
spell_queue[1] = effect
|
|
spell_queue[2] = effect.. ( rank or "" )
|
|
spell_queue[3] = target or default
|
|
end
|
|
|
|
-- Instant HoTs: libdebuff/Nampower via GetHotDuration, hook method as fallback
|
|
|
|
if effect == REJUVENATION then
|
|
local hotTarget = target or default
|
|
local now = pfUI.uf.now or GetTime()
|
|
local key = "Reju" .. hotTarget
|
|
|
|
-- Cooldown-Check
|
|
if instantHotCooldown[key] and (now - instantHotCooldown[key]) < INSTANT_HOT_COOLDOWN then
|
|
return
|
|
end
|
|
instantHotCooldown[key] = now
|
|
|
|
if libpredict.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[CastSpellByName REJU INSTANT]|r target=%s rank=%s (Fallback)", hotTarget, tostring(rankNum or "?")))
|
|
end
|
|
libpredict:Hot(player, hotTarget, "Reju", rejuvDuration, nil, "CastSpellByName-Instant", rankNum)
|
|
local rankStr = rankNum and tostring(rankNum) or "0"
|
|
libpredict.sender:SendHealCommMsg("Reju/"..hotTarget.."/"..rejuvDuration.."/"..rankStr.."/")
|
|
elseif effect == RENEW then
|
|
local hotTarget = target or default
|
|
local now = pfUI.uf.now or GetTime()
|
|
local key = "Renew" .. hotTarget
|
|
|
|
-- Cooldown-Check
|
|
if instantHotCooldown[key] and (now - instantHotCooldown[key]) < INSTANT_HOT_COOLDOWN then
|
|
return
|
|
end
|
|
instantHotCooldown[key] = now
|
|
|
|
if libpredict.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[CastSpellByName RENEW INSTANT]|r target=%s rank=%s (Fallback)", hotTarget, tostring(rankNum or "?")))
|
|
end
|
|
libpredict:Hot(player, hotTarget, "Renew", renewDuration, nil, "CastSpellByName-Instant", rankNum)
|
|
local rankStr = rankNum and tostring(rankNum) or "0"
|
|
libpredict.sender:SendHealCommMsg("Renew/"..hotTarget.."/"..renewDuration.."/"..rankStr.."/")
|
|
end
|
|
end)
|
|
|
|
hooksecurefunc("UseAction", function(slot, target, selfcast)
|
|
if not libpredict.sender.enabled then return end
|
|
if not IsCurrentAction(slot) then return end
|
|
local kind, id = GetActionInfo(slot)
|
|
local effect, rank
|
|
if kind == "spell" then
|
|
local spellInfo = C_Spell.GetSpellInfo(id)
|
|
effect, rank = spellInfo.name, spellInfo.rank
|
|
elseif kind == "macro" then
|
|
effect, rank = GetMacroSpell(id)
|
|
end
|
|
if not effect then return end
|
|
spell_queue[1] = effect
|
|
spell_queue[2] = effect.. ( rank or "" )
|
|
spell_queue[3] = selfcast and UnitName("player") or UnitName("target") and UnitCanAssist("player", "target") and UnitName("target") or UnitName("player")
|
|
|
|
-- Extract rank number
|
|
local rankNum = nil
|
|
if rank and rank ~= "" then
|
|
rankNum = tonumber((string.gsub(rank, "Rank ", ""))) or nil
|
|
end
|
|
|
|
-- Instant HoTs: libdebuff/Nampower via GetHotDuration, hook method as fallback
|
|
|
|
if effect == REJUVENATION then
|
|
local hotTarget = spell_queue[3]
|
|
local now = pfUI.uf.now or GetTime()
|
|
local key = "Reju" .. hotTarget
|
|
|
|
-- Cooldown-Check
|
|
if instantHotCooldown[key] and (now - instantHotCooldown[key]) < INSTANT_HOT_COOLDOWN then
|
|
return
|
|
end
|
|
instantHotCooldown[key] = now
|
|
|
|
if libpredict.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[UseAction REJU INSTANT]|r target=%s rank=%s (Fallback)", hotTarget, tostring(rankNum or "?")))
|
|
end
|
|
libpredict:Hot(player, hotTarget, "Reju", rejuvDuration, nil, "UseAction-Instant", rankNum)
|
|
local rankStr = rankNum and tostring(rankNum) or "0"
|
|
libpredict.sender:SendHealCommMsg("Reju/"..hotTarget.."/"..rejuvDuration.."/"..rankStr.."/")
|
|
elseif effect == RENEW then
|
|
local hotTarget = spell_queue[3]
|
|
local now = pfUI.uf.now or GetTime()
|
|
local key = "Renew" .. hotTarget
|
|
|
|
-- Cooldown-Check
|
|
if instantHotCooldown[key] and (now - instantHotCooldown[key]) < INSTANT_HOT_COOLDOWN then
|
|
return
|
|
end
|
|
instantHotCooldown[key] = now
|
|
|
|
if libpredict.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[UseAction RENEW INSTANT]|r target=%s rank=%s (Fallback)", hotTarget, tostring(rankNum or "?")))
|
|
end
|
|
libpredict:Hot(player, hotTarget, "Renew", renewDuration, nil, "UseAction-Instant", rankNum)
|
|
local rankStr = rankNum and tostring(rankNum) or "0"
|
|
libpredict.sender:SendHealCommMsg("Renew/"..hotTarget.."/"..renewDuration.."/"..rankStr.."/")
|
|
end
|
|
end)
|
|
|
|
libpredict.sender = CreateFrame("Frame", "pfPredictionSender", UIParent)
|
|
libpredict.sender.enabled = true
|
|
libpredict.sender.SendHealCommMsg = function(self, msg)
|
|
-- Smart channel selection: Only send to relevant channel to avoid duplicates
|
|
if IsInRaid() then
|
|
-- In raid: Only send to RAID (includes all raid members)
|
|
SendAddonMessage("HealComm", msg, "RAID")
|
|
elseif IsInGroup() then
|
|
-- In party: Only send to PARTY
|
|
SendAddonMessage("HealComm", msg, "PARTY")
|
|
end
|
|
-- Note: BATTLEGROUND channel not used (no reliable way to detect BG in Vanilla)
|
|
-- BG groups are handled by RAID channel
|
|
end
|
|
libpredict.sender.SendResCommMsg = function(self, msg)
|
|
-- Smart channel selection: Only send to relevant channel to avoid duplicates
|
|
if IsInRaid() then
|
|
-- In raid: Only send to RAID (includes all raid members)
|
|
SendAddonMessage("CTRA", msg, "RAID")
|
|
elseif IsInGroup() then
|
|
-- In party: Only send to PARTY
|
|
SendAddonMessage("CTRA", msg, "PARTY")
|
|
end
|
|
-- Note: BATTLEGROUND channel not used (no reliable way to detect BG in Vanilla)
|
|
-- BG groups are handled by RAID channel
|
|
end
|
|
|
|
libpredict.sender:SetScript("OnUpdate", function()
|
|
-- trigger delayed regrowth timers
|
|
local now = pfUI.uf.now or GetTime()
|
|
if this.regrowth_timer and now > this.regrowth_timer then
|
|
local target = this.regrowth_target or player
|
|
local duration = 20
|
|
local startTime = this.regrowth_start
|
|
local rank = this.regrowth_rank
|
|
|
|
libpredict:Hot(player, target, "Regr", duration, startTime, "OnUpdate", rank)
|
|
local rankStr = rank and tostring(rank) or "0"
|
|
libpredict.sender:SendHealCommMsg("Regr/"..target.."/"..duration.."/"..rankStr.."/")
|
|
|
|
-- Apply next queued Regrowth if available
|
|
this.regrowth_target = this.regrowth_target_next
|
|
this.regrowth_start = this.regrowth_start_next
|
|
this.regrowth_rank = this.regrowth_rank_next
|
|
this.regrowth_target_next = nil
|
|
this.regrowth_start_next = nil
|
|
this.regrowth_rank_next = nil
|
|
this.regrowth_timer = nil
|
|
end
|
|
end)
|
|
|
|
-- Nampower events
|
|
libpredict.sender:RegisterEvent("SPELL_FAILED_SELF")
|
|
libpredict.sender:RegisterEvent("SPELL_DELAYED_SELF")
|
|
libpredict.sender:RegisterEvent("SPELL_HEAL_BY_SELF")
|
|
libpredict.sender:RegisterEvent("SPELL_HEAL_BY_OTHER") -- populates foreignCache for other healers
|
|
|
|
-- force cache updates
|
|
libpredict.sender:RegisterUnitEvent("UNIT_INVENTORY_CHANGED", "player")
|
|
libpredict.sender:RegisterEvent("SKILL_LINES_CHANGED")
|
|
|
|
-- Shared cleanup helper for failed/interrupted casts
|
|
local function onCastFailed()
|
|
local s = libpredict.sender
|
|
if s.healing then
|
|
libpredict:HealStop(UnitName("player"))
|
|
s:SendHealCommMsg("Healstop")
|
|
s.healing = nil
|
|
elseif s.resurrecting then
|
|
local target = s.current_cast_target or senttarget or spell_queue[3]
|
|
libpredict:RessStop(UnitName("player"))
|
|
s:SendHealCommMsg("Resurrection/stop/")
|
|
s:SendResCommMsg("RESNO " .. (target or ""))
|
|
s.resurrecting = nil
|
|
end
|
|
if s.current_cast == REGROWTH then
|
|
s.regrowth_timer = nil
|
|
s.regrowth_start = nil
|
|
s.regrowth_target_next = nil
|
|
s.regrowth_start_next = nil
|
|
end
|
|
s.current_cast = nil
|
|
s.current_cast_target = nil
|
|
end
|
|
|
|
libpredict.sender:SetScript("OnEvent", function()
|
|
-- ============================================================
|
|
-- NAMPOWER PATH
|
|
-- ============================================================
|
|
if event == "SPELL_HEAL_BY_SELF" then
|
|
-- arg1=targetGuid, arg2=casterGuid, arg3=spellId, arg4=amount, arg5=critical, arg6=periodic
|
|
local spellId = arg3
|
|
local amount = arg4
|
|
local isCrit = arg5 == 1
|
|
local isPeriodic = arg6 == 1
|
|
local spellName = C_Spell.GetSpellName(spellId)
|
|
if spellName and spell_queue[1] == spellName then
|
|
UpdateCache(spell_queue[2], amount, isCrit)
|
|
end
|
|
|
|
elseif event == "SPELL_HEAL_BY_OTHER" then
|
|
-- Fires once per hit target for AoE heals (e.g. PoH).
|
|
-- Use this to build a per-caster cache of real heal amounts.
|
|
-- arg1=targetGuid, arg2=casterGuid, arg3=spellId, arg4=amount, arg5=critical, arg6=periodic
|
|
local casterGuid = arg2
|
|
local spellId = arg3
|
|
local amount = arg4
|
|
local isCrit = arg5 == 1
|
|
local isPeriodic = arg6 == 1
|
|
|
|
if isPeriodic or not spellId or not amount or amount <= 0 then return end
|
|
|
|
local casterName = resolveNameFromGuid(casterGuid)
|
|
if not casterName or casterName == player then return end -- own heals handled by SPELL_HEAL_BY_SELF
|
|
|
|
local spellName = C_Spell.GetSpellName(spellId)
|
|
if not spellName then return end
|
|
local rankStr = C_Spell.GetSpellSubtext(spellId)
|
|
local spellKey = spellName .. (rankStr or "")
|
|
|
|
foreignCache[casterName] = foreignCache[casterName] or {}
|
|
local existing = foreignCache[casterName][spellKey]
|
|
-- Store highest non-crit value for best prediction accuracy
|
|
if isCrit then
|
|
-- Estimate base from crit (vanilla crit = 150%)
|
|
local base = math.floor(amount * 2 / 3)
|
|
if not existing or base > existing then
|
|
foreignCache[casterName][spellKey] = base
|
|
end
|
|
else
|
|
if not existing or amount > existing then
|
|
foreignCache[casterName][spellKey] = amount
|
|
end
|
|
end
|
|
|
|
elseif event == "SPELL_FAILED_SELF" then
|
|
onCastFailed()
|
|
|
|
elseif event == "SPELL_DELAYED_SELF" then
|
|
-- arg1=casterGuid, arg2=delayMs
|
|
if libpredict.sender.healing then
|
|
libpredict:HealDelay(player, arg2)
|
|
libpredict.sender:SendHealCommMsg("Healdelay/" .. arg2 .. "/")
|
|
end
|
|
|
|
end
|
|
end)
|
|
|
|
function libpredict:GetHotDuration(unit, spell)
|
|
if unit == UNKNOWNOBJECT or unit == UKNOWNBEING then return end
|
|
|
|
-- NEW: Try libdebuff first (Nampower AURA_CAST events)
|
|
if pfUI.api.libdebuff and pfUI.api.libdebuff.GetBestAuraCast then
|
|
local guid = UnitGUID(unit)
|
|
if guid then
|
|
-- Get the best (highest rank) aura cast for this spell
|
|
local spellName = spell
|
|
|
|
-- Map short spell codes to full names
|
|
if spell == "Reju" then
|
|
spellName = REJUVENATION
|
|
elseif spell == "Regr" then
|
|
spellName = REGROWTH
|
|
elseif spell == "Renew" then
|
|
spellName = RENEW
|
|
end
|
|
|
|
local start, duration, timeleft, rank, casterGuid = pfUI.api.libdebuff:GetBestAuraCast(guid, spellName)
|
|
|
|
if start and duration and timeleft then
|
|
-- SUCCESS: libdebuff has accurate server-side data!
|
|
if libpredict.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[GetHotDuration]|r %s on %s via libdebuff: dur=%.1fs timeleft=%.1fs rank=%d",
|
|
spell, unit, duration, timeleft, rank or 0))
|
|
end
|
|
return start, duration, timeleft
|
|
end
|
|
end
|
|
end
|
|
|
|
-- FALLBACK: Use old prediction system (for non-Nampower clients or no AURA_CAST data)
|
|
local start, duration, timeleft
|
|
local now = pfUI.uf.now or GetTime()
|
|
|
|
local unitName = UnitName(unit)
|
|
local unitdata = hots[unitName]
|
|
|
|
if unitdata and unitdata[spell] then
|
|
local spellData = unitdata[spell]
|
|
if spellData.start and spellData.duration then
|
|
local endTime = spellData.start + spellData.duration
|
|
if endTime > now - 1 then
|
|
start = spellData.start
|
|
duration = spellData.duration
|
|
timeleft = endTime - now
|
|
|
|
if libpredict.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffff9900[GetHotDuration]|r %s on %s via prediction: dur=%.1fs timeleft=%.1fs",
|
|
spell, unit, duration, timeleft))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
return start, duration, timeleft
|
|
end
|
|
|
|
-- Debug command: /hotdebug - Show HoT tracking status
|
|
_G.SLASH_HOTDEBUG1 = "/hotdebug"
|
|
_G.SlashCmdList.HOTDEBUG = function()
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff========================================|r")
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff[HoT Tracking Debug]|r")
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff========================================|r")
|
|
|
|
-- Check libdebuff availability
|
|
local libdebuff_now = (pfUI.api.libdebuff and pfUI.api.libdebuff.GetBestAuraCast) and true or false
|
|
|
|
if libdebuff_now then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[PRIMARY]|r libdebuff integration: ACTIVE")
|
|
DEFAULT_CHAT_FRAME:AddMessage(" Using AURA_CAST events for server-side tracking")
|
|
DEFAULT_CHAT_FRAME:AddMessage(" Rank protection: ENABLED")
|
|
else
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cffff9900[PRIMARY]|r libdebuff integration: NOT AVAILABLE")
|
|
DEFAULT_CHAT_FRAME:AddMessage(" Reason: Nampower not enabled or libdebuff outdated")
|
|
end
|
|
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff[FALLBACK]|r Legacy prediction system: ACTIVE")
|
|
DEFAULT_CHAT_FRAME:AddMessage(" Using hook method + HealComm messages")
|
|
|
|
-- Show active HoTs in tracking
|
|
local hotCount = 0
|
|
for target, spells in pairs(hots) do
|
|
for spell, data in pairs(spells) do
|
|
hotCount = hotCount + 1
|
|
end
|
|
end
|
|
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ffff[TRACKED]|r %d HoTs in legacy system", hotCount))
|
|
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff========================================|r")
|
|
DEFAULT_CHAT_FRAME:AddMessage("Tip: /libpredict.debug = true for verbose logging")
|
|
end
|
|
|
|
pfUI.api.libpredict = libpredict |