Puppeteer/libs/HealPredict.lua
2025-08-21 20:55:59 -07:00

653 lines
21 KiB
Lua

-- Predicts healing based on previously seen healing.
-- This library currently is not independent and relies on some Puppeteer code.
if not PTUtil.IsSuperWowPresent() then
return
end
PTHealPredict = {}
PTUtil.SetEnvironment(PTHealPredict)
local _G = getfenv(0)
local compost = AceLibrary("Compost-2.0")
RelevantGUIDs = {} -- A set of GUIDs to listen to
IncomingHeals = {} -- Key: Receiver | Value: List of incoming casts
IncomingHots = {} -- Key: Receiver | Value: {HoT Name: {"caster", "id", "heal"}}
Casts = {} -- Key: Caster | Value: {"targets", "spellID", "startTime"}
LastCastedSpells = {}
-- A cache of expected heal values for spells. These values are saved.
-- Key: Spell ID | Value: Typical heal value for the spell ID
HealCache = {}
-- Since every healer heals for different values based on talents and gear,
-- everyone's heal values are cached
-- Key: Name | Value: Array: {Spell ID: Heal value}
PlayerHealCache = {}
ResurrectionTargets = {} -- Key: Receiver | Value: {Caster: {"startTime", "castTime"}}
-- An array of functions that listen to changes to incoming healing
Listeners = {}
local print
local colorize = PTUtil.Colorize
local PRAYER_OF_HEALING_IDS = PTUtil.ToSet({596, 996, 10960, 10961, 25316})
local ResurrectionSpells = PTUtil.ToSet({
"Resurrection", "Revive Champion", "Redemption", "Ancestral Spirit", "Rebirth"
})
local TRACKED_HOTS = PTUtil.ToSet({
"Rejuvenation", "Regrowth", -- Druid
"Renew", -- Priest
"Mend Pet", -- Hunter
"First Aid" -- Generic
})
function OnLoad()
print = Puppeteer.print
if not PTHealCache then
setglobal("PTHealCache", {})
end
HealCache = PTHealCache
local hardcodedHots = {
-- Bandages
[746] = 11, -- Linen
[1159] = 19, -- Heavy Linen
[3267] = 23, -- Wool
[3268] = 43, -- Heavy Wool
[7926] = 50, -- Silk
[7927] = 80, -- Heavy Silk
[10838] = 100, -- Mageweave
[10839] = 138, -- Heavy Mageweave
[18608] = 170, -- Runecloth
[18610] = 250, -- Heavy Runecloth
-- Mend Pet
[136] = 20,
[3111] = 38,
[3661] = 68,
[3662] = 103,
[13542] = 142,
[13543] = 189,
[13544] = 245
}
for k, v in pairs(hardcodedHots) do
HealCache[k.."-HoT"] = v
end
if not PTPlayerHealCache then
setglobal("PTPlayerHealCache", {})
end
if not PTPlayerHealCache[GetRealmName()] then
PTPlayerHealCache[GetRealmName()] = {}
end
PlayerHealCache = PTPlayerHealCache[GetRealmName()]
end
-- Get the expected heal of a player's spell
function GetExpectedHeal(playerName, spellID)
local playerCache = PlayerHealCache[playerName]
if not playerCache or not playerCache[spellID] then
return GetGenericExpectedHeal(spellID)
end
return playerCache[spellID]
end
function GetGenericExpectedHeal(spellID)
return HealCache[spellID] or 0
end
-- Returns all incoming healing and incoming direct healing
function GetIncomingHealing(guid)
local healing = 0
local casts = IncomingHeals[guid]
if casts then
for _, cast in pairs(casts) do
healing = healing + cast["heal"]
end
end
local directHealing = healing
local hots = IncomingHots[guid]
if hots then
for _, hot in pairs(hots) do
healing = healing + hot["heal"]
end
end
return healing, directHealing
end
-- Returns non-HoT incoming healing
function GetIncomingDirectHealing(guid)
local healing = 0
local casts = IncomingHeals[guid]
if casts then
for _, cast in pairs(casts) do
healing = healing + cast["heal"]
end
end
return healing
end
-- To mimick HealComm, but this currently only accepts GUIDs
function getHeal(guid)
return GetIncomingHealing(guid)
end
function IsBeingResurrected(guid)
return ResurrectionTargets[guid] ~= nil
end
function GetResurrectionCount(guid)
local resses = ResurrectionTargets[guid]
if not resses then
return 0
end
local count = 0
for _ in pairs(resses) do
count = count + 1
end
return count
end
-- Used for Prayer of Healing to add incoming healing to multiple players
function AddIncomingMultiCast(targets, caster, spellID, healAmount, castTime)
Casts[caster] = compost:AcquireHash(
"targets", targets,
"spellID", spellID,
"startTime", GetTime()
)
for _, target in ipairs(targets) do
AddIncomingCast(target, caster, spellID, healAmount, castTime, true)
end
end
function AddIncomingCast(target, caster, spellID, healAmount, castTime, multi)
if not multi then
Casts[caster] = compost:AcquireHash(
"targets", compost:Acquire(target),
"spellID", spellID,
"startTime", GetTime()
)
end
local targetTable = IncomingHeals[target]
if not targetTable then
targetTable = {}
IncomingHeals[target] = targetTable
end
targetTable[caster] = compost:AcquireHash(
"spellID", spellID,
"heal", healAmount,
"castTime", castTime,
"startTime", GetTime()
)
UpdateTarget(target)
end
function RemoveIncomingCast(caster)
local cast = Casts[caster]
if cast then
for _, target in ipairs(cast["targets"]) do
local incomingHeals = IncomingHeals[target]
compost:Reclaim(incomingHeals[caster])
incomingHeals[caster] = nil
UpdateTarget(target)
end
compost:Reclaim(cast["targets"])
compost:Reclaim(cast)
Casts[caster] = nil
end
end
function GetCurrentCast(caster)
local cast = Casts[caster]
if cast then
return IncomingHeals[cast["targets"][1]][caster]
end
end
function AddHot(target, caster, spellID, spellName, healAmount)
local hot = compost:AcquireHash(
"caster", caster,
"id", spellID,
"heal", healAmount,
"startTime", GetTime()
)
local targetTable = IncomingHots[target]
if not targetTable then
targetTable = {}
IncomingHots[target] = targetTable
end
targetTable[spellName] = hot
UpdateTarget(target)
end
function UpdateTarget(target)
for _, listener in ipairs(Listeners) do
listener(target, GetIncomingHealing(target))
end
end
local function trimDecimal(number, places)
local factor = 10 ^ places
return math.floor(number * factor) / factor
end
local GENERIC_CHANGE_FACTOR = 0.05
local PLAYER_CHANGE_FACTOR = 0.25
function UpdateCache(heal, name)
name = name or UnitName("player")
local lastCastedSpell = LastCastedSpells[name]
LastCastedSpells[name] = nil
if not lastCastedSpell then
return
end
local spellID = lastCastedSpell["spellID"]
if not PRAYER_OF_HEALING_IDS[spellID] then
if lastCastedSpell["target"] == "" then
--print(colorize("Don't have a target of spell cast for "..name.."'s "..spellID, 1, 0, 0))
return
end
local cache = PTUnit.Get(lastCastedSpell["target"])
if not cache or cache == PTUnit then
--print(colorize("Could not find "..name.."'s unit while updating cache!", 1, 0, 0))
return
end
if cache.HasHealingModifier then
--print(colorize("Not updating cache for "..name.."'s "..spellID.." because of healing modifier", 0.5, 0.5, 0.5))
return
end
end
-- Update the generic cache
if not HealCache[spellID] then
HealCache[spellID] = heal
else
local prevHeal = HealCache[spellID]
local adjustedHeal = trimDecimal(prevHeal + ((heal - prevHeal) * GENERIC_CHANGE_FACTOR), 2)
HealCache[spellID] = adjustedHeal
print(colorize("Generic "..spellID..": "..prevHeal.." -> "..adjustedHeal, 0, 0.8, 0.8))
end
if not PlayerHealCache[name] then
PlayerHealCache[name] = {}
end
-- Update the player-specific cache
local playerCache = PlayerHealCache[name]
if not playerCache[spellID] then
playerCache[spellID] = heal
print(colorize("Created cache for "..name.."'s "..spellID, 1, 0.5, 1))
end
local prevHeal = playerCache[spellID]
local adjustedHeal = trimDecimal(prevHeal + ((heal - prevHeal) * PLAYER_CHANGE_FACTOR), 2)
playerCache[spellID] = adjustedHeal
playerCache["lastSeen"] = time()
compost:Reclaim(lastCastedSpell)
print(colorize(name.."'s "..spellID..": "..prevHeal.." -> "..adjustedHeal, 0, 0.8, 0.2))
end
function UpdateCacheHot(spellName, heal, targetGuid, targetName, casterGuid, casterName)
if not IncomingHots[targetGuid] then
return
end
local hots = IncomingHots[targetGuid]
if not hots[spellName] then
return
end
heal = tonumber(heal) or 0
local hot = hots[spellName]
if hot["heal"] ~= heal then
local prevHeal = hot["heal"]
hot["heal"] = heal
UpdateTarget(targetGuid)
if not PlayerHealCache[casterName] then
PlayerHealCache[casterName] = {}
end
local spellID = hot["id"]
local cache = PTUnit.Get(targetGuid)
if not cache or cache == PTUnit then
print(colorize("Could not find "..targetName.."'s unit while updating cache!", 1, 0, 0))
return
end
if cache.HasHealingModifier then
print(colorize("Not updating cache for "..casterName.."'s "..spellID.." because of healing modifier", 0.5, 0.5, 0.5))
return
end
-- Update the player-specific cache
local playerCache = PlayerHealCache[casterName]
spellID = spellID.."-HoT"
if not playerCache[spellID] then
playerCache[spellID] = heal
print(colorize("Created cache for "..casterName.."'s "..spellID.." ("..spellName..")", 1, 0.5, 1))
end
PlayerHealCache[casterName][spellID] = heal
print(colorize(casterName.."'s "..spellID.." ("..spellName..")"..": "..prevHeal.." -> "..heal, 0, 0.8, 0.2))
end
end
function RemoveHoT(spellName, targetGuid)
if not IncomingHots[targetGuid] then
return
end
if not IncomingHots[targetGuid][spellName] then
return
end
local hot = IncomingHots[targetGuid][spellName]
-- A hack needed because overwritten HoTs cause the previous HoT to be removed,
-- which happens after the UNIT_CASTEVENT
if hot["startTime"] + 0.5 > GetTime() and not hot["swiftmend"] then
return
end
compost:Reclaim(hot)
IncomingHots[targetGuid][spellName] = nil
UpdateTarget(targetGuid)
end
local roster = AceLibrary("RosterLib-2.0")
local function getGuidFromLogName(name)
local petName, owner = PTUtil.cmatch(name, "%s (%s)")
local unit
if owner then -- A pet is being healed
local ownerUnit = roster:GetUnitIDFromName(owner)
if not ownerUnit then
return
end
unit = roster:GetPetFromOwner(ownerUnit)
else
unit = roster:GetUnitIDFromName(name)
end
if not unit then
-- Check custom units
for _, guid in pairs(PTUnitProxy.CustomUnitGUIDMap) do
if UnitName(guid) == name then
return guid
end
end
end
if unit then
local _, guid = UnitExists(unit)
return guid
end
end
local function getSelfGuid()
local _, guid = UnitExists("player")
return guid
end
local eventFrame = CreateFrame("Frame", "PTHealPredictCasts")
eventFrame:RegisterEvent("UNIT_CASTEVENT")
eventFrame:SetScript("OnEvent", function()
local caster, target, event, spellID, duration = arg1, arg2, arg3, arg4, arg5
if not RelevantGUIDs[caster] then
return
end
if not UnitIsPlayer(caster) then
return
end
local spellName = SpellInfo(spellID)
if ResurrectionSpells[spellName] then
if event == "START" then
if target then
if not ResurrectionTargets[target] then
ResurrectionTargets[target] = compost:GetTable()
end
local resses = ResurrectionTargets[target]
resses[caster] = compost:AcquireHash("startTime", GetTime(), "castTime", duration)
end
elseif event == "CAST" or event == "FAIL" then
local cast = Casts[caster]
if cast then
local target = cast["targets"][1]
local resses = ResurrectionTargets[target]
if resses[caster] then
compost:Reclaim(resses[caster])
resses[caster] = nil
if not next(resses) then
compost:Reclaim(resses)
ResurrectionTargets[target] = nil
end
end
end
end
UpdateTarget(target)
end
if event == "CAST" or event == "CHANNEL" then
-- Swiftmend could cause Rejuvenation or Regrowth to end very quickly,
-- so there needs to be a flag to allow the removal of the HoT
if spellName == "Swiftmend" then
if IncomingHots[target] then
local hots = IncomingHots[target]
if hots["Rejuvenation"] then
hots["Rejuvenation"]["swiftmend"] = true
end
if hots["Regrowth"] then
hots["Regrowth"]["swiftmend"] = true
end
end
end
if TRACKED_HOTS[spellName] then
if spellName == "Mend Pet" then -- Mend pet doesn't "target" the pet, so we have to acquire the pet
local units = PTGuidRoster.GetUnits(caster)
if not units then
return
end
local casterUnit = units[1]
if not casterUnit then
return
end
local petUnit = roster:GetPetFromOwner(casterUnit)
if not petUnit then
return
end
local _, guid = UnitExists(petUnit)
target = guid
end
AddHot(target, caster, spellID, spellName, GetExpectedHeal(UnitName(caster), spellID.."-HoT"))
end
end
-- Check started cast spell ID to prevent instant mid-cast spells from removing incoming healing
local currentCast = GetCurrentCast(caster)
if event == "CAST" and currentCast and currentCast["spellID"] == spellID then
RemoveIncomingCast(caster)
LastCastedSpells[UnitName(caster)] = compost:AcquireHash("unit", caster, "target", target, "spellID", spellID)
return
end
if event == "START" or event == "FAIL" then
RemoveIncomingCast(caster)
end
if event == "START" then
if target and target ~= "" and UnitCanAssist(caster, target) and duration > 0 then
local casterName = UnitName(caster)
local expectedHeal = GetExpectedHeal(casterName, spellID)
AddIncomingCast(target, caster, spellID, expectedHeal, duration)
elseif PRAYER_OF_HEALING_IDS[spellID] then
local inRange = PTUtil.GetSurroundingPartyMembers(caster)
local casterName = UnitName(caster)
local expectedHeal = GetExpectedHeal(casterName, spellID)
AddIncomingMultiCast(inRange, caster, spellID, expectedHeal, duration)
end
end
end)
-- Because the prediction code is not currently bullet-proof to infinite incoming heals, we're checking once a while for old casts
local GARBAGE_CHECK_INTERVAL = 10
local nextGarbageCheck = GetTime() + GARBAGE_CHECK_INTERVAL
eventFrame:SetScript("OnUpdate", function()
if GetTime() > nextGarbageCheck then
local time = GetTime()
nextGarbageCheck = time + GARBAGE_CHECK_INTERVAL
for receiver, casts in pairs(IncomingHeals) do
for caster, cast in pairs(casts) do
if cast["startTime"] + 15 < time then
print(colorize("Removed "..caster.."'s heal on "..receiver.." for taking too long", 1, 0, 0))
compost:Reclaim(cast)
casts[caster] = nil
UpdateTarget(receiver)
end
end
end
for receiver, hots in pairs(IncomingHots) do
for name, hot in pairs(hots) do
if hot["startTime"] + 25 < time then
print(colorize("Removed "..hot["caster"].."'s "..name.." (HoT) on "..
receiver.." for taking too long", 1, 0, 0))
compost:Reclaim(hot)
hots[name] = nil
UpdateTarget(receiver)
end
end
end
for target, resses in pairs(ResurrectionTargets) do
for caster, res in pairs(resses) do
if res["startTime"] + 20 < time then
print(colorize("Removed "..caster.."'s resurrection on "..
target.." for taking too long", 1, 0, 0))
compost:Reclaim(res)
resses[caster] = nil
ResurrectionTargets[target] = nil
UpdateTarget(target)
end
end
if not ResurrectionTargets[target] then -- Must've been removed
compost:Reclaim(resses)
end
end
end
end)
local cmatch = PTUtil.cmatch
local combatLogFrame = CreateFrame("Frame", "PTHealPredictCombatLog")
combatLogFrame:RegisterEvent("CHAT_MSG_SPELL_SELF_BUFF")
combatLogFrame:RegisterEvent("CHAT_MSG_SPELL_FRIENDLYPLAYER_BUFF")
combatLogFrame:RegisterEvent("CHAT_MSG_SPELL_HOSTILEPLAYER_BUFF") -- Needed to see casts coming from other players to yourself
combatLogFrame:RegisterEvent("CHAT_MSG_SPELL_PARTY_BUFF")
combatLogFrame:RegisterEvent("CHAT_MSG_SPELL_PET_BUFF")
combatLogFrame:SetScript("OnEvent", function()
if string.find(arg1, "critically") then
return
end
if string.find(arg1, "Bonus Healing") then
return
end
local spell, targetName, heal = cmatch(arg1, HEALEDSELFOTHER) -- "Your %s heals %s for %d."
if spell and targetName and heal then
UpdateCache(tonumber(heal))
return
end
local spell, heal = cmatch(arg1, HEALEDSELFSELF) -- "Your %s heals you for %d."
if spell and heal then
UpdateCache(tonumber(heal))
return
end
local name, spell, heal = cmatch(arg1, HEALEDOTHERSELF) -- "%s's %s heals you for %d."
if name and spell and heal then
UpdateCache(tonumber(heal), name)
return
end
local name, spell, targetName, heal = cmatch(arg1, HEALEDOTHEROTHER) -- "%s's %s heals %s for %d."
if name and spell and targetName and heal then
UpdateCache(tonumber(heal), name)
return
end
end)
local periodicCombatLogFrame = CreateFrame("Frame", "PTHealPredictPerCombatLog")
periodicCombatLogFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS")
periodicCombatLogFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_BUFFS")
periodicCombatLogFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_PARTY_BUFFS")
periodicCombatLogFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_BUFFS")
periodicCombatLogFrame:SetScript("OnEvent", function()
local heal, spell = cmatch(arg1, PERIODICAURAHEALSELFSELF) -- "You gain %d health from %s."
if heal and spell then
local selfName = UnitName("player")
local selfGuid = getSelfGuid()
UpdateCacheHot(spell, heal, selfGuid, selfName, selfGuid, selfName)
return
end
local name, heal, spell = cmatch(arg1, PERIODICAURAHEALSELFOTHER) -- "%s gains %d health from your %s."
if name and heal and spell then
local casterGuid = getSelfGuid()
local targetGuid = getGuidFromLogName(name)
UpdateCacheHot(spell, heal, targetGuid, name, casterGuid, UnitName(casterGuid))
return
end
local heal, name, spell = cmatch(arg1, PERIODICAURAHEALOTHERSELF) -- "You gain %d health from %s's %s."
if heal and name and spell then
local casterGuid = getGuidFromLogName(name)
local targetGuid = getSelfGuid()
UpdateCacheHot(spell, heal, targetGuid, UnitName("player"), casterGuid, name)
return
end
local targetName, heal, name, spell = cmatch(arg1, PERIODICAURAHEALOTHEROTHER) -- "%s gains %d health from %s's %s."
if targetName and heal and name and spell then
local casterGuid = getGuidFromLogName(name)
local targetGuid = getGuidFromLogName(targetName)
UpdateCacheHot(spell, heal, targetGuid, targetName, casterGuid, name)
return
end
end)
local auraCombatLogFrame = CreateFrame("Frame", "PTHealPredictAuraCombatLog")
auraCombatLogFrame:RegisterEvent("CHAT_MSG_SPELL_AURA_GONE_OTHER")
auraCombatLogFrame:RegisterEvent("CHAT_MSG_SPELL_AURA_GONE_PARTY")
auraCombatLogFrame:RegisterEvent("CHAT_MSG_SPELL_AURA_GONE_SELF")
auraCombatLogFrame:SetScript("OnEvent", function()
local spell, name = cmatch(arg1, AURAREMOVEDOTHER) -- "%s fades from %s."
if spell and name and name ~= "you" then
local guid = getGuidFromLogName(name)
if not guid then
return
end
RemoveHoT(spell, guid)
return
end
local spell = cmatch(arg1, AURAREMOVEDSELF) -- "%s fades from you."
if spell then
RemoveHoT(spell, getSelfGuid())
return
end
end)
-- Set the GUIDs to listen to
function SetRelevantGUIDs(guidArray)
RelevantGUIDs = PTUtil.ToSet(guidArray)
end
-- Provided listener function will receive the arguments: Updated GUID, Updated Incoming Healing
function HookUpdates(listener)
table.insert(Listeners, listener)
end