Files
pfUI/libs/libpredict.lua
T
Brues 00292ca3b4 Focus/Nameplates now respond solely to UNIT_* events
commit 70b1e66c2d
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Sun Jul 26 18:30:18 2026 -0500

    Use SetSize/SetShown and simplify minimap/map

commit 7b6bfe9975
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Sat Jul 25 11:27:14 2026 -0500

    Include raidpet frames in /pftest test mode

    The unit-frame test toggle (showall) only previews frames that exist and
    are positioned. Raidpet frames are created when raidpet.visible == "1",
    but LayoutPets' collapse mode only positions pets whose raidpet<N> unit
    actually exists, so solo/in test mode they stayed hidden.

    Add a showall branch to LayoutPets that mirror-lays every pet cell and
    shows it (bypassing collapse and the roster gate), still guarded by the
    existing showpets check so nothing happens when raidpet is disabled. Call
    LayoutPets from the /pftest handler so the grid updates immediately on
    toggle-on and restores to the normal layout on toggle-off.

commit 4c65c38647
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Sat Jul 25 10:34:33 2026 -0500

    Remove unused RangeCache local in UnitInRange

commit 6c96bbbe6c
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Sat Jul 25 10:34:33 2026 -0500

    Use Clamp() for the two-sided clamps in ui-widgets

    Replace the hand-rolled math.min/math.max and if/elseif clamp idioms in the
    status bar and scroll frame with Clamp(). Equivalent for every value seen
    (min <= max always holds), just clearer.

commit 830e0a0be1
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Sat Jul 25 10:34:33 2026 -0500

    Drop vanilla GetItemInfo shim for C_Item.GetItemInfo

    The compat/vanilla.lua GetItemInfo override reshaped vanilla's tuple into
    retail order by inserting nil at slot 4 (itemLevel), but truncated at 8
    fields, dropping itemEquipLoc/itemTexture. ClassicAPI's global GetItemInfo
    keeps the vanilla shape (its hook only warms the item cache), so the shim
    stayed necessary but incomplete.

    Remove it and point the three callers at ClassicAPI's C_Item.GetItemInfo,
    which returns the full 18-field retail tuple. Positions 1-8 are identical
    to the old shim output, so bags/roll are unchanged; character.lua's
    itemSlot (equipLoc, field 9) was always nil under the truncating shim and
    now resolves correctly for ShaguScore.

commit c0da63657d
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Sat Jul 25 10:09:34 2026 -0500

    Various cleanup

    Removes version compatibility checks for TBC/Vanilla, consolidating code to target a single WoW version. Refactors repetitive SetWidth/SetHeight calls to SetSize for cleaner code. Removes duplicate function definitions from unitxp module and simplifies conditional show/hide operations using SetShown.

commit 11a6302c2d
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Sat Jul 25 09:53:37 2026 -0500

    Delegate pfUI.api.strsplit to ClassicAPI's strsplit

    Replace the Lua pattern-based implementation with a thin wrapper around
    ClassicAPI's C-level strsplit. Keeps the pfUI.api.strsplit entry point for
    backwards compatibility with addons that call it, while dropping the
    redundant reimplementation.

    Behavioral note: the old version used ([^delim]+) which silently collapsed
    empty fields; delegating to real strsplit now preserves them
    ("a,,b" -> "a", "", "b"). All in-repo callers split numeric color tuples,
    version strings, or build name-keyed lookup tables, none of which are
    affected by empty-field preservation.

commit 67c126eac8
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Sat Jul 25 01:01:02 2026 -0500

    Bump ClassicAPI minimum version to 1.8.0

commit a6cf157518
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Sat Jul 25 00:49:50 2026 -0500

    read spell rank via C_Spell.GetSpellSubtext

    The rank string comes from the spell subtext ("Rank N"), which ClassicAPI's
    C_Spell.GetSpellSubtext returns directly -- so drop nampower's
    GetSpellRecField(spellId, "rank") here. The existing "Rank " gsub parse is
    unchanged.

commit 4b1ba99b4f
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Sat Jul 25 00:33:20 2026 -0500

    Move player info overlay onto ClassicAPI (drop Nampower)

    The haste/spell-power overlay read both values through Nampower and disabled
    itself entirely without it. Move both to ClassicAPI so it works on any
    ClassicAPI client:

    - Spell power: GetSpellPower("net") (nampower) -> GetSpellBonusDamage(i)
      (ClassicAPI, same per-school field). Merged the build+scan into one loop;
      the default school seeds the tiebreak so equal schools still favor it.
    - Haste: GetUnitField("player", "modCastSpeed") -> UnitSpellHaste("player"),
      which returns the haste percentage directly (the (1/modCastSpeed-1)*100
      conversion is now baked in, off the same UNIT_MOD_CAST_SPEED field, signed).
    - Dropped the "if not GetUnitField then return" gate -- UpdateInfoText no
      longer touches Nampower, so the overlay runs everywhere.

commit 7df4aa6d50
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Sat Jul 25 00:32:44 2026 -0500

    Hook the real global _G.UnitHealth for feign death

    Inside a RegisterModule body, `function UnitHealth(...)` defines UnitHealth on
    the pfUI environment, not the real global -- so the feign-death real-HP fix
    only reached callers that resolve UnitHealth through pfUI's env, and missed
    _G consumers (Blizzard frames, other addons). Hook _G.UnitHealth explicitly
    (and capture oldUnitHealth from _G) so the un-gate applies everywhere.

commit b0bf2fd869
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Fri Jul 24 21:10:58 2026 -0500

    Refactor nampower module

    Simplify reactive spell storage by using spell IDs instead of texture/name tables. Update to use modern C_Spell APIs (GetSpellTexture, GetSpellName) instead of manual texture paths. Consolidate SetWidth/SetHeight calls to SetSize and use print() instead of DEFAULT_CHAT_FRAME:AddMessage().

commit 0b06961333
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Fri Jul 24 20:54:23 2026 -0500

    Use GetNamePlateForUnit for target lookups; drop dead ScanGuid block

    - Replace the three GetNamePlateForGUID(UnitGUID("target")) round-trips with
      GetNamePlateForUnit("target"), which resolves the token to a GUID internally
      -- no UnitGUID string detour, and it returns nil for no-target/no-plate so
      the UnitExists guards collapse. GetNamePlateForGUID is left for raw-GUID cases.
    - Remove the dead libunitscan.ScanGuid nameplate block (ScanGuid was deleted
      from libunitscan long ago, so the guard was never true) -- it carried the
      last GetUnitField("npcFlags") read.

commit 3e6210086e
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Fri Jul 24 20:54:23 2026 -0500

    Drop Nampower stats system and polling from unit frames

    GetUnitStats now reads health/power straight from UnitHealth/UnitPower
    (the descriptor fields the server broadcasts) -- for a real unit token
    GetUnitField read nothing different, so the whole Nampower-vs-fallback
    apparatus was measuring a distinction that no longer exists. Removed:

    - The stats system: pfUI.uf.stats, pfUIStatsFrame + UpdateStatsDisplay, the
      lastUnitStats change-cache, the /pfuistats slash command, and every counter
      increment (event/heartbeat/earlyReturn/throttledSkip/nampower/fallback).
    - All GetUnitField health reads (GetUnitStats, heal-prediction, health-gradient
      color) -- collapsed to the UnitHealth/UnitHealthMax they already fell back to.
    - The heartbeat-polling backstop and its fallbackThrottle/lastEventUpdate deps.
      Frames now refresh on events only; eventless frames still use their own tick,
      and range/aggro still run in the eventless-actions block.

commit 63001b7b0c
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Fri Jul 24 20:09:42 2026 -0500

    Move nameplates onto ClassicAPI stable nameplate tokens

    ClassicAPI now assigns retail-exact, per-plate-stable "nameplateN" tokens and
    fires vanilla UNIT_* events for them, so the nameplate module can key off the
    token instead of Nampower/SuperWoW GUID primitives:

    - Events: UNIT_AURA_GUID/UNIT_FLAGS_GUID -> UNIT_AURA/UNIT_FLAGS, matched by
      the "nameplateN" token (guarded on the token prefix, since these also fire
      for target/party/raid). Registered unconditionally -- no GetUnitField gate.
    - Health: GetUnitField(guid, "health"/"maxHealth") -> UnitHealth/UnitHealthMax
      on the plate's cached token. Same UNIT_FIELD_HEALTH the server broadcasts
      (real HP on Turtle; the ~= 100 scaled-vs-real guard is unchanged).
    - Combat/target in GetCombatStateColor: GetUnitField("flags") + HasFlag ->
      UnitAffectingCombat(token); GetUnitField("target") + SuperWoW "<guid>target"
      -> the "nameplateNtarget" chain. Dropped the now-dead HasFlag,
      UNIT_FLAG_IN_COMBAT, and guidTargetTokenCache.
    - GetCastInfo now takes the token directly; every caller already holds it, so
      the UnitTokenFromGUID(guid) round-trip is gone (unused module-wide now).

    NAME_PLATE_UNIT_ADDED caches the token as plate.nameplate.unit alongside the
    GUID; REMOVED clears both. GUID is retained only as the stable cache key
    (debuffCache/threatMemory/combatColorCache). Only GetUnitField("npcFlags")
    remains -- it has no token/vanilla equivalent.

commit ce1c49fcbb
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date:   Fri Jul 24 18:20:06 2026 -0500

    Make focus/focustarget event-driven via ClassicAPI unit events

    ClassicAPI now fires UNIT_* (health/mana/aura/...) with arg1 == "focus" and
    arg1 == "focustarget", observed per-unit like target/party/raid. Both frames
    already registered those events (focus/focustarget are in pfValidUnits) and
    their OnEvent already matches arg1 == label, so the 0.2s polling ticks were
    pure workarounds for the missing events. Drop both ticks; the frames now
    refresh on-event like target, with range/glow still on the shared 0.5s state
    pass and PLAYER_FOCUS_CHANGED still driving assign/clear.
2026-07-26 18:32:36 -05:00

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 ~= 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
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 == UNKOWNBEING 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