mirror of
https://github.com/brues-code/pfUI.git
synced 2026-09-22 07:36:56 +00:00
00292ca3b4
commit70b1e66c2dAuthor: Brues <5278969+brues-code@users.noreply.github.com> Date: Sun Jul 26 18:30:18 2026 -0500 Use SetSize/SetShown and simplify minimap/map commit7b6bfe9975Author: 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. commit4c65c38647Author: Brues <5278969+brues-code@users.noreply.github.com> Date: Sat Jul 25 10:34:33 2026 -0500 Remove unused RangeCache local in UnitInRange commit6c96bbbe6cAuthor: 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. commit830e0a0be1Author: 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. commitc0da63657dAuthor: 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. commit11a6302c2dAuthor: 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. commit67c126eac8Author: 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 commita6cf157518Author: 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. commit4b1ba99b4fAuthor: 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. commit7df4aa6d50Author: 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. commitb0bf2fd869Author: 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(). commit0b06961333Author: 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. commit3e6210086eAuthor: 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. commit63001b7b0cAuthor: 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. commitce1c49fcbbAuthor: 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.
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 ~= 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 |