diff --git a/Compatibility/SuperMacro.lua b/Compatibility/SuperMacro.lua index 366c824..46f456e 100644 --- a/Compatibility/SuperMacro.lua +++ b/Compatibility/SuperMacro.lua @@ -29,6 +29,11 @@ do petaggressive=true, petdefensive=true, petwait=true, -- healing (requires QuickHeal addon) quickheal=true, qh=true, + -- additional commands that were missing + castpet=true, cleartarget=true, + applymain=true, applyoff=true, + -- slot-specific equip commands + ["equip11"]=true, ["equip12"]=true, ["equip13"]=true, ["equip14"]=true, } -- alias map (if CRM only registers /cancelaura, but /unbuff appears) @@ -66,6 +71,12 @@ do -- SuperMacro calls RunLine(line) one line at a time; handle first arg. local text = arg and arg[1] + -- FIX: Skip comment lines (SuperMacro uses -- for comments) + -- Let SuperMacro handle these to avoid any processing + if type(text) == "string" and string.find(text, "^%s*%-%-") then + return orig_RunLine(text) + end + -- IMPORTANT: Check for /nofirstaction BEFORE the stop flag check -- This allows /nofirstaction to clear the stopMacroFlag set by /firstaction if type(text) == "string" then @@ -109,7 +120,8 @@ do -- nothing to do; pass through to SM else -- 2) generic /cmd forwarding (lets CRM handlers parse []) - local _, _, raw, msg = string.find(text, "^/(%S+)%s*(.*)$") + -- FIX: Added %s* to handle leading whitespace (like /castsequence pattern above) + local _, _, raw, msg = string.find(text, "^%s*/(%S+)%s*(.*)$") if raw then local cmd = string.lower(raw) -- alias map: /unbuff → /cancelaura @@ -148,9 +160,10 @@ do CRM.Hooks.SuperMacro_RunMacro = orig_SuperMacro_RunMacro local function hooked_RunMacro(index) - -- Clear macro stop flags at macro start + -- Clear ALL macro stop flags at macro start (including firstaction flag) CRM.stopMacroFlag = false CRM.skipMacroFlag = false + CRM.stopOnCastFlag = false -- FIX: Also clear firstaction flag return orig_SuperMacro_RunMacro(index) end @@ -166,9 +179,10 @@ do CRM.Hooks.RunSuperMacro = orig_RunSuperMacro _G.RunSuperMacro = function(index) - -- Clear macro stop flags at macro start + -- Clear ALL macro stop flags at macro start (including firstaction flag) CRM.stopMacroFlag = false CRM.skipMacroFlag = false + CRM.stopOnCastFlag = false -- FIX: Also clear firstaction flag return orig_RunSuperMacro(index) end end diff --git a/Conditionals.lua b/Conditionals.lua index d86ed0f..c60283b 100644 --- a/Conditionals.lua +++ b/Conditionals.lua @@ -1259,6 +1259,97 @@ local function PFUI_HasLibDebuff() and type(pfUI.api.libdebuff.UnitDebuff) == "function" end +-- ============================================================================ +-- PENDING DEBUFF CAST DETECTION +-- ============================================================================ +-- Prevents double-application when spamming [nodebuff] macros with spell queue. +-- If we're currently casting or have queued a spell that applies this debuff, +-- treat the debuff as "pending" so [nodebuff] returns false. + +-- Helper: Normalize spell name for comparison (strip rank, lowercase) +local function NormalizeSpellNameForComparison(spellName) + if not spellName then return nil end + -- Strip rank suffix: "Faerie Fire (Feral)(Rank 4)" -> "Faerie Fire (Feral)" + local normalized = string.gsub(spellName, "%s*%(%s*Rank%s+%d+%s*%)", "") + -- Convert underscores to spaces and lowercase + normalized = string.lower(string.gsub(normalized, "_", " ")) + return normalized +end + +-- Check if player is currently casting or has queued a spell that would apply this debuff +-- This prevents [nodebuff] from returning true while the cast is in-flight +-- @param spellName string - The debuff spell name to check +-- @param targetUnit string - The target unit (to verify we're casting AT this target) +-- @return boolean - True if this debuff is pending (being cast/queued to this target) +local function IsPendingDebuffCast(spellName, targetUnit) + if not spellName then return false end + + local normalizedCheck = NormalizeSpellNameForComparison(spellName) + if not normalizedCheck then return false end + + -- Get target GUID for verification (only count as pending if casting AT this target) + local targetGuid = nil + if targetUnit and UnitExists(targetUnit) then + local _, guid = UnitExists(targetUnit) + targetGuid = guid + end + + -- Check if currently CASTING this spell + -- CleveRoids.CurrentSpell tracks the spell being cast with time-based duration + if CleveRoids.CurrentSpell and CleveRoids.CurrentSpell.type == "cast" then + local castingName = CleveRoids.CurrentSpell.spellName + if castingName then + local normalizedCasting = NormalizeSpellNameForComparison(castingName) + if normalizedCasting == normalizedCheck then + -- Verify cast is still in progress (not finished) + if CleveRoids.castStartTime and CleveRoids.castDuration then + local remaining = CleveRoids.castDuration - (GetTime() - CleveRoids.castStartTime) + if remaining > 0.1 then + -- Cast is in-flight, debuff is pending + return true + end + else + -- No timing info but spell type is "cast" - assume pending + return true + end + end + end + end + + -- Check if this spell is QUEUED via Nampower + -- CleveRoids.queuedSpell tracks the spell waiting to fire after GCD/cast + if CleveRoids.queuedSpell and CleveRoids.queuedSpell.spellName then + local queuedName = CleveRoids.queuedSpell.spellName + local normalizedQueued = NormalizeSpellNameForComparison(queuedName) + if normalizedQueued == normalizedCheck then + -- Spell is queued, debuff is pending + return true + end + end + + -- Check pfUI's pending debuff tracking (uses SPELL_GO events for accuracy) + -- pfUI.libdebuff_pending[guid][spellName] = true when cast is in-flight + -- Cleared on SPELL_GO miss/resist, so this is very accurate + if pfUI and pfUI.libdebuff_pending and targetGuid then + local pendingForTarget = pfUI.libdebuff_pending[targetGuid] + if pendingForTarget then + -- pfUI stores by spell name (may or may not have rank) + -- Check both the exact name and stripped name + for pendingSpell, _ in pairs(pendingForTarget) do + local normalizedPending = NormalizeSpellNameForComparison(pendingSpell) + if normalizedPending == normalizedCheck then + return true + end + end + end + end + + return false +end + +-- Expose for use in nodebuff conditional +CleveRoids.IsPendingDebuffCast = IsPendingDebuffCast + -- Helper: Get debuff time-left (seconds) from CleveRoids.libdebuff only local function _get_debuff_timeleft(unitToken, auraName) -- Defensive: verify libdebuff is a table before accessing properties @@ -4792,6 +4883,16 @@ CleveRoids.Keywords = { nodebuff = function(conditionals) return NegatedMulti(conditionals.nodebuff, function(v) + -- Extract spell name from args (could be string or table with name/operator/amount) + local spellName = type(v) == "table" and v.name or v + + -- Check if this debuff is PENDING (being cast or queued) + -- This prevents double-application when spamming macros with spell queue + if CleveRoids.IsPendingDebuffCast(spellName, conditionals.target) then + return false -- Treat as if debuff exists (nodebuff returns false) + end + + -- Debuff not pending, check if it actually exists on target return not CleveRoids.ValidateUnitDebuff(conditionals.target, v) end, conditionals, "nodebuff") end, @@ -6495,11 +6596,20 @@ CleveRoids.Keywords = { -- Negated spell name form return NegatedMulti(conditionals.nocursive, function(args) + -- Extract spell name from args + local spellName = type(args) == "string" and args or (type(args) == "table" and args.name) + + -- Check if this debuff is PENDING (being cast or queued) + -- This prevents double-application when spamming macros with spell queue + if spellName and CleveRoids.IsPendingDebuffCast(spellName, target) then + return false -- Treat as if debuff exists (nocursive returns false) + end + if type(args) == "string" then -- Simple spell name check: [nocursive:Rake] = true if Rake is missing return not CleveRoids.ValidateCursiveDebuff(target, args, nil, nil) elseif type(args) == "table" then - local spellName = args.name + spellName = args.name -- Handle multi-comparison negation if args.comparisons and type(args.comparisons) == "table" then diff --git a/Console.lua b/Console.lua index 49d3caf..3df5607 100644 --- a/Console.lua +++ b/Console.lua @@ -100,6 +100,13 @@ local StartAttack = function(msg) CleveRoids.CurrentSpell.autoAttackLock = true CleveRoids.autoAttackLockElapsed = GetTime() AttackTarget() + -- FIX: Immediately set autoAttack flag so subsequent macro lines know attack started + -- Don't wait for PLAYER_ENTER_COMBAT event which has a delay + CleveRoids.CurrentSpell.autoAttack = true + -- FIX: Queue icon update so action bars reflect the new state + if CleveRoidMacros and CleveRoidMacros.realtime == 0 then + CleveRoids.QueueActionUpdate() + end end end diff --git a/Core.lua b/Core.lua index b499c45..af257a2 100644 --- a/Core.lua +++ b/Core.lua @@ -757,14 +757,24 @@ function CleveRoids.TestForActiveAction(actions) local newSequence = nil local firstUnconditional = nil - -- Only use tooltip-only branch when #showtooltip exists but action list is empty - -- (e.g., bare "#showtooltip" with no /cast commands). When explicitTooltip is true, - -- the spell was added to actions.list with proper cmd/args, so use the ELSE branch. - if actions.tooltip and table.getn(actions.list) == 0 then - if CleveRoids.TestAction(actions.cmd or "", actions.args or "") then + -- Use tooltip-only branch when: + -- 1. #showtooltip has an explicit argument (explicitTooltip = true), OR + -- 2. #showtooltip exists but action list is empty (bare "#showtooltip") + -- This ensures explicit tooltips always show their icon/usability correctly. + if actions.tooltip and (actions.explicitTooltip or table.getn(actions.list) == 0) then + -- For explicit tooltips, directly use the tooltip as active action + -- (actions.cmd/args are empty, so TestAction would fail) + if actions.explicitTooltip then hasActive = true newActiveAction = actions.tooltip - -- Resolve nested macro references for #showtooltip propagation + elseif CleveRoids.TestAction(actions.cmd or "", actions.args or "") then + -- Bare #showtooltip (no arg) - validate with TestAction + hasActive = true + newActiveAction = actions.tooltip + end + + -- Resolve nested macro references for #showtooltip propagation + if hasActive and newActiveAction then local macroName = CleveRoids.GetMacroNameFromAction(actions.tooltip.action) if macroName then local resolved = CleveRoids.ResolveNestedMacroActive(actions.tooltip, 0) @@ -795,6 +805,15 @@ function CleveRoids.TestForActiveAction(actions) if action.sequence then newSequence = action.sequence newActiveAction = CleveRoids.GetCurrentSequenceAction(newSequence) + -- Check if current step is null/pass/noop - if so, skip this sequence for icon + if newActiveAction and newActiveAction.action then + local stepLower = string.lower(CleveRoids.Trim(newActiveAction.action)) + if stepLower == "null" or stepLower == "pass" or stepLower == "noop" then + hasActive = false + newActiveAction = nil + newSequence = nil + end + end if not newActiveAction then hasActive = false end else newActiveAction = action @@ -1659,6 +1678,7 @@ function CleveRoids.ParseSequence(text) lastUpdate = 0, args = args, list = {}, + perTargetState = {}, -- [guid] = {index = N, lastUpdate = T} for reset=target } -- fill reset rules: seconds or flags (target/combat/alt/ctrl/shift) @@ -2077,14 +2097,41 @@ function CleveRoids.GetCurrentSequenceAction(sequence) end function CleveRoids.ResetSequence(sequence) + -- Save the GUID before clearing it + local targetGuid = sequence.lastTargetGuid + sequence.index = 1 sequence.lastTargetGuid = nil -- Clear stored GUID so next use captures fresh target + + -- Also reset per-target state for current target if tracking + if sequence.reset and sequence.reset.target and targetGuid then + if sequence.perTargetState then + sequence.perTargetState[targetGuid] = nil + end + end end function CleveRoids.AdvanceSequence(sequence) if sequence.index < table.getn(sequence.list) then -- Not at the end yet, just advance normally + local oldIndex = sequence.index sequence.index = sequence.index + 1 + + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage(string.format( + "|cff00ff00[Sequence Advanced]|r %d -> %d (of %d)", + oldIndex, sequence.index, table.getn(sequence.list) + )) + end + + -- Sync per-target state if tracking by target + if sequence.reset and sequence.reset.target and sequence.lastTargetGuid then + sequence.perTargetState = sequence.perTargetState or {} + sequence.perTargetState[sequence.lastTargetGuid] = { + index = sequence.index, + lastUpdate = GetTime(), + } + end else -- At the end of sequence - check if we should auto-reset or stay at last step local hasNonModifierReset = false @@ -2703,6 +2750,13 @@ local function _startAttackAction() CleveRoids.CurrentSpell.autoAttackLock = true CleveRoids.autoAttackLockElapsed = GetTime() AttackTarget() + -- FIX: Immediately set autoAttack flag so subsequent macro lines know attack started + -- Don't wait for PLAYER_ENTER_COMBAT event which has a delay + CleveRoids.CurrentSpell.autoAttack = true + -- FIX: Queue icon update so action bars reflect the new state + if CleveRoidMacros and CleveRoidMacros.realtime == 0 then + CleveRoids.QueueActionUpdate() + end end end @@ -3484,6 +3538,28 @@ function CleveRoids.DoCastSequence(sequence) local active = CleveRoids.GetCurrentSequenceAction(sequence) if not (active and active.action) then return end + -- Debug: Show sequence state + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage(string.format( + "|cff00ffff[Sequence]|r Step %d/%d: '%s'", + sequence.index, table.getn(sequence.list), active.action + )) + end + + -- Check for "null" step - does nothing, allows macro to fall through + -- Usage: /castsequence reset=target Sunder Armor, null + local actionLower = string.lower(CleveRoids.Trim(active.action)) + if actionLower == "null" or actionLower == "pass" or actionLower == "noop" then + -- Update sequence state (so per-target tracking works) + sequence.status = 0 + sequence.lastUpdate = GetTime() + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ffff[Sequence]|r Hit null step, falling through") + end + -- Don't cast anything, just return - allows macro to continue to next line + return + end + sequence.status = 0 sequence.lastUpdate = GetTime() sequence.expires = 0 @@ -3814,6 +3890,19 @@ function CleveRoids.OnUpdate(self) CleveRoids.ClearSpellNameCaches() end end + + -- MEMORY: Clean up stale perTargetState entries in sequences (older than 5 minutes) + -- This prevents memory bloat from tracking many targets over time + local SEQUENCE_TARGET_STALE_TIME = 300 -- 5 minutes + for _, sequence in pairs(CleveRoids.Sequences) do + if sequence.perTargetState then + for guid, state in pairs(sequence.perTargetState) do + if state.lastUpdate and (time - state.lastUpdate) > SEQUENCE_TARGET_STALE_TIME then + sequence.perTargetState[guid] = nil + end + end + end + end end end @@ -3938,15 +4027,33 @@ function ActionHasRange(slot) if not slot then return nil end local actions = CleveRoids.GetAction(slot) -- Only override for our macros with #showtooltip - if actions and actions.tooltip and actions.active then - if actions.active.inRange ~= -1 then - return 1 -- Has range check with valid data - else - -- For channeled spells (inRange == -1), try proxy slot lookup - local spellName = actions.active.spell and actions.active.spell.name - local proxySlot = spellName and CleveRoids.GetProxyActionSlot(spellName) - if proxySlot then - return CleveRoids.Hooks.ActionHasRange(proxySlot) + if actions and actions.tooltip then + -- If we have an active action with valid range data, use it + if actions.active then + if actions.active.inRange ~= -1 then + return 1 -- Has range check with valid data + else + -- For channeled spells (inRange == -1), try proxy slot lookup + local spellName = actions.active.spell and actions.active.spell.name + local proxySlot = spellName and CleveRoids.GetProxyActionSlot(spellName) + if proxySlot then + return CleveRoids.Hooks.ActionHasRange(proxySlot) + end + end + end + -- FIX: Even without actions.active, if the macro has potential spell actions, + -- return 1 to ensure pfUI calls IsActionInRange (which will do proper checks) + -- This fixes Super macros where actions.active might not be populated yet + if actions.tooltip.spell or actions.tooltip.type == "spell" then + return 1 -- Tooltip is a spell - has range + end + -- Check if any action in the list is a spell + if actions.list then + for i = 1, table.getn(actions.list) do + local action = actions.list[i] + if action and (action.spell or action.type == "spell") then + return 1 -- Has at least one spell action + end end end end @@ -3959,12 +4066,40 @@ function IsActionInRange(slot, unit) if not slot then return nil end local actions = CleveRoids.GetAction(slot) -- Only override for our macros with #showtooltip - if actions and actions.tooltip and actions.active and actions.active.type == "spell" then - if actions.active.inRange ~= -1 then - return actions.active.inRange - else - -- For channeled spells (inRange == -1), try proxy slot lookup - local spellName = actions.active.spell and actions.active.spell.name + if actions and actions.tooltip then + -- If we have an active spell action, use its range data + if actions.active and actions.active.type == "spell" then + if actions.active.inRange ~= -1 then + return actions.active.inRange + else + -- For channeled spells (inRange == -1), try proxy slot lookup + local spellName = actions.active.spell and actions.active.spell.name + local proxySlot = spellName and CleveRoids.GetProxyActionSlot(spellName) + if proxySlot then + return CleveRoids.Hooks.IsActionInRange(proxySlot, unit) + end + end + end + -- FIX: If no active action but macro has tooltip spell, check its range directly + -- This ensures range coloring works even when actions.active hasn't been populated + if not actions.active and actions.tooltip.spell then + local spell = actions.tooltip.spell + local spellName = spell.name + if spellName and IsSpellInRange then + local targetUnit = unit or "target" + if UnitExists(targetUnit) then + -- FIX: Wrap in pcall - spell might not be in spellbook (items, other class spells, etc.) + local ok, result = pcall(IsSpellInRange, spellName, targetUnit) + if ok then + if result == 0 then + return 0 -- Out of range + elseif result == 1 then + return 1 -- In range + end + end + end + end + -- Try proxy slot as fallback local proxySlot = spellName and CleveRoids.GetProxyActionSlot(spellName) if proxySlot then return CleveRoids.Hooks.IsActionInRange(proxySlot, unit) @@ -3989,7 +4124,35 @@ function IsUsableAction(slot, unit) -- We have an active action - return its usable state return actions.active.usable, actions.active.oom else - -- This is our macro but no action is active (all conditionals failed) + -- FIX: If no active action but tooltip has a spell, check its usability directly + -- This ensures usability coloring works even when actions.active hasn't been populated + if actions.tooltip.spell then + local spell = actions.tooltip.spell + local spellName = spell.name + if spellName and IsSpellUsable then + -- FIX: Wrap in pcall - spell might not be in spellbook (items, other class spells, etc.) + local ok, usable, notEnoughMana = pcall(IsSpellUsable, spellName) + if ok then + if usable == 1 then + return 1, nil -- Usable + elseif notEnoughMana == 1 then + return nil, 1 -- Out of mana + else + return nil, nil -- Not usable (wrong stance, etc.) + end + end + end + -- Fallback: check if spell has a cost and compare to current mana + if spell.cost and spell.cost > 0 then + local currentMana = UnitMana("player") + if currentMana < spell.cost then + return nil, 1 -- Out of mana + end + end + -- Default to usable if we can't determine otherwise + return 1, nil + end + -- No tooltip spell - this macro's conditionals all failed -- Return nil to make the icon dark return nil, nil end @@ -4605,45 +4768,40 @@ function CleveRoids.Frame:UNIT_CASTEVENT(caster,target,action,spell_id,cast_time if target and lib and lib.objects then local normalizedTarget = CleveRoids.NormalizeGUID(target) if normalizedTarget and lib.objects[normalizedTarget] then - -- Refresh all active Judgements on the target (pfUI-style: by name, not just ID) + -- Refresh all active Judgements on the target for spellID, rec in pairs(lib.objects[normalizedTarget]) do - if rec.start and rec.duration then - local spellName = SpellInfo(spellID) - if spellName then - -- Remove rank to get base name - local baseName = string.gsub(spellName, "%s*%(Rank %d+%)", "") + -- Check if this is a judgement by spell ID + if lib.judgementSpells and 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() - -- Check if this is a judgement by name (pfUI approach) - if lib.judgementNames and lib.judgementNames[baseName] 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() + local spellName = SpellInfo(spellID) + local baseName = spellName and string.gsub(spellName, "%s*%(Rank %d+%)", "") or "Unknown" + + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff00ffaa[Judgement Refresh]|r Refreshed %s (ID:%d) on %s hit - new duration: %ds", + baseName, spellID, action, rec.duration) + ) + end + + -- Also sync to pfUI if it's loaded + if pfUI and pfUI.api and pfUI.api.libdebuff then + local targetName = (lib.guidToName and lib.guidToName[normalizedTarget]) or UnitName("target") + local targetLevel = UnitLevel("target") or 0 + + if targetName then + -- Refresh in pfUI's tracking (by name) + pfUI.api.libdebuff:AddEffect(targetName, targetLevel, baseName, rec.duration, "player") if CleveRoids.debug then DEFAULT_CHAT_FRAME:AddMessage( - string.format("|cff00ffaa[Judgement Refresh]|r Refreshed %s (ID:%d) on %s hit - new duration: %ds", - baseName, spellID, action, rec.duration) + string.format("|cff00ffaa[pfUI Judgement Refresh]|r Synced %s refresh to pfUI", baseName) ) end - - -- Also sync to pfUI if it's loaded - if pfUI and pfUI.api and pfUI.api.libdebuff then - local targetName = (lib.guidToName and lib.guidToName[normalizedTarget]) or UnitName("target") - local targetLevel = UnitLevel("target") or 0 - - if targetName then - -- Refresh in pfUI's tracking (by name) - pfUI.api.libdebuff:AddEffect(targetName, targetLevel, baseName, rec.duration, "player") - - if CleveRoids.debug then - DEFAULT_CHAT_FRAME:AddMessage( - string.format("|cff00ffaa[pfUI Judgement Refresh]|r Synced %s refresh to pfUI", baseName) - ) - end - end - end end end end @@ -4983,19 +5141,43 @@ function CleveRoids.Frame:PLAYER_TARGET_CHANGED() -- Clear resist state when target changes CleveRoids.ClearResistState() - -- Reset any sequence with reset=target that has progressed past the first step - -- Only reset if the target GUID actually changed (not just re-targeting same mob) + -- Handle sequences with reset=target - save/restore per-target state + -- Instead of resetting, we remember each target's progress in the sequence local currentGuid = nil if UnitExists("target") then local _, guid = UnitExists("target") currentGuid = guid end + for _, sequence in pairs(CleveRoids.Sequences) do - if sequence.index > 1 and sequence.reset and sequence.reset.target then - -- Compare with stored GUID - reset only if different - -- If no target now, currentGuid is nil, which differs from stored GUID - if sequence.lastTargetGuid ~= currentGuid then - CleveRoids.ResetSequence(sequence) + if sequence.reset and sequence.reset.target then + local oldGuid = sequence.lastTargetGuid + + -- Only process if target actually changed + if oldGuid ~= currentGuid then + -- Save current state for the old target (if we had one and progressed) + if oldGuid and sequence.index > 1 then + sequence.perTargetState = sequence.perTargetState or {} + sequence.perTargetState[oldGuid] = { + index = sequence.index, + lastUpdate = sequence.lastUpdate, + } + end + + -- Restore state for the new target (or start fresh) + if currentGuid and sequence.perTargetState and sequence.perTargetState[currentGuid] then + -- Restore saved state for this target + local saved = sequence.perTargetState[currentGuid] + sequence.index = saved.index + sequence.lastUpdate = saved.lastUpdate + else + -- New target we haven't seen - start at step 1 + sequence.index = 1 + sequence.status = 0 + end + + -- Update the current target GUID + sequence.lastTargetGuid = currentGuid end end end diff --git a/NampowerAPI.lua b/NampowerAPI.lua index 18c500f..568b564 100644 --- a/NampowerAPI.lua +++ b/NampowerAPI.lua @@ -44,11 +44,21 @@ - Detailed hit info: damage, crit/glancing/crushing, dodge/parry/block - Requires NP_EnableAutoAttackEvents=1 CVar to enable + Spell Start/Go Events (v2.25+): + - SPELL_START_SELF / SPELL_START_OTHER - Server notifies cast time spell begun + - SPELL_GO_SELF / SPELL_GO_OTHER - Server notifies spell completed casting + - Requires NP_EnableSpellStartEvents=1 and NP_EnableSpellGoEvents=1 CVars + + Spell Heal/Energize Events (v2.26+): + - SPELL_HEAL_BY_SELF / SPELL_HEAL_BY_OTHER / SPELL_HEAL_ON_SELF + - SPELL_ENERGIZE_BY_SELF / SPELL_ENERGIZE_BY_OTHER / SPELL_ENERGIZE_ON_SELF + - Requires NP_EnableSpellHealEvents=1 and NP_EnableSpellEnergizeEvents=1 CVars + Settings Integration: - Reads from NampowerSettings addon when available - Falls back to CVars when addon not present - Current version: v2.24.0 + Current version: v2.26.0 ]] local _G = _G or getfenv(0) @@ -171,6 +181,14 @@ API.VERSION_REQUIREMENTS = { -- v2.24+ - Auto-attack events ["AutoAttackEvents"] = { 2, 24, 0 }, -- AUTO_ATTACK_SELF/OTHER events + + -- v2.25+ - Spell start/go events (improved granularity over UNIT_CASTEVENT) + ["SpellStartEvents"] = { 2, 25, 0 }, -- SPELL_START_SELF/OTHER events + ["SpellGoEvents"] = { 2, 25, 0 }, -- SPELL_GO_SELF/OTHER events + + -- v2.26+ - Spell heal/energize events + ["SpellHealEvents"] = { 2, 26, 0 }, -- SPELL_HEAL_BY_SELF/OTHER/ON_SELF events + ["SpellEnergizeEvents"] = { 2, 26, 0 }, -- SPELL_ENERGIZE_BY_SELF/OTHER/ON_SELF events } -- Check if a specific feature is available @@ -277,6 +295,14 @@ local function InitializeFeatures() -- v2.24+ Auto-attack events f.hasAutoAttackEvents = API.HasFeature("AutoAttackEvents") + -- v2.25+ Spell start/go events + f.hasSpellStartEvents = API.HasFeature("SpellStartEvents") + f.hasSpellGoEvents = API.HasFeature("SpellGoEvents") + + -- v2.26+ Spell heal/energize events + f.hasSpellHealEvents = API.HasFeature("SpellHealEvents") + f.hasSpellEnergizeEvents = API.HasFeature("SpellEnergizeEvents") + -- Runtime detection for enhanced spell functions (verify by testing) if f.hasEnhancedSpellFunctions and GetSpellTexture then local success, result = pcall(function() @@ -336,6 +362,12 @@ API.defaultSettings = { NP_EnableAuraCastEvents = "0", -- Enable AURA_CAST_ON_SELF/OTHER events -- v2.24+ CVars NP_EnableAutoAttackEvents = "0", -- Enable AUTO_ATTACK_SELF/OTHER events + -- v2.25+ CVars + NP_EnableSpellStartEvents = "0", -- Enable SPELL_START_SELF/OTHER events + NP_EnableSpellGoEvents = "0", -- Enable SPELL_GO_SELF/OTHER events + -- v2.26+ CVars + NP_EnableSpellHealEvents = "0", -- Enable SPELL_HEAL_BY_SELF/OTHER/ON_SELF events + NP_EnableSpellEnergizeEvents = "0", -- Enable SPELL_ENERGIZE_BY_SELF/OTHER/ON_SELF events } -- Get a Nampower setting value @@ -1922,6 +1954,46 @@ API.VICTIMSTATE = { DEFLECTS = 8, } +-- Spell start/go event names (v2.25+) +-- These provide improved granularity over UNIT_CASTEVENT for spell cast tracking +-- SPELL_START: Server notifies a spell with cast time has begun +-- SPELL_GO: Server notifies a spell has completed casting (projectile launched, instant landed) +API.SPELL_START_EVENTS = { + "SPELL_START_SELF", -- Player starts casting (requires NP_EnableSpellStartEvents=1) + "SPELL_START_OTHER", -- Other unit starts casting +} + +API.SPELL_GO_EVENTS = { + "SPELL_GO_SELF", -- Player's spell completed/fired (requires NP_EnableSpellGoEvents=1) + "SPELL_GO_OTHER", -- Other unit's spell completed/fired +} + +-- Spell heal event names (v2.26+, requires NP_EnableSpellHealEvents=1) +-- Parameters: targetGuid, casterGuid, spellId, amount, isCritical, isPeriodic +API.SPELL_HEAL_EVENTS = { + "SPELL_HEAL_BY_SELF", -- Player healed a target + "SPELL_HEAL_BY_OTHER", -- Other unit healed a target + "SPELL_HEAL_ON_SELF", -- Player was healed +} + +-- Spell energize event names (v2.26+, requires NP_EnableSpellEnergizeEvents=1) +-- Parameters: targetGuid, casterGuid, spellId, powerType, amount, isPeriodic +-- powerType: 0=mana, 1=rage, 2=focus, 3=energy, 4=happiness +API.SPELL_ENERGIZE_EVENTS = { + "SPELL_ENERGIZE_BY_SELF", -- Player energized a target + "SPELL_ENERGIZE_BY_OTHER", -- Other unit energized a target + "SPELL_ENERGIZE_ON_SELF", -- Player was energized +} + +-- Power type constants (for SPELL_ENERGIZE events) +API.POWER_TYPE = { + MANA = 0, + RAGE = 1, + FOCUS = 2, + ENERGY = 3, + HAPPINESS = 4, +} + -- Unit events (v2.20+) API.UNIT_EVENTS = { "UNIT_DIED", -- Parameters: guid diff --git a/SuperCleveRoidMacros.toc b/SuperCleveRoidMacros.toc index 81a5e40..3b2d629 100644 --- a/SuperCleveRoidMacros.toc +++ b/SuperCleveRoidMacros.toc @@ -2,7 +2,7 @@ ## Author: DWG, _brain, Torio, yani9o, Avitasia ## Title: [Super]CleveRoidMacros ## Notes: /cleveroid for settings -## Version: 1.8 +## Version: 1.9 ## OptionalDeps: ClassicFocus, FocusFrame, pfUI, SuperMacro, Bongos_ActionBar, Cursive, XPerl, LunaUnitFrames, DragonflightReloaded, -Dragonflight3 ## SavedVariables: CleveRoidMacros, CleveRoids_LearnedDurations, CleveRoids_AuraTextures, CleveRoids_ImmunityData, CleveRoids_ComboDurations, CleveRoids_SpellSchools Localization.lua diff --git a/Utility.lua b/Utility.lua index fddb3f2..6d70e55 100644 --- a/Utility.lua +++ b/Utility.lua @@ -651,6 +651,577 @@ 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, slot} +lib.ownSlots = lib.ownSlots or {} -- [targetGUID][slot] = spellName +lib.allSlots = lib.allSlots or {} -- [targetGUID][slot] = {spellName, casterGuid, isOurs} +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 + +-- 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 + + -- v7.4.3+ detected, verify Nampower v2.26+ is active + if not GetNampowerVersion then return false end + local npMajor, npMinor = GetNampowerVersion() + if npMajor < 2 or (npMajor == 2 and npMinor < 26) then return false end + + -- Finally verify the exposed tables exist + if not pfUI.libdebuff_own then return false end + if not pfUI.libdebuff_all_slots then return false end + if not pfUI.libdebuff_pending then return false end + + return true +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 + lib.ownSlots = pfUI.libdebuff_own_slots + lib.allSlots = pfUI.libdebuff_all_slots + lib.allAuraCasts = pfUI.libdebuff_all_auras + lib.pendingCasts = pfUI.libdebuff_pending + + 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 + + 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) + if GetNampowerVersion then + local npMajor, npMinor = GetNampowerVersion() + if npMajor > 2 or (npMajor == 2 and npMinor >= 26) 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 tracking enabled", + npMajor, npMinor) + ) + end + + return true + end + end + + return false +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 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 = UnitExists(unit) + if not guid then return nil end + guid = CleveRoids.NormalizeGUID(guid) + + -- Check own debuffs first + if lib.ownDebuffs[guid] and lib.ownDebuffs[guid][spellName] then + local playerGuid = nil + if UnitExists then + local _, pg = UnitExists("player") + playerGuid = pg + end + return playerGuid + end + + -- Check allSlots for caster info + 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 = UnitExists(unit) + if not guid then return false end + guid = CleveRoids.NormalizeGUID(guid) + + -- Check own debuffs + if lib.ownDebuffs[guid] and lib.ownDebuffs[guid][spellName] then + return true + end + + -- Check allSlots for isOurs flag + 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 { @@ -1106,75 +1677,9 @@ lib.pendingJudgements = lib.pendingJudgements or {} -- This gets populated as we discover what debuffs actually appear after casting lib.detectedJudgementDebuffIDs = lib.detectedJudgementDebuffIDs or {} --- Judgement names for refresh tracking (like pfUI does it) --- We track by NAME to handle different spell IDs across servers -lib.judgementNames = lib.judgementNames or { - ["Judgement of Justice"] = true, - ["Judgement of Light"] = true, - ["Judgement of Wisdom"] = true, - ["Judgement of the Crusader"] = true, -} - --- Judgement refresh on melee hits (pfUI-style using CHAT_MSG_COMBAT_SELF_HITS) --- This is more reliable than UNIT_CASTEVENT MAINHAND/OFFHAND -local judgementRefreshFrame = CreateFrame("Frame", "CleveRoidsJudgementRefreshFrame", UIParent) -judgementRefreshFrame:RegisterEvent("CHAT_MSG_COMBAT_SELF_HITS") - -judgementRefreshFrame:SetScript("OnEvent", function() - -- Only process for paladins - if CleveRoids.playerClass ~= "PALADIN" then return end - if not arg1 then return end - - -- Check if this is a melee hit using global combat log patterns - -- COMBATHITSELFOTHER = "You hit %s for %d." - -- COMBATHITCRITSELFOTHER = "You crit %s for %d." - local isHit = string.find(arg1, "^You hit") or string.find(arg1, "^You crit") - if not isHit then return end - - -- Get current target GUID - local _, targetGUID = UnitExists("target") - if not targetGUID then return end - targetGUID = CleveRoids.NormalizeGUID(targetGUID) - if not targetGUID then return end - - -- Refresh all judgement debuffs on the target (by name, like pfUI) - if lib.objects[targetGUID] then - for spellID, rec in pairs(lib.objects[targetGUID]) do - if rec.start and rec.duration then - local spellName = SpellInfo(spellID) - if spellName then - -- Remove rank to get base name - local baseName = string.gsub(spellName, "%s*%(Rank %d+%)", "") - - -- Check if this is a judgement by name - if lib.judgementNames[baseName] then - local remaining = rec.duration + rec.start - GetTime() - if remaining > 0 then - -- Refresh by resetting start time - rec.start = GetTime() - - if CleveRoids.debug then - DEFAULT_CHAT_FRAME:AddMessage( - string.format("|cff00ffaa[Judgement Refresh]|r %s (ID:%d) refreshed on melee hit - %ds", - baseName, spellID, rec.duration) - ) - end - - -- Also sync to pfUI - if pfUI and pfUI.api and pfUI.api.libdebuff then - local targetName = lib.guidToName[targetGUID] or UnitName("target") - local targetLevel = UnitLevel("target") or 0 - if targetName then - pfUI.api.libdebuff:AddEffect(targetName, targetLevel, baseName, rec.duration, "player") - end - end - end - end - end - end - end - end -end) +-- 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 {} @@ -2981,17 +3486,61 @@ delayedTrackingFrame:SetScript("OnUpdate", function() end end end + + -- Periodically clean up stale tracking data (immune/reflect/evade correlation tables) + lib:CleanupStaleTrackingData() end) local ev = CreateFrame("Frame", "CleveRoidsLibDebuffFrame", UIParent) ev:RegisterEvent("PLAYER_TARGET_CHANGED") ev:RegisterEvent("UNIT_AURA") +ev:RegisterEvent("ADDON_LOADED") -- For pfUI integration initialization 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 + + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[Nampower]|r Registered UNIT_DIED for instant cleanup (v2.26+)") + 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 == "PLAYER_TARGET_CHANGED" then SeedUnit("target") @@ -3629,6 +4178,410 @@ ev:SetScript("OnEvent", function() end end 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 + -- Skip if pfUI enhanced tracking is active (it handles this) + if lib.hasPfUIEnhanced then return end + + local spellId = arg2 + local casterGuid = arg3 + local targetGuid = arg4 + 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 = SpellInfo and SpellInfo(spellId) + if not spellName then return end + + local _, playerGUID = UnitExists("player") + local isOurs = (casterGuid == playerGUID) + + -- 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 = UnitExists("target") + if CleveRoids.NormalizeGUID(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 then + -- Delay: 150ms with Nampower v2.26+ (AURA_CAST confirms faster), 300ms fallback + 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() + + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff00ff00[SPELL_GO REFRESH]|r %s refreshed on %s", + spellName, lib.guidToName[targetGuid] or "Unknown") + ) + end + 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 + local comboPoints = CleveRoids.GetComboPoints and CleveRoids.GetComboPoints() or 0 + + -- If already consumed, use lastComboPoints fallback + if comboPoints == 0 and CleveRoids.lastComboPoints and CleveRoids.lastComboPoints > 0 then + comboPoints = CleveRoids.lastComboPoints + end + + -- Also check 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 + + 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, spellRankStr, texture = SpellInfo and SpellInfo(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 = UnitExists("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. pendingCasts (captured by SPELL_GO - most reliable) + -- 2. CleveRoids.lastComboPoints (from /cast hook pre-population) + -- 3. GetComboPoints() (might be 0 if already consumed) + local comboPoints = 0 + local comboSource = "none" + + -- Check pendingCasts first (populated by SPELL_GO) + if 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" + -- Clean up after use + lib.pendingCasts[targetGuid][spellName] = nil + end + 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 + -- Lower rank cannot overwrite higher rank + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cffff6600[AURA_CAST RANK BLOCK]|r %s Rank %d cannot overwrite Rank %d", + spellName, rankNum, existing.rank) + ) + end + return + end + end + + lib.ownDebuffs[targetGuid][spellName] = { + startTime = now, + duration = duration, + texture = texture, + rank = rankNum, + 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 + + if not guid or not slot or not spellId then return end + guid = CleveRoids.NormalizeGUID(guid) + + local spellName = SpellInfo and SpellInfo(spellId) + if not spellName then return end + + local _, playerGUID = UnitExists("player") + + -- Update slot info in ownDebuffs if this is our debuff + if lib.ownDebuffs[guid] and lib.ownDebuffs[guid][spellName] then + lib.ownDebuffs[guid][spellName].slot = slot + end + + -- Update allSlots for slot tracking + lib.allSlots[guid] = lib.allSlots[guid] or {} + lib.allSlots[guid][slot] = { + spellName = spellName, + casterGuid = playerGUID, -- Default to player, updated if other caster known + isOurs = lib.ownDebuffs[guid] and lib.ownDebuffs[guid][spellName] ~= nil, + } + + -- Check allAuraCasts for caster info if not ours + if lib.allAuraCasts[guid] and lib.allAuraCasts[guid][spellName] then + for casterGuid, _ in pairs(lib.allAuraCasts[guid][spellName]) do + lib.allSlots[guid][slot].casterGuid = casterGuid + break + end + 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 + + if not guid or not slot then return end + guid = CleveRoids.NormalizeGUID(guid) + + local spellName = spellId and SpellInfo and SpellInfo(spellId) + + -- 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 + if lib.ownSlots[guid] and lib.ownSlots[guid][slot] then + lib.ownSlots[guid][slot] = nil + end + + -- Remove from allSlots and shift slots down + if lib.allSlots[guid] and lib.allSlots[guid][slot] then + lib.allSlots[guid][slot] = nil + + -- Shift slots down + 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.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] + + -- 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.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 + + -- Clean up GUID to name mapping (after 5 seconds to allow final lookups) + -- Actually, keep it for a bit in case we need it for immunity detection + -- lib.guidToName[guid] = nil + + 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) @@ -3637,12 +4590,16 @@ end) lib.lastPlayerCast = lib.lastPlayerCast or nil local evLearn = CreateFrame("Frame", "CleveRoidsLibDebuffLearnFrame", UIParent) -evLearn:RegisterEvent("RAW_COMBATLOG") +-- 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 @@ -3985,158 +4942,7 @@ evLearn:SetScript("OnEvent", function() CleveRoids.lastMoltenBlastTargetGUID = nil end end - - elseif event == "RAW_COMBATLOG" then - 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 = SpellInfo(spellID) - if name then - name = gsub(name, "%s*%(%s*Rank%s+%d+%s*%)", "") - if name == spellName then - 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 - if so, learn it with combo point context - local comboPoints = lib.learnCastTimers[targetGUID][spellID].comboPoints - if comboPoints and CleveRoids.IsComboScalingSpellID and CleveRoids.IsComboScalingSpellID(spellID) then - -- Learn combo spell duration - 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 - -- Learn normal spell duration - 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 not next(lib.learnCastTimers[targetGUID]) then - lib.learnCastTimers[targetGUID] = nil - end - end - - -- For personal debuffs, only remove if it was cast by the player - -- For shared debuffs, remove if expired OR not found in scan - 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 - -- Check if duration expired (with 1s safety margin for latency) - local hasExpired = (rec.start + rec.duration + 1) <= timestamp - - -- As a fallback, scan to see if the debuff is completely gone - -- (this helps catch edge cases where duration tracking is off) - local stillExists = false - local _, checkGUID = UnitExists("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 - - -- Remove if EITHER: - -- 1. Duration expired AND debuff not found in scan (definitely gone) - -- 2. Duration significantly expired (> 1s past expected expiry) - if (hasExpired and not stillExists) or ((rec.start + rec.duration + 2) <= timestamp) then - shouldRemove = true - if CleveRoids.debug then - DEFAULT_CHAT_FRAME:AddMessage( - string.format("|cffff8800[Fade Handler]|r Removed player's %s (ID:%d) - expired:%.1fs scan:%s", - spellName, spellID, rec.start + rec.duration, tostring(not stillExists)) - ) - end - elseif CleveRoids.debug then - DEFAULT_CHAT_FRAME:AddMessage( - string.format("|cffff8800[Fade Handler]|r Keeping player's %s (ID:%d) - not expired or still exists (expires:%.1fs now:%.1fs exists:%s)", - spellName, spellID, rec.start + rec.duration, timestamp, tostring(stillExists)) - ) - end - elseif CleveRoids.debug and rec.caster ~= "player" then - -- Ignore fade events for other players' personal debuffs - DEFAULT_CHAT_FRAME:AddMessage( - string.format("|cffff8800[Fade Handler]|r Ignored %s (ID:%d) fade - caster is '%s', not player", - spellName, spellID, tostring(rec.caster or "nil")) - ) - end - else - -- Shared debuff: use Cursive's approach - scan to verify it's gone - local stillExists = false - local _, checkGUID = UnitExists("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 - - -- Remove if not found in scan OR if duration well past expiry - if not stillExists or ((rec.start + rec.duration + 2) <= timestamp) then - shouldRemove = true - if CleveRoids.debug then - DEFAULT_CHAT_FRAME:AddMessage( - string.format("|cffff8800[Fade Handler]|r Removed shared %s (ID:%d) - scan:%s expired:%s", - spellName, spellID, tostring(not stillExists), tostring((rec.start + rec.duration) <= timestamp)) - ) - end - end - end - - if shouldRemove then - lib.objects[targetGUID][spellID] = nil - end - - if not next(lib.objects[targetGUID]) then - lib.objects[targetGUID] = nil - end - break - end - end - end + -- NOTE: RAW_COMBATLOG fade handling now done by unified CleveRoidsUnifiedCombatLogFrame end end) @@ -5814,7 +6620,7 @@ local function CancelPendingVerification(targetName, spellName) end end --- Combat log parser for immunity detection +-- 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 @@ -5822,58 +6628,183 @@ local function ParseImmunityCombatLog() if not message then return end - -- PERFORMANCE: Quick length and content checks - -- Minimum immunity message: "X is immune" = ~11 chars - if string.len(message) < 11 then return end + -- PERFORMANCE: Quick length check + if string.len(message) < 8 then return end - -- ONLY process immunity messages - NOT resists! - -- Resists are RNG-based and should NOT create immunity records - -- Only "immune" messages indicate true immunity + -- 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") - if not hasImmune then + -- 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("|cffaaaaaa[Immunity Parse]|r " .. message) + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffaaaaaa[CombatLog Parse]|r [%s] %s", missReason, message)) end local spellName = nil local targetName = nil local school = nil - -- 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") + -- ============================================================================ + -- 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 - 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 + -- 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 - -- Pattern 4: "Y is immune" (generic) - if not targetName then - _, _, extractedTarget = string.find(message, "^(.-)%s+is immune") - if extractedTarget then + -- ============================================================================ + -- 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 @@ -6866,20 +7797,177 @@ if originalUnitCastEvent then end end --- Register combat log event for reactive proc tracking +-- ============================================================================ +-- 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 NampowerAPI (copied for performance) +local VICTIMSTATE_DODGE = 2 +local VICTIMSTATE_PARRY = 3 +local VICTIMSTATE_BLOCKS = 5 + +-- 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 = UnitExists("player") + if not playerGUID then return end + + -- Determine current target GUID + local _, currentTargetGUID = UnitExists("target") + + -- ======================================================================== + -- OVERPOWER: Enemy dodges YOUR attack + -- ======================================================================== + if isPlayerAttacker and victimState == VICTIMSTATE_DODGE then + -- Enemy dodged our attack - Overpower proc + if CleveRoids.reactiveSpells and CleveRoids.reactiveSpells["Overpower"] then + local hasSpell = (CleveRoids.GetSpell and CleveRoids.GetSpell("Overpower")) or + (CleveRoids.Spells and CleveRoids.Spells["Overpower"]) + if hasSpell then + -- Overpower requires targeting the mob that dodged + 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 + 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 + local hasSpell = (CleveRoids.GetSpell and CleveRoids.GetSpell("Riposte")) or + (CleveRoids.Spells and CleveRoids.Spells["Riposte"]) + if hasSpell 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 + 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 + local hasSpell = (CleveRoids.GetSpell and CleveRoids.GetSpell("Revenge")) or + (CleveRoids.Spells and CleveRoids.Spells["Revenge"]) + if hasSpell 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 +end + +-- Register combat log event for reactive proc tracking (FALLBACK) -- PERFORMANCE: Removed CHAT_MSG_SPELL_SELF_DAMAGE (fires on every spell hit - not needed for dodge/parry/block) -- 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 CleveRoids.hasNampower and GetNampowerVersion then + local npMajor, npMinor = GetNampowerVersion() + if npMajor > 2 or (npMajor == 2 and npMinor >= 24) then + hasAutoAttackEvents = true + CleveRoids.usingNampowerAutoAttack = true + 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 + +-- Always register combat log events as fallback (or primary if no Nampower) reactiveFrame:RegisterEvent("RAW_COMBATLOG") reactiveFrame:RegisterEvent("CHAT_MSG_COMBAT_SELF_MISSES") reactiveFrame:RegisterEvent("CHAT_MSG_COMBAT_CREATURE_VS_SELF_MISSES") reactiveFrame:RegisterEvent("CHAT_MSG_COMBAT_CREATURE_VS_SELF_HITS") + reactiveFrame:SetScript("OnEvent", function() + -- ======================================================================== + -- 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 = UnitExists("player") + local isPlayerAttacker = (attackerGuid == playerGUID) + + CleveRoids.ProcessAutoAttackEvent(isPlayerAttacker, attackerGuid, targetGuid, totalDamage, hitInfo, victimState) + return -- Handled by Nampower, skip combat log parsing + end + + -- ======================================================================== + -- FALLBACK: Combat log parsing for older Nampower or no Nampower + -- ======================================================================== if 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() + -- Only use combat log if Nampower AUTO_ATTACK is not available + if not CleveRoids.usingNampowerAutoAttack then + CleveRoids.ParseReactiveCombatLog() + end end end) @@ -6982,11 +8070,158 @@ local function ParseResistCombatLog() end end --- Register for RAW_COMBATLOG (SuperWoW event) for resist tracking -local resistFrame = CreateFrame("Frame", "CleveRoidsResistFrame") -resistFrame:RegisterEvent("RAW_COMBATLOG") -resistFrame:SetScript("OnEvent", function() - if event == "RAW_COMBATLOG" then - ParseResistCombatLog() +-- ============================================================================ +-- 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 = SpellInfo(spellID) + if name then + name = gsub(name, "%s*%(%s*Rank%s+%d+%s*%)", "") + 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 = UnitExists("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 = UnitExists("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 + + -- 1. Debuff fade learning (for duration auto-learning) + HandleDebuffFade() + + -- 2. Immunity detection (immune/reflect/evade from combat log) + ParseImmunityCombatLog() + + -- 3. "Afflicted by" detection for hidden CC (e.g., Pounce stun) + ParseAfflictedCombatLog() + + -- 4. Reactive ability procs (only if not using Nampower AUTO_ATTACK events) + if not CleveRoids.usingNampowerAutoAttack then + CleveRoids.ParseReactiveCombatLog() + end + + -- 5. Resist tracking + ParseResistCombatLog() end)