mirror of
https://github.com/brues-code/SuperCleveRoidMacros.git
synced 2026-09-16 03:38:00 +00:00
c8bc4c134d
Two handlers opened by testing arg1 against a fixed set of unit tokens, so every other unit's copy of UNIT_AURA reached Lua only to be compared away -- in a raid that is the bulk of them. ClassicAPI's RegisterUnitEvent pushes that filter into the client, so the handler only runs for the units named. Filtering is per (frame, event), so the other events sharing the libdebuff frame are unaffected. Utility.lua UNIT_AURA -> "target" (libdebuff seeding) ComboPointTracker UNIT_AURA -> "target", "player" Extensions register through CleveRoids.RegisterEvent, so ExtensionsManager gains a RegisterUnitEvent alongside it. Separately, the main frame's unit state streams (UNIT_AURA / UNIT_HEALTH / UNIT_POWER, or the _GUID variants under Nampower v2.39+) are now registered only in event-driven mode. Their handlers are wholly wrapped in `realtime == 0`, so with realtime on every one of them crossed into Lua and returned immediately while the OnUpdate did the refreshing. Registration is applied at load, again at VARIABLES_LOADED once the saved setting is readable, and whenever /cleveroid realtime flips it. Those three stay plain RegisterEvent calls: their handlers ignore the unit and refresh every macro, and a conditional may name any unit ([@party3,hp:<50], @focus, @mouseover), so narrowing the token set would leave those icons stale. The _GUID variants could not be filtered anyway -- their first argument is a GUID, not a unit token.
10041 lines
408 KiB
Lua
10041 lines
408 KiB
Lua
--[[
|
|
Author: Dennis Werner Garske (DWG) / brian / Mewtiny
|
|
License: MIT License
|
|
]]
|
|
local _G = _G or getfenv(0)
|
|
local CleveRoids = _G.CleveRoids or {}
|
|
local _G = getfenv(0)
|
|
local strfind = string.find
|
|
local find = string.find -- Add this line
|
|
local gsub = string.gsub
|
|
local lower = string.lower
|
|
local floor = math.floor
|
|
local pairs = pairs
|
|
local ipairs = ipairs
|
|
local type = type
|
|
local tonumber = tonumber
|
|
local tostring = tostring
|
|
local GetTime = GetTime
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- PERFORMANCE: Memoized rank-stripping function
|
|
-- Caches results to avoid repeated string.gsub + string allocation on every call.
|
|
-- Two patterns are used across the codebase; we handle both with one function.
|
|
-- Cache grows with unique spell names (~200-300 total), negligible memory.
|
|
--------------------------------------------------------------------------------
|
|
local _stripRankCache = {}
|
|
local _STRIP_RANK_CACHE_MAX = 512
|
|
|
|
function CleveRoids.StripRank(name)
|
|
if not name then return nil end
|
|
local cached = _stripRankCache[name]
|
|
if cached then return cached end
|
|
-- Handles both "Spell (Rank 4)" and "Spell(Rank 4)" with flexible whitespace
|
|
cached = gsub(name, "%s*%(%s*Rank%s+%d+%s*%)", "")
|
|
_stripRankCache[name] = cached
|
|
return cached
|
|
end
|
|
|
|
function CleveRoids.ClearStripRankCache()
|
|
_stripRankCache = {}
|
|
end
|
|
|
|
-- Spells with physical damage + resistable CC effect (weapon-dependent)
|
|
-- These spells deal physical damage that ALWAYS lands (unless dodged/parried/blocked),
|
|
-- but apply a CC effect that can be resisted independently.
|
|
-- When the CC is resisted, we should NOT record physical immunity.
|
|
--
|
|
-- Master Strike (Warrior): 35% weapon damage + weapon-dependent CC
|
|
-- Mace: Disorient (3s) Sword: Disarm (3s) Axe: Immobilize (4s)
|
|
-- Polearm: Dismount Fist: Knockdown (2s) Dagger: Silence (3s)
|
|
-- Staff: Self-buff (parry, no CC)
|
|
local SPLIT_CC_SPELLS = {
|
|
[54016] = true, -- Master Strike (Mace) - Disorient
|
|
[54017] = true, -- Master Strike (Sword) - Disarm
|
|
[54018] = true, -- Master Strike (Axe) - Immobilize
|
|
[54019] = true, -- Master Strike (Polearm) - Dismount
|
|
[54020] = true, -- Master Strike (Fist) - Knockdown
|
|
[54021] = true, -- Master Strike (Staff) - Parry buff
|
|
[54022] = true, -- Master Strike (Dagger) - Silence
|
|
[54023] = true, -- Master Strike (Base)
|
|
[54024] = true, -- Master Strike (Level 0 variant)
|
|
}
|
|
|
|
-- Name-based lookup for split CC spells (for combat log parsing where only name is available)
|
|
-- All weapon variants share the same display name, so we need to check by name too
|
|
local SPLIT_CC_SPELL_NAMES = {
|
|
["Master Strike"] = true,
|
|
}
|
|
|
|
-- GUID normalization: ensure all GUIDs are strings for consistent table key lookups
|
|
-- In Lua, table["123"] is different from table[123], so we must normalize
|
|
function CleveRoids.NormalizeGUID(guid)
|
|
if not guid then return nil end
|
|
return tostring(guid)
|
|
end
|
|
|
|
-- Get GUID for a unit token via Nampower GetUnitGUID (v3.0+)
|
|
-- Returns: normalized GUID string, or nil
|
|
function CleveRoids.GetGUID(unit)
|
|
local guid = GetUnitGUID(unit)
|
|
if guid then return tostring(guid) end
|
|
return nil
|
|
end
|
|
|
|
-- Check if a unit is truly dead using Nampower health (sees through feign death)
|
|
-- Returns: true if unit HP is 0 (truly dead), false if alive or feigning
|
|
function CleveRoids.IsUnitDead(unit)
|
|
if CleveRoids.NampowerAPI then
|
|
local hp = CleveRoids.NampowerAPI.GetUnitHealth(unit)
|
|
if hp ~= nil then
|
|
return hp == 0
|
|
end
|
|
end
|
|
return UnitIsDead(unit)
|
|
end
|
|
|
|
-- Check if a unit is truly dead or a ghost using Nampower health
|
|
-- Returns: true if unit HP is 0 or unit is a ghost
|
|
function CleveRoids.IsUnitDeadOrGhost(unit)
|
|
return CleveRoids.IsUnitDead(unit) or UnitIsGhost(unit)
|
|
end
|
|
|
|
-- Hidden tooltip for scanning spell info
|
|
local SpellScanTooltip = nil
|
|
|
|
-- Create hidden tooltip for scanning (once)
|
|
local function GetSpellScanTooltip()
|
|
if not SpellScanTooltip then
|
|
SpellScanTooltip = CreateFrame("GameTooltip", "CleveRoidsSpellScanTooltip", nil, "GameTooltipTemplate")
|
|
SpellScanTooltip:SetOwner(WorldFrame, "ANCHOR_NONE")
|
|
end
|
|
return SpellScanTooltip
|
|
end
|
|
|
|
-- Cache for spell durations from tooltip
|
|
local cachedSpellDurations = {}
|
|
local spellDurationCacheTime = {}
|
|
local SPELL_CACHE_DURATION = 0.5 -- Re-scan every 0.5 seconds (haste can change mid-fight)
|
|
|
|
-- Get a spell's slot in the spellbook by name (finds highest rank by default)
|
|
-- If targetRank is specified (e.g., "Rank 5"), finds that specific rank
|
|
local function GetSpellSlotByName(targetSpellName, targetRank)
|
|
local foundSlot = nil
|
|
local foundRank = nil
|
|
local i = 1
|
|
while true do
|
|
local spellName, spellRank = GetSpellName(i, BOOKTYPE_SPELL)
|
|
if not spellName then break end
|
|
if spellName == targetSpellName then
|
|
if targetRank then
|
|
-- Looking for specific rank
|
|
if spellRank == targetRank then
|
|
return i, BOOKTYPE_SPELL
|
|
end
|
|
else
|
|
-- Looking for highest rank - keep scanning to find the last one
|
|
foundSlot = i
|
|
foundRank = spellRank
|
|
end
|
|
end
|
|
i = i + 1
|
|
end
|
|
-- Return highest rank found (last match in spellbook order)
|
|
return foundSlot, BOOKTYPE_SPELL
|
|
end
|
|
|
|
-- Parse channel duration from tooltip text
|
|
-- Matches patterns like "for 5 sec" or "over 8 sec" at the end of descriptions
|
|
local function ParseChannelDurationFromText(text)
|
|
if not text then return nil end
|
|
-- Match "for X sec" pattern (channels like Arcane Missiles, Drain Life)
|
|
local _, _, dur = string.find(text, "for (%d+%.?%d*) sec")
|
|
if dur then
|
|
return tonumber(dur)
|
|
end
|
|
-- Match "over X sec" pattern (some DoT/HoT tooltips)
|
|
local _, _, over = string.find(text, "over (%d+%.?%d*) sec")
|
|
if over then
|
|
return tonumber(over)
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Scan spell tooltip for channel/cast duration
|
|
-- Returns duration in seconds, or nil if not found
|
|
-- Tries Nampower GetSpellDuration (DBC, v2.38+) first, falls back to tooltip scanning
|
|
function CleveRoids.GetSpellDurationFromTooltip(spellName)
|
|
if not spellName then return nil end
|
|
|
|
-- Check cache
|
|
local now = GetTime()
|
|
if cachedSpellDurations[spellName] and spellDurationCacheTime[spellName] then
|
|
if now - spellDurationCacheTime[spellName] < SPELL_CACHE_DURATION then
|
|
return cachedSpellDurations[spellName]
|
|
end
|
|
end
|
|
|
|
-- Try Nampower DBC lookup first (v2.38+ GetSpellDuration) — faster and works for all spells
|
|
if CleveRoids.NampowerAPI and CleveRoids.NampowerAPI.features
|
|
and CleveRoids.NampowerAPI.features.hasGetSpellDuration then
|
|
-- Resolve name to ID
|
|
local spellID = nil
|
|
if GetSpellIdForName then
|
|
spellID = GetSpellIdForName(spellName)
|
|
end
|
|
if spellID then
|
|
local durationMs = CleveRoids.NampowerAPI.GetSpellDuration(spellID)
|
|
if durationMs and durationMs > 0 then
|
|
local duration = durationMs / 1000
|
|
cachedSpellDurations[spellName] = duration
|
|
spellDurationCacheTime[spellName] = now
|
|
return duration
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Fallback: tooltip scanning (works for player spellbook spells)
|
|
local slot, bookType = GetSpellSlotByName(spellName)
|
|
if not slot then return nil end
|
|
|
|
local tooltip = GetSpellScanTooltip()
|
|
tooltip:ClearLines()
|
|
tooltip:SetSpell(slot, bookType)
|
|
|
|
-- Scan tooltip lines for duration
|
|
for i = 1, tooltip:NumLines() do
|
|
local leftText = getglobal("CleveRoidsSpellScanTooltipTextLeft" .. i)
|
|
if leftText then
|
|
local text = leftText:GetText()
|
|
local duration = ParseChannelDurationFromText(text)
|
|
if duration then
|
|
-- Cache the result
|
|
cachedSpellDurations[spellName] = duration
|
|
spellDurationCacheTime[spellName] = now
|
|
return duration
|
|
end
|
|
end
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
-- Get channel duration for a spell by ID
|
|
-- Tries Nampower GetSpellDuration (DBC) first, falls back to tooltip via name
|
|
function CleveRoids.GetChannelDurationFromTooltipByID(spellID)
|
|
if not spellID then return nil end
|
|
|
|
-- Try Nampower DBC lookup first (v2.38+)
|
|
if CleveRoids.NampowerAPI and CleveRoids.NampowerAPI.features
|
|
and CleveRoids.NampowerAPI.features.hasGetSpellDuration then
|
|
local durationMs = CleveRoids.NampowerAPI.GetSpellDuration(spellID)
|
|
if durationMs and durationMs > 0 then
|
|
return durationMs / 1000
|
|
end
|
|
end
|
|
|
|
-- Fallback: resolve name and scan tooltip
|
|
local spellName = C_Spell.GetSpellName(spellID)
|
|
if not spellName then return nil end
|
|
return CleveRoids.GetSpellDurationFromTooltip(spellName)
|
|
end
|
|
|
|
if type(hooksecurefunc) ~= "function" then
|
|
function hooksecurefunc(arg1, arg2, arg3)
|
|
local tgt, fname, post
|
|
|
|
if type(arg1) == "string" and type(arg2) == "function" and arg3 == nil then
|
|
fname, post = arg1, arg2
|
|
local orig = _G[fname]
|
|
if type(orig) ~= "function" then return end
|
|
_G[fname] = function(...)
|
|
local args = arg or {}
|
|
local n = (type(args) == "table" and args.n) or 0
|
|
local r = { orig(unpack(args, 1, n)) }
|
|
post(unpack(args, 1, n))
|
|
return unpack(r, 1, table.getn(r))
|
|
end
|
|
return
|
|
end
|
|
|
|
if type(arg1) == "table" and type(arg2) == "string" and type(arg3) == "function" then
|
|
tgt, fname, post = arg1, arg2, arg3
|
|
local orig = tgt[fname]
|
|
if type(orig) ~= "function" then return end
|
|
tgt[fname] = function(...)
|
|
local args = arg or {}
|
|
local n = (type(args) == "table" and args.n) or 0
|
|
local r = { orig(unpack(args, 1, n)) }
|
|
post(unpack(args, 1, n))
|
|
return unpack(r, 1, table.getn(r))
|
|
end
|
|
return
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
|
|
function CleveRoids.Seq(_, i)
|
|
return (i or 0) + 1
|
|
end
|
|
|
|
-- PERFORMANCE: Trim cache to avoid repeated gsub pattern matching
|
|
local _trimCache = {}
|
|
local _trimCacheSize = 0
|
|
local _TRIM_CACHE_MAX = 512
|
|
|
|
function CleveRoids.Trim(str)
|
|
if not str then
|
|
return nil
|
|
end
|
|
|
|
-- PERFORMANCE: Check cache first
|
|
local cached = _trimCache[str]
|
|
if cached ~= nil then
|
|
return cached
|
|
end
|
|
|
|
local result = string.gsub(str, "^%s*(.-)%s*$", "%1")
|
|
|
|
-- Cache the result (limit cache size)
|
|
if _trimCacheSize < _TRIM_CACHE_MAX then
|
|
_trimCache[str] = result
|
|
_trimCacheSize = _trimCacheSize + 1
|
|
end
|
|
|
|
return result
|
|
end
|
|
|
|
do
|
|
local _G = _G or getfenv(0)
|
|
local CleveRoids = _G.CleveRoids or {}
|
|
_G.CleveRoids = CleveRoids
|
|
|
|
CleveRoids.__mo = CleveRoids.__mo or { sources = {}, current = nil, selfTriggered = false }
|
|
|
|
-- Priority levels for mouseover sources (higher = takes precedence)
|
|
-- All unit frame addons get priority 3, native event gets 2, tooltip fallback gets 1
|
|
local PRIORITY = {
|
|
-- Unit frame addons (priority 3)
|
|
pfui = 3,
|
|
blizz = 3,
|
|
aguf = 3, -- ag_UnitFrames
|
|
ctra = 3, -- CT_RaidAssist
|
|
ctuf = 3, -- CT_UnitFrames
|
|
cursive = 3, -- Cursive DoT tracker bars
|
|
duf = 3, -- DiscordUnitFrames
|
|
focus = 3, -- FocusFrame
|
|
grid = 3, -- Grid
|
|
ngrid = 3, -- NotGrid
|
|
praid = 3, -- PerfectRaid
|
|
sraid = 3, -- sRaidFrames
|
|
xperl = 3, -- X-Perl UnitFrames
|
|
luna = 3, -- LunaUnitFrames
|
|
dfr = 3, -- DragonflightReloaded
|
|
df3 = 3, -- Dragonflight3
|
|
-- Fallbacks (lower priority)
|
|
native = 2,
|
|
tooltip = 1,
|
|
}
|
|
|
|
-- Get priority for a source, handling prefixed keys like "pfui:player"
|
|
local function getPriority(source)
|
|
if PRIORITY[source] then
|
|
return PRIORITY[source]
|
|
end
|
|
-- Check for prefixed sources (e.g., "pfui:player" -> "pfui")
|
|
-- Using string.find with captures for Lua 5.0 compatibility
|
|
local _, _, prefix = string.find(source, "^(%w+):")
|
|
if prefix and PRIORITY[prefix] then
|
|
return PRIORITY[prefix]
|
|
end
|
|
return 0
|
|
end
|
|
|
|
local function getBest()
|
|
local bestSource, bestUnit, bestP = nil, nil, -1
|
|
for source, unit in pairs(CleveRoids.__mo.sources) do
|
|
if unit and unit ~= "" then
|
|
local p = getPriority(source)
|
|
if p > bestP then
|
|
bestP, bestSource, bestUnit = p, source, unit
|
|
end
|
|
end
|
|
end
|
|
return bestSource, bestUnit
|
|
end
|
|
|
|
-- Re-entrancy guard to prevent stack overflow from UI update cascades
|
|
local isUpdatingMouseover = false
|
|
|
|
local function apply(unit)
|
|
if _G.SetMouseoverUnit then
|
|
local resolved = unit
|
|
-- "focus" is not a valid unit token in 1.12.1; resolve to real token
|
|
if unit == "focus" then
|
|
if pfUI and pfUI.uf and pfUI.uf.focus and pfUI.uf.focus.label and pfUI.uf.focus.id
|
|
and UnitExists(pfUI.uf.focus.label .. pfUI.uf.focus.id) then
|
|
resolved = pfUI.uf.focus.label .. pfUI.uf.focus.id
|
|
else
|
|
-- No resolvable unit token; use fallback path
|
|
CleveRoids.mouseoverUnit = unit
|
|
if CleveRoids.QueueActionUpdate then CleveRoids.QueueActionUpdate() end
|
|
return
|
|
end
|
|
end
|
|
-- Available via SuperWoW or Nampower v2.37+
|
|
-- Set flag so UPDATE_MOUSEOVER_UNIT handler knows we triggered this
|
|
CleveRoids.__mo.selfTriggered = true
|
|
-- Use empty string instead of nil to properly clear mouseover.
|
|
-- SetMouseoverUnit(nil) doesn't properly clear the game's internal state,
|
|
-- causing UnitIsPlayer("mouseover") to return stale data (TurtleRP bug).
|
|
_G.SetMouseoverUnit(resolved or "")
|
|
else
|
|
CleveRoids.mouseoverUnit = unit
|
|
end
|
|
if CleveRoids.QueueActionUpdate then CleveRoids.QueueActionUpdate() end
|
|
end
|
|
|
|
function CleveRoids.SetMouseoverFrom(source, unit)
|
|
if not source or isUpdatingMouseover then return end
|
|
CleveRoids.__mo.sources[source] = unit
|
|
local _, bestUnit = getBest()
|
|
if bestUnit ~= CleveRoids.__mo.current then
|
|
CleveRoids.__mo.current = bestUnit
|
|
isUpdatingMouseover = true
|
|
apply(bestUnit)
|
|
isUpdatingMouseover = false
|
|
end
|
|
end
|
|
|
|
function CleveRoids.ClearMouseoverFrom(source, unitIfMatch)
|
|
if not source or isUpdatingMouseover then return end
|
|
if unitIfMatch and CleveRoids.__mo.sources[source] ~= unitIfMatch then
|
|
return
|
|
end
|
|
CleveRoids.__mo.sources[source] = nil
|
|
local _, bestUnit = getBest()
|
|
if bestUnit ~= CleveRoids.__mo.current then
|
|
CleveRoids.__mo.current = bestUnit
|
|
isUpdatingMouseover = true
|
|
apply(bestUnit)
|
|
isUpdatingMouseover = false
|
|
end
|
|
end
|
|
end
|
|
|
|
-- TODO: Get rid of one Split function. CleveRoids.splitString is ~10% slower
|
|
function CleveRoids.Split(s, p, trim)
|
|
local r, o = {}, 1
|
|
|
|
if not p or p == "" then
|
|
if trim then
|
|
s = CleveRoids.Trim(s)
|
|
end
|
|
for i = 1, string.len(s) do
|
|
table.insert(r, string.sub(s, i, 1))
|
|
end
|
|
return r
|
|
end
|
|
|
|
repeat
|
|
local b, e = string.find(s, p, o)
|
|
if b == nil then
|
|
local sub = string.sub(s, o)
|
|
table.insert(r, trim and CleveRoids.Trim(sub) or sub)
|
|
return r
|
|
end
|
|
if b > 1 then
|
|
local sub = string.sub(s, o, b - 1)
|
|
table.insert(r, trim and CleveRoids.Trim(sub) or sub)
|
|
else
|
|
table.insert(r, "")
|
|
end
|
|
o = e + 1
|
|
until false
|
|
end
|
|
|
|
function CleveRoids.splitString(str, seperatorPattern)
|
|
local tbl = {}
|
|
if not str then
|
|
return tbl
|
|
end
|
|
local pattern = "(.-)" .. seperatorPattern
|
|
local lastEnd = 1
|
|
local s, e, cap = string.find(str, pattern, 1)
|
|
|
|
while s do
|
|
if s ~= 1 or cap ~= "" then
|
|
table.insert(tbl,cap)
|
|
end
|
|
lastEnd = e + 1
|
|
s, e, cap = string.find(str, pattern, lastEnd)
|
|
end
|
|
|
|
if lastEnd <= string.len(str) then
|
|
cap = string.sub(str, lastEnd)
|
|
table.insert(tbl, cap)
|
|
end
|
|
|
|
return tbl
|
|
end
|
|
|
|
-- PERFORMANCE: Cache for splitStringIgnoringQuotes results
|
|
-- Key: str .. "|" .. separator_key, Value: result table
|
|
local _splitCache = {}
|
|
local _splitCacheSize = 0
|
|
local _SPLIT_CACHE_MAX = 256 -- Limit cache size to prevent unbounded growth
|
|
|
|
-- PERFORMANCE: Pre-built separator tables for common cases
|
|
local _defaultSeparator = { [";"] = ";" }
|
|
local _commaSeparator = { [","] = ",", [" "] = " " }
|
|
local _colonSeparator = { [":"] = ":" }
|
|
|
|
-- PERFORMANCE: Reusable result table pool
|
|
local _splitResultPool = {}
|
|
local _splitResultPoolSize = 0
|
|
|
|
local function getSplitResult()
|
|
if _splitResultPoolSize > 0 then
|
|
local r = _splitResultPool[_splitResultPoolSize]
|
|
_splitResultPool[_splitResultPoolSize] = nil
|
|
_splitResultPoolSize = _splitResultPoolSize - 1
|
|
-- Clear the table for reuse
|
|
for k in pairs(r) do r[k] = nil end
|
|
return r
|
|
end
|
|
return {}
|
|
end
|
|
|
|
-- Return a result to the pool (call when done iterating)
|
|
function CleveRoids.ReleaseSplitResult(result)
|
|
if result and _splitResultPoolSize < 16 then
|
|
_splitResultPoolSize = _splitResultPoolSize + 1
|
|
_splitResultPool[_splitResultPoolSize] = result
|
|
end
|
|
end
|
|
|
|
function CleveRoids.splitStringIgnoringQuotes(str, separator)
|
|
if not str then return {""} end
|
|
|
|
-- PERFORMANCE: Build cache key and check cache
|
|
local sepKey
|
|
if separator == nil then
|
|
sepKey = ";"
|
|
elseif type(separator) == "table" then
|
|
-- For table separators, use a simple identifier
|
|
if separator[1] == "," and separator[2] == " " then
|
|
sepKey = ",_"
|
|
else
|
|
sepKey = "t"
|
|
end
|
|
else
|
|
sepKey = separator
|
|
end
|
|
|
|
local cacheKey = str .. "|" .. sepKey
|
|
local cached = _splitCache[cacheKey]
|
|
if cached then
|
|
-- PERFORMANCE: Return cached result directly - callers should only iterate, not mutate
|
|
-- This avoids table allocation on every cache hit
|
|
return cached
|
|
end
|
|
|
|
local result = getSplitResult()
|
|
local temp = ""
|
|
local insideQuotes = false
|
|
local separators
|
|
|
|
-- PERFORMANCE: Use pre-built separator tables when possible
|
|
if separator == nil then
|
|
separators = _defaultSeparator
|
|
elseif type(separator) == "table" then
|
|
if separator[1] == "," and separator[2] == " " then
|
|
separators = _commaSeparator
|
|
elseif separator[1] == ":" then
|
|
separators = _colonSeparator
|
|
else
|
|
separators = {}
|
|
for _, s in separator do
|
|
separators[s] = s
|
|
end
|
|
end
|
|
else
|
|
if separator == ";" then
|
|
separators = _defaultSeparator
|
|
elseif separator == ":" then
|
|
separators = _colonSeparator
|
|
else
|
|
separators = { [separator] = separator }
|
|
end
|
|
end
|
|
|
|
-- PERFORMANCE: Use local references and avoid repeated function calls
|
|
local strlen = string.len(str)
|
|
local strsub = string.sub
|
|
local Trim = CleveRoids.Trim
|
|
local tinsert = table.insert
|
|
|
|
for i = 1, strlen do
|
|
local char = strsub(str, i, i)
|
|
|
|
if char == "\"" then
|
|
insideQuotes = not insideQuotes
|
|
temp = temp .. char
|
|
elseif separators[char] and not insideQuotes then
|
|
temp = Trim(temp)
|
|
if temp ~= "" then tinsert(result, temp) end
|
|
temp = ""
|
|
else
|
|
temp = temp .. char
|
|
end
|
|
end
|
|
|
|
if temp ~= "" then
|
|
temp = Trim(temp)
|
|
tinsert(result, temp)
|
|
end
|
|
|
|
if not next(result) then
|
|
result[1] = ""
|
|
end
|
|
|
|
-- PERFORMANCE: Cache the result (store a copy)
|
|
if _splitCacheSize < _SPLIT_CACHE_MAX then
|
|
local cacheCopy = {}
|
|
for i = 1, table.getn(result) do
|
|
cacheCopy[i] = result[i]
|
|
end
|
|
_splitCache[cacheKey] = cacheCopy
|
|
_splitCacheSize = _splitCacheSize + 1
|
|
end
|
|
|
|
return result
|
|
end
|
|
|
|
-- Splits a macro clause into its leading run of [group] blocks and the action
|
|
-- that follows, Blizzard-style: `[a][b] Spell` is two groups sharing one action.
|
|
-- Returns prefix (the leading whitespace and ? ! ~ flag characters, kept so a
|
|
-- variant re-parses exactly like the original), groups (array with .n, each
|
|
-- entry including its brackets) and restStart (index of the action text; 1 when
|
|
-- there are no groups). Quote-aware for " so a ] inside a quoted argument does
|
|
-- not close a group; not nesting-aware. An unclosed [ ends the scan at that [,
|
|
-- which is what the old greedy "%[(.+)%]" produced for such input.
|
|
function CleveRoids.ScanBracketGroups(msg)
|
|
local groups = { n = 0 }
|
|
if not msg then return "", groups, 1 end
|
|
|
|
local len = string.len(msg)
|
|
local _, prefixEnd = string.find(msg, "^[%s%?!~]*")
|
|
local prefix = string.sub(msg, 1, prefixEnd)
|
|
local i = prefixEnd + 1
|
|
|
|
while i <= len and string.sub(msg, i, i) == "[" do
|
|
local close = nil
|
|
local inQuotes = false
|
|
for j = i + 1, len do
|
|
local c = string.sub(msg, j, j)
|
|
if c == "\"" then
|
|
inQuotes = not inQuotes
|
|
elseif c == "]" and not inQuotes then
|
|
close = j
|
|
break
|
|
end
|
|
end
|
|
if not close then break end
|
|
|
|
groups.n = groups.n + 1
|
|
groups[groups.n] = string.sub(msg, i, close)
|
|
local _, wsEnd = string.find(msg, "^%s*", close + 1)
|
|
i = wsEnd + 1
|
|
end
|
|
|
|
if groups.n == 0 then
|
|
return prefix, groups, 1
|
|
end
|
|
return prefix, groups, i
|
|
end
|
|
|
|
-- Single-group variants of a multi-group clause: `[a][b] Spell` becomes
|
|
-- { "[a] Spell", "[b] Spell", n = 2 }. nil for anything with fewer than two
|
|
-- groups so callers take their normal path without allocating. Memoised per
|
|
-- clause string in CleveRoids.ExpandedGroups (false marks "nothing to expand").
|
|
function CleveRoids.ExpandBracketGroups(msg)
|
|
if not msg or not string.find(msg, "%[") then return nil end
|
|
|
|
local cached = CleveRoids.ExpandedGroups[msg]
|
|
if cached ~= nil then
|
|
return cached or nil
|
|
end
|
|
|
|
local variants = nil
|
|
local prefix, groups, restStart = CleveRoids.ScanBracketGroups(msg)
|
|
if groups.n > 1 then
|
|
local rest = string.sub(msg, restStart)
|
|
if rest ~= "" then rest = " " .. rest end
|
|
variants = { n = groups.n }
|
|
for i = 1, groups.n do
|
|
variants[i] = prefix .. groups[i] .. rest
|
|
end
|
|
end
|
|
|
|
CleveRoids.ExpandedGroups[msg] = variants or false
|
|
return variants
|
|
end
|
|
|
|
function CleveRoids.Print(...)
|
|
local c = "|cFF4477FFCleveR|r|cFFFFFFFFoid :: |r"
|
|
local out = ""
|
|
|
|
for i=1, arg.n, 1 do
|
|
out = out..tostring(arg[i]).." "
|
|
end
|
|
if WowLuaFrameOutput then
|
|
WowLuaFrameOutput:AddMessage(out)
|
|
else
|
|
if not DEFAULT_CHAT_FRAME:IsVisible() then
|
|
FCF_SelectDockFrame(DEFAULT_CHAT_FRAME)
|
|
end
|
|
DEFAULT_CHAT_FRAME:AddMessage(c..out)
|
|
end
|
|
end
|
|
|
|
function CleveRoids.PrintI(msg, depth)
|
|
depth = depth or 0
|
|
msg = string.rep(" ", depth) .. tostring(msg)
|
|
CleveRoids.Print(msg)
|
|
end
|
|
|
|
function CleveRoids.PrintT(t, depth)
|
|
depth = depth or 0
|
|
local cs = "|cffc8c864"
|
|
local ce = "|r"
|
|
|
|
if t == nil or type(t) ~= "table" then
|
|
CleveRoids.PrintI(t, depth)
|
|
else
|
|
for k, v in pairs(t) do
|
|
if type(v) == "table" then
|
|
CleveRoids.PrintI(cs..tostring(k)..":"..ce.." <TABLE>", depth)
|
|
CleveRoids.PrintT(v, depth + 1)
|
|
else
|
|
CleveRoids.PrintI(cs..tostring(k)..ce..": "..tostring(v), depth)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
CleveRoids.buttons = {
|
|
['1'] = 'LeftButton',
|
|
['2'] = 'RightButton',
|
|
['3'] = 'MiddleButton',
|
|
['4'] = 'Button4',
|
|
['5'] = 'Button5',
|
|
}
|
|
|
|
-- True while any mapped mouse button is held. Backs the argument-less [button] /
|
|
-- [nobutton], mirroring how a bare [mod] means "any modifier". Bare [nobutton] is
|
|
-- the practical "activated by a keybind, not a click" test.
|
|
function CleveRoids.AnyMouseButtonDown()
|
|
for _, name in pairs(CleveRoids.buttons) do
|
|
if IsMouseButtonDown(name) then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
CleveRoids.kmods = {
|
|
ctrl = IsControlKeyDown,
|
|
lctrl = IsLeftControlKeyDown,
|
|
rctrl = IsRightControlKeyDown,
|
|
alt = IsAltKeyDown,
|
|
lalt = IsLeftAltKeyDown,
|
|
ralt = IsRightAltKeyDown,
|
|
shift = IsShiftKeyDown,
|
|
lshift = IsLeftShiftKeyDown,
|
|
rshift = IsRightShiftKeyDown,
|
|
mod = IsModifierKeyDown,
|
|
nomod = function() return not IsModifierKeyDown() end,
|
|
}
|
|
|
|
-- Key code lookup table for [keydown:X] conditional (Nampower v2.41+)
|
|
-- Maps lowercase name -> integer key code from the Nampower KEY_DOWN/KEY_UP enum
|
|
CleveRoids.KEY_NAMES = {
|
|
-- Printable ASCII
|
|
space=32,
|
|
["0"]=48,["1"]=49,["2"]=50,["3"]=51,["4"]=52,
|
|
["5"]=53,["6"]=54,["7"]=55,["8"]=56,["9"]=57,
|
|
a=65,b=66,c=67,d=68,e=69,f=70,g=71,h=72,i=73,j=74,k=75,l=76,
|
|
m=77,n=78,o=79,p=80,q=81,r=82,s=83,t=84,u=85,v=86,w=87,x=88,y=89,z=90,
|
|
-- Special
|
|
tilde=256,
|
|
-- Numpad
|
|
numpad0=257,numpad1=258,numpad2=259,numpad3=260,numpad4=261,
|
|
numpad5=262,numpad6=263,numpad7=264,numpad8=265,numpad9=266,
|
|
-- Control keys
|
|
escape=512,enter=513,
|
|
-- Function keys
|
|
f1=768,f2=769,f3=770,f4=771,f5=772,f6=773,
|
|
f7=774,f8=775,f9=776,f10=777,f11=778,f12=779,
|
|
}
|
|
|
|
CleveRoids.operators = {
|
|
["<"] = "lt",
|
|
["lt"] = "<",
|
|
[">"] = "gt",
|
|
["gt"] = ">",
|
|
["="] = "eq",
|
|
["eq"] = "=",
|
|
["<="] = "lte",
|
|
["lte"] = "<=",
|
|
[">="] = "gte",
|
|
["gte"] = ">=",
|
|
["~="] = "ne",
|
|
["ne"] = "~=",
|
|
}
|
|
|
|
CleveRoids.comparators = {
|
|
lt = function(a, b) return (a < b) end,
|
|
gt = function(a, b) return (a > b) end,
|
|
eq = function(a, b) return (a == b) end,
|
|
lte = function(a, b) return (a <= b) end,
|
|
gte = function(a, b) return (a >= b) end,
|
|
ne = function(a, b) return (a ~= b) end,
|
|
}
|
|
CleveRoids.comparators["<"] = CleveRoids.comparators.lt
|
|
CleveRoids.comparators[">"] = CleveRoids.comparators.gt
|
|
CleveRoids.comparators["="] = CleveRoids.comparators.eq
|
|
CleveRoids.comparators["<="] = CleveRoids.comparators.lte
|
|
CleveRoids.comparators[">="] = CleveRoids.comparators.gte
|
|
CleveRoids.comparators["~="] = CleveRoids.comparators.ne
|
|
|
|
|
|
_G["CleveRoids"] = CleveRoids
|
|
|
|
local _G = _G or getfenv(0)
|
|
local CleveRoids = _G.CleveRoids or {}
|
|
_G.CleveRoids = CleveRoids
|
|
|
|
CleveRoids.libdebuff = CleveRoids.libdebuff or {}
|
|
local lib = CleveRoids.libdebuff
|
|
|
|
lib.objects = lib.objects or {}
|
|
lib.guidToName = lib.guidToName or {}
|
|
|
|
-- PFUI v7.4.3+ NAMPOWER-BASED DEBUFF TRACKING INTEGRATION
|
|
-- When pfUI v7.4.3+ with Nampower v2.26+ is available, we leverage pfUI's
|
|
-- comprehensive debuff tracking tables instead of duplicating the event handlers.
|
|
-- This provides accurate caster GUID tracking, rank checking, combo point
|
|
-- duration calculation, and miss/dodge/parry/resist/immune detection.
|
|
|
|
-- Enhanced tables (populated by pfUI or standalone Nampower handlers)
|
|
lib.ownDebuffs = lib.ownDebuffs or {} -- [targetGUID][spellName] = {startTime, duration, texture, rank, spellId}
|
|
lib.ownSlots = lib.ownSlots or {} -- [targetGUID][slot] = spellName (LEGACY - empty when pfUI 7.6+ is active)
|
|
lib.allSlots = lib.allSlots or {} -- [targetGUID][slot] = {spellName, casterGuid, isOurs} (LEGACY - empty when pfUI 7.6+ is active)
|
|
lib.slotOwnership = lib.slotOwnership or {} -- [targetGUID][auraSlot] = {casterGuid, spellName, spellId, isOurs} (pfUI 7.6+ GetUnitField edition)
|
|
lib.allAuraCasts = lib.allAuraCasts or {} -- [targetGUID][spellName][casterGuid] = {startTime, duration, rank}
|
|
lib.pendingCasts = lib.pendingCasts or {} -- [targetGUID][spellName] = {casterGuid, rank, time, comboPoints}
|
|
lib.recentMisses = lib.recentMisses or {} -- [targetGUID][spellName] = {time, spellId, targetName, reason} for miss/dodge/parry detection
|
|
lib.recentDeaths = lib.recentDeaths or {} -- [targetGUID] = GetTime() timestamp of UNIT_DIED (prevents false immunity on dead targets)
|
|
lib.recentCCHits = lib.recentCCHits or {} -- [targetGUID][ccType] = {count, lastHitTime} for DR tracking (prevents DR immunity → permanent)
|
|
lib.iconCache = lib.iconCache or {} -- [spellId] = texture (shared with pfUI 7.6 or standalone)
|
|
|
|
-- Buff tracking tables (parallel to debuff tables, standalone Nampower mode only)
|
|
-- Buff tables are NOT linked to pfUI — pfUI has no public buff tracking tables.
|
|
-- They remain empty when hasPfUIEnhanced=true since all buff event handlers are gated by it.
|
|
|
|
-- Flag indicating whether enhanced pfUI tracking is available
|
|
lib.hasPfUIEnhanced = false
|
|
lib.hasStandaloneNampower = false
|
|
|
|
-- Check if pfUI v7.4.3+ with enhanced libdebuff is available
|
|
function lib:HasEnhancedPfUILibdebuff()
|
|
-- Check pfUI exists
|
|
if not pfUI then return false end
|
|
|
|
-- Check version from TOC (stored in pfUI.version after ADDON_LOADED)
|
|
-- Minimum required: 7.4.3
|
|
local v = pfUI.version
|
|
if not v or not v.major then return false end
|
|
|
|
-- Version comparison: 7.4.3+
|
|
if v.major > 7 then
|
|
-- Continue to Nampower check
|
|
elseif v.major < 7 then
|
|
return false
|
|
else
|
|
-- major == 7
|
|
if v.minor > 4 then
|
|
-- Continue to Nampower check
|
|
elseif v.minor < 4 then
|
|
return false
|
|
else
|
|
-- minor == 4
|
|
if (v.fix or 0) < 3 then
|
|
return false
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Verify Nampower version based on pfUI version:
|
|
-- - pfUI 7.6+ (GetUnitField edition): requires Nampower v2.37.0+
|
|
-- - pfUI 7.4.3 to 7.5.x (legacy): requires Nampower v2.26+
|
|
if not GetNampowerVersion then return false end
|
|
local npMajor, npMinor, npPatch = GetNampowerVersion()
|
|
npPatch = npPatch or 0
|
|
|
|
-- Check if this is pfUI 7.6+ (GetUnitField edition) by looking for the new table
|
|
local isPfUI76 = pfUI.libdebuff_slot_ownership ~= nil
|
|
|
|
if isPfUI76 then
|
|
-- pfUI 7.6+ requires Nampower v2.37.0+
|
|
if npMajor < 2 then return false end
|
|
if npMajor == 2 and npMinor < 37 then return false end
|
|
else
|
|
-- Legacy pfUI 7.4.3-7.5.x requires Nampower v2.26+
|
|
if npMajor < 2 or (npMajor == 2 and npMinor < 26) then return false end
|
|
end
|
|
|
|
-- Finally verify the exposed tables exist
|
|
-- NOTE: libdebuff_own_slots and libdebuff_all_slots are LEGACY stubs (empty) in pfUI 7.6+
|
|
-- The new GetUnitField-based libdebuff uses libdebuff_slot_ownership instead
|
|
if not pfUI.libdebuff_own then return false end
|
|
if not pfUI.libdebuff_pending then return false end
|
|
-- Check for either legacy tables (pre-7.6) or new slotOwnership table (7.6+)
|
|
if not pfUI.libdebuff_all_slots and not pfUI.libdebuff_slot_ownership then return false end
|
|
|
|
return true
|
|
end
|
|
|
|
|
|
-- Icon caching helper: DBC lookup via GetSpellRecField
|
|
function lib:GetCachedIcon(spellId)
|
|
if not spellId then return nil end
|
|
if lib.iconCache[spellId] then return lib.iconCache[spellId] end
|
|
|
|
local texture = C_Spell.GetSpellTexture(spellId) or "Interface\\Icons\\INV_Misc_QuestionMark"
|
|
|
|
lib.iconCache[spellId] = texture
|
|
return texture
|
|
end
|
|
|
|
-- Initialize pfUI integration (call after ADDON_LOADED for pfUI)
|
|
function lib:InitPfUIIntegration()
|
|
if lib:HasEnhancedPfUILibdebuff() then
|
|
-- Link to pfUI's tables directly
|
|
lib.ownDebuffs = pfUI.libdebuff_own or lib.ownDebuffs
|
|
lib.allAuraCasts = pfUI.libdebuff_all_auras or lib.allAuraCasts
|
|
lib.pendingCasts = pfUI.libdebuff_pending or lib.pendingCasts
|
|
|
|
-- LEGACY slot tables (empty in pfUI 7.6+ GetUnitField edition, but kept for backwards compat)
|
|
lib.ownSlots = pfUI.libdebuff_own_slots or lib.ownSlots
|
|
lib.allSlots = pfUI.libdebuff_all_slots or lib.allSlots
|
|
|
|
-- NEW: GetUnitField-based slot ownership (pfUI 7.6+)
|
|
-- This replaces the legacy ownSlots/allSlots with stable aura slot tracking
|
|
if pfUI.libdebuff_slot_ownership then
|
|
lib.slotOwnership = pfUI.libdebuff_slot_ownership
|
|
end
|
|
|
|
lib.hasPfUIEnhanced = true
|
|
lib.hasStandaloneNampower = false
|
|
|
|
-- Unregister chat log events since SPELL_GO provides miss detection
|
|
if CleveRoidsLibDebuffLearnFrame then
|
|
CleveRoidsLibDebuffLearnFrame:UnregisterEvent("CHAT_MSG_SPELL_SELF_DAMAGE")
|
|
end
|
|
|
|
-- Delegate event unregistration and hook registration to pfUI compatibility layer.
|
|
-- All pfUI hook logic lives in Compatibility/pfUI.lua for easier debugging.
|
|
if CleveRoids.Compatibility_pfUI and CleveRoids.Compatibility_pfUI.SetupPfUIEventHooks then
|
|
CleveRoids.Compatibility_pfUI.SetupPfUIEventHooks(lib)
|
|
else
|
|
-- Fallback: call lib method directly (pfUI extension not yet loaded)
|
|
lib:RegisterPfUIHooks()
|
|
end
|
|
|
|
if CleveRoids.debug then
|
|
local v = pfUI.version
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff33ff99[libdebuff]|r pfUI v%d.%d.%d enhanced tracking enabled",
|
|
v.major, v.minor, v.fix or 0)
|
|
)
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
-- Check for standalone Nampower v2.26+ (when pfUI is not available or outdated)
|
|
-- Standalone mode uses SPELL_GO and AURA_CAST events which are available in v2.26+
|
|
if GetNampowerVersion then
|
|
local npMajor, npMinor, npPatch = GetNampowerVersion()
|
|
npPatch = npPatch or 0
|
|
local hasMinVersion = npMajor > 2 or (npMajor == 2 and npMinor >= 26)
|
|
if hasMinVersion then
|
|
lib.hasStandaloneNampower = true
|
|
lib.hasPfUIEnhanced = false
|
|
|
|
-- Unregister chat log events since SPELL_GO provides miss detection
|
|
if CleveRoidsLibDebuffLearnFrame then
|
|
CleveRoidsLibDebuffLearnFrame:UnregisterEvent("CHAT_MSG_SPELL_SELF_DAMAGE")
|
|
end
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff33ff99[libdebuff]|r Standalone Nampower v%d.%d.%d tracking enabled",
|
|
npMajor, npMinor, npPatch)
|
|
)
|
|
end
|
|
|
|
return true
|
|
end
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
-- Register pfUI libdebuff hooks (thin wrapper — delegates to Compatibility/pfUI.lua).
|
|
-- Hook logic has been moved to Extension.SetupPfUIEventHooks() for easier debugging.
|
|
-- This wrapper exists for backwards compatibility if called directly.
|
|
function lib:RegisterPfUIHooks()
|
|
if CleveRoids.Compatibility_pfUI and CleveRoids.Compatibility_pfUI.SetupPfUIEventHooks then
|
|
CleveRoids.Compatibility_pfUI.SetupPfUIEventHooks(lib)
|
|
end
|
|
end
|
|
|
|
-- Unique debuffs: Same spell overwrites itself when cast by different player
|
|
-- Only one instance can exist on a target (regardless of caster)
|
|
lib.uniqueDebuffs = lib.uniqueDebuffs or {
|
|
["Hunter's Mark"] = true,
|
|
["Scorpid Sting"] = true,
|
|
["Curse of Weakness"] = true,
|
|
["Curse of Recklessness"] = true,
|
|
["Curse of the Elements"] = true,
|
|
["Curse of Shadow"] = true,
|
|
["Curse of Tongues"] = true,
|
|
["Curse of Idiocy"] = true,
|
|
["Curse of Agony"] = true,
|
|
["Curse of Doom"] = true,
|
|
["Curse of Exhaustion"] = true,
|
|
["Judgement of Light"] = true,
|
|
["Judgement of Wisdom"] = true,
|
|
["Judgement of Justice"] = true,
|
|
["Judgement of the Crusader"] = true,
|
|
["Shadow Vulnerability"] = true,
|
|
["Shadow Weaving"] = true,
|
|
["Stormstrike"] = true,
|
|
["Sunder Armor"] = true,
|
|
["Expose Armor"] = true,
|
|
["Nightfall"] = true,
|
|
["Improved Scorch"] = true,
|
|
["Winter's Chill"] = true,
|
|
}
|
|
|
|
-- Debuff pairs that can overwrite each other (e.g., Faerie Fire variants)
|
|
lib.debuffOverwritePairs = lib.debuffOverwritePairs or {
|
|
-- Faerie Fire variants
|
|
["Faerie Fire"] = "Faerie Fire (Feral)",
|
|
["Faerie Fire (Feral)"] = "Faerie Fire",
|
|
|
|
-- Demo variants
|
|
["Demoralizing Shout"] = "Demoralizing Roar",
|
|
["Demoralizing Roar"] = "Demoralizing Shout",
|
|
}
|
|
|
|
-- Combo point abilities: Duration depends on combo points used
|
|
lib.combopointAbilities = lib.combopointAbilities or {
|
|
["Rip"] = true,
|
|
["Rupture"] = true,
|
|
["Kidney Shot"] = true,
|
|
["Slice and Dice"] = true,
|
|
["Expose Armor"] = true,
|
|
}
|
|
|
|
-- Check if a spell recently failed (miss/dodge/parry/resist/immune/evade)
|
|
-- @param spellName: The spell to check
|
|
-- @param targetGuid: (optional) If provided, check only for this target; otherwise check all targets
|
|
function lib:DidSpellFail(spellName, targetGuid)
|
|
if not spellName then return false end
|
|
local now = GetTime()
|
|
|
|
if targetGuid then
|
|
-- Check specific target
|
|
if lib.recentMisses[targetGuid] and lib.recentMisses[targetGuid][spellName] then
|
|
local data = lib.recentMisses[targetGuid][spellName]
|
|
if data and data.time and (now - data.time) < 1 then
|
|
return true
|
|
end
|
|
end
|
|
else
|
|
-- Check all targets (backwards compatibility)
|
|
for _, spells in pairs(lib.recentMisses) do
|
|
if spells[spellName] then
|
|
local data = spells[spellName]
|
|
if data and data.time and (now - data.time) < 1 then
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Recent combat log miss reasons (immune/reflect/evade)
|
|
-- Structure: [targetName][spellName] = { time = X, reason = "immune"|"reflect"|"evade" }
|
|
lib.recentCombatLogReasons = lib.recentCombatLogReasons or {}
|
|
|
|
-- Process miss reason from combat log correlation
|
|
-- Called 300ms after SPELL_GO with miss to allow combat log to arrive
|
|
-- Parameters:
|
|
-- verifyData: { spellName, spellId, targetGuid, targetName, checkTime }
|
|
function lib:ProcessMissReason(verifyData)
|
|
if not verifyData or not verifyData.spellName or not verifyData.targetName then
|
|
return
|
|
end
|
|
|
|
local spellName = verifyData.spellName
|
|
local targetName = verifyData.targetName
|
|
local targetGuid = verifyData.targetGuid
|
|
local spellId = verifyData.spellId
|
|
local now = GetTime()
|
|
|
|
-- Look up reason from combat log correlation table
|
|
local reason = nil
|
|
if lib.recentCombatLogReasons[targetName] and lib.recentCombatLogReasons[targetName][spellName] then
|
|
local reasonData = lib.recentCombatLogReasons[targetName][spellName]
|
|
-- Only use if within 1 second of the miss
|
|
if (now - reasonData.time) < 1.5 then
|
|
reason = reasonData.reason
|
|
end
|
|
-- Clean up
|
|
lib.recentCombatLogReasons[targetName][spellName] = nil
|
|
end
|
|
|
|
-- Also check for generic target entries (e.g., "Target is immune" without spell)
|
|
if not reason and lib.recentCombatLogReasons[targetName] and lib.recentCombatLogReasons[targetName]["_generic"] then
|
|
local reasonData = lib.recentCombatLogReasons[targetName]["_generic"]
|
|
if (now - reasonData.time) < 1.5 then
|
|
reason = reasonData.reason
|
|
end
|
|
lib.recentCombatLogReasons[targetName]["_generic"] = nil
|
|
end
|
|
|
|
-- Update recentMisses with the reason
|
|
if lib.recentMisses[targetGuid] and lib.recentMisses[targetGuid][spellName] then
|
|
lib.recentMisses[targetGuid][spellName].reason = reason
|
|
end
|
|
|
|
if CleveRoids.debug then
|
|
local reasonStr = reason or "unknown (no combat log match)"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff9900[ProcessMissReason]|r %s on %s: %s",
|
|
spellName, targetName, reasonStr)
|
|
)
|
|
end
|
|
|
|
-- Handle based on reason
|
|
if reason == "immune" then
|
|
-- Immunity already recorded by ParseImmunityCombatLog, no additional action needed
|
|
-- But we can verify CC immunity detection here for CC spells
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00ff00[Immunity Verified]|r %s is immune to %s", targetName, spellName)
|
|
)
|
|
end
|
|
|
|
elseif reason == "reflect" then
|
|
-- Reflect is a temporary state (buff-based), don't record as permanent immunity
|
|
-- Store in a temporary reflect tracking table for awareness
|
|
lib.recentReflects = lib.recentReflects or {}
|
|
lib.recentReflects[targetName] = lib.recentReflects[targetName] or {}
|
|
lib.recentReflects[targetName][spellName] = {
|
|
time = now,
|
|
spellId = spellId,
|
|
}
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff00ff[Spell Reflected]|r %s reflected %s", targetName, spellName)
|
|
)
|
|
end
|
|
|
|
elseif reason == "evade" then
|
|
-- Evade is a temporary mob state (out of range, pathing issue, etc.)
|
|
-- Don't record as immunity - this is NOT a permanent trait
|
|
lib.recentEvades = lib.recentEvades or {}
|
|
lib.recentEvades[targetName] = lib.recentEvades[targetName] or {}
|
|
lib.recentEvades[targetName][spellName] = {
|
|
time = now,
|
|
spellId = spellId,
|
|
}
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffaaaaaa[Evade]|r %s evaded %s (temporary state, not immunity)",
|
|
targetName, spellName)
|
|
)
|
|
end
|
|
end
|
|
|
|
-- Clean up recentMisses entry for this target/spell after processing
|
|
if lib.recentMisses and lib.recentMisses[targetGuid] then
|
|
lib.recentMisses[targetGuid][spellName] = nil
|
|
-- Clean up empty target tables
|
|
local isEmpty = true
|
|
for _ in pairs(lib.recentMisses[targetGuid]) do
|
|
isEmpty = false
|
|
break
|
|
end
|
|
if isEmpty then
|
|
lib.recentMisses[targetGuid] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Check if a spell was recently reflected by a target
|
|
-- Returns: true if reflected within last 3 seconds
|
|
function lib:WasSpellReflected(targetName, spellName)
|
|
if not targetName then return false end
|
|
|
|
local now = GetTime()
|
|
if lib.recentReflects and lib.recentReflects[targetName] then
|
|
if spellName then
|
|
-- Check specific spell
|
|
local data = lib.recentReflects[targetName][spellName]
|
|
if data and (now - data.time) < 3 then
|
|
return true
|
|
end
|
|
else
|
|
-- Check any spell on target
|
|
for _, data in pairs(lib.recentReflects[targetName]) do
|
|
if (now - data.time) < 3 then
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Check if target recently evaded
|
|
-- Returns: true if evaded within last 3 seconds
|
|
function lib:DidTargetEvade(targetName)
|
|
if not targetName then return false end
|
|
|
|
local now = GetTime()
|
|
if lib.recentEvades and lib.recentEvades[targetName] then
|
|
for _, data in pairs(lib.recentEvades[targetName]) do
|
|
if (now - data.time) < 3 then
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Periodic cleanup of stale tracking tables
|
|
-- Call this periodically (e.g., every 30 seconds) to prevent memory leaks
|
|
local lastCleanupTime = 0
|
|
function lib:CleanupStaleTrackingData()
|
|
local now = GetTime()
|
|
|
|
-- Only run every 30 seconds
|
|
if (now - lastCleanupTime) < 30 then
|
|
return
|
|
end
|
|
lastCleanupTime = now
|
|
|
|
local staleTime = 10 -- Entries older than 10 seconds are cleaned up
|
|
|
|
-- Clean recentCombatLogReasons
|
|
if lib.recentCombatLogReasons then
|
|
for targetName, spells in pairs(lib.recentCombatLogReasons) do
|
|
for spellName, data in pairs(spells) do
|
|
if (now - data.time) > staleTime then
|
|
spells[spellName] = nil
|
|
end
|
|
end
|
|
-- Remove empty target tables
|
|
if not next(spells) then
|
|
lib.recentCombatLogReasons[targetName] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Clean recentMisses
|
|
if lib.recentMisses then
|
|
for guid, spells in pairs(lib.recentMisses) do
|
|
for spellName, data in pairs(spells) do
|
|
if (now - data.time) > staleTime then
|
|
spells[spellName] = nil
|
|
end
|
|
end
|
|
if not next(spells) then
|
|
lib.recentMisses[guid] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Clean recentReflects
|
|
if lib.recentReflects then
|
|
for targetName, spells in pairs(lib.recentReflects) do
|
|
for spellName, data in pairs(spells) do
|
|
if (now - data.time) > staleTime then
|
|
spells[spellName] = nil
|
|
end
|
|
end
|
|
if not next(spells) then
|
|
lib.recentReflects[targetName] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Clean recentEvades
|
|
if lib.recentEvades then
|
|
for targetName, spells in pairs(lib.recentEvades) do
|
|
for spellName, data in pairs(spells) do
|
|
if (now - data.time) > staleTime then
|
|
spells[spellName] = nil
|
|
end
|
|
end
|
|
if not next(spells) then
|
|
lib.recentEvades[targetName] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Clean recentDeaths (dead target immunity guard)
|
|
if lib.recentDeaths then
|
|
for guid, deathTime in pairs(lib.recentDeaths) do
|
|
if (now - deathTime) > staleTime then
|
|
lib.recentDeaths[guid] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Clean pendingCasts (including combo point data from SPELL_GO)
|
|
if lib.pendingCasts then
|
|
for guid, spells in pairs(lib.pendingCasts) do
|
|
for spellName, data in pairs(spells) do
|
|
local capturedAt = data.capturedAt or data.time or 0
|
|
if (now - capturedAt) > staleTime then
|
|
spells[spellName] = nil
|
|
end
|
|
end
|
|
if not next(spells) then
|
|
lib.pendingCasts[guid] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
end
|
|
|
|
-- Get the caster GUID for a debuff on a target
|
|
-- Returns: casterGuid or nil
|
|
function lib:GetDebuffCaster(unit, spellName)
|
|
if not spellName then return nil end
|
|
|
|
local guid = CleveRoids.GetGUID(unit)
|
|
if not guid then return nil end
|
|
|
|
-- Check own debuffs first
|
|
if lib.ownDebuffs[guid] and lib.ownDebuffs[guid][spellName] then
|
|
local playerGuid = CleveRoids.GetGUID("player")
|
|
return playerGuid
|
|
end
|
|
|
|
-- Check slotOwnership (pfUI 7.6+ GetUnitField edition) for caster info
|
|
if lib.slotOwnership[guid] then
|
|
for auraSlot, slotData in pairs(lib.slotOwnership[guid]) do
|
|
if slotData.spellName == spellName then
|
|
return slotData.casterGuid
|
|
end
|
|
end
|
|
end
|
|
|
|
-- LEGACY: Check allSlots for caster info (pre-pfUI 7.6)
|
|
if lib.allSlots[guid] then
|
|
for slot, slotData in pairs(lib.allSlots[guid]) do
|
|
if slotData.spellName == spellName then
|
|
return slotData.casterGuid
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Check allAuraCasts
|
|
if lib.allAuraCasts[guid] and lib.allAuraCasts[guid][spellName] then
|
|
-- Return first caster found (there should typically be only one for unique debuffs)
|
|
for casterGuid, _ in pairs(lib.allAuraCasts[guid][spellName]) do
|
|
return casterGuid
|
|
end
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
-- Check if a debuff on a target is from the player
|
|
-- Returns: true if player's debuff, false otherwise
|
|
function lib:IsOurDebuff(unit, spellName)
|
|
if not spellName then return false end
|
|
|
|
local guid = CleveRoids.GetGUID(unit)
|
|
if not guid then return false end
|
|
|
|
-- Check own debuffs
|
|
if lib.ownDebuffs[guid] and lib.ownDebuffs[guid][spellName] then
|
|
return true
|
|
end
|
|
|
|
-- Check slotOwnership (pfUI 7.6+ GetUnitField edition) for isOurs flag
|
|
if lib.slotOwnership[guid] then
|
|
for auraSlot, slotData in pairs(lib.slotOwnership[guid]) do
|
|
if slotData.spellName == spellName then
|
|
return slotData.isOurs == true
|
|
end
|
|
end
|
|
end
|
|
|
|
-- LEGACY: Check allSlots for isOurs flag (pre-pfUI 7.6)
|
|
if lib.allSlots[guid] then
|
|
for slot, slotData in pairs(lib.allSlots[guid]) do
|
|
if slotData.spellName == spellName then
|
|
return slotData.isOurs == true
|
|
end
|
|
end
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
-- Get all tracked debuffs on a target
|
|
-- Returns: table of {spellName = {duration, timeleft, casterGuid, isOurs, stacks}}
|
|
function lib:GetAllDebuffsOnTarget(guid)
|
|
if not guid then return {} end
|
|
guid = CleveRoids.NormalizeGUID(guid)
|
|
|
|
local result = {}
|
|
local now = GetTime()
|
|
|
|
-- Gather from ownDebuffs
|
|
if lib.ownDebuffs[guid] then
|
|
for spellName, data in pairs(lib.ownDebuffs[guid]) do
|
|
if data.startTime and data.duration then
|
|
local timeleft = (data.startTime + data.duration) - now
|
|
if timeleft > 0 then
|
|
result[spellName] = {
|
|
duration = data.duration,
|
|
timeleft = timeleft,
|
|
casterGuid = nil, -- Player's own, get from UnitExists("player")
|
|
isOurs = true,
|
|
stacks = 1,
|
|
rank = data.rank,
|
|
slot = data.slot,
|
|
}
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Gather from allAuraCasts (other players' debuffs)
|
|
if lib.allAuraCasts[guid] then
|
|
for spellName, casterTable in pairs(lib.allAuraCasts[guid]) do
|
|
if not result[spellName] then
|
|
for casterGuid, data in pairs(casterTable) do
|
|
if data.startTime and data.duration then
|
|
local timeleft = (data.startTime + data.duration) - now
|
|
if timeleft > 0 then
|
|
result[spellName] = {
|
|
duration = data.duration,
|
|
timeleft = timeleft,
|
|
casterGuid = casterGuid,
|
|
isOurs = false,
|
|
stacks = 1,
|
|
rank = data.rank,
|
|
}
|
|
break -- Only store first active caster's data
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
return result
|
|
end
|
|
|
|
-- Check if a pending cast exists for a spell on a target
|
|
function lib:HasPendingCast(targetGuid, spellName)
|
|
if not targetGuid or not spellName then return false end
|
|
targetGuid = CleveRoids.NormalizeGUID(targetGuid)
|
|
|
|
if lib.pendingCasts[targetGuid] and lib.pendingCasts[targetGuid][spellName] then
|
|
local data = lib.pendingCasts[targetGuid][spellName]
|
|
-- Pending casts expire after 1 second
|
|
if data.time and (GetTime() - data.time) < 1 then
|
|
return true
|
|
end
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
-- PERSONAL DEBUFFS: Each player can have their own instance of these debuffs on the same target
|
|
-- These are DoTs, poisons, stings, and most CC effects
|
|
lib.personalDebuffs = lib.personalDebuffs or {
|
|
-- WARRIOR
|
|
[772] = 9, -- Rend (Rank 1)
|
|
[6546] = 12, -- Rend (Rank 2)
|
|
[6547] = 15, -- Rend (Rank 3)
|
|
[6548] = 18, -- Rend (Rank 4)
|
|
[11572] = 21, -- Rend (Rank 5)
|
|
[11573] = 21, -- Rend (Rank 6)
|
|
[11574] = 21, -- Rend (Rank 7)
|
|
|
|
[7372] = 15, -- Hamstring (Rank 1)
|
|
[7373] = 15, -- Hamstring (Rank 2)
|
|
[1715] = 15, -- Hamstring (Rank 3)
|
|
|
|
[12323] = 6, -- Piercing Howl
|
|
|
|
-- ROGUE
|
|
[2094] = 10, -- Blind
|
|
[21060] = 10, -- Blind (alternate?)
|
|
|
|
[6770] = 25, -- Sap (Rank 1)
|
|
[2070] = 35, -- Sap (Rank 2)
|
|
[11297] = 45, -- Sap (Rank 3)
|
|
|
|
[1776] = 4, -- Gouge (Rank 1) - Scales with talent
|
|
[1777] = 4, -- Gouge (Rank 2)
|
|
[8629] = 4, -- Gouge (Rank 3)
|
|
[11285] = 4, -- Gouge (Rank 4)
|
|
[11286] = 4, -- Gouge (Rank 5)
|
|
|
|
[1943] = 8, -- Rupture Rank 1
|
|
[8639] = 8, -- Rupture Rank 2
|
|
[8640] = 8, -- Rupture Rank 3
|
|
[11273] = 8, -- Rupture Rank 4
|
|
[11274] = 8, -- Rupture Rank 5
|
|
[11275] = 8, -- Rupture Rank 6
|
|
|
|
[703] = 18, -- Garrote (Rank 1)
|
|
[8631] = 18, -- Garrote (Rank 2)
|
|
[8632] = 18, -- Garrote (Rank 3)
|
|
[8633] = 18, -- Garrote (Rank 4)
|
|
[11289] = 18, -- Garrote (Rank 5)
|
|
[11290] = 18, -- Garrote (Rank 6)
|
|
|
|
[2818] = 12, -- Deadly Poison (Rank 1)
|
|
[2819] = 12, -- Deadly Poison II (Rank 2)
|
|
[11353] = 12, -- Deadly Poison III (Rank 3)
|
|
[11354] = 12, -- Deadly Poison IV (Rank 4)
|
|
[25349] = 12, -- Deadly Poison V (Rank 5)
|
|
|
|
[16511] = 15, -- Hemorrhage
|
|
|
|
[1833] = 4, -- Cheap Shot
|
|
[14902] = 4, -- Cheap Shot (alternate?)
|
|
|
|
-- Rogue poisons (not in Cursive, estimates):
|
|
[3409] = 12, -- Crippling Poison
|
|
[5760] = 16, -- Mind-numbing Poison (Rank 1)
|
|
[8694] = 16, -- Mind-numbing Poison (Rank 2)
|
|
[11399] = 16, -- Mind-numbing Poison (Rank 3)
|
|
[13218] = 15, -- Wound Poison (Rank 1)
|
|
[13222] = 15, -- Wound Poison (Rank 2)
|
|
[13223] = 15, -- Wound Poison (Rank 3)
|
|
[13224] = 15, -- Wound Poison (Rank 4)
|
|
|
|
-- HUNTER
|
|
[3043] = 20, -- Scorpid Sting (Rank 1)
|
|
[14275] = 20, -- Scorpid Sting (Rank 2)
|
|
[14276] = 20, -- Scorpid Sting (Rank 3)
|
|
[14277] = 20, -- Scorpid Sting (Rank 4)
|
|
|
|
[1978] = 15, -- Serpent Sting (Rank 1)
|
|
[13549] = 15, -- Serpent Sting (Rank 2)
|
|
[13550] = 15, -- Serpent Sting (Rank 3)
|
|
[13551] = 15, -- Serpent Sting (Rank 4)
|
|
[13552] = 15, -- Serpent Sting (Rank 5)
|
|
[13553] = 15, -- Serpent Sting (Rank 6)
|
|
[13554] = 15, -- Serpent Sting (Rank 7)
|
|
[13555] = 15, -- Serpent Sting (Rank 8)
|
|
[25295] = 15, -- Serpent Sting (Rank 9)
|
|
|
|
[3034] = 8, -- Viper Sting (Rank 1)
|
|
[14279] = 8, -- Viper Sting (Rank 2)
|
|
[14280] = 8, -- Viper Sting (Rank 3)
|
|
|
|
[2974] = 10, -- Wing Clip (Rank 1)
|
|
[14267] = 10, -- Wing Clip (Rank 2)
|
|
[14268] = 10, -- Wing Clip (Rank 3)
|
|
|
|
[5116] = 4, -- Concussive Shot
|
|
|
|
[19386] = 12, -- Wyvern Sting (Rank 1)
|
|
[24132] = 12, -- Wyvern Sting (Rank 2)
|
|
[24133] = 12, -- Wyvern Sting (Rank 3)
|
|
|
|
[19306] = 5, -- Counterattack (Rank 1)
|
|
[20909] = 5, -- Counterattack (Rank 2)
|
|
[20910] = 5, -- Counterattack (Rank 3)
|
|
|
|
[1130] = 120, -- Hunter's Mark (Rank 1)
|
|
[14323] = 120, -- Hunter's Mark (Rank 2)
|
|
[14324] = 120, -- Hunter's Mark (Rank 3)
|
|
[14325] = 120, -- Hunter's Mark (Rank 4)
|
|
|
|
-- DRUID
|
|
[339] = 12, -- Entangling Roots (Rank 1)
|
|
[1062] = 15, -- Entangling Roots (Rank 2)
|
|
[5195] = 18, -- Entangling Roots (Rank 3)
|
|
[5196] = 21, -- Entangling Roots (Rank 4)
|
|
[9852] = 24, -- Entangling Roots (Rank 5)
|
|
[9853] = 27, -- Entangling Roots (Rank 6)
|
|
|
|
[700] = 20, -- Sleep (Rank 1)
|
|
[1090] = 30, -- Sleep (Rank 2)
|
|
[2937] = 40, -- Sleep (Rank 3)
|
|
|
|
[770] = 40, -- Faerie Fire (Rank 1)
|
|
[778] = 40, -- Faerie Fire (Rank 2)
|
|
[9749] = 40, -- Faerie Fire (Rank 3)
|
|
[9907] = 40, -- Faerie Fire (Rank 4)
|
|
|
|
[16855] = 40, -- Faerie Fire (Bear) (Rank 1)
|
|
[17387] = 40, -- Faerie Fire (Bear) (Rank 2)
|
|
[17388] = 40, -- Faerie Fire (Bear) (Rank 3)
|
|
[17389] = 40, -- Faerie Fire (Bear) (Rank 4)
|
|
|
|
[16857] = 40, -- Faerie Fire (Feral) (Rank 1)
|
|
[17390] = 40, -- Faerie Fire (Feral) (Rank 2)
|
|
[17391] = 40, -- Faerie Fire (Feral) (Rank 3)
|
|
[17392] = 40, -- Faerie Fire (Feral) (Rank 4)
|
|
|
|
[2637] = 20, -- Hibernate (Rank 1)
|
|
[18657] = 30, -- Hibernate (Rank 2)
|
|
[18658] = 40, -- Hibernate (Rank 3)
|
|
|
|
[5570] = 18, -- Insect Swarm (Rank 1)
|
|
[24974] = 18, -- Insect Swarm (Rank 2)
|
|
[24975] = 18, -- Insect Swarm (Rank 3)
|
|
[24976] = 18, -- Insect Swarm (Rank 4)
|
|
[24977] = 18, -- Insect Swarm (Rank 5)
|
|
|
|
[8921] = 9, -- Moonfire (Rank 1)
|
|
[8924] = 18, -- Moonfire (Rank 2)
|
|
[8925] = 18, -- Moonfire (Rank 3)
|
|
[8926] = 18, -- Moonfire (Rank 4)
|
|
[8927] = 18, -- Moonfire (Rank 5)
|
|
[8928] = 18, -- Moonfire (Rank 6)
|
|
[8929] = 18, -- Moonfire (Rank 7)
|
|
[9833] = 18, -- Moonfire (Rank 8)
|
|
[9834] = 18, -- Moonfire (Rank 9)
|
|
[9835] = 18, -- Moonfire (Rank 10)
|
|
|
|
[1822] = 9, -- Rake (Rank 1)
|
|
[1823] = 9, -- Rake (Rank 2)
|
|
[1824] = 9, -- Rake (Rank 3)
|
|
[9904] = 9, -- Rake (Rank 4)
|
|
|
|
-- NOTE: Rip is a combo-scaling personal debuff (base 10s + 2s per CP)
|
|
[1079] = 10, -- Rip (Rank 1)
|
|
[9492] = 10, -- Rip (Rank 2)
|
|
[9493] = 10, -- Rip (Rank 3)
|
|
[9752] = 10, -- Rip (Rank 4)
|
|
[9894] = 10, -- Rip (Rank 5)
|
|
[9896] = 10, -- Rip (Rank 6)
|
|
|
|
[2908] = 15, -- Soothe Animal (Rank 1)
|
|
[8955] = 15, -- Soothe Animal (Rank 2)
|
|
[9901] = 15, -- Soothe Animal (Rank 3)
|
|
|
|
[5211] = 2, -- Bash (Rank 1)
|
|
[6798] = 3, -- Bash (Rank 2)
|
|
[8983] = 4, -- Bash (Rank 3)
|
|
|
|
[9007] = 18, -- Pounce Bleed (Rank 1) - triggered by Pounce 9005
|
|
[9824] = 18, -- Pounce Bleed (Rank 2) - triggered by Pounce 9823
|
|
[9826] = 18, -- Pounce Bleed (Rank 3) - triggered by Pounce 9827
|
|
|
|
-- WARLOCK
|
|
[172] = 12, -- Corruption (Rank 1)
|
|
[6222] = 15, -- Corruption (Rank 2)
|
|
[6223] = 18, -- Corruption (Rank 3)
|
|
[7648] = 18, -- Corruption (Rank 4)
|
|
[11671] = 18, -- Corruption (Rank 5)
|
|
[11672] = 18, -- Corruption (Rank 6)
|
|
[25311] = 18, -- Corruption (Rank 7)
|
|
|
|
[980] = 24, -- Curse of Agony (Rank 1)
|
|
[1014] = 24, -- Curse of Agony (Rank 2)
|
|
[6217] = 24, -- Curse of Agony (Rank 3)
|
|
[11711] = 24, -- Curse of Agony (Rank 4)
|
|
[11712] = 24, -- Curse of Agony (Rank 5)
|
|
[11713] = 24, -- Curse of Agony (Rank 6)
|
|
|
|
[18265] = 30, -- Siphon Life (Rank 1)
|
|
[18879] = 30, -- Siphon Life (Rank 2)
|
|
[18880] = 30, -- Siphon Life (Rank 3)
|
|
[18881] = 30, -- Siphon Life (Rank 4)
|
|
|
|
[52550] = 8, -- Dark Harvest (Rank 1)
|
|
[52551] = 8, -- Dark Harvest (Rank 2)
|
|
[52552] = 8, -- Dark Harvest (Rank 3)
|
|
|
|
[603] = 60, -- Curse of Doom
|
|
|
|
[704] = 120, -- Curse of Recklessness (Rank 1)
|
|
[7658] = 120, -- Curse of Recklessness (Rank 2)
|
|
[7659] = 120, -- Curse of Recklessness (Rank 3)
|
|
[11717] = 120, -- Curse of Recklessness (Rank 4)
|
|
|
|
[17862] = 300, -- Curse of Shadow (Rank 1)
|
|
[17937] = 300, -- Curse of Shadow (Rank 2)
|
|
|
|
[1490] = 300, -- Curse of Elements (Rank 1)
|
|
[11721] = 300, -- Curse of Elements (Rank 2)
|
|
[11722] = 300, -- Curse of Elements (Rank 3)
|
|
|
|
[1714] = 30, -- Curse of Tongues (Rank 1)
|
|
[11719] = 30, -- Curse of Tongues (Rank 2)
|
|
|
|
[702] = 120, -- Curse of Weakness (Rank 1)
|
|
[1108] = 120, -- Curse of Weakness (Rank 2)
|
|
[6205] = 120, -- Curse of Weakness (Rank 3)
|
|
[7646] = 120, -- Curse of Weakness (Rank 4)
|
|
[11707] = 120, -- Curse of Weakness (Rank 5)
|
|
[11708] = 120, -- Curse of Weakness (Rank 6)
|
|
|
|
[18223] = 12, -- Curse of Exhaustion
|
|
|
|
[348] = 15, -- Immolate (Rank 1)
|
|
[707] = 15, -- Immolate (Rank 2)
|
|
[1094] = 15, -- Immolate (Rank 3)
|
|
[2941] = 15, -- Immolate (Rank 4)
|
|
[11665] = 15, -- Immolate (Rank 5)
|
|
[11667] = 15, -- Immolate (Rank 6)
|
|
[11668] = 15, -- Immolate (Rank 7)
|
|
[25309] = 15, -- Immolate (Rank 8)
|
|
|
|
[6789] = 3, -- Death Coil (Rank 1)
|
|
[17925] = 3, -- Death Coil (Rank 2)
|
|
[17926] = 3, -- Death Coil (Rank 3)
|
|
|
|
[710] = 20, -- Banish (Rank 1)
|
|
[18647] = 30, -- Banish (Rank 2)
|
|
|
|
[5782] = 10, -- Fear (Rank 1)
|
|
[6213] = 15, -- Fear (Rank 2)
|
|
[6215] = 20, -- Fear (Rank 3)
|
|
|
|
-- MAGE
|
|
[118] = 20, -- Polymorph (Rank 1)
|
|
[12824] = 30, -- Polymorph (Rank 2)
|
|
[12825] = 40, -- Polymorph (Rank 3)
|
|
[12826] = 50, -- Polymorph (Rank 4)
|
|
|
|
[28270] = 50, -- Polymorph: Cow
|
|
[28271] = 50, -- Polymorph: Turtle
|
|
[28272] = 50, -- Polymorph: Pig
|
|
|
|
[116] = 5, -- Frostbolt slow
|
|
[120] = 5, -- Cone of Cold slow
|
|
[6136] = 15, -- Chilled
|
|
[12484] = 15, -- Improved Blizzard slow
|
|
[12486] = 15, -- Blizzard slow
|
|
|
|
-- PRIEST
|
|
[1425] = 30, -- Shackle Undead (Rank 1)
|
|
[9486] = 40, -- Shackle Undead (Rank 2)
|
|
[10956] = 50, -- Shackle Undead (Rank 3)
|
|
|
|
[453] = 15, -- Mind Soothe (Rank 1)
|
|
[8192] = 15, -- Mind Soothe (Rank 2)
|
|
[10953] = 15, -- Mind Soothe (Rank 3)
|
|
|
|
[605] = 60, -- Mind Control (Rank 1)
|
|
[10911] = 30, -- Mind Control (Rank 2)
|
|
[10912] = 30, -- Mind Control (Rank 3)
|
|
|
|
[2944] = 24, -- Devouring Plague (Rank 1)
|
|
[19276] = 24, -- Devouring Plague (Rank 2)
|
|
[19277] = 24, -- Devouring Plague (Rank 3)
|
|
[19278] = 24, -- Devouring Plague (Rank 4)
|
|
[19279] = 24, -- Devouring Plague (Rank 5)
|
|
[19280] = 24, -- Devouring Plague (Rank 6)
|
|
|
|
[9035] = 120, -- Hex of Weakness (Rank 1)
|
|
[19281] = 120, -- Hex of Weakness (Rank 2)
|
|
[19282] = 120, -- Hex of Weakness (Rank 3)
|
|
[19283] = 120, -- Hex of Weakness (Rank 4)
|
|
[19284] = 120, -- Hex of Weakness (Rank 5)
|
|
[19285] = 120, -- Hex of Weakness (Rank 6)
|
|
|
|
[589] = 18, -- Shadow Word: Pain (Rank 1)
|
|
[594] = 18, -- Shadow Word: Pain (Rank 2)
|
|
[970] = 18, -- Shadow Word: Pain (Rank 3)
|
|
[992] = 18, -- Shadow Word: Pain (Rank 4)
|
|
[2767] = 18, -- Shadow Word: Pain (Rank 5)
|
|
[10892] = 18, -- Shadow Word: Pain (Rank 6)
|
|
[10893] = 18, -- Shadow Word: Pain (Rank 7)
|
|
[10894] = 18, -- Shadow Word: Pain (Rank 8)
|
|
|
|
[15286] = 60, -- Vampiric Embrace
|
|
|
|
[14914] = 10, -- Holy Fire (Rank 1)
|
|
[15262] = 10, -- Holy Fire (Rank 2)
|
|
[15263] = 10, -- Holy Fire (Rank 3)
|
|
[15264] = 10, -- Holy Fire (Rank 4)
|
|
[15265] = 10, -- Holy Fire (Rank 5)
|
|
[15266] = 10, -- Holy Fire (Rank 6)
|
|
[15267] = 10, -- Holy Fire (Rank 7)
|
|
[15261] = 10, -- Holy Fire (Rank 8)
|
|
|
|
-- Mind Flay
|
|
[15407] = 3, -- Mind Flay (Rank 1)
|
|
[17311] = 3, -- Mind Flay (Rank 2)
|
|
[17312] = 3, -- Mind Flay (Rank 3)
|
|
[17313] = 3, -- Mind Flay (Rank 4)
|
|
[17314] = 3, -- Mind Flay (Rank 5)
|
|
[18807] = 3, -- Mind Flay (Rank 6)
|
|
|
|
-- PALADIN
|
|
[853] = 6, -- Hammer of Justice (Rank 1)
|
|
[5588] = 6, -- Hammer of Justice (Rank 2)
|
|
[5589] = 6, -- Hammer of Justice (Rank 3)
|
|
[10308] = 6, -- Hammer of Justice (Rank 4)
|
|
|
|
-- NOTE: Judgements moved to sharedDebuffs for proper target scanning
|
|
-- NOTE: 51750 is the CAST spell for Turtle WoW, not a debuff (debuff is 51752)
|
|
|
|
-- SHAMAN
|
|
-- NOTE: Flame Shock is 15s in Turtle WoW (was 12s in vanilla)
|
|
[8050] = 15, -- Flame Shock (Rank 1)
|
|
[8052] = 15, -- Flame Shock (Rank 2)
|
|
[8053] = 15, -- Flame Shock (Rank 3)
|
|
[10447] = 15, -- Flame Shock (Rank 4)
|
|
[10448] = 15, -- Flame Shock (Rank 5)
|
|
[29228] = 15, -- Flame Shock (Rank 6)
|
|
|
|
-- Frost Shock
|
|
[8056] = 8, -- Frost Shock (Rank 1)
|
|
[8058] = 8, -- Frost Shock (Rank 2)
|
|
[10472] = 8, -- Frost Shock (Rank 3)
|
|
[10473] = 8, -- Frost Shock (Rank 4)
|
|
}
|
|
|
|
-- SHARED DEBUFFS: Only one instance exists on a target, shared/refreshed by all players
|
|
-- These are armor reductions, attack power reductions, and marks
|
|
lib.sharedDebuffs = lib.sharedDebuffs or {
|
|
-- PALADIN JUDGEMENTS (tracked as shared so SeedUnit picks them up from target scanning)
|
|
[20184] = 10, -- Judgement of Justice
|
|
[20185] = 10, -- Judgement of Light (Rank 1)
|
|
[20267] = 10, -- Judgement of Light (Rank 2)
|
|
[20268] = 10, -- Judgement of Light (Rank 3)
|
|
[20271] = 10, -- Judgement of Light (Rank 4)
|
|
[20186] = 10, -- Judgement of Wisdom (Rank 1)
|
|
[20354] = 10, -- Judgement of Wisdom (Rank 2)
|
|
[20355] = 10, -- Judgement of Wisdom (Rank 3)
|
|
[51751] = 10, -- Judgement of Wisdom (Rank 4) - Turtle WoW
|
|
[51752] = 10, -- Judgement of Wisdom (Rank 5) - Turtle WoW
|
|
[21183] = 10, -- Judgement of the Crusader (Rank 1)
|
|
[20183] = 10, -- Judgement of the Crusader (Rank 2)
|
|
[20300] = 10, -- Judgement of the Crusader (Rank 3)
|
|
[20301] = 10, -- Judgement of the Crusader (Rank 4)
|
|
[20302] = 10, -- Judgement of the Crusader (Rank 5)
|
|
[20303] = 10, -- Judgement of the Crusader (Rank 6)
|
|
[51752] = 10, -- Turtle WoW: Judgement of Wisdom debuff
|
|
|
|
-- WARRIOR
|
|
[7386] = 30, -- Sunder Armor (Rank 1)
|
|
[7405] = 30, -- Sunder Armor (Rank 2)
|
|
[8380] = 30, -- Sunder Armor (Rank 3)
|
|
[8647] = 30, -- Sunder Armor (Rank 4)
|
|
[11597] = 30, -- Sunder Armor (Rank 5)
|
|
|
|
[6343] = 10, -- Thunder Clap (Rank 1)
|
|
[8198] = 14, -- Thunder Clap (Rank 2)
|
|
[8205] = 18, -- Thunder Clap (Rank 3)
|
|
[11580] = 22, -- Thunder Clap (Rank 4)
|
|
[11581] = 26, -- Thunder Clap (Rank 5)
|
|
[13532] = 30, -- Thunder Clap (Rank 6)
|
|
|
|
[1160] = 30, -- Demoralizing Shout (Rank 1)
|
|
[6190] = 30, -- Demoralizing Shout (Rank 2)
|
|
[11554] = 30, -- Demoralizing Shout (Rank 3)
|
|
[11555] = 30, -- Demoralizing Shout (Rank 4)
|
|
[11556] = 30, -- Demoralizing Shout (Rank 5)
|
|
|
|
-- ROGUE
|
|
[8647] = 30, -- Expose Armor (Rank 1)
|
|
[8649] = 30, -- Expose Armor (Rank 2)
|
|
[8650] = 30, -- Expose Armor (Rank 3)
|
|
[11197] = 30, -- Expose Armor (Rank 4)
|
|
[11198] = 30, -- Expose Armor (Rank 5)
|
|
|
|
-- HUNTER
|
|
[1130] = 120, -- Hunter's Mark (Rank 1)
|
|
[14323] = 120, -- Hunter's Mark (Rank 2)
|
|
[14324] = 120, -- Hunter's Mark (Rank 3)
|
|
[14325] = 120, -- Hunter's Mark (Rank 4)
|
|
|
|
-- DRUID
|
|
[770] = 40, -- Faerie Fire (Rank 1)
|
|
[778] = 40, -- Faerie Fire (Rank 2)
|
|
[9749] = 40, -- Faerie Fire (Rank 3)
|
|
[9907] = 40, -- Faerie Fire (Rank 4)
|
|
|
|
[16855] = 40, -- Faerie Fire (Bear) (Rank 1)
|
|
[17387] = 40, -- Faerie Fire (Bear) (Rank 2)
|
|
[17388] = 40, -- Faerie Fire (Bear) (Rank 3)
|
|
[17389] = 40, -- Faerie Fire (Bear) (Rank 4)
|
|
|
|
[16857] = 40, -- Faerie Fire (Feral) (Rank 1)
|
|
[17390] = 40, -- Faerie Fire (Feral) (Rank 2)
|
|
[17391] = 40, -- Faerie Fire (Feral) (Rank 3)
|
|
[17392] = 40, -- Faerie Fire (Feral) (Rank 4)
|
|
|
|
[99] = 30, -- Demoralizing Roar (Rank 1)
|
|
[1735] = 30, -- Demoralizing Roar (Rank 2)
|
|
[9490] = 30, -- Demoralizing Roar (Rank 3)
|
|
[9747] = 30, -- Demoralizing Roar (Rank 4)
|
|
[9898] = 30, -- Demoralizing Roar (Rank 5)
|
|
|
|
[5209] = 6, -- Challenging Roar
|
|
}
|
|
|
|
-- JUDGEMENT SPELLS: These are refreshed by the paladin's melee attacks
|
|
-- Track them by spell ID for refresh detection
|
|
lib.judgementSpells = lib.judgementSpells or {
|
|
-- Vanilla IDs
|
|
[20184] = true, -- Judgement of Justice
|
|
[20185] = true, -- Judgement of Light (Rank 1)
|
|
[20267] = true, -- Judgement of Light (Rank 2)
|
|
[20268] = true, -- Judgement of Light (Rank 3)
|
|
[20271] = true, -- Judgement of Light (Rank 4)
|
|
[20186] = true, -- Judgement of Wisdom (Rank 1)
|
|
[20354] = true, -- Judgement of Wisdom (Rank 2)
|
|
[20355] = true, -- Judgement of Wisdom (Rank 3)
|
|
[21183] = true, -- Judgement of the Crusader (Rank 1)
|
|
[20183] = true, -- Judgement of the Crusader (Rank 2)
|
|
[20300] = true, -- Judgement of the Crusader (Rank 3)
|
|
[20301] = true, -- Judgement of the Crusader (Rank 4)
|
|
[20302] = true, -- Judgement of the Crusader (Rank 5)
|
|
[20303] = true, -- Judgement of the Crusader (Rank 6)
|
|
|
|
-- Turtle WoW custom judgement IDs (debuff IDs only, not cast spells)
|
|
[51751] = true, -- Judgement of Wisdom (Rank 4) - Turtle WoW
|
|
[51752] = true, -- Judgement of Wisdom (Rank 5) - Turtle WoW
|
|
-- Auto-detection will discover other Turtle WoW judgement debuffs at runtime
|
|
}
|
|
|
|
-- Pending judgement casts: Map cast spell ID to target for debuff ID detection
|
|
-- When paladin casts Judgement, we need to find what debuff actually appears
|
|
lib.pendingJudgements = lib.pendingJudgements or {}
|
|
|
|
-- Detected judgement debuff IDs: Maps debuff name patterns to spell IDs
|
|
-- This gets populated as we discover what debuffs actually appear after casting
|
|
lib.detectedJudgementDebuffIDs = lib.detectedJudgementDebuffIDs or {}
|
|
|
|
-- NOTE: Judgement refresh on melee hits is handled by evJudgement (below)
|
|
-- It only uses chat-based detection when SuperWoW is unavailable;
|
|
-- otherwise Core.lua handles it via UNIT_CASTEVENT (MAINHAND/OFFHAND)
|
|
|
|
-- Combined table for backwards compatibility (will be deprecated)
|
|
lib.durations = lib.durations or {}
|
|
for k, v in pairs(lib.personalDebuffs) do
|
|
lib.durations[k] = v
|
|
end
|
|
for k, v in pairs(lib.sharedDebuffs) do
|
|
lib.durations[k] = v
|
|
end
|
|
|
|
-- MUTUALLY EXCLUSIVE DEBUFFS: Only one per caster can exist on a target
|
|
-- When a new curse is applied, remove any other curses from the same caster
|
|
lib.curseSpellIDs = lib.curseSpellIDs or {
|
|
-- Curse of Agony
|
|
[980] = true, [1014] = true, [6217] = true, [11711] = true, [11712] = true, [11713] = true,
|
|
-- Curse of Doom
|
|
[603] = true,
|
|
-- Curse of Recklessness
|
|
[704] = true, [7658] = true, [7659] = true, [11717] = true,
|
|
-- Curse of Shadow
|
|
[17862] = true, [17937] = true,
|
|
-- Curse of Elements
|
|
[1490] = true, [11721] = true, [11722] = true,
|
|
-- Curse of Tongues
|
|
[1714] = true, [11719] = true,
|
|
-- Curse of Weakness
|
|
[702] = true, [1108] = true, [6205] = true, [7646] = true, [11707] = true, [11708] = true,
|
|
-- Curse of Exhaustion
|
|
[18223] = true,
|
|
}
|
|
|
|
-- Helper function to check if a debuff is personal (returns true) or shared (returns false)
|
|
function lib:IsPersonalDebuff(spellID)
|
|
if lib.personalDebuffs[spellID] then
|
|
return true
|
|
elseif lib.sharedDebuffs[spellID] then
|
|
return false
|
|
end
|
|
-- Unknown debuffs are treated as personal by default (safer for multi-player scenarios)
|
|
return true
|
|
end
|
|
|
|
-- Helper function to extract numeric rank from spell ID
|
|
-- Returns rank number (1, 2, 3, etc.) or 0 if no rank found
|
|
function lib:GetSpellRank(spellID)
|
|
if not spellID then return 0 end
|
|
local name = C_Spell.GetSpellName(spellID)
|
|
local rankStr = C_Spell.GetSpellSubtext(spellID)
|
|
if not rankStr or rankStr == "" then return 0 end
|
|
|
|
-- Handle different rank formats
|
|
-- Could be: "Rank 9", "9", or number 9
|
|
local rankType = type(rankStr)
|
|
|
|
if rankType == "number" then
|
|
return rankStr
|
|
elseif rankType == "string" then
|
|
-- Try to extract number from "Rank X" format or just "X"
|
|
local _, _, rank = string.find(rankStr, "(%d+)")
|
|
return tonumber(rank) or 0
|
|
end
|
|
|
|
return 0
|
|
end
|
|
|
|
-- Helper function to get spell base name (without rank)
|
|
function lib:GetSpellBaseName(spellID)
|
|
if not spellID then return nil end
|
|
local name = C_Spell.GetSpellName(spellID)
|
|
if not name then return nil end
|
|
|
|
-- Remove rank suffix
|
|
return CleveRoids.StripRank(name)
|
|
end
|
|
|
|
-- Track rank refreshes for pfUI integration
|
|
lib.rankRefreshOverrides = lib.rankRefreshOverrides or {}
|
|
|
|
-- Helper function to check if we should apply a debuff based on rank comparison
|
|
-- Returns: true (apply), false (skip), or {refresh=spellID, duration=X} (refresh higher rank)
|
|
-- Also removes lower/equal rank versions when applying a higher rank
|
|
function lib:ShouldApplyDebuffRank(targetGUID, newSpellID)
|
|
if not targetGUID or not newSpellID then return true end
|
|
|
|
-- Get base name and rank of new spell
|
|
local newBaseName = lib:GetSpellBaseName(newSpellID)
|
|
local newRank = lib:GetSpellRank(newSpellID)
|
|
|
|
if not newBaseName then return true end
|
|
|
|
-- Track highest existing rank and spell IDs to remove
|
|
local highestExistingRank = 0
|
|
local highestExistingSpellID = nil
|
|
local highestExistingDuration = nil
|
|
local spellIDsToRemove = {}
|
|
|
|
-- Check all active debuffs on target for same base name
|
|
if lib.objects[targetGUID] then
|
|
for existingSpellID, rec in pairs(lib.objects[targetGUID]) do
|
|
if rec and rec.start and rec.duration and existingSpellID ~= newSpellID then
|
|
-- Check if debuff is still active
|
|
local remaining = rec.duration + rec.start - GetTime()
|
|
if remaining > 0 then
|
|
-- Check if same spell (same base name)
|
|
local existingBaseName = lib:GetSpellBaseName(existingSpellID)
|
|
if existingBaseName == newBaseName then
|
|
-- Same spell - track for potential removal
|
|
local existingRank = lib:GetSpellRank(existingSpellID)
|
|
|
|
if existingRank > highestExistingRank then
|
|
highestExistingRank = existingRank
|
|
highestExistingSpellID = existingSpellID
|
|
highestExistingDuration = rec.duration
|
|
end
|
|
|
|
-- Mark all same-name spells for potential removal
|
|
table.insert(spellIDsToRemove, existingSpellID)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- If a higher rank exists, preserve its remaining time instead of blocking
|
|
if newRank > 0 and highestExistingRank > 0 and newRank < highestExistingRank then
|
|
-- Calculate remaining time on the higher rank
|
|
local rec = lib.objects[targetGUID][highestExistingSpellID]
|
|
local timeRemaining = rec.duration + rec.start - GetTime()
|
|
|
|
-- Store refresh override for pfUI integration
|
|
lib.rankRefreshOverrides[newBaseName] = {
|
|
timestamp = GetTime(),
|
|
targetGUID = targetGUID,
|
|
lowerRankCast = newRank,
|
|
higherRankActive = highestExistingRank,
|
|
refreshSpellID = highestExistingSpellID,
|
|
preservedTimeRemaining = timeRemaining
|
|
}
|
|
|
|
CleveRoids.DebugChanged("rank_preserve_" .. newBaseName .. "_" .. tostring(targetGUID),
|
|
string.format("|cff00aaff[Rank Preserve]|r Cast %s Rank %d, preserving Rank %d timer (%.1fs remaining)",
|
|
newBaseName, newRank, highestExistingRank, timeRemaining))
|
|
|
|
-- Return special value indicating we should preserve the higher rank's timer
|
|
return {
|
|
preserve = highestExistingSpellID,
|
|
timeRemaining = timeRemaining,
|
|
targetGUID = targetGUID
|
|
}
|
|
end
|
|
|
|
-- Remove all other ranks (lower or equal) before applying new rank
|
|
if table.getn(spellIDsToRemove) > 0 then
|
|
-- Get target name for pfUI cleanup
|
|
local targetName = lib.guidToName[targetGUID]
|
|
|
|
for _, removeID in ipairs(spellIDsToRemove) do
|
|
if lib.objects[targetGUID][removeID] then
|
|
local removeRank = lib:GetSpellRank(removeID)
|
|
lib.objects[targetGUID][removeID] = nil
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00ff00[Rank Check]|r Removed %s (Rank %d) before applying Rank %d",
|
|
newBaseName, removeRank, newRank)
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Also clean up pfUI's tracking to prevent it from showing old ranks
|
|
if pfUI and pfUI.api and pfUI.api.libdebuff and targetName then
|
|
local pflib = pfUI.api.libdebuff
|
|
|
|
if pflib.objects and pflib.objects[targetName] then
|
|
for level, effects in pairs(pflib.objects[targetName]) do
|
|
if type(effects) == "table" and effects[newBaseName] then
|
|
-- Remove the old rank entry from pfUI
|
|
effects[newBaseName] = nil
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00ff00[Rank Check]|r Removed %s from pfUI tracking before applying new rank",
|
|
newBaseName)
|
|
)
|
|
end
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Apply the new rank
|
|
return true
|
|
end
|
|
|
|
lib.learnCastTimers = lib.learnCastTimers or {}
|
|
|
|
CleveRoids_LearnedDurations = CleveRoids_LearnedDurations or {}
|
|
|
|
function lib:GetDuration(spellID, casterGUID, comboPoints)
|
|
-- Check combo-specific learned durations first if this is a combo spell
|
|
if comboPoints and CleveRoids_ComboDurations and CleveRoids_ComboDurations[spellID] then
|
|
local comboDuration = CleveRoids_ComboDurations[spellID][comboPoints]
|
|
if comboDuration and comboDuration > 0 then
|
|
if CleveRoids.debug then
|
|
local spellName = C_Spell.GetSpellName(spellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffccccff[DEBUG GetDuration]|r %s (ID:%d) CP:%d -> %ds (from learned combo)",
|
|
spellName, spellID, comboPoints, comboDuration)
|
|
)
|
|
end
|
|
return comboDuration
|
|
end
|
|
end
|
|
|
|
-- Check caster-specific learned durations
|
|
if casterGUID and CleveRoids_LearnedDurations and CleveRoids_LearnedDurations[spellID] then
|
|
local learned = CleveRoids_LearnedDurations[spellID][casterGUID]
|
|
if learned and learned > 0 then
|
|
if CleveRoids.debug then
|
|
local spellName = C_Spell.GetSpellName(spellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffccccff[DEBUG GetDuration]|r %s (ID:%d) -> %ds (from learned caster)",
|
|
spellName, spellID, learned)
|
|
)
|
|
end
|
|
return learned
|
|
end
|
|
end
|
|
|
|
-- Fall back to static database
|
|
local staticDur = self.durations[spellID] or 0
|
|
if CleveRoids.debug and staticDur > 0 then
|
|
local spellName = C_Spell.GetSpellName(spellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffccccff[DEBUG GetDuration]|r %s (ID:%d) -> %ds (from static DB)",
|
|
spellName, spellID, staticDur)
|
|
)
|
|
end
|
|
return staticDur
|
|
end
|
|
|
|
function lib:AddEffect(guid, unitName, spellID, duration, stacks, caster)
|
|
if not guid or not spellID then return end
|
|
|
|
-- Normalize GUID to string for consistent table key lookups
|
|
guid = CleveRoids.NormalizeGUID(guid)
|
|
if not guid then return end
|
|
|
|
duration = duration or lib:GetDuration(spellID, caster)
|
|
if duration <= 0 then return end
|
|
|
|
lib.objects[guid] = lib.objects[guid] or {}
|
|
lib.guidToName[guid] = unitName
|
|
|
|
-- CURSE REPLACEMENT: Only one curse per caster can exist on a target
|
|
-- When casting a new curse, remove all other curses from tracking for this target
|
|
if lib.curseSpellIDs[spellID] then
|
|
for trackedID, rec in pairs(lib.objects[guid]) do
|
|
if lib.curseSpellIDs[trackedID] and trackedID ~= spellID then
|
|
if CleveRoids.debug then
|
|
local oldName = C_Spell.GetSpellName(trackedID) or "Unknown"
|
|
local newName = C_Spell.GetSpellName(spellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff8800[Curse Replace]|r %s replaced by %s on %s",
|
|
oldName, newName, unitName or "target"))
|
|
end
|
|
lib.objects[guid][trackedID] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
local rec = lib.objects[guid][spellID] or {}
|
|
rec.spellID = spellID
|
|
rec.start = GetTime()
|
|
rec.duration = duration
|
|
rec.stacks = stacks or 0
|
|
rec.caster = caster
|
|
|
|
lib.objects[guid][spellID] = rec
|
|
|
|
-- OWN DEBUFFS TRACKING: Mirror pfUI's ownDebuffs structure for player casts
|
|
-- When pfUI enhanced is active, pfUI handles ownDebuffs writes from its own
|
|
-- SPELL_GO/AURA_CAST/DEBUFF_ADDED handlers. Without pfUI, we populate it here
|
|
-- so lib.ownDebuffs has consistent data for downrank checks and debuff queries.
|
|
if caster == "player" and not lib.hasPfUIEnhanced then
|
|
local spellName = C_Spell.GetSpellName(spellID)
|
|
if spellName then
|
|
local spellRankStr = C_Spell.GetSpellSubtext(spellID)
|
|
local rankNum = spellRankStr and tonumber((string.gsub(spellRankStr, "Rank ", ""))) or 0
|
|
|
|
lib.ownDebuffs[guid] = lib.ownDebuffs[guid] or {}
|
|
|
|
-- Downrank protection: don't overwrite a higher rank that's still active
|
|
local existing = lib.ownDebuffs[guid][spellName]
|
|
local blocked = false
|
|
if existing and existing.rank and existing.spellId and existing.spellId ~= spellID then
|
|
if rankNum > 0 and existing.rank > rankNum then
|
|
local existingTimeleft = (existing.startTime + existing.duration) - GetTime()
|
|
if existingTimeleft > 0 then
|
|
blocked = true
|
|
end
|
|
end
|
|
end
|
|
|
|
if not blocked then
|
|
local texture = lib:GetCachedIcon(spellID)
|
|
lib.ownDebuffs[guid][spellName] = {
|
|
startTime = GetTime(),
|
|
duration = duration,
|
|
texture = texture,
|
|
rank = rankNum,
|
|
spellId = spellID,
|
|
stacks = stacks or 1,
|
|
}
|
|
end
|
|
end
|
|
end
|
|
|
|
if CleveRoids.debug then
|
|
local spellName = C_Spell.GetSpellName(spellID) or "Unknown"
|
|
CleveRoids.DebugChanged("addeffect_" .. spellID .. "_" .. tostring(guid),
|
|
string.format("|cff00ffff[AddEffect]|r %s (ID:%d) %ds on %s, caster:%s",
|
|
spellName, spellID, duration, unitName or "Unknown", caster or "nil"))
|
|
end
|
|
end
|
|
|
|
function lib:UnitDebuff(unit, id, filterCaster)
|
|
local guid = CleveRoids.GetGUID(unit)
|
|
if not guid then return nil end
|
|
|
|
local texture, stacks, dtype, spellID = nil, nil, nil, nil
|
|
|
|
-- SuperWoW debuff slots: 1-16 are regular debuffs, 17-48 overflow to buff slots 1-32
|
|
-- See: https://forum.turtle-wow.org/viewtopic.php?t=13281
|
|
if id <= 16 then
|
|
-- Regular debuff slot
|
|
texture, stacks, dtype, spellID = UnitDebuff(unit, id)
|
|
else
|
|
-- Overflow debuff in buff slot: debuff index 17 = buff index 1, etc.
|
|
local buffIndex = id - 16
|
|
if buffIndex <= 32 then
|
|
texture, stacks, spellID = UnitBuff(unit, buffIndex)
|
|
-- Only accept buffs that are known debuffs (either static or learned durations, including combo durations)
|
|
if texture and spellID and lib:GetDuration(spellID) <= 0 then
|
|
return nil
|
|
end
|
|
end
|
|
end
|
|
|
|
if not texture or not spellID then return nil end
|
|
|
|
local name = C_Spell.GetSpellName(spellID)
|
|
local duration, timeleft, caster = nil, -1, nil
|
|
|
|
local rec = lib.objects[guid] and lib.objects[guid][spellID]
|
|
|
|
if rec and rec.duration and rec.start then
|
|
local remaining = rec.duration + rec.start - GetTime()
|
|
if remaining > 0 then
|
|
duration = rec.duration
|
|
timeleft = remaining
|
|
caster = rec.caster
|
|
stacks = rec.stacks or stacks
|
|
|
|
-- Filter by caster if requested
|
|
-- Since personal debuffs are only tracked via UNIT_CASTEVENT (player only),
|
|
-- this filter only applies to shared debuffs where multiple casters can apply
|
|
if filterCaster and caster ~= filterCaster then
|
|
return nil
|
|
end
|
|
else
|
|
lib.objects[guid][spellID] = nil
|
|
end
|
|
elseif filterCaster then
|
|
-- No tracking record exists. This means either:
|
|
-- 1. Personal debuff from another player (wasn't tracked via UNIT_CASTEVENT)
|
|
-- 2. Shared debuff that expired from tracking
|
|
-- In both cases, if filtering by caster, we should reject it
|
|
return nil
|
|
end
|
|
|
|
return name, nil, texture, stacks, dtype, duration, timeleft, caster
|
|
end
|
|
|
|
-- Query buff data with duration and caster tracking (buff slots only)
|
|
function lib:UnitBuff(unit, id, filterCaster)
|
|
local guid = CleveRoids.GetGUID(unit)
|
|
if not guid then return nil end
|
|
|
|
-- Only check buff slots
|
|
local texture, stacks, spellID = UnitBuff(unit, id)
|
|
|
|
if not texture or not spellID then return nil end
|
|
|
|
local name = C_Spell.GetSpellName(spellID)
|
|
local duration, timeleft, caster = nil, -1, nil
|
|
|
|
local rec = lib.objects[guid] and lib.objects[guid][spellID]
|
|
|
|
if rec and rec.duration and rec.start then
|
|
local remaining = rec.duration + rec.start - GetTime()
|
|
if remaining > 0 then
|
|
duration = rec.duration
|
|
timeleft = remaining
|
|
caster = rec.caster
|
|
stacks = rec.stacks or stacks
|
|
|
|
-- Filter by caster if requested
|
|
if filterCaster and caster ~= filterCaster then
|
|
return nil
|
|
end
|
|
else
|
|
lib.objects[guid][spellID] = nil
|
|
end
|
|
elseif filterCaster then
|
|
-- No tracking record exists - reject when filtering by caster
|
|
return nil
|
|
end
|
|
|
|
return name, nil, texture, stacks, nil, duration, timeleft, caster
|
|
end
|
|
|
|
-- Find a player-cast debuff by spell ID (searches all slots including buff slots)
|
|
function lib:FindPlayerDebuff(unit, spellID)
|
|
local guid = CleveRoids.GetGUID(unit)
|
|
if not guid then return nil end
|
|
|
|
-- Check if we're tracking this spell for this unit
|
|
local rec = lib.objects[guid] and lib.objects[guid][spellID]
|
|
if not rec then return nil end
|
|
|
|
-- Only return if it was cast by player
|
|
if rec.caster ~= "player" then return nil end
|
|
|
|
-- Check if it's still active
|
|
local remaining = rec.duration + rec.start - GetTime()
|
|
if remaining <= 0 then
|
|
lib.objects[guid][spellID] = nil
|
|
return nil
|
|
end
|
|
|
|
-- Find the texture/stacks via one by-ID lookup (walks both debuff and buff
|
|
-- ranges), replacing the 16 debuff + 32 buff slot scan.
|
|
local texture, stacks = nil, rec.stacks
|
|
local aura = CleveRoids.ClassicAPI.GetUnitAuraBySpellID(unit, spellID)
|
|
if aura then
|
|
texture = aura.icon
|
|
stacks = aura.applications or stacks
|
|
end
|
|
|
|
if not texture then return nil end
|
|
|
|
local name = C_Spell.GetSpellName(spellID)
|
|
return name, nil, texture, stacks, nil, rec.duration, remaining, rec.caster
|
|
end
|
|
|
|
-- Find a player-cast buff by spell ID (searches buff slots only)
|
|
function lib:FindPlayerBuff(unit, spellID)
|
|
local guid = CleveRoids.GetGUID(unit)
|
|
if not guid then return nil end
|
|
|
|
-- Check if we're tracking this spell for this unit
|
|
local rec = lib.objects[guid] and lib.objects[guid][spellID]
|
|
if not rec then return nil end
|
|
|
|
-- Only return if it was cast by player
|
|
if rec.caster ~= "player" then return nil end
|
|
|
|
-- Check if it's still active
|
|
local remaining = rec.duration + rec.start - GetTime()
|
|
if remaining <= 0 then
|
|
lib.objects[guid][spellID] = nil
|
|
return nil
|
|
end
|
|
|
|
-- Find the texture/stacks via one by-ID lookup restricted to buffs.
|
|
local texture, stacks = nil, rec.stacks
|
|
local aura = CleveRoids.ClassicAPI.GetUnitAuraBySpellID(unit, spellID, "HELPFUL")
|
|
if aura then
|
|
texture = aura.icon
|
|
stacks = aura.applications or stacks
|
|
end
|
|
|
|
if not texture then return nil end
|
|
|
|
local name = C_Spell.GetSpellName(spellID)
|
|
return name, nil, texture, stacks, nil, rec.duration, remaining, rec.caster
|
|
end
|
|
|
|
local function SeedUnit(unit)
|
|
local guid = CleveRoids.GetGUID(unit)
|
|
if not guid then return end
|
|
local unitName = UnitName(unit)
|
|
|
|
for i=1, 16 do
|
|
local tex, stacks, dtype, spellID = UnitDebuff(unit, i)
|
|
if not tex then break end
|
|
|
|
if spellID then
|
|
-- CURSIVE ARCHITECTURE: Skip personal debuffs - they should only be tracked via UNIT_CASTEVENT
|
|
-- Only track shared debuffs in SeedUnit (e.g., Sunder Armor, Thunder Clap)
|
|
if lib:IsPersonalDebuff(spellID) then
|
|
-- Skip this debuff - it should only come from UNIT_CASTEVENT
|
|
else
|
|
local duration = lib:GetDuration(spellID)
|
|
if duration > 0 then
|
|
-- SHARED DEBUFFS: Check if already tracked and handle refresh detection
|
|
-- Player's casts are tracked via UNIT_CASTEVENT, but OTHER players' casts
|
|
-- only trigger UNIT_AURA events. Detect refreshes via stack count changes.
|
|
local existing = lib.objects[guid] and lib.objects[guid][spellID]
|
|
|
|
if existing and existing.start and existing.duration then
|
|
-- Already tracked - check if debuff was refreshed (by player or others)
|
|
if existing.stacks ~= stacks then
|
|
local oldStacks = existing.stacks or 0
|
|
existing.stacks = stacks
|
|
|
|
-- If stacks INCREASED, debuff was refreshed - reset timer to full duration
|
|
-- This catches refreshes from other players (we can't detect via UNIT_CASTEVENT)
|
|
if (stacks or 0) > oldStacks then
|
|
existing.start = GetTime()
|
|
existing.duration = duration
|
|
|
|
if CleveRoids.debug then
|
|
local spellName = C_Spell.GetSpellName(spellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffaaff00[DEBUG SeedUnit Debuff]|r %s (ID:%d) stack increased %d->%d, timer RESET to %ds (refresh detected)",
|
|
spellName, spellID, oldStacks, stacks or 0, duration)
|
|
)
|
|
end
|
|
else
|
|
-- Stacks decreased (shouldn't happen normally) - just update stacks, preserve timer
|
|
if CleveRoids.debug then
|
|
local spellName = C_Spell.GetSpellName(spellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffaaff00[DEBUG SeedUnit Debuff]|r %s (ID:%d) updated stacks to %d (timer preserved)",
|
|
spellName, spellID, stacks or 0)
|
|
)
|
|
end
|
|
end
|
|
end
|
|
-- NOTE: When stacks stay the same (e.g., already at 5/5 Sunder), we can't detect
|
|
-- refreshes from other players. This is a fundamental limitation without server-side
|
|
-- duration data. The timer continues from the last known refresh.
|
|
else
|
|
-- First time seeing this debuff - add with full duration
|
|
-- Check for combo spell duration
|
|
if CleveRoids.IsComboScalingSpellID and CleveRoids.IsComboScalingSpellID(spellID) and
|
|
CleveRoids_ComboDurations and CleveRoids_ComboDurations[spellID] then
|
|
-- Use the longest learned duration (assume 5 CP)
|
|
for cp = 5, 1, -1 do
|
|
if CleveRoids_ComboDurations[spellID][cp] then
|
|
duration = CleveRoids_ComboDurations[spellID][cp]
|
|
if CleveRoids.debug then
|
|
local spellName = C_Spell.GetSpellName(spellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffaaff00[DEBUG SeedUnit Debuff]|r %s (ID:%d) using learned %dCP duration:%ds",
|
|
spellName, spellID, cp, duration)
|
|
)
|
|
end
|
|
break
|
|
end
|
|
end
|
|
end
|
|
lib:AddEffect(guid, unitName, spellID, duration, stacks, nil)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
for i=1, 32 do
|
|
local tex, stacks, spellID = UnitBuff(unit, i)
|
|
if not tex then break end
|
|
|
|
if spellID then
|
|
-- CURSIVE ARCHITECTURE: Skip personal debuffs in overflow buff slots
|
|
-- Only track shared debuffs in SeedUnit
|
|
if lib:IsPersonalDebuff(spellID) then
|
|
-- Skip this debuff - it should only come from UNIT_CASTEVENT
|
|
else
|
|
local duration = lib:GetDuration(spellID)
|
|
if duration > 0 then
|
|
-- SHARED DEBUFFS (overflow): Check if already tracked and handle refresh detection
|
|
-- Same logic as regular debuff slots - detect refreshes via stack count changes.
|
|
local existing = lib.objects[guid] and lib.objects[guid][spellID]
|
|
|
|
if existing and existing.start and existing.duration then
|
|
-- Already tracked - check if debuff was refreshed (by player or others)
|
|
if existing.stacks ~= stacks then
|
|
local oldStacks = existing.stacks or 0
|
|
existing.stacks = stacks
|
|
|
|
-- If stacks INCREASED, debuff was refreshed - reset timer to full duration
|
|
if (stacks or 0) > oldStacks then
|
|
existing.start = GetTime()
|
|
existing.duration = duration
|
|
|
|
if CleveRoids.debug then
|
|
local spellName = C_Spell.GetSpellName(spellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffaaff00[DEBUG SeedUnit Buff]|r %s (ID:%d) stack increased %d->%d, timer RESET to %ds (refresh detected)",
|
|
spellName, spellID, oldStacks, stacks or 0, duration)
|
|
)
|
|
end
|
|
else
|
|
-- Stacks decreased (shouldn't happen normally) - just update stacks, preserve timer
|
|
if CleveRoids.debug then
|
|
local spellName = C_Spell.GetSpellName(spellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffaaff00[DEBUG SeedUnit Buff]|r %s (ID:%d) updated stacks to %d (timer preserved)",
|
|
spellName, spellID, stacks or 0)
|
|
)
|
|
end
|
|
end
|
|
end
|
|
else
|
|
-- First time seeing this debuff - add with full duration
|
|
-- Check for combo spell duration
|
|
if CleveRoids.IsComboScalingSpellID and CleveRoids.IsComboScalingSpellID(spellID) and
|
|
CleveRoids_ComboDurations and CleveRoids_ComboDurations[spellID] then
|
|
-- Use the longest learned duration (assume 5 CP)
|
|
for cp = 5, 1, -1 do
|
|
if CleveRoids_ComboDurations[spellID][cp] then
|
|
duration = CleveRoids_ComboDurations[spellID][cp]
|
|
if CleveRoids.debug then
|
|
local spellName = C_Spell.GetSpellName(spellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffaaff00[DEBUG SeedUnit Buff]|r %s (ID:%d) using learned %dCP duration:%ds",
|
|
spellName, spellID, cp, duration)
|
|
)
|
|
end
|
|
break
|
|
end
|
|
end
|
|
end
|
|
lib:AddEffect(guid, unitName, spellID, duration, stacks, nil)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Carnage refresh system
|
|
-- NOTE: Carnage proc detection is now handled in ComboPointTracker.lua via PLAYER_COMBO_POINTS event
|
|
-- When Carnage procs, combo points don't drop to 0 after Ferocious Bite (they stay at 1)
|
|
-- The ApplyCarnageRefresh function below is called from ComboPointTracker when a proc is detected
|
|
|
|
-- WARLOCK DARK HARVEST: Duration Acceleration System (TWoW Custom)
|
|
-- Credits: Avitasia / Cursive addon
|
|
-- Dark Harvest is a channeled spell that accelerates DoT tick rate by 30%
|
|
-- While channeling, DoTs on the target expire 30% faster
|
|
|
|
-- Calculate Dark Harvest reduction for a debuff record
|
|
-- Returns the amount of time to subtract from remaining duration
|
|
function lib.GetDarkHarvestReduction(rec)
|
|
if not rec or not rec.dhStartTime then
|
|
return 0
|
|
end
|
|
|
|
local endTime = rec.dhEndTime or GetTime()
|
|
local dhActiveTime = endTime - rec.dhStartTime
|
|
if dhActiveTime > 0 then
|
|
return dhActiveTime * 0.3 -- 30% acceleration
|
|
end
|
|
return 0
|
|
end
|
|
|
|
-- Track Dark Harvest start for all DoTs on target
|
|
function lib.ApplyDarkHarvestStart(targetGUID)
|
|
if not lib.objects[targetGUID] then return end
|
|
|
|
local now = GetTime()
|
|
for spellID, rec in pairs(lib.objects[targetGUID]) do
|
|
if type(rec) == "table" and rec.duration and rec.start then
|
|
-- Only affect DoTs (debuffs with duration > 0 that tick)
|
|
-- Skip if already has dhStartTime (avoid double-tracking)
|
|
if not rec.dhStartTime then
|
|
rec.dhStartTime = now
|
|
rec.dhEndTime = nil -- Clear any previous end time
|
|
end
|
|
end
|
|
end
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff9482c9[Dark Harvest]|r Started accelerating DoTs on %s",
|
|
lib.guidToName[targetGUID] or "Unknown")
|
|
)
|
|
end
|
|
end
|
|
|
|
-- Track Dark Harvest end for all DoTs on target
|
|
function lib.ApplyDarkHarvestEnd(targetGUID)
|
|
if not lib.objects[targetGUID] then return end
|
|
|
|
local now = GetTime()
|
|
for spellID, rec in pairs(lib.objects[targetGUID]) do
|
|
if type(rec) == "table" and rec.dhStartTime and not rec.dhEndTime then
|
|
rec.dhEndTime = now
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Get time remaining for a debuff, accounting for Dark Harvest acceleration
|
|
function lib.GetTimeRemainingWithDarkHarvest(rec)
|
|
if not rec or not rec.duration or not rec.start then
|
|
return 0
|
|
end
|
|
|
|
local baseRemaining = rec.duration + rec.start - GetTime()
|
|
local dhReduction = lib.GetDarkHarvestReduction(rec)
|
|
|
|
return baseRemaining - dhReduction
|
|
end
|
|
|
|
-- Personal debuff pending tracking system
|
|
-- Stores personal debuffs to be added after 0.5s delay (to verify they weren't dodged/parried/blocked)
|
|
-- Format: { [index] = { timestamp = GetTime(), targetGUID = guid, targetName = name, spellID = id, duration = X, comboPoints = CP } }
|
|
lib.pendingPersonalDebuffs = lib.pendingPersonalDebuffs or {}
|
|
|
|
-- CC (Crowd Control) pending tracking system
|
|
-- Stores CC spells to verify they landed (for immunity detection)
|
|
-- Format: { [index] = { timestamp = GetTime(), targetGUID = guid, targetName = name, spellID = id, ccType = "stun" } }
|
|
lib.pendingCCDebuffs = lib.pendingCCDebuffs or {}
|
|
|
|
-- Shared debuff pending tracking system
|
|
-- Stores shared debuffs to verify they landed (for immunity detection by spell school)
|
|
-- Format: { [index] = { timestamp = GetTime(), targetGUID = guid, targetName = name, spellID = id, school = "arcane", duration = X, stacks = N } }
|
|
lib.pendingSharedDebuffs = lib.pendingSharedDebuffs or {}
|
|
|
|
-- Spells with HIDDEN CC debuffs - these apply CC effects but don't show visible debuffs
|
|
-- Pounce stun is hidden - the target is stunned but no debuff icon appears
|
|
-- These spells use extended verification (0.4s instead of 0.2s) to allow time for:
|
|
-- 1. "afflicted by" combat log messages to arrive and confirm success
|
|
-- 2. Mechanic-based ValidateUnitCC check (if the stun is internally tracked)
|
|
-- If neither confirms the CC landed, immunity is recorded
|
|
lib.hiddenCCSpells = {
|
|
[9005] = true, -- Pounce (Rank 1)
|
|
[9823] = true, -- Pounce (Rank 2)
|
|
[9827] = true, -- Pounce (Rank 3)
|
|
}
|
|
|
|
-- Reverse lookup: spell NAME -> immunity type for combat log tracking
|
|
-- Used by ParseAfflictedCombatLog to confirm spells landed via "afflicted by" messages
|
|
-- Format: { ["SpellName"] = { type = "cc" or "school", value = "stun" or "bleed" } }
|
|
lib.trackedAfflictions = {
|
|
-- Hidden CC effects (no visible debuff, must use combat log)
|
|
["Pounce"] = { type = "cc", value = "stun" },
|
|
|
|
-- Bleed effects (visible debuff, but combat log is more reliable)
|
|
["Pounce Bleed"] = { type = "school", value = "bleed" },
|
|
["Rake"] = { type = "school", value = "bleed" },
|
|
["Rip"] = { type = "school", value = "bleed" },
|
|
["Lacerate"] = { type = "school", value = "bleed" },
|
|
["Garrote"] = { type = "school", value = "bleed" },
|
|
["Rupture"] = { type = "school", value = "bleed" },
|
|
["Deep Wound"] = { type = "school", value = "bleed" },
|
|
["Deep Wounds"] = { type = "school", value = "bleed" },
|
|
["Rend"] = { type = "school", value = "bleed" },
|
|
}
|
|
|
|
-- Function to apply Carnage refresh (exposed for ComboPointTracker to call on proc detection)
|
|
function lib.ApplyCarnageRefresh(targetGUID, targetName, biteSpellID)
|
|
if CleveRoids.debug then
|
|
-- Compare Carnage GUID with current target GUID
|
|
local currentTargetGUID = CleveRoids.GetGUID("target")
|
|
local guidMatch = (targetGUID == currentTargetGUID) and "MATCH" or "MISMATCH"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff00ff[Carnage]|r ApplyCarnageRefresh called for %s (GUID:%s, current:%s, %s)",
|
|
targetName or "Unknown", tostring(targetGUID), tostring(currentTargetGUID), guidMatch)
|
|
)
|
|
end
|
|
|
|
-- Only refresh debuffs if they're currently active on the target
|
|
if not lib.objects[targetGUID] then
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff6600[Carnage]|r No tracking data for GUID %s", tostring(targetGUID))
|
|
)
|
|
end
|
|
return
|
|
end
|
|
|
|
-- Try to refresh Rip
|
|
if CleveRoids.lastRipCast and CleveRoids.lastRipCast.duration and CleveRoids.lastRipCast.spellID and
|
|
CleveRoids.lastRipCast.targetGUID == targetGUID then
|
|
-- Only refresh if the SAME rank that was cast is still active
|
|
local ripSpellID = CleveRoids.lastRipCast.spellID
|
|
local rec = lib.objects[targetGUID] and lib.objects[targetGUID][ripSpellID]
|
|
if rec and rec.duration and rec.start then
|
|
-- Check if Rip is still active (not expired)
|
|
local remaining = rec.duration + rec.start - GetTime()
|
|
if remaining > 0 then
|
|
-- Found the exact same rank active, refresh it with the saved duration
|
|
local ripDuration = CleveRoids.lastRipCast.duration
|
|
local ripComboPoints = CleveRoids.lastRipCast.comboPoints or 5
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff00ff[Carnage]|r About to refresh Rip: %ds on %s (spellID:%d)",
|
|
ripDuration, targetName or "Unknown", ripSpellID)
|
|
)
|
|
end
|
|
|
|
-- Store duration override for pfUI hooks (BEFORE updating tracking)
|
|
if not CleveRoids.carnageDurationOverrides then
|
|
CleveRoids.carnageDurationOverrides = {}
|
|
end
|
|
CleveRoids.carnageDurationOverrides[ripSpellID] = {
|
|
duration = ripDuration,
|
|
timestamp = GetTime(),
|
|
targetGUID = targetGUID
|
|
}
|
|
|
|
-- Update CleveRoids internal tracking
|
|
if lib.objects[targetGUID] and lib.objects[targetGUID][ripSpellID] then
|
|
local oldCaster = lib.objects[targetGUID][ripSpellID].caster
|
|
lib.objects[targetGUID][ripSpellID].duration = ripDuration
|
|
lib.objects[targetGUID][ripSpellID].start = GetTime()
|
|
lib.objects[targetGUID][ripSpellID].expiry = GetTime() + ripDuration
|
|
-- Ensure caster is preserved (required for personal debuff tracking)
|
|
if not lib.objects[targetGUID][ripSpellID].caster then
|
|
lib.objects[targetGUID][ripSpellID].caster = "player"
|
|
end
|
|
|
|
if CleveRoids.debug then
|
|
-- Verify the record was actually saved
|
|
local verifyRec = lib.objects[targetGUID] and lib.objects[targetGUID][ripSpellID]
|
|
if verifyRec then
|
|
local verifyRemaining = verifyRec.duration + verifyRec.start - GetTime()
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff00ff[Carnage]|r Updated CleveRoids tracking for Rip (GUID:%s, caster:%s->%s)",
|
|
tostring(targetGUID), tostring(oldCaster), tostring(verifyRec.caster))
|
|
)
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff00ff[Carnage VERIFY]|r Rip record: dur=%s, start=%s, remaining=%.1fs",
|
|
tostring(verifyRec.duration), tostring(verifyRec.start), verifyRemaining)
|
|
)
|
|
else
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff0000[Carnage ERROR]|r Rip record MISSING immediately after update!")
|
|
)
|
|
end
|
|
end
|
|
else
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff6600[Carnage]|r WARNING: Rip record not found! GUID:%s, objects[GUID]:%s",
|
|
tostring(targetGUID), tostring(lib.objects[targetGUID]))
|
|
)
|
|
end
|
|
end
|
|
|
|
-- DON'T call pfUI's AddEffect - just update the existing entry directly
|
|
-- pfUI will pick up the new duration through our GetDuration/UnitDebuff hooks
|
|
if pfUI and pfUI.api and pfUI.api.libdebuff then
|
|
local pflib = pfUI.api.libdebuff
|
|
local ripSpellName = C_Spell.GetSpellName(ripSpellID)
|
|
local baseName = CleveRoids.StripRank(ripSpellName) or "Rip"
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff00ff[Carnage]|r Updating existing pfUI entry for Rip directly")
|
|
)
|
|
end
|
|
|
|
-- Find and update the existing pfUI entry (don't create new ones)
|
|
if pflib.objects and pflib.objects[targetName] then
|
|
local updated = false
|
|
for level, effects in pairs(pflib.objects[targetName]) do
|
|
if type(effects) == "table" and effects[baseName] then
|
|
-- Update the existing entry
|
|
effects[baseName].start = GetTime()
|
|
effects[baseName].duration = ripDuration
|
|
effects[baseName].caster = "player"
|
|
updated = true
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff00ff[Carnage]|r Updated pfUI Rip at level %s", tostring(level))
|
|
)
|
|
end
|
|
-- Only update the FIRST occurrence to avoid duplicates
|
|
break
|
|
end
|
|
end
|
|
|
|
if updated and pflib.UpdateUnits then
|
|
pflib:UpdateUnits()
|
|
end
|
|
end
|
|
end
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff00ff[Carnage]|r Finished refreshing Rip: %ds on %s",
|
|
ripDuration, targetName or "Unknown")
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Try to refresh Rake
|
|
if CleveRoids.lastRakeCast and CleveRoids.lastRakeCast.duration and CleveRoids.lastRakeCast.spellID and
|
|
CleveRoids.lastRakeCast.targetGUID == targetGUID then
|
|
-- Only refresh if the SAME rank that was cast is still active
|
|
local rakeSpellID = CleveRoids.lastRakeCast.spellID
|
|
local rec = lib.objects[targetGUID] and lib.objects[targetGUID][rakeSpellID]
|
|
if rec and rec.duration and rec.start then
|
|
-- Check if Rake is still active (not expired)
|
|
local remaining = rec.duration + rec.start - GetTime()
|
|
if remaining > 0 then
|
|
-- Found the exact same rank active, refresh it with the saved duration
|
|
local rakeDuration = CleveRoids.lastRakeCast.duration
|
|
local rakeComboPoints = CleveRoids.lastRakeCast.comboPoints or 5
|
|
|
|
-- Store duration override for pfUI hooks (BEFORE updating tracking)
|
|
if not CleveRoids.carnageDurationOverrides then
|
|
CleveRoids.carnageDurationOverrides = {}
|
|
end
|
|
CleveRoids.carnageDurationOverrides[rakeSpellID] = {
|
|
duration = rakeDuration,
|
|
timestamp = GetTime(),
|
|
targetGUID = targetGUID
|
|
}
|
|
|
|
-- Update CleveRoids internal tracking
|
|
if lib.objects[targetGUID] and lib.objects[targetGUID][rakeSpellID] then
|
|
local oldCaster = lib.objects[targetGUID][rakeSpellID].caster
|
|
lib.objects[targetGUID][rakeSpellID].duration = rakeDuration
|
|
lib.objects[targetGUID][rakeSpellID].start = GetTime()
|
|
lib.objects[targetGUID][rakeSpellID].expiry = GetTime() + rakeDuration
|
|
-- Ensure caster is preserved (required for personal debuff tracking)
|
|
if not lib.objects[targetGUID][rakeSpellID].caster then
|
|
lib.objects[targetGUID][rakeSpellID].caster = "player"
|
|
end
|
|
|
|
if CleveRoids.debug then
|
|
-- Verify the record was actually saved
|
|
local verifyRec = lib.objects[targetGUID] and lib.objects[targetGUID][rakeSpellID]
|
|
if verifyRec then
|
|
local verifyRemaining = verifyRec.duration + verifyRec.start - GetTime()
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff00ff[Carnage]|r Updated CleveRoids tracking for Rake (GUID:%s, caster:%s->%s)",
|
|
tostring(targetGUID), tostring(oldCaster), tostring(verifyRec.caster))
|
|
)
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff00ff[Carnage VERIFY]|r Rake record: dur=%s, remaining=%.1fs",
|
|
tostring(verifyRec.duration), verifyRemaining)
|
|
)
|
|
else
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff0000[Carnage ERROR]|r Rake record MISSING immediately after update!")
|
|
)
|
|
end
|
|
end
|
|
else
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff6600[Carnage]|r WARNING: Rake record not found! GUID:%s",
|
|
tostring(targetGUID))
|
|
)
|
|
end
|
|
end
|
|
|
|
-- DON'T call pfUI's AddEffect - just update the existing entry directly
|
|
-- pfUI will pick up the new duration through our GetDuration/UnitDebuff hooks
|
|
if pfUI and pfUI.api and pfUI.api.libdebuff then
|
|
local pflib = pfUI.api.libdebuff
|
|
local rakeSpellName = C_Spell.GetSpellName(rakeSpellID)
|
|
local baseName = CleveRoids.StripRank(rakeSpellName) or "Rake"
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff00ff[Carnage]|r Updating existing pfUI entry for Rake directly")
|
|
)
|
|
end
|
|
|
|
-- Find and update the existing pfUI entry (don't create new ones)
|
|
if pflib.objects and pflib.objects[targetName] then
|
|
local updated = false
|
|
for level, effects in pairs(pflib.objects[targetName]) do
|
|
if type(effects) == "table" and effects[baseName] then
|
|
-- Update the existing entry
|
|
effects[baseName].start = GetTime()
|
|
effects[baseName].duration = rakeDuration
|
|
effects[baseName].caster = "player"
|
|
updated = true
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff00ff[Carnage]|r Updated pfUI Rake at level %s", tostring(level))
|
|
)
|
|
end
|
|
-- Only update the FIRST occurrence to avoid duplicates
|
|
break
|
|
end
|
|
end
|
|
|
|
if updated and pflib.UpdateUnits then
|
|
pflib:UpdateUnits()
|
|
end
|
|
end
|
|
end
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff00ff[Carnage]|r Refreshed Rake: %ds on %s",
|
|
rakeDuration, targetName or "Unknown")
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Frame for delayed personal debuff tracking and judgement scanning
|
|
-- NOTE: Carnage refresh is now handled via PLAYER_COMBO_POINTS event in ComboPointTracker.lua
|
|
|
|
-- PERFORMANCE: Upvalues for frequently called functions
|
|
local _GetTime = GetTime
|
|
local _UnitExists = UnitExists
|
|
local _UnitDebuff = UnitDebuff
|
|
local _IsUnitDead = CleveRoids.IsUnitDead
|
|
local _UnitName = UnitName
|
|
local _GetSpellRecField = GetSpellRecField
|
|
local _string_find = string.find
|
|
local _string_format = string.format
|
|
local _string_sub = string.sub
|
|
local _tostring = tostring
|
|
local _next = next
|
|
local _pairs = pairs
|
|
local _getn = table.getn -- Lua 5.0 compatible table length
|
|
local _table_insert = table.insert -- Lua 5.0 compatible
|
|
|
|
-- PERFORMANCE: Static buffers for removal tracking - reused every frame
|
|
-- Using mark-and-sweep pattern instead of table.remove() for O(1) removal
|
|
local _pendingJudgementsBuffer = {}
|
|
local _pendingPersonalBuffer = {}
|
|
local _pendingCCBuffer = {}
|
|
local _pendingSharedBuffer = {}
|
|
|
|
-- Spells with INVULNERABILITY mechanic (mechanic 25) from BuffLib SpellData DBC
|
|
-- These grant temporary immunity and should not trigger permanent immunity recording
|
|
-- Source: BuffLib/SpellData.lua - extracted from DBC files
|
|
local INVULNERABILITY_SPELL_IDS = {
|
|
-- Paladin
|
|
[498] = true, -- Divine Protection (Rank 1)
|
|
[642] = true, -- Divine Shield (Rank 1)
|
|
[1020] = true, -- Divine Shield (Rank 2)
|
|
[1022] = true, -- Blessing of Protection (Rank 1)
|
|
[5573] = true, -- Divine Protection (Rank 2)
|
|
[5599] = true, -- Blessing of Protection (Rank 2)
|
|
[10278] = true, -- Blessing of Protection (Rank 3)
|
|
[25771] = true, -- Forbearance (debuff after immunity)
|
|
-- Old/Unused Paladin
|
|
[1052] = true, -- zzOLDBlessing of Righteousness
|
|
[5601] = true, -- zzOLDBlessing of Righteousness
|
|
[5602] = true, -- zzOLDBlessing of Righteousness
|
|
[10280] = true, -- zzOLDBlessing of Righteousness
|
|
[10281] = true, -- zzOLDBlessing of Righteousness
|
|
-- Rogue
|
|
[6770] = true, -- Sap (invuln during effect)
|
|
-- NPC/Misc
|
|
[7992] = true, -- Slowing Poison
|
|
[11638] = true, -- Radiation Poisoning
|
|
[14897] = true, -- Slowing Poison
|
|
[16603] = true, -- Demonfork
|
|
[16791] = true, -- Furious Anger
|
|
[17407] = true, -- Wound
|
|
[18208] = true, -- Poison
|
|
[23230] = true, -- Blood Fury
|
|
[24005] = true, -- Food
|
|
[24707] = true, -- Food
|
|
[24865] = true, -- Sanctified Orb
|
|
[26263] = true, -- Dim Sum
|
|
[28522] = true, -- Icebolt
|
|
[29055] = true, -- Refreshing Red Apple
|
|
[29325] = true, -- Acid Volley
|
|
[29330] = true, -- Sapphiron's Wing Buffet Despawn
|
|
-- Spell Reflection (no DBC mechanic, but grants immunity to reflected schools)
|
|
[9941] = true, -- Spell Reflection
|
|
[9943] = true, -- Spell Reflection
|
|
[10074] = true, -- Spell Reflection
|
|
[11818] = true, -- Spell Reflection
|
|
[21118] = true, -- Spell Reflection
|
|
-- School-specific Reflectors (Engineering items)
|
|
[23097] = true, -- Fire Reflector
|
|
[23131] = true, -- Frost Reflector
|
|
[23132] = true, -- Shadow Reflector
|
|
[23178] = true, -- Nature Reflector
|
|
[23216] = true, -- Arcane Reflector
|
|
-- Multi-school Reflect (NPC abilities)
|
|
[13022] = true, -- Fire and Arcane Reflect
|
|
[19595] = true, -- Shadow and Frost Reflect
|
|
-- Generic Reflection buffs
|
|
[3651] = true, -- Shield of Reflection
|
|
[9906] = true, -- Reflection
|
|
[10831] = true, -- Reflection Field
|
|
[17106] = true, -- Reflection
|
|
[17107] = true, -- Reflection
|
|
[17108] = true, -- Reflection
|
|
[20223] = true, -- Magic Reflection
|
|
[20619] = true, -- Magic Reflection
|
|
[22067] = true, -- Reflection
|
|
[23920] = true, -- Shield Reflection
|
|
[23921] = true, -- Shield Reflection
|
|
[27564] = true, -- Reflection
|
|
}
|
|
|
|
-- Check if a unit has any immunity-granting buff active
|
|
-- Uses only spell IDs from DBC mechanic 25 (INVULNERABILITY) for reliability
|
|
-- Returns the buff name if found, nil otherwise
|
|
local function HasImmunityGrantingBuff(unit)
|
|
if not UnitExists(unit) then return nil end
|
|
|
|
for i = 1, 32 do
|
|
local texture, stacks, spellID = UnitBuff(unit, i)
|
|
if not texture then break end
|
|
|
|
if spellID and INVULNERABILITY_SPELL_IDS[spellID] then
|
|
local buffName = C_Spell.GetSpellName(spellID) or ("SpellID:" .. spellID)
|
|
return buffName
|
|
end
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
-- PERFORMANCE: Throttling - run at 20Hz max instead of every frame (60+Hz)
|
|
-- Minimum delay is 0.2s, so 20Hz (50ms) gives us 4 checks per minimum delay
|
|
local _lastDelayedTrackingUpdate = 0
|
|
local _DELAYED_TRACKING_INTERVAL = 0.05 -- 50ms = 20Hz
|
|
|
|
local delayedTrackingFrame = CreateFrame("Frame", "CleveRoidsDelayedTrackingFrame", UIParent)
|
|
delayedTrackingFrame:SetScript("OnUpdate", function()
|
|
-- PERFORMANCE: Throttle updates - most pending items have 0.2-0.5s delays anyway
|
|
local currentTime = _GetTime()
|
|
if (currentTime - _lastDelayedTrackingUpdate) < _DELAYED_TRACKING_INTERVAL then
|
|
return
|
|
end
|
|
_lastDelayedTrackingUpdate = currentTime
|
|
|
|
-- Process deferred guidToName cleanups
|
|
if lib._guidNameCleanupQueue then
|
|
local i = 1
|
|
while i <= table.getn(lib._guidNameCleanupQueue) do
|
|
local entry = lib._guidNameCleanupQueue[i]
|
|
if entry and currentTime >= entry.expiry then
|
|
-- Only remove if no active tracking data references this GUID
|
|
if not (lib.ownDebuffs[entry.guid] or lib.pendingCasts[entry.guid]) then
|
|
lib.guidToName[entry.guid] = nil
|
|
end
|
|
table.remove(lib._guidNameCleanupQueue, i)
|
|
else
|
|
i = i + 1
|
|
end
|
|
end
|
|
end
|
|
|
|
-- PERFORMANCE: Early exit if all queues are empty
|
|
-- NOTE: Use next() instead of [1] to handle array holes from table.remove()
|
|
-- Array holes can occur when combat log handlers remove entries mid-iteration
|
|
local hasJudgements = lib.pendingJudgements and _next(lib.pendingJudgements)
|
|
local hasPersonal = lib.pendingPersonalDebuffs and _next(lib.pendingPersonalDebuffs)
|
|
local hasCC = lib.pendingCCDebuffs and _next(lib.pendingCCDebuffs)
|
|
local hasShared = lib.pendingSharedDebuffs and _next(lib.pendingSharedDebuffs)
|
|
local hasOverrides = lib.rankRefreshOverrides and _next(lib.rankRefreshOverrides)
|
|
|
|
-- DEBUG: Track pending queue state (keyed — only prints on change)
|
|
if CleveRoids.debug then
|
|
local sharedCount = lib.pendingSharedDebuffs and _getn(lib.pendingSharedDebuffs) or 0
|
|
local personalCount = lib.pendingPersonalDebuffs and _getn(lib.pendingPersonalDebuffs) or 0
|
|
if sharedCount > 0 then
|
|
CleveRoids.DebugChanged("pending_shared",
|
|
_string_format("|cffaaaaaa[Pending Queue]|r shared:%d personal:%d",
|
|
sharedCount, personalCount))
|
|
elseif CleveRoids._lastDebugState["pending_shared"] then
|
|
CleveRoids._lastDebugState["pending_shared"] = nil
|
|
end
|
|
if personalCount > 0 then
|
|
CleveRoids.DebugChanged("pending_personal",
|
|
_string_format("|cffaaaaaa[Personal Queue]|r count:%d",
|
|
personalCount))
|
|
elseif CleveRoids._lastDebugState["pending_personal"] then
|
|
CleveRoids._lastDebugState["pending_personal"] = nil
|
|
end
|
|
end
|
|
|
|
if not (hasJudgements or hasPersonal or hasCC or hasShared or hasOverrides) then
|
|
return
|
|
end
|
|
|
|
-- Cache frequently accessed values
|
|
local hasSuperwow = CleveRoids.hasSuperwow
|
|
local hasExtendedTokens = CleveRoids.NampowerAPI and CleveRoids.NampowerAPI.features
|
|
and CleveRoids.NampowerAPI.features.hasExtendedUnitTokens
|
|
local debug = CleveRoids.debug
|
|
|
|
-- Resolve a GUID to a queryable unit token
|
|
-- SuperWoW: returns GUID directly (SuperWoW extends WoW APIs to accept GUIDs)
|
|
-- Nampower v2.41+ extended tokens: GUID works as unit token if unit is in range
|
|
-- Fallback: returns "target" if GUID matches current target, nil otherwise
|
|
local function ResolveGUIDUnit(guid)
|
|
if not guid then return nil end
|
|
if hasSuperwow then return guid end
|
|
if hasExtendedTokens and _UnitExists(guid) then return guid end
|
|
local ctGUID = CleveRoids.GetGUID("target")
|
|
if ctGUID and ctGUID == guid then
|
|
return "target"
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Process pending judgement scans to detect actual debuff IDs
|
|
if hasJudgements then
|
|
local writeIdx = 0
|
|
local pendingList = lib.pendingJudgements
|
|
|
|
for i = 1, _getn(pendingList) do
|
|
local pending = pendingList[i]
|
|
-- Guard against nil entries (can occur if combat log events remove items during iteration)
|
|
if pending then
|
|
local elapsed = currentTime - pending.timestamp
|
|
|
|
-- Scan target after 0.5 seconds to find the actual judgement debuff
|
|
if elapsed >= 0.5 then
|
|
-- Check if this is still the current target
|
|
local currentTargetGUID = CleveRoids.GetGUID("target")
|
|
|
|
if currentTargetGUID == pending.targetGUID then
|
|
-- Scan all debuffs on target to find judgement-type debuffs
|
|
for slot = 1, 16 do
|
|
local _, _, _, debuffSpellID = _UnitDebuff("target", slot)
|
|
if not debuffSpellID then break end
|
|
|
|
local debuffName = C_Spell.GetSpellName(debuffSpellID)
|
|
-- Check if this is a judgement debuff (name starts with "Judgement")
|
|
if debuffName and _string_find(debuffName, "^Judgement") then
|
|
-- Found a judgement debuff! Store it for refresh tracking
|
|
if not lib.detectedJudgementDebuffIDs[debuffSpellID] then
|
|
lib.detectedJudgementDebuffIDs[debuffSpellID] = true
|
|
lib.judgementSpells[debuffSpellID] = true -- Add to refresh list
|
|
-- Also register as shared debuff so [debuff]/[nodebuff] fallback detection works
|
|
-- for custom Turtle WoW spell IDs not in the hardcoded sharedDebuffs table
|
|
if not lib.sharedDebuffs[debuffSpellID] then
|
|
lib.sharedDebuffs[debuffSpellID] = 10 -- Standard judgement duration
|
|
CleveRoids.InvalidateSpellNameCache()
|
|
end
|
|
|
|
if debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cff00ffff[Judgement Detect]|r Found debuff %s (ID:%d) from cast (ID:%d) - added to refresh + shared list",
|
|
debuffName, debuffSpellID, pending.castSpellID)
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
-- Item processed, don't copy to output
|
|
else
|
|
-- Item not ready, keep it
|
|
writeIdx = writeIdx + 1
|
|
if writeIdx ~= i then
|
|
pendingList[writeIdx] = pending
|
|
end
|
|
end
|
|
end -- if pending
|
|
end
|
|
|
|
-- PERFORMANCE: Clear remaining slots and update length
|
|
for i = writeIdx + 1, _getn(pendingList) do
|
|
pendingList[i] = nil
|
|
end
|
|
end
|
|
|
|
-- Process pending personal debuffs
|
|
if hasPersonal then
|
|
-- Count entries properly using pairs (handles array holes)
|
|
local pendingCount = 0
|
|
for _ in _pairs(lib.pendingPersonalDebuffs) do
|
|
pendingCount = pendingCount + 1
|
|
end
|
|
|
|
if debug and pendingCount > 0 then
|
|
CleveRoids.DebugChanged("pending_debug",
|
|
_string_format("|cffaaaaaa[Pending Debug]|r %d personal debuffs waiting", pendingCount))
|
|
end
|
|
|
|
-- Use pairs() to iterate and rebuild without holes
|
|
-- This handles array holes created by table.remove() in combat log handlers
|
|
local newPendingList = {}
|
|
|
|
for _, pending in _pairs(lib.pendingPersonalDebuffs) do
|
|
-- Process each entry (pairs handles holes correctly)
|
|
if pending then
|
|
local elapsed = currentTime - pending.timestamp
|
|
|
|
-- Add debuff after 0.2 second delay (enough time for server sync)
|
|
if elapsed >= 0.2 then
|
|
-- DEBUFF IMMUNITY VERIFICATION: Check if debuff actually appeared
|
|
-- This happens AFTER the delay, giving the server time to sync the debuff
|
|
local isBleedSpell = CleveRoids.BleedSpellIDs and CleveRoids.BleedSpellIDs[pending.spellID]
|
|
local debuffVerified = true
|
|
|
|
-- SPELL_GO confirmed miss → debuff definitely didn't land (any spell type)
|
|
-- Immunity is recorded by SPELL_MISS_SELF → ProcessSpellMissSelf → RecordImmunity
|
|
if pending.spellGoMissed then
|
|
debuffVerified = false
|
|
if debug then
|
|
local spellNameDbg = C_Spell.GetSpellName(pending.spellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffff6600[Personal Miss]|r %s (ID:%d) missed on %s - SPELL_GO confirmed miss",
|
|
spellNameDbg, pending.spellID, pending.targetName or "Unknown")
|
|
)
|
|
end
|
|
end
|
|
|
|
-- DEBUG: Log verification attempt for bleed spells
|
|
if debug and isBleedSpell and not pending.spellGoMissed then
|
|
local spellNameDbg = C_Spell.GetSpellName(pending.spellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cff00aaff[Bleed Verify Start]|r %s (ID:%d) on %s - hasSuperwow:%s, targetGUID:%s",
|
|
spellNameDbg, pending.spellID, pending.targetName or "Unknown",
|
|
hasSuperwow and "yes" or "NO", pending.targetGUID and "exists" or "NIL")
|
|
)
|
|
end
|
|
|
|
local verifyUnit = isBleedSpell and not pending.spellGoMissed
|
|
and pending.targetGUID and ResolveGUIDUnit(pending.targetGUID) or nil
|
|
if isBleedSpell and verifyUnit then
|
|
-- Check if mob is in bleed whitelist (skip verification for known bleeders)
|
|
local isWhitelisted = CleveRoids.MobsThatBleed and CleveRoids.MobsThatBleed[pending.targetGUID]
|
|
|
|
if not isWhitelisted then
|
|
-- Check if target is dead - requires special handling
|
|
if _IsUnitDead(verifyUnit) then
|
|
-- Target died - check if we saw "afflicted by" message before death
|
|
if pending.verifiedByAffliction then
|
|
-- We confirmed bleed landed via combat log before target died
|
|
if debug then
|
|
local spellNameDebug = C_Spell.GetSpellName(pending.spellID) or "Bleed"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cff00ff00[Bleed Verified]|r %s on %s confirmed via 'afflicted by' (target now dead)",
|
|
spellNameDebug, pending.targetName or "Unknown")
|
|
)
|
|
end
|
|
else
|
|
-- Target died WITHOUT "afflicted by" message
|
|
-- BUT: Don't record immunity for one-shot kills!
|
|
-- If target died within 0.5s of cast, the bleed didn't have time to apply
|
|
-- even on a valid target. We can't determine immunity from instant kills.
|
|
local timeSinceCast = currentTime - pending.timestamp
|
|
local ONE_SHOT_THRESHOLD = 0.5 -- seconds
|
|
|
|
if timeSinceCast < ONE_SHOT_THRESHOLD then
|
|
-- One-shot kill - can't determine immunity, skip recording
|
|
-- Set debuffVerified=false so we don't add to tracking or remove existing immunity
|
|
debuffVerified = false
|
|
if debug then
|
|
local spellNameDebug = C_Spell.GetSpellName(pending.spellID) or "Bleed"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffaaaaaa[Bleed Skip]|r %s on %s - target died too quickly (%.2fs), can't determine immunity",
|
|
spellNameDebug, pending.targetName or "Unknown", timeSinceCast)
|
|
)
|
|
end
|
|
-- Note: We set debuffVerified = false but DON'T record immunity
|
|
-- This is intentional - one-shots are inconclusive
|
|
else
|
|
-- Target died without "afflicted by" — inconclusive, don't record immunity.
|
|
-- "afflicted by" combat log messages can be delayed/throttled, so absence
|
|
-- is not proof of immunity. SPELL_MISS_SELF handles true immunity detection.
|
|
debuffVerified = false
|
|
if debug then
|
|
local spellNameForImmunity = C_Spell.GetSpellName(pending.spellID) or "Bleed"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffaaaaaa[Bleed Inconclusive]|r %s on %s - target died without 'afflicted by' (%.2fs), not recording immunity",
|
|
spellNameForImmunity, pending.targetName or "Unknown", timeSinceCast)
|
|
)
|
|
end
|
|
end
|
|
end
|
|
elseif not UnitExists(verifyUnit) then
|
|
-- Target despawned or GUID is invalid - can't verify, skip without recording immunity
|
|
-- This is similar to one-shot kills: inconclusive result, don't record immunity
|
|
debuffVerified = false
|
|
if debug then
|
|
local spellNameDebug = C_Spell.GetSpellName(pending.spellID) or "Bleed"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffaaaaaa[Bleed Skip]|r %s on %s - target no longer exists (despawned), can't determine immunity",
|
|
spellNameDebug, pending.targetName or "Unknown")
|
|
)
|
|
end
|
|
-- Note: We set debuffVerified = false but DON'T record immunity
|
|
-- This is intentional - despawned targets are inconclusive
|
|
else
|
|
-- Target is alive - check debuffs for bleed spell
|
|
debuffVerified = false
|
|
local totalDebuffs = 0
|
|
|
|
for slot = 1, 48 do
|
|
local _, _, _, debuffSpellID = _UnitDebuff(verifyUnit, slot)
|
|
if not debuffSpellID then
|
|
if slot <= 16 then break end -- Regular debuffs are dense, overflow continues on nil
|
|
else
|
|
totalDebuffs = totalDebuffs + 1
|
|
if debuffSpellID == pending.spellID then
|
|
debuffVerified = true
|
|
-- Don't break - continue counting total debuffs for immunity vs cap detection
|
|
end
|
|
end
|
|
end
|
|
|
|
-- If bleed is missing and target has few debuffs, it's likely immunity (not debuff cap)
|
|
if not debuffVerified then
|
|
local DEBUFF_CAP_THRESHOLD = 47 -- Max is 48 (16 visible + 32 overflow)
|
|
-- Guard: don't record immunity for player targets (PvP resists, trinkets, etc.)
|
|
local isPlayer = UnitIsPlayer(verifyUnit)
|
|
-- Guard: don't record if target has temporary immunity buff
|
|
local immunityBuff = not isPlayer and HasImmunityGrantingBuff(verifyUnit)
|
|
if isPlayer then
|
|
if debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffaaaaaa[Bleed Skip]|r Player target %s - not recording immunity",
|
|
pending.targetName or "Unknown")
|
|
)
|
|
end
|
|
elseif immunityBuff then
|
|
if debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffaaaaaa[Bleed Skip]|r %s has %s - not recording immunity",
|
|
pending.targetName or "Unknown", immunityBuff)
|
|
)
|
|
end
|
|
elseif totalDebuffs < DEBUFF_CAP_THRESHOLD then
|
|
-- Few debuffs = likely bleed immunity, not debuff cap
|
|
if pending.targetName and pending.targetName ~= "" then
|
|
-- Record as BLEED immunity directly (bypass split damage override in RecordImmunity)
|
|
-- RecordImmunity would record Rake/Pounce as "physical" (initial school),
|
|
-- but we specifically detected the BLEED debuff didn't land
|
|
local spellNameForImmunity = C_Spell.GetSpellName(pending.spellID) or "Bleed"
|
|
|
|
if not CleveRoids_ImmunityData["bleed"] then
|
|
CleveRoids_ImmunityData["bleed"] = {}
|
|
end
|
|
CleveRoids_ImmunityData["bleed"][pending.targetName] = true
|
|
|
|
CleveRoids.DebugChanged("bleed_immune_" .. _tostring(pending.targetName),
|
|
_string_format("|cffff6600[Bleed Immunity]|r %s is immune to bleed (%s) - only %d debuffs on target",
|
|
pending.targetName, spellNameForImmunity, totalDebuffs))
|
|
end
|
|
else
|
|
CleveRoids.DebugChanged("bleed_cap_" .. _tostring(pending.targetName) .. "_" .. pending.spellID,
|
|
_string_format("|cffff6600[Debuff Cap]|r %s not found on %s - likely pushed off (%d debuffs on target)",
|
|
C_Spell.GetSpellName(pending.spellID) or "Bleed", pending.targetName or "Unknown", totalDebuffs))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- NON-BLEED VERIFICATION: Verify non-bleed personal debuffs actually applied
|
|
-- Always verify even when spellGoHit=true: SPELL_GO "hit" means initial damage landed,
|
|
-- not that the DoT was applied (e.g., lower rank Moonfire deals initial damage but DoT
|
|
-- is blocked by "a more powerful spell is already active")
|
|
if not isBleedSpell and debuffVerified and not pending.spellGoMissed then
|
|
-- Fast path: use pfUI's downrank-protected ownDebuffs table when available
|
|
-- pfUI blocks lower-rank overwrites, so if ownDebuffs still has a higher rank
|
|
-- with time remaining, the lower rank DoT didn't apply
|
|
local downrankBlocked = false
|
|
if lib.ownDebuffs and pending.targetGUID then
|
|
local spellName = C_Spell.GetSpellName(pending.spellID)
|
|
if spellName then
|
|
local existing = lib.ownDebuffs[pending.targetGUID] and lib.ownDebuffs[pending.targetGUID][spellName]
|
|
if existing and existing.rank and existing.spellId and existing.spellId ~= pending.spellID then
|
|
local pendingRankStr = C_Spell.GetSpellSubtext(pending.spellID)
|
|
local pendingRank = pendingRankStr and tonumber((string.gsub(pendingRankStr, "Rank ", ""))) or 0
|
|
if pendingRank > 0 and existing.rank > pendingRank then
|
|
local existingTimeleft = (existing.startTime + existing.duration) - GetTime()
|
|
if existingTimeleft > 0 then
|
|
downrankBlocked = true
|
|
debuffVerified = false
|
|
CleveRoids.DebugChanged("downrank_" .. pending.spellID .. "_" .. _tostring(pending.targetGUID),
|
|
_string_format("|cffff6600[Downrank Blocked]|r %s Rank %d blocked by Rank %d (%.1fs left)",
|
|
spellName, pendingRank, existing.rank, existingTimeleft))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Non-pfUI fallback: check lib.objects for a higher rank of the same spell
|
|
if not downrankBlocked and lib.objects and pending.targetGUID and lib.objects[pending.targetGUID] then
|
|
local pendingBaseName = lib.GetSpellBaseName and lib:GetSpellBaseName(pending.spellID)
|
|
local pendingRank = lib.GetSpellRank and lib:GetSpellRank(pending.spellID)
|
|
if pendingBaseName and pendingRank > 0 then
|
|
for existingSID, rec in pairs(lib.objects[pending.targetGUID]) do
|
|
if existingSID ~= pending.spellID and rec and rec.caster == "player"
|
|
and rec.start and rec.duration then
|
|
local remaining = rec.duration + rec.start - GetTime()
|
|
if remaining > 0 then
|
|
local existingBaseName = lib:GetSpellBaseName(existingSID)
|
|
if existingBaseName == pendingBaseName then
|
|
local existingRank = lib:GetSpellRank(existingSID)
|
|
if existingRank > pendingRank then
|
|
downrankBlocked = true
|
|
debuffVerified = false
|
|
CleveRoids.DebugChanged("downrank_obj_" .. pending.spellID .. "_" .. _tostring(pending.targetGUID),
|
|
_string_format("|cffff6600[Downrank Blocked]|r %s Rank %d blocked by Rank %d in lib.objects (%.1fs left)",
|
|
pendingBaseName, pendingRank, existingRank, remaining))
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local nonBleedVerifyUnit = not downrankBlocked
|
|
and pending.targetGUID and ResolveGUIDUnit(pending.targetGUID) or nil
|
|
if nonBleedVerifyUnit then
|
|
if _IsUnitDead(nonBleedVerifyUnit) then
|
|
-- Target died - can't verify, assume landed
|
|
if debug then
|
|
local spellNameDebug = C_Spell.GetSpellName(pending.spellID) or "Debuff"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffaaaaaa[NonBleed Skip]|r %s on %s - target dead, assuming landed",
|
|
spellNameDebug, pending.targetName or "Unknown")
|
|
)
|
|
end
|
|
elseif not UnitExists(nonBleedVerifyUnit) then
|
|
-- Target despawned - inconclusive, don't add to tracking
|
|
debuffVerified = false
|
|
if debug then
|
|
local spellNameDebug = C_Spell.GetSpellName(pending.spellID) or "Debuff"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffaaaaaa[NonBleed Skip]|r %s on %s - target despawned, can't verify",
|
|
spellNameDebug, pending.targetName or "Unknown")
|
|
)
|
|
end
|
|
else
|
|
-- Target is alive - check if debuff exists
|
|
local nonBleedFound = false
|
|
local totalDebuffs = 0
|
|
|
|
for slot = 1, 48 do
|
|
local _, _, _, debuffSpellID = _UnitDebuff(nonBleedVerifyUnit, slot)
|
|
if not debuffSpellID then
|
|
if slot <= 16 then break end -- Regular debuffs are dense, overflow continues on nil
|
|
else
|
|
totalDebuffs = totalDebuffs + 1
|
|
if debuffSpellID == pending.spellID then
|
|
nonBleedFound = true
|
|
-- Don't break - continue counting for immunity vs cap detection
|
|
end
|
|
end
|
|
end
|
|
|
|
if not nonBleedFound then
|
|
debuffVerified = false
|
|
local DEBUFF_CAP_THRESHOLD = 47
|
|
local isPlayer = UnitIsPlayer(nonBleedVerifyUnit)
|
|
local immunityBuff = not isPlayer and HasImmunityGrantingBuff(nonBleedVerifyUnit)
|
|
if isPlayer then
|
|
if debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffaaaaaa[NonBleed Skip]|r Player target %s - not recording immunity",
|
|
pending.targetName or "Unknown")
|
|
)
|
|
end
|
|
elseif immunityBuff then
|
|
if debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffaaaaaa[NonBleed Skip]|r %s has %s - not recording immunity",
|
|
pending.targetName or "Unknown", immunityBuff)
|
|
)
|
|
end
|
|
elseif totalDebuffs < DEBUFF_CAP_THRESHOLD then
|
|
-- Few debuffs = likely school immunity, not debuff cap
|
|
-- Use RecordImmunity for proper DBC school lookup (with bleed override)
|
|
if pending.targetName and pending.targetName ~= "" then
|
|
CleveRoids.RecordImmunity(pending.targetName, nil, nil, pending.spellID)
|
|
CleveRoids.DebugChanged("nonbleed_immune_" .. _tostring(pending.targetName) .. "_" .. pending.spellID,
|
|
_string_format("|cffff6600[NonBleed Immunity]|r %s is immune to %s - only %d debuffs on target",
|
|
pending.targetName, C_Spell.GetSpellName(pending.spellID) or "Debuff", totalDebuffs))
|
|
end
|
|
else
|
|
CleveRoids.DebugChanged("nonbleed_cap_" .. _tostring(pending.targetName) .. "_" .. pending.spellID,
|
|
_string_format("|cffff6600[NonBleed Debuff Cap]|r %s not found on %s - likely pushed off (%d debuffs)",
|
|
C_Spell.GetSpellName(pending.spellID) or "Debuff", pending.targetName or "Unknown", totalDebuffs))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
-- If no verifyUnit (can't resolve GUID), debuffVerified stays true (can't verify)
|
|
end
|
|
|
|
-- Only apply the debuff to tracking if it was verified (or not a bleed/was whitelisted)
|
|
if debuffVerified then
|
|
-- Debug: Show what we're about to add to tracking
|
|
if debug then
|
|
local spellNameDebug = C_Spell.GetSpellName(pending.spellID) or "Unknown"
|
|
local guidStr = _string_sub(_tostring(pending.targetGUID or "nil"), 1, 20)
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cff88ff88[Pending Process]|r Adding %s (ID:%d, %ds) GUID:%s",
|
|
spellNameDebug, pending.spellID, pending.duration or 0, guidStr)
|
|
)
|
|
end
|
|
|
|
lib:AddEffect(pending.targetGUID, pending.targetName, pending.spellID, pending.duration, 0, "player")
|
|
|
|
-- Remove any existing bleed immunity record for this NPC ONLY if a bleed spell landed
|
|
-- Previously this unconditionally removed bleed immunity for ANY personal debuff
|
|
if isBleedSpell and pending.targetName and pending.targetName ~= "" then
|
|
CleveRoids.RemoveSpellImmunity(pending.targetName, "bleed")
|
|
end
|
|
|
|
-- CARNAGE TALENT: Mark Rake as verified for Ferocious Bite refresh
|
|
local isRakeSpell = CleveRoids.RakeSpellIDs and CleveRoids.RakeSpellIDs[pending.spellID]
|
|
if isRakeSpell and CleveRoids.lastRakeCast and CleveRoids.lastRakeCast.pending then
|
|
if CleveRoids.lastRakeCast.targetGUID == pending.targetGUID and
|
|
CleveRoids.lastRakeCast.spellID == pending.spellID then
|
|
CleveRoids.lastRakeCast.pending = nil -- Mark as verified
|
|
if debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cff00ff00[Carnage]|r Rake verified on %s - ready for Ferocious Bite refresh",
|
|
pending.targetName or "Unknown")
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
if debug then
|
|
local spellName = C_Spell.GetSpellName(pending.spellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cff00ff00[Delayed Track]|r Applied %s (ID:%d) to tracking on %s",
|
|
spellName, pending.spellID, pending.targetName or "Unknown")
|
|
)
|
|
end
|
|
else
|
|
-- Bleed didn't land - clear pending Rake data for Carnage if applicable
|
|
local isRakeSpell = CleveRoids.RakeSpellIDs and CleveRoids.RakeSpellIDs[pending.spellID]
|
|
if isRakeSpell and CleveRoids.lastRakeCast and CleveRoids.lastRakeCast.pending then
|
|
if CleveRoids.lastRakeCast.targetGUID == pending.targetGUID and
|
|
CleveRoids.lastRakeCast.spellID == pending.spellID then
|
|
CleveRoids.lastRakeCast = nil -- Clear invalid Rake data
|
|
if debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffff6600[Carnage]|r Rake failed to apply on %s - clearing tracking data",
|
|
pending.targetName or "Unknown")
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
-- Item processed, don't add to new list
|
|
else
|
|
-- Item not ready, keep it in the new list
|
|
_table_insert(newPendingList, pending)
|
|
end
|
|
end -- if pending
|
|
end
|
|
|
|
-- Replace old list with compacted new list (no holes)
|
|
lib.pendingPersonalDebuffs = newPendingList
|
|
end
|
|
|
|
-- Process pending CC debuffs for immunity detection
|
|
if hasCC then
|
|
-- Use pairs() to iterate and rebuild without holes (same fix as personal debuffs)
|
|
local newPendingList = {}
|
|
|
|
for _, pending in _pairs(lib.pendingCCDebuffs) do
|
|
-- Process each entry (pairs handles holes correctly)
|
|
if pending then
|
|
local elapsed = currentTime - pending.timestamp
|
|
|
|
-- Check after delay: 0.4s for hidden CC (no visible debuff), 0.2s for normal CC
|
|
-- Hidden CC (e.g., Pounce stun) needs longer delay to wait for "afflicted by" messages
|
|
local verifyDelay = pending.isHiddenCC and 0.4 or 0.2
|
|
|
|
-- Drop stale entries (>2s) without recording immunity - inconclusive due to severe lag
|
|
if elapsed > 2.0 then
|
|
if debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffff6600[CC Stale]|r Dropping stale CC entry for %s on %s (%.1fs old)",
|
|
pending.ccType or "CC", pending.targetName or "Unknown", elapsed)
|
|
)
|
|
end
|
|
-- Don't add to newPendingList - silently discard
|
|
elseif elapsed >= verifyDelay then
|
|
local ccVerified = false
|
|
local totalDebuffs = 0
|
|
|
|
-- Check if already verified via "afflicted by" combat log message
|
|
-- This happens for hidden CC spells like Pounce when the message arrives first
|
|
if pending.verifiedByAffliction then
|
|
ccVerified = true
|
|
end
|
|
|
|
-- SPELL_GO early-exit: Use definitive hit/miss data when available
|
|
-- This is faster and more reliable than debuff scanning, and handles debuff cap correctly
|
|
if not ccVerified and pending.spellGoHit then
|
|
ccVerified = true
|
|
if debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cff00ff00[CC Verified via SPELL_GO]|r %s landed on %s - skipping debuff scan",
|
|
pending.ccType or "CC", pending.targetName or "Unknown")
|
|
)
|
|
end
|
|
end
|
|
|
|
-- CC IMMUNITY VERIFICATION: Check if CC effect actually landed
|
|
-- Uses hybrid approach: direct spell ID match OR mechanic-based validation
|
|
-- (CC debuff IDs often differ from cast IDs, e.g., Pounce cast ≠ Pounce Stun debuff)
|
|
-- Guard: skip debuff scanning if SPELL_GO already determined outcome
|
|
local ccVerifyUnit = not ccVerified and not pending.spellGoHit and not pending.spellGoMissed
|
|
and pending.targetGUID and ResolveGUIDUnit(pending.targetGUID) or nil
|
|
if ccVerifyUnit then
|
|
-- Skip verification if target is dead (debuffs are removed on death)
|
|
if _IsUnitDead(ccVerifyUnit) then
|
|
ccVerified = true -- Assume CC landed, can't verify on dead target
|
|
if debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffff6600[CC Verify Skip]|r Target %s is dead - skipping immunity check for %s",
|
|
pending.targetName or "Unknown", pending.ccType or "CC")
|
|
)
|
|
end
|
|
else
|
|
-- Method 1: Direct spell ID matching (scan debuffs for exact spell ID)
|
|
if pending.spellID then
|
|
for slot = 1, 48 do
|
|
local texture, _, _, debuffSpellID = _UnitDebuff(ccVerifyUnit, slot)
|
|
if not texture then
|
|
if slot <= 16 then break end -- Regular debuffs are dense, overflow continues on nil
|
|
else
|
|
totalDebuffs = totalDebuffs + 1
|
|
-- Debug: Show each debuff found during CC verification
|
|
if debug then
|
|
local debuffName = C_Spell.GetSpellName(debuffSpellID) or "Unknown"
|
|
local mechanic = CleveRoids.GetSpellMechanic and CleveRoids.GetSpellMechanic(debuffSpellID) or 0
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffaaaaaa[CC Scan]|r Slot %d: %s (ID:%d, Mech:%d) - Looking for %s (ID:%d)",
|
|
slot, debuffName, debuffSpellID or 0, mechanic, pending.spellName or "CC", pending.spellID or 0)
|
|
)
|
|
end
|
|
if debuffSpellID == pending.spellID then
|
|
ccVerified = true
|
|
-- Don't break - continue counting total debuffs for immunity vs cap detection
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Method 2: Mechanic-based validation (fallback if spell ID not found)
|
|
-- CC debuff IDs often differ from cast IDs (e.g., Pounce stun uses different ID)
|
|
-- Check if target has ANY debuff of the correct CC type (stun, fear, etc.)
|
|
if not ccVerified and pending.ccType and CleveRoids.ValidateUnitCC then
|
|
if debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffaaaa00[CC Verify]|r Direct ID match failed, trying mechanic check for %s...",
|
|
pending.ccType)
|
|
)
|
|
end
|
|
ccVerified = CleveRoids.ValidateUnitCC(ccVerifyUnit, pending.ccType)
|
|
if debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cff00aaff[CC Verify]|r Mechanic check result: %s",
|
|
ccVerified and "FOUND" or "NOT FOUND")
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if not ccVerified and pending.spellGoMissed and debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffff6600[CC Missed via SPELL_GO]|r %s missed on %s - checking immunity",
|
|
pending.ccType or "CC", pending.targetName or "Unknown")
|
|
)
|
|
end
|
|
|
|
-- If CC didn't land, check if it's immunity or debuff cap
|
|
if not ccVerified and ccVerifyUnit and not _IsUnitDead(ccVerifyUnit) then
|
|
-- Guard: don't record immunity for player targets (PvP trinkets, resists, etc.)
|
|
if UnitIsPlayer(ccVerifyUnit) then
|
|
if debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffaaaaaa[CC Skip]|r Player target %s - not recording %s immunity",
|
|
pending.targetName or "Unknown", pending.ccType or "CC")
|
|
)
|
|
end
|
|
-- Guard: don't record if target has temporary immunity buff
|
|
elseif HasImmunityGrantingBuff(ccVerifyUnit) then
|
|
if debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffaaaaaa[CC Skip]|r %s has immunity buff - not recording %s immunity",
|
|
pending.targetName or "Unknown", pending.ccType or "CC")
|
|
)
|
|
end
|
|
else
|
|
-- totalDebuffs already counted above, reuse it
|
|
if totalDebuffs == 0 then
|
|
-- Count wasn't done (dead target check skipped counting), do it now
|
|
for slot = 1, 48 do
|
|
local texture, _, _, debuffSpellID = _UnitDebuff(ccVerifyUnit, slot)
|
|
if not texture then
|
|
if slot <= 16 then break end
|
|
else
|
|
totalDebuffs = totalDebuffs + 1
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Try to get target name if we don't have it (might have been a focus/mouseover cast)
|
|
local resolvedTargetName = pending.targetName
|
|
if (not resolvedTargetName or resolvedTargetName == "") and pending.targetGUID then
|
|
-- Try to get from GUID cache
|
|
resolvedTargetName = lib.guidToName[pending.targetGUID]
|
|
-- Try to resolve from current target
|
|
if not resolvedTargetName then
|
|
local currentTargetGUID = CleveRoids.GetGUID("target")
|
|
if currentTargetGUID and currentTargetGUID == pending.targetGUID then
|
|
resolvedTargetName = _UnitName("target")
|
|
lib.guidToName[pending.targetGUID] = resolvedTargetName
|
|
end
|
|
end
|
|
-- Try to resolve using SuperWoW or Nampower extended tokens GUID-based query
|
|
if not resolvedTargetName and (hasSuperwow or hasExtendedTokens) then
|
|
if _UnitExists(pending.targetGUID) then
|
|
local name = _UnitName(pending.targetGUID)
|
|
if name and name ~= "Unknown" then
|
|
resolvedTargetName = name
|
|
lib.guidToName[pending.targetGUID] = resolvedTargetName
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local DEBUFF_CAP_THRESHOLD = 47 -- Max is 48 (16 visible + 32 overflow)
|
|
|
|
-- SPLIT CC SPELLS: Skip immunity recording for spells with physical damage + resistable CC
|
|
-- (e.g., Master Strike) - physical damage lands but CC can be resisted independently
|
|
-- spellGoHit: SPELL_GO confirmed hit, so CC was resisted independently (not true immunity)
|
|
local isSplitCCSpell = SPLIT_CC_SPELLS[pending.spellID] or pending.spellGoHit
|
|
if isSplitCCSpell then
|
|
if debug then
|
|
local spellNameDebug = pending.spellName or (C_Spell.GetSpellName(pending.spellID) or "Unknown")
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cff00aaff[Split CC Skip]|r %s CC resisted on %s - skipping CC immunity recording",
|
|
spellNameDebug, resolvedTargetName or "Unknown")
|
|
)
|
|
end
|
|
-- Skip immunity recording - damage landed, only CC was resisted
|
|
elseif totalDebuffs < DEBUFF_CAP_THRESHOLD then
|
|
-- Check DR before recording as permanent CC immunity
|
|
local isDR = false
|
|
if pending.targetGUID and pending.ccType then
|
|
local drEntry = lib.recentCCHits[pending.targetGUID] and lib.recentCCHits[pending.targetGUID][pending.ccType]
|
|
if drEntry and (GetTime() - drEntry.lastHitTime) < 20 and drEntry.count >= 3 then
|
|
isDR = true
|
|
end
|
|
end
|
|
if isDR then
|
|
if debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cff00aaff[CC DR Skip]|r %s on %s - likely DR immune (3+ recent %s hits), not recording permanent immunity",
|
|
pending.spellName or "Unknown", resolvedTargetName or "Unknown", pending.ccType or "Unknown")
|
|
)
|
|
end
|
|
elseif resolvedTargetName and resolvedTargetName ~= "" and pending.ccType then
|
|
CleveRoids.RecordCCImmunity(resolvedTargetName, pending.ccType, nil, pending.spellName)
|
|
|
|
if debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cff00ff00[CC Immunity]|r %s is immune to %s (%s) - verified: debuff missing, only %d debuffs on target",
|
|
resolvedTargetName, pending.ccType, pending.spellName or "Unknown", totalDebuffs)
|
|
)
|
|
end
|
|
elseif debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffff6600[CC Immunity Skip]|r Could not resolve target name for GUID %s - cannot record %s immunity",
|
|
pending.targetGUID or "nil", pending.ccType or "Unknown")
|
|
)
|
|
end
|
|
else
|
|
-- Many debuffs = likely pushed off
|
|
if debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffff6600[CC Debuff Cap]|r %s not found on %s - likely pushed off (%d debuffs)",
|
|
pending.ccType, resolvedTargetName or "Unknown", totalDebuffs)
|
|
)
|
|
end
|
|
end
|
|
end -- end of player/immunity-buff guard else block
|
|
else
|
|
-- CC landed successfully - remove any existing immunity record for this NPC/CC type
|
|
local resolvedTargetName = pending.targetName
|
|
if (not resolvedTargetName or resolvedTargetName == "") and pending.targetGUID then
|
|
resolvedTargetName = lib.guidToName[pending.targetGUID]
|
|
end
|
|
if resolvedTargetName and pending.ccType then
|
|
CleveRoids.RemoveCCImmunity(resolvedTargetName, pending.ccType)
|
|
end
|
|
-- Track successful CC hit for DR detection
|
|
if pending.targetGUID and pending.ccType then
|
|
lib.recentCCHits[pending.targetGUID] = lib.recentCCHits[pending.targetGUID] or {}
|
|
local entry = lib.recentCCHits[pending.targetGUID][pending.ccType]
|
|
lib.recentCCHits[pending.targetGUID][pending.ccType] = {
|
|
count = (entry and entry.count or 0) + 1,
|
|
lastHitTime = GetTime(),
|
|
}
|
|
end
|
|
if debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cff00ff00[CC Verified]|r %s landed on %s",
|
|
pending.ccType, resolvedTargetName or "Unknown")
|
|
)
|
|
end
|
|
end
|
|
-- Item processed, don't add to new list
|
|
else
|
|
-- Item not ready, keep it in the new list
|
|
_table_insert(newPendingList, pending)
|
|
end
|
|
end -- if pending
|
|
end
|
|
|
|
-- Replace old list with compacted new list (no holes)
|
|
lib.pendingCCDebuffs = newPendingList
|
|
end
|
|
|
|
-- Process pending shared debuffs for immunity detection
|
|
if hasShared then
|
|
-- Use pairs() to iterate and rebuild without holes (same fix as personal/CC debuffs)
|
|
local newPendingList = {}
|
|
|
|
-- Count for debug output
|
|
local pendingCount = 0
|
|
for _ in _pairs(lib.pendingSharedDebuffs) do
|
|
pendingCount = pendingCount + 1
|
|
end
|
|
|
|
if debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cff00aaff[Shared Process]|r Processing %d pending shared debuffs", pendingCount)
|
|
)
|
|
end
|
|
|
|
for _, pending in _pairs(lib.pendingSharedDebuffs) do
|
|
-- Guard against nil entries (can occur if combat log events remove items during iteration)
|
|
if pending then
|
|
local elapsed = currentTime - pending.timestamp
|
|
|
|
if debug then
|
|
local spellName = pending.spellID and C_Spell.GetSpellName(pending.spellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cff00aaff[Shared Check]|r %s elapsed:%.2fs (need 0.2s)", spellName, elapsed)
|
|
)
|
|
end
|
|
|
|
-- Drop stale entries (>2s) without recording immunity - inconclusive due to severe lag
|
|
if elapsed > 2.0 then
|
|
if debug then
|
|
local spellNameDebug = pending.spellID and C_Spell.GetSpellName(pending.spellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffff6600[Shared Stale]|r Dropping stale shared entry for %s on %s (%.1fs old)",
|
|
spellNameDebug, pending.targetName or "Unknown", elapsed)
|
|
)
|
|
end
|
|
-- Don't add to newPendingList - silently discard
|
|
elseif elapsed >= 0.2 then
|
|
local debuffVerified = false
|
|
local totalDebuffs = 0
|
|
|
|
-- SPELL_GO early-exit: Use definitive hit/miss data when available
|
|
if pending.spellGoHit then
|
|
debuffVerified = true
|
|
if debug then
|
|
local spellNameDebug = C_Spell.GetSpellName(pending.spellID) or "Shared Debuff"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cff00ff00[Shared Verified via SPELL_GO]|r %s landed on %s - skipping debuff scan",
|
|
spellNameDebug, pending.targetName or "Unknown")
|
|
)
|
|
end
|
|
end
|
|
|
|
-- SPELL_GO confirmed miss - debuff definitely didn't land
|
|
if pending.spellGoMissed then
|
|
-- Leave debuffVerified=false → falls through to immunity recording
|
|
if debug then
|
|
local spellNameDebug = C_Spell.GetSpellName(pending.spellID) or "Shared Debuff"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffff6600[Shared Miss]|r %s missed on %s - SPELL_GO confirmed miss",
|
|
spellNameDebug, pending.targetName or "Unknown")
|
|
)
|
|
end
|
|
end
|
|
|
|
-- Skip verification if target is dead (debuffs are removed on death)
|
|
-- Guard: skip debuff scanning if SPELL_GO already determined outcome
|
|
local sharedVerifyUnit = not debuffVerified and not pending.spellGoHit and not pending.spellGoMissed
|
|
and pending.targetGUID and ResolveGUIDUnit(pending.targetGUID) or nil
|
|
if sharedVerifyUnit then
|
|
if _IsUnitDead(sharedVerifyUnit) then
|
|
-- Target died - can't verify immunity, assume debuff landed
|
|
debuffVerified = true
|
|
if debug then
|
|
local spellNameDebug = C_Spell.GetSpellName(pending.spellID) or "Shared Debuff"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffff6600[Shared Verify Skip]|r Target %s is dead - skipping immunity check for %s",
|
|
pending.targetName or "Unknown", spellNameDebug)
|
|
)
|
|
end
|
|
else
|
|
-- Target is alive - check if debuff exists
|
|
for slot = 1, 48 do
|
|
local _, _, _, debuffSpellID = _UnitDebuff(sharedVerifyUnit, slot)
|
|
if not debuffSpellID then
|
|
if slot <= 16 then break end -- Regular debuffs are dense, overflow continues on nil
|
|
else
|
|
totalDebuffs = totalDebuffs + 1
|
|
if debuffSpellID == pending.spellID then
|
|
debuffVerified = true
|
|
-- Don't break - continue counting total debuffs for immunity vs cap detection
|
|
end
|
|
end
|
|
end
|
|
end
|
|
elseif not pending.spellGoMissed then
|
|
-- No SuperWoW or no GUID - assume debuff landed (can't verify)
|
|
debuffVerified = true
|
|
end
|
|
|
|
-- Process result
|
|
if debuffVerified then
|
|
-- Debuff landed - add to tracking
|
|
if debug then
|
|
local spellNameDebug = C_Spell.GetSpellName(pending.spellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cff88ff88[Shared Verified]|r %s (ID:%d) verified on %s",
|
|
spellNameDebug, pending.spellID, pending.targetName or "Unknown")
|
|
)
|
|
end
|
|
|
|
lib:AddEffect(pending.targetGUID, pending.targetName, pending.spellID, pending.duration, pending.stacks or 0, "player")
|
|
|
|
-- Remove any existing immunity record for this NPC for this spell's school
|
|
if pending.school and pending.targetName and pending.targetName ~= "" then
|
|
CleveRoids.RemoveSpellImmunity(pending.targetName, pending.school)
|
|
end
|
|
else
|
|
-- Debuff didn't land - check if it's immunity or debuff cap
|
|
local DEBUFF_CAP_THRESHOLD = 47 -- Max is 48 (16 visible + 32 overflow)
|
|
|
|
-- Guard: don't record immunity for player targets
|
|
local sharedQueryUnit = pending.targetGUID and ResolveGUIDUnit(pending.targetGUID) or nil
|
|
local isPlayer = sharedQueryUnit and UnitIsPlayer(sharedQueryUnit)
|
|
local sharedImmunityBuff = sharedQueryUnit and not isPlayer and HasImmunityGrantingBuff(sharedQueryUnit)
|
|
|
|
-- SPLIT CC SPELLS: Skip immunity recording for spells with physical damage + resistable CC
|
|
-- (e.g., Master Strike) - the physical damage lands but CC can be resisted independently
|
|
-- spellGoHit: SPELL_GO confirmed hit, so debuff was resisted independently (not true immunity)
|
|
local isSplitCCSpell = SPLIT_CC_SPELLS[pending.spellID] or SPLIT_CC_SPELLS[pending.castSpellID] or pending.spellGoHit
|
|
if isPlayer then
|
|
if debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffaaaaaa[Shared Skip]|r Player target %s - not recording immunity",
|
|
pending.targetName or "Unknown")
|
|
)
|
|
end
|
|
elseif sharedImmunityBuff then
|
|
if debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffaaaaaa[Shared Skip]|r %s has immunity buff - not recording immunity",
|
|
pending.targetName or "Unknown")
|
|
)
|
|
end
|
|
elseif isSplitCCSpell then
|
|
if debug then
|
|
local spellNameDebug = C_Spell.GetSpellName(pending.castSpellID or pending.spellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cff00aaff[Split CC Skip]|r %s CC resisted on %s - skipping immunity recording (physical damage landed)",
|
|
spellNameDebug, pending.targetName or "Unknown")
|
|
)
|
|
end
|
|
-- Skip immunity recording - damage landed, only CC was resisted
|
|
elseif totalDebuffs < DEBUFF_CAP_THRESHOLD then
|
|
-- Few debuffs = likely immunity — use DBC school if available (more reliable than name-based)
|
|
local recordSchool = pending.school
|
|
if pending.spellID and _GetSpellRecField then
|
|
local dbcSchool = _GetSpellRecField(pending.spellID, "school")
|
|
-- Inline school mapping (SCHOOL_NAMES local is defined later in file, not visible here)
|
|
local dbcSchoolName = dbcSchool == 0 and "physical" or dbcSchool == 1 and "holy"
|
|
or dbcSchool == 2 and "fire" or dbcSchool == 3 and "nature"
|
|
or dbcSchool == 4 and "frost" or dbcSchool == 5 and "shadow"
|
|
or dbcSchool == 6 and "arcane" or nil
|
|
if dbcSchoolName then
|
|
recordSchool = dbcSchoolName
|
|
end
|
|
end
|
|
if pending.targetName and pending.targetName ~= "" and recordSchool then
|
|
-- Record immunity for this spell's school
|
|
if not CleveRoids_ImmunityData[recordSchool] then
|
|
CleveRoids_ImmunityData[recordSchool] = {}
|
|
end
|
|
CleveRoids_ImmunityData[recordSchool][pending.targetName] = true
|
|
|
|
if debug then
|
|
local spellNameDebug = C_Spell.GetSpellName(pending.spellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffff6600[Shared Immunity]|r %s is immune to %s (%s) - only %d debuffs on target",
|
|
pending.targetName, recordSchool, spellNameDebug, totalDebuffs)
|
|
)
|
|
end
|
|
end
|
|
else
|
|
-- Many debuffs = likely pushed off at debuff cap
|
|
if debug then
|
|
local spellNameDebug = C_Spell.GetSpellName(pending.spellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffff6600[Shared Debuff Cap]|r %s not found on %s - likely pushed off (%d debuffs)",
|
|
spellNameDebug, pending.targetName or "Unknown", totalDebuffs)
|
|
)
|
|
end
|
|
end
|
|
end
|
|
-- Item processed, don't add to new list
|
|
else
|
|
-- Item not ready, keep it in the new list
|
|
_table_insert(newPendingList, pending)
|
|
end
|
|
end -- if pending
|
|
end
|
|
|
|
-- Replace old list with compacted new list (no holes)
|
|
lib.pendingSharedDebuffs = newPendingList
|
|
end
|
|
|
|
-- Clean up old rank refresh overrides (older than 2 seconds)
|
|
if hasOverrides then
|
|
for spellName, override in pairs(lib.rankRefreshOverrides) do
|
|
if currentTime - override.timestamp > 2.0 then
|
|
lib.rankRefreshOverrides[spellName] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
-- CleanupStaleTrackingData moved to UnitXP timer (30s interval) when available,
|
|
-- falls back to inline call here when UnitXP is not present
|
|
if not CleveRoids.hasUnitXP then
|
|
lib:CleanupStaleTrackingData()
|
|
end
|
|
end)
|
|
|
|
-- Periodic libdebuff cleanup via ClassicAPI's C_Timer (every 30s).
|
|
CleveRoids._cleanupTicker = C_Timer.NewTicker(30, function()
|
|
if CleveRoids.isShuttingDown then return end
|
|
local libRef = CleveRoids.libdebuff
|
|
if libRef and libRef.CleanupStaleTrackingData then
|
|
libRef:CleanupStaleTrackingData()
|
|
end
|
|
end)
|
|
|
|
local ev = CreateFrame("Frame", "CleveRoidsLibDebuffFrame", UIParent)
|
|
ev:RegisterEvent("PLAYER_TARGET_CHANGED")
|
|
ev:RegisterUnitEvent("UNIT_AURA", "target")
|
|
ev:RegisterEvent("ADDON_LOADED") -- For pfUI integration initialization
|
|
ev:RegisterEvent("ZONE_CHANGED_NEW_AREA") -- Clear known enemy GUIDs on zone change
|
|
|
|
if CleveRoids.hasSuperwow then
|
|
ev:RegisterEvent("UNIT_CASTEVENT")
|
|
end
|
|
|
|
-- Register Nampower v2.26+ events for standalone mode (when pfUI is not available or outdated)
|
|
-- These provide accurate hit/miss detection and debuff application tracking
|
|
if CleveRoids.hasNampower then
|
|
local npMajor, npMinor = GetNampowerVersion()
|
|
if npMajor > 2 or (npMajor == 2 and npMinor >= 26) then
|
|
ev:RegisterEvent("SPELL_GO_SELF")
|
|
ev:RegisterEvent("SPELL_GO_OTHER")
|
|
ev:RegisterEvent("AURA_CAST_ON_SELF")
|
|
ev:RegisterEvent("AURA_CAST_ON_OTHER")
|
|
ev:RegisterEvent("DEBUFF_ADDED_OTHER")
|
|
ev:RegisterEvent("DEBUFF_REMOVED_OTHER")
|
|
ev:RegisterEvent("UNIT_DIED") -- Instant cleanup on target death
|
|
|
|
-- BUFF_ADDED/REMOVED events require v2.30+ for auraSlot and state args
|
|
if npMajor > 2 or (npMajor == 2 and npMinor >= 30) then
|
|
ev:RegisterEvent("BUFF_REMOVED_SELF")
|
|
ev:RegisterEvent("BUFF_REMOVED_OTHER")
|
|
end
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[Nampower]|r Registered UNIT_DIED for instant cleanup (v2.26+)")
|
|
end
|
|
end
|
|
|
|
-- Register SPELL_START/SPELL_FAILED events for cast tracking (v2.25+)
|
|
-- These events let us know when ANY unit starts or fails a cast-time spell
|
|
-- pfUI 7.6 handles these itself, but we register anyway for standalone mode
|
|
if npMajor > 2 or (npMajor == 2 and npMinor >= 25) then
|
|
ev:RegisterEvent("SPELL_START_SELF")
|
|
ev:RegisterEvent("SPELL_START_OTHER")
|
|
ev:RegisterEvent("SPELL_FAILED_OTHER")
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[Nampower]|r Registered SPELL_START/SPELL_FAILED for cast tracking (v2.25+)")
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Track if UNIT_DIED is available for cleanup optimization
|
|
lib.hasUnitDiedEvent = CleveRoids.hasNampower and GetNampowerVersion and (function()
|
|
local npMajor, npMinor = GetNampowerVersion()
|
|
return npMajor > 2 or (npMajor == 2 and npMinor >= 26)
|
|
end)() or false
|
|
|
|
ev:SetScript("OnEvent", function()
|
|
-- Initialize pfUI integration when pfUI loads
|
|
if event == "ADDON_LOADED" and arg1 == "pfUI" then
|
|
-- Defer initialization slightly to ensure pfUI.version is populated
|
|
local initFrame = CreateFrame("Frame")
|
|
local initTimer = 0
|
|
initFrame:SetScript("OnUpdate", function()
|
|
initTimer = initTimer + arg1
|
|
if initTimer >= 0.5 then
|
|
lib:InitPfUIIntegration()
|
|
this:SetScript("OnUpdate", nil)
|
|
end
|
|
end)
|
|
return
|
|
end
|
|
|
|
if event == "ZONE_CHANGED_NEW_AREA" then
|
|
-- Wipe known enemy GUIDs — all previous units are invalid in the new zone
|
|
if CleveRoids.knownEnemyGuids then
|
|
for k in pairs(CleveRoids.knownEnemyGuids) do
|
|
CleveRoids.knownEnemyGuids[k] = nil
|
|
end
|
|
end
|
|
-- Wipe GUID-to-name cache — GUIDs can be reused across zones
|
|
if lib.guidToName then
|
|
for k in pairs(lib.guidToName) do
|
|
lib.guidToName[k] = nil
|
|
end
|
|
end
|
|
-- Wipe DR tracking — DR state is invalid in a new zone
|
|
if lib.recentCCHits then
|
|
for k in pairs(lib.recentCCHits) do
|
|
lib.recentCCHits[k] = nil
|
|
end
|
|
end
|
|
return
|
|
|
|
elseif event == "PLAYER_TARGET_CHANGED" then
|
|
-- Seed known enemy GUIDs from target changes
|
|
if UnitExists("target") and UnitCanAttack("player", "target") and CleveRoids.knownEnemyGuids then
|
|
local guid = CleveRoids.GetGUID("target")
|
|
if guid then
|
|
CleveRoids.knownEnemyGuids[guid] = true
|
|
end
|
|
end
|
|
SeedUnit("target")
|
|
|
|
elseif event == "UNIT_AURA" then
|
|
SeedUnit("target")
|
|
|
|
elseif event == "UNIT_CASTEVENT" then
|
|
local casterGUID = arg1
|
|
local targetGUID = arg2
|
|
local eventType = arg3
|
|
local spellID = arg4
|
|
|
|
-- Normalize targetGUID to string for consistent table key lookups
|
|
targetGUID = CleveRoids.NormalizeGUID(targetGUID)
|
|
|
|
-- Capture combo points when cast STARTS (before they're consumed)
|
|
if (eventType == "START" or eventType == "CHANNEL") and spellID then
|
|
local playerGUID = CleveRoids.GetGUID("player")
|
|
if casterGUID == playerGUID and targetGUID then
|
|
-- If this is a combo scaling spell OR Ferocious Bite, capture combo points NOW (before consumption)
|
|
local isComboSpell = CleveRoids.IsComboScalingSpellID and CleveRoids.IsComboScalingSpellID(spellID)
|
|
local isFerociousBite = CleveRoids.FerociousBiteSpellIDs and CleveRoids.FerociousBiteSpellIDs[spellID]
|
|
|
|
if isComboSpell or isFerociousBite then
|
|
local currentCP = CleveRoids.GetComboPoints()
|
|
if currentCP and currentCP > 0 then
|
|
CleveRoids.lastComboPoints = currentCP
|
|
if CleveRoids.debug then
|
|
local spellName = C_Spell.GetSpellName(spellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffaaaaff[UNIT_CASTEVENT START]|r Captured %d CP before casting %s (ID:%d)",
|
|
currentCP, spellName, spellID)
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- WARLOCK DARK HARVEST: Track channeling for DoT acceleration (TWoW Custom)
|
|
-- Credits: Avitasia / Cursive addon
|
|
-- Dark Harvest accelerates DoT tick rate by 30% while channeling
|
|
if eventType == "CHANNEL" and CleveRoids.DarkHarvestSpellIDs and CleveRoids.DarkHarvestSpellIDs[spellID] then
|
|
-- Get channel duration from tooltip or use base duration (8 seconds)
|
|
local channelDuration = 8 -- Base Dark Harvest duration
|
|
|
|
CleveRoids.darkHarvestData = {
|
|
targetGUID = targetGUID,
|
|
spellID = spellID,
|
|
startTime = GetTime(),
|
|
channelDuration = channelDuration,
|
|
isActive = true
|
|
}
|
|
|
|
-- Apply Dark Harvest acceleration to all existing DoTs on target
|
|
lib.ApplyDarkHarvestStart(targetGUID)
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff9482c9[Dark Harvest]|r Started channeling on %s (DoTs will tick 30%% faster)",
|
|
lib.guidToName[targetGUID] or "Unknown")
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if eventType == "CAST" and spellID then
|
|
local playerGUID = CleveRoids.GetGUID("player")
|
|
if casterGUID == playerGUID and targetGUID then
|
|
|
|
-- DRUID CARNAGE TALENT: Track Ferocious Bite cast for proc detection
|
|
-- Carnage proc is detected in ComboPointTracker via PLAYER_COMBO_POINTS event
|
|
-- When Carnage procs, combo points don't decrease (or increase by 1)
|
|
if CleveRoids.FerociousBiteSpellIDs and CleveRoids.FerociousBiteSpellIDs[spellID] then
|
|
-- Check if player has Carnage talent (any rank)
|
|
-- Carnage: Tab 2 (Feral Combat), Talent 17
|
|
local _, _, _, _, rank = GetTalentInfo(2, 17)
|
|
local carnageRank = tonumber(rank) or 0
|
|
|
|
if carnageRank >= 1 then
|
|
local targetName = lib.guidToName[targetGUID]
|
|
if not targetName then
|
|
local currentTargetGUID = CleveRoids.GetGUID("target")
|
|
if currentTargetGUID == targetGUID then
|
|
targetName = UnitName("target")
|
|
lib.guidToName[targetGUID] = targetName
|
|
else
|
|
targetName = "Unknown"
|
|
end
|
|
end
|
|
|
|
-- Track Ferocious Bite cast for Carnage proc detection (via PLAYER_COMBO_POINTS)
|
|
-- Unlike the old system, we don't schedule a refresh here - we wait for the proc
|
|
CleveRoids.lastFerociousBiteTime = GetTime()
|
|
CleveRoids.lastFerociousBiteTargetGUID = targetGUID
|
|
CleveRoids.lastFerociousBiteTargetName = targetName
|
|
CleveRoids.lastFerociousBiteSpellID = spellID
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff00ff[Carnage]|r Tracking Ferocious Bite on %s (waiting for proc detection)",
|
|
targetName or "Unknown")
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- SHAMAN MOLTEN BLAST: Track for Flame Shock refresh detection (TWoW Custom)
|
|
-- Credits: Avitasia / Cursive addon
|
|
if CleveRoids.MoltenBlastSpellIDs and CleveRoids.MoltenBlastSpellIDs[spellID] then
|
|
CleveRoids.lastMoltenBlastTime = GetTime()
|
|
CleveRoids.lastMoltenBlastTargetGUID = targetGUID
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff0070dd[Molten Blast]|r Tracking cast on %s (waiting for hit confirmation)",
|
|
lib.guidToName[targetGUID] or "Unknown")
|
|
)
|
|
end
|
|
end
|
|
|
|
-- WARLOCK CONFLAGRATE: Reduces Immolate duration by 3 seconds
|
|
-- Credits: Avitasia / Cursive addon
|
|
if CleveRoids.ConflagrateSpellIDs and CleveRoids.ConflagrateSpellIDs[spellID] then
|
|
-- Find active Immolate on target and reduce its duration
|
|
if lib.objects[targetGUID] then
|
|
for immolateID, _ in pairs(CleveRoids.ImmolateSpellIDs or {}) do
|
|
local rec = lib.objects[targetGUID][immolateID]
|
|
if rec and rec.duration and rec.start then
|
|
local remaining = rec.duration + rec.start - GetTime()
|
|
if remaining > 0 then
|
|
-- Reduce duration by 3 seconds
|
|
rec.duration = rec.duration - 3
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff9482c9[Conflagrate]|r Reduced Immolate duration by 3s (now %.1fs remaining)",
|
|
rec.duration + rec.start - GetTime())
|
|
)
|
|
end
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- POUNCE: Convert cast spell ID to triggered Pounce Bleed spell ID
|
|
-- Pounce (cast) triggers a separate Pounce Bleed spell with a different ID
|
|
-- We track the bleed, not the stun, for immunity detection
|
|
local trackingSpellID = spellID
|
|
if CleveRoids.PounceToBleedMapping and CleveRoids.PounceToBleedMapping[spellID] then
|
|
trackingSpellID = CleveRoids.PounceToBleedMapping[spellID]
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00aaff[Pounce→Bleed]|r Converted cast ID %d to bleed ID %d", spellID, trackingSpellID)
|
|
)
|
|
end
|
|
end
|
|
|
|
-- CC IMMUNITY TRACKING: Check if this spell is a CC spell and track for immunity verification
|
|
-- Uses the original spellID (not trackingSpellID) to detect CC type
|
|
local ccType = CleveRoids.GetSpellCCType and CleveRoids.GetSpellCCType(spellID)
|
|
|
|
-- Debug: Show what GetSpellCCType returns for this spell
|
|
if CleveRoids.debug then
|
|
local spellNameDebug = C_Spell.GetSpellName(spellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffaaaaaa[CC Check]|r %s (ID:%d) → ccType: %s",
|
|
spellNameDebug, spellID, ccType or "nil")
|
|
)
|
|
end
|
|
|
|
if ccType then
|
|
-- Track ALL CC spells for immunity verification, including hidden CC (e.g., Pounce stun)
|
|
-- Hidden CC spells don't show visible debuffs, but:
|
|
-- 1. They may still be detectable via mechanic-based ValidateUnitCC check
|
|
-- 2. Combat log "afflicted by" messages confirm successful CC
|
|
-- 3. If neither detection method finds the CC, we record immunity
|
|
local isHiddenCC = lib.hiddenCCSpells and lib.hiddenCCSpells[spellID]
|
|
-- v3.0+: Dynamically detect hidden CC via IsAuraHidden
|
|
if not isHiddenCC and _G.IsAuraHidden then
|
|
isHiddenCC = (_G.IsAuraHidden(spellID) == 1)
|
|
end
|
|
local spellName = C_Spell.GetSpellName(spellID)
|
|
-- Get target name from cache or current target
|
|
local ccTargetName = lib.guidToName[targetGUID]
|
|
if not ccTargetName then
|
|
local currentTargetGUID = CleveRoids.GetGUID("target")
|
|
if currentTargetGUID == targetGUID then
|
|
ccTargetName = UnitName("target")
|
|
lib.guidToName[targetGUID] = ccTargetName
|
|
end
|
|
end
|
|
table.insert(lib.pendingCCDebuffs, {
|
|
timestamp = GetTime(),
|
|
targetGUID = targetGUID,
|
|
targetName = ccTargetName,
|
|
spellID = spellID,
|
|
spellName = spellName,
|
|
ccType = ccType,
|
|
isHiddenCC = isHiddenCC, -- Flag for hidden CC spells
|
|
})
|
|
|
|
if CleveRoids.debug then
|
|
local hiddenStr = isHiddenCC and " (hidden CC)" or ""
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00ff00[CC Track]|r Tracking %s (%s) on %s for immunity verification%s",
|
|
spellName or "Unknown", ccType, ccTargetName or "Unknown", hiddenStr)
|
|
)
|
|
end
|
|
end
|
|
|
|
-- Check if this is a combo point scaling spell first
|
|
local duration = nil
|
|
local comboPoints = nil
|
|
if CleveRoids.TrackComboPointCastByID then
|
|
duration = CleveRoids.TrackComboPointCastByID(trackingSpellID, targetGUID)
|
|
-- Get combo points used from tracking
|
|
if CleveRoids.ComboPointTracking and CleveRoids.ComboPointTracking.byID and
|
|
CleveRoids.ComboPointTracking.byID[trackingSpellID] then
|
|
comboPoints = CleveRoids.ComboPointTracking.byID[trackingSpellID].combo_points
|
|
end
|
|
end
|
|
|
|
-- If not a combo scaling spell, use normal duration lookup
|
|
if not duration then
|
|
duration = lib:GetDuration(trackingSpellID, casterGUID)
|
|
|
|
-- Apply all duration modifiers (Nampower, talents, equipment, set bonuses)
|
|
-- (combo spells already have modifiers applied in CalculateComboScaledDurationByID)
|
|
if duration and CleveRoids.ApplyAllDurationModifiers then
|
|
duration = CleveRoids.ApplyAllDurationModifiers(trackingSpellID, duration)
|
|
end
|
|
end
|
|
|
|
-- DEBUG: Show what duration we calculated
|
|
if CleveRoids.debug and duration then
|
|
local spellName = C_Spell.GetSpellName(trackingSpellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff00ff[DEBUG CAST]|r %s (ID:%d) CP:%s duration:%ds",
|
|
spellName, trackingSpellID, tostring(comboPoints or "nil"), duration)
|
|
)
|
|
end
|
|
|
|
if duration and duration > 0 then
|
|
local targetName = lib.guidToName[targetGUID]
|
|
if not targetName then
|
|
local currentTargetGUID = CleveRoids.GetGUID("target")
|
|
if currentTargetGUID == targetGUID then
|
|
targetName = UnitName("target")
|
|
lib.guidToName[targetGUID] = targetName
|
|
end
|
|
-- Try SuperWoW GUID-based name lookup if still not resolved
|
|
if not targetName and CleveRoids.hasSuperwow then
|
|
targetName = UnitName(targetGUID)
|
|
if targetName and targetName ~= "Unknown" then
|
|
lib.guidToName[targetGUID] = targetName
|
|
end
|
|
end
|
|
-- Final fallback
|
|
if not targetName then
|
|
targetName = "Unknown"
|
|
end
|
|
end
|
|
|
|
-- For combo spells, populate name-based tracking for pfUI compatibility
|
|
if comboPoints and comboPoints > 0 then
|
|
local spellName = C_Spell.GetSpellName(spellID)
|
|
if spellName and CleveRoids.ComboPointTracking then
|
|
-- Remove rank from spell name to match pfUI's format
|
|
local baseName = CleveRoids.StripRank(spellName)
|
|
CleveRoids.ComboPointTracking[baseName] = {
|
|
combo_points = comboPoints,
|
|
duration = duration,
|
|
cast_time = GetTime(),
|
|
target = targetName,
|
|
confirmed = true -- This is from actual UNIT_CASTEVENT, always confirmed
|
|
}
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00ffff[Name-based tracking]|r Set %s: %d CP, %ds duration (for pfUI)",
|
|
baseName, comboPoints, duration)
|
|
)
|
|
end
|
|
|
|
-- Update pfUI's duration database directly (pre-7.6 only)
|
|
-- pfUI 7.6+ handles combo durations internally via GetStoredComboPoints()
|
|
if pfUI and pfUI.api and pfUI.api.libdebuff and pfUI.api.libdebuff.debuffs then
|
|
pfUI.api.libdebuff.debuffs[baseName] = duration
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff00ff[pfUI Duration Inject]|r Set pfUI.api.libdebuff.debuffs['%s'] = %ds",
|
|
baseName, duration)
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Check if this is a personal debuff - if so, delay tracking to verify it lands
|
|
local isPersonal = lib:IsPersonalDebuff(trackingSpellID)
|
|
|
|
if isPersonal then
|
|
-- Check if we should apply based on rank comparison
|
|
local rankCheck = lib:ShouldApplyDebuffRank(targetGUID, trackingSpellID)
|
|
|
|
if rankCheck == true then
|
|
-- Normal application - schedule personal debuff for delayed tracking
|
|
table.insert(lib.pendingPersonalDebuffs, {
|
|
timestamp = GetTime(),
|
|
targetGUID = targetGUID,
|
|
targetName = targetName,
|
|
spellID = trackingSpellID, -- Use tracking spell ID (e.g., Pounce Bleed, not Pounce)
|
|
castSpellID = spellID, -- Original cast spell ID (for dodge/parry matching)
|
|
duration = duration,
|
|
comboPoints = comboPoints
|
|
})
|
|
|
|
-- If this is a Judgement spell cast by a Paladin, schedule a scan to find the actual debuff ID
|
|
-- Check by ID (known judgement debuffs) OR by name (Turtle WoW custom cast spell IDs)
|
|
local isJudgementCast = lib.judgementSpells[spellID]
|
|
if not isJudgementCast and CleveRoids.playerClass == "PALADIN" then
|
|
local castName = C_Spell.GetSpellName(spellID)
|
|
isJudgementCast = castName and _string_find(castName, "^Judgement")
|
|
end
|
|
if CleveRoids.playerClass == "PALADIN" and isJudgementCast then
|
|
table.insert(lib.pendingJudgements, {
|
|
timestamp = GetTime(),
|
|
castSpellID = spellID,
|
|
targetGUID = targetGUID,
|
|
targetName = targetName
|
|
})
|
|
end
|
|
|
|
if CleveRoids.debug then
|
|
local spellName = C_Spell.GetSpellName(trackingSpellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffaaff00[Pending Track]|r Scheduled %s (ID:%d) for tracking on %s (will apply if hit)",
|
|
spellName, trackingSpellID, targetName or "Unknown")
|
|
)
|
|
end
|
|
elseif type(rankCheck) == "table" and rankCheck.preserve then
|
|
-- Preserve higher rank's timer - schedule with higher rank's spell ID and remaining time
|
|
table.insert(lib.pendingPersonalDebuffs, {
|
|
timestamp = GetTime(),
|
|
targetGUID = targetGUID,
|
|
targetName = targetName,
|
|
spellID = rankCheck.preserve, -- Use higher rank's spell ID
|
|
duration = rankCheck.timeRemaining, -- Use remaining time, not full duration
|
|
comboPoints = comboPoints,
|
|
isRankPreserve = true -- Flag this as a rank preserve
|
|
})
|
|
|
|
if CleveRoids.debug then
|
|
local spellName = C_Spell.GetSpellName(rankCheck.preserve) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffaaff00[Pending Track]|r Scheduled rank preserve for %s (ID:%d, %.1fs remaining) on %s",
|
|
spellName, rankCheck.preserve, rankCheck.timeRemaining, targetName or "Unknown")
|
|
)
|
|
end
|
|
end
|
|
-- If rankCheck == false, skip entirely (shouldn't happen for personal debuffs with new logic)
|
|
else
|
|
-- Shared debuff - schedule for delayed verification (immunity detection)
|
|
-- For stacking debuffs (Sunder, Faerie Fire), predict new stack count
|
|
local newStacks = 0
|
|
|
|
-- Check current stacks on target
|
|
local currentTargetGUID = CleveRoids.GetGUID("target")
|
|
if currentTargetGUID == targetGUID then
|
|
-- Scan debuff slots to find current stacks
|
|
for i = 1, 16 do
|
|
local _, existingStacks, _, existingSpellID = UnitDebuff("target", i)
|
|
if existingSpellID == spellID then
|
|
newStacks = (existingStacks or 0) + 1
|
|
break
|
|
end
|
|
end
|
|
|
|
-- If not found in debuff slots, check buff slots (overflow)
|
|
if newStacks == 0 then
|
|
for i = 1, 32 do
|
|
local _, existingStacks, existingSpellID = UnitBuff("target", i)
|
|
if existingSpellID == spellID then
|
|
newStacks = (existingStacks or 0) + 1
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
-- If still not found, this is the first stack
|
|
if newStacks == 0 then
|
|
newStacks = 1
|
|
end
|
|
|
|
-- Cap at maximum stacks (5 for Sunder Armor, most debuffs)
|
|
-- TODO: Make this configurable per spell if needed
|
|
if newStacks > 5 then
|
|
newStacks = 5
|
|
end
|
|
else
|
|
-- Target changed or not available, default to 1 stack
|
|
newStacks = 1
|
|
end
|
|
|
|
-- Determine spell school for immunity tracking
|
|
-- GetSpellSchool is defined later in file, use CleveRoids wrapper if available
|
|
local spellSchool = nil
|
|
local spellNameForSchool = C_Spell.GetSpellName(spellID)
|
|
if CleveRoids.GetSpellSchool then
|
|
spellSchool = CleveRoids.GetSpellSchool(spellNameForSchool, spellID)
|
|
end
|
|
|
|
-- Check if we should apply based on rank comparison
|
|
local rankCheck = lib:ShouldApplyDebuffRank(targetGUID, spellID)
|
|
|
|
local trackingSpellID = spellID
|
|
local trackingDuration = duration
|
|
if type(rankCheck) == "table" and rankCheck.preserve then
|
|
-- Preserve higher rank's timer
|
|
trackingSpellID = rankCheck.preserve
|
|
trackingDuration = rankCheck.timeRemaining
|
|
elseif rankCheck == false then
|
|
-- Skip entirely
|
|
trackingSpellID = nil
|
|
end
|
|
|
|
if trackingSpellID then
|
|
-- REFRESH DETECTION: If newStacks > 1, debuff already existed on target
|
|
-- Skip pending queue for refreshes - directly reset the timer
|
|
-- The pending queue is only for NEW applications where immunity detection matters
|
|
local isRefresh = (newStacks > 1)
|
|
|
|
if isRefresh then
|
|
-- Direct timer reset for refreshes (debuff already verified to exist)
|
|
lib:AddEffect(targetGUID, targetName, trackingSpellID, trackingDuration, newStacks, "player")
|
|
|
|
if CleveRoids.debug then
|
|
local spellName = C_Spell.GetSpellName(trackingSpellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00ff00[Shared Refresh]|r %s (ID:%d) on %s - timer reset to %ds (stacks:%d)",
|
|
spellName, trackingSpellID, targetName or "Unknown", trackingDuration, newStacks)
|
|
)
|
|
end
|
|
else
|
|
-- NEW APPLICATION: Schedule for delayed verification (immunity detection)
|
|
table.insert(lib.pendingSharedDebuffs, {
|
|
timestamp = GetTime(),
|
|
targetGUID = targetGUID,
|
|
targetName = targetName,
|
|
spellID = trackingSpellID,
|
|
castSpellID = spellID, -- Original cast spell ID (for dodge/parry matching)
|
|
duration = trackingDuration,
|
|
stacks = newStacks,
|
|
school = spellSchool,
|
|
isRankPreserve = (trackingSpellID ~= spellID)
|
|
})
|
|
|
|
if CleveRoids.debug then
|
|
local spellName = C_Spell.GetSpellName(trackingSpellID) or "Unknown"
|
|
local queueLen = table.getn(lib.pendingSharedDebuffs)
|
|
local firstItem = lib.pendingSharedDebuffs[1]
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffaaff00[Pending Shared]|r Scheduled %s (ID:%d, school:%s) for tracking on %s [queueLen:%d, [1]:%s]",
|
|
spellName, trackingSpellID, spellSchool or "unknown", targetName or "Unknown",
|
|
queueLen, firstItem and "exists" or "NIL")
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Track this cast for miss/dodge/parry removal
|
|
lib.lastPlayerCast = {
|
|
spellID = spellID,
|
|
targetGUID = targetGUID,
|
|
timestamp = GetTime()
|
|
}
|
|
|
|
-- DRUID CARNAGE TALENT: Save Rip cast duration for later refresh by Ferocious Bite
|
|
if CleveRoids.RipSpellIDs and CleveRoids.RipSpellIDs[spellID] then
|
|
-- Clear any stale Carnage duration override for this spell
|
|
-- New cast should use its own duration, not old Carnage refresh duration
|
|
if CleveRoids.carnageDurationOverrides and CleveRoids.carnageDurationOverrides[spellID] then
|
|
CleveRoids.carnageDurationOverrides[spellID] = nil
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff888888[Carnage]|r Cleared stale Rip override (new cast replaces)")
|
|
)
|
|
end
|
|
end
|
|
|
|
if CleveRoids.lastRipCast then
|
|
CleveRoids.lastRipCast.spellID = spellID
|
|
CleveRoids.lastRipCast.duration = duration
|
|
CleveRoids.lastRipCast.targetGUID = targetGUID
|
|
CleveRoids.lastRipCast.comboPoints = comboPoints or 0
|
|
CleveRoids.lastRipCast.timestamp = GetTime()
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00ff00[Carnage]|r Saved Rip cast: %ds duration (%d CP) on target %s",
|
|
duration, comboPoints or 0, targetName or "Unknown")
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- NOTE: Bleed immunity detection is now handled in the delayed pending debuff
|
|
-- processing system (see pendingPersonalDebuffs OnUpdate handler above).
|
|
-- This prevents false positives from checking UnitDebuff() immediately after
|
|
-- UNIT_CASTEVENT, before the server has synced the debuff to the client.
|
|
|
|
-- CARNAGE TALENT: Save Rake cast data for potential Ferocious Bite refresh
|
|
-- Verification that the bleed landed happens in the delayed tracking system
|
|
local isRakeSpell = CleveRoids.RakeSpellIDs and CleveRoids.RakeSpellIDs[spellID]
|
|
if isRakeSpell then
|
|
-- Clear any stale Carnage duration override for this spell
|
|
-- New cast should use its own duration, not old Carnage refresh duration
|
|
if CleveRoids.carnageDurationOverrides and CleveRoids.carnageDurationOverrides[spellID] then
|
|
CleveRoids.carnageDurationOverrides[spellID] = nil
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff888888[Carnage]|r Cleared stale Rake override (new cast replaces)")
|
|
)
|
|
end
|
|
end
|
|
|
|
if CleveRoids.lastRakeCast then
|
|
CleveRoids.lastRakeCast.spellID = spellID
|
|
CleveRoids.lastRakeCast.duration = duration
|
|
CleveRoids.lastRakeCast.targetGUID = targetGUID
|
|
CleveRoids.lastRakeCast.comboPoints = comboPoints or 0
|
|
CleveRoids.lastRakeCast.timestamp = GetTime()
|
|
CleveRoids.lastRakeCast.pending = true -- Mark as pending verification
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00ff00[Carnage]|r Saved Rake cast (pending verification): %ds duration (%d CP) on target %s",
|
|
duration, comboPoints or 0, targetName or "Unknown")
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ALWAYS set up learning for combo spells (even if we have calculated duration)
|
|
if comboPoints then
|
|
lib.learnCastTimers[targetGUID] = lib.learnCastTimers[targetGUID] or {}
|
|
lib.learnCastTimers[targetGUID][spellID] = {
|
|
start = GetTime(),
|
|
caster = casterGUID,
|
|
comboPoints = comboPoints -- Store CP count for learning
|
|
}
|
|
end
|
|
|
|
else
|
|
lib.learnCastTimers[targetGUID] = lib.learnCastTimers[targetGUID] or {}
|
|
lib.learnCastTimers[targetGUID][spellID] = {
|
|
start = GetTime(),
|
|
caster = casterGUID
|
|
}
|
|
|
|
lib.objects[targetGUID] = lib.objects[targetGUID] or {}
|
|
lib.objects[targetGUID][spellID] = {
|
|
spellID = spellID,
|
|
start = GetTime(),
|
|
duration = 999, -- Placeholder
|
|
stacks = 0,
|
|
caster = casterGUID
|
|
}
|
|
end
|
|
end
|
|
|
|
-- SHARED DEBUFFS FROM OTHER PLAYERS: Track when other players cast shared debuffs
|
|
-- This ensures Sunder Armor, Faerie Fire, etc. are tracked when ANY player casts them
|
|
-- Personal debuffs are still only tracked from the player (we only care about our own)
|
|
local playerGUID = CleveRoids.GetGUID("player")
|
|
if casterGUID ~= playerGUID and targetGUID then
|
|
-- Check if this is a shared debuff we should track
|
|
if lib.sharedDebuffs[spellID] then
|
|
local duration = lib:GetDuration(spellID)
|
|
if duration and duration > 0 then
|
|
-- Get target name from cache or current target
|
|
local targetName = lib.guidToName[targetGUID]
|
|
if not targetName then
|
|
local currentTargetGUID = CleveRoids.GetGUID("target")
|
|
if currentTargetGUID == targetGUID then
|
|
targetName = UnitName("target")
|
|
lib.guidToName[targetGUID] = targetName
|
|
end
|
|
end
|
|
|
|
-- For stacking debuffs, predict new stack count
|
|
local newStacks = 1
|
|
local currentTargetGUID = CleveRoids.GetGUID("target")
|
|
if currentTargetGUID == targetGUID then
|
|
-- Scan debuff slots to find current stacks
|
|
for i = 1, 16 do
|
|
local _, existingStacks, _, existingSpellID = UnitDebuff("target", i)
|
|
if existingSpellID == spellID then
|
|
newStacks = (existingStacks or 0) + 1
|
|
break
|
|
end
|
|
end
|
|
|
|
-- If not found in debuff slots, check buff slots (overflow)
|
|
if newStacks == 1 then
|
|
for i = 1, 32 do
|
|
local _, existingStacks, existingSpellID = UnitBuff("target", i)
|
|
if existingSpellID == spellID then
|
|
newStacks = (existingStacks or 0) + 1
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Cap at maximum stacks
|
|
if newStacks > 5 then
|
|
newStacks = 5
|
|
end
|
|
end
|
|
|
|
-- Directly add the effect (other players' casts don't need immunity verification)
|
|
-- Reset timer to full duration since the debuff was just refreshed
|
|
lib:AddEffect(targetGUID, targetName, spellID, duration, newStacks, casterGUID)
|
|
|
|
if CleveRoids.debug then
|
|
local spellName = C_Spell.GetSpellName(spellID) or "Unknown"
|
|
local casterName = lib.guidToName[casterGUID] or "Other Player"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00aaff[Other Cast]|r %s cast %s (ID:%d) on %s - timer reset to %ds",
|
|
casterName, spellName, spellID, targetName or "Unknown", duration)
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- NAMPOWER v2.25+ CAST TRACKING HANDLERS (Standalone or pfUI < 7.6)
|
|
-- Populates CleveRoids.castTracking for [casting] conditional support.
|
|
-- When pfUI 7.6+ is active, castTracking points to pfUI.libdebuff_casts
|
|
-- and these handlers return early (pfUI handles it).
|
|
|
|
elseif event == "SPELL_START_SELF" or event == "SPELL_START_OTHER" then
|
|
-- v2.38+: For player's own channeling spells, pre-store the accurate server channel duration
|
|
-- BEFORE the pfUI early-return so SPELLCAST_CHANNEL_START (which fires later) can use it.
|
|
-- arg8 == 1 means spellType=Channeling; arg7 is the channel duration in ms.
|
|
if event == "SPELL_START_SELF" and arg8 == 1 and arg7 and arg7 > 0 then
|
|
CleveRoids._v238ChannelDuration = arg7 / 1000
|
|
CleveRoids._v238ChannelSpellId = arg2
|
|
end
|
|
|
|
-- Nampower fallback for UNIT_CASTEVENT START/CHANNEL: capture combo points and Dark Harvest
|
|
-- Only needed when SuperWoW is not available (UNIT_CASTEVENT won't fire)
|
|
if event == "SPELL_START_SELF" and not CleveRoids.hasSuperwow then
|
|
local spellID = arg2
|
|
local casterGuid = arg3
|
|
local targetGuid = arg4
|
|
local playerGUID = CleveRoids.GetGUID("player")
|
|
if casterGuid == playerGUID and spellID and targetGuid then
|
|
targetGuid = CleveRoids.NormalizeGUID(targetGuid)
|
|
|
|
-- Capture combo points before consumption (equivalent to UNIT_CASTEVENT START)
|
|
local isComboSpell = CleveRoids.IsComboScalingSpellID and CleveRoids.IsComboScalingSpellID(spellID)
|
|
local isFerociousBite = CleveRoids.FerociousBiteSpellIDs and CleveRoids.FerociousBiteSpellIDs[spellID]
|
|
|
|
if isComboSpell or isFerociousBite then
|
|
local currentCP = CleveRoids.GetComboPoints and CleveRoids.GetComboPoints()
|
|
if currentCP and currentCP > 0 then
|
|
CleveRoids.lastComboPoints = currentCP
|
|
if CleveRoids.debug then
|
|
local spellName = C_Spell.GetSpellName(spellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cffaaaaff[SPELL_START_SELF]|r Captured %d CP before casting %s (ID:%d)",
|
|
currentCP, spellName, spellID)
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- WARLOCK DARK HARVEST: Track channeling for DoT acceleration (TWoW Custom)
|
|
local spellType = arg8
|
|
if spellType == 1 and CleveRoids.DarkHarvestSpellIDs and CleveRoids.DarkHarvestSpellIDs[spellID] then
|
|
local channelDuration = 8 -- Base Dark Harvest duration
|
|
CleveRoids.darkHarvestData = {
|
|
targetGUID = targetGuid,
|
|
spellID = spellID,
|
|
startTime = GetTime(),
|
|
channelDuration = channelDuration,
|
|
isActive = true
|
|
}
|
|
lib.ApplyDarkHarvestStart(targetGuid)
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
_string_format("|cff9482c9[Dark Harvest]|r Started channeling on %s (DoTs will tick 30%% faster)",
|
|
lib.guidToName[targetGuid] or "Unknown")
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- pfUI 7.6 manages castTracking via its own SPELL_START handler
|
|
|
|
local spellId = arg2
|
|
local casterGuid = arg3
|
|
local castTime = arg6 -- cast time in milliseconds
|
|
-- v2.38+: arg7 = channel duration (ms, channeling spells only), arg8 = spellType (0=Normal,1=Channeling,2=Autorepeating)
|
|
local channelDuration = arg7
|
|
local spellType = arg8
|
|
|
|
if not casterGuid or not spellId then return end
|
|
|
|
local spellName = C_Spell.GetSpellName(spellId)
|
|
local icon = lib:GetCachedIcon(spellId)
|
|
local now = GetTime()
|
|
|
|
-- For channeling spells (v2.38+: spellType==1), use the channel duration.
|
|
-- For normal cast-time spells, use castTime. Fall back to castTime if params unavailable.
|
|
local durationMs
|
|
if spellType == 1 and channelDuration and channelDuration > 0 then
|
|
durationMs = channelDuration
|
|
else
|
|
durationMs = castTime or 0
|
|
end
|
|
local durationSec = durationMs / 1000
|
|
|
|
CleveRoids.castTracking[casterGuid] = {
|
|
spellID = spellId,
|
|
spellName = spellName,
|
|
icon = icon,
|
|
startTime = now,
|
|
duration = durationSec,
|
|
endTime = durationSec > 0 and (now + durationSec) or nil,
|
|
-- v2.40+: arg4 is now correct for friendly player targets (packed GUID bug fixed).
|
|
-- Nil/zero target means no explicit target (e.g. self-cast buffs, AoE spells).
|
|
targetGuid = (arg4 and arg4 ~= "0x0000000000000000") and CleveRoids.NormalizeGUID(arg4) or nil,
|
|
}
|
|
|
|
if CleveRoids.debug then
|
|
local typeStr = spellType == 1 and "channel" or (spellType == 2 and "auto" or "cast")
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00ccff[SPELL_START]|r %s (ID:%d) cast by %s - %.1fs [%s]",
|
|
spellName or "Unknown", spellId, casterGuid, durationSec, typeStr)
|
|
)
|
|
end
|
|
|
|
elseif event == "SPELL_FAILED_OTHER" then
|
|
-- pfUI 7.6 manages castTracking cleanup itself
|
|
|
|
local casterGuid = arg1
|
|
if casterGuid and CleveRoids.castTracking[casterGuid] then
|
|
if CleveRoids.debug then
|
|
local entry = CleveRoids.castTracking[casterGuid]
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff6600[SPELL_FAILED]|r %s cast by %s interrupted/failed",
|
|
entry.spellName or "Unknown", casterGuid)
|
|
)
|
|
end
|
|
CleveRoids.castTracking[casterGuid] = nil
|
|
end
|
|
|
|
-- NAMPOWER v2.26+ SPELL EVENT HANDLERS (Standalone Mode)
|
|
-- These handlers are only used when pfUI v7.4.3+ is NOT available.
|
|
-- When pfUI is available, we use its tables directly instead.
|
|
|
|
elseif event == "SPELL_GO_SELF" or event == "SPELL_GO_OTHER" then
|
|
-- Clear the cast tracking entry FIRST, before the hasPfUIEnhanced bail below.
|
|
-- We always own castTracking (SPELL_START populates it unconditionally), whereas
|
|
-- hasPfUIEnhanced only gates the miss/debuff work further down. Clearing used to
|
|
-- sit after that bail, so with pfUI's enhanced libdebuff tables present
|
|
-- SPELL_START kept adding entries while this cleanup never ran and castTracking
|
|
-- grew without bound.
|
|
-- v2.40+: Save the SPELL_START targetGuid before clearing so we can fall back
|
|
-- to it below when SPELL_GO arg4 is empty (e.g. AoE spells with no single target).
|
|
local startTargetGuid
|
|
if arg3 and CleveRoids.castTracking[arg3] then
|
|
startTargetGuid = CleveRoids.castTracking[arg3].targetGuid
|
|
CleveRoids.castTracking[arg3] = nil
|
|
end
|
|
|
|
-- Skip the remaining miss/debuff work if pfUI enhanced tracking is active
|
|
if lib.hasPfUIEnhanced then return end
|
|
|
|
local spellId = arg2
|
|
local casterGuid = arg3
|
|
-- v2.40+: SPELL_GO targetGuid is now correct for friendly player GUIDs.
|
|
-- Fall back to the SPELL_START-cached targetGuid for spells with no explicit
|
|
-- single target in SPELL_GO (AoE, self-cast with no target arg, etc.).
|
|
local targetGuid = arg4
|
|
if not targetGuid or targetGuid == "0x0000000000000000" then
|
|
targetGuid = startTargetGuid
|
|
end
|
|
local numHit = arg6 or 0
|
|
local numMissed = arg7 or 0
|
|
|
|
if not spellId or not targetGuid then return end
|
|
targetGuid = CleveRoids.NormalizeGUID(targetGuid)
|
|
|
|
local spellName = C_Spell.GetSpellName(spellId)
|
|
if not spellName then return end
|
|
|
|
local playerGUID = CleveRoids.GetGUID("player")
|
|
local isOurs = (casterGuid == playerGUID)
|
|
|
|
-- Annotate pending CC/shared debuffs with SPELL_GO hit/miss outcome
|
|
-- This data is consumed by the OnUpdate verification loop for early-exit paths
|
|
if isOurs then
|
|
-- Annotate pendingCCDebuffs
|
|
for _, pending in ipairs(lib.pendingCCDebuffs) do
|
|
if pending and pending.spellID == spellId and pending.targetGUID == targetGuid then
|
|
if numHit > 0 then
|
|
pending.spellGoHit = true
|
|
elseif numMissed > 0 then
|
|
pending.spellGoMissed = true
|
|
end
|
|
break
|
|
end
|
|
end
|
|
-- Annotate pendingSharedDebuffs (match against both castSpellID and spellID)
|
|
for _, pending in ipairs(lib.pendingSharedDebuffs) do
|
|
if pending and pending.targetGUID == targetGuid
|
|
and (pending.castSpellID == spellId or pending.spellID == spellId) then
|
|
if numHit > 0 then
|
|
pending.spellGoHit = true
|
|
elseif numMissed > 0 then
|
|
pending.spellGoMissed = true
|
|
end
|
|
break
|
|
end
|
|
end
|
|
-- Annotate pendingPersonalDebuffs (from UNIT_CASTEVENT when SuperWoW present,
|
|
-- or from SPELL_GO_SELF personal debuff bridge when SuperWoW absent)
|
|
for _, pending in ipairs(lib.pendingPersonalDebuffs) do
|
|
if pending and pending.targetGUID == targetGuid
|
|
and (pending.castSpellID == spellId or pending.spellID == spellId) then
|
|
if numHit > 0 then
|
|
pending.spellGoHit = true
|
|
elseif numMissed > 0 then
|
|
pending.spellGoMissed = true
|
|
end
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Spell missed - clear pending, mark as failed, track for immunity detection
|
|
if numHit == 0 and numMissed > 0 then
|
|
if lib.pendingCasts[targetGuid] then
|
|
lib.pendingCasts[targetGuid][spellName] = nil
|
|
end
|
|
|
|
-- Get target name for immunity tracking
|
|
local targetName = lib.guidToName[targetGuid]
|
|
if not targetName then
|
|
local currentTargetGUID = CleveRoids.GetGUID("target")
|
|
if currentTargetGUID == targetGuid then
|
|
targetName = UnitName("target")
|
|
lib.guidToName[targetGuid] = targetName
|
|
end
|
|
end
|
|
|
|
-- Mark spell as recently failed for DidSpellFail() check
|
|
-- Store details for immunity/reflect/evade detection via combat log correlation
|
|
lib.recentMisses[targetGuid] = lib.recentMisses[targetGuid] or {}
|
|
lib.recentMisses[targetGuid][spellName] = {
|
|
time = GetTime(),
|
|
spellId = spellId,
|
|
targetGuid = targetGuid,
|
|
targetName = targetName,
|
|
casterGuid = casterGuid,
|
|
isOurs = isOurs,
|
|
reason = nil, -- Will be set by combat log parser (immune/reflect/evade/resist/dodge/parry)
|
|
}
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff6600[SPELL_GO MISS]|r %s missed on %s - pending immunity check",
|
|
spellName, targetName or "Unknown")
|
|
)
|
|
end
|
|
|
|
-- Schedule immunity verification after a short delay
|
|
-- This gives combat log time to report the miss reason (immune/reflect/evade)
|
|
-- Optimization: Use shorter delay with Nampower v2.26+ since AURA_CAST events are faster
|
|
if isOurs and targetName and not CleveRoids.usingSpellMissEvents then
|
|
-- Delay: 150ms with Nampower v2.26+ (AURA_CAST confirms faster), 300ms fallback
|
|
-- Skipped when SPELL_MISS events handle immunity detection directly (v2.31+)
|
|
local verifyDelay = lib.hasUnitDiedEvent and 0.15 or 0.3
|
|
|
|
local verifyFrame = CreateFrame("Frame")
|
|
local verifyData = {
|
|
spellName = spellName,
|
|
spellId = spellId,
|
|
targetGuid = targetGuid,
|
|
targetName = targetName,
|
|
checkTime = GetTime() + verifyDelay,
|
|
}
|
|
verifyFrame:SetScript("OnUpdate", function()
|
|
if GetTime() >= verifyData.checkTime then
|
|
this:SetScript("OnUpdate", nil)
|
|
lib:ProcessMissReason(verifyData)
|
|
end
|
|
end)
|
|
end
|
|
|
|
return
|
|
end
|
|
|
|
-- Spell hit - do refresh logic for OWN debuffs
|
|
if isOurs and lib.ownDebuffs[targetGuid] and lib.ownDebuffs[targetGuid][spellName] then
|
|
local existingData = lib.ownDebuffs[targetGuid][spellName]
|
|
if existingData.startTime and existingData.duration then
|
|
local timeleft = (existingData.startTime + existingData.duration) - GetTime()
|
|
if timeleft > 0 then
|
|
-- Refresh the timer
|
|
lib.ownDebuffs[targetGuid][spellName].startTime = GetTime()
|
|
|
|
CleveRoids.DebugChanged("spellgo_refresh_" .. spellName .. "_" .. tostring(targetGuid),
|
|
string.format("|cff00ff00[SPELL_GO REFRESH]|r %s refreshed on %s",
|
|
spellName, lib.guidToName[targetGuid] or "Unknown"))
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Spell hit on target - clear any pending immunity checks
|
|
-- (The spell landed, so target is definitely not immune to it)
|
|
if isOurs and lib.recentMisses and lib.recentMisses[targetGuid] then
|
|
lib.recentMisses[targetGuid][spellName] = nil
|
|
end
|
|
|
|
-- COMBO POINT INTEGRATION: Capture combo points on SPELL_GO hit
|
|
-- SPELL_GO fires immediately when the spell lands - this is the best time
|
|
-- to capture combo points because they might already be consumed by the
|
|
-- time AURA_CAST fires. Store in pendingCasts for AURA_CAST to use.
|
|
if isOurs and lib.combopointAbilities and lib.combopointAbilities[spellName] then
|
|
-- Capture combo points from multiple sources
|
|
-- Priority: SPELL_CAST_EVENT (client-side, pre-server) > GetComboPoints > lastComboPoints > ComboPointTracking
|
|
local comboPoints = 0
|
|
|
|
-- PRIMARY: Check SPELL_CAST_EVENT capture (most reliable - captured before server consumed CP)
|
|
local pending = CleveRoids.pendingCasts and CleveRoids.pendingCasts[spellId]
|
|
if pending and pending.comboPoints and pending.comboPoints > 0 then
|
|
comboPoints = pending.comboPoints
|
|
end
|
|
|
|
-- Fallback: Try GetComboPoints (may still be available)
|
|
if comboPoints == 0 then
|
|
comboPoints = CleveRoids.GetComboPoints and CleveRoids.GetComboPoints() or 0
|
|
end
|
|
|
|
-- Fallback: lastComboPoints (from pre-cast hooks or UNIT_CASTEVENT START)
|
|
if comboPoints == 0 and CleveRoids.lastComboPoints and CleveRoids.lastComboPoints > 0 then
|
|
comboPoints = CleveRoids.lastComboPoints
|
|
end
|
|
|
|
-- Fallback: ComboPointTracking for recent data (from /cast hook pre-population)
|
|
if comboPoints == 0 and CleveRoids.ComboPointTracking then
|
|
local tracking = CleveRoids.ComboPointTracking[spellName]
|
|
if tracking and tracking.combo_points and tracking.combo_points > 0 then
|
|
local age = GetTime() - (tracking.cast_time or 0)
|
|
if age < 1.0 then -- Use if less than 1 second old
|
|
comboPoints = tracking.combo_points
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Store combo points in pendingCasts for AURA_CAST to retrieve
|
|
if comboPoints > 0 then
|
|
lib.pendingCasts[targetGuid] = lib.pendingCasts[targetGuid] or {}
|
|
lib.pendingCasts[targetGuid][spellName] = lib.pendingCasts[targetGuid][spellName] or {}
|
|
lib.pendingCasts[targetGuid][spellName].comboPoints = comboPoints
|
|
lib.pendingCasts[targetGuid][spellName].capturedAt = GetTime()
|
|
lib.pendingCasts[targetGuid][spellName].spellId = spellId
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00ffaa[SPELL_GO COMBO]|r Captured %d CP for %s on %s",
|
|
comboPoints, spellName, lib.guidToName[targetGuid] or "Unknown")
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Nampower fallback: Cast-complete features from UNIT_CASTEVENT CAST
|
|
-- Only needed when SuperWoW is not available and spell hit the target
|
|
if isOurs and not CleveRoids.hasSuperwow and numHit > 0 then
|
|
-- DRUID CARNAGE TALENT: Track Ferocious Bite cast for proc detection
|
|
if CleveRoids.FerociousBiteSpellIDs and CleveRoids.FerociousBiteSpellIDs[spellId] then
|
|
local _, _, _, _, rank = GetTalentInfo(2, 17)
|
|
local carnageRank = tonumber(rank) or 0
|
|
if carnageRank >= 1 then
|
|
local targetName = lib.guidToName[targetGuid] or UnitName("target") or "Unknown"
|
|
CleveRoids.lastFerociousBiteTime = GetTime()
|
|
CleveRoids.lastFerociousBiteTargetGUID = targetGuid
|
|
CleveRoids.lastFerociousBiteTargetName = targetName
|
|
CleveRoids.lastFerociousBiteSpellID = spellId
|
|
end
|
|
end
|
|
|
|
-- SHAMAN MOLTEN BLAST: Track for Flame Shock refresh detection
|
|
if CleveRoids.MoltenBlastSpellIDs and CleveRoids.MoltenBlastSpellIDs[spellId] then
|
|
CleveRoids.lastMoltenBlastTime = GetTime()
|
|
CleveRoids.lastMoltenBlastTargetGUID = targetGuid
|
|
end
|
|
|
|
-- WARLOCK CONFLAGRATE: Reduces Immolate duration by 3 seconds
|
|
if CleveRoids.ConflagrateSpellIDs and CleveRoids.ConflagrateSpellIDs[spellId] then
|
|
if lib.objects[targetGuid] then
|
|
for immolateID, _ in pairs(CleveRoids.ImmolateSpellIDs or {}) do
|
|
local rec = lib.objects[targetGuid][immolateID]
|
|
if rec and rec.duration and rec.start then
|
|
local remaining = rec.duration + rec.start - GetTime()
|
|
if remaining > 0 then
|
|
rec.duration = rec.duration - 3
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- CC IMMUNITY TRACKING: Check if this spell is a CC spell
|
|
local ccType = CleveRoids.GetSpellCCType and CleveRoids.GetSpellCCType(spellId)
|
|
if ccType then
|
|
local isHiddenCC = lib.hiddenCCSpells and lib.hiddenCCSpells[spellId]
|
|
if not isHiddenCC and _G.IsAuraHidden then
|
|
isHiddenCC = (_G.IsAuraHidden(spellId) == 1)
|
|
end
|
|
local ccTargetName = lib.guidToName[targetGuid]
|
|
if not ccTargetName then
|
|
local currentTargetGUID = CleveRoids.GetGUID("target")
|
|
if currentTargetGUID == targetGuid then
|
|
ccTargetName = UnitName("target")
|
|
lib.guidToName[targetGuid] = ccTargetName
|
|
end
|
|
end
|
|
table.insert(lib.pendingCCDebuffs, {
|
|
timestamp = GetTime(),
|
|
targetGUID = targetGuid,
|
|
targetName = ccTargetName,
|
|
spellID = spellId,
|
|
spellName = spellName,
|
|
ccType = ccType,
|
|
isHiddenCC = isHiddenCC,
|
|
spellGoHit = true, -- We already know it hit
|
|
})
|
|
end
|
|
|
|
-- DRUID CARNAGE: Save Rip cast data for potential Ferocious Bite refresh
|
|
if CleveRoids.RipSpellIDs and CleveRoids.RipSpellIDs[spellId] then
|
|
if CleveRoids.carnageDurationOverrides and CleveRoids.carnageDurationOverrides[spellId] then
|
|
CleveRoids.carnageDurationOverrides[spellId] = nil
|
|
end
|
|
end
|
|
|
|
-- CARNAGE: Save Rake cast data for potential Ferocious Bite refresh
|
|
if CleveRoids.RakeSpellIDs and CleveRoids.RakeSpellIDs[spellId] then
|
|
if CleveRoids.carnageDurationOverrides and CleveRoids.carnageDurationOverrides[spellId] then
|
|
CleveRoids.carnageDurationOverrides[spellId] = nil
|
|
end
|
|
end
|
|
|
|
-- Track last player cast for miss/dodge/parry removal
|
|
lib.lastPlayerCast = {
|
|
spellID = spellId,
|
|
targetGUID = targetGuid,
|
|
timestamp = GetTime()
|
|
}
|
|
|
|
-- PERSONAL DEBUFF TRACKING: Bridge for [debuff:X>Y] conditionals
|
|
-- Without SuperWoW's UNIT_CASTEVENT or pfUI, personal debuffs never reach lib.objects.
|
|
-- This replicates the relevant personal debuff logic from UNIT_CASTEVENT CAST handler.
|
|
|
|
-- Pounce: Convert cast spell ID to triggered Pounce Bleed spell ID
|
|
local trackingSpellID = spellId
|
|
if CleveRoids.PounceToBleedMapping and CleveRoids.PounceToBleedMapping[spellId] then
|
|
trackingSpellID = CleveRoids.PounceToBleedMapping[spellId]
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00aaff[SPELL_GO Pounce\226\134\146Bleed]|r Converted cast ID %d to bleed ID %d", spellId, trackingSpellID)
|
|
)
|
|
end
|
|
end
|
|
|
|
-- Check if this is a combo point scaling spell
|
|
local debuffDuration = nil
|
|
local debuffComboPoints = nil
|
|
if CleveRoids.TrackComboPointCastByID then
|
|
debuffDuration = CleveRoids.TrackComboPointCastByID(trackingSpellID, targetGuid)
|
|
if CleveRoids.ComboPointTracking and CleveRoids.ComboPointTracking.byID and
|
|
CleveRoids.ComboPointTracking.byID[trackingSpellID] then
|
|
debuffComboPoints = CleveRoids.ComboPointTracking.byID[trackingSpellID].combo_points
|
|
end
|
|
end
|
|
|
|
-- If not combo scaling, try normal duration lookup
|
|
if not debuffDuration then
|
|
debuffDuration = lib:GetDuration(trackingSpellID, playerGUID)
|
|
if debuffDuration and CleveRoids.ApplyAllDurationModifiers then
|
|
debuffDuration = CleveRoids.ApplyAllDurationModifiers(trackingSpellID, debuffDuration)
|
|
end
|
|
end
|
|
|
|
if debuffDuration and debuffDuration > 0 then
|
|
-- Only process personal debuffs (shared debuffs use SeedUnit fallback)
|
|
local isPersonal = lib:IsPersonalDebuff(trackingSpellID)
|
|
|
|
if isPersonal then
|
|
-- Resolve target name
|
|
local targetName = lib.guidToName[targetGuid]
|
|
if not targetName then
|
|
local currentTargetGUID = CleveRoids.GetGUID("target")
|
|
if currentTargetGUID == targetGuid then
|
|
targetName = UnitName("target")
|
|
lib.guidToName[targetGuid] = targetName
|
|
end
|
|
if not targetName then
|
|
targetName = "Unknown"
|
|
end
|
|
end
|
|
|
|
-- Check rank comparison
|
|
local rankCheck = lib:ShouldApplyDebuffRank(targetGuid, trackingSpellID)
|
|
|
|
if rankCheck == true then
|
|
table.insert(lib.pendingPersonalDebuffs, {
|
|
timestamp = GetTime(),
|
|
targetGUID = targetGuid,
|
|
targetName = targetName,
|
|
spellID = trackingSpellID,
|
|
castSpellID = spellId,
|
|
duration = debuffDuration,
|
|
comboPoints = debuffComboPoints,
|
|
spellGoHit = true, -- Already confirmed hit from SPELL_GO
|
|
})
|
|
|
|
-- Judgement scan for Paladins
|
|
local isJudgementCast = lib.judgementSpells[spellId]
|
|
if not isJudgementCast and CleveRoids.playerClass == "PALADIN" then
|
|
local castName = C_Spell.GetSpellName(spellId)
|
|
isJudgementCast = castName and string.find(castName, "^Judgement")
|
|
end
|
|
if CleveRoids.playerClass == "PALADIN" and isJudgementCast then
|
|
table.insert(lib.pendingJudgements, {
|
|
timestamp = GetTime(),
|
|
castSpellID = spellId,
|
|
targetGUID = targetGuid,
|
|
targetName = targetName
|
|
})
|
|
end
|
|
|
|
if CleveRoids.debug then
|
|
local trackingName = C_Spell.GetSpellName(trackingSpellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffaaff00[SPELL_GO Pending]|r Scheduled %s (ID:%d) for tracking on %s (confirmed hit)",
|
|
trackingName, trackingSpellID, targetName)
|
|
)
|
|
end
|
|
elseif type(rankCheck) == "table" and rankCheck.preserve then
|
|
table.insert(lib.pendingPersonalDebuffs, {
|
|
timestamp = GetTime(),
|
|
targetGUID = targetGuid,
|
|
targetName = targetName,
|
|
spellID = rankCheck.preserve,
|
|
duration = rankCheck.timeRemaining,
|
|
comboPoints = debuffComboPoints,
|
|
isRankPreserve = true,
|
|
spellGoHit = true,
|
|
})
|
|
|
|
if CleveRoids.debug then
|
|
local preserveName = C_Spell.GetSpellName(rankCheck.preserve) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffaaff00[SPELL_GO Pending]|r Scheduled rank preserve for %s (ID:%d, %.1fs remaining) on %s",
|
|
preserveName, rankCheck.preserve, rankCheck.timeRemaining, targetName)
|
|
)
|
|
end
|
|
end
|
|
|
|
-- Save Rip cast data for Ferocious Bite Carnage refresh
|
|
if CleveRoids.RipSpellIDs and CleveRoids.RipSpellIDs[spellId] and CleveRoids.lastRipCast then
|
|
CleveRoids.lastRipCast.spellID = spellId
|
|
CleveRoids.lastRipCast.duration = debuffDuration
|
|
CleveRoids.lastRipCast.targetGUID = targetGuid
|
|
CleveRoids.lastRipCast.comboPoints = debuffComboPoints or 0
|
|
CleveRoids.lastRipCast.timestamp = GetTime()
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00ff00[SPELL_GO Carnage]|r Saved Rip cast: %ds duration (%d CP) on target %s",
|
|
debuffDuration, debuffComboPoints or 0, targetName)
|
|
)
|
|
end
|
|
end
|
|
|
|
-- Save Rake cast data for Ferocious Bite Carnage refresh
|
|
if CleveRoids.RakeSpellIDs and CleveRoids.RakeSpellIDs[spellId] and CleveRoids.lastRakeCast then
|
|
CleveRoids.lastRakeCast.spellID = spellId
|
|
CleveRoids.lastRakeCast.duration = debuffDuration
|
|
CleveRoids.lastRakeCast.targetGUID = targetGuid
|
|
CleveRoids.lastRakeCast.comboPoints = debuffComboPoints or 0
|
|
CleveRoids.lastRakeCast.timestamp = GetTime()
|
|
CleveRoids.lastRakeCast.pending = true
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00ff00[SPELL_GO Carnage]|r Saved Rake cast (pending verification): %ds duration (%d CP) on target %s",
|
|
debuffDuration, debuffComboPoints or 0, targetName)
|
|
)
|
|
end
|
|
end
|
|
|
|
-- Name-based combo tracking for pfUI compatibility
|
|
if debuffComboPoints and debuffComboPoints > 0 and CleveRoids.ComboPointTracking then
|
|
local baseName = CleveRoids.StripRank(spellName)
|
|
CleveRoids.ComboPointTracking[baseName] = {
|
|
combo_points = debuffComboPoints,
|
|
duration = debuffDuration,
|
|
cast_time = GetTime(),
|
|
target = targetName,
|
|
confirmed = true
|
|
}
|
|
-- Update pfUI's duration database directly (pre-7.6 only)
|
|
if pfUI and pfUI.api and pfUI.api.libdebuff and pfUI.api.libdebuff.debuffs then
|
|
pfUI.api.libdebuff.debuffs[baseName] = debuffDuration
|
|
end
|
|
end
|
|
|
|
-- Set up learning for combo spells
|
|
if debuffComboPoints then
|
|
lib.learnCastTimers[targetGuid] = lib.learnCastTimers[targetGuid] or {}
|
|
lib.learnCastTimers[targetGuid][spellId] = {
|
|
start = GetTime(),
|
|
caster = playerGUID,
|
|
comboPoints = debuffComboPoints
|
|
}
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
elseif event == "AURA_CAST_ON_SELF" or event == "AURA_CAST_ON_OTHER" then
|
|
-- Skip if pfUI enhanced tracking is active
|
|
if lib.hasPfUIEnhanced then return end
|
|
|
|
local spellId = arg1
|
|
local casterGuid = arg2
|
|
local targetGuid = arg3
|
|
local durationMs = arg8
|
|
|
|
if not spellId or not targetGuid then return end
|
|
targetGuid = CleveRoids.NormalizeGUID(targetGuid)
|
|
|
|
local spellName = C_Spell.GetSpellName(spellId)
|
|
local spellRankStr = C_Spell.GetSpellSubtext(spellId)
|
|
local texture = lib:GetCachedIcon(spellId)
|
|
if not spellName then return end
|
|
|
|
-- Extract rank number
|
|
local rankNum = 0
|
|
if spellRankStr and spellRankStr ~= "" then
|
|
rankNum = tonumber((string.gsub(spellRankStr, "Rank ", ""))) or 0
|
|
end
|
|
|
|
local duration = durationMs and (durationMs / 1000) or 0
|
|
local now = GetTime()
|
|
local playerGUID = CleveRoids.GetGUID("player")
|
|
local isOurs = (playerGUID and casterGuid == playerGUID)
|
|
|
|
-- Check if this debuff recently failed (miss/dodge/parry) on this specific target
|
|
if isOurs and lib:DidSpellFail(spellName, targetGuid) then
|
|
return -- Don't track - spell failed
|
|
end
|
|
|
|
-- Handle combo point abilities for our casts
|
|
if isOurs and lib.combopointAbilities[spellName] then
|
|
-- Priority order for combo points:
|
|
-- 1. SPELL_CAST_EVENT capture (client-side, before server consumes CP)
|
|
-- 2. pendingCasts from SPELL_GO (captured on spell landing)
|
|
-- 3. CleveRoids.lastComboPoints (from pre-cast hooks)
|
|
-- 4. GetComboPoints() (might be 0 if already consumed)
|
|
local comboPoints = 0
|
|
local comboSource = "none"
|
|
|
|
-- PRIMARY: Check SPELL_CAST_EVENT capture (most reliable - client-side pre-server)
|
|
local castPending = CleveRoids.pendingCasts and CleveRoids.pendingCasts[spellId]
|
|
if castPending and castPending.comboPoints and castPending.comboPoints > 0 then
|
|
comboPoints = castPending.comboPoints
|
|
comboSource = "SPELL_CAST_EVENT"
|
|
end
|
|
|
|
-- Fallback: Check lib.pendingCasts (populated by SPELL_GO)
|
|
if comboPoints == 0 and lib.pendingCasts[targetGuid] and lib.pendingCasts[targetGuid][spellName] then
|
|
local pending = lib.pendingCasts[targetGuid][spellName]
|
|
local age = GetTime() - (pending.capturedAt or 0)
|
|
if pending.comboPoints and pending.comboPoints > 0 and age < 2.0 then
|
|
comboPoints = pending.comboPoints
|
|
comboSource = "SPELL_GO"
|
|
end
|
|
end
|
|
|
|
-- Clean up lib.pendingCasts after reading (regardless of which source won)
|
|
if lib.pendingCasts[targetGuid] and lib.pendingCasts[targetGuid][spellName] then
|
|
lib.pendingCasts[targetGuid][spellName] = nil
|
|
end
|
|
|
|
-- Fallback to lastComboPoints
|
|
if comboPoints == 0 and CleveRoids.lastComboPoints and CleveRoids.lastComboPoints > 0 then
|
|
comboPoints = CleveRoids.lastComboPoints
|
|
comboSource = "lastComboPoints"
|
|
end
|
|
|
|
-- Last resort: current GetComboPoints (might be 0)
|
|
if comboPoints == 0 then
|
|
comboPoints = CleveRoids.GetComboPoints and CleveRoids.GetComboPoints() or 0
|
|
if comboPoints > 0 then
|
|
comboSource = "GetComboPoints"
|
|
end
|
|
end
|
|
|
|
-- Default to 1 CP if we couldn't find any data
|
|
if comboPoints == 0 then
|
|
comboPoints = 1
|
|
comboSource = "default"
|
|
end
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00aaff[AURA_CAST COMBO]|r %s using %d CP (source: %s)",
|
|
spellName, comboPoints, comboSource)
|
|
)
|
|
end
|
|
|
|
-- Use our GetDuration which handles combo points
|
|
duration = lib:GetDuration(spellId, casterGuid, comboPoints)
|
|
end
|
|
|
|
-- Store in appropriate table
|
|
if isOurs then
|
|
lib.ownDebuffs[targetGuid] = lib.ownDebuffs[targetGuid] or {}
|
|
|
|
-- Check existing for rank comparison
|
|
local existing = lib.ownDebuffs[targetGuid][spellName]
|
|
if existing and existing.startTime and existing.duration then
|
|
local timeleft = (existing.startTime + existing.duration) - now
|
|
if timeleft > 0 and rankNum > 0 and existing.rank and rankNum < existing.rank then
|
|
CleveRoids.DebugChanged("auracast_rank_" .. spellName .. "_" .. tostring(targetGuid),
|
|
string.format("|cffff6600[AURA_CAST RANK BLOCK]|r %s Rank %d cannot overwrite Rank %d",
|
|
spellName, rankNum, existing.rank))
|
|
return
|
|
end
|
|
end
|
|
|
|
lib.ownDebuffs[targetGuid][spellName] = {
|
|
startTime = now,
|
|
duration = duration,
|
|
texture = texture,
|
|
rank = rankNum,
|
|
spellId = spellId,
|
|
stacks = 1,
|
|
slot = nil, -- Will be set by DEBUFF_ADDED
|
|
}
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00ffff[AURA_CAST OURS]|r %s duration=%.1fs target=%s",
|
|
spellName, duration, lib.guidToName[targetGuid] or "Unknown")
|
|
)
|
|
end
|
|
else
|
|
-- Other player's debuff
|
|
lib.allAuraCasts[targetGuid] = lib.allAuraCasts[targetGuid] or {}
|
|
lib.allAuraCasts[targetGuid][spellName] = lib.allAuraCasts[targetGuid][spellName] or {}
|
|
lib.allAuraCasts[targetGuid][spellName][casterGuid] = {
|
|
startTime = now,
|
|
duration = duration,
|
|
rank = rankNum,
|
|
}
|
|
end
|
|
|
|
elseif event == "DEBUFF_ADDED_OTHER" then
|
|
-- Skip if pfUI enhanced tracking is active
|
|
if lib.hasPfUIEnhanced then return end
|
|
|
|
local guid = arg1
|
|
local slot = arg2
|
|
local spellId = arg3
|
|
local stacks = arg4 or 1
|
|
local auraSlot = arg6 -- v2.30+: raw 0-based aura slot index (stable, no shifting)
|
|
local state = arg7 -- v2.32+: 0=added, 1=removed, 2=modified (stack increase)
|
|
|
|
if not guid or not slot or not spellId then return end
|
|
guid = CleveRoids.NormalizeGUID(guid)
|
|
|
|
local spellName = C_Spell.GetSpellName(spellId)
|
|
if not spellName then return end
|
|
|
|
local playerGUID = CleveRoids.GetGUID("player")
|
|
local isOurs = lib.ownDebuffs[guid] and lib.ownDebuffs[guid][spellName] ~= nil
|
|
|
|
-- v2.32+: state == 2 means stack increase - update stacks on existing entry
|
|
if state == 2 and isOurs then
|
|
lib.ownDebuffs[guid][spellName].stacks = stacks
|
|
lib.ownDebuffs[guid][spellName].slot = slot
|
|
if auraSlot then
|
|
lib.ownDebuffs[guid][spellName].auraSlot = auraSlot
|
|
end
|
|
return
|
|
end
|
|
|
|
-- Update slot info in ownDebuffs if this is our debuff
|
|
if isOurs then
|
|
lib.ownDebuffs[guid][spellName].slot = slot
|
|
if auraSlot then
|
|
lib.ownDebuffs[guid][spellName].auraSlot = auraSlot
|
|
end
|
|
end
|
|
|
|
-- Determine caster GUID
|
|
local casterGuid = playerGUID -- Default to player
|
|
if lib.allAuraCasts[guid] and lib.allAuraCasts[guid][spellName] then
|
|
for cGuid, _ in pairs(lib.allAuraCasts[guid][spellName]) do
|
|
casterGuid = cGuid
|
|
break
|
|
end
|
|
end
|
|
|
|
-- v2.30+: Use stable auraSlot for slotOwnership (no shifting needed)
|
|
if auraSlot then
|
|
lib.slotOwnership[guid] = lib.slotOwnership[guid] or {}
|
|
lib.slotOwnership[guid][auraSlot] = {
|
|
casterGuid = casterGuid,
|
|
spellName = spellName,
|
|
spellId = spellId,
|
|
isOurs = isOurs,
|
|
}
|
|
end
|
|
|
|
-- Legacy: Update allSlots for slot tracking (pre-v2.30 or fallback)
|
|
-- Skip if luaSlot is 0 (hidden aura in v3.0+)
|
|
if slot > 0 then
|
|
lib.allSlots[guid] = lib.allSlots[guid] or {}
|
|
lib.allSlots[guid][slot] = {
|
|
spellName = spellName,
|
|
casterGuid = casterGuid,
|
|
isOurs = isOurs,
|
|
}
|
|
end
|
|
|
|
elseif event == "DEBUFF_REMOVED_OTHER" then
|
|
-- Skip if pfUI enhanced tracking is active
|
|
if lib.hasPfUIEnhanced then return end
|
|
|
|
local guid = arg1
|
|
local slot = arg2
|
|
local spellId = arg3
|
|
local stacks = arg4 -- v2.32+: current stack count after decrement
|
|
local auraSlot = arg6 -- v2.30+: raw 0-based aura slot index (stable, no shifting)
|
|
local state = arg7 -- v2.32+: 0=added, 1=removed, 2=modified (stack decrease)
|
|
|
|
if not guid or not slot then return end
|
|
guid = CleveRoids.NormalizeGUID(guid)
|
|
|
|
local spellName = spellId and C_Spell.GetSpellName(spellId)
|
|
|
|
-- v2.32+: state == 2 means stack decrease - update stacks, don't remove
|
|
if state == 2 and stacks and stacks > 0 then
|
|
if spellName and lib.ownDebuffs[guid] and lib.ownDebuffs[guid][spellName] then
|
|
lib.ownDebuffs[guid][spellName].stacks = stacks
|
|
end
|
|
return
|
|
end
|
|
|
|
-- Full removal (state == 1, or nil for pre-v2.32)
|
|
-- Remove from ownDebuffs if present
|
|
if spellName and lib.ownDebuffs[guid] and lib.ownDebuffs[guid][spellName] then
|
|
lib.ownDebuffs[guid][spellName] = nil
|
|
end
|
|
|
|
-- Remove from ownSlots
|
|
-- Skip if luaSlot is 0 (hidden aura in v3.0+)
|
|
if slot > 0 and lib.ownSlots[guid] and lib.ownSlots[guid][slot] then
|
|
lib.ownSlots[guid][slot] = nil
|
|
end
|
|
|
|
-- v2.30+: Remove from stable slotOwnership (no shifting needed!)
|
|
if auraSlot and lib.slotOwnership[guid] then
|
|
lib.slotOwnership[guid][auraSlot] = nil
|
|
end
|
|
|
|
-- Legacy: Remove from allSlots and shift slots down (pre-v2.30 or fallback)
|
|
-- Skip if luaSlot is 0 (hidden aura in v3.0+)
|
|
if slot > 0 and lib.allSlots[guid] and lib.allSlots[guid][slot] then
|
|
lib.allSlots[guid][slot] = nil
|
|
|
|
-- Shift slots down (only needed for legacy slot tracking)
|
|
local maxSlot = 0
|
|
for s in pairs(lib.allSlots[guid]) do
|
|
if s > maxSlot then maxSlot = s end
|
|
end
|
|
|
|
for s = slot + 1, maxSlot + 1 do
|
|
if lib.allSlots[guid][s] then
|
|
lib.allSlots[guid][s - 1] = lib.allSlots[guid][s]
|
|
lib.allSlots[guid][s] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
-- NAMPOWER v2.30+ BUFF_REMOVED_SELF - Player's own buffs removed
|
|
elseif event == "BUFF_REMOVED_SELF" then
|
|
if lib.hasPfUIEnhanced then return end
|
|
|
|
local spellId = arg3
|
|
local state = arg7
|
|
if not spellId then return end
|
|
if state == 2 then return end -- Stack decrease only, not full removal
|
|
|
|
local spellName = C_Spell.GetSpellName(spellId)
|
|
|
|
-- Also prune from OverflowBuffs (existing system)
|
|
if spellId and CleveRoids.OverflowBuffs and CleveRoids.OverflowBuffs[spellId] then
|
|
CleveRoids.OverflowBuffs[spellId] = nil
|
|
end
|
|
|
|
-- Also prune from combat log fallback overflow table (name-based)
|
|
if spellName and CleveRoids.OverflowBuffsByName then
|
|
local lowerName = string.lower(spellName)
|
|
if CleveRoids.OverflowBuffsByName[lowerName] then
|
|
CleveRoids.OverflowBuffsByName[lowerName] = nil
|
|
end
|
|
end
|
|
|
|
-- NAMPOWER v2.30+ BUFF_REMOVED_OTHER - Buffs removed from other units
|
|
elseif event == "BUFF_REMOVED_OTHER" then
|
|
if lib.hasPfUIEnhanced then return end
|
|
|
|
local guid = CleveRoids.NormalizeGUID(arg1)
|
|
local spellId = arg3
|
|
local state = arg7
|
|
if not guid or not spellId then return end
|
|
if state == 2 then return end -- Stack decrease only, not full removal
|
|
|
|
local spellName = C_Spell.GetSpellName(spellId)
|
|
|
|
-- Also clean AllCasterAuraTracking (keyed by spellName)
|
|
if spellName and CleveRoids.AllCasterAuraTracking[guid] then
|
|
if CleveRoids.AllCasterAuraTracking[guid][spellName] then
|
|
CleveRoids.AllCasterAuraTracking[guid][spellName] = nil
|
|
if not next(CleveRoids.AllCasterAuraTracking[guid]) then
|
|
CleveRoids.AllCasterAuraTracking[guid] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
-- NAMPOWER v2.26+ UNIT_DIED - Instant cleanup on target death
|
|
elseif event == "UNIT_DIED" then
|
|
local guid = arg1
|
|
if not guid then return end
|
|
guid = CleveRoids.NormalizeGUID(guid)
|
|
|
|
-- Get unit name before cleanup (for debug output)
|
|
local unitName = lib.guidToName[guid]
|
|
|
|
-- Track recent death to prevent false immunity recording
|
|
-- (dead targets report SPELL_MISS IMMUNE for in-flight spells)
|
|
lib.recentDeaths[guid] = GetTime()
|
|
|
|
-- Clean up all tracking data for this GUID immediately
|
|
if lib.ownDebuffs[guid] then
|
|
lib.ownDebuffs[guid] = nil
|
|
end
|
|
if lib.ownSlots[guid] then
|
|
lib.ownSlots[guid] = nil
|
|
end
|
|
if lib.allSlots[guid] then
|
|
lib.allSlots[guid] = nil
|
|
end
|
|
if lib.slotOwnership[guid] then
|
|
lib.slotOwnership[guid] = nil
|
|
end
|
|
if lib.allAuraCasts[guid] then
|
|
lib.allAuraCasts[guid] = nil
|
|
end
|
|
if lib.pendingCasts[guid] then
|
|
lib.pendingCasts[guid] = nil
|
|
end
|
|
if lib.recentMisses and lib.recentMisses[guid] then
|
|
lib.recentMisses[guid] = nil
|
|
end
|
|
if lib.recentCCHits and lib.recentCCHits[guid] then
|
|
lib.recentCCHits[guid] = nil
|
|
end
|
|
|
|
-- Clean up buff tracking tables
|
|
|
|
-- Clean up cast tracking for this unit (they can't be casting if dead)
|
|
if CleveRoids.castTracking[guid] then
|
|
CleveRoids.castTracking[guid] = nil
|
|
end
|
|
|
|
-- Clean up overflow buff tracking (death removes all buffs)
|
|
local playerGUID = CleveRoids.GetGUID("player")
|
|
if playerGUID and guid == playerGUID then
|
|
-- Player died: clear all overflow buff entries and reset cap status
|
|
if CleveRoids.OverflowBuffs then
|
|
for k in pairs(CleveRoids.OverflowBuffs) do
|
|
CleveRoids.OverflowBuffs[k] = nil
|
|
end
|
|
end
|
|
if CleveRoids.AuraCapStatus then
|
|
CleveRoids.AuraCapStatus.playerBuffCapped = false
|
|
CleveRoids.AuraCapStatus.playerDebuffCapped = false
|
|
end
|
|
end
|
|
-- Clean up all-caster aura tracking for the dead unit
|
|
if CleveRoids.AllCasterAuraTracking and CleveRoids.AllCasterAuraTracking[guid] then
|
|
CleveRoids.AllCasterAuraTracking[guid] = nil
|
|
end
|
|
-- Remove from known enemy GUID tracker
|
|
if CleveRoids.knownEnemyGuids then
|
|
CleveRoids.knownEnemyGuids[guid] = nil
|
|
end
|
|
|
|
-- Deferred GUID-to-name cleanup (5s delay to allow immunity detection lookups)
|
|
lib._guidNameCleanupQueue = lib._guidNameCleanupQueue or {}
|
|
table.insert(lib._guidNameCleanupQueue, { guid = guid, expiry = GetTime() + 5 })
|
|
|
|
if CleveRoids.debug and unitName then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff6600[UNIT_DIED]|r Cleaned up tracking data for %s (%s)",
|
|
unitName, guid)
|
|
)
|
|
end
|
|
end
|
|
end)
|
|
|
|
-- Track the last cast spell and target for miss/dodge/parry removal
|
|
-- Format: { spellID = id, targetGUID = guid, timestamp = GetTime() }
|
|
lib.lastPlayerCast = lib.lastPlayerCast or nil
|
|
|
|
local evLearn = CreateFrame("Frame", "CleveRoidsLibDebuffLearnFrame", UIParent)
|
|
-- NOTE: RAW_COMBATLOG now handled by unified CleveRoidsUnifiedCombatLogFrame
|
|
evLearn:RegisterEvent("CHAT_MSG_SPELL_SELF_DAMAGE") -- For miss/dodge/parry detection
|
|
|
|
evLearn:SetScript("OnEvent", function()
|
|
-- Handle spell misses, dodges, parries, resists, blocks, and immunities
|
|
if event == "CHAT_MSG_SPELL_SELF_DAMAGE" then
|
|
-- Skip chat log parsing when Nampower SPELL_GO events are available
|
|
-- SPELL_GO provides real-time miss detection with GUID tracking
|
|
if lib.hasStandaloneNampower or lib.hasPfUIEnhanced then return end
|
|
|
|
local message = arg1
|
|
if not message then return end
|
|
|
|
-- Cursive-style pattern matching (more reliable than substring search)
|
|
-- Patterns match "Your [Spell] was/is [result] by [Target]" format
|
|
local spell_failed_tests = {
|
|
"Your (.+) was resisted by (.+)", -- resist
|
|
"Your (.+) missed (.+)", -- miss
|
|
"Your (.+) is parried by (.+)", -- parry (is, not was)
|
|
"Your (.+) was dodged by (.+)", -- dodge
|
|
"Your (.+) was blocked by (.+)", -- full block
|
|
"Your (.+) fail.+%. (.+) is immune", -- immune
|
|
}
|
|
|
|
local spellName, targetName
|
|
for _, pattern in ipairs(spell_failed_tests) do
|
|
local _, _, foundSpell, foundTarget = find(message, pattern)
|
|
if foundSpell and foundTarget then
|
|
spellName = foundSpell
|
|
targetName = foundTarget
|
|
break
|
|
end
|
|
end
|
|
|
|
if spellName and targetName then
|
|
-- CARNAGE: Clear Ferocious Bite tracking if it was dodged/parried/blocked
|
|
-- This prevents false proc detection in edge cases
|
|
if CleveRoids.lastFerociousBiteTime then
|
|
-- Check if the failed spell is Ferocious Bite
|
|
local isFerociousBite = false
|
|
if CleveRoids.FerociousBiteSpellIDs then
|
|
for biteSpellID, _ in pairs(CleveRoids.FerociousBiteSpellIDs) do
|
|
local biteName = C_Spell.GetSpellName(biteSpellID)
|
|
if biteName then
|
|
biteName = CleveRoids.StripRank(biteName)
|
|
local messageSpellName = CleveRoids.StripRank(spellName)
|
|
if lower(biteName) == lower(messageSpellName) then
|
|
isFerociousBite = true
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if isFerociousBite then
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff00ff[Carnage]|r Ferocious Bite avoided by %s - clearing tracking",
|
|
targetName or "Unknown")
|
|
)
|
|
end
|
|
|
|
-- Clear Ferocious Bite tracking (no proc possible since it was avoided)
|
|
CleveRoids.lastFerociousBiteTime = nil
|
|
CleveRoids.lastFerociousBiteTargetGUID = nil
|
|
CleveRoids.lastFerociousBiteTargetName = nil
|
|
CleveRoids.lastFerociousBiteSpellID = nil
|
|
end
|
|
end
|
|
|
|
-- PERSONAL DEBUFFS: Cancel pending tracking if spell was dodged/parried/blocked
|
|
if lib.pendingPersonalDebuffs then
|
|
local messageSpellName = CleveRoids.StripRank(spellName)
|
|
local toRemove = {}
|
|
|
|
for i, pending in ipairs(lib.pendingPersonalDebuffs) do
|
|
-- Check both the triggered spell name AND the original cast spell name
|
|
-- (e.g., "Pounce Bleed" is triggered by "Pounce", but combat log says "Pounce was dodged")
|
|
local pendingSpellName = C_Spell.GetSpellName(pending.spellID)
|
|
local castSpellName = pending.castSpellID and C_Spell.GetSpellName(pending.castSpellID)
|
|
|
|
local matchesTriggered = false
|
|
local matchesCast = false
|
|
|
|
if pendingSpellName then
|
|
pendingSpellName = CleveRoids.StripRank(pendingSpellName)
|
|
matchesTriggered = lower(pendingSpellName) == lower(messageSpellName)
|
|
end
|
|
|
|
if castSpellName then
|
|
castSpellName = CleveRoids.StripRank(castSpellName)
|
|
matchesCast = lower(castSpellName) == lower(messageSpellName)
|
|
end
|
|
|
|
if matchesTriggered or matchesCast then
|
|
-- Found the pending debuff that was avoided - cancel it
|
|
if CleveRoids.debug then
|
|
local displayName = matchesCast and castSpellName or pendingSpellName
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff0000[Pending Track]|r Cancelled %s (ID:%d) - avoided by %s",
|
|
displayName or "Unknown", pending.spellID, targetName or "Unknown")
|
|
)
|
|
end
|
|
table.insert(toRemove, i)
|
|
end
|
|
end
|
|
|
|
-- Remove cancelled debuffs (iterate backwards to avoid index shifting)
|
|
for i = table.getn(toRemove), 1, -1 do
|
|
table.remove(lib.pendingPersonalDebuffs, toRemove[i])
|
|
end
|
|
end
|
|
|
|
-- PENDING CC/SHARED DEBUFFS: Cancel pending tracking if spell was dodged/parried/blocked/missed/resisted/immune
|
|
-- NOTE: We cancel on ALL failure types:
|
|
-- - Physical avoidance (dodge/parry/block/miss): Combat mechanic
|
|
-- - Resist: RNG-based spell resistance
|
|
-- - Immune: TRUE immunity (ParseImmunityCombatLog also records for [immune] conditional)
|
|
local isSpellFailure = find(message, "dodged") or find(message, "parried") or
|
|
find(message, "blocked") or find(message, "missed") or
|
|
find(message, "resist") or find(message, "immune")
|
|
|
|
if isSpellFailure and lib.pendingCCDebuffs then
|
|
local messageSpellName = CleveRoids.StripRank(spellName)
|
|
local toRemove = {}
|
|
|
|
for i, pending in ipairs(lib.pendingCCDebuffs) do
|
|
local pendingSpellName = C_Spell.GetSpellName(pending.spellID)
|
|
if pendingSpellName then
|
|
pendingSpellName = CleveRoids.StripRank(pendingSpellName)
|
|
if lower(pendingSpellName) == lower(messageSpellName) then
|
|
-- Found the pending CC that was avoided/resisted - cancel it (don't record immunity)
|
|
if CleveRoids.debug then
|
|
local failType = find(message, "dodged") and "dodged" or
|
|
find(message, "parried") and "parried" or
|
|
find(message, "blocked") and "blocked" or
|
|
find(message, "resist") and "resisted" or
|
|
find(message, "immune") and "immune" or "missed"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff0000[CC Track]|r Cancelled %s (%s) - %s by %s",
|
|
pendingSpellName, pending.ccType or "CC", failType, targetName or "Unknown")
|
|
)
|
|
end
|
|
table.insert(toRemove, i)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Remove cancelled CC tracking (iterate backwards to avoid index shifting)
|
|
for i = table.getn(toRemove), 1, -1 do
|
|
table.remove(lib.pendingCCDebuffs, toRemove[i])
|
|
end
|
|
end
|
|
|
|
-- SHARED DEBUFFS: Cancel pending tracking if spell was dodged/parried/blocked/missed/resisted/immune
|
|
if isSpellFailure and lib.pendingSharedDebuffs then
|
|
local messageSpellName = CleveRoids.StripRank(spellName)
|
|
local toRemove = {}
|
|
|
|
for i, pending in ipairs(lib.pendingSharedDebuffs) do
|
|
-- Check both the tracking spell name AND the original cast spell name
|
|
local pendingSpellName = C_Spell.GetSpellName(pending.spellID)
|
|
local castSpellName = pending.castSpellID and C_Spell.GetSpellName(pending.castSpellID)
|
|
|
|
local matchesTracking = false
|
|
local matchesCast = false
|
|
|
|
if pendingSpellName then
|
|
pendingSpellName = CleveRoids.StripRank(pendingSpellName)
|
|
matchesTracking = lower(pendingSpellName) == lower(messageSpellName)
|
|
end
|
|
|
|
if castSpellName then
|
|
castSpellName = CleveRoids.StripRank(castSpellName)
|
|
matchesCast = lower(castSpellName) == lower(messageSpellName)
|
|
end
|
|
|
|
if matchesTracking or matchesCast then
|
|
-- Found the pending shared debuff that failed - cancel tracking
|
|
if CleveRoids.debug then
|
|
local displayName = matchesCast and castSpellName or pendingSpellName
|
|
local failType = find(message, "dodged") and "dodged" or
|
|
find(message, "parried") and "parried" or
|
|
find(message, "blocked") and "blocked" or
|
|
find(message, "resist") and "resisted" or
|
|
find(message, "immune") and "immune" or "missed"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff0000[Shared Track]|r Cancelled %s (school:%s) - %s by %s",
|
|
displayName or "Unknown", pending.school or "unknown", failType, targetName or "Unknown")
|
|
)
|
|
end
|
|
table.insert(toRemove, i)
|
|
end
|
|
end
|
|
|
|
-- Remove cancelled shared debuff tracking (iterate backwards to avoid index shifting)
|
|
for i = table.getn(toRemove), 1, -1 do
|
|
table.remove(lib.pendingSharedDebuffs, toRemove[i])
|
|
end
|
|
end
|
|
|
|
-- Use the last player cast info if available and recent (within 1 second)
|
|
if lib.lastPlayerCast and lib.lastPlayerCast.timestamp and
|
|
(GetTime() - lib.lastPlayerCast.timestamp) < 1.0 then
|
|
|
|
local targetGUID = lib.lastPlayerCast.targetGUID
|
|
local castSpellID = lib.lastPlayerCast.spellID
|
|
|
|
-- Get the spell name from the cast (strip rank)
|
|
local castSpellName = C_Spell.GetSpellName(castSpellID)
|
|
if castSpellName then
|
|
castSpellName = CleveRoids.StripRank(castSpellName)
|
|
|
|
-- Strip rank from the message spell name for comparison
|
|
local messageSpellName = CleveRoids.StripRank(spellName)
|
|
|
|
-- Only remove if the spell names match (case-insensitive)
|
|
if lower(castSpellName) == lower(messageSpellName) then
|
|
-- Verify the target name matches the stored GUID
|
|
local expectedTargetName = lib.guidToName[targetGUID]
|
|
if expectedTargetName and lower(expectedTargetName) ~= lower(targetName) then
|
|
-- Wrong target - this failure is for a different cast, not the one in lastPlayerCast
|
|
if CleveRoids.debugVerbose then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff9900[Spell Failed]|r Ignoring %s failure - target mismatch (expected:%s, got:%s)",
|
|
castSpellName, expectedTargetName, targetName)
|
|
)
|
|
end
|
|
return
|
|
end
|
|
|
|
-- Find all spell IDs matching this name (all ranks)
|
|
local matchingSpellIDs = {}
|
|
if lib.personalDebuffs then
|
|
for sid, _ in pairs(lib.personalDebuffs) do
|
|
local name = C_Spell.GetSpellName(sid)
|
|
if name then
|
|
name = CleveRoids.StripRank(name)
|
|
if name == castSpellName then
|
|
table.insert(matchingSpellIDs, sid)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if lib.sharedDebuffs then
|
|
for sid, _ in pairs(lib.sharedDebuffs) do
|
|
local name = C_Spell.GetSpellName(sid)
|
|
if name then
|
|
name = CleveRoids.StripRank(name)
|
|
if name == castSpellName then
|
|
table.insert(matchingSpellIDs, sid)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Remove ALL ranks from tracking (the cast failed)
|
|
if targetGUID and lib.objects[targetGUID] then
|
|
for _, sid in ipairs(matchingSpellIDs) do
|
|
if lib.objects[targetGUID][sid] then
|
|
local rec = lib.objects[targetGUID][sid]
|
|
-- Only remove if very recently added (within 1 second)
|
|
if rec.start and (GetTime() - rec.start) < 1.0 then
|
|
lib.objects[targetGUID][sid] = nil
|
|
if CleveRoids.debug then
|
|
local failType = find(message, "resist") and "resisted" or
|
|
find(message, "miss") and "missed" or
|
|
find(message, "dodge") and "dodged" or
|
|
find(message, "parry") and "parried" or
|
|
find(message, "block") and "blocked" or
|
|
find(message, "immune") and "immune" or "failed"
|
|
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff6600[Spell Failed]|r Removed %s (ID:%d) from %s - %s",
|
|
castSpellName, sid, targetName or "target", failType)
|
|
)
|
|
|
|
-- If unknown fail type, show the actual message for debugging
|
|
if failType == "failed" and CleveRoids.debugVerbose then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffaaaaaa[Debug Message]|r %s", message)
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Clear the last cast so we don't remove it again
|
|
lib.lastPlayerCast = nil
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- SHAMAN MOLTEN BLAST: Check for damage hit to trigger Flame Shock refresh (TWoW Custom)
|
|
-- Credits: Avitasia / Cursive addon
|
|
-- Pattern: "Your Molten Blast hits/crits X for Y Fire damage"
|
|
if CleveRoids.lastMoltenBlastTime and CleveRoids.lastMoltenBlastTargetGUID then
|
|
local timeSinceCast = GetTime() - CleveRoids.lastMoltenBlastTime
|
|
-- Check within 1 second of cast
|
|
if timeSinceCast < 1.0 then
|
|
-- Check if message mentions Molten Blast damage
|
|
if find(message, "Molten Blast") and find(message, "Fire damage") then
|
|
local targetGUID = CleveRoids.lastMoltenBlastTargetGUID
|
|
|
|
-- Find active Flame Shock on target and refresh it
|
|
if lib.objects[targetGUID] then
|
|
for flameShockID, _ in pairs(CleveRoids.FlameShockSpellIDs or {}) do
|
|
local rec = lib.objects[targetGUID][flameShockID]
|
|
-- Only refresh player's own Flame Shock, not other shamans'
|
|
if rec and rec.duration and rec.start and rec.caster == "player" then
|
|
local remaining = rec.duration + rec.start - GetTime()
|
|
if remaining > 0 then
|
|
-- Refresh: reset start time to now
|
|
rec.start = GetTime()
|
|
|
|
-- Sync refresh to pfUI
|
|
local targetName = lib.guidToName[targetGUID]
|
|
if pfUI and pfUI.api and pfUI.api.libdebuff and targetName then
|
|
local pflib = pfUI.api.libdebuff
|
|
local spellName = C_Spell.GetSpellName(flameShockID)
|
|
if spellName and pflib.AddEffect then
|
|
local baseName = CleveRoids.StripRank(spellName)
|
|
local targetLevel = UnitLevel(targetGUID) or UnitLevel("target") or 1
|
|
pflib:AddEffect(targetName, targetLevel, baseName, rec.duration, "player")
|
|
end
|
|
end
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff0070dd[Molten Blast]|r Refreshed Flame Shock to %.1fs on %s",
|
|
rec.duration, lib.guidToName[targetGUID] or "Unknown")
|
|
)
|
|
end
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Clear tracking
|
|
CleveRoids.lastMoltenBlastTime = nil
|
|
CleveRoids.lastMoltenBlastTargetGUID = nil
|
|
end
|
|
else
|
|
-- Time window expired
|
|
CleveRoids.lastMoltenBlastTime = nil
|
|
CleveRoids.lastMoltenBlastTargetGUID = nil
|
|
end
|
|
end
|
|
-- NOTE: RAW_COMBATLOG fade handling now done by unified CleveRoidsUnifiedCombatLogFrame
|
|
end
|
|
end)
|
|
|
|
-- Cleanup dead/invalid targets
|
|
local evCleanup = CreateFrame("Frame", "CleveRoidsLibDebuffCleanupFrame", UIParent)
|
|
evCleanup:RegisterEvent("PLAYER_TARGET_CHANGED")
|
|
evCleanup:RegisterEvent("PLAYER_DEAD")
|
|
evCleanup:RegisterEvent("PLAYER_ENTERING_WORLD")
|
|
|
|
local lastCleanup = 0
|
|
local CLEANUP_THROTTLE = 2 -- Only run cleanup every 2 seconds max
|
|
|
|
evCleanup:SetScript("OnEvent", function()
|
|
local timestamp = GetTime()
|
|
|
|
-- Cleanup on zone change / login / death
|
|
if event == "PLAYER_ENTERING_WORLD" or event == "PLAYER_DEAD" then
|
|
-- Keep only current target's data
|
|
local currentGUID = CleveRoids.GetGUID("target")
|
|
if currentGUID then
|
|
local temp = lib.objects[currentGUID]
|
|
lib.objects = {}
|
|
if temp then
|
|
lib.objects[currentGUID] = temp
|
|
end
|
|
else
|
|
lib.objects = {} -- Clear everything
|
|
end
|
|
lastCleanup = timestamp
|
|
return
|
|
end
|
|
|
|
-- Throttle cleanup on target change
|
|
if event == "PLAYER_TARGET_CHANGED" then
|
|
if (timestamp - lastCleanup) < CLEANUP_THROTTLE then
|
|
return -- Don't cleanup too frequently
|
|
end
|
|
lastCleanup = timestamp
|
|
|
|
-- Remove expired effects from all GUIDs
|
|
for guid, effects in pairs(lib.objects) do
|
|
local targetGUID = CleveRoids.GetGUID("target")
|
|
local isCurrentTarget = (targetGUID == guid)
|
|
|
|
-- Check if current target is dead
|
|
if isCurrentTarget and CleveRoids.IsUnitDead("target") then
|
|
lib.objects[guid] = nil
|
|
lib.guidToName[guid] = nil -- MEMORY: Clean up name mapping
|
|
else
|
|
-- Remove expired effects
|
|
for spellID, effect in pairs(effects) do
|
|
if effect.start + effect.duration < timestamp then
|
|
effects[spellID] = nil
|
|
end
|
|
end
|
|
|
|
-- Remove GUID if no effects remain (but keep current target)
|
|
if not next(effects) and not isCurrentTarget then
|
|
lib.objects[guid] = nil
|
|
lib.guidToName[guid] = nil -- MEMORY: Clean up name mapping
|
|
end
|
|
end
|
|
end
|
|
|
|
-- MEMORY: Clean up orphaned guidToName entries (GUIDs not in lib.objects)
|
|
for guid in pairs(lib.guidToName) do
|
|
if not lib.objects[guid] then
|
|
lib.guidToName[guid] = nil
|
|
end
|
|
end
|
|
end
|
|
end)
|
|
|
|
-- Judgement refresh on melee hits
|
|
-- Priority: SuperWoW UNIT_CASTEVENT > Nampower AUTO_ATTACK_OTHER > Chat log fallback
|
|
-- This chat-based fallback is only used if neither SuperWoW nor Nampower v2.24+ is available
|
|
local evJudgement = CreateFrame("Frame", "CleveRoidsLibDebuffJudgementRefreshFrame", UIParent)
|
|
|
|
-- Only use chat-based detection if SuperWoW and Nampower auto-attack events are not available
|
|
local hasAutoAttackEvents = CleveRoids.NampowerAPI and CleveRoids.NampowerAPI.features
|
|
and CleveRoids.NampowerAPI.features.hasAutoAttackEvents
|
|
if not CleveRoids.hasSuperwow and not hasAutoAttackEvents then
|
|
evJudgement:RegisterEvent("CHAT_MSG_COMBAT_SELF_HITS")
|
|
evJudgement:RegisterEvent("CHAT_MSG_COMBAT_SELF_MISSES")
|
|
end
|
|
|
|
evJudgement:SetScript("OnEvent", function()
|
|
-- Skip if SuperWoW or Nampower auto-attack events handle this
|
|
if CleveRoids.hasSuperwow then return end
|
|
if hasAutoAttackEvents then return end
|
|
-- Only process for paladins
|
|
if CleveRoids.playerClass ~= "PALADIN" then return end
|
|
|
|
if not arg1 then return end
|
|
|
|
-- Debug: Show all combat hit events if debug is enabled
|
|
if CleveRoids.debug and event == "CHAT_MSG_COMBAT_SELF_HITS" then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cffaaaaaa[Combat Hit Event]|r " .. arg1)
|
|
end
|
|
|
|
-- Check if this is a melee hit (not a spell hit)
|
|
-- CHAT_MSG_COMBAT_SELF_HITS contains both melee and spell hits
|
|
-- Filter out spell hits by checking for spell names in parentheses or common patterns
|
|
-- Melee hits look like: "You hit Target for X." or "You crit Target for X."
|
|
local isSpellHit = string.find(arg1, "%(") -- Spell hits often have parentheses
|
|
if isSpellHit then return end
|
|
|
|
-- Must contain "hit" or "crit" to be a valid melee attack
|
|
local lowerMsg = string.lower(arg1)
|
|
local hasHit = string.find(lowerMsg, "hit") or string.find(lowerMsg, "crit")
|
|
if not hasHit then return end
|
|
|
|
-- Get current target
|
|
local targetGUID = CleveRoids.GetGUID("target")
|
|
if not targetGUID then return end
|
|
|
|
if not lib.objects[targetGUID] then return end
|
|
|
|
-- Refresh all active Judgements on the target
|
|
for spellID, rec in pairs(lib.objects[targetGUID]) do
|
|
if lib.judgementSpells[spellID] and rec.start and rec.duration then
|
|
-- Only refresh if the Judgement is still active and was cast by player
|
|
local remaining = rec.duration + rec.start - GetTime()
|
|
if remaining > 0 and rec.caster == "player" then
|
|
-- Refresh the Judgement by updating the start time
|
|
rec.start = GetTime()
|
|
|
|
if CleveRoids.debug then
|
|
local spellName = C_Spell.GetSpellName(spellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00ffaa[Judgement Refresh]|r Refreshed %s (ID:%d) on melee hit - new duration: %ds",
|
|
spellName, spellID, rec.duration)
|
|
)
|
|
end
|
|
|
|
-- Also sync to pfUI if it's loaded (pre-7.6 only)
|
|
if pfUI and pfUI.api and pfUI.api.libdebuff then
|
|
local targetName = lib.guidToName[targetGUID] or UnitName("target")
|
|
local targetLevel = UnitLevel("target") or 0
|
|
local spellName = C_Spell.GetSpellName(spellID)
|
|
|
|
if spellName and targetName then
|
|
local effectName = CleveRoids.StripRank(spellName)
|
|
pfUI.api.libdebuff:AddEffect(targetName, targetLevel, effectName, rec.duration, "player")
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00ffaa[pfUI Judgement Refresh]|r Synced %s refresh to pfUI",
|
|
effectName)
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end)
|
|
|
|
-- TALENT MODIFIER SYSTEM
|
|
|
|
-- Database of talent modifiers for debuff durations
|
|
-- Structure: [spellID] = {
|
|
-- talent = "Talent Name" (optional, for name-based lookup),
|
|
-- tab = talentTab (preferred, for direct position lookup),
|
|
-- id = talentID (preferred, for direct position lookup),
|
|
-- modifier = function(baseDuration, talentRank)
|
|
-- }
|
|
CleveRoids.talentModifiers = CleveRoids.talentModifiers or {}
|
|
|
|
-- ROGUE talent modifiers
|
|
-- Taste for Blood: Increases Rupture duration by 2 seconds per rank (3 ranks max)
|
|
-- Talent Position: Tab 1, Talent 10 (Assassination tree)
|
|
-- Talent Spell IDs: 14174 (Rank 1), 14175 (Rank 2), 14176 (Rank 3)
|
|
CleveRoids.talentModifiers[1943] = { tab = 1, id = 10, talent = "Taste for Blood", modifier = function(base, rank) return base + (rank * 2) end } -- Rupture Rank 1
|
|
CleveRoids.talentModifiers[8639] = { tab = 1, id = 10, talent = "Taste for Blood", modifier = function(base, rank) return base + (rank * 2) end } -- Rupture Rank 2
|
|
CleveRoids.talentModifiers[8640] = { tab = 1, id = 10, talent = "Taste for Blood", modifier = function(base, rank) return base + (rank * 2) end } -- Rupture Rank 3
|
|
CleveRoids.talentModifiers[11273] = { tab = 1, id = 10, talent = "Taste for Blood", modifier = function(base, rank) return base + (rank * 2) end } -- Rupture Rank 4
|
|
CleveRoids.talentModifiers[11274] = { tab = 1, id = 10, talent = "Taste for Blood", modifier = function(base, rank) return base + (rank * 2) end } -- Rupture Rank 5
|
|
CleveRoids.talentModifiers[11275] = { tab = 1, id = 10, talent = "Taste for Blood", modifier = function(base, rank) return base + (rank * 2) end } -- Rupture Rank 6
|
|
|
|
-- Improved Gouge: Increases Gouge duration by 0.5 seconds per rank (3 ranks max)
|
|
-- Talent Position: Tab 3, Talent 3 (Subtlety tree)
|
|
-- Talent Spell IDs: 13741 (Rank 1), 13793 (Rank 2), 13792 (Rank 3)
|
|
CleveRoids.talentModifiers[1776] = { tab = 3, id = 3, talent = "Improved Gouge", modifier = function(base, rank) return base + (rank * 0.5) end } -- Gouge Rank 1
|
|
CleveRoids.talentModifiers[1777] = { tab = 3, id = 3, talent = "Improved Gouge", modifier = function(base, rank) return base + (rank * 0.5) end } -- Gouge Rank 2
|
|
CleveRoids.talentModifiers[8629] = { tab = 3, id = 3, talent = "Improved Gouge", modifier = function(base, rank) return base + (rank * 0.5) end } -- Gouge Rank 3
|
|
CleveRoids.talentModifiers[11285] = { tab = 3, id = 3, talent = "Improved Gouge", modifier = function(base, rank) return base + (rank * 0.5) end } -- Gouge Rank 4
|
|
CleveRoids.talentModifiers[11286] = { tab = 3, id = 3, talent = "Improved Gouge", modifier = function(base, rank) return base + (rank * 0.5) end } -- Gouge Rank 5
|
|
|
|
-- PRIEST talent modifiers
|
|
-- Improved Shadow Word: Pain: Increases SW:P duration by 3 seconds per rank (2 ranks max)
|
|
-- Talent Position: Tab 3 (Shadow), Talent 4
|
|
-- Talent Spell IDs: 15275 (Rank 1), 15317 (Rank 2)
|
|
CleveRoids.talentModifiers[589] = { tab = 3, id = 4, talent = "Improved Shadow Word: Pain", modifier = function(base, rank) return base + (rank * 3) end } -- SW:P Rank 1
|
|
CleveRoids.talentModifiers[594] = { tab = 3, id = 4, talent = "Improved Shadow Word: Pain", modifier = function(base, rank) return base + (rank * 3) end } -- SW:P Rank 2
|
|
CleveRoids.talentModifiers[970] = { tab = 3, id = 4, talent = "Improved Shadow Word: Pain", modifier = function(base, rank) return base + (rank * 3) end } -- SW:P Rank 3
|
|
CleveRoids.talentModifiers[992] = { tab = 3, id = 4, talent = "Improved Shadow Word: Pain", modifier = function(base, rank) return base + (rank * 3) end } -- SW:P Rank 4
|
|
CleveRoids.talentModifiers[2767] = { tab = 3, id = 4, talent = "Improved Shadow Word: Pain", modifier = function(base, rank) return base + (rank * 3) end } -- SW:P Rank 5
|
|
CleveRoids.talentModifiers[10892] = { tab = 3, id = 4, talent = "Improved Shadow Word: Pain", modifier = function(base, rank) return base + (rank * 3) end } -- SW:P Rank 6
|
|
CleveRoids.talentModifiers[10893] = { tab = 3, id = 4, talent = "Improved Shadow Word: Pain", modifier = function(base, rank) return base + (rank * 3) end } -- SW:P Rank 7
|
|
CleveRoids.talentModifiers[10894] = { tab = 3, id = 4, talent = "Improved Shadow Word: Pain", modifier = function(base, rank) return base + (rank * 3) end } -- SW:P Rank 8
|
|
|
|
-- DRUID talent modifiers
|
|
-- Brutal Impact: Increases Bash and Pounce stun duration by 0.5 seconds per rank (2 ranks max)
|
|
-- Talent Position: Tab 2 (Feral Combat), Talent 4
|
|
-- Talent Spell IDs: 16940 (Rank 1), 16941 (Rank 2)
|
|
CleveRoids.talentModifiers[5211] = { tab = 2, id = 4, talent = "Brutal Impact", modifier = function(base, rank) return base + (rank * 0.5) end } -- Bash Rank 1
|
|
CleveRoids.talentModifiers[6798] = { tab = 2, id = 4, talent = "Brutal Impact", modifier = function(base, rank) return base + (rank * 0.5) end } -- Bash Rank 2
|
|
CleveRoids.talentModifiers[8983] = { tab = 2, id = 4, talent = "Brutal Impact", modifier = function(base, rank) return base + (rank * 0.5) end } -- Bash Rank 3
|
|
|
|
-- NOTE: Brutal Impact affects the STUN portion of Pounce (cast spell IDs 9005, 9823, 9827)
|
|
-- The BLEED portion (triggered spell IDs 9007, 9824, 9826) is NOT affected by Brutal Impact
|
|
-- We track the bleed for immunity detection, not the stun, so no talent modifiers needed here
|
|
|
|
-- NOTE: Carnage talent (Tab 2, ID 17) is NOT a duration modifier!
|
|
-- Carnage is a refresh mechanic: When Ferocious Bite procs Carnage, it refreshes Rip/Rake to original duration
|
|
-- Proc detection: Combo points stay at 1 after FB instead of dropping to 0
|
|
-- This is handled in ComboPointTracker.lua via PLAYER_COMBO_POINTS event
|
|
|
|
-- WARRIOR talent modifiers
|
|
-- Booming Voice: Increases duration of Battle Shout and Demoralizing Shout by 12% per rank (5 ranks max)
|
|
-- Talent Position: Tab 2 (Fury), Talent 1
|
|
-- Talent Spell IDs: 12321 (Rank 1), 12835 (Rank 2), 12836 (Rank 3), 12837 (Rank 4), 12838 (Rank 5)
|
|
local boomingVoiceModifier = function(base, rank) return base * (1 + rank * 0.12) end -- 12% per rank, 60% at rank 5
|
|
CleveRoids.talentModifiers[1160] = { tab = 2, id = 1, talent = "Booming Voice", modifier = boomingVoiceModifier } -- Demoralizing Shout Rank 1
|
|
CleveRoids.talentModifiers[6190] = { tab = 2, id = 1, talent = "Booming Voice", modifier = boomingVoiceModifier } -- Demoralizing Shout Rank 2
|
|
CleveRoids.talentModifiers[11554] = { tab = 2, id = 1, talent = "Booming Voice", modifier = boomingVoiceModifier } -- Demoralizing Shout Rank 3
|
|
CleveRoids.talentModifiers[11555] = { tab = 2, id = 1, talent = "Booming Voice", modifier = boomingVoiceModifier } -- Demoralizing Shout Rank 4
|
|
CleveRoids.talentModifiers[11556] = { tab = 2, id = 1, talent = "Booming Voice", modifier = boomingVoiceModifier } -- Demoralizing Shout Rank 5
|
|
|
|
-- Function to get talent rank
|
|
-- Supports both position-based (tab, id) and name-based lookup
|
|
-- Position-based is preferred as it's more reliable and locale-independent (like Cursive addon)
|
|
-- Parameters: talentName OR (tab, id)
|
|
-- Returns: Talent rank (0 if not found or not learned)
|
|
function CleveRoids.GetTalentRank(talentName, tab, id)
|
|
-- Method 1: Direct position lookup (preferred, like Cursive)
|
|
if tab and id then
|
|
local _, _, _, _, rank = GetTalentInfo(tab, id)
|
|
return tonumber(rank) or 0
|
|
end
|
|
|
|
-- Method 2: Name-based lookup (fallback for backwards compatibility)
|
|
if not talentName then return 0 end
|
|
|
|
-- Ensure talents are indexed
|
|
if not CleveRoids.Talents or table.getn(CleveRoids.Talents) == 0 then
|
|
if CleveRoids.IndexTalents then
|
|
CleveRoids.IndexTalents()
|
|
end
|
|
end
|
|
|
|
local rank = CleveRoids.Talents[talentName]
|
|
return (rank and tonumber(rank)) or 0
|
|
end
|
|
|
|
-- Apply talent modifiers to a debuff duration
|
|
-- Parameters:
|
|
-- spellID: The spell ID
|
|
-- baseDuration: The base duration (after combo points if applicable)
|
|
-- Returns: Modified duration, or baseDuration if no talent modifier applies
|
|
function CleveRoids.ApplyTalentModifier(spellID, baseDuration)
|
|
if not spellID or not baseDuration then
|
|
return baseDuration
|
|
end
|
|
|
|
local modifier = CleveRoids.talentModifiers[spellID]
|
|
if not modifier then
|
|
return baseDuration
|
|
end
|
|
|
|
local talentRank = 0
|
|
local lookupMethod = "none"
|
|
|
|
-- Method 1: Position-based lookup (preferred, locale-independent)
|
|
if modifier.tab and modifier.id then
|
|
local _, _, _, _, rank = GetTalentInfo(modifier.tab, modifier.id)
|
|
talentRank = tonumber(rank) or 0
|
|
if talentRank > 0 then
|
|
lookupMethod = "position"
|
|
end
|
|
end
|
|
|
|
-- Method 2: Name-based lookup (fallback for backwards compatibility)
|
|
if talentRank == 0 and modifier.talent then
|
|
talentRank = CleveRoids.GetTalentRank(modifier.talent)
|
|
if talentRank > 0 then
|
|
lookupMethod = "name"
|
|
end
|
|
end
|
|
|
|
if talentRank == 0 then
|
|
return baseDuration
|
|
end
|
|
|
|
local modifiedDuration = modifier.modifier(baseDuration, talentRank)
|
|
|
|
if CleveRoids.debug then
|
|
local talentName = modifier.talent or ("Tab " .. modifier.tab .. " ID " .. modifier.id)
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff00ff[Talent Modifier]|r %s (ID:%d): %ds -> %ds (talent: %s rank %d, %s)",
|
|
C_Spell.GetSpellName(spellID) or "Unknown", spellID, baseDuration, modifiedDuration,
|
|
talentName, talentRank, lookupMethod)
|
|
)
|
|
end
|
|
|
|
return modifiedDuration
|
|
end
|
|
|
|
-- Helper function to register a talent modifier
|
|
-- Usage: CleveRoids.RegisterTalentModifier(spellID, talentName, modifierFunction)
|
|
function CleveRoids.RegisterTalentModifier(spellID, talentName, modifierFunc)
|
|
if not spellID or not talentName or not modifierFunc then
|
|
return false
|
|
end
|
|
|
|
CleveRoids.talentModifiers[spellID] = {
|
|
talent = talentName,
|
|
modifier = modifierFunc
|
|
}
|
|
|
|
return true
|
|
end
|
|
|
|
-- Diagnostic function to check talent modifier system
|
|
-- Usage: CleveRoids.DiagnoseTalentModifier(spellID, baseDuration)
|
|
function CleveRoids.DiagnoseTalentModifier(spellID, baseDuration)
|
|
baseDuration = baseDuration or 10 -- Default test duration
|
|
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00=== Talent Modifier Diagnostic ===|r")
|
|
|
|
-- Check if spellID is valid
|
|
local spellName = C_Spell.GetSpellName(spellID)
|
|
if not spellName then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cffff0000ERROR: Invalid spell ID " .. tostring(spellID) .. "|r")
|
|
return
|
|
end
|
|
DEFAULT_CHAT_FRAME:AddMessage("Spell: " .. spellName .. " (ID: " .. spellID .. ")")
|
|
|
|
-- Check if talents are indexed
|
|
if not CleveRoids.Talents then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cffff0000ERROR: Talents table doesn't exist!|r")
|
|
DEFAULT_CHAT_FRAME:AddMessage("Attempting to index talents...")
|
|
if CleveRoids.IndexTalents then
|
|
CleveRoids.IndexTalents()
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00Talents indexed.|r")
|
|
else
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cffff0000ERROR: IndexTalents function not found!|r")
|
|
return
|
|
end
|
|
end
|
|
|
|
local talentCount = 0
|
|
for k, v in pairs(CleveRoids.Talents) do
|
|
if type(k) == "string" and type(v) == "number" and v > 0 then
|
|
talentCount = talentCount + 1
|
|
end
|
|
end
|
|
DEFAULT_CHAT_FRAME:AddMessage("Talents indexed: " .. talentCount .. " talents found")
|
|
|
|
-- Check if modifier is registered
|
|
local modifier = CleveRoids.talentModifiers[spellID]
|
|
if not modifier then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cffff0000No talent modifier registered for this spell|r")
|
|
return
|
|
end
|
|
|
|
local talentDesc = modifier.talent or ("Tab " .. tostring(modifier.tab) .. " ID " .. tostring(modifier.id))
|
|
DEFAULT_CHAT_FRAME:AddMessage("Modifier registered: " .. talentDesc)
|
|
|
|
-- Check talent rank using BOTH methods
|
|
local talentRank = 0
|
|
local lookupMethod = "none"
|
|
|
|
if modifier.tab and modifier.id then
|
|
-- Position-based lookup (preferred)
|
|
local _, name, _, _, rank = GetTalentInfo(modifier.tab, modifier.id)
|
|
talentRank = tonumber(rank) or 0
|
|
lookupMethod = "position"
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00Position lookup (Tab " .. modifier.tab .. ", ID " .. modifier.id .. "):|r")
|
|
DEFAULT_CHAT_FRAME:AddMessage(" Talent name: " .. tostring(name))
|
|
DEFAULT_CHAT_FRAME:AddMessage(" Rank: " .. talentRank)
|
|
end
|
|
|
|
if modifier.talent then
|
|
-- Name-based lookup (fallback)
|
|
local nameRank = CleveRoids.GetTalentRank(modifier.talent)
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cffaaaa00Name lookup (" .. modifier.talent .. "):|r")
|
|
DEFAULT_CHAT_FRAME:AddMessage(" Rank: " .. nameRank)
|
|
|
|
-- Use name rank if position didn't work
|
|
if talentRank == 0 and nameRank > 0 then
|
|
talentRank = nameRank
|
|
lookupMethod = "name"
|
|
end
|
|
|
|
-- Check if talent exists in table
|
|
local directLookup = CleveRoids.Talents[modifier.talent]
|
|
DEFAULT_CHAT_FRAME:AddMessage(" Direct table lookup: " .. tostring(directLookup))
|
|
end
|
|
|
|
DEFAULT_CHAT_FRAME:AddMessage("Final rank: " .. talentRank .. " (via " .. lookupMethod .. ")")
|
|
|
|
if talentRank == 0 then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cffff0000You don't have " .. talentDesc .. "!|r")
|
|
-- Show available talents for debugging
|
|
DEFAULT_CHAT_FRAME:AddMessage("Available talents with ranks:")
|
|
local shown = 0
|
|
for name, rank in pairs(CleveRoids.Talents) do
|
|
if type(name) == "string" and type(rank) == "number" and rank > 0 then
|
|
DEFAULT_CHAT_FRAME:AddMessage(" - " .. name .. ": " .. rank)
|
|
shown = shown + 1
|
|
if shown >= 10 then
|
|
DEFAULT_CHAT_FRAME:AddMessage(" ... (showing first 10)")
|
|
break
|
|
end
|
|
end
|
|
end
|
|
else
|
|
-- Test the modifier
|
|
local modifiedDuration = modifier.modifier(baseDuration, talentRank)
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00Test calculation:|r")
|
|
DEFAULT_CHAT_FRAME:AddMessage(" Base: " .. baseDuration .. "s")
|
|
DEFAULT_CHAT_FRAME:AddMessage(" Modified: " .. modifiedDuration .. "s")
|
|
DEFAULT_CHAT_FRAME:AddMessage(" Bonus: +" .. (modifiedDuration - baseDuration) .. "s")
|
|
|
|
-- Test actual application
|
|
local applied = CleveRoids.ApplyTalentModifier(spellID, baseDuration)
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00Via ApplyTalentModifier:|r " .. applied .. "s")
|
|
end
|
|
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00=== End Diagnostic ===|r")
|
|
end
|
|
|
|
-- EQUIPMENT MODIFIER SYSTEM
|
|
|
|
-- Database of equipment modifiers for debuff durations
|
|
-- Structure: [spellID] = { slot = invSlotID, items = { [itemID] = modifierFunction } }
|
|
CleveRoids.equipmentModifiers = CleveRoids.equipmentModifiers or {}
|
|
|
|
-- DRUID equipment modifiers
|
|
-- Idol of Savagery (Black Morass Idol): Reduces Rip and Rake duration by 10% (multiplicative)
|
|
-- Item ID: 61699, Slot: 18 (Ranged/Relic)
|
|
local ripRakeIdolModifier = function(duration, itemID)
|
|
if itemID == 61699 then
|
|
-- Idol of Savagery: 10% reduction (multiply by 0.9)
|
|
return duration * 0.9
|
|
end
|
|
return duration
|
|
end
|
|
|
|
-- Rip (all ranks)
|
|
CleveRoids.equipmentModifiers[1079] = { slot = 18, modifier = ripRakeIdolModifier } -- Rip Rank 1
|
|
CleveRoids.equipmentModifiers[9492] = { slot = 18, modifier = ripRakeIdolModifier } -- Rip Rank 2
|
|
CleveRoids.equipmentModifiers[9493] = { slot = 18, modifier = ripRakeIdolModifier } -- Rip Rank 3
|
|
CleveRoids.equipmentModifiers[9752] = { slot = 18, modifier = ripRakeIdolModifier } -- Rip Rank 4
|
|
CleveRoids.equipmentModifiers[9894] = { slot = 18, modifier = ripRakeIdolModifier } -- Rip Rank 5
|
|
CleveRoids.equipmentModifiers[9896] = { slot = 18, modifier = ripRakeIdolModifier } -- Rip Rank 6
|
|
|
|
-- Rake (all ranks)
|
|
CleveRoids.equipmentModifiers[1822] = { slot = 18, modifier = ripRakeIdolModifier } -- Rake Rank 1
|
|
CleveRoids.equipmentModifiers[1823] = { slot = 18, modifier = ripRakeIdolModifier } -- Rake Rank 2
|
|
CleveRoids.equipmentModifiers[1824] = { slot = 18, modifier = ripRakeIdolModifier } -- Rake Rank 3
|
|
CleveRoids.equipmentModifiers[9904] = { slot = 18, modifier = ripRakeIdolModifier } -- Rake Rank 4
|
|
|
|
-- Function to get equipped item ID in a specific slot
|
|
function CleveRoids.GetEquippedItemID(slotID)
|
|
return CleveRoids.ClassicAPI.GetInventoryItemID("player", slotID)
|
|
end
|
|
|
|
-- Apply equipment modifiers to a debuff duration
|
|
-- Parameters:
|
|
-- spellID: The spell ID
|
|
-- baseDuration: The base duration (after combo points and talents)
|
|
-- Returns: Modified duration, or baseDuration if no equipment modifier applies
|
|
function CleveRoids.ApplyEquipmentModifier(spellID, baseDuration)
|
|
if not spellID or not baseDuration then
|
|
return baseDuration
|
|
end
|
|
|
|
local modifier = CleveRoids.equipmentModifiers[spellID]
|
|
if not modifier then
|
|
return baseDuration
|
|
end
|
|
|
|
local itemID = CleveRoids.GetEquippedItemID(modifier.slot)
|
|
if not itemID then
|
|
return baseDuration
|
|
end
|
|
|
|
local modifiedDuration = modifier.modifier(baseDuration, itemID)
|
|
|
|
if modifiedDuration ~= baseDuration and CleveRoids.debug then
|
|
local itemName = C_Item.GetItemName({ equipmentSlotIndex = modifier.slot }) or "Unknown"
|
|
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff00ff[Equipment Modifier]|r %s (ID:%d): %ds -> %ds (item: %s [%d])",
|
|
C_Spell.GetSpellName(spellID) or "Unknown", spellID, baseDuration, modifiedDuration,
|
|
itemName, itemID)
|
|
)
|
|
end
|
|
|
|
return modifiedDuration
|
|
end
|
|
|
|
-- Helper function to register an equipment modifier
|
|
-- Usage: CleveRoids.RegisterEquipmentModifier(spellID, slotID, modifierFunction)
|
|
function CleveRoids.RegisterEquipmentModifier(spellID, slotID, modifierFunc)
|
|
if not spellID or not slotID or not modifierFunc then
|
|
return false
|
|
end
|
|
|
|
CleveRoids.equipmentModifiers[spellID] = {
|
|
slot = slotID,
|
|
modifier = modifierFunc
|
|
}
|
|
|
|
return true
|
|
end
|
|
|
|
-- NAMPOWER SPELL MODIFIERS INTEGRATION (v2.18+)
|
|
|
|
-- Try to get duration modifiers from Nampower's GetSpellModifiers
|
|
-- This function supplements (not replaces) the talent/equipment modifier system
|
|
-- Returns: Modified duration, or nil if no Nampower modifier applies
|
|
-- Parameters:
|
|
-- spellID: The spell ID
|
|
-- baseDuration: The base duration before modifiers
|
|
function CleveRoids.ApplyNampowerDurationModifier(spellID, baseDuration)
|
|
if not spellID or not baseDuration then
|
|
return nil
|
|
end
|
|
|
|
-- Check if Nampower API is available with GetSpellModifiers
|
|
local API = CleveRoids.NampowerAPI
|
|
if not API or not GetSpellModifiers then
|
|
return nil
|
|
end
|
|
|
|
-- MODIFIER_DURATION = 1
|
|
local flat, percent, hasModifier = GetSpellModifiers(spellID, 1)
|
|
|
|
if not hasModifier or (flat == 0 and percent == 0) then
|
|
return nil
|
|
end
|
|
|
|
-- Apply flat modifier first, then percentage
|
|
-- Nampower returns percent as final multiplier (90 = 90% of original, not -10% change)
|
|
local modified = baseDuration + (flat or 0)
|
|
if percent and percent ~= 0 then
|
|
modified = modified * (percent / 100)
|
|
end
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff00ff[Nampower Duration Modifier]|r %s (ID:%d): %ds -> %ds (flat: %+d, percent: %d%%)",
|
|
C_Spell.GetSpellName(spellID) or "Unknown", spellID, baseDuration, modified,
|
|
flat or 0, percent or 0)
|
|
)
|
|
end
|
|
|
|
return modified
|
|
end
|
|
|
|
-- Comprehensive duration modifier function that combines all sources
|
|
-- Logic:
|
|
-- 1. If Nampower GetSpellModifiers available: use it exclusively (includes talents, buffs, equipment)
|
|
-- 2. Otherwise fallback: apply manual talent modifiers, then equipment modifiers
|
|
-- 3. Always apply set bonus modifiers last
|
|
-- Parameters:
|
|
-- spellID: The spell ID
|
|
-- baseDuration: The base duration (after combo points if applicable)
|
|
-- Returns: Fully modified duration
|
|
function CleveRoids.ApplyAllDurationModifiers(spellID, baseDuration)
|
|
if not spellID or not baseDuration then
|
|
return baseDuration
|
|
end
|
|
|
|
local duration = baseDuration
|
|
|
|
-- Try Nampower's GetSpellModifiers first (includes dynamic modifiers from buffs/talents/equipment)
|
|
local nampowerDuration = CleveRoids.ApplyNampowerDurationModifier(spellID, duration)
|
|
if nampowerDuration then
|
|
-- Nampower handled all modifiers (talents, buffs, equipment effects like Idol of Savagery)
|
|
-- Do NOT apply equipment modifiers again - Nampower already includes them
|
|
duration = nampowerDuration
|
|
else
|
|
-- Fallback: use manual talent and equipment modifiers when Nampower unavailable
|
|
if CleveRoids.ApplyTalentModifier then
|
|
duration = CleveRoids.ApplyTalentModifier(spellID, duration)
|
|
end
|
|
if CleveRoids.ApplyEquipmentModifier then
|
|
duration = CleveRoids.ApplyEquipmentModifier(spellID, duration)
|
|
end
|
|
end
|
|
|
|
-- Always apply set bonus modifiers last
|
|
if CleveRoids.ApplySetBonusModifier then
|
|
duration = CleveRoids.ApplySetBonusModifier(spellID, duration)
|
|
end
|
|
|
|
return duration
|
|
end
|
|
|
|
-- SET BONUS MODIFIER SYSTEM
|
|
|
|
-- Database of set bonus modifiers for debuff durations
|
|
-- Structure: [spellID] = { items = {itemID1, itemID2, ...}, threshold = X, modifier = function(baseDuration) }
|
|
-- When a setId is given, items can be omitted and resolved from ItemSet.dbc
|
|
CleveRoids.setbonusModifiers = CleveRoids.setbonusModifiers or {}
|
|
|
|
-- Cache: setId -> { itemId1, itemId2, ... } (resolved from ItemSet.dbc or hardcoded)
|
|
local setItemCache = {}
|
|
|
|
-- Resolve set items: use the ItemSet.dbc lookup when a setId is given, otherwise
|
|
-- fall back to the hardcoded list on the modifier.
|
|
local function ResolveSetItems(modifier)
|
|
-- If items are already provided (hardcoded), use them directly
|
|
if modifier.items and table.getn(modifier.items) > 0 then
|
|
return modifier.items
|
|
end
|
|
|
|
-- DBC lookup by setId via ClassicAPI
|
|
if modifier.setId then
|
|
-- Check cache first
|
|
if setItemCache[modifier.setId] then
|
|
return setItemCache[modifier.setId]
|
|
end
|
|
|
|
local info = CleveRoids.ClassicAPI.GetItemSetInfo(modifier.setId)
|
|
local items = info and info.items
|
|
if items and table.getn(items) > 0 then
|
|
setItemCache[modifier.setId] = items
|
|
return items
|
|
end
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
-- Function to count how many items from a set are currently equipped
|
|
-- Accepts either a direct items table or a modifier entry with setId for DBC lookup
|
|
function CleveRoids.CountEquippedSetItems(items)
|
|
if not items or type(items) ~= "table" then return 0 end
|
|
|
|
local count = 0
|
|
-- Check all equipment slots (1-19)
|
|
for slot = 1, 19 do
|
|
local itemID = CleveRoids.GetEquippedItemID(slot)
|
|
if itemID then
|
|
-- Check if this item is in the set
|
|
for _, setItemID in ipairs(items) do
|
|
if itemID == setItemID then
|
|
count = count + 1
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
return count
|
|
end
|
|
|
|
-- Count equipped set items by set ID, comparing each equipped slot's own set
|
|
-- membership (ClassicAPI GetItemSetIDByID) against the target set.
|
|
function CleveRoids.CountEquippedSetItemsBySetId(setId)
|
|
if not setId then return 0 end
|
|
|
|
local count = 0
|
|
for slot = 1, 19 do
|
|
local itemID = CleveRoids.GetEquippedItemID(slot)
|
|
if itemID and CleveRoids.ClassicAPI.GetItemSetIDByID(itemID) == setId then
|
|
count = count + 1
|
|
end
|
|
end
|
|
return count
|
|
end
|
|
|
|
-- Apply set bonus modifiers to a debuff duration
|
|
-- Parameters:
|
|
-- spellID: The spell ID
|
|
-- baseDuration: The base duration (after combo points, talents, and equipment)
|
|
-- Returns: Modified duration, or baseDuration if no set bonus modifier applies
|
|
function CleveRoids.ApplySetBonusModifier(spellID, baseDuration)
|
|
if not spellID or not baseDuration then
|
|
return baseDuration
|
|
end
|
|
|
|
local modifier = CleveRoids.setbonusModifiers[spellID]
|
|
if not modifier then
|
|
return baseDuration
|
|
end
|
|
|
|
-- Count equipped pieces: use setId for fast Nampower lookup, or resolve item list
|
|
local equippedCount
|
|
if modifier.setId then
|
|
equippedCount = CleveRoids.CountEquippedSetItemsBySetId(modifier.setId)
|
|
else
|
|
local items = ResolveSetItems(modifier)
|
|
if not items then return baseDuration end
|
|
equippedCount = CleveRoids.CountEquippedSetItems(items)
|
|
end
|
|
|
|
if equippedCount < modifier.threshold then
|
|
return baseDuration
|
|
end
|
|
|
|
local modifiedDuration = modifier.modifier(baseDuration)
|
|
|
|
if modifiedDuration ~= baseDuration and CleveRoids.debug then
|
|
local spellName = C_Spell.GetSpellName(spellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00ffff[Set Bonus Modifier]|r %s (ID:%d): %.1fs -> %.1fs (%d/%d pieces)",
|
|
spellName, spellID, baseDuration, modifiedDuration,
|
|
equippedCount, modifier.threshold)
|
|
)
|
|
end
|
|
|
|
return modifiedDuration
|
|
end
|
|
|
|
-- Helper function to register a set bonus modifier
|
|
-- Usage: CleveRoids.RegisterSetBonusModifier(spellID, itemsTable, threshold, modifierFunction)
|
|
-- With a setId: CleveRoids.RegisterSetBonusModifier(spellID, nil, threshold, modifierFunction, setId)
|
|
function CleveRoids.RegisterSetBonusModifier(spellID, items, threshold, modifierFunc, setId)
|
|
if not spellID or not threshold or not modifierFunc then
|
|
return false
|
|
end
|
|
if not items and not setId then
|
|
return false
|
|
end
|
|
|
|
CleveRoids.setbonusModifiers[spellID] = {
|
|
items = items,
|
|
setId = setId,
|
|
threshold = threshold,
|
|
modifier = modifierFunc
|
|
}
|
|
|
|
return true
|
|
end
|
|
|
|
-- Get info about an equipped item's set (from ItemSet.dbc via ClassicAPI)
|
|
-- Returns: setName, setId, equippedCount, totalPieces, items or nil
|
|
function CleveRoids.GetEquippedItemSetInfo(itemId)
|
|
if not itemId then return nil end
|
|
|
|
local setId = CleveRoids.ClassicAPI.GetItemSetIDByID(itemId)
|
|
if not setId then return nil end
|
|
|
|
local info = CleveRoids.ClassicAPI.GetItemSetInfo(setId)
|
|
local setName = info and info.name or nil
|
|
local items = info and info.items or nil
|
|
local totalPieces = items and table.getn(items) or 0
|
|
local equippedCount = CleveRoids.CountEquippedSetItemsBySetId(setId)
|
|
|
|
return setName, setId, equippedCount, totalPieces, items
|
|
end
|
|
|
|
-- Count equipped pieces of a named or ID-referenced item set
|
|
-- nameOrId: set name (string, e.g. "Judgement Battlegear") or set ID (number)
|
|
-- Returns: equipped count (number), setId (number or nil)
|
|
-- Both name and ID lookups resolve from ItemSet.dbc via ClassicAPI
|
|
function CleveRoids.GetEquippedSetPieceCount(nameOrId)
|
|
if not nameOrId then return 0, nil end
|
|
|
|
-- Numeric: treat as set ID directly
|
|
local numericId = tonumber(nameOrId)
|
|
if numericId then
|
|
return CleveRoids.CountEquippedSetItemsBySetId(numericId), numericId
|
|
end
|
|
|
|
-- String: scan equipped items and match by set name from ItemSet.dbc
|
|
local lowerName = string.lower(nameOrId)
|
|
|
|
for slot = 1, 19 do
|
|
local itemID = CleveRoids.GetEquippedItemID(slot)
|
|
if itemID then
|
|
local setId = CleveRoids.ClassicAPI.GetItemSetIDByID(itemID)
|
|
if setId then
|
|
local info = CleveRoids.ClassicAPI.GetItemSetInfo(setId)
|
|
if info and info.name and string.lower(info.name) == lowerName then
|
|
return CleveRoids.CountEquippedSetItemsBySetId(setId), setId
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
return 0, nil
|
|
end
|
|
|
|
-- DRUID set bonus modifiers
|
|
-- Dreamwalker Regalia (4/9): Increases Moonfire duration by 3 seconds and Insect Swarm by 2 seconds
|
|
-- setId 536 = Dreamwalker Regalia; hardcoded items as fallback when DBC lookup unavailable
|
|
local DREAMWALKER_SET_ID = 536
|
|
local dreamwalkerItems = { 47372, 47373, 47374, 47375, 47376, 47377, 47378, 47379, 47380 }
|
|
local dreamwalkerMoonfireModifier = function(base) return base + 3 end
|
|
local dreamwalkerInsectSwarmModifier = function(base) return base + 2 end
|
|
|
|
-- Moonfire (all ranks)
|
|
CleveRoids.setbonusModifiers[8921] = { setId = DREAMWALKER_SET_ID, items = dreamwalkerItems, threshold = 4, modifier = dreamwalkerMoonfireModifier } -- Moonfire Rank 1
|
|
CleveRoids.setbonusModifiers[8924] = { setId = DREAMWALKER_SET_ID, items = dreamwalkerItems, threshold = 4, modifier = dreamwalkerMoonfireModifier } -- Moonfire Rank 2
|
|
CleveRoids.setbonusModifiers[8925] = { setId = DREAMWALKER_SET_ID, items = dreamwalkerItems, threshold = 4, modifier = dreamwalkerMoonfireModifier } -- Moonfire Rank 3
|
|
CleveRoids.setbonusModifiers[8926] = { setId = DREAMWALKER_SET_ID, items = dreamwalkerItems, threshold = 4, modifier = dreamwalkerMoonfireModifier } -- Moonfire Rank 4
|
|
CleveRoids.setbonusModifiers[8927] = { setId = DREAMWALKER_SET_ID, items = dreamwalkerItems, threshold = 4, modifier = dreamwalkerMoonfireModifier } -- Moonfire Rank 5
|
|
CleveRoids.setbonusModifiers[8928] = { setId = DREAMWALKER_SET_ID, items = dreamwalkerItems, threshold = 4, modifier = dreamwalkerMoonfireModifier } -- Moonfire Rank 6
|
|
CleveRoids.setbonusModifiers[8929] = { setId = DREAMWALKER_SET_ID, items = dreamwalkerItems, threshold = 4, modifier = dreamwalkerMoonfireModifier } -- Moonfire Rank 7
|
|
CleveRoids.setbonusModifiers[9833] = { setId = DREAMWALKER_SET_ID, items = dreamwalkerItems, threshold = 4, modifier = dreamwalkerMoonfireModifier } -- Moonfire Rank 8
|
|
CleveRoids.setbonusModifiers[9834] = { setId = DREAMWALKER_SET_ID, items = dreamwalkerItems, threshold = 4, modifier = dreamwalkerMoonfireModifier } -- Moonfire Rank 9
|
|
CleveRoids.setbonusModifiers[9835] = { setId = DREAMWALKER_SET_ID, items = dreamwalkerItems, threshold = 4, modifier = dreamwalkerMoonfireModifier } -- Moonfire Rank 10
|
|
|
|
-- Insect Swarm (all ranks)
|
|
CleveRoids.setbonusModifiers[5570] = { setId = DREAMWALKER_SET_ID, items = dreamwalkerItems, threshold = 4, modifier = dreamwalkerInsectSwarmModifier } -- Insect Swarm Rank 1
|
|
CleveRoids.setbonusModifiers[24974] = { setId = DREAMWALKER_SET_ID, items = dreamwalkerItems, threshold = 4, modifier = dreamwalkerInsectSwarmModifier } -- Insect Swarm Rank 2
|
|
CleveRoids.setbonusModifiers[24975] = { setId = DREAMWALKER_SET_ID, items = dreamwalkerItems, threshold = 4, modifier = dreamwalkerInsectSwarmModifier } -- Insect Swarm Rank 3
|
|
CleveRoids.setbonusModifiers[24976] = { setId = DREAMWALKER_SET_ID, items = dreamwalkerItems, threshold = 4, modifier = dreamwalkerInsectSwarmModifier } -- Insect Swarm Rank 4
|
|
CleveRoids.setbonusModifiers[24977] = { setId = DREAMWALKER_SET_ID, items = dreamwalkerItems, threshold = 4, modifier = dreamwalkerInsectSwarmModifier } -- Insect Swarm Rank 5
|
|
|
|
-- Haruspex's Garb (3/5): Increases Faerie Fire duration by 5 seconds
|
|
-- setId 474 = Haruspex's Garb; hardcoded items as fallback when DBC lookup unavailable
|
|
local HARUSPEX_SET_ID = 474
|
|
local haruspexItems = { 19613, 19955, 19840, 19839, 19838 }
|
|
local haruspexFaerieFireModifier = function(base) return base + 5 end
|
|
|
|
-- Faerie Fire (non-feral, all ranks)
|
|
CleveRoids.setbonusModifiers[770] = { setId = HARUSPEX_SET_ID, items = haruspexItems, threshold = 3, modifier = haruspexFaerieFireModifier } -- Faerie Fire Rank 1
|
|
CleveRoids.setbonusModifiers[778] = { setId = HARUSPEX_SET_ID, items = haruspexItems, threshold = 3, modifier = haruspexFaerieFireModifier } -- Faerie Fire Rank 2
|
|
CleveRoids.setbonusModifiers[9749] = { setId = HARUSPEX_SET_ID, items = haruspexItems, threshold = 3, modifier = haruspexFaerieFireModifier } -- Faerie Fire Rank 3
|
|
CleveRoids.setbonusModifiers[9907] = { setId = HARUSPEX_SET_ID, items = haruspexItems, threshold = 3, modifier = haruspexFaerieFireModifier } -- Faerie Fire Rank 4
|
|
|
|
-- IMMUNITY TRACKING SYSTEM
|
|
-- Initialize SavedVariables for immunity tracking
|
|
CleveRoids_ImmunityData = CleveRoids_ImmunityData or {}
|
|
|
|
-- Spell school constants
|
|
local IMMUNITY_SCHOOLS = {
|
|
physical = 1,
|
|
holy = 2,
|
|
fire = 3,
|
|
nature = 4,
|
|
frost = 5,
|
|
shadow = 6,
|
|
arcane = 7,
|
|
bleed = 8,
|
|
unknown = 9, -- For spells where we can't determine the school
|
|
}
|
|
|
|
-- CC (Crowd Control) immunity types
|
|
-- These are stored with "cc_" prefix in CleveRoids_ImmunityData to avoid collision with damage schools
|
|
local CC_IMMUNITY_TYPES = {
|
|
stun = true, -- Cheap Shot, Kidney Shot, Bash, Gouge, Sap
|
|
fear = true, -- Fear, Psychic Scream, Howl of Terror
|
|
root = true, -- Entangling Roots, Frost Nova
|
|
silence = true, -- Silence, Counterspell
|
|
sleep = true, -- Hibernate, Wyvern Sting
|
|
charm = true, -- Mind Control, Seduction
|
|
polymorph = true, -- Polymorph (all variants)
|
|
banish = true, -- Banish
|
|
horror = true, -- Death Coil
|
|
disorient = true, -- Scatter Shot, Blind
|
|
snare = true, -- Hamstring, Wing Clip
|
|
}
|
|
|
|
-- Maps DBC mechanic IDs back to CC type names for immunity recording
|
|
-- Inverse of CleveRoids.CCMechanics (defined in Conditionals.lua)
|
|
local MECHANIC_TO_CC_TYPE = {
|
|
[1] = "charm",
|
|
[2] = "disorient",
|
|
[5] = "fear",
|
|
[7] = "root",
|
|
[9] = "silence",
|
|
[10] = "sleep",
|
|
[11] = "snare",
|
|
[12] = "stun",
|
|
[13] = "stun", -- freeze → stun (similar effect)
|
|
[14] = "stun", -- knockout/gouge → stun
|
|
[17] = "polymorph",
|
|
[18] = "banish",
|
|
[20] = "shackle",
|
|
[24] = "horror",
|
|
[27] = "disorient", -- daze → disorient
|
|
[30] = "stun", -- sap → stun
|
|
}
|
|
|
|
-- Spells with split damage types (initial hit vs DoT/debuff)
|
|
-- GetSpellSchool() returns the debuff school by default for these spells
|
|
-- Users can explicitly check the initial damage school with [noimmune:physical]
|
|
local SPLIT_DAMAGE_SPELLS = {
|
|
["Rake"] = { initial = "physical", debuff = "bleed" },
|
|
["Pounce"] = { initial = "physical", debuff = "bleed" },
|
|
["Garrote"] = { initial = "physical", debuff = "bleed" },
|
|
}
|
|
|
|
-- Known non-damaging spells that won't be learned via SPELL_DAMAGE_EVENT
|
|
-- These need explicit school mapping to avoid pattern matching errors
|
|
-- (e.g., "Faerie Fire" contains "fire" but is actually arcane)
|
|
--
|
|
-- Add spells here if:
|
|
-- 1. They don't deal damage (so won't trigger SPELL_DAMAGE_EVENT)
|
|
-- 2. Their name causes false positives in pattern matching
|
|
-- 3. You need accurate school detection for immunity checking
|
|
--
|
|
-- Format: ["Spell Name"] = "school" (without rank)
|
|
local KNOWN_NON_DAMAGING_SPELLS = {
|
|
-- Druid
|
|
["Faerie Fire"] = "nature",
|
|
["Faerie Fire (Feral)"] = "nature",
|
|
["Faerie Fire (Bear)"] = "nature",
|
|
["Moonfire"] = "arcane", -- Initial hit deals damage, but debuff is arcane
|
|
["Insect Swarm"] = "nature",
|
|
["Abolish Poison"] = "nature",
|
|
["Remove Curse"] = "arcane",
|
|
["Pounce Bleed"] = "bleed", -- Triggered by Pounce (9005→9007, 9823→9824, 9827→9826)
|
|
|
|
-- Mage
|
|
["Amplify Magic"] = "arcane",
|
|
["Dampen Magic"] = "arcane",
|
|
["Remove Lesser Curse"] = "arcane",
|
|
["Slow Fall"] = "arcane",
|
|
["Detect Magic"] = "arcane",
|
|
|
|
-- Priest
|
|
["Dispel Magic"] = "holy",
|
|
["Cure Disease"] = "holy",
|
|
["Abolish Disease"] = "holy",
|
|
["Power Word: Fortitude"] = "holy",
|
|
["Power Word: Shield"] = "holy",
|
|
["Divine Spirit"] = "holy",
|
|
["Fear Ward"] = "holy",
|
|
["Resurrection"] = "holy",
|
|
|
|
-- Paladin
|
|
["Cleanse"] = "holy",
|
|
["Purify"] = "holy",
|
|
["Divine Protection"] = "holy",
|
|
["Divine Shield"] = "holy",
|
|
["Blessing of Protection"] = "holy",
|
|
["Blessing of Freedom"] = "holy",
|
|
["Blessing of Sacrifice"] = "holy",
|
|
["Redemption"] = "holy",
|
|
|
|
-- Warlock
|
|
["Banish"] = "shadow",
|
|
["Curse of Weakness"] = "shadow",
|
|
["Curse of Recklessness"] = "shadow",
|
|
["Curse of Tongues"] = "shadow",
|
|
["Amplify Curse"] = "shadow",
|
|
["Death Coil"] = "shadow", -- Has damage component but often used for CC
|
|
|
|
-- Shaman
|
|
["Cure Poison"] = "nature",
|
|
["Cure Disease"] = "nature",
|
|
["Purge"] = "nature",
|
|
["Ancestral Spirit"] = "nature",
|
|
|
|
-- Hunter
|
|
["Aspect of the Hawk"] = "nature",
|
|
["Aspect of the Monkey"] = "nature",
|
|
["Aspect of the Cheetah"] = "nature",
|
|
["Aspect of the Pack"] = "nature",
|
|
["Aspect of the Wild"] = "nature",
|
|
}
|
|
|
|
-- Spell school mapping learned from Nampower damage events
|
|
-- This table maps spell IDs to their damage school (fire, frost, nature, shadow, arcane, holy, physical, bleed)
|
|
-- Persisted via SavedVariable (CleveRoids_SpellSchools) for accuracy across sessions
|
|
CleveRoids_SpellSchools = CleveRoids_SpellSchools or {}
|
|
CleveRoids.spellSchoolMapping = CleveRoids_SpellSchools -- Alias for easier access
|
|
|
|
-- School ID to name mapping (from Nampower SPELL_DAMAGE_EVENT documentation)
|
|
-- Based on WoW 1.12.1 SpellSchools enum values (NOT bitmasks)
|
|
local SCHOOL_NAMES = {
|
|
[0] = "physical", -- SPELL_SCHOOL_NORMAL (Physical/Armor)
|
|
[1] = "holy", -- SPELL_SCHOOL_HOLY
|
|
[2] = "fire", -- SPELL_SCHOOL_FIRE
|
|
[3] = "nature", -- SPELL_SCHOOL_NATURE
|
|
[4] = "frost", -- SPELL_SCHOOL_FROST
|
|
[5] = "shadow", -- SPELL_SCHOOL_SHADOW
|
|
[6] = "arcane" -- SPELL_SCHOOL_ARCANE
|
|
}
|
|
|
|
-- Convert Nampower spell school ID to school name
|
|
-- Note: In vanilla WoW 1.12.1, schools are enum values (0-6), not bitmasks
|
|
-- Multi-school spells (like Frostfire Bolt) don't exist in vanilla
|
|
local function GetSchoolNameFromID(spellSchool)
|
|
if not spellSchool then
|
|
return "physical"
|
|
end
|
|
|
|
-- Direct lookup from enum
|
|
local schoolName = SCHOOL_NAMES[spellSchool]
|
|
if schoolName then
|
|
return schoolName
|
|
end
|
|
|
|
-- Fallback for unknown school IDs
|
|
return "physical"
|
|
end
|
|
|
|
-- Track damage events to learn spell schools automatically
|
|
-- This provides the most accurate school detection without relying on tooltip parsing
|
|
--
|
|
-- Nampower SPELL_DAMAGE_EVENT parameters:
|
|
-- arg1: targetGuid (string)
|
|
-- arg2: casterGuid (string)
|
|
-- arg3: spellId (int)
|
|
-- arg4: amount (int) - damage dealt
|
|
-- arg5: mitigationStr (string) - "absorb,block,resist"
|
|
-- arg6: hitInfo (int) - bitmask flags (0x02 = crit, 0x08 = split damage, etc.)
|
|
-- arg7: spellSchool (int) - enum 0-6 (0=physical, 1=holy, 2=fire, 3=nature, 4=frost, 5=shadow, 6=arcane)
|
|
-- arg8: effectAuraStr (string) - "effect1,effect2,effect3,auraType"
|
|
local function OnSpellDamageEvent()
|
|
if not CleveRoids.hasNampower then return end
|
|
|
|
local spellId = arg3
|
|
local spellSchool = arg7
|
|
|
|
if not spellId or not spellSchool then return end
|
|
|
|
-- PERFORMANCE: Skip if already learned and finalized
|
|
local currentSchool = CleveRoids.spellSchoolMapping[spellId]
|
|
if currentSchool then
|
|
-- If already learned as anything other than physical, we're done (can't be upgraded)
|
|
if currentSchool ~= "physical" then
|
|
return
|
|
end
|
|
-- If learned as physical, continue processing - might upgrade to bleed
|
|
end
|
|
|
|
-- Convert school enum (0-6) to name
|
|
local schoolName = GetSchoolNameFromID(spellSchool)
|
|
|
|
-- Special handling for bleeds:
|
|
-- SPELL_AURA_PERIODIC_DAMAGE_PERCENT (89) indicates percentage-based DoT (bleeds)
|
|
-- These show as "physical" school but are actually bleeds (ignore armor)
|
|
local effectAuraStr = arg8
|
|
if effectAuraStr then
|
|
local _, _, _, auraType = string.find(effectAuraStr, "([^,]+),([^,]+),([^,]+),([^,]+)")
|
|
if auraType and tonumber(auraType) == 89 then
|
|
schoolName = "bleed"
|
|
end
|
|
end
|
|
|
|
-- Check if this is a known bleed spell by name (fallback) - only if physical
|
|
if schoolName == "physical" then
|
|
local spellName = C_Spell.GetSpellName(spellId)
|
|
if spellName then
|
|
local baseName = string.gsub(spellName, "%s*%(.-%)%s*$", "")
|
|
local lower = string.lower(baseName)
|
|
if string.find(lower, "rip") or string.find(lower, "rake") or string.find(lower, "rupture") or
|
|
string.find(lower, "garrote") or string.find(lower, "rend") or string.find(lower, "deep wound") or
|
|
string.find(lower, "hemorrhage") or string.find(lower, "pounce") then
|
|
schoolName = "bleed"
|
|
end
|
|
end
|
|
end
|
|
|
|
-- PERFORMANCE: Skip if the detected school matches what we already know
|
|
if currentSchool == schoolName then
|
|
return -- No change needed
|
|
end
|
|
|
|
-- Only update if not already known or if upgrading from physical to bleed
|
|
if not currentSchool or (currentSchool == "physical" and schoolName == "bleed") then
|
|
CleveRoids.spellSchoolMapping[spellId] = schoolName
|
|
|
|
if CleveRoids.debug then
|
|
local spellName = C_Spell.GetSpellName(spellId) or "Unknown"
|
|
CleveRoids.Print(string.format("|cff88ff88[School Learned]|r %s (ID:%d) = %s (raw:%d)",
|
|
spellName, spellId, schoolName, spellSchool))
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Register Nampower damage events for spell school tracking
|
|
if CleveRoids.hasNampower then
|
|
local schoolTracker = CreateFrame("Frame", "CleveRoidsSchoolTracker")
|
|
schoolTracker:RegisterEvent("SPELL_DAMAGE_EVENT_SELF")
|
|
schoolTracker:RegisterEvent("SPELL_DAMAGE_EVENT_OTHER")
|
|
schoolTracker:SetScript("OnEvent", OnSpellDamageEvent)
|
|
end
|
|
|
|
-- Cache for spell school lookups
|
|
local spellSchoolCache = {}
|
|
|
|
-- Get the damage school of a spell
|
|
-- Parameters:
|
|
-- spellName: The name of the spell (with or without rank)
|
|
-- spellID: Optional spell ID for more accurate lookups
|
|
-- Returns: School name (fire, frost, nature, shadow, arcane, holy, physical, bleed) or nil
|
|
-- Priority:
|
|
-- 0. Split damage spell table (e.g., Rake = bleed debuff)
|
|
-- 1. Learned from Nampower damage events (most accurate for bleed detection)
|
|
-- 2. Name-to-ID resolution + learned mapping
|
|
-- 3. DBC spell school via GetSpellRecField (authoritative for non-bleed spells)
|
|
-- 4. Cached lookups
|
|
-- 6. Known non-damaging spells table (e.g., Faerie Fire = arcane)
|
|
-- 7. Tooltip scanning (player's spellbook only)
|
|
-- 8. Name pattern matching (fallback, least accurate)
|
|
-- Returns nil if school cannot be determined (not cached)
|
|
-- Authoritative bleed test from Spell.dbc mechanics. A bleed carries
|
|
-- SpellMechanic 15 either at the spell level (Garrote/Rupture/Rend/Rip/Pounce/
|
|
-- Deep Wounds) OR on an effect (Rake: spell-level 0, EffectMechanic[2]=15), so
|
|
-- both must be checked -- GetSpellMechanicByID alone misses the effect-level case.
|
|
local SPELL_MECHANIC_BLEED = 15
|
|
local function IsBleedByMechanic(spellID)
|
|
if not spellID then return false end
|
|
local API = CleveRoids.ClassicAPI
|
|
if API.GetSpellMechanicByID(spellID) == SPELL_MECHANIC_BLEED then
|
|
return true
|
|
end
|
|
local em = API.GetSpellEffectMechanics(spellID)
|
|
if em then
|
|
for i = 1, 3 do
|
|
if em[i] == SPELL_MECHANIC_BLEED then return true end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function GetSpellSchool(spellName, spellID)
|
|
if not spellName and not spellID then return nil end
|
|
|
|
-- Get base name early for split damage spell check
|
|
local baseName = nil
|
|
if spellName then
|
|
baseName = string.gsub(spellName, "%s*%(.-%)%s*$", "")
|
|
elseif spellID then
|
|
local fullName = C_Spell.GetSpellName(spellID)
|
|
if fullName then
|
|
baseName = string.gsub(fullName, "%s*%(.-%)%s*$", "")
|
|
end
|
|
end
|
|
|
|
-- PRIORITY 0 (HIGHEST): Split damage spells ALWAYS use their debuff school for immunity checks
|
|
-- This prevents the initial physical hit from being learned and masking bleed immunity.
|
|
-- Example: Rake's initial hit is physical, but the DoT is bleed. On bleed-immune targets,
|
|
-- the DoT never ticks, so spellSchoolMapping stays "physical" - which would miss bleed immunity!
|
|
if baseName and SPLIT_DAMAGE_SPELLS[baseName] then
|
|
local school = SPLIT_DAMAGE_SPELLS[baseName].debuff
|
|
return school
|
|
end
|
|
|
|
-- PRIORITY 0.5: DBC bleed mechanic (authoritative, proactive). Catches bleeds
|
|
-- before any damage event is seen, incl. effect-level ones like Rake, and
|
|
-- correctly excludes non-bleeds the name patterns would false-positive (e.g.
|
|
-- Hemorrhage). Only bleed is decided here; other schools fall through.
|
|
if spellID and IsBleedByMechanic(spellID) then
|
|
return "bleed"
|
|
end
|
|
|
|
-- PRIORITY 1: Use learned school from Nampower damage events (most accurate)
|
|
if spellID and CleveRoids.spellSchoolMapping[spellID] then
|
|
return CleveRoids.spellSchoolMapping[spellID]
|
|
end
|
|
|
|
-- PRIORITY 2: If we have name but no ID, try to find ID and check mapping
|
|
if spellName and not spellID then
|
|
if CleveRoids.GetSpellIdForName then
|
|
spellID = CleveRoids.GetSpellIdForName(spellName)
|
|
if spellID and CleveRoids.spellSchoolMapping[spellID] then
|
|
return CleveRoids.spellSchoolMapping[spellID]
|
|
end
|
|
end
|
|
end
|
|
|
|
-- PRIORITY 3: DBC spell school lookup via Nampower (authoritative for non-bleed spells)
|
|
-- Note: DBC school field returns 0 (physical) for bleed spells too, so learned mapping
|
|
-- from damage events (priority 1-2) takes precedence for bleed detection
|
|
if spellID and GetSpellRecField then
|
|
local dbcSchool = GetSpellRecField(spellID, "school")
|
|
if dbcSchool and SCHOOL_NAMES[dbcSchool] then
|
|
local school = SCHOOL_NAMES[dbcSchool]
|
|
-- Don't cache physical from DBC - it might be bleed (learned later from damage events)
|
|
if school ~= "physical" then
|
|
if not baseName and spellName then
|
|
baseName = string.gsub(spellName, "%s*%(.-%)%s*$", "")
|
|
end
|
|
if baseName then
|
|
spellSchoolCache[baseName] = school
|
|
end
|
|
return school
|
|
end
|
|
end
|
|
end
|
|
|
|
-- If we only have spellID but no name, try to get name from GetSpellRecField
|
|
if spellID and not spellName then
|
|
spellName = C_Spell.GetSpellName(spellID)
|
|
end
|
|
|
|
if not spellName then return nil end
|
|
|
|
-- Remove rank information for cache consistency (may already be set from above)
|
|
if not baseName then
|
|
baseName = string.gsub(spellName, "%s*%(.-%)%s*$", "")
|
|
end
|
|
|
|
-- PRIORITY 4: Check cache
|
|
if spellSchoolCache[baseName] then
|
|
return spellSchoolCache[baseName]
|
|
end
|
|
|
|
-- PRIORITY 5: SPLIT_DAMAGE_SPELLS already checked at Priority 0
|
|
|
|
-- PRIORITY 6: Check known non-damaging spells
|
|
-- These won't be learned via damage events and need explicit mapping
|
|
if KNOWN_NON_DAMAGING_SPELLS[baseName] then
|
|
local school = KNOWN_NON_DAMAGING_SPELLS[baseName]
|
|
spellSchoolCache[baseName] = school
|
|
return school
|
|
end
|
|
|
|
-- PRIORITY 7: Try to find spell in player's spellbook and scan tooltip
|
|
local school = nil
|
|
local spell = CleveRoids.GetSpell(baseName)
|
|
|
|
if spell then
|
|
-- Create tooltip if needed
|
|
if not CleveRoidsSchoolTooltip then
|
|
CreateFrame("GameTooltip", "CleveRoidsSchoolTooltip", nil, "GameTooltipTemplate")
|
|
CleveRoidsSchoolTooltip:SetOwner(WorldFrame, "ANCHOR_NONE")
|
|
end
|
|
|
|
CleveRoidsSchoolTooltip:ClearLines()
|
|
CleveRoidsSchoolTooltip:SetSpell(spell.spellSlot, spell.bookType)
|
|
|
|
-- Scan tooltip for school keywords
|
|
for i = 1, CleveRoidsSchoolTooltip:NumLines() do
|
|
local line = _G["CleveRoidsSchoolTooltipTextLeft" .. i]
|
|
if line then
|
|
local text = string.lower(line:GetText() or "")
|
|
|
|
if string.find(text, "bleed") then
|
|
school = "bleed"
|
|
break
|
|
elseif string.find(text, "fire") or string.find(text, "flame") then
|
|
school = "fire"
|
|
break
|
|
elseif string.find(text, "frost") or
|
|
string.find(text, "^ice[^%a]") or string.find(text, "[^%a]ice[^%a]") or string.find(text, "[^%a]ice$") then
|
|
school = "frost"
|
|
break
|
|
elseif string.find(text, "nature") or string.find(text, "poison") then
|
|
school = "nature"
|
|
break
|
|
elseif string.find(text, "shadow") or string.find(text, "dark") then
|
|
school = "shadow"
|
|
break
|
|
elseif string.find(text, "arcane") then
|
|
school = "arcane"
|
|
break
|
|
elseif string.find(text, "holy") or string.find(text, "divine") then
|
|
school = "holy"
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- PRIORITY 8: Fallback pattern matching for common spell name patterns
|
|
-- NOTE: This is the lowest priority fallback - be specific to avoid false positives
|
|
-- (e.g., "Faerie Fire" should not match as "fire" school)
|
|
if not school then
|
|
local lower = string.lower(baseName)
|
|
|
|
-- Bleed effects (DoTs that ignore armor)
|
|
if string.find(lower, "rip") or string.find(lower, "rake") or string.find(lower, "rupture") or
|
|
string.find(lower, "garrote") or string.find(lower, "rend") or string.find(lower, "deep wound") or
|
|
string.find(lower, "pounce") then
|
|
-- NOTE: Hemorrhage intentionally excluded -- Spell.dbc gives it no bleed
|
|
-- mechanic (its damage is physical), so bleed-immune mobs don't resist it.
|
|
school = "bleed"
|
|
|
|
-- Arcane
|
|
elseif string.find(lower, "arcane") or string.find(lower, "polymorph") or
|
|
string.find(lower, "mana burn") then
|
|
school = "arcane"
|
|
|
|
-- Nature: Faerie Fire (check before "fire" to avoid false positive)
|
|
elseif string.find(lower, "faerie") then
|
|
school = "nature"
|
|
|
|
-- Fire (specific patterns to avoid false positives)
|
|
elseif string.find(lower, "^fire") or string.find(lower, " fire") or -- Starts with or contains " fire"
|
|
string.find(lower, "flame") or string.find(lower, "immolat") or
|
|
string.find(lower, "scorch") or string.find(lower, "pyroblast") or
|
|
string.find(lower, "ignite") or string.find(lower, "combustion") then
|
|
school = "fire"
|
|
|
|
-- Frost (use word-boundary matching for "ice" to avoid false positives
|
|
-- from spell names like "Slice and Dice" containing "ice" as substring)
|
|
elseif string.find(lower, "frost") or
|
|
string.find(lower, "^ice[^%a]") or string.find(lower, "[^%a]ice[^%a]") or string.find(lower, "[^%a]ice$") or
|
|
string.find(lower, "blizzard") or string.find(lower, "freeze") or
|
|
string.find(lower, "chill") then
|
|
school = "frost"
|
|
|
|
-- Nature
|
|
elseif string.find(lower, "nature") or string.find(lower, "poison") or
|
|
string.find(lower, "sting") or string.find(lower, "wrath") or
|
|
string.find(lower, "starfire") or string.find(lower, "thorns") then
|
|
school = "nature"
|
|
|
|
-- Shadow
|
|
elseif string.find(lower, "shadow") or string.find(lower, "curse") or
|
|
string.find(lower, "corruption") or string.find(lower, "drain") or
|
|
string.find(lower, "vampir") or string.find(lower, "affliction") then
|
|
school = "shadow"
|
|
|
|
-- Holy
|
|
elseif string.find(lower, "holy") or string.find(lower, "smite") or
|
|
string.find(lower, "exorcism") or string.find(lower, "consecrat") or
|
|
string.find(lower, "judgment") or string.find(lower, "hammer of wrath") then
|
|
school = "holy"
|
|
|
|
-- No default fallback - return nil for unknown schools
|
|
-- RecordImmunity() will classify nil as "unknown" rather than
|
|
-- incorrectly attributing physical immunity
|
|
end
|
|
end
|
|
|
|
-- Cache the result (only cache if we actually determined a school)
|
|
if school then
|
|
spellSchoolCache[baseName] = school
|
|
end
|
|
return school
|
|
end
|
|
|
|
-- Public API: Get spell school by name and/or ID
|
|
-- Usage: CleveRoids.GetSpellSchool("Fireball") or CleveRoids.GetSpellSchool(nil, 133)
|
|
CleveRoids.GetSpellSchool = GetSpellSchool
|
|
|
|
-- Public API: Get spell school by ID only (convenience wrapper)
|
|
-- Usage: CleveRoids.GetSpellSchoolByID(133)
|
|
function CleveRoids.GetSpellSchoolByID(spellID)
|
|
return GetSpellSchool(nil, spellID)
|
|
end
|
|
|
|
-- Get current buffs on a unit
|
|
local function GetUnitBuffs(unit)
|
|
local buffs = {}
|
|
|
|
for i = 1, 32 do
|
|
local texture, stacks, spellID = UnitBuff(unit, i)
|
|
if not texture then break end
|
|
|
|
if spellID then
|
|
local buffName = C_Spell.GetSpellName(spellID)
|
|
if buffName then
|
|
buffs[buffName] = true
|
|
end
|
|
end
|
|
end
|
|
|
|
return buffs
|
|
end
|
|
|
|
-- CC IMMUNITY TRACKING
|
|
|
|
-- Get CC type from a spell ID via DBC mechanic lookup (nampower, then ClassicAPI)
|
|
-- Returns: CC type name (e.g., "stun", "fear") or nil if not a CC spell
|
|
-- EffectApplyAuraName → CC type mapping (fallback when mechanics are unset)
|
|
-- Uses vanilla 1.12.1 aura type enum values
|
|
-- Note: some CC types share aura IDs (e.g., sleep uses MOD_STUN + sleep mechanic,
|
|
-- horror uses MOD_FEAR + horror mechanic). Those are caught by mechanic lookup;
|
|
-- this table handles spells where mechanics are 0 but the aura effect is CC.
|
|
local AURA_NAME_TO_CC_TYPE = {
|
|
[1] = "charm", -- SPELL_AURA_BIND_SIGHT
|
|
[2] = "charm", -- SPELL_AURA_MOD_POSSESS_PET
|
|
[5] = "polymorph", -- SPELL_AURA_MOD_CONFUSE (disorient/polymorph)
|
|
[7] = "fear", -- SPELL_AURA_MOD_FEAR (also horror with mechanic 24)
|
|
[11] = "root", -- SPELL_AURA_MOD_ROOT
|
|
[12] = "stun", -- SPELL_AURA_MOD_STUN (also sleep/sap/banish with their mechanics)
|
|
[27] = "silence", -- SPELL_AURA_MOD_SILENCE
|
|
[31] = "charm", -- SPELL_AURA_MOD_POSSESS
|
|
[33] = "snare", -- SPELL_AURA_MOD_DECREASE_SPEED
|
|
}
|
|
|
|
local function GetSpellCCType(spellID)
|
|
if not spellID or spellID <= 0 then return nil end
|
|
|
|
-- Priority 1: DBC mechanic lookup (authoritative, covers all spells)
|
|
if GetSpellRecField then
|
|
-- Check spell-level mechanic first
|
|
local mechanic = GetSpellRecField(spellID, "mechanic")
|
|
if mechanic and mechanic > 0 and MECHANIC_TO_CC_TYPE[mechanic] then
|
|
return MECHANIC_TO_CC_TYPE[mechanic]
|
|
end
|
|
|
|
-- Check per-effect mechanics (array of 3 effects)
|
|
local effectMechanics = GetSpellRecField(spellID, "effectMechanic")
|
|
if effectMechanics then
|
|
for i = 1, 3 do
|
|
local em = effectMechanics[i]
|
|
if em and em > 0 and MECHANIC_TO_CC_TYPE[em] then
|
|
return MECHANIC_TO_CC_TYPE[em]
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Priority 1b: effectApplyAuraName fallback (catches spells with CC aura
|
|
-- effects but no mechanic set, e.g., Repentance applying MOD_STUN)
|
|
local auraNames = GetSpellRecField(spellID, "effectApplyAuraName")
|
|
if auraNames then
|
|
for i = 1, 3 do
|
|
local an = auraNames[i]
|
|
if an and an > 0 and AURA_NAME_TO_CC_TYPE[an] then
|
|
return AURA_NAME_TO_CC_TYPE[an]
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Priority 2: ClassicAPI Spell.dbc reader (always present; covers every spell).
|
|
-- A subset of Priority 1's per-effect/aura checks, used when nampower's
|
|
-- GetSpellRecField is unavailable.
|
|
local mechanic = CleveRoids.ClassicAPI.GetSpellMechanicByID(spellID)
|
|
if mechanic and mechanic > 0 and MECHANIC_TO_CC_TYPE[mechanic] then
|
|
return MECHANIC_TO_CC_TYPE[mechanic]
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
-- Expose publicly for use by other modules
|
|
CleveRoids.GetSpellCCType = GetSpellCCType
|
|
|
|
-- Record a CC immunity (permanent or buff-based)
|
|
-- Parameters:
|
|
-- npcName: Name of the NPC that is immune
|
|
-- ccType: CC type name (e.g., "stun", "fear", "root")
|
|
-- conditionalBuff: Optional buff name required for the immunity
|
|
-- spellName: Optional spell name for debug output
|
|
local function RecordCCImmunity(npcName, ccType, conditionalBuff, spellName)
|
|
if not npcName or not ccType or npcName == "" then
|
|
return
|
|
end
|
|
|
|
-- Validate CC type
|
|
if not CC_IMMUNITY_TYPES[ccType] then
|
|
if CleveRoids.debug then
|
|
CleveRoids.Print("|cffff0000[CC Immunity]|r Unknown CC type: " .. tostring(ccType))
|
|
end
|
|
return
|
|
end
|
|
|
|
-- Store with "cc_" prefix to avoid collision with damage schools
|
|
local key = "cc_" .. ccType
|
|
|
|
-- Initialize CC type table
|
|
if not CleveRoids_ImmunityData[key] then
|
|
CleveRoids_ImmunityData[key] = {}
|
|
end
|
|
|
|
-- Don't overwrite existing immunity with same data
|
|
local existing = CleveRoids_ImmunityData[key][npcName]
|
|
|
|
-- Record immunity
|
|
if conditionalBuff then
|
|
-- Buff-based immunity
|
|
local immunityData = { buff = conditionalBuff }
|
|
if existing and type(existing) == "table" and existing.buff == conditionalBuff then
|
|
return -- Already recorded
|
|
end
|
|
CleveRoids_ImmunityData[key][npcName] = immunityData
|
|
|
|
if CleveRoids.debug then
|
|
local spellInfo = spellName and (" (" .. spellName .. ")") or ""
|
|
CleveRoids.Print("|cff00ff00[CC Immunity]|r " .. npcName .. " is immune to " .. ccType .. spellInfo .. " when buffed with: " .. conditionalBuff)
|
|
end
|
|
else
|
|
-- Permanent immunity
|
|
if existing == true then
|
|
return -- Already recorded
|
|
end
|
|
CleveRoids_ImmunityData[key][npcName] = true
|
|
|
|
if CleveRoids.debug then
|
|
local spellInfo = spellName and (" (" .. spellName .. ")") or ""
|
|
CleveRoids.Print("|cff00ff00[CC Immunity]|r " .. npcName .. " is permanently immune to " .. ccType .. spellInfo)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Expose publicly
|
|
CleveRoids.RecordCCImmunity = RecordCCImmunity
|
|
|
|
-- Remove a CC immunity record when a spell successfully lands
|
|
-- This is called when we verify that a CC effect actually landed on a previously-immune NPC
|
|
-- Parameters:
|
|
-- npcName: Name of the NPC
|
|
-- ccType: CC type name (e.g., "stun", "fear", "root")
|
|
local function RemoveCCImmunity(npcName, ccType)
|
|
if not npcName or not ccType or npcName == "" then
|
|
return
|
|
end
|
|
|
|
local key = "cc_" .. ccType
|
|
|
|
if CleveRoids_ImmunityData[key] and CleveRoids_ImmunityData[key][npcName] then
|
|
CleveRoids_ImmunityData[key][npcName] = nil
|
|
|
|
if CleveRoids.debug then
|
|
CleveRoids.Print("|cff00aaff[CC Immunity Removed]|r " .. npcName .. " is no longer immune to " .. ccType .. " (spell landed successfully)")
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Expose publicly
|
|
CleveRoids.RemoveCCImmunity = RemoveCCImmunity
|
|
|
|
-- Check if a unit is immune to a specific CC type
|
|
-- Parameters:
|
|
-- unitId: WoW unit ID (e.g., "target", "focus")
|
|
-- ccType: CC type name (e.g., "stun", "fear")
|
|
-- Returns: true if immune, false otherwise
|
|
local function CheckCCImmunity(unitId, ccType)
|
|
if not unitId or not UnitExists(unitId) then
|
|
return false
|
|
end
|
|
|
|
-- CC immunity only tracked for NPCs
|
|
if UnitIsPlayer(unitId) then
|
|
return false
|
|
end
|
|
|
|
local targetName = UnitName(unitId)
|
|
-- Fallback to GUID->name cache if UnitName fails
|
|
-- This happens when multiscan passes a GUID that isn't the current target
|
|
if not targetName or targetName == "" or targetName == "Unknown" then
|
|
local normalizedGuid = CleveRoids.NormalizeGUID(unitId)
|
|
if normalizedGuid and lib and lib.guidToName then
|
|
targetName = lib.guidToName[normalizedGuid]
|
|
end
|
|
end
|
|
if not targetName or targetName == "" then
|
|
return false
|
|
end
|
|
|
|
-- Look up CC immunity with "cc_" prefix
|
|
local key = "cc_" .. ccType
|
|
local immunityData = CleveRoids_ImmunityData[key] and CleveRoids_ImmunityData[key][targetName]
|
|
|
|
if not immunityData then
|
|
return false
|
|
end
|
|
|
|
if immunityData == true then
|
|
-- Permanent immunity
|
|
return true
|
|
elseif type(immunityData) == "table" and immunityData.buff then
|
|
-- Buff-based immunity: check if the buff is currently active
|
|
local buffs = GetUnitBuffs(unitId)
|
|
return buffs[immunityData.buff] == true
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
-- Expose publicly
|
|
CleveRoids.CheckCCImmunity = CheckCCImmunity
|
|
|
|
-- DAMAGE SCHOOL IMMUNITY RECORDING
|
|
|
|
-- Record an immunity (permanent or buff-based)
|
|
-- Parameters:
|
|
-- npcName: Name of the NPC that is immune
|
|
-- spellName: Name of the spell that was resisted/immune
|
|
-- conditionalBuff: Optional buff name required for the immunity
|
|
-- spellID: Optional spell ID for more accurate school detection
|
|
local function RecordImmunity(npcName, spellName, conditionalBuff, spellID)
|
|
if not npcName or (not spellName and not spellID) or npcName == "" then
|
|
return
|
|
end
|
|
|
|
-- Determine school: DBC lookup by spellID is authoritative for immunity recording.
|
|
-- Unlike GetSpellSchool() (designed for debuff tracking, skips physical to detect bleed),
|
|
-- immunity recording needs the actual DBC school — physical immunity IS a real thing.
|
|
local school = nil
|
|
|
|
-- SPLIT DAMAGE SPELLS: Use initial hit school, not the debuff school.
|
|
-- e.g., Pounce stun-immune doesn't mean bleed-immune.
|
|
if spellName then
|
|
local baseName = string.gsub(spellName, "%s*%(.-%)%s*$", "")
|
|
if SPLIT_DAMAGE_SPELLS[baseName] then
|
|
school = SPLIT_DAMAGE_SPELLS[baseName].initial
|
|
if CleveRoids.debug then
|
|
CleveRoids.Print("|cff00aaff[Split Damage]|r " .. baseName .. " immunity recorded as '" .. school .. "' (initial), not '" .. SPLIT_DAMAGE_SPELLS[baseName].debuff .. "' (debuff)")
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Direct DBC lookup: spellID → GetSpellRecField → school enum → name
|
|
if not school and spellID and GetSpellRecField then
|
|
local dbcSchool = GetSpellRecField(spellID, "school")
|
|
if dbcSchool and SCHOOL_NAMES[dbcSchool] then
|
|
school = SCHOOL_NAMES[dbcSchool]
|
|
end
|
|
-- Bleed spells have DBC school=0 (physical) but immunity should be "bleed", not "physical"
|
|
-- Split damage spells (Rake/Pounce/Garrote) are already handled above — their cast spell
|
|
-- triggers IMMUNE for the initial physical hit, so "physical" is correct for those.
|
|
-- Pure bleed spells (Rip, etc.) that reach this path need the override.
|
|
if school == "physical" and CleveRoids.BleedSpellIDs and CleveRoids.BleedSpellIDs[spellID] then
|
|
school = "bleed"
|
|
if CleveRoids.debug then
|
|
CleveRoids.Print("|cff00aaff[Bleed Override]|r " .. (spellName or tostring(spellID)) .. " immunity recorded as 'bleed' (DBC school=physical, but spell is a bleed)")
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Fallback: try learned mapping from damage events
|
|
if not school and spellID and CleveRoids.spellSchoolMapping[spellID] then
|
|
school = CleveRoids.spellSchoolMapping[spellID]
|
|
end
|
|
|
|
-- Last resort: name-based lookup (no spellID available)
|
|
if not school and spellName then
|
|
school = GetSpellSchool(spellName, nil)
|
|
end
|
|
|
|
if not school then
|
|
school = "unknown"
|
|
if CleveRoids.debug then
|
|
CleveRoids.Print("|cffff9900[Unknown School]|r Could not determine school for: " .. (spellName or tostring(spellID)))
|
|
end
|
|
end
|
|
|
|
-- Initialize school table
|
|
if not CleveRoids_ImmunityData[school] then
|
|
CleveRoids_ImmunityData[school] = {}
|
|
end
|
|
|
|
-- Record immunity
|
|
if conditionalBuff then
|
|
-- Buff-based immunity
|
|
local immunityData = { buff = conditionalBuff }
|
|
if school == "unknown" then
|
|
immunityData.spell = spellName
|
|
end
|
|
CleveRoids_ImmunityData[school][npcName] = immunityData
|
|
|
|
if CleveRoids.debug then
|
|
if school == "unknown" then
|
|
CleveRoids.Print("|cffff6600Immunity:|r " .. npcName .. " is immune to '" .. spellName .. "' (unknown school) when buffed with: " .. conditionalBuff)
|
|
else
|
|
CleveRoids.Print("|cffff6600Immunity:|r " .. npcName .. " is immune to " .. school .. " when buffed with: " .. conditionalBuff)
|
|
end
|
|
end
|
|
else
|
|
-- Permanent immunity
|
|
local immunityData
|
|
if school == "unknown" then
|
|
-- Store spell name for unknown school immunities
|
|
immunityData = { spell = spellName }
|
|
else
|
|
immunityData = true
|
|
end
|
|
|
|
if CleveRoids_ImmunityData[school][npcName] ~= immunityData then
|
|
CleveRoids_ImmunityData[school][npcName] = immunityData
|
|
if CleveRoids.debug then
|
|
if school == "unknown" then
|
|
CleveRoids.Print("|cffff6600Immunity:|r " .. npcName .. " is permanently immune to '" .. spellName .. "' (unknown school)")
|
|
else
|
|
CleveRoids.Print("|cffff6600Immunity:|r " .. npcName .. " is permanently immune to " .. school)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Expose RecordImmunity globally for use by debuff verification (bleed immunity detection)
|
|
CleveRoids.RecordImmunity = RecordImmunity
|
|
|
|
-- Remove a spell/school immunity record when a spell successfully lands
|
|
-- This is called when we verify that a debuff actually landed on a previously-immune NPC
|
|
-- Parameters:
|
|
-- npcName: Name of the NPC
|
|
-- school: Damage school (e.g., "fire", "bleed") or spell name for unknown schools
|
|
local function RemoveSpellImmunity(npcName, school)
|
|
if not npcName or not school or npcName == "" then
|
|
return
|
|
end
|
|
|
|
if CleveRoids_ImmunityData[school] and CleveRoids_ImmunityData[school][npcName] then
|
|
CleveRoids_ImmunityData[school][npcName] = nil
|
|
|
|
if CleveRoids.debug then
|
|
CleveRoids.Print("|cff00aaff[Immunity Removed]|r " .. npcName .. " is no longer immune to " .. school .. " (spell landed successfully)")
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Expose publicly
|
|
CleveRoids.RemoveSpellImmunity = RemoveSpellImmunity
|
|
|
|
-- Cancel pending verification entries when immunity is confirmed from combat log
|
|
-- This prevents redundant 0.2s verification when combat log already told us about immunity
|
|
-- Parameters:
|
|
-- targetName: Name of the target (required)
|
|
-- spellName: Name of the spell (optional - if nil, cancels ALL pending entries for target)
|
|
local function CancelPendingVerification(targetName, spellName)
|
|
if not targetName or targetName == "" then return end
|
|
|
|
local debug = CleveRoids.debug
|
|
local lowerTarget = string.lower(targetName)
|
|
local lowerSpell = spellName and string.lower(string.gsub(spellName, "%s*%(.-%)%s*$", ""))
|
|
|
|
-- Cancel pending CC debuff entries
|
|
if lib.pendingCCDebuffs then
|
|
local toRemove = {}
|
|
for i, pending in ipairs(lib.pendingCCDebuffs) do
|
|
local matches = false
|
|
if pending.targetName and string.lower(pending.targetName) == lowerTarget then
|
|
if lowerSpell then
|
|
-- Specific spell - only cancel if spell matches
|
|
local pendingSpellName = pending.spellName and string.lower(string.gsub(pending.spellName, "%s*%(.-%)%s*$", ""))
|
|
if pendingSpellName and pendingSpellName == lowerSpell then
|
|
matches = true
|
|
end
|
|
else
|
|
-- No spell specified - cancel all for this target
|
|
matches = true
|
|
end
|
|
end
|
|
if matches then
|
|
if debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00aaff[Combat Log Immune]|r Cancelled pending CC verification for %s on %s - combat log confirmed immunity",
|
|
pending.ccType or "CC", targetName)
|
|
)
|
|
end
|
|
table.insert(toRemove, i)
|
|
end
|
|
end
|
|
for i = table.getn(toRemove), 1, -1 do
|
|
table.remove(lib.pendingCCDebuffs, toRemove[i])
|
|
end
|
|
end
|
|
|
|
-- Cancel pending shared debuff entries
|
|
if lib.pendingSharedDebuffs then
|
|
local toRemove = {}
|
|
for i, pending in ipairs(lib.pendingSharedDebuffs) do
|
|
local matches = false
|
|
if pending.targetName and string.lower(pending.targetName) == lowerTarget then
|
|
if lowerSpell then
|
|
local pendingSpellName = pending.spellID and C_Spell.GetSpellName(pending.spellID)
|
|
pendingSpellName = pendingSpellName and string.lower(string.gsub(pendingSpellName, "%s*%(.-%)%s*$", ""))
|
|
if pendingSpellName and pendingSpellName == lowerSpell then
|
|
matches = true
|
|
end
|
|
else
|
|
matches = true
|
|
end
|
|
end
|
|
if matches then
|
|
if debug then
|
|
local spellNameDebug = pending.spellID and C_Spell.GetSpellName(pending.spellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00aaff[Combat Log Immune]|r Cancelled pending shared debuff verification for %s on %s - combat log confirmed immunity",
|
|
spellNameDebug, targetName)
|
|
)
|
|
end
|
|
table.insert(toRemove, i)
|
|
end
|
|
end
|
|
for i = table.getn(toRemove), 1, -1 do
|
|
table.remove(lib.pendingSharedDebuffs, toRemove[i])
|
|
end
|
|
end
|
|
|
|
-- Cancel pending personal debuff entries (bleeds)
|
|
if lib.pendingPersonalDebuffs then
|
|
local toRemove = {}
|
|
for i, pending in ipairs(lib.pendingPersonalDebuffs) do
|
|
local matches = false
|
|
if pending.targetName and string.lower(pending.targetName) == lowerTarget then
|
|
if lowerSpell then
|
|
local pendingSpellName = pending.spellID and C_Spell.GetSpellName(pending.spellID)
|
|
pendingSpellName = pendingSpellName and string.lower(string.gsub(pendingSpellName, "%s*%(.-%)%s*$", ""))
|
|
local castSpellName = pending.castSpellID and C_Spell.GetSpellName(pending.castSpellID)
|
|
castSpellName = castSpellName and string.lower(string.gsub(castSpellName, "%s*%(.-%)%s*$", ""))
|
|
if (pendingSpellName and pendingSpellName == lowerSpell) or
|
|
(castSpellName and castSpellName == lowerSpell) then
|
|
matches = true
|
|
end
|
|
else
|
|
matches = true
|
|
end
|
|
end
|
|
if matches then
|
|
if debug then
|
|
local spellNameDebug = pending.spellID and C_Spell.GetSpellName(pending.spellID) or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00aaff[Combat Log Immune]|r Cancelled pending personal debuff verification for %s on %s - combat log confirmed immunity",
|
|
spellNameDebug, targetName)
|
|
)
|
|
end
|
|
table.insert(toRemove, i)
|
|
end
|
|
end
|
|
for i = table.getn(toRemove), 1, -1 do
|
|
table.remove(lib.pendingPersonalDebuffs, toRemove[i])
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Combat log parser for immunity, reflect, and evade detection
|
|
-- Handles both RAW_COMBATLOG (arg1=formatted, arg2=raw) and CHAT_MSG events (arg1=formatted only)
|
|
local function ParseImmunityCombatLog()
|
|
local message = arg1 -- Formatted chat message text
|
|
local rawMessage = arg2 -- Raw message (only present for RAW_COMBATLOG)
|
|
|
|
if not message then return end
|
|
|
|
-- PERFORMANCE: Quick length check
|
|
if string.len(message) < 8 then return end
|
|
|
|
-- Detect message type: immune, reflect, or evade
|
|
local hasImmune = string.find(message, "immune")
|
|
local hasReflect = string.find(message, "reflect")
|
|
local hasEvade = string.find(message, "evade")
|
|
|
|
-- ONLY process immune/reflect/evade messages - NOT resists!
|
|
-- Resists are RNG-based and should NOT create immunity records
|
|
if not hasImmune and not hasReflect and not hasEvade then
|
|
return
|
|
end
|
|
|
|
-- Determine the miss reason type
|
|
local missReason = nil
|
|
if hasImmune then
|
|
missReason = "immune"
|
|
elseif hasReflect then
|
|
missReason = "reflect"
|
|
elseif hasEvade then
|
|
missReason = "evade"
|
|
end
|
|
|
|
-- Debug: Show the message we're parsing
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffaaaaaa[CombatLog Parse]|r [%s] %s", missReason, message))
|
|
end
|
|
|
|
local spellName = nil
|
|
local targetName = nil
|
|
local school = nil
|
|
|
|
-- IMMUNE PATTERNS
|
|
if hasImmune then
|
|
-- Pattern 1: "Your [Spell] fails. Y is immune."
|
|
local _, _, extractedSpell, extractedTarget = string.find(message, "Your%s+(.-)%s+fails%.%s+(.-)%s+is immune")
|
|
if extractedSpell and extractedTarget then
|
|
spellName = extractedSpell
|
|
targetName = extractedTarget
|
|
end
|
|
|
|
-- Pattern 2: "Your [Spell] failed. Y is immune." (past tense)
|
|
if not spellName or not targetName then
|
|
_, _, extractedSpell, extractedTarget = string.find(message, "Your%s+(.-)%s+failed%.%s+(.-)%s+is immune")
|
|
if extractedSpell and extractedTarget then
|
|
spellName = extractedSpell
|
|
targetName = extractedTarget
|
|
end
|
|
end
|
|
|
|
-- Pattern 3: "Y is immune to [School] damage"
|
|
if not targetName then
|
|
_, _, extractedTarget = string.find(message, "^(.-)%s+is immune to")
|
|
if extractedTarget then
|
|
targetName = extractedTarget
|
|
end
|
|
end
|
|
|
|
-- Pattern 4: "Y is immune" (generic)
|
|
if not targetName then
|
|
_, _, extractedTarget = string.find(message, "^(.-)%s+is immune")
|
|
if extractedTarget then
|
|
targetName = extractedTarget
|
|
end
|
|
end
|
|
end
|
|
|
|
-- REFLECT PATTERNS
|
|
if hasReflect and not targetName then
|
|
-- Pattern 1: "Your [Spell] is reflected back by Y."
|
|
local _, _, extractedSpell, extractedTarget = string.find(message, "Your%s+(.-)%s+is reflected back by%s+(.-)%.")
|
|
if extractedSpell and extractedTarget then
|
|
spellName = extractedSpell
|
|
targetName = extractedTarget
|
|
end
|
|
|
|
-- Pattern 2: "Your [Spell] was reflected by Y."
|
|
if not spellName or not targetName then
|
|
_, _, extractedSpell, extractedTarget = string.find(message, "Your%s+(.-)%s+was reflected by%s+(.-)%.")
|
|
if extractedSpell and extractedTarget then
|
|
spellName = extractedSpell
|
|
targetName = extractedTarget
|
|
end
|
|
end
|
|
|
|
-- Pattern 3: "Y reflects your [Spell]."
|
|
if not targetName then
|
|
_, _, extractedTarget, extractedSpell = string.find(message, "^(.-)%s+reflects your%s+(.-)%.")
|
|
if extractedTarget and extractedSpell then
|
|
targetName = extractedTarget
|
|
spellName = extractedSpell
|
|
end
|
|
end
|
|
|
|
-- Pattern 4: "Y reflects [Spell] back at you."
|
|
if not targetName then
|
|
_, _, extractedTarget, extractedSpell = string.find(message, "^(.-)%s+reflects%s+(.-)%s+back")
|
|
if extractedTarget and extractedSpell then
|
|
targetName = extractedTarget
|
|
spellName = extractedSpell
|
|
end
|
|
end
|
|
end
|
|
|
|
-- EVADE PATTERNS
|
|
if hasEvade and not targetName then
|
|
-- Pattern 1: "Your [Spell] fails. Y evades."
|
|
local _, _, extractedSpell, extractedTarget = string.find(message, "Your%s+(.-)%s+fails%.%s+(.-)%s+evades")
|
|
if extractedSpell and extractedTarget then
|
|
spellName = extractedSpell
|
|
targetName = extractedTarget
|
|
end
|
|
|
|
-- Pattern 2: "Your [Spell] failed. Y evades." (past tense)
|
|
if not spellName or not targetName then
|
|
_, _, extractedSpell, extractedTarget = string.find(message, "Your%s+(.-)%s+failed%.%s+(.-)%s+evades")
|
|
if extractedSpell and extractedTarget then
|
|
spellName = extractedSpell
|
|
targetName = extractedTarget
|
|
end
|
|
end
|
|
|
|
-- Pattern 3: "Y evades your [Spell]."
|
|
if not targetName then
|
|
_, _, extractedTarget, extractedSpell = string.find(message, "^(.-)%s+evades your%s+(.-)%.")
|
|
if extractedTarget and extractedSpell then
|
|
targetName = extractedTarget
|
|
spellName = extractedSpell
|
|
end
|
|
end
|
|
|
|
-- Pattern 4: "Y evades." (generic)
|
|
if not targetName then
|
|
_, _, extractedTarget = string.find(message, "^(.-)%s+evades")
|
|
if extractedTarget then
|
|
targetName = extractedTarget
|
|
end
|
|
end
|
|
end
|
|
|
|
-- STORE REASON FOR SPELL_GO CORRELATION
|
|
-- Store the reason for ProcessMissReason to correlate with SPELL_GO events
|
|
if targetName and lib and lib.recentCombatLogReasons then
|
|
lib.recentCombatLogReasons[targetName] = lib.recentCombatLogReasons[targetName] or {}
|
|
if spellName then
|
|
lib.recentCombatLogReasons[targetName][spellName] = {
|
|
time = GetTime(),
|
|
reason = missReason,
|
|
}
|
|
else
|
|
-- Store generic reason for target (no specific spell)
|
|
lib.recentCombatLogReasons[targetName]["_generic"] = {
|
|
time = GetTime(),
|
|
reason = missReason,
|
|
}
|
|
end
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00aaff[CombatLog Stored]|r %s -> %s (%s)",
|
|
spellName or "_generic", targetName, missReason)
|
|
)
|
|
end
|
|
end
|
|
|
|
-- For reflect and evade, we don't record as immunity - just return after storing reason
|
|
if hasReflect or hasEvade then
|
|
return
|
|
end
|
|
|
|
-- NOTE: Resist patterns removed - resists are RNG-based, not immunity
|
|
-- Only "immune" messages should create immunity records
|
|
|
|
-- Extract damage school if explicitly mentioned in message
|
|
-- IMPORTANT: Only search the text AFTER "is immune to" for the school keyword,
|
|
-- NOT the entire message. NPC names like "Frostmane Troll" or "Shadowforge Dwarf"
|
|
-- contain school keywords that would cause false immunity records.
|
|
if string.find(message, "is immune to") then
|
|
local _, immuneEnd = string.find(message, "is immune to%s*")
|
|
if immuneEnd then
|
|
local schoolText = string.lower(string.sub(message, immuneEnd + 1))
|
|
if string.find(schoolText, "^fire") then
|
|
school = "fire"
|
|
elseif string.find(schoolText, "^frost") then
|
|
school = "frost"
|
|
elseif string.find(schoolText, "^nature") then
|
|
school = "nature"
|
|
elseif string.find(schoolText, "^shadow") then
|
|
school = "shadow"
|
|
elseif string.find(schoolText, "^arcane") then
|
|
school = "arcane"
|
|
elseif string.find(schoolText, "^holy") then
|
|
school = "holy"
|
|
elseif string.find(schoolText, "^physical") then
|
|
school = "physical"
|
|
elseif string.find(schoolText, "^bleed") then
|
|
school = "bleed"
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Fallback: Try to get target from current target if message mentions them
|
|
if not targetName and UnitExists("target") then
|
|
local currentTargetName = UnitName("target")
|
|
if currentTargetName and string.find(message, currentTargetName) then
|
|
targetName = currentTargetName
|
|
end
|
|
end
|
|
|
|
-- Skip if target is dead (dead targets report "immune" for in-flight spells)
|
|
if targetName and UnitExists("target") and UnitName("target") == targetName and CleveRoids.IsUnitDead("target") then
|
|
if CleveRoids.debug then
|
|
CleveRoids.Print("|cff00aaff[CombatLog Dead Skip]|r " .. targetName .. " is dead - ignoring immunity from combat log")
|
|
end
|
|
CancelPendingVerification(targetName, spellName)
|
|
return
|
|
end
|
|
|
|
-- If we have a school but no spell, use the school directly
|
|
if school and targetName and not spellName then
|
|
-- Check if target is queryable for buff check
|
|
local canQuery = UnitExists("target") and UnitName("target") == targetName
|
|
if canQuery then
|
|
local immunityBuff = HasImmunityGrantingBuff("target")
|
|
if immunityBuff then
|
|
if CleveRoids.debug then
|
|
CleveRoids.Print("|cff00aaff[CombatLog Temp Skip]|r " .. targetName .. " has " .. immunityBuff)
|
|
end
|
|
CancelPendingVerification(targetName, nil)
|
|
return
|
|
end
|
|
else
|
|
-- Cannot query target — inconclusive, don't record
|
|
if CleveRoids.debug then
|
|
CleveRoids.Print("|cff00aaff[CombatLog Inconclusive]|r Cannot query " .. targetName .. " for buffs - not recording " .. school .. " immunity")
|
|
end
|
|
CancelPendingVerification(targetName, nil)
|
|
return
|
|
end
|
|
|
|
-- Record immunity by school
|
|
if not CleveRoids_ImmunityData[school] then
|
|
CleveRoids_ImmunityData[school] = {}
|
|
end
|
|
|
|
-- Permanent immunity
|
|
if CleveRoids_ImmunityData[school][targetName] ~= true then
|
|
CleveRoids_ImmunityData[school][targetName] = true
|
|
if CleveRoids.debug then
|
|
CleveRoids.Print("|cffff6600Immunity:|r " .. targetName .. " is permanently immune to " .. school)
|
|
end
|
|
end
|
|
-- Cancel pending verification - combat log confirmed immunity
|
|
CancelPendingVerification(targetName, nil)
|
|
return
|
|
end
|
|
|
|
-- If we have a spell and target, record immunity
|
|
if spellName and targetName then
|
|
-- SPELL_MISS_SELF (Nampower v2.31+, always available since addon requires v3.0+)
|
|
-- provides exact spellId for accurate school detection and already handles immunity
|
|
-- recording via ProcessSpellMissSelf. Skip duplicate text-based recording to avoid
|
|
-- false positives from unreliable spell name → school resolution (e.g., "ice" substring
|
|
-- matching in GetSpellSchool, NPC name contamination, etc.)
|
|
-- The reason was already stored above for SPELL_GO correlation (recentCombatLogReasons).
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00aaff[CombatLog Deferred]|r %s on %s - SPELL_MISS_SELF handles immunity recording",
|
|
spellName, targetName)
|
|
)
|
|
end
|
|
CancelPendingVerification(targetName, spellName)
|
|
end
|
|
end
|
|
|
|
-- Combat log parser for "afflicted by" messages
|
|
-- Tracks when CC effects (like Pounce stun) and bleeds successfully land
|
|
-- This confirms the effect worked and removes any false immunity records
|
|
local function ParseAfflictedCombatLog()
|
|
local message = arg1
|
|
if not message then return end
|
|
|
|
-- Quick check: must contain "afflicted by"
|
|
if not string.find(message, "afflicted by") then return end
|
|
|
|
-- Pattern: "X is afflicted by Y" or "X is afflicted by Y (N)."
|
|
-- Handle both with and without trailing period
|
|
local _, _, targetName, spellName = string.find(message, "^(.-)%s+is afflicted by%s+(.+)")
|
|
if not targetName or not spellName then return end
|
|
|
|
-- Remove trailing period if present
|
|
spellName = string.gsub(spellName, "%.$", "")
|
|
-- Remove stack count like "(1)" from spell name
|
|
spellName = string.gsub(spellName, "%s*%(%d+%)$", "")
|
|
|
|
-- Check if this is a tracked spell (CC or bleed)
|
|
local affliction = lib.trackedAfflictions and lib.trackedAfflictions[spellName]
|
|
if not affliction then
|
|
return -- Not a tracked spell, ignore
|
|
end
|
|
|
|
if affliction.type == "cc" then
|
|
-- CC effect landed - remove any false CC immunity
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00ff00[CC Landed]|r %s afflicted by %s (%s)",
|
|
targetName, spellName, affliction.value)
|
|
)
|
|
end
|
|
RemoveCCImmunity(targetName, affliction.value)
|
|
|
|
-- Mark any pending CC verification for this target/spell as verified
|
|
-- This prevents false immunity recordings when "afflicted by" message arrives
|
|
-- before the verification delay completes (especially for hidden CC spells)
|
|
if lib.pendingCCDebuffs then
|
|
for _, pending in ipairs(lib.pendingCCDebuffs) do
|
|
if pending.targetName == targetName and
|
|
pending.ccType == affliction.value and
|
|
not pending.verifiedByAffliction then
|
|
pending.verifiedByAffliction = true
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00aaff[CC Verified Early]|r %s on %s confirmed via 'afflicted by' message",
|
|
pending.ccType, targetName)
|
|
)
|
|
end
|
|
break -- Only mark one pending entry
|
|
end
|
|
end
|
|
end
|
|
|
|
elseif affliction.type == "school" then
|
|
-- School/bleed effect landed - remove any false school immunity
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00ff00[Bleed Landed]|r %s afflicted by %s (%s)",
|
|
targetName, spellName, affliction.value)
|
|
)
|
|
end
|
|
RemoveSpellImmunity(targetName, affliction.value)
|
|
|
|
-- Mark any pending bleed verification for this target/spell as verified
|
|
-- This prevents false immunity recordings when target dies before verification
|
|
-- (e.g., Rake lands, target dies at 0.1s, verification at 0.2s would miss it)
|
|
if lib.pendingPersonalDebuffs then
|
|
for _, pending in ipairs(lib.pendingPersonalDebuffs) do
|
|
if pending.targetName == targetName and not pending.verifiedByAffliction then
|
|
-- Check if this pending entry matches the affliction spell
|
|
local pendingSpellName = pending.spellID and C_Spell.GetSpellName(pending.spellID)
|
|
if pendingSpellName then
|
|
-- Strip rank info for comparison
|
|
pendingSpellName = string.gsub(pendingSpellName, "%s*%(.-%)%s*$", "")
|
|
local afflictionSpellName = string.gsub(spellName, "%s*%(.-%)%s*$", "")
|
|
if string.lower(pendingSpellName) == string.lower(afflictionSpellName) then
|
|
pending.verifiedByAffliction = true
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00aaff[Bleed Verified Early]|r %s on %s confirmed via 'afflicted by' message",
|
|
pendingSpellName, targetName)
|
|
)
|
|
end
|
|
break -- Only mark one pending entry
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Check if a unit is immune to a spell, damage school, or CC type
|
|
-- Supports: CheckImmunity(unitId, "Flame Shock") or CheckImmunity(unitId, "fire") or CheckImmunity(unitId, "stun")
|
|
function CleveRoids.CheckImmunity(unitId, spellOrSchool)
|
|
if not unitId or not UnitExists(unitId) then
|
|
return false
|
|
end
|
|
|
|
if not spellOrSchool or spellOrSchool == "" then
|
|
return false
|
|
end
|
|
|
|
-- Check if input is a CC type (stun, fear, root, etc.)
|
|
local inputLower = string.lower(spellOrSchool)
|
|
if CC_IMMUNITY_TYPES[inputLower] then
|
|
return CheckCCImmunity(unitId, inputLower)
|
|
end
|
|
|
|
-- Universal debuff-based immunities (Banish, etc.)
|
|
-- Banish makes target immune to most damage schools (not all spells)
|
|
do
|
|
-- Banish: 710 = Rank 1, 18647 = Rank 2. One by-spellID lookup each --
|
|
-- C_UnitAuras walks the whole aura array, so it finds the debuff even when
|
|
-- it has overflowed into an NPC's buff slots (no manual slot scan / overflow gate).
|
|
local API = CleveRoids.ClassicAPI
|
|
local hasBanish = (API.GetUnitAuraBySpellID(unitId, 710)
|
|
or API.GetUnitAuraBySpellID(unitId, 18647)) and true or false
|
|
|
|
-- If Banished, check what's being tested for immunity
|
|
if hasBanish then
|
|
-- Banished targets are immune to all damage schools
|
|
-- (but Banish itself can be recast immediately)
|
|
local banishImmuneSchools = {
|
|
fire = true,
|
|
frost = true,
|
|
nature = true,
|
|
shadow = true,
|
|
arcane = true,
|
|
holy = true,
|
|
physical = true,
|
|
bleed = true
|
|
}
|
|
|
|
if banishImmuneSchools[inputLower] then
|
|
return true
|
|
end
|
|
|
|
-- For specific spells, determine their school and check
|
|
if not IMMUNITY_SCHOOLS[inputLower] then
|
|
local spellSchool = GetSpellSchool(spellOrSchool)
|
|
if spellSchool and banishImmuneSchools[spellSchool] then
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Only works on NPCs for NPC-specific immunities
|
|
if UnitIsPlayer(unitId) then
|
|
return false
|
|
end
|
|
|
|
local targetName = UnitName(unitId)
|
|
-- Fallback to GUID->name cache if UnitName fails
|
|
-- This happens when multiscan passes a GUID that isn't the current target
|
|
if not targetName or targetName == "" or targetName == "Unknown" then
|
|
local normalizedGuid = CleveRoids.NormalizeGUID(unitId)
|
|
if normalizedGuid and lib and lib.guidToName then
|
|
targetName = lib.guidToName[normalizedGuid]
|
|
end
|
|
end
|
|
if not targetName or targetName == "" then
|
|
return false
|
|
end
|
|
|
|
-- Check if input is a spell school name directly
|
|
-- (inputLower already defined above for CC type check)
|
|
local school = nil
|
|
local checkSpellName = nil -- For unknown school, we need to match spell name too
|
|
|
|
if IMMUNITY_SCHOOLS[inputLower] then
|
|
-- Input is a damage school name (fire, frost, nature, etc.)
|
|
school = inputLower
|
|
else
|
|
-- Input is a spell name, need to determine its school
|
|
checkSpellName = spellOrSchool -- Save the spell name for unknown school check
|
|
|
|
-- Get base name for split damage spell check
|
|
local baseName = string.gsub(spellOrSchool, "%s*%(.-%)%s*$", "")
|
|
|
|
-- SPLIT DAMAGE SPELLS: Check BOTH initial and debuff schools
|
|
-- If target is immune to EITHER component, skip the spell
|
|
if SPLIT_DAMAGE_SPELLS[baseName] then
|
|
local splitData = SPLIT_DAMAGE_SPELLS[baseName]
|
|
local initialSchool = splitData.initial
|
|
local debuffSchool = splitData.debuff
|
|
|
|
-- Check initial school immunity (e.g., physical for Rake's initial hit)
|
|
local initialImmune = false
|
|
if initialSchool and CleveRoids_ImmunityData[initialSchool] then
|
|
local initialImmunityData = CleveRoids_ImmunityData[initialSchool][targetName]
|
|
if initialImmunityData == true then
|
|
initialImmune = true
|
|
elseif type(initialImmunityData) == "table" and not initialImmunityData.buff then
|
|
initialImmune = true
|
|
elseif type(initialImmunityData) == "table" and initialImmunityData.buff then
|
|
-- Check if target has the immunity-granting buff
|
|
for i = 1, 32 do
|
|
local texture, stacks, spellID = UnitBuff(unitId, i)
|
|
if not texture then break end
|
|
if spellID then
|
|
local buffName = C_Spell.GetSpellName(spellID)
|
|
if buffName and buffName == initialImmunityData.buff then
|
|
initialImmune = true
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Check debuff school immunity (e.g., bleed for Rake's DoT)
|
|
local debuffImmune = false
|
|
if debuffSchool and CleveRoids_ImmunityData[debuffSchool] then
|
|
local debuffImmunityData = CleveRoids_ImmunityData[debuffSchool][targetName]
|
|
if debuffImmunityData == true then
|
|
debuffImmune = true
|
|
elseif type(debuffImmunityData) == "table" and not debuffImmunityData.buff then
|
|
debuffImmune = true
|
|
elseif type(debuffImmunityData) == "table" and debuffImmunityData.buff then
|
|
-- Check if target has the immunity-granting buff
|
|
for i = 1, 32 do
|
|
local texture, stacks, spellID = UnitBuff(unitId, i)
|
|
if not texture then break end
|
|
if spellID then
|
|
local buffName = C_Spell.GetSpellName(spellID)
|
|
if buffName and buffName == debuffImmunityData.buff then
|
|
debuffImmune = true
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Return true if immune to EITHER component
|
|
if initialImmune or debuffImmune then
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff6600[Immunity Check]|r %s on %s: initial(%s)=%s, debuff(%s)=%s",
|
|
baseName, targetName,
|
|
initialSchool, initialImmune and "IMMUNE" or "ok",
|
|
debuffSchool, debuffImmune and "IMMUNE" or "ok")
|
|
)
|
|
end
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
school = GetSpellSchool(spellOrSchool)
|
|
if not school then
|
|
school = "unknown" -- If we can't determine school, check unknown category
|
|
end
|
|
end
|
|
|
|
-- Check immunity data for this school
|
|
if not CleveRoids_ImmunityData[school] then
|
|
return false
|
|
end
|
|
|
|
local immunityData = CleveRoids_ImmunityData[school][targetName]
|
|
|
|
-- No immunity data for this NPC
|
|
if not immunityData then
|
|
return false
|
|
end
|
|
|
|
-- Permanent immunity
|
|
if immunityData == true then
|
|
return true
|
|
end
|
|
|
|
-- Table-based immunity data (buff-based or unknown school with spell name)
|
|
if type(immunityData) == "table" then
|
|
-- For unknown school, check if spell name matches
|
|
if school == "unknown" and immunityData.spell and checkSpellName then
|
|
if immunityData.spell ~= checkSpellName then
|
|
return false -- NPC is immune to a different spell, not this one
|
|
end
|
|
end
|
|
|
|
-- Check buff-based immunity (if NPC has the required buff). One by-name
|
|
-- lookup across the unit's buffs via C_UnitAuras -- no 32-slot scan.
|
|
if immunityData.buff then
|
|
if CleveRoids.ClassicAPI.GetAuraDataBySpellName(unitId, immunityData.buff, "HELPFUL") then
|
|
return true
|
|
end
|
|
-- Buff not found, not currently immune
|
|
return false
|
|
end
|
|
|
|
-- Unknown school permanent immunity (has spell name, no buff requirement)
|
|
if immunityData.spell and not immunityData.buff then
|
|
return true -- Permanent immunity to this specific spell
|
|
end
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
-- Management functions for immunity data
|
|
function CleveRoids.ListImmunities(school)
|
|
if school then
|
|
school = string.lower(school)
|
|
if not CleveRoids_ImmunityData[school] then
|
|
CleveRoids.Print("No immunity data for school: " .. school)
|
|
return
|
|
end
|
|
|
|
CleveRoids.Print("|cff00ff00" .. school .. " immunities:|r")
|
|
local count = 0
|
|
for npc, data in pairs(CleveRoids_ImmunityData[school]) do
|
|
if data == true then
|
|
CleveRoids.Print(" - " .. npc .. " (permanent)")
|
|
elseif type(data) == "table" then
|
|
if data.buff and data.spell then
|
|
-- Unknown school with conditional buff
|
|
CleveRoids.Print(" - " .. npc .. " immune to '" .. data.spell .. "' (when buffed: " .. data.buff .. ")")
|
|
elseif data.buff then
|
|
-- Known school with conditional buff
|
|
CleveRoids.Print(" - " .. npc .. " (when buffed: " .. data.buff .. ")")
|
|
elseif data.spell then
|
|
-- Unknown school, permanent
|
|
CleveRoids.Print(" - " .. npc .. " immune to '" .. data.spell .. "' (permanent)")
|
|
end
|
|
end
|
|
count = count + 1
|
|
end
|
|
CleveRoids.Print("Total: " .. count)
|
|
else
|
|
-- List all schools
|
|
CleveRoids.Print("|cff00ff00Immunity Data by School:|r")
|
|
for schoolName, npcs in pairs(CleveRoids_ImmunityData) do
|
|
local count = 0
|
|
for _ in pairs(npcs) do
|
|
count = count + 1
|
|
end
|
|
if count > 0 then
|
|
CleveRoids.Print(" " .. schoolName .. ": " .. count .. " NPCs")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
function CleveRoids.ClearImmunities(school)
|
|
if school then
|
|
school = string.lower(school)
|
|
CleveRoids_ImmunityData[school] = {}
|
|
CleveRoids.Print("Cleared " .. school .. " immunity data")
|
|
else
|
|
CleveRoids_ImmunityData = {}
|
|
CleveRoids.Print("Cleared all immunity data")
|
|
end
|
|
end
|
|
|
|
function CleveRoids.AddImmunity(npcName, school, buffName)
|
|
if not npcName or not school then
|
|
CleveRoids.Print("Usage: /cleveroid addimmune <npc name> <school> [buff name]")
|
|
CleveRoids.Print("Schools: fire, frost, nature, shadow, arcane, holy, physical, bleed, unknown")
|
|
return
|
|
end
|
|
|
|
school = string.lower(school)
|
|
if not IMMUNITY_SCHOOLS[school] then
|
|
CleveRoids.Print("Invalid school. Use: fire, frost, nature, shadow, arcane, holy, physical, bleed, unknown")
|
|
return
|
|
end
|
|
|
|
if not CleveRoids_ImmunityData[school] then
|
|
CleveRoids_ImmunityData[school] = {}
|
|
end
|
|
|
|
if buffName and buffName ~= "" then
|
|
CleveRoids_ImmunityData[school][npcName] = { buff = buffName }
|
|
CleveRoids.Print("Added: " .. npcName .. " is immune to " .. school .. " when buffed with: " .. buffName)
|
|
else
|
|
CleveRoids_ImmunityData[school][npcName] = true
|
|
CleveRoids.Print("Added: " .. npcName .. " is permanently immune to " .. school)
|
|
end
|
|
end
|
|
|
|
function CleveRoids.RemoveImmunity(npcName, school)
|
|
if not npcName or not school then
|
|
CleveRoids.Print("Usage: /cleveroid removeimmune <npc name> <school>")
|
|
return
|
|
end
|
|
|
|
school = string.lower(school)
|
|
if CleveRoids_ImmunityData[school] and CleveRoids_ImmunityData[school][npcName] then
|
|
CleveRoids_ImmunityData[school][npcName] = nil
|
|
CleveRoids.Print("Removed: " .. npcName .. " from " .. school .. " immunities")
|
|
else
|
|
CleveRoids.Print("Not found: " .. npcName .. " in " .. school .. " immunities")
|
|
end
|
|
end
|
|
|
|
-- CC IMMUNITY MANAGEMENT COMMANDS
|
|
|
|
-- List CC immunities
|
|
function CleveRoids.ListCCImmunities(ccType)
|
|
if ccType then
|
|
ccType = string.lower(ccType)
|
|
local key = "cc_" .. ccType
|
|
|
|
if not CC_IMMUNITY_TYPES[ccType] then
|
|
CleveRoids.Print("Invalid CC type. Use: stun, fear, root, silence, sleep, charm, polymorph, banish, horror, disorient, snare")
|
|
return
|
|
end
|
|
|
|
if not CleveRoids_ImmunityData[key] then
|
|
CleveRoids.Print("No " .. ccType .. " immunity data recorded")
|
|
return
|
|
end
|
|
|
|
CleveRoids.Print("|cff00ff00" .. ccType .. " immunities:|r")
|
|
local count = 0
|
|
for npc, data in pairs(CleveRoids_ImmunityData[key]) do
|
|
if data == true then
|
|
CleveRoids.Print(" - " .. npc .. " (permanent)")
|
|
elseif type(data) == "table" and data.buff then
|
|
CleveRoids.Print(" - " .. npc .. " (when buffed: " .. data.buff .. ")")
|
|
end
|
|
count = count + 1
|
|
end
|
|
CleveRoids.Print("Total: " .. count)
|
|
else
|
|
-- List all CC types
|
|
CleveRoids.Print("|cff00ff00CC Immunity Data by Type:|r")
|
|
local found = false
|
|
for key, npcs in pairs(CleveRoids_ImmunityData) do
|
|
-- Only show CC immunity keys (prefixed with "cc_")
|
|
if string.sub(key, 1, 3) == "cc_" then
|
|
local count = 0
|
|
for _ in pairs(npcs) do
|
|
count = count + 1
|
|
end
|
|
if count > 0 then
|
|
local ccName = string.sub(key, 4) -- Remove "cc_" prefix
|
|
CleveRoids.Print(" " .. ccName .. ": " .. count .. " NPCs")
|
|
found = true
|
|
end
|
|
end
|
|
end
|
|
if not found then
|
|
CleveRoids.Print(" No CC immunity data recorded")
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Clear CC immunities
|
|
function CleveRoids.ClearCCImmunities(ccType)
|
|
if ccType then
|
|
ccType = string.lower(ccType)
|
|
local key = "cc_" .. ccType
|
|
|
|
if not CC_IMMUNITY_TYPES[ccType] then
|
|
CleveRoids.Print("Invalid CC type. Use: stun, fear, root, silence, sleep, charm, polymorph, banish, horror, disorient, snare")
|
|
return
|
|
end
|
|
|
|
CleveRoids_ImmunityData[key] = {}
|
|
CleveRoids.Print("Cleared " .. ccType .. " immunity data")
|
|
else
|
|
-- Clear all CC immunities (only cc_ prefixed keys)
|
|
local cleared = 0
|
|
for key, _ in pairs(CleveRoids_ImmunityData) do
|
|
if string.sub(key, 1, 3) == "cc_" then
|
|
CleveRoids_ImmunityData[key] = nil
|
|
cleared = cleared + 1
|
|
end
|
|
end
|
|
CleveRoids.Print("Cleared all CC immunity data (" .. cleared .. " types)")
|
|
end
|
|
end
|
|
|
|
-- Manually add CC immunity
|
|
function CleveRoids.AddCCImmunity(npcName, ccType, buffName)
|
|
if not npcName or not ccType then
|
|
CleveRoids.Print("Usage: /cleveroid addccimmune <npc name> <cctype> [buff name]")
|
|
CleveRoids.Print("CC Types: stun, fear, root, silence, sleep, charm, polymorph, banish, horror, disorient, snare")
|
|
return
|
|
end
|
|
|
|
ccType = string.lower(ccType)
|
|
if not CC_IMMUNITY_TYPES[ccType] then
|
|
CleveRoids.Print("Invalid CC type. Use: stun, fear, root, silence, sleep, charm, polymorph, banish, horror, disorient, snare")
|
|
return
|
|
end
|
|
|
|
local key = "cc_" .. ccType
|
|
if not CleveRoids_ImmunityData[key] then
|
|
CleveRoids_ImmunityData[key] = {}
|
|
end
|
|
|
|
if buffName and buffName ~= "" then
|
|
CleveRoids_ImmunityData[key][npcName] = { buff = buffName }
|
|
CleveRoids.Print("Added: " .. npcName .. " is immune to " .. ccType .. " when buffed with: " .. buffName)
|
|
else
|
|
CleveRoids_ImmunityData[key][npcName] = true
|
|
CleveRoids.Print("Added: " .. npcName .. " is permanently immune to " .. ccType)
|
|
end
|
|
end
|
|
|
|
-- Remove a CC immunity (command handler with user feedback)
|
|
-- NOTE: Do not use for automatic removal - use CleveRoids.RemoveCCImmunity instead
|
|
function CleveRoids.RemoveCCImmunityCommand(npcName, ccType)
|
|
if not npcName or not ccType then
|
|
CleveRoids.Print("Usage: /cleveroid removeccimmune <npc name> <cctype>")
|
|
return
|
|
end
|
|
|
|
ccType = string.lower(ccType)
|
|
local key = "cc_" .. ccType
|
|
if CleveRoids_ImmunityData[key] and CleveRoids_ImmunityData[key][npcName] then
|
|
CleveRoids_ImmunityData[key][npcName] = nil
|
|
CleveRoids.Print("Removed: " .. npcName .. " from " .. ccType .. " immunities")
|
|
else
|
|
CleveRoids.Print("Not found: " .. npcName .. " in " .. ccType .. " immunities")
|
|
end
|
|
end
|
|
|
|
-- Register combat log events for immunity tracking
|
|
-- PERFORMANCE: Only use RAW_COMBATLOG and SPELL_FAILURE to avoid spam from damage events
|
|
-- CHAT_MSG_SPELL_*_DAMAGE fires on EVERY hit/resist (100+ times/second in combat)
|
|
-- EXCEPTION: CHAT_MSG_SPELL_SELF_DAMAGE is needed for immunity detection (includes "is immune" messages)
|
|
-- EXCEPTION: CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE is needed for "afflicted by" detection (hidden CC spells)
|
|
local immunityFrame = CreateFrame("Frame", "CleveRoidsImmunityFrame")
|
|
immunityFrame:RegisterEvent("RAW_COMBATLOG")
|
|
immunityFrame:RegisterEvent("CHAT_MSG_SPELL_FAILURE")
|
|
immunityFrame:RegisterEvent("CHAT_MSG_SPELL_SELF_DAMAGE")
|
|
immunityFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE")
|
|
immunityFrame:SetScript("OnEvent", function()
|
|
if event == "RAW_COMBATLOG" or event == "CHAT_MSG_SPELL_FAILURE" or event == "CHAT_MSG_SPELL_SELF_DAMAGE" then
|
|
ParseImmunityCombatLog()
|
|
end
|
|
-- Check for "afflicted by" messages for hidden CC spells (e.g., Pounce stun)
|
|
-- These come through RAW_COMBATLOG and PERIODIC_CREATURE_DAMAGE
|
|
if event == "RAW_COMBATLOG" or event == "CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE" then
|
|
ParseAfflictedCombatLog()
|
|
end
|
|
end)
|
|
|
|
-- REACTIVE ABILITY PROC TRACKING SYSTEM
|
|
-- Tracks reactive ability procs independently of stance/usability
|
|
-- Allows detection of Overpower/Revenge/Riposte procs even when not in correct stance
|
|
|
|
-- Table to store reactive proc states with expiry times and target GUIDs
|
|
-- Structure: { spellName = { expiry = time, targetGUID = guid } }
|
|
CleveRoids.reactiveProcs = CleveRoids.reactiveProcs or {}
|
|
|
|
-- Table to track which reactive spells we've seen proc at least once
|
|
-- This helps us decide whether to use combat log tracking vs fallback methods
|
|
CleveRoids.reactiveProcsEverSeen = CleveRoids.reactiveProcsEverSeen or {}
|
|
|
|
-- Proc durations (in seconds)
|
|
-- Overpower and Revenge: 4 seconds
|
|
-- Riposte: 5 seconds (keeping at 5 for safety, can be adjusted)
|
|
local REACTIVE_PROC_DURATION = 4.0
|
|
|
|
-- VictimState constant for dodge detection (used by ParseReactiveCombatLog)
|
|
local VICTIMSTATE_DODGE_REACTIVE = 2
|
|
|
|
-- Shared patterns: procs when ENEMY dodges YOUR attack (auto or ability)
|
|
-- Used by both Overpower (Warrior) and Surprise Attack (Rogue)
|
|
local ENEMY_DODGE_PATTERNS = {
|
|
"(.+) dodges", -- English: "Target dodges"
|
|
"(.+) weicht aus", -- German
|
|
"(.+) esquive", -- French
|
|
"(.+)이%(가%) 회피", -- Korean
|
|
"躲闪了(.+)", -- Chinese Simplified
|
|
"躲閃了(.+)", -- Chinese Traditional
|
|
"was dodged by", -- English: "Your Mortal Strike was dodged by Target"
|
|
"wurde von (.+) ausgewichen", -- German
|
|
"a été esquivé par", -- French
|
|
"을%(를%) (.+)이%(가%) 회피", -- Korean
|
|
"被(.+)躲闪", -- Chinese Simplified
|
|
"被(.+)躲閃", -- Chinese Traditional
|
|
}
|
|
|
|
-- Reactive ability trigger patterns for combat log
|
|
local reactivePatterns = {
|
|
Overpower = {
|
|
patterns = ENEMY_DODGE_PATTERNS,
|
|
type = "enemy_dodge",
|
|
requiresTargetGUID = true,
|
|
duration = 4.0
|
|
},
|
|
["Surprise Attack"] = {
|
|
patterns = ENEMY_DODGE_PATTERNS,
|
|
type = "enemy_dodge",
|
|
requiresTargetGUID = true,
|
|
duration = 4.0
|
|
},
|
|
Riposte = {
|
|
-- Procs when YOU parry an enemy attack
|
|
patterns = {
|
|
"You parry", -- English: "You parry X's Y"
|
|
"Ihr pariert", -- German
|
|
"Vous parez", -- French
|
|
"막아냈습니다", -- Korean
|
|
"你招架了", -- Chinese Simplified
|
|
"你招架了", -- Chinese Traditional
|
|
},
|
|
type = "player_parry",
|
|
requiresTargetGUID = true, -- Track which enemy you parried
|
|
duration = 4.0 -- 5 second proc window (estimated, may be 4s)
|
|
},
|
|
Revenge = {
|
|
-- Procs when YOU block, dodge, or parry an enemy attack (any stance)
|
|
-- Does NOT require targeting the triggering mob - can use on any target
|
|
patterns = {
|
|
"You block", -- English: "You block X's Y"
|
|
"You dodge", -- English: "You dodge X's Y"
|
|
"You parry", -- English: "You parry X's Y"
|
|
"hits you.*%(%d+ blocked%)", -- English: "X hits you for Y (Z blocked)" - more specific
|
|
"Ihr blockt", -- German block
|
|
"Ihr weicht aus", -- German dodge
|
|
"Ihr pariert", -- German parry
|
|
"Vous bloquez", -- French block
|
|
"Vous esquivez", -- French dodge
|
|
"Vous parez", -- French parry
|
|
"막았습니다", -- Korean block
|
|
"회피했습니다", -- Korean dodge
|
|
"막아냈습니다", -- Korean parry
|
|
"你格挡了", -- Chinese Simplified block
|
|
"你躲闪了", -- Chinese Simplified dodge
|
|
"你招架了", -- Chinese Simplified parry
|
|
"你格擋了", -- Chinese Traditional block
|
|
"你躲閃了", -- Chinese Traditional dodge
|
|
"你招架了", -- Chinese Traditional parry
|
|
},
|
|
type = "player_avoid",
|
|
requiresTargetGUID = false, -- Revenge usable on any target once procced
|
|
duration = 4.0
|
|
}
|
|
}
|
|
|
|
-- Set a reactive proc state with optional target GUID
|
|
function CleveRoids.SetReactiveProc(spellName, duration, targetGUID)
|
|
duration = duration or REACTIVE_PROC_DURATION
|
|
CleveRoids.reactiveProcs[spellName] = {
|
|
expiry = GetTime() + duration,
|
|
targetGUID = targetGUID
|
|
}
|
|
-- Mark that we've seen this reactive spell proc at least once
|
|
CleveRoids.reactiveProcsEverSeen[spellName] = true
|
|
end
|
|
|
|
-- Check if a reactive proc is active (with optional GUID check)
|
|
function CleveRoids.HasReactiveProc(spellName)
|
|
local procData = CleveRoids.reactiveProcs[spellName]
|
|
if not procData or not procData.expiry then
|
|
return false
|
|
end
|
|
|
|
local now = GetTime()
|
|
if now >= procData.expiry then
|
|
-- Expired, clear it
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff9900[HasReactiveProc]|r %s: EXPIRED - clearing and queuing update", spellName)
|
|
)
|
|
end
|
|
CleveRoids.reactiveProcs[spellName] = nil
|
|
-- Queue action update to refresh icon state
|
|
CleveRoids.QueueActionUpdate()
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff9900[HasReactiveProc]|r Action update queued, isActionUpdateQueued = %s", tostring(CleveRoids.isActionUpdateQueued))
|
|
)
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- If proc has a target GUID requirement, check if current target matches
|
|
if procData.targetGUID then
|
|
local targetGUID = CleveRoids.GetGUID("target")
|
|
if not targetGUID or targetGUID ~= procData.targetGUID then
|
|
return false
|
|
end
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
-- Clear a reactive proc
|
|
function CleveRoids.ClearReactiveProc(spellName)
|
|
CleveRoids.reactiveProcs[spellName] = nil
|
|
end
|
|
|
|
-- Parse combat log for reactive ability triggers
|
|
-- lowerMsg: optional pre-lowercased message from dispatcher (avoids redundant string.lower allocation)
|
|
function CleveRoids.ParseReactiveCombatLog(lowerMsg)
|
|
if not arg1 then return end
|
|
|
|
local message = arg1
|
|
|
|
-- PERFORMANCE: Early return if no reactive spells configured
|
|
if not CleveRoids.reactiveSpells or not next(CleveRoids.reactiveSpells) then
|
|
return
|
|
end
|
|
|
|
-- PERFORMANCE: Quick keyword check - most messages won't match
|
|
-- Use pre-computed lowerMsg if provided by dispatcher, otherwise compute it
|
|
lowerMsg = lowerMsg or lower(message)
|
|
if not (strfind(lowerMsg, "dodge") or strfind(lowerMsg, "parry") or strfind(lowerMsg, "block")) then
|
|
return
|
|
end
|
|
|
|
local targetGUID = CleveRoids.GetGUID("target")
|
|
|
|
-- Check each reactive ability's trigger patterns
|
|
-- NOTE: Overpower (enemy_dodge) ALWAYS uses combat log text parsing, even when SPELL_GO
|
|
-- events are available. SPELL_GO only provides hit/miss COUNTS, not miss TYPES, so it
|
|
-- cannot distinguish dodge (Overpower proc) from parry/block/resist (no Overpower proc).
|
|
for spellName, config in pairs(reactivePatterns) do
|
|
if CleveRoids.reactiveSpells and CleveRoids.reactiveSpells[spellName] then
|
|
for _, pattern in ipairs(config.patterns) do
|
|
if strfind(message, pattern) then
|
|
-- Found a trigger event (works in any stance)
|
|
local guid = config.requiresTargetGUID and targetGUID or nil
|
|
local duration = config.duration or REACTIVE_PROC_DURATION
|
|
CleveRoids.SetReactiveProc(spellName, duration, guid)
|
|
|
|
-- For Overpower (enemy dodge), also update LastSwing so [lastswing:dodge] works
|
|
if config.type == "enemy_dodge" and CleveRoids.LastSwing then
|
|
CleveRoids.LastSwing.timestamp = GetTime()
|
|
CleveRoids.LastSwing.victimState = VICTIMSTATE_DODGE_REACTIVE
|
|
CleveRoids.LastSwing.targetGuid = guid
|
|
end
|
|
|
|
-- DEBUG: Show what triggered the proc
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff9900[REACTIVE PROC]|r %s triggered by: |cffffffff%s|r (pattern: |cffcccccc%s|r)",
|
|
spellName, message, pattern)
|
|
)
|
|
end
|
|
|
|
-- Update action buttons to reflect new state
|
|
CleveRoids.QueueActionUpdate()
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Removed: ClearReactiveProcOnCast plus wrappers around Frame.UNIT_CASTEVENT and
|
|
-- Frame.SPELL_START_SELF that tried to clear a reactive proc when its spell was cast.
|
|
-- The feature never worked, for three independent reasons:
|
|
-- 1. Both wrappers guarded on `CleveRoids.Frame and CleveRoids.Frame.<handler>`,
|
|
-- but CleveRoids.Frame is created in Core.lua, which loads AFTER this file, so
|
|
-- the guard was always false and neither wrapper was ever installed.
|
|
-- 2. The UNIT_CASTEVENT wrapper tested arg2 == "START", but that signature is
|
|
-- (caster, target, action, spell_id, cast_time) -- the action is arg3, so it was
|
|
-- comparing the target.
|
|
-- 3. Both passed a spell *ID* to a function that looks the name up in
|
|
-- CleveRoids.reactiveSpells, which is keyed by name, so nothing could match.
|
|
-- They also each allocated an `arg` table per call and unpack()'d it. Deleting is
|
|
-- behaviour-preserving; if clearing a reactive proc on cast is still wanted it needs
|
|
-- to be written fresh against the real handler signatures.
|
|
|
|
-- NAMPOWER v2.24+ AUTO_ATTACK EVENT HANDLER FOR REACTIVE ABILITIES
|
|
-- Uses native events for dodge/parry/block detection when available.
|
|
-- Falls back to combat log parsing for older Nampower versions.
|
|
|
|
-- VictimState constants (from AUTO_ATTACK / SPELL_GO events)
|
|
local VICTIMSTATE_UNAFFECTED = 0 -- Generic miss (seen with HITINFO_MISS)
|
|
local VICTIMSTATE_NORMAL = 1 -- Hit landed
|
|
local VICTIMSTATE_DODGE = 2
|
|
local VICTIMSTATE_PARRY = 3
|
|
local VICTIMSTATE_INTERRUPT = 4
|
|
local VICTIMSTATE_BLOCKS = 5
|
|
local VICTIMSTATE_EVADES = 6
|
|
local VICTIMSTATE_IS_IMMUNE = 7
|
|
local VICTIMSTATE_DEFLECTS = 8
|
|
|
|
-- HitInfo constant for SPELL_GO miss tracking
|
|
local HITINFO_MISS = 16 -- 0x10
|
|
|
|
-- Track if we're using Nampower events (set during initialization)
|
|
CleveRoids.usingNampowerAutoAttack = false
|
|
|
|
-- Process AUTO_ATTACK events for reactive ability procs
|
|
-- Parameters: attackerGuid, targetGuid, totalDamage, hitInfo, victimState, ...
|
|
function CleveRoids.ProcessAutoAttackEvent(isPlayerAttacker, attackerGuid, targetGuid, totalDamage, hitInfo, victimState)
|
|
-- Get player GUID for comparison
|
|
local playerGUID = CleveRoids.GetGUID("player")
|
|
if not playerGUID then return end
|
|
|
|
-- Determine current target GUID
|
|
local currentTargetGUID = CleveRoids.GetGUID("target")
|
|
|
|
-- ========================================================================
|
|
-- OVERPOWER / SURPRISE ATTACK: Enemy dodges YOUR attack
|
|
-- ========================================================================
|
|
if isPlayerAttacker and victimState == VICTIMSTATE_DODGE then
|
|
-- Enemy dodged our attack - proc all dodge-reactive spells
|
|
if CleveRoids.reactiveSpells and CleveRoids.reactiveSpells["Overpower"] then
|
|
CleveRoids.SetReactiveProc("Overpower", 4.0, targetGuid)
|
|
CleveRoids.QueueActionUpdate()
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00ff00[AUTO_ATTACK]|r Overpower proc - enemy dodged (victimState=%d)", victimState))
|
|
end
|
|
end
|
|
if CleveRoids.reactiveSpells and CleveRoids.reactiveSpells["Surprise Attack"] then
|
|
CleveRoids.SetReactiveProc("Surprise Attack", 4.0, targetGuid)
|
|
CleveRoids.QueueActionUpdate()
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00ff00[AUTO_ATTACK]|r Surprise Attack proc - enemy dodged (victimState=%d)", victimState))
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ========================================================================
|
|
-- RIPOSTE: YOU parry an enemy attack
|
|
-- ========================================================================
|
|
if not isPlayerAttacker and targetGuid == playerGUID and victimState == VICTIMSTATE_PARRY then
|
|
-- We parried an enemy attack - Riposte proc
|
|
if CleveRoids.reactiveSpells and CleveRoids.reactiveSpells["Riposte"] then
|
|
-- Riposte requires targeting the mob we parried
|
|
CleveRoids.SetReactiveProc("Riposte", 4.0, attackerGuid)
|
|
CleveRoids.QueueActionUpdate()
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00ff00[AUTO_ATTACK]|r Riposte proc - player parried (victimState=%d)",
|
|
victimState)
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ========================================================================
|
|
-- REVENGE: YOU block, dodge, or parry an enemy attack
|
|
-- ========================================================================
|
|
if not isPlayerAttacker and targetGuid == playerGUID then
|
|
local isAvoidance = (victimState == VICTIMSTATE_DODGE or
|
|
victimState == VICTIMSTATE_PARRY or
|
|
victimState == VICTIMSTATE_BLOCKS)
|
|
if isAvoidance then
|
|
if CleveRoids.reactiveSpells and CleveRoids.reactiveSpells["Revenge"] then
|
|
-- Revenge can be used on any target once procced
|
|
CleveRoids.SetReactiveProc("Revenge", 4.0, nil)
|
|
CleveRoids.QueueActionUpdate()
|
|
|
|
if CleveRoids.debug then
|
|
local avoidType = victimState == VICTIMSTATE_DODGE and "dodge" or
|
|
(victimState == VICTIMSTATE_PARRY and "parry" or "block")
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00ff00[AUTO_ATTACK]|r Revenge proc - player %s (victimState=%d)",
|
|
avoidType, victimState)
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- NAMPOWER v2.31+ SPELL_MISS EVENT HANDLERS
|
|
-- Provides direct miss type information (immune, dodge, resist, etc.) with spellId + targetGuid,
|
|
-- eliminating the need for combat log text parsing and time-delayed correlation.
|
|
|
|
-- MissInfo constants (local copies for performance)
|
|
local MISSINFO_MISS = 1
|
|
local MISSINFO_RESIST = 2
|
|
local MISSINFO_DODGE = 3
|
|
local MISSINFO_PARRY = 4
|
|
local MISSINFO_BLOCK = 5
|
|
local MISSINFO_EVADE = 6
|
|
local MISSINFO_IMMUNE = 7
|
|
local MISSINFO_IMMUNE2 = 8
|
|
local MISSINFO_REFLECT = 11
|
|
|
|
-- Track if we're using SPELL_MISS events (set during initialization)
|
|
CleveRoids.usingSpellMissEvents = false
|
|
|
|
-- Process SPELL_MISS_SELF: player's spell missed/was resisted/immune/etc.
|
|
-- Parameters: spellId, targetGuid, missInfo, [unused]
|
|
local function ProcessSpellMissSelf(spellId, targetGuid, missInfo)
|
|
if not spellId or not targetGuid or not missInfo then return end
|
|
|
|
-- Resolve target name from GUID cache, current target, or extended tokens
|
|
local hasExtTokens = CleveRoids.NampowerAPI and CleveRoids.NampowerAPI.features
|
|
and CleveRoids.NampowerAPI.features.hasExtendedUnitTokens
|
|
local targetName = lib.guidToName[targetGuid]
|
|
if not targetName then
|
|
local currentTargetGUID = CleveRoids.GetGUID("target")
|
|
if currentTargetGUID == targetGuid then
|
|
targetName = UnitName("target")
|
|
if targetName then
|
|
lib.guidToName[targetGuid] = targetName
|
|
end
|
|
end
|
|
end
|
|
-- Nampower extended tokens fallback: resolve name from GUID directly
|
|
if not targetName and hasExtTokens and UnitExists(targetGuid) then
|
|
targetName = UnitName(targetGuid)
|
|
if targetName then
|
|
lib.guidToName[targetGuid] = targetName
|
|
end
|
|
end
|
|
-- Cross-validate cached name against live data when extended tokens available
|
|
if targetName and hasExtTokens and UnitExists(targetGuid) then
|
|
local liveName = UnitName(targetGuid)
|
|
if liveName and liveName ~= "Unknown" and liveName ~= targetName then
|
|
lib.guidToName[targetGuid] = liveName
|
|
targetName = liveName
|
|
end
|
|
end
|
|
|
|
-- Resolve spell name
|
|
local spellName = type(spellId) == "number" and spellId > 0 and C_Spell.GetSpellName(spellId)
|
|
local baseName = spellName and string.gsub(spellName, "%s*%(.-%)%s*$", "") or nil
|
|
|
|
-- ========================================================================
|
|
-- IMMUNE / IMMUNE2 (missInfo 7/8)
|
|
-- ========================================================================
|
|
if missInfo == MISSINFO_IMMUNE or missInfo == MISSINFO_IMMUNE2 then
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff6600[SPELL_MISS]|r IMMUNE - %s on %s (spellId=%d, missInfo=%d)",
|
|
spellName or "?", targetName or "?", spellId, missInfo)
|
|
)
|
|
end
|
|
|
|
if targetName and spellName then
|
|
-- Cancel any pending delayed verification immediately
|
|
CancelPendingVerification(targetName, spellName)
|
|
|
|
-- Skip split CC spells (physical damage + resistable CC)
|
|
if SPLIT_CC_SPELLS[spellId] or (baseName and SPLIT_CC_SPELL_NAMES[baseName]) then
|
|
if CleveRoids.debug then
|
|
CleveRoids.Print("|cff00aaff[SPELL_MISS Split CC Skip]|r " .. spellName .. " CC immune on " .. targetName)
|
|
end
|
|
return
|
|
end
|
|
|
|
-- Skip if target recently died (dead targets report IMMUNE for in-flight spells)
|
|
if lib.recentDeaths[targetGuid] and (GetTime() - lib.recentDeaths[targetGuid]) < 3 then
|
|
if CleveRoids.debug then
|
|
CleveRoids.Print("|cff00aaff[SPELL_MISS Dead Skip]|r " .. targetName .. " recently died - ignoring immunity")
|
|
end
|
|
return
|
|
end
|
|
-- Also check if target is truly dead via Nampower health
|
|
if UnitExists("target") and UnitName("target") == targetName and CleveRoids.IsUnitDead("target") then
|
|
if CleveRoids.debug then
|
|
CleveRoids.Print("|cff00aaff[SPELL_MISS Dead Skip]|r " .. targetName .. " is dead - ignoring immunity")
|
|
end
|
|
-- Record death GUID for future spells in same frame
|
|
lib.recentDeaths[targetGuid] = GetTime()
|
|
return
|
|
end
|
|
|
|
-- Resolve a queryable unit for buff checks
|
|
-- Try current target first, then GUID via extended tokens
|
|
local queryUnit = nil
|
|
if UnitExists("target") and UnitName("target") == targetName then
|
|
queryUnit = "target"
|
|
elseif hasExtTokens and UnitExists(targetGuid) then
|
|
queryUnit = targetGuid
|
|
end
|
|
|
|
-- Skip if target has temporary immunity buff (Divine Shield, etc.)
|
|
if queryUnit then
|
|
local immunityBuff = HasImmunityGrantingBuff(queryUnit)
|
|
if immunityBuff then
|
|
if CleveRoids.debug then
|
|
CleveRoids.Print("|cff00aaff[SPELL_MISS Temp Skip]|r " .. targetName .. " has " .. immunityBuff)
|
|
end
|
|
return
|
|
end
|
|
end
|
|
|
|
-- Check DR before recording as permanent CC immunity
|
|
local ccType = GetSpellCCType(spellId)
|
|
if ccType and targetGuid then
|
|
local drEntry = lib.recentCCHits[targetGuid] and lib.recentCCHits[targetGuid][ccType]
|
|
if drEntry and (GetTime() - drEntry.lastHitTime) < 20 and drEntry.count >= 3 then
|
|
if CleveRoids.debug then
|
|
CleveRoids.Print("|cff00aaff[SPELL_MISS DR Skip]|r " .. targetName .. " - likely DR immune to " .. ccType .. ", not recording")
|
|
end
|
|
return
|
|
end
|
|
end
|
|
|
|
-- Cannot query target (despawned/phased/switched) - inconclusive, don't record
|
|
if not queryUnit then
|
|
if CleveRoids.debug then
|
|
CleveRoids.Print("|cff00aaff[SPELL_MISS Inconclusive]|r Cannot query " .. targetName .. " for buffs - not recording immunity")
|
|
end
|
|
return
|
|
end
|
|
|
|
-- Record as permanent immunity
|
|
if ccType then
|
|
RecordCCImmunity(targetName, ccType, nil, spellName)
|
|
else
|
|
RecordImmunity(targetName, spellName, nil, spellId)
|
|
end
|
|
end
|
|
|
|
-- Populate backward-compat tables for SPELL_GO correlation
|
|
if targetName and spellName then
|
|
lib.recentCombatLogReasons[targetName] = lib.recentCombatLogReasons[targetName] or {}
|
|
lib.recentCombatLogReasons[targetName][spellName] = {
|
|
time = GetTime(),
|
|
reason = "immune",
|
|
}
|
|
end
|
|
if targetGuid and spellName then
|
|
lib.recentMisses[targetGuid] = lib.recentMisses[targetGuid] or {}
|
|
lib.recentMisses[targetGuid][spellName] = {
|
|
time = GetTime(),
|
|
spellId = spellId,
|
|
targetGuid = targetGuid,
|
|
targetName = targetName,
|
|
reason = "immune",
|
|
}
|
|
end
|
|
return
|
|
end
|
|
|
|
-- ========================================================================
|
|
-- REFLECT (missInfo 11)
|
|
-- ========================================================================
|
|
if missInfo == MISSINFO_REFLECT then
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff00ff[SPELL_MISS]|r REFLECT - %s reflected by %s",
|
|
spellName or "?", targetName or "?")
|
|
)
|
|
end
|
|
|
|
if targetName and spellName then
|
|
lib.recentReflects = lib.recentReflects or {}
|
|
lib.recentReflects[targetName] = lib.recentReflects[targetName] or {}
|
|
lib.recentReflects[targetName][spellName] = {
|
|
time = GetTime(),
|
|
spellId = spellId,
|
|
}
|
|
|
|
lib.recentCombatLogReasons[targetName] = lib.recentCombatLogReasons[targetName] or {}
|
|
lib.recentCombatLogReasons[targetName][spellName] = {
|
|
time = GetTime(),
|
|
reason = "reflect",
|
|
}
|
|
end
|
|
return
|
|
end
|
|
|
|
-- ========================================================================
|
|
-- EVADE (missInfo 6)
|
|
-- ========================================================================
|
|
if missInfo == MISSINFO_EVADE then
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffaaaaaa[SPELL_MISS]|r EVADE - %s evaded by %s (not immunity)",
|
|
spellName or "?", targetName or "?")
|
|
)
|
|
end
|
|
|
|
if targetName and spellName then
|
|
lib.recentCombatLogReasons[targetName] = lib.recentCombatLogReasons[targetName] or {}
|
|
lib.recentCombatLogReasons[targetName][spellName] = {
|
|
time = GetTime(),
|
|
reason = "evade",
|
|
}
|
|
end
|
|
return
|
|
end
|
|
|
|
-- ========================================================================
|
|
-- DODGE (missInfo 3) - Overpower / Surprise Attack proc for yellow abilities
|
|
-- ========================================================================
|
|
if missInfo == MISSINFO_DODGE then
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00ff00[SPELL_MISS]|r DODGE - %s dodged by %s",
|
|
spellName or "?", targetName or "?")
|
|
)
|
|
end
|
|
|
|
-- Overpower proc (enemy dodged our yellow ability)
|
|
if CleveRoids.reactiveSpells and CleveRoids.reactiveSpells["Overpower"] then
|
|
CleveRoids.SetReactiveProc("Overpower", 4.0, targetGuid)
|
|
end
|
|
|
|
-- Surprise Attack proc (same trigger as Overpower - enemy dodged)
|
|
if CleveRoids.reactiveSpells and CleveRoids.reactiveSpells["Surprise Attack"] then
|
|
CleveRoids.SetReactiveProc("Surprise Attack", 4.0, targetGuid)
|
|
end
|
|
|
|
-- Update LastSwing tracking
|
|
if CleveRoids.LastSwing then
|
|
CleveRoids.LastSwing.timestamp = GetTime()
|
|
CleveRoids.LastSwing.hitInfo = HITINFO_MISS
|
|
CleveRoids.LastSwing.victimState = VICTIMSTATE_DODGE
|
|
CleveRoids.LastSwing.damage = 0
|
|
CleveRoids.LastSwing.blockedAmount = 0
|
|
CleveRoids.LastSwing.absorbAmount = 0
|
|
CleveRoids.LastSwing.resistAmount = 0
|
|
CleveRoids.LastSwing.targetGuid = targetGuid
|
|
end
|
|
|
|
CleveRoids.QueueActionUpdate()
|
|
return
|
|
end
|
|
|
|
-- ========================================================================
|
|
-- RESIST (missInfo 2) - Full resist tracking
|
|
-- ========================================================================
|
|
if missInfo == MISSINFO_RESIST then
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff9900[SPELL_MISS]|r RESIST - %s fully resisted by %s",
|
|
spellName or "?", targetName or "?")
|
|
)
|
|
end
|
|
|
|
CleveRoids.SetResistState("full", targetGuid)
|
|
return
|
|
end
|
|
|
|
-- ========================================================================
|
|
-- PARRY (missInfo 4) - Populate combat log reasons for completeness
|
|
-- ========================================================================
|
|
if missInfo == MISSINFO_PARRY then
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff8888ff[SPELL_MISS]|r PARRY - %s parried by %s",
|
|
spellName or "?", targetName or "?")
|
|
)
|
|
end
|
|
|
|
-- Update LastSwing tracking for parried yellow abilities
|
|
if CleveRoids.LastSwing then
|
|
CleveRoids.LastSwing.timestamp = GetTime()
|
|
CleveRoids.LastSwing.hitInfo = HITINFO_MISS
|
|
CleveRoids.LastSwing.victimState = VICTIMSTATE_PARRY
|
|
CleveRoids.LastSwing.damage = 0
|
|
CleveRoids.LastSwing.blockedAmount = 0
|
|
CleveRoids.LastSwing.absorbAmount = 0
|
|
CleveRoids.LastSwing.resistAmount = 0
|
|
CleveRoids.LastSwing.targetGuid = targetGuid
|
|
end
|
|
|
|
CleveRoids.QueueActionUpdate()
|
|
return
|
|
end
|
|
end
|
|
|
|
-- Process SPELL_MISS_OTHER: another unit's spell missed (player might be the target)
|
|
-- Parameters: spellId, casterGuid, targetGuid, missInfo
|
|
local function ProcessSpellMissOther(spellId, casterGuid, targetGuid, missInfo)
|
|
if not targetGuid or not missInfo then return end
|
|
|
|
-- Only care when player is the target (victim)
|
|
local playerGUID = CleveRoids.GetGUID("player")
|
|
if not playerGUID or targetGuid ~= playerGUID then return end
|
|
|
|
-- DODGE: Enemy spell dodged by player → Revenge proc
|
|
if missInfo == MISSINFO_DODGE then
|
|
if CleveRoids.reactiveSpells and CleveRoids.reactiveSpells["Revenge"] then
|
|
CleveRoids.SetReactiveProc("Revenge", 4.0, nil)
|
|
CleveRoids.QueueActionUpdate()
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[SPELL_MISS]|r Revenge proc - player dodged enemy spell")
|
|
end
|
|
end
|
|
end
|
|
|
|
-- PARRY: Enemy spell parried by player → Riposte + Revenge proc
|
|
if missInfo == MISSINFO_PARRY then
|
|
if CleveRoids.reactiveSpells and CleveRoids.reactiveSpells["Riposte"] then
|
|
CleveRoids.SetReactiveProc("Riposte", 4.0, casterGuid)
|
|
CleveRoids.QueueActionUpdate()
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[SPELL_MISS]|r Riposte proc - player parried enemy spell")
|
|
end
|
|
end
|
|
if CleveRoids.reactiveSpells and CleveRoids.reactiveSpells["Revenge"] then
|
|
CleveRoids.SetReactiveProc("Revenge", 4.0, nil)
|
|
CleveRoids.QueueActionUpdate()
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[SPELL_MISS]|r Revenge proc - player parried enemy spell")
|
|
end
|
|
end
|
|
end
|
|
|
|
-- BLOCK: Enemy spell blocked by player → Revenge proc
|
|
if missInfo == MISSINFO_BLOCK then
|
|
if CleveRoids.reactiveSpells and CleveRoids.reactiveSpells["Revenge"] then
|
|
CleveRoids.SetReactiveProc("Revenge", 4.0, nil)
|
|
CleveRoids.QueueActionUpdate()
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[SPELL_MISS]|r Revenge proc - player blocked enemy spell")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Register combat log event for reactive proc tracking (FALLBACK)
|
|
-- CHAT_MSG_SPELL_SELF_DAMAGE is needed for yellow ability dodge/parry/block detection
|
|
-- (e.g., "Your Mortal Strike was dodged by Target.") - only auto-attack dodges come through COMBAT_SELF_MISSES
|
|
-- CHAT_MSG_COMBAT_CREATURE_VS_SELF_HITS is needed for partial block detection (Revenge)
|
|
local reactiveFrame = CreateFrame("Frame", "CleveRoidsReactiveFrame")
|
|
|
|
-- Check for Nampower v2.24+ AUTO_ATTACK events
|
|
local hasAutoAttackEvents = false
|
|
if GetNampowerVersion then
|
|
local npMajor, npMinor = GetNampowerVersion()
|
|
if npMajor > 2 or (npMajor == 2 and npMinor >= 24) then
|
|
hasAutoAttackEvents = true
|
|
CleveRoids.usingNampowerAutoAttack = true
|
|
|
|
-- Auto-enable the CVar required for AUTO_ATTACK events
|
|
-- This is persistent, so only set if not already enabled
|
|
if GetCVar and SetCVar then
|
|
local currentValue = GetCVar("NP_EnableAutoAttackEvents")
|
|
if currentValue ~= "1" then
|
|
SetCVar("NP_EnableAutoAttackEvents", "1")
|
|
-- Note: CVar takes effect on next login/reload, but we register anyway
|
|
end
|
|
end
|
|
|
|
reactiveFrame:RegisterEvent("AUTO_ATTACK_SELF")
|
|
reactiveFrame:RegisterEvent("AUTO_ATTACK_OTHER")
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[Nampower]|r Using AUTO_ATTACK events for reactive abilities (v2.24+)")
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Check for Nampower v2.31+ SPELL_MISS events (direct miss type information)
|
|
local hasSpellMissEvents = false
|
|
if GetNampowerVersion then
|
|
local npMajor, npMinor = GetNampowerVersion()
|
|
if npMajor > 2 or (npMajor == 2 and npMinor >= 31) then
|
|
hasSpellMissEvents = true
|
|
CleveRoids.usingSpellMissEvents = true
|
|
reactiveFrame:RegisterEvent("SPELL_MISS_SELF")
|
|
reactiveFrame:RegisterEvent("SPELL_MISS_OTHER")
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[Nampower]|r Using SPELL_MISS events for immunity/reactive detection (v2.31+)")
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Check for Nampower v2.25+ SPELL_GO events (LastSwing tracking for yellow attack misses)
|
|
-- NOTE: SPELL_GO only provides hit/miss counts, not miss types, so Overpower still needs
|
|
-- combat log text parsing. SPELL_GO is used as a gate to avoid parsing combat log on every
|
|
-- successful spell hit - only parse when SPELL_GO reports a miss.
|
|
local hasSpellGoEvents = false
|
|
if GetNampowerVersion then
|
|
local npMajor, npMinor = GetNampowerVersion()
|
|
if npMajor > 2 or (npMajor == 2 and npMinor >= 25) then
|
|
hasSpellGoEvents = true
|
|
reactiveFrame:RegisterEvent("SPELL_GO_SELF")
|
|
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[Nampower]|r Using SPELL_GO events for yellow attack LastSwing tracking (v2.25+)")
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Register combat log events for reactive abilities
|
|
-- Outgoing dodge detection (Overpower) uses combat log text parsing because SPELL_GO only
|
|
-- provides hit/miss counts, not miss types (dodge vs parry vs block vs resist). However,
|
|
-- with SPELL_GO available, CHAT_MSG_SPELL_SELF_DAMAGE parsing is gated behind a miss window
|
|
-- so we skip parsing on every successful spell hit (see OnEvent handler below).
|
|
-- AUTO_ATTACK events handle white swing avoidance; combat log handles yellow ability dodges.
|
|
if hasAutoAttackEvents then
|
|
-- Incoming avoidance (Riposte/Revenge)
|
|
reactiveFrame:RegisterEvent("CHAT_MSG_COMBAT_CREATURE_VS_SELF_MISSES")
|
|
reactiveFrame:RegisterEvent("CHAT_MSG_COMBAT_CREATURE_VS_SELF_HITS")
|
|
-- Outgoing dodge detection (Overpower) - gated by SPELL_GO miss window when available
|
|
reactiveFrame:RegisterEvent("CHAT_MSG_COMBAT_SELF_MISSES")
|
|
reactiveFrame:RegisterEvent("CHAT_MSG_SPELL_SELF_DAMAGE")
|
|
else
|
|
-- Full combat log fallback
|
|
reactiveFrame:RegisterEvent("RAW_COMBATLOG")
|
|
reactiveFrame:RegisterEvent("CHAT_MSG_COMBAT_SELF_MISSES")
|
|
reactiveFrame:RegisterEvent("CHAT_MSG_SPELL_SELF_DAMAGE")
|
|
reactiveFrame:RegisterEvent("CHAT_MSG_COMBAT_CREATURE_VS_SELF_MISSES")
|
|
reactiveFrame:RegisterEvent("CHAT_MSG_COMBAT_CREATURE_VS_SELF_HITS")
|
|
end
|
|
|
|
reactiveFrame:SetScript("OnEvent", function()
|
|
-- ========================================================================
|
|
-- NAMPOWER v2.31+ SPELL_MISS EVENTS (preferred when available)
|
|
-- ========================================================================
|
|
if event == "SPELL_MISS_SELF" then
|
|
-- Player's spell missed: arg1=spellId, arg2=targetGuid, arg3=missInfo
|
|
ProcessSpellMissSelf(arg1, arg2, arg3)
|
|
return
|
|
end
|
|
|
|
if event == "SPELL_MISS_OTHER" then
|
|
-- Other unit's spell missed: arg1=spellId, arg2=casterGuid, arg3=targetGuid, arg4=missInfo
|
|
ProcessSpellMissOther(arg1, arg2, arg3, arg4)
|
|
return
|
|
end
|
|
|
|
-- ========================================================================
|
|
-- NAMPOWER v2.24+ AUTO_ATTACK EVENTS (preferred when available)
|
|
-- ========================================================================
|
|
if event == "AUTO_ATTACK_SELF" then
|
|
-- Player is the TARGET of an attack
|
|
-- Parameters: attackerGuid, targetGuid, totalDamage, hitInfo, victimState, ...
|
|
local attackerGuid = arg1
|
|
local targetGuid = arg2
|
|
local totalDamage = arg3
|
|
local hitInfo = arg4
|
|
local victimState = arg5
|
|
|
|
CleveRoids.ProcessAutoAttackEvent(false, attackerGuid, targetGuid, totalDamage, hitInfo, victimState)
|
|
return -- Handled by Nampower, skip combat log parsing
|
|
end
|
|
|
|
if event == "AUTO_ATTACK_OTHER" then
|
|
-- Player is the ATTACKER (or watching other units)
|
|
local attackerGuid = arg1
|
|
local targetGuid = arg2
|
|
local totalDamage = arg3
|
|
local hitInfo = arg4
|
|
local victimState = arg5
|
|
|
|
-- Check if player is the attacker
|
|
local playerGUID = CleveRoids.GetGUID("player")
|
|
local isPlayerAttacker = (attackerGuid == playerGUID)
|
|
|
|
CleveRoids.ProcessAutoAttackEvent(isPlayerAttacker, attackerGuid, targetGuid, totalDamage, hitInfo, victimState)
|
|
return -- Handled by Nampower, skip combat log parsing
|
|
end
|
|
|
|
-- ========================================================================
|
|
-- NAMPOWER v2.25+ SPELL_GO EVENTS (yellow attack LastSwing tracking)
|
|
-- ========================================================================
|
|
-- Two-step: SPELL_CAST_EVENT (success=1) → SPELL_GO_SELF (hit/miss outcome)
|
|
-- Updates LastSwing state for yellow ability misses. Overpower detection still uses
|
|
-- combat log text parsing (SPELL_GO only has hit/miss counts, not types like dodge).
|
|
-- SPELL_GO_SELF params: itemId, spellId, casterGuid, targetGuid, castFlags, numTargetsHit, numTargetsMissed
|
|
if event == "SPELL_GO_SELF" then
|
|
local spellId = arg2
|
|
local targetGuid = arg4
|
|
local numHit = arg6 or 0
|
|
local numMissed = arg7 or 0
|
|
|
|
-- Step 1: Validate against SPELL_CAST_EVENT (confirms player-initiated, successful cast)
|
|
local pending = CleveRoids.pendingCasts and CleveRoids.pendingCasts[spellId]
|
|
if not pending or pending.consumed then
|
|
return -- No matching SPELL_CAST_EVENT with success=1, or already processed
|
|
end
|
|
|
|
-- Mark pending cast as consumed with timestamp instead of deleting immediately.
|
|
-- Other handlers (libdebuff SPELL_GO, AURA_CAST, ComboPointTracker) on different
|
|
-- frames may still need to read this data. Stale entries are cleaned up on next
|
|
-- SPELL_CAST_EVENT for the same spellId (overwritten) or expire naturally.
|
|
pending.consumed = true
|
|
pending.consumedAt = GetTime()
|
|
|
|
-- Skip channels and targeting spells (not melee/ranged attacks)
|
|
-- CastType: NORMAL=1, NON_GCD=2, ON_SWING=3, CHANNEL=4, TARGETING=5, TARGETING_NON_GCD=6
|
|
if pending.castType and pending.castType >= 4 then
|
|
return
|
|
end
|
|
|
|
-- Step 2: Check hit/miss outcome for LastSwing tracking
|
|
-- NOTE: SPELL_GO only provides hit/miss COUNTS, not miss TYPES (dodge vs parry vs
|
|
-- block vs resist). Overpower detection is handled by combat log text parsing which
|
|
-- can distinguish "dodged" from other avoidance types. We only update LastSwing here
|
|
-- as a generic miss indicator (combat log overwrites with specific type if dodge).
|
|
if numMissed >= 1 and numHit == 0 then
|
|
-- Resolve the target GUID. v2.40+ fixes packed GUID parsing so player GUIDs
|
|
-- are no longer returned as 0x0000000000000000 in SPELL_GO. The fallback
|
|
-- chain here is still needed for spells that genuinely have no single target
|
|
-- in SPELL_GO (AoE, on-swing abilities, etc.).
|
|
local procTarget = targetGuid
|
|
if not procTarget or procTarget == "0x0000000000000000" then
|
|
procTarget = pending.targetGuid
|
|
if not procTarget or procTarget == "0x0000000000000000" then
|
|
procTarget = CleveRoids.GetGUID("target")
|
|
end
|
|
end
|
|
|
|
-- Update LastSwing for yellow miss (victimState unknown from SPELL_GO, use UNAFFECTED)
|
|
-- Don't overwrite AUTO_ATTACK data from the same frame (on-swing abilities fire both)
|
|
-- Combat log handler will overwrite with specific type (dodge/parry/etc.) if available
|
|
if CleveRoids.LastSwing and CleveRoids.LastSwing.timestamp ~= GetTime() then
|
|
CleveRoids.LastSwing.timestamp = GetTime()
|
|
CleveRoids.LastSwing.hitInfo = HITINFO_MISS
|
|
CleveRoids.LastSwing.victimState = VICTIMSTATE_UNAFFECTED
|
|
CleveRoids.LastSwing.damage = 0
|
|
CleveRoids.LastSwing.blockedAmount = 0
|
|
CleveRoids.LastSwing.absorbAmount = 0
|
|
CleveRoids.LastSwing.resistAmount = 0
|
|
CleveRoids.LastSwing.targetGuid = procTarget
|
|
end
|
|
|
|
-- Gate combat log parsing: set miss window so CHAT_MSG_SPELL_SELF_DAMAGE
|
|
-- only calls ParseReactiveCombatLog when we know a yellow miss occurred.
|
|
-- This avoids parsing every successful spell hit message.
|
|
-- Skip when SPELL_MISS handles dodge/parry detection directly (v2.31+)
|
|
if not hasSpellMissEvents then
|
|
CleveRoids._yellowMissWindow = GetTime()
|
|
end
|
|
|
|
if CleveRoids.debug then
|
|
local spellName = C_Spell.GetSpellName(spellId) or tostring(spellId)
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cff00ff00[SPELL_GO]|r Yellow miss detected - %s (hit=%d, miss=%d, castType=%s) - deferring to combat log for type",
|
|
spellName, numHit, numMissed, tostring(pending.castType))
|
|
)
|
|
end
|
|
end
|
|
|
|
return
|
|
end
|
|
|
|
-- ========================================================================
|
|
-- COMBAT LOG TEXT PARSING FOR REACTIVE ABILITIES
|
|
-- ========================================================================
|
|
-- Handles: Overpower (outgoing dodge), Riposte (incoming parry), Revenge (incoming avoid).
|
|
-- SPELL_GO provides hit/miss counts but not types, so combat log is still needed to
|
|
-- distinguish dodge (Overpower) from parry/block/resist. But we only parse when SPELL_GO
|
|
-- told us a miss occurred, avoiding overhead on every successful spell hit.
|
|
if event == "CHAT_MSG_SPELL_SELF_DAMAGE" then
|
|
-- With SPELL_MISS events (v2.31+): dodge/parry/block handled directly, skip combat log
|
|
if hasSpellMissEvents then
|
|
return
|
|
end
|
|
-- Yellow ability avoidance (e.g., "Your Mortal Strike was dodged by Target")
|
|
-- With SPELL_GO events: only parse when a yellow miss was recently detected.
|
|
-- Without SPELL_GO: always parse (fallback for older Nampower).
|
|
if hasSpellGoEvents then
|
|
local missWindow = CleveRoids._yellowMissWindow
|
|
if not missWindow or (GetTime() - missWindow) > 1.0 then
|
|
return -- No recent yellow miss from SPELL_GO, skip parsing
|
|
end
|
|
end
|
|
CleveRoids.ParseReactiveCombatLog()
|
|
elseif event == "RAW_COMBATLOG" or
|
|
event == "CHAT_MSG_COMBAT_SELF_MISSES" or
|
|
event == "CHAT_MSG_COMBAT_CREATURE_VS_SELF_MISSES" or
|
|
event == "CHAT_MSG_COMBAT_CREATURE_VS_SELF_HITS" then
|
|
CleveRoids.ParseReactiveCombatLog()
|
|
end
|
|
end)
|
|
|
|
-- RESIST TRACKING SYSTEM
|
|
-- Tracks full and partial spell resists for [resisted] and [noresisted] conditionals
|
|
|
|
-- Set resist state with target GUID matching
|
|
function CleveRoids.SetResistState(resistType, targetGUID)
|
|
CleveRoids.resistState = {
|
|
resistType = resistType,
|
|
targetGUID = targetGUID
|
|
}
|
|
CleveRoids.QueueActionUpdate()
|
|
|
|
if CleveRoids.debug then
|
|
local targetName = UnitName("target") or "Unknown"
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
string.format("|cffff6600[Resist Track]|r %s resist on %s",
|
|
string.upper(resistType), targetName)
|
|
)
|
|
end
|
|
end
|
|
|
|
-- Clear resist state
|
|
function CleveRoids.ClearResistState()
|
|
if CleveRoids.resistState then
|
|
CleveRoids.resistState = nil
|
|
CleveRoids.QueueActionUpdate()
|
|
end
|
|
end
|
|
|
|
-- Check resist state (used by conditionals)
|
|
-- Returns true if resist matches the type AND current target matches the GUID
|
|
function CleveRoids.CheckResistState(resistType)
|
|
local state = CleveRoids.resistState
|
|
if not state then return false end
|
|
|
|
-- Must have current target that matches the GUID from resist event
|
|
local currentTargetGUID = CleveRoids.GetGUID("target")
|
|
if not currentTargetGUID or currentTargetGUID ~= state.targetGUID then
|
|
return false
|
|
end
|
|
|
|
-- If no specific type requested, any resist matches
|
|
if not resistType or resistType == "" then
|
|
return true
|
|
end
|
|
|
|
-- Check specific resist type
|
|
return state.resistType == string.lower(resistType)
|
|
end
|
|
|
|
-- Parse combat log for resist messages
|
|
-- lowerMsg: optional pre-lowercased message from dispatcher (avoids redundant string.lower allocation)
|
|
local function ParseResistCombatLog(lowerMsg)
|
|
local message = arg1 -- Formatted message (localized)
|
|
|
|
if not message then return end
|
|
|
|
-- PERFORMANCE: Quick length check (minimum resist message ~15 chars)
|
|
if string.len(message) < 15 then return end
|
|
|
|
-- PERFORMANCE: Quick keyword check before expensive pattern matching
|
|
-- Use pre-computed lowerMsg if provided by dispatcher, otherwise compute it
|
|
lowerMsg = lowerMsg or string.lower(message)
|
|
if not string.find(lowerMsg, "resist") then return end
|
|
|
|
-- Get current target info for matching
|
|
local targetGUID = CleveRoids.GetGUID("target")
|
|
if not targetGUID then return end
|
|
|
|
local targetName = UnitName("target")
|
|
if not targetName then return end
|
|
|
|
-- Verify the resist was against current target by checking if target name appears in message
|
|
if not string.find(message, targetName, 1, true) then
|
|
-- Target name not found in message - resist was against different mob
|
|
return
|
|
end
|
|
|
|
-- Detect partial resist: "Your X hit Y for Z. (N resisted)"
|
|
-- Key pattern: "resisted)" with closing parenthesis indicates damage was dealt
|
|
local isPartial = string.find(message, "resisted%)")
|
|
|
|
-- Detect full resist patterns:
|
|
-- Pattern 1: "Your X was resisted by Y."
|
|
-- Pattern 2: "Y resists your X."
|
|
local isFull = false
|
|
if not isPartial then
|
|
if string.find(message, "was resisted by") or string.find(message, "resists your") then
|
|
isFull = true
|
|
end
|
|
end
|
|
|
|
-- Set resist state based on type detected
|
|
if isPartial then
|
|
CleveRoids.SetResistState("partial", targetGUID)
|
|
elseif isFull then
|
|
CleveRoids.SetResistState("full", targetGUID)
|
|
end
|
|
end
|
|
|
|
-- UNIFIED COMBAT LOG DISPATCHER
|
|
-- Consolidates all RAW_COMBATLOG handling into a single frame for performance.
|
|
-- Previously, 4 separate frames were each parsing the same combat log events.
|
|
-- This single dispatcher calls all parsing functions once per event.
|
|
--
|
|
-- Functions called:
|
|
-- 1. HandleDebuffFade() - Learn debuff durations when they fade
|
|
-- 2. ParseImmunityCombatLog() - Immune/reflect/evade detection
|
|
-- 3. ParseAfflictedCombatLog() - "afflicted by" detection for hidden CC
|
|
-- 4. ParseReactiveCombatLog() - Reactive ability procs (fallback when no Nampower)
|
|
-- 5. ParseResistCombatLog() - Resist tracking
|
|
|
|
-- Helper function for debuff fade learning and cleanup (extracted from evLearn)
|
|
-- Handles "X fades from Y" messages in RAW_COMBATLOG
|
|
local function HandleDebuffFade()
|
|
local raw = arg2
|
|
-- PERFORMANCE: Quick length check before string search
|
|
if not raw or string.len(raw) < 12 then return end -- "X fades from Y" minimum length
|
|
if not find(raw, "fades from") then return end
|
|
|
|
local _, _, spellName = find(raw, "^(.-) fades from ")
|
|
local _, _, targetGUID = find(raw, "from (.-).$")
|
|
|
|
if lower(targetGUID or "") == "you" then
|
|
_, targetGUID = UnitExists("player")
|
|
end
|
|
targetGUID = gsub(targetGUID or "", "^0x", "")
|
|
|
|
if not spellName or targetGUID == "" then return end
|
|
if not lib.objects[targetGUID] then return end
|
|
|
|
local timestamp = GetTime()
|
|
|
|
for spellID in pairs(lib.objects[targetGUID]) do
|
|
local name = C_Spell.GetSpellName(spellID)
|
|
if name then
|
|
name = CleveRoids.StripRank(name)
|
|
if name == spellName then
|
|
-- Learn duration if we have timing data
|
|
if lib.learnCastTimers[targetGUID] and
|
|
lib.learnCastTimers[targetGUID][spellID] then
|
|
|
|
local castTime = lib.learnCastTimers[targetGUID][spellID].start
|
|
local casterGUID = lib.learnCastTimers[targetGUID][spellID].caster
|
|
local actualDuration = timestamp - castTime
|
|
|
|
-- Check if this is a combo point spell
|
|
local comboPoints = lib.learnCastTimers[targetGUID][spellID].comboPoints
|
|
if comboPoints and CleveRoids.IsComboScalingSpellID and CleveRoids.IsComboScalingSpellID(spellID) then
|
|
CleveRoids_ComboDurations = CleveRoids_ComboDurations or {}
|
|
CleveRoids_ComboDurations[spellID] = CleveRoids_ComboDurations[spellID] or {}
|
|
CleveRoids_ComboDurations[spellID][comboPoints] = floor(actualDuration + 0.5)
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
"|cff4b7dccCleveRoids:|r Learned combo spell " .. spellName ..
|
|
" (ID:" .. spellID .. ") at " .. comboPoints .. " CP = " .. floor(actualDuration + 0.5) .. "s"
|
|
)
|
|
end
|
|
else
|
|
CleveRoids_LearnedDurations[spellID] = CleveRoids_LearnedDurations[spellID] or {}
|
|
CleveRoids_LearnedDurations[spellID][casterGUID] = floor(actualDuration + 0.5)
|
|
if CleveRoids.debug then
|
|
DEFAULT_CHAT_FRAME:AddMessage(
|
|
"|cff4b7dccCleveRoids:|r Learned " .. spellName ..
|
|
" (ID:" .. spellID .. ") = " .. floor(actualDuration + 0.5) .. "s"
|
|
)
|
|
end
|
|
end
|
|
|
|
lib.learnCastTimers[targetGUID][spellID] = nil
|
|
if lib.learnCastTimers[targetGUID] and not next(lib.learnCastTimers[targetGUID]) then
|
|
lib.learnCastTimers[targetGUID] = nil
|
|
end
|
|
end
|
|
|
|
-- Cleanup logic for personal vs shared debuffs
|
|
local rec = lib.objects[targetGUID][spellID]
|
|
local isPersonal = lib:IsPersonalDebuff(spellID)
|
|
local shouldRemove = false
|
|
|
|
if isPersonal then
|
|
-- Personal debuff: only remove if cast by player
|
|
if rec.caster == "player" then
|
|
local hasExpired = (rec.start + rec.duration + 1) <= timestamp
|
|
local stillExists = false
|
|
local checkGUID = CleveRoids.GetGUID("target")
|
|
if checkGUID == targetGUID then
|
|
for i = 1, 16 do
|
|
local _, _, _, checkSpellID = UnitDebuff("target", i)
|
|
if checkSpellID == spellID then
|
|
stillExists = true
|
|
break
|
|
end
|
|
end
|
|
end
|
|
if (hasExpired and not stillExists) or ((rec.start + rec.duration + 2) <= timestamp) then
|
|
shouldRemove = true
|
|
end
|
|
end
|
|
else
|
|
-- Shared debuff: scan to verify it's gone
|
|
local stillExists = false
|
|
local checkGUID = CleveRoids.GetGUID("target")
|
|
if checkGUID == targetGUID then
|
|
for i = 1, 16 do
|
|
local _, _, _, checkSpellID = UnitDebuff("target", i)
|
|
if checkSpellID == spellID then
|
|
stillExists = true
|
|
break
|
|
end
|
|
end
|
|
end
|
|
if not stillExists or ((rec.start + rec.duration + 2) <= timestamp) then
|
|
shouldRemove = true
|
|
end
|
|
end
|
|
|
|
if shouldRemove then
|
|
lib.objects[targetGUID][spellID] = nil
|
|
end
|
|
|
|
if not next(lib.objects[targetGUID]) then
|
|
lib.objects[targetGUID] = nil
|
|
end
|
|
return
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Unified combat log frame - processes RAW_COMBATLOG ONCE and dispatches to all handlers
|
|
local unifiedCombatLogFrame = CreateFrame("Frame", "CleveRoidsUnifiedCombatLogFrame")
|
|
unifiedCombatLogFrame:RegisterEvent("RAW_COMBATLOG")
|
|
unifiedCombatLogFrame:SetScript("OnEvent", function()
|
|
if event ~= "RAW_COMBATLOG" then return end
|
|
|
|
-- PERFORMANCE: Dispatcher-level triage to avoid calling handlers that will immediately return.
|
|
-- Each handler previously did its own nil/length/keyword checks independently, resulting in
|
|
-- redundant work and unnecessary function call overhead. Now we do one pass of keyword checks
|
|
-- and only call relevant handlers. Also shares string.lower() between reactive and resist handlers.
|
|
local msg = arg1
|
|
local raw = arg2
|
|
|
|
-- 1. Debuff fade learning (checks raw/arg2 for "fades from")
|
|
if raw and string.len(raw) >= 12 and find(raw, "fades from") then
|
|
HandleDebuffFade()
|
|
end
|
|
|
|
-- All remaining handlers need arg1
|
|
if not msg then return end
|
|
local msgLen = string.len(msg)
|
|
if msgLen < 8 then return end
|
|
|
|
-- 2. Immunity detection (immune/reflect/evade from combat log)
|
|
if find(msg, "immune") or find(msg, "reflect") or find(msg, "evade") then
|
|
ParseImmunityCombatLog()
|
|
end
|
|
|
|
-- 3. "Afflicted by" detection for hidden CC (e.g., Pounce stun)
|
|
if find(msg, "afflicted by") then
|
|
ParseAfflictedCombatLog()
|
|
end
|
|
|
|
-- PERFORMANCE: Compute lowerMsg once, share between reactive and resist handlers
|
|
-- Previously each handler called string.lower() independently (2 string allocations)
|
|
local lowerMsg = lower(msg)
|
|
|
|
-- 4. Reactive ability procs (always run - AUTO_ATTACK events only cover white swings,
|
|
-- yellow ability dodges like Mortal Strike/Heroic Strike come through combat log)
|
|
if strfind(lowerMsg, "dodge") or strfind(lowerMsg, "parry") or strfind(lowerMsg, "block") then
|
|
CleveRoids.ParseReactiveCombatLog(lowerMsg)
|
|
end
|
|
|
|
-- 5. Resist tracking
|
|
if msgLen >= 15 and strfind(lowerMsg, "resist") then
|
|
ParseResistCombatLog(lowerMsg)
|
|
end
|
|
end)
|