Files
ScaledLifeTap/ScaledLifeTap.lua
T
2026-10-30 16:56:31 +00:00

396 lines
16 KiB
Lua

-- ScaledLifeTap 4.1
-- Original by Kimilly (thanks to Aredia of Gilneas for the macro and research).
-- Casts the highest Life Tap rank whose mana return fits in your missing mana.
--
-- Macro: /slt cast (or the old /script ScaledLifeTap_CastLifeTap() )
local L = SCALEDLIFETAP_L
local Scanner = ScaledLifeTap_Scanner
-- Base health converted per rank, and spell damage coefficient per rank.
-- Coefficient is 0.8 reduced by the sub-level-20 penalty for ranks 1 (lvl 6) and 2 (lvl 16).
local BASE = { 30, 75, 140, 220, 310, 424 }
local COEF = { 0.38, 0.68, 0.8, 0.8, 0.8, 0.8 }
local DEFAULTS = {
overtap = true, -- cast rank 1 if no rank fits but mana is not full
debug = false,
reserve = 0, -- never tap below this % of max health
bonus = 0, -- extra spell damage to add (e.g. elixirs/buffs the scanner can't see)
source = "auto", -- "auto" = ClassicAPI > Nampower > built-in; or force one (incl. bonusscanner)
calibrate = true, -- learn a mana correction from the actual mana each tap gives
gainFactor = 1,
samples = 0,
}
local spellIndex = {} -- [rank] = spellbook index
local maxRank = 0
local spellsDirty = true
local talentMult, talentDirty = 1, true
local function msg(text) DEFAULT_CHAT_FRAME:AddMessage("|cff9482c9ScaledLifeTap:|r " .. text) end
local function dbg(text) if SLT_Config and SLT_Config.debug then msg(text) end end
---------------------------------------------------------------------------
-- spellbook / talents (cached, refreshed on events)
---------------------------------------------------------------------------
local function scanSpellbook()
for k in pairs(spellIndex) do spellIndex[k] = nil end
maxRank = 0
local i = 1
while true do
local name, rankText = GetSpellName(i, BOOKTYPE_SPELL)
if not name then break end -- end of spellbook (v3 looped forever here)
if name == L.LIFETAP then
local _, _, r = string.find(rankText or "", "(%d+)")
r = tonumber(r) or 1
if BASE[r] then
spellIndex[r] = i
if r > maxRank then maxRank = r end
end
end
i = i + 1
end
spellsDirty = false
end
local function scanTalents()
talentMult = 1
for tab = 1, (GetNumTalentTabs() or 0) do
for t = 1, (GetNumTalents(tab) or 0) do
local name, _, _, _, rank = GetTalentInfo(tab, t)
if name == L.IMP_LIFETAP then
talentMult = 1 + 0.1 * (rank or 0)
talentDirty = false
return
end
end
end
talentDirty = false
end
---------------------------------------------------------------------------
-- spell damage
---------------------------------------------------------------------------
-- Each source returns (genericDamage, shadowDamage) or nil when unavailable.
-- ClassicAPI and Nampower read the client's own computed shadow spell damage,
-- which already includes gear, enchants, buffs and set bonuses.
local SOURCES = {
classicapi = function()
if type(GetSpellBonusDamage) ~= "function" then return nil end
local ok, v = pcall(GetSpellBonusDamage, 6) -- 6 = Shadow (1-based)
if ok and tonumber(v) then return 0, tonumber(v) end
end,
nampower = function()
if type(GetSpellPower) ~= "function" or type(GetNampowerVersion) ~= "function" then return nil end
local ok, _, _, _, _, _, shadow = pcall(GetSpellPower) -- 6th value = Shadow
if ok and tonumber(shadow) then return 0, tonumber(shadow) end
end,
bonusscanner = function()
if not (BonusScanner and BonusScanner.GetBonus and IsAddOnLoaded("BonusScanner")) then return nil end
return tonumber(BonusScanner:GetBonus("DMG")) or 0, tonumber(BonusScanner:GetBonus("SHADOWDMG")) or 0
end,
internal = function() return Scanner:Get() end,
}
local SOURCE_NAMES = { classicapi = "ClassicAPI", nampower = "Nampower", bonusscanner = "BonusScanner", internal = "built-in scanner" }
-- BonusScanner is deliberately not in the auto order: it gives the same totals
-- as the built-in scanner on English clients and likely 0 on German/French ones.
-- It can still be forced with /slt source bonusscanner.
local AUTO_ORDER = { "classicapi", "nampower", "internal" }
local LIVE_SOURCES = { classicapi = true, nampower = true } -- these already include buffs
function ScaledLifeTap_GetDamageBonus()
local want = SLT_Config.source or "auto"
local dmg, shadow, key
if want ~= "auto" and SOURCES[want] then
dmg, shadow = SOURCES[want]()
key = want
end
if not dmg then
for i = 1, table.getn(AUTO_ORDER) do
key = AUTO_ORDER[i]
dmg, shadow = SOURCES[key]()
if dmg then break end
end
end
local total = (dmg or 0) + (shadow or 0) + (SLT_Config.bonus or 0)
return total, dmg or 0, shadow or 0, SOURCE_NAMES[key], LIVE_SOURCES[key]
end
---------------------------------------------------------------------------
-- mana check: compares predicted mana per Life Tap with what the server
-- actually gave, and learns a correction factor if your server's numbers
-- differ from standard 1.12. The actual amount comes from Nampower's
-- SPELL_ENERGIZE_BY_SELF event or ClassicAPI's combat log (SPELL_ENERGIZE).
---------------------------------------------------------------------------
local lastCast -- { rank, predicted, time }
local warned = false
local combatLog -- frame listening to ClassicAPI's combat log, only while a tap is pending
local function stopCombatLog()
if combatLog then combatLog:UnregisterEvent("COMBAT_LOG_EVENT_UNFILTERED") end
end
local function onEnergize(spellId, powerType, amount, nameChecked)
if not lastCast or powerType ~= 0 or not tonumber(amount) then return end
if GetTime() - lastCast.time > 3 then lastCast = nil; stopCombatLog(); return end
if not nameChecked and type(GetSpellNameAndRankForId) == "function" then
local ok, name = pcall(GetSpellNameAndRankForId, spellId)
if not ok or name ~= L.LIFETAP then return end
end
local base = lastCast.predicted / (SLT_Config.gainFactor or 1)
lastCast = nil
stopCombatLog()
if base <= 0 or not SLT_Config.calibrate then return end
local ratio = amount / base
if ratio < 0.5 or ratio > 2 then return end -- ignore nonsense
-- smooth toward the observed ratio
SLT_Config.gainFactor = (SLT_Config.gainFactor or 1) * 0.7 + ratio * 0.3
SLT_Config.samples = (SLT_Config.samples or 0) + 1
if not warned and SLT_Config.samples >= 3 and math.abs(SLT_Config.gainFactor - 1) > 0.03 then
warned = true
msg(string.format("Life Tap is giving %d%% of the predicted mana on this server; adjusting automatically (/slt calibrate reset to undo).",
SLT_Config.gainFactor * 100 + 0.5))
end
end
-- ClassicAPI combat log. COMBAT_LOG_EVENT_UNFILTERED fires for everything
-- near you, so the frame registers it only after a tap and drops it as soon
-- as the energize arrives (or 3 seconds pass).
local function hasCombatLog()
return type(CombatLogGetCurrentEventInfo) == "function" and type(UnitGUID) == "function"
end
local function onCombatLog()
local _, sub, _, src, _, _, _, _, _, _, _, spellId, spellName, _, amount, powerType = CombatLogGetCurrentEventInfo()
if sub ~= "SPELL_ENERGIZE" or spellName ~= L.LIFETAP then return end
if src ~= UnitGUID("player") then return end
onEnergize(spellId, powerType, amount, true)
end
local function watchCombatLog()
if not hasCombatLog() then return end
if not combatLog then
combatLog = CreateFrame("Frame")
combatLog:SetScript("OnEvent", onCombatLog)
end
combatLog:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED")
if C_Timer and C_Timer.After then C_Timer.After(3.5, stopCombatLog) end
end
---------------------------------------------------------------------------
-- rank selection
---------------------------------------------------------------------------
-- returns rank (or nil), hpCost, manaGain
function ScaledLifeTap_ChooseRank()
if spellsDirty then scanSpellbook() end
if talentDirty then scanTalents() end
if maxRank == 0 then return nil end
local bonus = ScaledLifeTap_GetDamageBonus()
local mult = talentMult * (SLT_Config.gainFactor or 1)
local hp = UnitHealth("player")
local missing = UnitManaMax("player") - UnitMana("player")
local floor = UnitHealthMax("player") * (SLT_Config.reserve or 0) / 100
for r = maxRank, 1, -1 do
if spellIndex[r] then
local cost = BASE[r] + COEF[r] * bonus
local gain = cost * mult
if hp - cost > floor and missing >= gain then return r, cost, gain, true end
end
end
if SLT_Config.overtap and missing > 0 then
local r = 1
while r <= maxRank and not spellIndex[r] do r = r + 1 end
if spellIndex[r] then
local cost = BASE[r] + COEF[r] * bonus
if hp - cost > floor then return r, cost, cost * mult, false end
end
end
return nil
end
function ScaledLifeTap_CastLifeTap()
local r, cost, gain, fits = ScaledLifeTap_ChooseRank()
if r then
CastSpell(spellIndex[r], BOOKTYPE_SPELL)
-- only fully-fitting casts are used for the mana check (overtap casts may hit the mana cap)
if fits and SLT_Config.calibrate then
lastCast = { rank = r, predicted = gain, time = GetTime() }
watchCombatLog()
else
lastCast = nil
end
dbg(string.format("Rank %d: -%d hp, +%d mana", r, cost, gain))
elseif maxRank == 0 then
dbg("Life Tap not found in spellbook.")
else
dbg("No rank fits (mana full or health too low).")
end
end
-- backwards compatibility with v3 macros
ScaledLifetap_CastLifeTap = ScaledLifeTap_CastLifeTap
function ScaledLifeTap_Debug(arg)
if arg == nil then return SLT_Config.debug end
SLT_Config.debug = (arg == 1 or arg == true)
end
---------------------------------------------------------------------------
-- status / slash commands
---------------------------------------------------------------------------
local function onoff(v) return v and "|cff00ff00on|r" or "|cffff0000off|r" end
-- Status as plain lines, shared by /slt status and the options page.
-- summary: spell damage line, multiplier line. ranks: one line per known rank.
local function statusLines()
if spellsDirty then scanSpellbook() end
if talentDirty then scanTalents() end
local total, dmg, shadow, source, live = ScaledLifeTap_GetDamageBonus()
local summary, ranks = {}, {}
if live then
summary[1] = string.format("Spell damage: %d shadow (incl. buffs) + %d manual = %d [%s]", shadow, SLT_Config.bonus or 0, total, source)
else
summary[1] = string.format("Spell damage: %d + %d shadow + %d manual = %d [%s, gear only]", dmg, shadow, SLT_Config.bonus or 0, total, source)
end
local mult = talentMult * (SLT_Config.gainFactor or 1)
summary[2] = string.format("Improved Life Tap x%.1f, server correction x%.3f (%d samples)",
talentMult, SLT_Config.gainFactor or 1, SLT_Config.samples or 0)
if maxRank == 0 then
ranks[1] = "Life Tap is not in your spellbook."
end
for r = maxRank, 1, -1 do
if spellIndex[r] then
local cost = BASE[r] + COEF[r] * total
table.insert(ranks, string.format("Rank %d: -%d health, +%d mana", r, cost, cost * mult))
end
end
return summary, ranks
end
local function printStatus()
local summary, ranks = statusLines()
for i = 1, table.getn(summary) do msg(summary[i]) end
msg(string.format("overtap %s | reserve %d%% | mana check %s", onoff(SLT_Config.overtap), SLT_Config.reserve or 0, onoff(SLT_Config.calibrate)))
for i = 1, table.getn(ranks) do msg(" " .. ranks[i]) end
end
---------------------------------------------------------------------------
-- small API for Options.lua
---------------------------------------------------------------------------
ScaledLifeTap = {
DEFAULTS = DEFAULTS,
StatusLines = statusLines,
SourceNames = SOURCE_NAMES,
IsSourceAvailable = function(key)
if key == "auto" or key == "internal" then return true end
return SOURCES[key] and SOURCES[key]() ~= nil
end,
IsLiveSource = function()
local _, _, _, _, live = ScaledLifeTap_GetDamageBonus()
return live and true or false
end,
CanCalibrate = function()
return type(GetNampowerVersion) == "function" or hasCombatLog()
end,
ResetCalibration = function()
SLT_Config.gainFactor, SLT_Config.samples = 1, 0
warned = false
end,
Rescan = function()
Scanner:MarkDirty(); spellsDirty = true; talentDirty = true
end,
}
local function toggleArg(arg, current)
if arg == "on" then return true elseif arg == "off" then return false end
return not current
end
SLASH_SCALEDLIFETAP1 = "/slt"
SLASH_SCALEDLIFETAP2 = "/scaledlifetap"
SlashCmdList["SCALEDLIFETAP"] = function(input)
local _, _, cmd, arg = string.find(string.lower(input or ""), "^%s*(%S*)%s*(.-)%s*$")
if cmd == "cast" then
ScaledLifeTap_CastLifeTap()
elseif cmd == "status" then
printStatus()
elseif cmd == "overtap" then
SLT_Config.overtap = toggleArg(arg, SLT_Config.overtap); msg("overtap " .. onoff(SLT_Config.overtap))
elseif cmd == "debug" then
SLT_Config.debug = toggleArg(arg, SLT_Config.debug); msg("debug " .. onoff(SLT_Config.debug))
elseif cmd == "reserve" and tonumber(arg) then
local v = math.max(0, math.min(90, tonumber(arg)))
SLT_Config.reserve = v; msg("health reserve " .. v .. "%")
elseif cmd == "bonus" and tonumber(arg) then
SLT_Config.bonus = tonumber(arg); msg("manual spell damage bonus " .. SLT_Config.bonus)
elseif cmd == "calibrate" and (arg == "on" or arg == "off" or arg == "") then
SLT_Config.calibrate = toggleArg(arg, SLT_Config.calibrate); msg("mana check " .. onoff(SLT_Config.calibrate))
elseif cmd == "calibrate" and arg == "reset" then
ScaledLifeTap.ResetCalibration(); msg("mana correction reset")
elseif cmd == "source" and (arg == "auto" or SOURCES[arg]) then
SLT_Config.source = arg; Scanner:MarkDirty(); msg("damage source " .. arg)
elseif cmd == "rescan" then
ScaledLifeTap.Rescan(); printStatus()
elseif cmd == "options" or cmd == "config" then
if ScaledLifeTap.OpenOptions then ScaledLifeTap.OpenOptions() end
else
msg("commands: (or open the options window with /slt options)")
msg(" /slt cast - cast the best Life Tap rank (put this in a macro)")
msg(" /slt status - show bonus, talent and per-rank numbers")
msg(" /slt overtap [on|off] - fall back to rank 1 when no rank fits (" .. onoff(SLT_Config.overtap) .. ")")
msg(" /slt reserve <0-90> - never tap below this % health (" .. (SLT_Config.reserve or 0) .. "%)")
msg(" /slt bonus <n> - extra spell damage to add, e.g. from elixirs (" .. (SLT_Config.bonus or 0) .. ")")
msg(" /slt source auto|classicapi|nampower|bonusscanner|internal - where spell damage comes from (" .. SLT_Config.source .. ")")
msg(" /slt calibrate [on|off|reset] - learn actual mana per tap (needs Nampower or ClassicAPI) (" .. onoff(SLT_Config.calibrate) .. ")")
msg(" /slt debug [on|off] - print each cast (" .. onoff(SLT_Config.debug) .. ")")
msg(" /slt rescan - force a gear/spellbook/talent rescan")
msg(" /slt options - open the options window")
end
end
---------------------------------------------------------------------------
-- events
---------------------------------------------------------------------------
SLT_Config = SLT_Config or {}
local f = CreateFrame("Frame", "ScaledLifeTapEventFrame")
f:RegisterEvent("VARIABLES_LOADED")
f:RegisterEvent("UNIT_INVENTORY_CHANGED")
f:RegisterEvent("SPELLS_CHANGED")
f:RegisterEvent("LEARNED_SPELL_IN_TAB")
f:RegisterEvent("CHARACTER_POINTS_CHANGED")
f:RegisterEvent("PLAYER_ENTERING_WORLD")
-- Nampower custom event (registering it enables it on Nampower 4.5+; older
-- versions need /run SetCVar("NP_EnableSpellEnergizeEvents","1") ). Harmless otherwise.
pcall(f.RegisterEvent, f, "SPELL_ENERGIZE_BY_SELF")
f:SetScript("OnEvent", function()
if event == "SPELL_ENERGIZE_BY_SELF" then
onEnergize(arg3, arg4, arg5)
elseif event == "UNIT_INVENTORY_CHANGED" then
if arg1 == "player" then Scanner:MarkDirty() end
elseif event == "SPELLS_CHANGED" or event == "LEARNED_SPELL_IN_TAB" then
spellsDirty = true
elseif event == "CHARACTER_POINTS_CHANGED" then
talentDirty = true
elseif event == "PLAYER_ENTERING_WORLD" then
Scanner:MarkDirty(); spellsDirty = true; talentDirty = true
elseif event == "VARIABLES_LOADED" then
if type(SLT_Config) ~= "table" then SLT_Config = {} end
-- v3 stored "overtapEnabled"
if SLT_Config.overtapEnabled ~= nil and SLT_Config.overtap == nil then
SLT_Config.overtap = SLT_Config.overtapEnabled
end
SLT_Config.overtapEnabled = nil
for k, v in pairs(DEFAULTS) do
if SLT_Config[k] == nil then SLT_Config[k] = v end
end
end
end)
-- make sure defaults exist even before VARIABLES_LOADED (e.g. when loaded late)
for k, v in pairs(DEFAULTS) do if SLT_Config[k] == nil then SLT_Config[k] = v end end