mirror of
https://github.com/brues-code/pfUI.git
synced 2026-09-24 00:26:55 +00:00
e017dbf515
* chat: detect whispers before the timestamp is prepended Whisper detection tests for the whisper colour code at position 1, but the timestamp is prepended first, so with timestamps enabled the code is no longer at position 1 and every whisper failed the test -- losing both the recolour and the correct chat-history entry. (cherry picked from commit 4b69d597631c422d9360a94ff129323288a28cc2) * macrotweak + libpredict: inverted install guard, misspelled globals macrotweak: _AddHistoryLine is the backup slot this block creates, so it is nil until line 18 runs. Guarding on its truthiness meant the chat-history filter never installed at all -- macro calls kept landing in chat input history. libpredict: UKNOWNBEING / UNKOWNBEING are misspelled, so both resolve to nil and the guards never matched the real UNKNOWNBEING. Neutral if the global is absent on this client, correct if present. Not needed: his libpredict already fixed the always-true 'event == "A" or "B"' condition (libs/libpredict.lua:828). (cherry picked from commit 4068110dc4134823f1c09f60ca5f8959302cd14f) * mapreveal: look the explore cache up by the key it is actually stored under explorecaches is keyed by the plain area name (line ~160), but the hover frame carried only a decorated 'mapFileName (area)' display string, so every lookup missed and the hover highlight never fired. Store the plain name alongside it and key off that; the tooltip keeps the decorated string. (cherry picked from commit 5e6fe969a03a88c68e28614548b9d44db562ab9a) * socialmod: don't clobber the friend-online match with the offline one The offline match was assigned unconditionally over the online match, so a friend coming online never had lastseen recorded. (cherry picked from commit ad927806c46042163f84bde4db1082726bedf28b) * swingtimer: off-hand weapons are inventory type 22, not 21 The off-hand slot accepts one-hand (13) and off-hand (22) weapons. Type 21 is INVTYPE_WEAPONMAINHAND and can never be equipped there, so the off-hand swing timer never recognised a real off-hand weapon. The other two swingtimer fixes are not needed here: his hunter check already uses UnitClassBase (locale-independent, better than our UnitClass second return), and his generic on-next-swing detection covers Raptor Strike without our hardcoded spell-id list. (cherry picked from commit 9ab7f5f7224d12361ca6cd5a49181cbb4bebd387) * superwow: compare the version numerically, not by exact string SUPERWOW_VERSION == "1.5" silently disables the GUID-to-name combat text hook on any release past 1.5. Currently 1.5 here, so this is forward-compat only. Not needed: the clickthrough slash commands already go through RegisterSlashCommand, which writes _G properly. (cherry picked from commit 82a37752a5782479849ce7e1304c24733c2ff97a) * api: measure a real pixel against UIParent, not the uiScale cvar The uiScale cvar caps at 1.0 while both the pixelperfect module and the firstrun slider push UIParent past it via SetScale, and it is ignored entirely while useUiScale is off, so borders came out the wrong thickness on the Huge/Large presets. Ask the frame for its effective scale instead, and guard an unparseable gxResolution. Also cache GetItemLinkByName and count its failures: the scan walked every id on each call with no memory, so an unresolvable name hitched on every tooltip hover. Raise the ceiling from 25818 to 61000 as well -- Octo/Turtle custom items live well past the vanilla range and never resolved. (cherry picked from commit dac2d3416aef85a4c4b71c0239d93d309e76e30b) * init: load pixelperfect first so GetPerfectPixel caches the right scale pixelperfect sets the UI scale that GetPerfectPixel measures against, and that value is cached on first use. Loading it 56th baked in the previous scale. (cherry picked from commit dd89a210330583f890a51a82b29e968f3ad36b34) * modules: route two global overrides through _G so they leave the sandbox pfUI.env has __index but no __newindex, so a bare global assignment inside a RegisterModule closure is written into the sandbox table and never reaches _G. unitxp's BattlefieldFrame_Show override therefore never fired the BG queue-pop notification, and RaidFrame.lua never saw GROUP_REPLACE_PARTY. The slash-command half of this fix is not needed here: RegisterSlashCommand already writes through _G. (cherry picked from commit 617c8320aca2465af62cf0ff5be7dceaed29a3b2) * bags: remove the duplicate search OnHide handler Two byte-identical frame.search OnHide handlers were installed back to back; the second overwrote the first, so the first was dead code. (cherry picked from commit e77650448b92e5d7f18ae58632e4531cb2813ea6) * buffwatch: invalidate the filter cache when a skill is listed fcache is built once per config table and never cleared, so ctrl/shift-clicking a skill onto the whitelist or blacklist had no effect until the next reload. * roll: actually capture the everyone-passed subject strfind(LOOT_ROLL_ALL_PASSED, LOOT_ROLL_PASSED) has no captures, so `everyone` was always nil, never reached the blacklist, and "Everyone has passed on: X" was recorded as a real player passing. Builds a sample from LOOT_ROLL_ALL_PASSED and runs the same LOOT_ROLL_PASSED match the scanner uses, so the subject is captured the way it will actually appear.
1319 lines
48 KiB
Lua
1319 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 ~= UNKNOWNBEING 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
|
|
|
|
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:RegisterEvent("UNIT_INVENTORY_CHANGED")
|
|
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 == UNKNOWNBEING 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 |