diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 7d097d6..0000000 --- a/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -wow-api-type-definitions/ -.gitignore diff --git a/ComboPointTracker.lua b/ComboPointTracker.lua new file mode 100644 index 0000000..a670a0a --- /dev/null +++ b/ComboPointTracker.lua @@ -0,0 +1,1013 @@ +--[[ + ComboPoint Tracker for Duration-Scaling Spells + Author: Extension for CleveRoids + Purpose: Track combo points used when casting spells that scale duration with combo points +]] + +local _G = _G or getfenv(0) +local CleveRoids = _G.CleveRoids or {} + +-- Initialize combo point tracking table +CleveRoids.ComboPointTracking = CleveRoids.ComboPointTracking or {} +-- Note: CleveRoids.lastComboPoints and CleveRoids.lastComboPointsTime are initialized in Init.lua + +-- Initialize SavedVariable for learned combo durations +-- Structure: CleveRoids_ComboDurations[spellID][comboPoints] = duration +CleveRoids_ComboDurations = CleveRoids_ComboDurations or {} + +-- Storage for last Rip cast (for Carnage talent mechanic) +-- Carnage talent: When Ferocious Bite procs Carnage, it refreshes Rip and Rake to their original duration +-- Detection: When combo points don't drop to 0 after FB (they stay at 1 = Carnage proc) +-- Talent Position: Tab 2 (Feral Combat), Talent 17 +-- Rank 1: 10% per CP, Rank 2: 20% per CP +CleveRoids.lastRipCast = CleveRoids.lastRipCast or { + duration = nil, + targetGUID = nil, + timestamp = 0 +} + +CleveRoids.lastRakeCast = CleveRoids.lastRakeCast or { + duration = nil, + targetGUID = nil, + timestamp = 0 +} + +-- Define spells that scale with combo points by SPELL ID and their duration formulas +-- Duration = base + (combo_points - 1) * increment +CleveRoids.ComboScalingSpellsByID = { + -- ROGUE: Rupture - 8 sec base, +2 sec per additional combo point + [1943] = { base = 8, increment = 2, name = "Rupture" }, -- Rank 1 + [8639] = { base = 8, increment = 2, name = "Rupture" }, -- Rank 2 + [8640] = { base = 8, increment = 2, name = "Rupture" }, -- Rank 3 + [11273] = { base = 8, increment = 2, name = "Rupture" }, -- Rank 4 + [11274] = { base = 8, increment = 2, name = "Rupture" }, -- Rank 5 + [11275] = { base = 8, increment = 2, name = "Rupture" }, -- Rank 6 + + -- ROGUE: Kidney Shot - Rank 1: 1 sec base, Rank 2: 2 sec base, +1 sec per CP + [408] = { base = 1, increment = 1, name = "Kidney Shot" }, -- Rank 1 + [8643] = { base = 2, increment = 1, name = "Kidney Shot" }, -- Rank 2 + + -- DRUID: Rip - 12 sec base, +4 sec per additional combo point (Source: Vanilla WoW Wiki) + -- Note: Some sources say 10+2, but testing shows 12+4 is accurate for 1.12 + [1079] = { base = 10, increment = 2, name = "Rip" }, -- Rank 1 + [9492] = { base = 10, increment = 2, name = "Rip" }, -- Rank 2 + [9493] = { base = 10, increment = 2, name = "Rip" }, -- Rank 3 + [9752] = { base = 10, increment = 2, name = "Rip" }, -- Rank 4 + [9894] = { base = 10, increment = 2, name = "Rip" }, -- Rank 5 + [9896] = { base = 10, increment = 2, name = "Rip" }, -- Rank 6 +} + +-- Ferocious Bite spell IDs (for Carnage talent mechanic) +-- Carnage talent: When FB procs Carnage, refreshes Rip and Rake back to their original duration +-- Proc detection: combo points stay at 1 after FB instead of dropping to 0 +-- Talent Position: Tab 2 (Feral Combat), Talent 17 +CleveRoids.FerociousBiteSpellIDs = { + [22557] = true, -- Rank 1 + [22568] = true, -- Rank 2 + [22827] = true, -- Rank 3 + [22828] = true, -- Rank 4 + [22829] = true, -- Rank 5 + [31018] = true, -- Rank 6 +} + +-- Rip spell IDs (for Carnage talent - refreshed when Carnage procs) +CleveRoids.RipSpellIDs = { + [1079] = true, -- Rank 1 + [9492] = true, -- Rank 2 + [9493] = true, -- Rank 3 + [9752] = true, -- Rank 4 + [9894] = true, -- Rank 5 + [9896] = true, -- Rank 6 +} + +-- Rake spell IDs (for Carnage talent - refreshed when Carnage procs) +CleveRoids.RakeSpellIDs = { + [1822] = true, -- Rank 1 + [1823] = true, -- Rank 2 + [1824] = true, -- Rank 3 + [9904] = true, -- Rank 4 +} + +-- Pounce Bleed spell IDs (for immunity detection - bleed portion of Pounce) +-- Note: Pounce (cast) TRIGGERS a separate Pounce Bleed spell with different IDs +-- Cast IDs: 9005, 9823, 9827 → Trigger Bleed IDs: 9007, 9824, 9826 +CleveRoids.PounceBleedSpellIDs = { + [9007] = true, -- Rank 1 (triggered by Pounce 9005) + [9824] = true, -- Rank 2 (triggered by Pounce 9823) + [9826] = true, -- Rank 3 (triggered by Pounce 9827) +} + +-- Combined table for all bleed spells that need immunity detection +-- Used when checking if a cast bleed failed to apply (indicates bleed immunity) +-- NOTE: These are the DEBUFF spell IDs (what appears on target), not cast spell IDs +CleveRoids.BleedSpellIDs = { + -- Rip (bleed DoT from Cat Form finisher) + [1079] = true, -- Rank 1 + [9492] = true, -- Rank 2 + [9493] = true, -- Rank 3 + [9752] = true, -- Rank 4 + [9894] = true, -- Rank 5 + [9896] = true, -- Rank 6 + -- Rake (cast ID = debuff ID for Rake) + [1822] = true, -- Rank 1 + [1823] = true, -- Rank 2 + [1824] = true, -- Rank 3 + [9904] = true, -- Rank 4 + -- Pounce Bleed (triggered spell IDs, different from cast IDs) + [9007] = true, -- Rank 1 (triggered by Pounce 9005) + [9824] = true, -- Rank 2 (triggered by Pounce 9823) + [9826] = true, -- Rank 3 (triggered by Pounce 9827) +} + +-- Mapping from Pounce cast spell IDs to their triggered Pounce Bleed spell IDs +-- Used to track the correct debuff when UNIT_CASTEVENT fires for Pounce +CleveRoids.PounceToBleedMapping = { + [9005] = 9007, -- Pounce Rank 1 → Pounce Bleed Rank 1 + [9823] = 9824, -- Pounce Rank 2 → Pounce Bleed Rank 2 + [9827] = 9826, -- Pounce Rank 3 → Pounce Bleed Rank 3 +} + +-- ============================================================================= +-- SHAMAN: Molten Blast → Flame Shock Refresh (TWoW Custom) +-- When Molten Blast hits, it refreshes Flame Shock duration on the target +-- Detection: Monitor combat log for Molten Blast damage, then refresh Flame Shock +-- ============================================================================= +CleveRoids.MoltenBlastSpellIDs = { + [36916] = true, -- Rank 1 + [36917] = true, -- Rank 2 + [36918] = true, -- Rank 3 + [36919] = true, -- Rank 4 + [36920] = true, -- Rank 5 + [36921] = true, -- Rank 6 +} + +CleveRoids.FlameShockSpellIDs = { + [8050] = true, -- Rank 1 + [8052] = true, -- Rank 2 + [8053] = true, -- Rank 3 + [10447] = true, -- Rank 4 + [10448] = true, -- Rank 5 + [29228] = true, -- Rank 6 +} + +-- ============================================================================= +-- WARLOCK: Conflagrate → Immolate Duration Reduction +-- When Conflagrate is cast, it reduces Immolate duration by 3 seconds +-- ============================================================================= +CleveRoids.ConflagrateSpellIDs = { + [17962] = true, -- Rank 1 + [18930] = true, -- Rank 2 + [18931] = true, -- Rank 3 + [18932] = true, -- Rank 4 +} + +CleveRoids.ImmolateSpellIDs = { + [348] = true, -- Rank 1 + [707] = true, -- Rank 2 + [1094] = true, -- Rank 3 + [2941] = true, -- Rank 4 + [11665] = true, -- Rank 5 + [11667] = true, -- Rank 6 + [11668] = true, -- Rank 7 + [25309] = true, -- Rank 8 +} + +-- ============================================================================= +-- WARLOCK: Dark Harvest Duration Acceleration (TWoW Custom) +-- Channeled spell that accelerates DoT tick rate by 30% while channeling +-- Complex tracking: debuff expires 30% faster while Dark Harvest is active +-- ============================================================================= +CleveRoids.DarkHarvestSpellIDs = { + [52550] = true, -- Rank 1 + [52551] = true, -- Rank 2 + [52552] = true, -- Rank 3 +} + +-- ============================================================================= +-- DRUID: Rake Debuff Cap Boss Whitelist +-- These bosses are likely to hit the 48 debuff cap, causing Rake to get pushed off +-- For mobs NOT in this list, we verify Rake is actually on the target before tracking +-- Prevents false tracking when debuffs get pushed off at cap +-- ============================================================================= +CleveRoids.MobsThatBleed = { + -- Karazhan Crypts / TWoW Custom + ["0xF13000F1F3276A33"] = true, -- Keeper Gnarlmoon + ["0xF13000F1FA276A32"] = true, -- Ley-Watcher Incantagos + ["0xF13000EA3F276C05"] = true, -- King + ["0xF13000EA31279058"] = true, -- Queen + ["0xF13000EA43276C06"] = true, -- Bishop + ["0xF13000EA44279044"] = true, -- Pawn + ["0xF13000EA42276C07"] = true, -- Rook + ["0xF13000EA4D05DA44"] = true, -- Sanv Tas'dal + ["0xF13000EA57276C04"] = true, -- Kruul + ["0xF130016C95276DAF"] = true, -- Mephistroth + + -- Naxxramas + ["0xF130003E5401591A"] = true, -- Anub'Rekhan + ["0xF130003E510159B0"] = true, -- Grand Widow Faerlina + ["0xF130003E500159A3"] = true, -- Maexxna + ["0xF130003EBD01598C"] = true, -- Razuvious + ["0xF130003EBC01599F"] = true, -- Gothik + ["0xF130003EBF015AB2"] = true, -- Zeliek + ["0xF130003EBE015AB3"] = true, -- Mograine + ["0xF130003EC1015AB1"] = true, -- Blaumeux + ["0xF130003EC0015AB0"] = true, -- Thane + ["0xF130003E52015824"] = true, -- Noth + ["0xF130003E4001588D"] = true, -- Heigan + ["0xF130003E8B0158A2"] = true, -- Loatheb + ["0xF130003E9C0158EA"] = true, -- Patchwerk + ["0xF130003E3B0158EF"] = true, -- Grobbulus + ["0xF130003E3C0158F0"] = true, -- Gluth + ["0xF130003E380159A0"] = true, -- Thaddius + ["0xF130003E75015AB4"] = true, -- Sapphiron + ["0xF130003E76015AED"] = true, -- Kel'Thuzad +} + +-- Legacy name-based table for backwards compatibility +CleveRoids.ComboScalingSpells = { + ["Rupture"] = { base_duration = 8, increment = 2, all_ranks = true }, + ["Kidney Shot(Rank 1)"] = { base_duration = 1, increment = 1 }, + ["Kidney Shot(Rank 2)"] = { base_duration = 2, increment = 1 }, + ["Rip"] = { base_duration = 10, increment = 2, all_ranks = true } +} + +-- Function to get current combo points +-- Note: In Vanilla WoW 1.12, GetComboPoints() takes no parameters +-- (The "player", "target" syntax is from TBC/WotLK) +function CleveRoids.GetComboPoints() + return GetComboPoints() or 0 +end + +-- Update stored combo points (call this frequently) +function CleveRoids.UpdateComboPoints() + local current = CleveRoids.GetComboPoints() + if current > 0 then + CleveRoids.lastComboPoints = current + CleveRoids.lastComboPointsTime = GetTime() + end +end + +-- Function to check if a spell scales with combo points +function CleveRoids.IsComboScalingSpell(spellName) + if not spellName then return false end + + -- Check exact match first + if CleveRoids.ComboScalingSpells[spellName] then + return true + end + + -- Check for spells marked as all_ranks + for spell, data in pairs(CleveRoids.ComboScalingSpells) do + if data.all_ranks then + -- Remove rank from spell name for comparison + local baseName = string.gsub(spell, "%(Rank %d+%)", "") + local checkName = string.gsub(spellName, "%(Rank %d+%)", "") + if baseName == checkName then + return true + end + end + end + + return false +end + +-- Function to get spell data for combo scaling +function CleveRoids.GetComboScalingData(spellName) + if not spellName then return nil end + + -- Check exact match first + if CleveRoids.ComboScalingSpells[spellName] then + return CleveRoids.ComboScalingSpells[spellName] + end + + -- Check for spells marked as all_ranks + for spell, data in pairs(CleveRoids.ComboScalingSpells) do + if data.all_ranks then + local baseName = string.gsub(spell, "%(Rank %d+%)", "") + local checkName = string.gsub(spellName, "%(Rank %d+%)", "") + if baseName == checkName then + return data + end + end + end + + -- Special handling for Kidney Shot ranks + if string.find(spellName, "Kidney Shot") then + local _, _, rank = string.find(spellName, "Kidney Shot%(Rank (%d+)%)") + if rank then + rank = tonumber(rank) + if rank == 1 then + return CleveRoids.ComboScalingSpells["Kidney Shot(Rank 1)"] + elseif rank == 2 then + return CleveRoids.ComboScalingSpells["Kidney Shot(Rank 2)"] + end + end + -- Default to base Kidney Shot data + return CleveRoids.ComboScalingSpells["Kidney Shot"] + end + + return nil +end + +-- Function to calculate duration based on combo points +function CleveRoids.CalculateComboScaledDuration(spellName, comboPoints) + local data = CleveRoids.GetComboScalingData(spellName) + if not data then return nil end + + comboPoints = comboPoints or CleveRoids.GetComboPoints() + if comboPoints < 1 then comboPoints = 1 end -- Minimum 1 combo point + if comboPoints > 5 then comboPoints = 5 end -- Maximum 5 combo points + + return data.base_duration + (comboPoints - 1) * data.increment +end + +-- NEW: Check if spell ID is a combo scaling spell +function CleveRoids.IsComboScalingSpellID(spellID) + return CleveRoids.ComboScalingSpellsByID[spellID] ~= nil +end + +-- NEW: Get combo scaling data by spell ID +function CleveRoids.GetComboScalingDataByID(spellID) + return CleveRoids.ComboScalingSpellsByID[spellID] +end + +-- NEW: Get learned duration for specific combo point count +function CleveRoids.GetLearnedComboDuration(spellID, comboPoints) + if not CleveRoids_ComboDurations then + CleveRoids_ComboDurations = {} + end + if CleveRoids_ComboDurations[spellID] and CleveRoids_ComboDurations[spellID][comboPoints] then + return CleveRoids_ComboDurations[spellID][comboPoints] + end + return nil +end + +-- NEW: Calculate duration by spell ID and combo points (uses learned durations first) +function CleveRoids.CalculateComboScaledDurationByID(spellID, comboPoints) + local data = CleveRoids.GetComboScalingDataByID(spellID) + if not data then return nil end + + comboPoints = comboPoints or CleveRoids.GetComboPoints() + if comboPoints < 1 then comboPoints = 1 end -- Minimum 1 combo point + if comboPoints > 5 then comboPoints = 5 end -- Maximum 5 combo points + + local baseDuration + + -- Check for learned duration first + local learned = CleveRoids.GetLearnedComboDuration(spellID, comboPoints) + if learned then + baseDuration = learned + else + -- Fall back to formula + baseDuration = data.base + (comboPoints - 1) * data.increment + end + + -- Apply all duration modifiers (Nampower, talents, equipment, set bonuses) + if CleveRoids.ApplyAllDurationModifiers then + baseDuration = CleveRoids.ApplyAllDurationModifiers(spellID, baseDuration) + else + -- Fallback for load order (ApplyAllDurationModifiers not yet defined) + if CleveRoids.ApplyTalentModifier then + baseDuration = CleveRoids.ApplyTalentModifier(spellID, baseDuration) + end + if CleveRoids.ApplyEquipmentModifier then + baseDuration = CleveRoids.ApplyEquipmentModifier(spellID, baseDuration) + end + end + + return baseDuration +end + +-- Function to track combo points when casting (by spell name) +function CleveRoids.TrackComboPointCast(spellName) + if not CleveRoids.IsComboScalingSpell(spellName) then + return + end + + -- Get current combo points, but if they're 0 (already consumed), use last known value + local comboPoints = CleveRoids.GetComboPoints() + + -- If combo points are 0, use lastComboPoints as fallback + if comboPoints == 0 and CleveRoids.lastComboPoints > 0 then + comboPoints = CleveRoids.lastComboPoints + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cffff9900CleveRoids:|r ComboTrack: Using lastComboPoints (%d) for %s", + comboPoints, spellName) + ) + end + end + + local duration = CleveRoids.CalculateComboScaledDuration(spellName, comboPoints) + local currentTime = GetTime() + + -- Only update if this is a better (higher CP) value than existing, or if existing is stale + local existing = CleveRoids.ComboPointTracking[spellName] + if existing then + local age = currentTime - existing.cast_time + -- Keep existing if it's fresh (<0.5s) and EITHER has more combo points OR is confirmed + if age < 0.5 then + if existing.combo_points > comboPoints then + -- Don't overwrite better data with worse data + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff888888CleveRoids:|r ComboTrack: Ignoring %s with %d CP (have %d CP)", + spellName, comboPoints, existing.combo_points) + ) + end + return + elseif existing.confirmed then + -- Don't overwrite confirmed tracking with unconfirmed evaluation + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff888888CleveRoids:|r ComboTrack: Ignoring %s - already confirmed", + spellName) + ) + end + return + end + end + end + + -- Store the tracking data + CleveRoids.ComboPointTracking[spellName] = { + combo_points = comboPoints, + duration = duration, + cast_time = currentTime, + target = UnitName("target") or "Unknown", + confirmed = false -- Will be set to true by SPELLCAST_START + } + + -- Also store in spell_tracking for integration with existing system + if not CleveRoids.spell_tracking[spellName] then + CleveRoids.spell_tracking[spellName] = {} + end + + CleveRoids.spell_tracking[spellName].last_combo_points = comboPoints + CleveRoids.spell_tracking[spellName].last_duration = duration + CleveRoids.spell_tracking[spellName].last_cast_time = currentTime + + -- Debug output + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff4b7dccCleveRoids:|r ComboTrack: %s cast with %d CP, duration: %d seconds", + spellName, comboPoints, duration) + ) + end +end + +-- NEW: Track combo points by spell ID (for UNIT_CASTEVENT integration) +function CleveRoids.TrackComboPointCastByID(spellID, targetGUID) + if not CleveRoids.IsComboScalingSpellID(spellID) then + return nil + end + + -- Get current combo points, but if they're 0 (already consumed), use last known value + local comboPoints = CleveRoids.GetComboPoints() + + -- If combo points are 0, try multiple fallback sources + if comboPoints == 0 then + -- First, try lastComboPoints (don't reset immediately - let it persist) + if CleveRoids.lastComboPoints > 0 then + comboPoints = CleveRoids.lastComboPoints + -- Don't reset here - let it persist for multiple events + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cffff9900[TrackComboByID]|r Using lastComboPoints: %d for spell ID %d", + comboPoints, spellID) + ) + end + else + -- Second, check if name-based tracking has recent data for this spell + local spellName = SpellInfo(spellID) + if spellName then + -- Remove rank info for comparison + local baseName = string.gsub(spellName, "%s*%(Rank %d+%)", "") + if CleveRoids.ComboPointTracking[spellName] then + local tracking = CleveRoids.ComboPointTracking[spellName] + if tracking.combo_points and tracking.combo_points > 0 and + (GetTime() - tracking.cast_time) < 0.5 then -- Within last 0.5 seconds + comboPoints = tracking.combo_points + end + elseif CleveRoids.ComboPointTracking[baseName] then + local tracking = CleveRoids.ComboPointTracking[baseName] + if tracking.combo_points and tracking.combo_points > 0 and + (GetTime() - tracking.cast_time) < 0.5 then + comboPoints = tracking.combo_points + end + end + end + end + end + + local duration = CleveRoids.CalculateComboScaledDurationByID(spellID, comboPoints) + + if not duration then return nil end + + -- Store tracking data by spell ID + if not CleveRoids.ComboPointTracking.byID then + CleveRoids.ComboPointTracking.byID = {} + end + + CleveRoids.ComboPointTracking.byID[spellID] = { + combo_points = comboPoints, + duration = duration, + cast_time = GetTime(), + target_guid = targetGUID + } + + -- Debug output + if CleveRoids.debug then + local data = CleveRoids.ComboScalingSpellsByID[spellID] + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff4b7dccCleveRoids:|r ComboTrack: %s (ID:%d) cast with %d CP, duration: %d seconds", + data.name, spellID, comboPoints, duration) + ) + end + + -- Reset lastComboPoints after successfully using it + if comboPoints > 0 and comboPoints == CleveRoids.lastComboPoints then + CleveRoids.lastComboPoints = 0 + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff888888[TrackComboByID]|r Reset lastComboPoints after use") + ) + end + end + + return duration +end + +-- API function to get last tracked combo points for a spell +function CleveRoids.GetLastComboPointsForSpell(spellName) + if CleveRoids.ComboPointTracking[spellName] then + return CleveRoids.ComboPointTracking[spellName].combo_points + elseif CleveRoids.spell_tracking[spellName] then + return CleveRoids.spell_tracking[spellName].last_combo_points + end + return nil +end + +-- API function to get last calculated duration for a spell +function CleveRoids.GetLastDurationForSpell(spellName) + if CleveRoids.ComboPointTracking[spellName] then + return CleveRoids.ComboPointTracking[spellName].duration + elseif CleveRoids.spell_tracking[spellName] then + return CleveRoids.spell_tracking[spellName].last_duration + end + return nil +end + +-- Utility function to display current combo tracking info +function CleveRoids.ShowComboTracking() + CleveRoids.Print("=== Combo Point Tracking ===") + local hasData = false + for spell, data in pairs(CleveRoids.ComboPointTracking) do + if spell ~= "byID" and data.combo_points then + CleveRoids.Print(string.format("%s: %d CP, %ds duration, target: %s", + spell, data.combo_points, data.duration, data.target)) + hasData = true + end + end + if not hasData then + CleveRoids.Print("No combo finishers tracked yet. Cast Rupture, Rip, or Kidney Shot!") + end +end + +-- Slash command for debugging +SLASH_COMBOTRACK1 = "/combotrack" +SlashCmdList.COMBOTRACK = function(msg) + if msg == "show" then + CleveRoids.ShowComboTracking() + elseif msg == "clear" then + CleveRoids.ComboPointTracking = {} + CleveRoids.ComboPointTracking.byID = {} + CleveRoids.Print("Combo tracking data cleared") + elseif msg == "debug" then + CleveRoids.debug = not CleveRoids.debug + CleveRoids.Print("Combo tracking debug: " .. (CleveRoids.debug and "ON" or "OFF")) + else + CleveRoids.Print("ComboTrack commands:") + CleveRoids.Print(" /combotrack show - Display current tracking data") + CleveRoids.Print(" /combotrack clear - Clear tracking data") + CleveRoids.Print(" /combotrack debug - Toggle debug output") + end +end + +-- Export to global namespace NOW, before Extension registration +_G["CleveRoids"] = CleveRoids + +-- DEBUG: Confirm ShowComboTracking is defined (disabled to reduce login spam) +-- if CleveRoids.ShowComboTracking then +-- DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00ComboPointTracker: ShowComboTracking defined and exported!|r") +-- else +-- DEFAULT_CHAT_FRAME:AddMessage("|cffff0000ComboPointTracker: ERROR - ShowComboTracking NOT defined!|r") +-- end + +-- Hook global CastSpell IMMEDIATELY (before Extension system) +if _G.CastSpell then + local originalCastSpell = _G.CastSpell + _G.CastSpell = function(id, bookType) + -- Capture combo points IMMEDIATELY, before anything else + local currentCP = CleveRoids.GetComboPoints() + + -- Get spell name for checking + local spellName = GetSpellName(id, bookType) + + if currentCP and currentCP > 0 then + CleveRoids.lastComboPoints = currentCP + CleveRoids.lastComboPointsTime = GetTime() + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cffaaff00[CastSpell Hook]|r Captured %d CP before spell %d/%s (name:%s)", + currentCP, id or 0, bookType or "nil", spellName or "nil") + ) + end + end + + -- Check if it's a combo finisher and pre-populate tracking + if spellName then + local isCombo = CleveRoids.IsComboScalingSpell(spellName) + if CleveRoids.debug and currentCP and currentCP > 0 then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff888888[CastSpell Debug]|r spell='%s' isCombo=%s CP=%d", + spellName, tostring(isCombo), currentCP) + ) + end + + if isCombo and currentCP and currentCP > 0 then + local duration = CleveRoids.CalculateComboScaledDuration(spellName, currentCP) + if duration then + local baseName = string.gsub(spellName, "%s*%(Rank %d+%)", "") + CleveRoids.ComboPointTracking[baseName] = { + combo_points = currentCP, + duration = duration, + cast_time = GetTime(), + target = UnitName("target") or "Unknown", + confirmed = true + } + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff00ff00[CastSpell Pre-Track]|r %s with %d CP, duration: %ds", + baseName, currentCP, duration) + ) + end + end + end + end + + return originalCastSpell(id, bookType) + end + -- DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00ComboPointTracker: CastSpell hook installed!|r") +end + +-- Hook global UseAction IMMEDIATELY (before Extension system) +if _G.UseAction then + local originalUseAction = _G.UseAction + _G.UseAction = function(slot, target, button) + -- Always capture combo points before action, in case it's a finisher + local currentCP = CleveRoids.GetComboPoints() + + -- Inspect the action button to see what spell it is + local macroName, actionType, actionID = GetActionText(slot) + local spellName = nil + + if actionType == "SPELL" and actionID then + spellName = SpellInfo(actionID) + end + + if currentCP and currentCP > 0 then + CleveRoids.lastComboPoints = currentCP + CleveRoids.lastComboPointsTime = GetTime() + -- Debug message removed to reduce spam + + -- If it's a combo finisher, pre-populate tracking + if spellName and CleveRoids.IsComboScalingSpell(spellName) then + local duration = CleveRoids.CalculateComboScaledDuration(spellName, currentCP) + if duration then + local baseName = string.gsub(spellName, "%s*%(Rank %d+%)", "") + CleveRoids.ComboPointTracking[baseName] = { + combo_points = currentCP, + duration = duration, + cast_time = GetTime(), + target = UnitName("target") or "Unknown", + confirmed = true + } + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff00ff00[UseAction Pre-Track]|r %s with %d CP, duration: %ds", + baseName, currentCP, duration) + ) + end + end + end + end + -- Debug message removed to reduce spam + return originalUseAction(slot, target, button) + end + -- DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00ComboPointTracker: UseAction hook installed!|r") +end + +-- Hook global CastSpellByName IMMEDIATELY (before Extension system) +if CastSpellByName then + local originalCastSpellByName = CastSpellByName + CastSpellByName = function(spellName, onSelf) + local currentCP = CleveRoids.GetComboPoints() + -- Debug message removed to reduce spam + + if spellName and CleveRoids.IsComboScalingSpell(spellName) then + if currentCP and currentCP > 0 then + CleveRoids.lastComboPoints = currentCP + CleveRoids.lastComboPointsTime = GetTime() + + -- Pre-populate tracking + local duration = CleveRoids.CalculateComboScaledDuration(spellName, currentCP) + if duration then + local baseName = string.gsub(spellName, "%s*%(Rank %d+%)", "") + CleveRoids.ComboPointTracking[baseName] = { + combo_points = currentCP, + duration = duration, + cast_time = GetTime(), + target = UnitName("target") or "Unknown", + confirmed = true + } + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cffff8800[CastSpellByName Pre-Track]|r %s with %d CP, duration: %ds", + baseName, currentCP, duration) + ) + end + end + end + end + + return originalCastSpellByName(spellName, onSelf) + end + -- DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00ComboPointTracker: CastSpellByName hook installed!|r") +end + +-- Hook into the existing DoCast function (safe to fail) +if not CleveRoids.RegisterExtension then + -- ExtensionsManager not loaded yet, skip extension system + -- DEFAULT_CHAT_FRAME:AddMessage("|cffff0000ComboPointTracker: RegisterExtension not found, skipping Extension system|r") + return +end + +local Extension = CleveRoids.RegisterExtension("ComboPointTracker") + +function Extension.OnLoad() + -- Register for cast events + Extension.RegisterEvent("SPELLCAST_START", "OnSpellcastStart") + Extension.RegisterEvent("SPELLCAST_STOP", "OnSpellcastStop") + Extension.RegisterEvent("SPELLCAST_FAILED", "OnSpellcastFailed") + Extension.RegisterEvent("SPELLCAST_INTERRUPTED", "OnSpellcastInterrupted") + Extension.RegisterEvent("PLAYER_TARGET_CHANGED", "OnTargetChanged") + Extension.RegisterEvent("UNIT_AURA", "OnUnitAura") + Extension.RegisterEvent("PLAYER_COMBO_POINTS", "OnComboPointsChanged") + + -- PERFORMANCE OPTIMIZATION: Removed OnUpdate polling for combo points + -- Combo points are now tracked via PLAYER_COMBO_POINTS event + spell cast hooks + -- This eliminates continuous polling while maintaining identical functionality + -- Events that trigger combo point updates: + -- - PLAYER_COMBO_POINTS (native event when CP change) + -- - PLAYER_TARGET_CHANGED (target switch clears CP) + -- - UNIT_AURA (for debuff applications) + -- - CastSpellByName/QueueSpellByName hooks (pre-cast capture) + + -- Note: CastSpell, UseAction, and CastSpellByName hooks are set up immediately when file loads (see above) +end + +-- Update combo points on relevant events +function Extension.OnTargetChanged() + CleveRoids.UpdateComboPoints() +end + +function Extension.OnUnitAura() + if arg1 == "target" or arg1 == "player" then + CleveRoids.UpdateComboPoints() + end +end + +function Extension.OnComboPointsChanged() + CleveRoids.UpdateComboPoints() + + -- CARNAGE PROC DETECTION (Cursive-style) + -- When Ferocious Bite is used, combo points should drop to 0 + -- If Carnage procs, combo points will be 1 instead (the Carnage-granted combo point) + -- Check: After Ferocious Bite (within 0.5s), if combo points > 0, Carnage procced + if CleveRoids.lastFerociousBiteTime and CleveRoids.lastFerociousBiteTargetGUID then + local timeSinceBite = GetTime() - CleveRoids.lastFerociousBiteTime + if timeSinceBite < 0.5 then + local currentCP = CleveRoids.GetComboPoints() + if currentCP > 0 then + -- Carnage procced! Combo points didn't drop to 0 (or rose back to 1) + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cffff00ff[Carnage]|r PROC DETECTED! CP=%d after Ferocious Bite (%.2fs ago)", + currentCP, timeSinceBite) + ) + end + + -- Apply the Carnage refresh to Rip and Rake + local targetGUID = CleveRoids.lastFerociousBiteTargetGUID + local targetName = CleveRoids.lastFerociousBiteTargetName or "Unknown" + local biteSpellID = CleveRoids.lastFerociousBiteSpellID + + -- Call the Carnage refresh function in Utility.lua + if CleveRoids.libdebuff and CleveRoids.libdebuff.ApplyCarnageRefresh then + CleveRoids.libdebuff.ApplyCarnageRefresh(targetGUID, targetName, biteSpellID) + end + + -- Clear the tracking to prevent multiple refreshes + CleveRoids.lastFerociousBiteTime = nil + CleveRoids.lastFerociousBiteTargetGUID = nil + CleveRoids.lastFerociousBiteTargetName = nil + CleveRoids.lastFerociousBiteSpellID = nil + end + else + -- Time window expired, clear tracking + if CleveRoids.lastFerociousBiteTime then + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cffff00ff[Carnage]|r No proc - time window expired (%.2fs)", timeSinceBite) + ) + end + CleveRoids.lastFerociousBiteTime = nil + CleveRoids.lastFerociousBiteTargetGUID = nil + CleveRoids.lastFerociousBiteTargetName = nil + CleveRoids.lastFerociousBiteSpellID = nil + end + end + end +end + +-- Event handlers +function Extension.OnSpellcastStart() + -- Track combo points at cast start for channeled combo spells + local spellName = arg1 + if spellName and CleveRoids.IsComboScalingSpell(spellName) then + -- Capture current combo points BEFORE the spell cast + local currentCP = CleveRoids.GetComboPoints() + if currentCP and currentCP > 0 then + CleveRoids.lastComboPoints = currentCP + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cffaaaaff[Pre-Cast]|r Captured %d combo points before casting %s", + currentCP, spellName) + ) + end + end + + CleveRoids.TrackComboPointCast(spellName) + + -- Mark this tracking as confirmed (actual cast, not just evaluation) + if CleveRoids.ComboPointTracking[spellName] then + CleveRoids.ComboPointTracking[spellName].confirmed = true + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff00ff00[Confirmed]|r %s tracking confirmed (SPELLCAST_START)", + spellName) + ) + end + end + end +end + +function Extension.OnSpellcastStop() + -- Track combo points at successful cast completion + local spellName = arg1 + if spellName and CleveRoids.IsComboScalingSpell(spellName) then + -- Update tracking if spell successfully completed + local tracking = CleveRoids.ComboPointTracking[spellName] + if tracking and (GetTime() - tracking.cast_time) < 1 then + -- Cast was recent, mark as successful + tracking.successful = true + end + end +end + +function Extension.OnSpellcastFailed() + -- Clear tracking for failed casts + local spellName = arg1 + if spellName and CleveRoids.ComboPointTracking[spellName] then + local tracking = CleveRoids.ComboPointTracking[spellName] + if tracking and (GetTime() - tracking.cast_time) < 1 then + -- Recent cast failed, clear confirmed flag and the tracking + tracking.confirmed = false + CleveRoids.ComboPointTracking[spellName] = nil + end + end +end + +function Extension.OnSpellcastInterrupted() + -- Clear tracking for interrupted casts + Extension.OnSpellcastFailed() +end + +-- Hook CastSpellByName to track combo points +function Extension.CastSpellByName_Hook(spellName, onSelf) + if spellName and CleveRoids.IsComboScalingSpell(spellName) then + -- Capture current combo points BEFORE the spell cast + local currentCP = CleveRoids.GetComboPoints() + if currentCP and currentCP > 0 then + CleveRoids.lastComboPoints = currentCP + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cffaaaaff[Pre-Cast]|r Captured %d combo points before casting %s", + currentCP, spellName) + ) + end + end + + CleveRoids.TrackComboPointCast(spellName) + + -- Confirm the tracking immediately - this only fires on actual casts + if CleveRoids.ComboPointTracking[spellName] then + CleveRoids.ComboPointTracking[spellName].confirmed = true + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff00ff00[Confirmed]|r %s tracking confirmed (CastSpellByName)", + spellName) + ) + end + end + end +end + +-- Hook CleveRoids.CastSpell if it exists +function Extension.CastSpell_Hook(spellName) + if spellName and CleveRoids.IsComboScalingSpell(spellName) then + -- Capture current combo points BEFORE the spell cast + local currentCP = CleveRoids.GetComboPoints() + if currentCP and currentCP > 0 then + CleveRoids.lastComboPoints = currentCP + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cffaaaaff[Pre-Cast]|r Captured %d combo points before casting %s", + currentCP, spellName) + ) + end + end + + CleveRoids.TrackComboPointCast(spellName) + + -- Confirm the tracking immediately - this only fires on actual casts + if CleveRoids.ComboPointTracking[spellName] then + CleveRoids.ComboPointTracking[spellName].confirmed = true + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff00ff00[Confirmed]|r %s tracking confirmed (CleveRoids.CastSpell)", + spellName) + ) + end + end + end +end + +-- Integration with DoCast function (moved before Extension registration) +if CleveRoids.DoCast then + local originalDoCast = CleveRoids.DoCast + CleveRoids.DoCast = function(msg) + -- Extract spell name from message + local spellName = msg + + -- Remove conditionals if present + local condEnd = string.find(msg, "]") + if condEnd then + spellName = string.sub(msg, condEnd + 1) + end + + spellName = CleveRoids.Trim(spellName) + + -- Track combo points if it's a scaling spell + if CleveRoids.IsComboScalingSpell(spellName) then + -- Capture current combo points BEFORE the spell cast + local currentCP = CleveRoids.GetComboPoints() + if currentCP and currentCP > 0 then + CleveRoids.lastComboPoints = currentCP + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cffaaaaff[Pre-Cast]|r Captured %d combo points before casting %s", + currentCP, spellName) + ) + end + end + + CleveRoids.TrackComboPointCast(spellName) + + -- Confirm the tracking - DoCast only fires on actual casts + if CleveRoids.ComboPointTracking[spellName] then + CleveRoids.ComboPointTracking[spellName].confirmed = true + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff00ff00[Confirmed]|r %s tracking confirmed (DoCast)", + spellName) + ) + end + end + end + + -- Call original function + return originalDoCast(msg) + end +end diff --git a/Compatibility/Bongos.lua b/Compatibility/Bongos.lua index 1d2774e..371d237 100644 --- a/Compatibility/Bongos.lua +++ b/Compatibility/Bongos.lua @@ -3,6 +3,45 @@ CleveRoids.RegisterActionEventHandler(function(slot, event) local button = getglobal("BActionButton" .. slot) if button then - BActionButton.Update(button) + -- For slot change events, do a full button update + if event == "ACTIONBAR_SLOT_CHANGED" then + BActionButton.Update(button) + end + + -- COOLDOWN FIX: Always update the cooldown using CleveRoids' active spell + -- Bongos uses button.cooldown for the cooldown frame + local cooldownFrame = button.cooldown or getglobal(button:GetName() .. "Cooldown") + if cooldownFrame then + local start, duration, enable + local spellSlot, bookType = CleveRoids.GetActionSpellSlot(slot) + + if spellSlot and bookType then + start, duration, enable = GetSpellCooldown(spellSlot, bookType) + else + local actions = CleveRoids.GetAction(slot) + local actionToCheck = actions and (actions.active or actions.tooltip) + if actionToCheck and actionToCheck.item then + local item = actionToCheck.item + if item.bagID and item.slot then + start, duration, enable = GetContainerItemCooldown(item.bagID, item.slot) + elseif item.inventoryID then + start, duration, enable = GetInventoryItemCooldown("player", item.inventoryID) + end + elseif actionToCheck then + local slotId = tonumber(actionToCheck.action) + if slotId and slotId >= 1 and slotId <= 19 then + start, duration, enable = GetInventoryItemCooldown("player", slotId) + end + end + + if not start then + start, duration, enable = GetActionCooldown(slot) + end + end + + if start and duration then + CooldownFrame_SetTimer(cooldownFrame, start, duration, (enable and enable > 0) and enable or 1) + end + end end end) diff --git a/Compatibility/SuperMacro.lua b/Compatibility/SuperMacro.lua index e3734ef..46f456e 100644 --- a/Compatibility/SuperMacro.lua +++ b/Compatibility/SuperMacro.lua @@ -5,6 +5,7 @@ - Intercepts CRM-owned slashcommands (including {…}, […], !, ?, ~) - Forwards to CRM's SlashCmdList handlers; preserves /castsequence - Leaves SuperMacro unmodified; safe fallback to original RunLine + - Redirects global RunMacro to SuperMacro_RunMacro so RunLine hook can intercept ]] do @@ -14,15 +15,25 @@ do CRM.Hooks = CRM.Hooks or {} -- Commands owned by CRM (keep in sync with Console.lua registrations) + -- NOTE: /runmacro is NOT intercepted here - we let SuperMacro's RunLine handle it + -- naturally via SuperMacro_RunMacro. This avoids conflicts with SuperMacro's keybinding system. local INTERCEPT = { cast=true, castsequence=true, use=true, - startattack=true, stopattack=true, stopcasting=true, + startattack=true, stopattack=true, stopcasting=true, stopmacro=true, + skipmacro=true, firstaction=true, nofirstaction=true, target=true, retarget=true, cancelaura=true, unbuff=true, unqueue=true, unshift=true, equip=true, equipmh=true, equipoh=true, - runmacro=true, + -- runmacro intentionally NOT included - see note above -- pet petattack=true, petfollow=true, petpassive=true, 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) @@ -60,10 +71,36 @@ do -- SuperMacro calls RunLine(line) one line at a time; handle first arg. local text = arg and arg[1] - -- respect /stopmacro guard - if CRM.stopmacro then - CRM.stopmacro = false - return true + -- 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 + local _, _, nofirstactionArgs = string.find(text, "^%s*/nofirstaction%s*(.*)") + if nofirstactionArgs then + -- Capture stopOnCastFlag BEFORE DoNoFirstAction clears it + local wasFirstActionActive = CRM.stopOnCastFlag + if type(CleveRoids.DoNoFirstAction) == "function" then + pcall(CleveRoids.DoNoFirstAction, nofirstactionArgs or "") + end + -- Also clear stopMacroFlag if it was set by firstaction mechanism + if wasFirstActionActive and CRM.stopMacroFlag then + CRM.stopMacroFlag = false + end + return true -- We handled it + end + end + + -- Check macro stop flags before processing any line + -- (but /nofirstaction was already handled above) + if CRM.stopMacroFlag or CRM.skipMacroFlag then + -- If stopOnCastFlag is true, this is firstaction mode - skip but continue + -- If stopOnCastFlag is false, this is /stopmacro - skip the line + return true -- Skip this line, tell SM we handled it end if type(text) == "string" then @@ -83,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 @@ -100,7 +138,7 @@ do if type(fn) == "function" then local b2, e2 = string.find(text, "^%s*/" .. k .. "%s+[!%[%{%?~]") if b2 then - local msg2 = string.gsub(string.sub(text, e2 + 1), "^%s+", "") + local msg2 = string.gsub(string.sub(text, e2), "^%s+", "") pcall(fn, msg2) return true -- IMPORTANT end @@ -114,6 +152,40 @@ do end CRM.SM_RunLineHooked = true + + -- Hook SuperMacro_RunMacro to clear stopmacro flag at macro start + -- and redirect global RunMacro so all macro execution goes through RunLine + if type(_G.SuperMacro_RunMacro) == "function" then + local orig_SuperMacro_RunMacro = _G.SuperMacro_RunMacro + CRM.Hooks.SuperMacro_RunMacro = orig_SuperMacro_RunMacro + + local function hooked_RunMacro(index) + -- 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 + + _G.SuperMacro_RunMacro = hooked_RunMacro + _G.RunMacro = hooked_RunMacro + _G.Macro = hooked_RunMacro + end + + -- Hook RunSuperMacro (for "Super" extended text macros) to clear stopmacro flag + -- This is a separate function from SuperMacro_RunMacro and uses RunLine directly + if type(_G.RunSuperMacro) == "function" then + local orig_RunSuperMacro = _G.RunSuperMacro + CRM.Hooks.RunSuperMacro = orig_RunSuperMacro + + _G.RunSuperMacro = function(index) + -- 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 end -- ===== Order-agnostic loader: install only when BOTH addons are present ===== @@ -133,7 +205,8 @@ do local function both_loaded() if SM_LOADED and CRM_LOADED then return true end -- heuristic fallback in case this file loads late: - local sm_ok = type(_G.RunLine) == "function" + -- Check for SuperMacro's namespaced internal function (preferred) or RunLine + local sm_ok = type(_G.SuperMacro_RunMacro) == "function" or type(_G.RunLine) == "function" local crm_ok = _G.CleveRoids and _G.SlashCmdList and type(_G.SlashCmdList.CAST) == "function" return sm_ok and crm_ok end @@ -142,7 +215,9 @@ do if both_loaded() then install_supermacro_hook() end end - f:SetScript("OnEvent", function(_, evt, addon) + f:SetScript("OnEvent", function() + local evt = event + local addon = arg1 if evt == "ADDON_LOADED" then if type(addon) == "string" then note_loaded(addon) end try_install() diff --git a/Compatibility/UltimaMacros.lua b/Compatibility/UltimaMacros.lua new file mode 100644 index 0000000..2120aa1 --- /dev/null +++ b/Compatibility/UltimaMacros.lua @@ -0,0 +1,185 @@ +--[[ + UltimaMacros compatibility shim for CleveRoidMacros (1.12.1-safe) + - Installs only when BOTH UltimaMacros and SuperCleveRoidMacros are loaded + - Hooks UltimaMacros' UM_RunLine to intercept CRM-owned slash commands + - Clears CRM stop flags at macro start + - Enables #showtooltip dynamic icon support for UltimaMacros +]] + +do + local _G = _G or getfenv(0) + local CRM = _G.CleveRoids or {} + _G.CleveRoids = CRM + CRM.Hooks = CRM.Hooks or {} + + -- Commands owned by CRM (same as SuperMacro.lua) + local INTERCEPT = { + cast=true, castsequence=true, use=true, + startattack=true, stopattack=true, stopcasting=true, stopmacro=true, + skipmacro=true, firstaction=true, nofirstaction=true, + target=true, retarget=true, cancelaura=true, unbuff=true, + unqueue=true, unshift=true, equip=true, equipmh=true, equipoh=true, + petattack=true, petfollow=true, petpassive=true, + petaggressive=true, petdefensive=true, petwait=true, + quickheal=true, qh=true, + castpet=true, cleartarget=true, + applymain=true, applyoff=true, + ["equip11"]=true, ["equip12"]=true, ["equip13"]=true, ["equip14"]=true, + } + + local ALIASES = { unbuff = "cancelaura" } + + local function has_handler(cmd) + local list = _G.SlashCmdList + return cmd and list and type(list[string.upper(cmd)]) == "function" + end + + local function install_ultimamacros_hook() + if CRM.UM_RunLineHooked then return end + if type(_G.UM_RunLine) ~= "function" then return end + + local orig_RunLine = _G.UM_RunLine + CRM.Hooks.UM_RunLine = orig_RunLine + + _G.UM_RunLine = function(line) + if type(line) ~= "string" then + return orig_RunLine(line) + end + + -- Skip comment lines + if string.find(line, "^%s*%-%-") then + return orig_RunLine(line) + end + + -- Skip #showtooltip lines (don't execute, just for icon) + if string.find(line, "^%s*#") then + return + end + + -- Handle /nofirstaction BEFORE stop flag check + local _, _, nofirstactionArgs = string.find(line, "^%s*/nofirstaction%s*(.*)") + if nofirstactionArgs then + local wasFirstActionActive = CRM.stopOnCastFlag + if type(CleveRoids.DoNoFirstAction) == "function" then + pcall(CleveRoids.DoNoFirstAction, nofirstactionArgs or "") + end + if wasFirstActionActive and CRM.stopMacroFlag then + CRM.stopMacroFlag = false + end + return + end + + -- Check macro stop flags + if CRM.stopMacroFlag or CRM.skipMacroFlag then + return + end + + -- Handle /castsequence + local _, _, csRest = string.find(line, "^%s*/castsequence%s*(.*)") + if csRest then + if type(CleveRoids.DoCastSequence) == "function" then + pcall(CleveRoids.DoCastSequence, csRest or "") + return + end + local fn = _G.SlashCmdList and _G.SlashCmdList["CASTSEQUENCE"] + if type(fn) == "function" then + pcall(fn, csRest or "") + return + end + end + + -- Generic /cmd forwarding + local _, _, raw, msg = string.find(line, "^%s*/(%S+)%s*(.*)$") + if raw then + local cmd = string.lower(raw) + if cmd == "unbuff" then cmd = "cancelaura" end + if INTERCEPT[cmd] and has_handler(cmd) then + pcall(_G.SlashCmdList[string.upper(cmd)], msg or "") + return + end + end + + -- Fast-path for extended/bracket tokens + local hooks = { cast = CRM.DoCast, target = CRM.DoTarget, use = CRM.DoUse } + for k, fn in pairs(hooks) do + if type(fn) == "function" then + local b2, e2 = string.find(line, "^%s*/" .. k .. "%s+[!%[%{%?~]") + if b2 then + local msg2 = string.gsub(string.sub(line, e2), "^%s+", "") + pcall(fn, msg2) + return + end + end + end + + -- Not handled by CRM: let UltimaMacros do its normal processing + return orig_RunLine(line) + end + + CRM.UM_RunLineHooked = true + + -- Hook UM_Run to clear stop flags at macro start + if type(_G.UM_Run) == "function" then + local orig_UM_Run = _G.UM_Run + CRM.Hooks.UM_Run = orig_UM_Run + + _G.UM_Run = function(name) + -- Clear ALL macro stop flags at macro start + CRM.stopMacroFlag = false + CRM.skipMacroFlag = false + CRM.stopOnCastFlag = false + return orig_UM_Run(name) + end + end + + -- Register for dynamic icon updates (modifier keys, target changes, etc.) + if CRM.RegisterActionEventHandler then + CRM.RegisterActionEventHandler(function(slot, event) + -- Trigger button update for UltimaMacros slots + if _G.UM_GetMappedName and _G.UM_RefreshActionButtonsForSlot then + local name = _G.UM_GetMappedName(slot) + if name then + _G.UM_RefreshActionButtonsForSlot(slot) + end + end + end) + end + end + + -- Order-agnostic loader + local UM_LOADED, CRM_LOADED = false, false + local f = CreateFrame("Frame") + f:RegisterEvent("ADDON_LOADED") + f:RegisterEvent("PLAYER_LOGIN") + + local function note_loaded(name) + if name == "UltimaMacros" then + UM_LOADED = true + elseif name == "SuperCleveRoidMacros" or name == "CleveRoidMacros" then + CRM_LOADED = true + end + end + + local function both_loaded() + if UM_LOADED and CRM_LOADED then return true end + -- Heuristic fallback + local um_ok = type(_G.UM_Run) == "function" and type(_G.UM_RunLine) == "function" + local crm_ok = _G.CleveRoids and _G.SlashCmdList and type(_G.SlashCmdList.CAST) == "function" + return um_ok and crm_ok + end + + local function try_install() + if both_loaded() then install_ultimamacros_hook() end + end + + f:SetScript("OnEvent", function() + local evt = event + local addon = arg1 + if evt == "ADDON_LOADED" then + if type(addon) == "string" then note_loaded(addon) end + try_install() + elseif evt == "PLAYER_LOGIN" then + try_install() + end + end) +end diff --git a/Compatibility/pfUI.lua b/Compatibility/pfUI.lua index 992cf25..0c343be 100644 --- a/Compatibility/pfUI.lua +++ b/Compatibility/pfUI.lua @@ -91,6 +91,413 @@ function Extension.HandleSendChatMessageHook() end end +-- Helper function to check for Carnage duration override +-- Returns override duration and timeleft if found, nil otherwise +local function GetCarnageOverride(effect) + if not effect or not CleveRoids.carnageDurationOverrides then + return nil, nil + end + + for spellID, override in pairs(CleveRoids.carnageDurationOverrides) do + local spellName = SpellInfo(spellID) + if spellName then + local baseName = string.gsub(spellName, "%s*%(Rank %d+%)", "") + if baseName == effect and override.timestamp and (GetTime() - override.timestamp) < 5 then + local timeleft = override.duration - (GetTime() - override.timestamp) + if timeleft < 0 then timeleft = 0 end + return override.duration, timeleft + end + end + end + return nil, nil +end + +-- Hook pfUI's libdebuff to use our combo-aware durations +-- NOTE: pfUI 7.6+ (GetUnitField edition) handles combo durations and Carnage internally. +-- We only inject when there's a mismatch between pfUI's data and ours. +function Extension.HookPfUILibdebuff() + if not pfUI or not pfUI.api or not pfUI.api.libdebuff then + return false + end + + local pflib = pfUI.api.libdebuff + + -- Check if pfUI 7.6+ with GetUnitField-based libdebuff is active + -- If so, pfUI handles combo durations and Carnage internally - we only override on mismatch + local hasPfUI76 = CleveRoids.hasPfUI76 or (pfUI.libdebuff_slot_ownership ~= nil) + + -- Hook GetDuration if it exists + -- pfUI's GetDuration signature: function(effect, rank) where effect is spell NAME + -- pfUI 7.6+ already handles combo durations via GetStoredComboPoints(), so only override on mismatch + if pflib.GetDuration and not Extension.pfLibDebuffHooked then + local originalGetDuration = pflib.GetDuration + + pflib.GetDuration = function(self, effect, rank) + local pfuiDuration = originalGetDuration(self, effect, rank) + + -- Check for Carnage duration overrides (only if pfUI doesn't have it) + local carnageDuration = GetCarnageOverride(effect) + if carnageDuration then + -- Only override if pfUI's duration is significantly different (>1s difference) + if not pfuiDuration or math.abs(carnageDuration - pfuiDuration) > 1 then + if Extension.Debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff00aaff[pfUI Duration Override]|r %s: Carnage %.1fs (pfUI: %.1fs)", + effect, carnageDuration, pfuiDuration or 0) + ) + end + return carnageDuration + end + end + + -- Check name-based tracking for fresh combo casts (only if pfUI returned 0 or nil) + if (not pfuiDuration or pfuiDuration == 0) and CleveRoids.ComboPointTracking and CleveRoids.ComboPointTracking[effect] then + local tracking = CleveRoids.ComboPointTracking[effect] + if tracking.duration and tracking.confirmed and (GetTime() - tracking.cast_time) < 0.5 then + if Extension.Debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff00aaff[pfUI Duration Override]|r %s: Combo tracking %.1fs (pfUI: %.1fs)", + effect, tracking.duration, pfuiDuration or 0) + ) + end + return tracking.duration + end + end + + return pfuiDuration + end + + Extension.pfLibDebuffHooked = true + Extension.DLOG("Hooked pfUI.api.libdebuff.GetDuration (mismatch-only mode)") + end + + -- Hook AddEffect if it exists + -- NOTE: In pfUI 7.6+, AddEffect is LEGACY (rarely called - events handle tracking) + -- We only hook for pre-7.6 compatibility and rank checking fallback + if pflib.AddEffect and not Extension.pfLibAddEffectHooked and not hasPfUI76 then + local originalAddEffect = pflib.AddEffect + + pflib.AddEffect = function(self, unit, unitlevel, effect, duration, caster) + -- RANK CHECKING: Preserve higher rank's remaining time if lower rank was cast + -- NOTE: 'unit' is a unit NAME (e.g., "Expert Training Dummy"), not a unit ID + -- Defensive: verify libdebuff is a table, not a function + if caster == "player" and type(CleveRoids.libdebuff) == "table" and duration and duration > 0 then + -- Try to find the GUID for this unit name + local unitGUID = nil + + -- Check if this is the current target + if UnitName("target") == unit then + local _, guid = UnitExists("target") + unitGUID = CleveRoids.NormalizeGUID(guid) + end + + -- If we couldn't match to current target, check guidToName mapping + if not unitGUID and CleveRoids.libdebuff.guidToName then + for guid, name in pairs(CleveRoids.libdebuff.guidToName) do + if name == unit then + unitGUID = guid + break + end + end + end + + -- Check if a higher rank of this spell is already active + if unitGUID and CleveRoids.libdebuff.objects and CleveRoids.libdebuff.objects[unitGUID] then + -- Find all spell IDs that match this effect name + for spellID, rec in pairs(CleveRoids.libdebuff.objects[unitGUID]) do + if rec and rec.start and rec.duration then + -- Get spell name for this ID + local spellName = SpellInfo(spellID) + if spellName then + local baseName = string.gsub(spellName, "%s*%(Rank %d+%)", "") + if baseName == effect then + -- Same spell - check if still active + local remaining = rec.duration + rec.start - GetTime() + if remaining > 0 then + -- If incoming duration > remaining time, we're trying to add more time + -- This means either a refresh or lower rank cast - preserve existing timer + if duration > remaining then + if Extension.Debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff00aaff[pfUI Rank Preserve]|r %s: Preserving timer (%.1fs remaining vs %.1fs incoming)", + effect, remaining, duration) + ) + end + + -- Preserve the existing timer + duration = remaining + end + break + end + end + end + end + end + end + end + + -- Check for Carnage duration overrides FIRST (highest priority) + local carnageDuration = GetCarnageOverride(effect) + if carnageDuration then + duration = duration or carnageDuration + caster = caster or "player" -- Ensure caster is set for UnitOwnDebuff filtering + end + + -- Check if this is a combo scaling spell by name + if not duration and CleveRoids.IsComboScalingSpell and CleveRoids.IsComboScalingSpell(effect) then + if CleveRoids.ComboPointTracking and CleveRoids.ComboPointTracking[effect] then + local tracking = CleveRoids.ComboPointTracking[effect] + if tracking.duration and tracking.confirmed and (GetTime() - tracking.cast_time) < 0.5 then + duration = tracking.duration + caster = caster or "player" + end + end + end + + return originalAddEffect(self, unit, unitlevel, effect, duration, caster) + end + + Extension.pfLibAddEffectHooked = true + Extension.DLOG("Hooked pfUI.api.libdebuff.AddEffect (pre-7.6 legacy mode)") + elseif hasPfUI76 then + Extension.DLOG("Skipped AddEffect hook (pfUI 7.6+ handles internally)") + end + + -- Hook UnitDebuff to return Carnage override duration to display code + -- Only override when pfUI's duration differs significantly from ours + if pflib.UnitDebuff and not Extension.pfLibUnitDebuffHooked then + local originalUnitDebuff = pflib.UnitDebuff + + pflib.UnitDebuff = function(self, unit, id) + local effect, rank, texture, stacks, dtype, duration, timeleft, caster = originalUnitDebuff(self, unit, id) + + -- Only check Carnage override if pfUI returned data but duration might be wrong + if effect then + local carnageDuration, carnageTimeleft = GetCarnageOverride(effect) + if carnageDuration then + -- Only override if there's a significant difference (>1s) + if not duration or math.abs(carnageDuration - duration) > 1 then + if Extension.Debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff00aaff[pfUI UnitDebuff Override]|r %s: Carnage %.1fs/%.1fs (pfUI: %.1fs/%.1fs)", + effect, carnageDuration, carnageTimeleft, duration or 0, timeleft or 0) + ) + end + duration = carnageDuration + timeleft = carnageTimeleft + end + end + end + + return effect, rank, texture, stacks, dtype, duration, timeleft, caster + end + + Extension.pfLibUnitDebuffHooked = true + Extension.DLOG("Hooked pfUI.api.libdebuff.UnitDebuff (mismatch-only mode)") + end + + -- Hook UnitOwnDebuff to return Carnage override duration when selfdebuff is enabled + -- Only override when pfUI's duration differs significantly from ours + if pflib.UnitOwnDebuff and not Extension.pfLibUnitOwnDebuffHooked then + local originalUnitOwnDebuff = pflib.UnitOwnDebuff + + pflib.UnitOwnDebuff = function(self, unit, id) + local effect, rank, texture, stacks, dtype, duration, timeleft, caster = originalUnitOwnDebuff(self, unit, id) + + if effect then + local carnageDuration, carnageTimeleft = GetCarnageOverride(effect) + if carnageDuration then + -- Only override if there's a significant difference (>1s) + if not duration or math.abs(carnageDuration - duration) > 1 then + if Extension.Debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff00aaff[pfUI UnitOwnDebuff Override]|r %s: Carnage %.1fs/%.1fs (pfUI: %.1fs/%.1fs)", + effect, carnageDuration, carnageTimeleft, duration or 0, timeleft or 0) + ) + end + duration = carnageDuration + timeleft = carnageTimeleft + end + end + -- If UnitOwnDebuff returned nil but we have a Carnage override, synthesize from UnitDebuff + -- This fallback is only needed for edge cases where pfUI doesn't track the debuff yet + elseif not effect and CleveRoids.carnageDurationOverrides then + -- Use pflib:UnitDebuff which includes our Carnage override hook + local baseEffect, baseRank, baseTex, baseStacks, baseDtype, baseDur, baseLeft, _ = pflib:UnitDebuff(unit, id) + if baseEffect then + local carnageDuration2, carnageTimeleft2 = GetCarnageOverride(baseEffect) + if carnageDuration2 then + return baseEffect, baseRank, baseTex, baseStacks, baseDtype, carnageDuration2, carnageTimeleft2, "player" + end + end + end + + return effect, rank, texture, stacks, dtype, duration, timeleft, caster + end + + Extension.pfLibUnitOwnDebuffHooked = true + Extension.DLOG("Hooked pfUI.api.libdebuff.UnitOwnDebuff (mismatch-only mode)") + end + + return Extension.pfLibDebuffHooked or Extension.pfLibAddEffectHooked or Extension.pfLibUnitDebuffHooked or Extension.pfLibUnitOwnDebuffHooked +end + +-- Synchronize combo durations to pfUI's libdebuff objects +function Extension.SyncComboDurationToPfUI(guid, spellID, duration) + if not pfUI or not pfUI.api or not pfUI.api.libdebuff then + return + end + + -- Get unit name from GUID + local unitName = nil + local unitLevel = 0 + + -- Check if this is the current target + local _, targetGUID = UnitExists("target") + if targetGUID == guid then + unitName = UnitName("target") + unitLevel = UnitLevel("target") or 0 + end + + -- If we couldn't find the unit, use GUID to name mapping from libdebuff + if not unitName and CleveRoids.libdebuff and CleveRoids.libdebuff.guidToName then + unitName = CleveRoids.libdebuff.guidToName[guid] + -- Default to level 0 if we don't have the unit targeted + unitLevel = 0 + end + + if not unitName then + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[pfUI Sync]|r Could not find unit name for GUID") + end + return + end + + -- Get spell name from spell ID + local spellName = SpellInfo(spellID) + if not spellName then + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[pfUI Sync]|r Could not find spell name for ID " .. spellID) + end + return + end + + -- Remove rank from spell name to match pfUI's format + local effectName = string.gsub(spellName, "%s*%(Rank %d+%)", "") + + -- Update pfUI's stored debuff duration + local pflib = pfUI.api.libdebuff + if pflib.objects and pflib.objects[unitName] then + -- Try both the specific level and level 0 (fallback) + for _, level in ipairs({unitLevel, 0}) do + if pflib.objects[unitName][level] and pflib.objects[unitName][level][effectName] then + local old_duration = pflib.objects[unitName][level][effectName].duration + pflib.objects[unitName][level][effectName].duration = duration + + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff00ffaa[pfUI Sync]|r Updated %s on %s (L%d): %ds -> %ds", + effectName, unitName, level, old_duration or 0, duration) + ) + end + return + end + end + end + + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cffaaaa00[pfUI Sync]|r Effect not found in pfUI storage: %s on %s", + effectName, unitName) + ) + end +end + +-- Register action event handler for pfUI button updates +function Extension.RegisterPfUIActionEventHandler() + if not pfUI or Extension.actionHandlerRegistered then + return + end + + -- Register a handler that will be called whenever CleveRoids updates macro states + if CleveRoids.RegisterActionEventHandler then + Extension.DLOG("Registering pfUI action event handler") + CleveRoids.RegisterActionEventHandler(function(slot, event, ...) + local button = pfUI.bars and pfUI.bars.buttons and pfUI.bars.buttons[slot] + + if Extension.Debug then + DEFAULT_CHAT_FRAME:AddMessage(string.format( + "|cff00ff00[pfUI CD]|r slot=%s event=%s button=%s cd=%s", + tostring(slot), tostring(event), + button and "yes" or "no", + (button and button.cd) and "yes" or "no" + )) + end + + -- For slot change events, do a full button update + if event == "ACTIONBAR_SLOT_CHANGED" then + -- Trigger pfUI's button update for this slot + -- Mark the slot for update in pfUI's cache, which will be processed on next OnUpdate + if pfUI.bars and pfUI.bars.update then + pfUI.bars.update[slot] = true + end + + -- Also directly call ButtonFullUpdate if the button exists + if button and pfUI.bars.ButtonFullUpdate then + pfUI.bars.ButtonFullUpdate(button) + end + end + + -- COOLDOWN FIX: Always update cooldowns explicitly + -- pfUI's macro scanner caches spell info and doesn't know about + -- CleveRoids' conditional spell changes. Manually update the cooldown + -- using the active spell from CleveRoids. + -- Note: pfUI uses button.cd for cooldown frame (not button.cooldown) + if button and button.cd then + local start, duration, enable + local spellSlot, bookType = CleveRoids.GetActionSpellSlot(slot) + + if spellSlot and bookType then + -- Active spell found - get its cooldown + start, duration, enable = GetSpellCooldown(spellSlot, bookType) + else + -- No active spell - check for item cooldown + local actions = CleveRoids.GetAction(slot) + local actionToCheck = actions and (actions.active or actions.tooltip) + if actionToCheck and actionToCheck.item then + local item = actionToCheck.item + if item.bagID and item.slot then + start, duration, enable = GetContainerItemCooldown(item.bagID, item.slot) + elseif item.inventoryID then + start, duration, enable = GetInventoryItemCooldown("player", item.inventoryID) + end + elseif actionToCheck then + -- Check for equipment slot (e.g., trinket) + local slotId = tonumber(actionToCheck.action) + if slotId and slotId >= 1 and slotId <= 19 then + start, duration, enable = GetInventoryItemCooldown("player", slotId) + end + end + + -- Fallback: use the hooked GetActionCooldown for non-CleveRoids actions + if not start then + start, duration, enable = GetActionCooldown(slot) + end + end + + -- Apply cooldown if we have valid data + -- Ensure enable is at least 1 (0 can hide the cooldown) + if start and duration then + CooldownFrame_SetTimer(button.cd, start, duration, (enable and enable > 0) and enable or 1) + end + end + end) + + Extension.actionHandlerRegistered = true + Extension.DLOG("Registered action event handler for pfUI button updates") + end +end + -- Main compatibility check and setup function Extension.SetupCompatibility() Extension.pfUILoaded = (pfUI ~= nil) @@ -99,6 +506,12 @@ function Extension.SetupCompatibility() if Extension.pfUILoaded then Extension.DLOG("pfUI detected") + -- Hook libdebuff for combo duration support + Extension.HookPfUILibdebuff() + + -- Register action event handler for button updates + Extension.RegisterPfUIActionEventHandler() + if Extension.macrotweakLoaded then Extension.DLOG("pfUI macrotweak module detected") @@ -111,17 +524,136 @@ function Extension.SetupCompatibility() end end +-- ============================================================================ +-- API Functions for pfUI Integration +-- These functions allow pfUI's actionbar module to query CleveRoids for +-- the active spell data, enabling proper cooldown/tooltip/icon display. +-- ============================================================================ + +-- Get the spell slot and book type for a given action slot +-- Returns: spellSlot, bookType (or nil, nil if not a CleveRoids-managed macro) +-- This is used by pfUI's ButtonMacroScan to get spell data for macros +function CleveRoids.GetActionSpellSlot(actionSlot) + if not actionSlot then return nil, nil end + + local actions = CleveRoids.GetAction(actionSlot) + if not actions then return nil, nil end + + -- Check if we have an active action with spell data + if actions.active and actions.active.spell then + local spell = actions.active.spell + if spell.spellSlot and spell.bookType then + return spell.spellSlot, spell.bookType + end + end + + -- Fallback: check tooltip action + if actions.tooltip then + local action = actions.tooltip + if action.spell and action.spell.spellSlot and action.spell.bookType then + return action.spell.spellSlot, action.spell.bookType + end + end + + return nil, nil +end + +-- Get the spell slot and book type for a given macro name +-- Returns: spellSlot, bookType (or nil, nil if not found) +function CleveRoids.GetMacroSpellSlot(macroName) + if not macroName then return nil, nil end + + local macro = CleveRoids.Macros[macroName] + if not macro or not macro.actions then return nil, nil end + + local actions = macro.actions + + -- Check if we have an active action with spell data + if actions.active and actions.active.spell then + local spell = actions.active.spell + if spell.spellSlot and spell.bookType then + return spell.spellSlot, spell.bookType + end + end + + -- Fallback: check tooltip action + if actions.tooltip then + local action = actions.tooltip + if action.spell and action.spell.spellSlot and action.spell.bookType then + return action.spell.spellSlot, action.spell.bookType + end + end + + return nil, nil +end + +-- Check if CleveRoids is managing a given action slot +-- Returns: true if CleveRoids has parsed this macro, false otherwise +function CleveRoids.IsManagedAction(actionSlot) + if not actionSlot then return false end + local actions = CleveRoids.GetAction(actionSlot) + return actions ~= nil and (actions.tooltip ~= nil or actions.list ~= nil) +end + +-- Check if CleveRoids is managing a given macro by name +-- Returns: true if CleveRoids has parsed this macro, false otherwise +function CleveRoids.IsManagedMacro(macroName) + if not macroName then return false end + return CleveRoids.Macros[macroName] ~= nil +end + +-- Get the active spell name for a given action slot (for debugging/display) +-- Returns: spellName (or nil if not found) +function CleveRoids.GetActionActiveSpellName(actionSlot) + if not actionSlot then return nil end + + local actions = CleveRoids.GetAction(actionSlot) + if not actions then return nil end + + if actions.active and actions.active.action then + return actions.active.action + end + + if actions.tooltip and actions.tooltip.action then + return actions.tooltip.action + end + + return nil +end + function Extension.OnLoad() Extension.DLOG("Extension pfUI Loaded.") Extension.HookMethod(CleveRoids, "GetFocusName", "FocusNameHook", true) + -- Export extension for external access + CleveRoids.Compatibility_pfUI = Extension + -- Initial compatibility check Extension.SetupCompatibility() + + -- Add slash command to toggle pfUI cooldown debug + -- Usage: /pfuicd to toggle debug mode + SlashCmdList["PFUICD"] = function() + Extension.Debug = not Extension.Debug + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[pfUI Compat]|r Debug mode: " .. (Extension.Debug and "ON" or "OFF")) + if Extension.Debug then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[pfUI Compat]|r Handler registered: " .. (Extension.actionHandlerRegistered and "YES" or "NO")) + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[pfUI Compat]|r pfUI detected: " .. (Extension.pfUILoaded and "YES" or "NO")) + if pfUI and pfUI.bars then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[pfUI Compat]|r pfUI.bars exists: YES") + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[pfUI Compat]|r pfUI.bars.buttons: " .. (pfUI.bars.buttons and "YES" or "NO")) + else + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[pfUI Compat]|r pfUI.bars exists: NO") + end + end + end + SLASH_PFUICD1 = "/pfuicd" end function Extension.ADDON_LOADED() - -- Check if pfUI just loaded - if arg1 == "pfUI" then + -- Check if pfUI just loaded AND the global actually exists + -- (another addon could be named "pfUI" without being the real UI framework) + if arg1 == "pfUI" and pfUI then Extension.pfUILoaded = true -- pfUI modules load after ADDON_LOADED, so schedule a check if CleveRoids.ScheduleTimer then @@ -135,6 +667,19 @@ end function Extension.PLAYER_LOGIN() -- Final check after everything is loaded Extension.SetupCompatibility() + + -- Print startup status only if pfUI global exists and compatibility was set up + if Extension.pfUILoaded and pfUI then + local statusMsg = "|cff00ff00[SCRM]|r pfUI compatibility loaded" + if CleveRoids.hasPfUI76 then + statusMsg = statusMsg .. " (7.6+ GUID cast tracking)" + end + -- statusMsg = statusMsg .. ". Use /pfuicd for debug." + DEFAULT_CHAT_FRAME:AddMessage(statusMsg) + if not Extension.actionHandlerRegistered then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[SCRM]|r WARNING: pfUI action handler NOT registered!") + end + end end -- Utility: Schedule a delayed function call (if not already defined) @@ -143,6 +688,9 @@ if not CleveRoids.ScheduleTimer then local timers = {} timerFrame:SetScript("OnUpdate", function() + -- Prevent SuperWoW API calls during shutdown (crash prevention) + if CleveRoids.isShuttingDown then return end + local time = GetTime() for i = table.getn(timers), 1, -1 do local timer = timers[i] diff --git a/Conditionals.lua b/Conditionals.lua index f8f1554..4be9e92 100644 --- a/Conditionals.lua +++ b/Conditionals.lua @@ -5,6 +5,420 @@ local _G = _G or getfenv(0) local CleveRoids = _G.CleveRoids or {} +-- Permanent cache for name normalization (underscores to spaces) +local _normalizedNames = {} + +-- PERFORMANCE: Cache for lowercased normalized names (for case-insensitive comparisons) +local _lowerNormalizedNames = {} + +-- Cached name normalization +function CleveRoids.NormalizeName(name) + if not name then return name end + local c = _normalizedNames[name] + if c then return c end + c = string.gsub(name, "_", " ") + _normalizedNames[name] = c + return c +end + +-- PERFORMANCE: Cached lowercase normalization (underscores to spaces + lowercase) +local function GetLowerNormalizedName(name) + if not name then return name end + local c = _lowerNormalizedNames[name] + if c then return c end + c = string.lower(string.gsub(name, "_", " ")) + _lowerNormalizedNames[name] = c + return c +end + +-- PERFORMANCE: Cache for lowercased strings +local _lowercaseCache = {} +local function GetLowercaseString(str) + if not str then return str end + local c = _lowercaseCache[str] + if c then return c end + c = string.lower(str) + _lowercaseCache[str] = c + return c +end + +-- Direct passthrough functions (no caching overhead for normal usage) +function CleveRoids.GetCachedTime() + return GetTime() +end + +function CleveRoids.GetCachedPlayerHealthPercent() + local max = UnitHealthMax("player") + return max > 0 and (100 * UnitHealth("player") / max) or 0 +end + +function CleveRoids.GetCachedPlayerPowerPercent() + local max = UnitManaMax("player") + return max > 0 and (100 * UnitMana("player") / max) or 0 +end + +function CleveRoids.GetCachedPlayerPower() + return UnitMana("player") +end + +function CleveRoids.GetCachedTargetHealthPercent() + if not UnitExists("target") then return 0 end + local max = UnitHealthMax("target") + return max > 0 and (100 * UnitHealth("target") / max) or 0 +end + +-- Cooldown uses original function directly +function CleveRoids.GetCachedCooldown(name, ignoreGCD) + return CleveRoids._GetCooldownUncached(name, ignoreGCD) +end + +-- No-ops for compatibility +function CleveRoids.ClearFrameCache() end +function CleveRoids.TrackButtonPress() end +function CleveRoids.GetCacheStats() return nil end +function CleveRoids.SetCacheTTL(ms) end +function CleveRoids.SetMacroThrottle(ms) end +function CleveRoids.GetMacroThrottle() return 0 end + +-- ============================================================================ +-- PERFORMANCE: Cache spell name -> spell ID mappings for debuff lookups +-- This avoids iterating personalDebuffs/sharedDebuffs and calling SpellInfo() repeatedly +local _spellNameToIDs = {} -- [spellName] = { spellID1, spellID2, ... } +local _spellNameToIDsBuilt = false + +-- Build the spell name to ID cache (called once on first debuff check) +local function BuildSpellNameCache() + if _spellNameToIDsBuilt then return end + _spellNameToIDsBuilt = true + + local lib = CleveRoids.libdebuff + if not lib then return end + + local gsub = string.gsub + + if lib.personalDebuffs then + for sid, _ in pairs(lib.personalDebuffs) do + local name = SpellInfo(sid) + if name then + name = gsub(name, "%s*%(%s*Rank%s+%d+%s*%)", "") + if not _spellNameToIDs[name] then + _spellNameToIDs[name] = {} + end + table.insert(_spellNameToIDs[name], sid) + end + end + end + + if lib.sharedDebuffs then + for sid, _ in pairs(lib.sharedDebuffs) do + local name = SpellInfo(sid) + if name then + name = gsub(name, "%s*%(%s*Rank%s+%d+%s*%)", "") + if not _spellNameToIDs[name] then + _spellNameToIDs[name] = {} + end + -- Avoid duplicates + local found = false + for _, existingId in ipairs(_spellNameToIDs[name]) do + if existingId == sid then found = true; break end + end + if not found then + table.insert(_spellNameToIDs[name], sid) + end + end + end + end +end + +-- Get cached spell IDs for a spell name +local function GetSpellIDsForName(spellName) + BuildSpellNameCache() + -- Strip rank from input spell name to match cache keys + -- This handles cases like "Faerie Fire (Feral)(Rank 4)" -> "Faerie Fire (Feral)" + if spellName then + spellName = string.gsub(spellName, "%s*%(%s*Rank%s+%d+%s*%)", "") + -- Convert underscores to spaces for matching (e.g., "Thunder_Clap" -> "Thunder Clap") + spellName = string.gsub(spellName, "_", " ") + end + return _spellNameToIDs[spellName] +end + +-- Invalidate cache (call if debuff lists change) +function CleveRoids.InvalidateSpellNameCache() + _spellNameToIDs = {} + _spellNameToIDsBuilt = false +end + +-- PERFORMANCE: Equipment cache for HasGearEquipped (avoids 19-slot scan per call) +-- Invalidated on UNIT_INVENTORY_CHANGED via CleveRoids.InvalidateEquipmentCache() +-- Enhanced with Nampower v2.18+ GetEquippedItems when available +local _equippedItemIDs = {} -- [slot] = itemID (number) +local _equippedItemNames = {} -- [slot] = itemName (lowercase string) +local _equipmentCacheValid = false + +-- Track if we've warned about GetEquippedItems errors (warn once per session) +local _getEquippedItemsErrorWarned = false + +local function BuildEquipmentCache() + if _equipmentCacheValid then return end + _equipmentCacheValid = true + + -- Clear old data + for i = 1, 19 do + _equippedItemIDs[i] = nil + _equippedItemNames[i] = nil + end + + local string_find = string.find + local string_lower = string.lower + + -- Try Nampower GetEquippedItems for faster enumeration + -- Requires v2.22+ because earlier versions (e.g., v2.19.1) have internal bug + local API = CleveRoids.NampowerAPI + local hasValidNampower = API and API.HasMinimumVersion and API.HasMinimumVersion(2, 22, 0) + + if hasValidNampower and GetEquippedItems then + -- Use pcall to catch any internal Nampower errors and fall back gracefully + local success, result = pcall(GetEquippedItems, "player") + + if not success then + -- Log the error once per session for debugging + if not _getEquippedItemsErrorWarned then + _getEquippedItemsErrorWarned = true + local errMsg = tostring(result) + if CleveRoids.Print then + CleveRoids.Print("|cffff6600Warning:|r GetEquippedItems failed: " .. errMsg) + CleveRoids.Print("Using fallback equipment detection. Consider updating Nampower.") + end + end + -- Fall through to manual enumeration + elseif result and type(result) == "table" then + local usedNampower = false + for nampowerSlot, itemInfo in pairs(result) do + -- Nampower uses 0-indexed slots, WoW API uses 1-indexed + -- tonumber() handles both string and numeric keys from different Nampower versions + -- Skip non-numeric keys (metadata fields, etc.) + local slotNum = tonumber(nampowerSlot) + if slotNum and type(itemInfo) == "table" and itemInfo.itemId then + local slot = slotNum + 1 + -- itemInfo must be a table to access .itemId (userdata from some Nampower versions is not indexable) + if slot >= 1 and slot <= 19 then + _equippedItemIDs[slot] = itemInfo.itemId + usedNampower = true + + -- Get item name via Nampower API or GetItemInfo + local itemName = API and API.GetItemName and API.GetItemName(itemInfo.itemId) + if not itemName then + itemName = GetItemInfo(itemInfo.itemId) + end + if itemName then + _equippedItemNames[slot] = string_lower(itemName) + end + end + end + end + if usedNampower then + return -- Done with Nampower path + end + -- Fall through to manual enumeration if Nampower returned userdata items + end + end + + -- Fallback: manual slot enumeration + for slot = 1, 19 do + local link = GetInventoryItemLink("player", slot) + if link then + local _, _, id = string_find(link, "item:(%d+)") + local _, _, nameInBrackets = string_find(link, "%[(.+)%]") + + if id then + _equippedItemIDs[slot] = tonumber(id) + end + if nameInBrackets then + _equippedItemNames[slot] = string_lower(nameInBrackets) + elseif id then + -- Fallback: resolve via GetItemInfo + local itemName = GetItemInfo(tonumber(id)) + if itemName then + _equippedItemNames[slot] = string_lower(itemName) + end + end + end + end +end + +-- Invalidate equipment cache (call on UNIT_INVENTORY_CHANGED) +function CleveRoids.InvalidateEquipmentCache() + _equipmentCacheValid = false +end + +-- ============================================================================ +-- PERFORMANCE: Unified item location lookup using CleveRoids.Items cache +-- Returns: { type="inventory"|"bag", inventoryID=N } or { type="bag", bag=N, slot=N } +-- Returns nil if item not found +-- Enhanced with Nampower v2.18+ FindPlayerItemSlot when available +-- ============================================================================ +local string_lower = string.lower +local string_find = string.find + +-- Fast item lookup using cache - O(1) instead of O(n) scan +-- @param item: item ID (number) or item name (string) +-- @return table with location info, or nil if not found +function CleveRoids.FindItemLocation(item) + -- Try Nampower v2.18+ native lookup first (much faster for item ID/name) + if FindPlayerItemSlot and item then + local numericItem = tonumber(item) + + -- Skip native lookup for equipment slot numbers (1-19) + if not (numericItem and numericItem >= 1 and numericItem <= 19) then + local bag, slot = FindPlayerItemSlot(item) + + if slot then + if bag == nil then + -- Equipped item - Nampower returns 1-indexed slot + return { type = "inventory", inventoryID = slot } + else + -- Bag item + return { type = "bag", bag = bag, slot = slot } + end + end + -- If Nampower lookup failed, fall through to cache lookup + -- (might find via substring match or cached alias) + end + end + + -- Fallback to CleveRoids.Items cache + local Items = CleveRoids.Items + if not Items then return nil end + + local itemData = nil + + -- Case 1: Numeric item ID + local numericItem = tonumber(item) + if numericItem then + -- Check if it's an equipment slot (1-19) + if numericItem >= 1 and numericItem <= 19 then + local link = GetInventoryItemLink("player", numericItem) + if link then + return { type = "inventory", inventoryID = numericItem } + end + return nil + end + + -- Look up by item ID in cache (Items[id] = name) + local itemName = Items[numericItem] + if itemName then + itemData = Items[itemName] + end + else + -- Case 2: String item name + if type(item) == "string" and item ~= "" then + -- Try exact match first, then lowercase + itemData = Items[item] + if not itemData or type(itemData) == "string" then + local lowerItem = string_lower(item) + local resolved = Items[lowerItem] + if type(resolved) == "string" then + itemData = Items[resolved] + elseif type(resolved) == "table" then + itemData = resolved + end + elseif type(itemData) == "string" then + -- Resolve indirection (lowercase -> canonical name) + itemData = Items[itemData] + end + end + end + + if not itemData or type(itemData) ~= "table" then + return nil + end + + -- Return location info + if itemData.inventoryID then + return { type = "inventory", inventoryID = itemData.inventoryID, itemData = itemData } + elseif itemData.bagID and itemData.slot then + return { type = "bag", bag = itemData.bagID, slot = itemData.slot, itemData = itemData } + end + + return nil +end + +-- Fast item existence check using cache +-- @param item: item ID (number) or item name (string) +-- @return boolean +function CleveRoids.HasItemCached(item) + return CleveRoids.FindItemLocation(item) ~= nil +end + +-- Fast item cooldown lookup using cache +-- Enhanced with Nampower v2.18+ GetItemIdCooldown for detailed info +-- @param item: item ID (number) or item name (string) +-- @return remainingSeconds, totalDuration, enabled +function CleveRoids.GetItemCooldownCached(item) + local location = CleveRoids.FindItemLocation(item) + if not location then + return 0, 0, 0 + end + + -- Try Nampower v2.18+ GetItemIdCooldown for more detailed info + -- This requires knowing the item ID + if GetItemIdCooldown then + local itemId = nil + + -- Get item ID from location or input + if location.itemData and location.itemData.id then + itemId = location.itemData.id + elseif type(item) == "number" and item > 19 then + itemId = item + else + -- Extract item ID from inventory/bag link + local link + if location.type == "inventory" then + link = GetInventoryItemLink("player", location.inventoryID) + else + link = GetContainerItemLink(location.bag, location.slot) + end + if link then + local _, _, id = string.find(link, "item:(%d+)") + if id then itemId = tonumber(id) end + end + end + + if itemId then + local cdInfo = GetItemIdCooldown(itemId) + if cdInfo then + local remaining = (cdInfo.cooldownRemainingMs or 0) / 1000 + local duration = (cdInfo.individualDurationMs or cdInfo.categoryDurationMs or 0) / 1000 + local enabled = (cdInfo.isOnCooldown == 1) and 1 or 0 + return remaining, duration, enabled + end + end + end + + -- Fallback to standard WoW API + local start, duration, enable + if location.type == "inventory" then + start, duration, enable = GetInventoryItemCooldown("player", location.inventoryID) + else + start, duration, enable = GetContainerItemCooldown(location.bag, location.slot) + end + + -- Normalize cooldown values + start = tonumber(start) or 0 + duration = tonumber(duration) or 0 + enable = tonumber(enable) or 0 + + if duration <= 0 or start <= 0 then + return 0, 0, enable + end + + local remaining = (start + duration) - GetTime() + if remaining < 0 then remaining = 0 end + + return remaining, duration, enable +end + --This table maps stat keys to the functions that retrieve their values. local stat_checks = { -- Base Stats (Corrected to use the 'effective' stat with gear) @@ -49,12 +463,74 @@ local stat_checks = { shadow_res = function() local _, val = UnitResistance("player", 6); return val end } -local function And(t,func) - if type(func) ~= "function" then return false end +-- ============================================================================ +-- PERFORMANCE: Specialized comparison functions to avoid closure allocation +-- These functions replace common patterns like: +-- return Or(t, function(v) return (i == tonumber(v)) end) +-- with: +-- return OrEqualsNumber(t, i) +-- ============================================================================ + +-- Or where any value equals target number (after tonumber conversion) +local function OrEqualsNumber(t, target) if type(t) ~= "table" then - t = { [1] = t } + return tonumber(t) == target end - for k,v in pairs(t) do + local k, v = next(t) + while k do + if tonumber(v) == target then return true end + k, v = next(t, k) + end + return false +end + +-- Or where any value equals target string (after string.lower conversion) +local function OrEqualsStringLower(t, target) + if type(t) ~= "table" then + return string.lower(t) == target + end + local k, v = next(t) + while k do + if string.lower(v) == target then return true end + k, v = next(t, k) + end + return false +end + +-- Or where any value does NOT equal target number (for negated conditionals) +local function OrNotEqualsNumber(t, target) + if type(t) ~= "table" then + return tonumber(t) ~= target + end + local k, v = next(t) + while k do + if tonumber(v) ~= target then return true end + k, v = next(t, k) + end + return false +end + +-- And where ALL values do NOT equal target number (for negated conditionals with AND logic) +local function AndNotEqualsNumber(t, target) + if type(t) ~= "table" then + return tonumber(t) ~= target + end + local k, v = next(t) + while k do + if tonumber(v) == target then return false end + k, v = next(t, k) + end + return true +end + +-- PERFORMANCE: Avoid creating wrapper tables for single values +local function And(t, func) + if type(func) ~= "function" then return false end + -- PERFORMANCE: Handle non-table case without allocation + if type(t) ~= "table" then + return func(t) and true or false + end + for k, v in pairs(t) do if not func(v) then return false end @@ -62,12 +538,13 @@ local function And(t,func) return true end -local function Or(t,func) +local function Or(t, func) if type(func) ~= "function" then return false end + -- PERFORMANCE: Handle non-table case without allocation if type(t) ~= "table" then - t = { [1] = t } + return func(t) and true or false end - for k,v in pairs(t) do + for k, v in pairs(t) do if func(v) then return true end @@ -75,6 +552,715 @@ local function Or(t,func) return false end +-- Helper to choose And() or Or() based on operator metadata +-- For positive conditionals (hp, power, cooldown, etc.): +-- - OR separator (/) uses Or() logic -> ANY value can match +-- - AND separator (&) uses And() logic -> ALL values must match +local function Multi(t, func, conditionals, condition) + if type(func) ~= "function" then return false end + + -- PERFORMANCE: Handle non-table case without allocation + if type(t) ~= "table" then + return func(t) and true or false + end + + -- Check for grouped structure (multiple instances of same conditional) + -- Groups are AND'd together, values within each group use group's operator + if conditionals and conditionals._groups and conditionals._groups[condition] then + local groups = conditionals._groups[condition] + -- All groups must pass (AND between groups) + for _, group in ipairs(groups) do + local groupPassed = false + local groupOp = group.operator or "OR" + + if groupOp == "AND" then + -- AND within group: ALL values must match + groupPassed = true + for _, v in ipairs(group.values) do + if not func(v) then + groupPassed = false + break + end + end + else + -- OR within group: ANY value can match + for _, v in ipairs(group.values) do + if func(v) then + groupPassed = true + break + end + end + end + + -- If any group fails, the whole conditional fails (AND between groups) + if not groupPassed then return false end + end + return true + end + + -- Fallback: Check operator type from metadata (single group / backwards compat) + local operatorType = "OR" -- default + if conditionals and conditionals._operators and conditionals._operators[condition] then + operatorType = conditionals._operators[condition] + end + + if operatorType == "AND" then + -- AND separator (&): ALL must match + for k, v in pairs(t) do + if not func(v) then return false end + end + return true + else + -- OR separator (/) [default]: ANY can match + for k, v in pairs(t) do + if func(v) then return true end + end + return false + end +end + +-- Helper to choose And() or Or() based on operator metadata +-- For negated conditionals (nomybuff, nozone, etc.), operators are FLIPPED (De Morgan's law): +-- - OR separator (/) [default]: ALL must be missing (e.g., nobuff:X/Y = no X AND no Y) +-- - AND separator (&): ANY can be missing (e.g., nobuff:X&Y = no X OR no Y) +-- This matches natural language: "no X or Y" intuitively means "neither X nor Y" +local function NegatedMulti(t, func, conditionals, condition) + if type(func) ~= "function" then return false end + + -- PERFORMANCE: Handle non-table case without allocation + if type(t) ~= "table" then + return func(t) and true or false + end + + -- Check for grouped structure (multiple instances of same conditional) + -- Groups are AND'd together, values within each group use FLIPPED group operator (De Morgan) + if conditionals and conditionals._groups and conditionals._groups[condition] then + local groups = conditionals._groups[condition] + -- All groups must pass (AND between groups) + for _, group in ipairs(groups) do + local groupPassed = false + local groupOp = group.operator or "OR" + + -- FLIPPED from positive conditionals (De Morgan's law for intuitive behavior) + if groupOp == "AND" then + -- AND separator (&) FLIPPED: ANY negation can pass (missing at least one) + for _, v in ipairs(group.values) do + if func(v) then + groupPassed = true + break + end + end + else + -- OR separator (/) FLIPPED: ALL negations must pass (missing all) + groupPassed = true + for _, v in ipairs(group.values) do + if not func(v) then + groupPassed = false + break + end + end + end + + -- If any group fails, the whole conditional fails (AND between groups) + if not groupPassed then return false end + end + return true + end + + -- Fallback: Check operator type from metadata (single group / backwards compat) + local operatorType = "OR" -- default + if conditionals and conditionals._operators and conditionals._operators[condition] then + operatorType = conditionals._operators[condition] + end + + -- FLIPPED from positive conditionals (De Morgan's law for intuitive behavior) + if operatorType == "AND" then + -- AND separator (&): ANY negation can pass (missing at least one) + for k, v in pairs(t) do + if func(v) then return true end + end + return false + else + -- OR separator (/) [default]: ALL negations must pass (missing all) + for k, v in pairs(t) do + if not func(v) then return false end + end + return true + end +end + +-- ============================================================================ +-- THREAT TRACKING (reads server data via CHAT_MSG_ADDON like TWThreat) +-- ============================================================================ + +-- Storage for threat data +CleveRoids.ThreatData = { + playerName = UnitName("player"), + threats = {}, -- [playerName] = { threat, perc, tank, melee } + lastUpdate = 0, +} + +-- Parse threat packet from server (same format as TWThreat) +-- Format: TWTv4=player1:tank:threat:perc:melee;player2:tank:threat:perc:melee;... +local function ParseThreatPacket(packet) + local threatApi = "TWTv4=" + local startPos = string.find(packet, threatApi, 1, true) + if not startPos then return end + + local playersString = string.sub(packet, startPos + string.len(threatApi)) + local playerName = CleveRoids.ThreatData.playerName + + -- Clear old data + CleveRoids.ThreatData.threats = {} + CleveRoids.ThreatData.lastUpdate = GetTime() + + -- Split by semicolon + for playerData in string.gfind(playersString, "[^;]+") do + -- Split by colon: player:tank:threat:perc:melee + local parts = {} + for part in string.gfind(playerData, "[^:]+") do + table.insert(parts, part) + end + + if parts[1] and parts[2] and parts[3] and parts[4] and parts[5] then + local name = parts[1] + local tank = parts[2] == "1" + local threat = tonumber(parts[3]) or 0 + local perc = tonumber(parts[4]) or 0 + local melee = parts[5] == "1" + + CleveRoids.ThreatData.threats[name] = { + threat = threat, + perc = perc, + tank = tank, + melee = melee, + } + end + end +end + +-- Get player's threat percentage +function CleveRoids.GetPlayerThreatPercent() + local playerName = CleveRoids.ThreatData.playerName + local data = CleveRoids.ThreatData.threats[playerName] + if data then + return data.perc + end + return nil +end + +-- Create frame to listen for threat addon messages +local threatFrame = CreateFrame("Frame", "CleveRoidsThreatFrame") +threatFrame:RegisterEvent("CHAT_MSG_ADDON") +threatFrame:SetScript("OnEvent", function() + if event == "CHAT_MSG_ADDON" then + -- arg1 = prefix, arg2 = message, arg3 = channel, arg4 = sender + if arg2 and string.find(arg2, "TWTv4=", 1, true) then + ParseThreatPacket(arg2) + end + end +end) + +-- ============================================================================ +-- AUTO-ATTACK EVENT TRACKING (Nampower v2.24+) +-- ============================================================================ +-- Tracks player's melee swings and incoming attacks for conditionals: +-- [lastswing:crit/glancing/miss/dodge/parry/blocked/offhand] +-- [incominghit:crit/crushing/dodge/parry/blocked] +-- [buffcapped] / [debuffcapped] +-- Requires NP_EnableAutoAttackEvents=1 and NP_EnableAuraCastEvents=1 CVars + +-- State tracking for player's outgoing melee swings +CleveRoids.LastSwing = { + timestamp = 0, -- GetTime() when swing occurred + damage = 0, -- Total damage dealt + hitInfo = 0, -- HitInfo bitfield + victimState = 0, -- VictimState enum + blockedAmount = 0, -- Amount blocked + absorbAmount = 0, -- Amount absorbed + resistAmount = 0, -- Amount resisted + targetGuid = nil, -- GUID of target hit +} + +-- State tracking for incoming attacks on player +CleveRoids.LastIncomingHit = { + timestamp = 0, + damage = 0, + hitInfo = 0, + victimState = 0, + blockedAmount = 0, + absorbAmount = 0, + resistAmount = 0, + attackerGuid = nil, +} + +-- Aura cap status tracking (from AURA_CAST events) +-- For player: 32 buff slots, 16 debuff slots +-- For enemy NPCs: 16 debuff slots + 32 overflow into buff slots = 48 total visual +CleveRoids.AuraCapStatus = { + -- Player aura cap status + playerBuffCapped = false, + playerDebuffCapped = false, + playerLastUpdate = 0, + -- Target aura cap status (tracked per-GUID) + targetCapStatus = {}, -- [guid] = { buffCapped, debuffCapped, timestamp } +} + +-- All-caster aura duration tracking (from AURA_CAST events) +-- Tracks buff/debuff durations from ANY caster, not just player +-- Structure: [targetGuid][spellId] = { start, duration, casterGuid } +CleveRoids.AllCasterAuraTracking = {} + +-- Helper to get time remaining from all-caster tracking +function CleveRoids.GetAllCasterAuraTimeRemaining(targetGuid, spellId) + if not targetGuid or not spellId then return nil end + local targetData = CleveRoids.AllCasterAuraTracking[targetGuid] + if not targetData then return nil end + local auraData = targetData[spellId] + if not auraData or not auraData.start or not auraData.duration then return nil end + + local remaining = auraData.duration + auraData.start - GetTime() + if remaining <= 0 then + -- Expired, clean up + targetData[spellId] = nil + return nil + end + return remaining +end + +-- Helper to find aura by name across all spell IDs for a target +function CleveRoids.FindAllCasterAuraByName(targetGuid, searchName) + if not targetGuid or not searchName then return nil, nil end + local targetData = CleveRoids.AllCasterAuraTracking[targetGuid] + if not targetData then return nil, nil end + + local now = GetTime() + local searchLower = string.lower(searchName) + + for spellId, auraData in pairs(targetData) do + if auraData.start and auraData.duration then + local remaining = auraData.duration + auraData.start - now + if remaining > 0 then + -- Get spell name and compare + local spellName = SpellInfo(spellId) + if spellName then + -- Strip rank and compare lowercase + local baseName = string.gsub(spellName, "%s*%(%s*Rank%s+%d+%s*%)", "") + if string.lower(baseName) == searchLower then + return remaining, auraData.casterGuid + end + end + else + -- Expired, clean up + targetData[spellId] = nil + end + end + end + return nil, nil +end + +-- HitInfo bitfield values (from NampowerAPI.lua, duplicated for local access) +-- Converted to decimal for Lua 5.0 compatibility (no hex literals) +local HITINFO_MISS = 16 -- 0x10 +local HITINFO_CRITICALHIT = 128 -- 0x80 +local HITINFO_GLANCING = 16384 -- 0x4000 +local HITINFO_CRUSHING = 32768 -- 0x8000 +local HITINFO_LEFTSWING = 4 -- 0x4 (Off-hand attack) + +-- VictimState values +local VICTIMSTATE_DODGE = 2 +local VICTIMSTATE_PARRY = 3 +local VICTIMSTATE_BLOCKS = 5 + +-- Aura cap status bitfield +local AURA_CAP_BUFF_FULL = 1 +local AURA_CAP_DEBUFF_FULL = 2 + +-- Helper: Check if a HitInfo bitfield contains a specific flag +local function HasHitFlag(hitInfo, flag) + if not hitInfo or not flag then return false end + if bit and bit.band then + return bit.band(hitInfo, flag) ~= 0 + end + -- Fallback for environments without bit library + return false +end + +-- Process AUTO_ATTACK_OTHER event (player attacking something, or other units) +local function OnAutoAttackOther(attackerGuid, targetGuid, totalDamage, hitInfo, victimState, + subDamageCount, blockedAmount, totalAbsorb, totalResist) + -- Check if player is the attacker + local _, playerGuid = UnitExists("player") + if not playerGuid or attackerGuid ~= playerGuid then + return -- Not player's attack, ignore + end + + CleveRoids.LastSwing.timestamp = GetTime() + CleveRoids.LastSwing.damage = totalDamage or 0 + CleveRoids.LastSwing.hitInfo = hitInfo or 0 + CleveRoids.LastSwing.victimState = victimState or 0 + CleveRoids.LastSwing.blockedAmount = blockedAmount or 0 + CleveRoids.LastSwing.absorbAmount = totalAbsorb or 0 + CleveRoids.LastSwing.resistAmount = totalResist or 0 + CleveRoids.LastSwing.targetGuid = targetGuid +end + +-- Process AUTO_ATTACK_SELF event (player being attacked) +local function OnAutoAttackSelf(attackerGuid, targetGuid, totalDamage, hitInfo, victimState, + subDamageCount, blockedAmount, totalAbsorb, totalResist) + CleveRoids.LastIncomingHit.timestamp = GetTime() + CleveRoids.LastIncomingHit.damage = totalDamage or 0 + CleveRoids.LastIncomingHit.hitInfo = hitInfo or 0 + CleveRoids.LastIncomingHit.victimState = victimState or 0 + CleveRoids.LastIncomingHit.blockedAmount = blockedAmount or 0 + CleveRoids.LastIncomingHit.absorbAmount = totalAbsorb or 0 + CleveRoids.LastIncomingHit.resistAmount = totalResist or 0 + CleveRoids.LastIncomingHit.attackerGuid = attackerGuid +end + +-- Process AURA_CAST events for cap status tracking +-- Parameters: spellId, casterGuid, targetGuid, effect, effectAuraName, +-- effectAmplitude, effectMiscValue, durationMs, auraCapStatus +local function OnAuraCastSelf(spellId, casterGuid, targetGuid, effect, effectAuraName, + effectAmplitude, effectMiscValue, durationMs, auraCapStatus) + if not auraCapStatus then return end + + local now = GetTime() + CleveRoids.AuraCapStatus.playerLastUpdate = now + + -- Check buff bar full (bit 1) + CleveRoids.AuraCapStatus.playerBuffCapped = HasHitFlag(auraCapStatus, AURA_CAP_BUFF_FULL) + -- Check debuff bar full (bit 2) + CleveRoids.AuraCapStatus.playerDebuffCapped = HasHitFlag(auraCapStatus, AURA_CAP_DEBUFF_FULL) +end + +local function OnAuraCastOther(spellId, casterGuid, targetGuid, effect, effectAuraName, + effectAmplitude, effectMiscValue, durationMs, auraCapStatus) + if not targetGuid then return end + + local now = GetTime() + + -- Store aura duration for all-caster tracking (even without auraCapStatus) + if spellId and durationMs and durationMs > 0 then + if not CleveRoids.AllCasterAuraTracking[targetGuid] then + CleveRoids.AllCasterAuraTracking[targetGuid] = {} + end + CleveRoids.AllCasterAuraTracking[targetGuid][spellId] = { + start = now, + duration = durationMs / 1000, -- Convert ms to seconds + casterGuid = casterGuid, + } + + -- Debug output when enabled + if CleveRoids.debug then + local spellName = SpellInfo(spellId) or "Unknown" + DEFAULT_CHAT_FRAME:AddMessage(string.format( + "|cff00ffff[AuraTrack]|r %s (ID:%d) on %s, dur=%.1fs", + spellName, spellId, string.sub(tostring(targetGuid), 1, 16), durationMs / 1000 + )) + end + end + + -- Store cap status for this target GUID (if available) + if auraCapStatus then + CleveRoids.AuraCapStatus.targetCapStatus[targetGuid] = { + buffCapped = HasHitFlag(auraCapStatus, AURA_CAP_BUFF_FULL), + debuffCapped = HasHitFlag(auraCapStatus, AURA_CAP_DEBUFF_FULL), + timestamp = now, + } + end + + -- Cleanup old entries (older than 60 seconds) to prevent memory leak + local count = 0 + for guid, data in pairs(CleveRoids.AuraCapStatus.targetCapStatus) do + count = count + 1 + if now - data.timestamp > 60 then + CleveRoids.AuraCapStatus.targetCapStatus[guid] = nil + end + end + + -- Cleanup old aura tracking entries + for guid, spells in pairs(CleveRoids.AllCasterAuraTracking) do + local hasActive = false + for sid, auraData in pairs(spells) do + if auraData.start and auraData.duration then + local remaining = auraData.duration + auraData.start - now + if remaining <= 0 then + spells[sid] = nil + else + hasActive = true + end + end + end + -- Remove empty target entries + if not hasActive then + CleveRoids.AllCasterAuraTracking[guid] = nil + end + end +end + +-- Create frame to listen for Nampower auto-attack and aura events +local autoAttackFrame = CreateFrame("Frame", "CleveRoidsAutoAttackFrame") + +-- Register for events on PLAYER_ENTERING_WORLD (after CVars are applied) +autoAttackFrame:RegisterEvent("PLAYER_ENTERING_WORLD") +autoAttackFrame:SetScript("OnEvent", function() + if event == "PLAYER_ENTERING_WORLD" then + -- Check if Nampower v2.24+ is available for auto-attack events + local API = CleveRoids.NampowerAPI + if API and API.features and API.features.hasAutoAttackEvents then + -- Register for auto-attack events (requires NP_EnableAutoAttackEvents=1) + this:RegisterEvent("AUTO_ATTACK_OTHER") + this:RegisterEvent("AUTO_ATTACK_SELF") + end + + -- Check for aura cast events (v2.20+, requires NP_EnableAuraCastEvents=1) + if API and API.features and API.features.hasAuraCastEvents then + this:RegisterEvent("AURA_CAST_ON_SELF") + this:RegisterEvent("AURA_CAST_ON_OTHER") + + -- Check if the CVar is enabled (required for all-caster buff tracking) + local auraCastEnabled = GetCVar("NP_EnableAuraCastEvents") + if auraCastEnabled ~= "1" then + CleveRoids.Print("|cFFFFFF00Warning:|r All-caster buff/debuff time tracking requires:") + CleveRoids.Print(" |cFF00FFFF/run SetCVar(\"NP_EnableAuraCastEvents\", \"1\")|r") + CleveRoids.Print(" Without this, |cFFFF9900[buff:X 0 + elseif swingTypeLower == "offhand" or swingTypeLower == "oh" then + return HasHitFlag(swing.hitInfo, HITINFO_LEFTSWING) + elseif swingTypeLower == "mainhand" or swingTypeLower == "mh" then + return not HasHitFlag(swing.hitInfo, HITINFO_LEFTSWING) + elseif swingTypeLower == "hit" then + -- A successful hit (not miss, not dodged, not parried) + return not HasHitFlag(swing.hitInfo, HITINFO_MISS) and + swing.victimState ~= VICTIMSTATE_DODGE and + swing.victimState ~= VICTIMSTATE_PARRY + end + + -- Default: already checked isRecent above, so return true for any recent swing + return true +end + +-- Validate incominghit conditional +function CleveRoids.ValidateIncomingHit(hitType, operator, amount) + local hit = CleveRoids.LastIncomingHit + if hit.timestamp == 0 then + return false -- No incoming hit recorded + end + + local hitTypeLower = hitType and string.lower(hitType) + + -- Time-based check + if operator and amount then + local elapsed = GetTime() - hit.timestamp + if CleveRoids.operators[operator] then + return CleveRoids.comparators[operator](elapsed, amount) + end + return false + end + + -- Type-based checks (all require the hit to be within 5 seconds) + local isRecent = (GetTime() - hit.timestamp) < 5 + if not isRecent then + return false -- Hit too old, don't report any type + end + + if hitTypeLower == "crit" or hitTypeLower == "critical" then + return HasHitFlag(hit.hitInfo, HITINFO_CRITICALHIT) + elseif hitTypeLower == "crushing" then + return HasHitFlag(hit.hitInfo, HITINFO_CRUSHING) + elseif hitTypeLower == "glancing" then + return HasHitFlag(hit.hitInfo, HITINFO_GLANCING) + elseif hitTypeLower == "miss" or hitTypeLower == "missed" then + return HasHitFlag(hit.hitInfo, HITINFO_MISS) + elseif hitTypeLower == "dodge" or hitTypeLower == "dodged" then + return hit.victimState == VICTIMSTATE_DODGE + elseif hitTypeLower == "parry" or hitTypeLower == "parried" then + return hit.victimState == VICTIMSTATE_PARRY + elseif hitTypeLower == "blocked" or hitTypeLower == "block" then + return hit.victimState == VICTIMSTATE_BLOCKS or hit.blockedAmount > 0 + elseif hitTypeLower == "hit" then + -- A successful incoming hit (not miss, not dodged, not parried by us) + return not HasHitFlag(hit.hitInfo, HITINFO_MISS) and + hit.victimState ~= VICTIMSTATE_DODGE and + hit.victimState ~= VICTIMSTATE_PARRY + end + + -- Default: already checked isRecent above, so return true for any recent hit + return true +end + +-- Check if player's buff bar is capped (32 slots) +function CleveRoids.IsPlayerBuffCapped() + -- First check from AURA_CAST events (most accurate) + if CleveRoids.AuraCapStatus.playerLastUpdate > 0 then + return CleveRoids.AuraCapStatus.playerBuffCapped + end + + -- Fallback: count player buffs manually + local count = 0 + for i = 0, 31 do + if GetPlayerBuffTexture(GetPlayerBuff(i, "HELPFUL")) then + count = count + 1 + end + end + return count >= 32 +end + +-- Check if player's debuff bar is capped (16 slots) +function CleveRoids.IsPlayerDebuffCapped() + if CleveRoids.AuraCapStatus.playerLastUpdate > 0 then + return CleveRoids.AuraCapStatus.playerDebuffCapped + end + + -- Fallback: count player debuffs + local count = 0 + for i = 0, 15 do + if GetPlayerBuffTexture(GetPlayerBuff(i, "HARMFUL")) then + count = count + 1 + end + end + return count >= 16 +end + +-- Check if target's debuff bar is capped +-- For NPCs: 16 debuff slots + 32 overflow = 48 total visual debuff capacity +function CleveRoids.IsTargetDebuffCapped(unit) + unit = unit or "target" + if not UnitExists(unit) then return false end + + -- Check cached AURA_CAST data by GUID + local _, guid = UnitExists(unit) + if guid then + local capData = CleveRoids.AuraCapStatus.targetCapStatus[guid] + if capData and (GetTime() - capData.timestamp) < 30 then + return capData.debuffCapped + end + end + + -- Fallback: count debuffs on target manually + -- For NPCs: check up to 48 slots (16 debuff + 32 overflow in buff slots) + if not CleveRoids.hasSuperwow then + -- Without SuperWoW, can only see 16 debuff slots + local count = 0 + for i = 1, 16 do + if UnitDebuff(unit, i) then + count = count + 1 + else + break + end + end + return count >= 16 + end + + -- With SuperWoW: count all debuffs including overflow + local debuffCount = 0 + + -- Count regular debuff slots (1-16, dense) + for i = 1, 16 do + local texture, _, _, spellId = UnitDebuff(unit, i) + if texture then + debuffCount = debuffCount + 1 + else + break -- Dense, stop at first nil + end + end + + -- Count overflow debuffs in buff slots (sparse, check all 32) + -- Overflow debuffs appear in buff slots but are filtered by UnitDebuff + -- We need to use libdebuff or check buff slots for debuff-like effects + -- For simplicity, consider 16 regular debuffs = capped for most use cases + return debuffCount >= 16 +end + +-- Check if target's buff bar is capped (32 slots for NPCs) +function CleveRoids.IsTargetBuffCapped(unit) + unit = unit or "target" + if not UnitExists(unit) then return false end + + local _, guid = UnitExists(unit) + if guid then + local capData = CleveRoids.AuraCapStatus.targetCapStatus[guid] + if capData and (GetTime() - capData.timestamp) < 30 then + return capData.buffCapped + end + end + + -- Fallback: count buffs + local count = 0 + for i = 1, 32 do + if UnitBuff(unit, i) then + count = count + 1 + else + break + end + end + return count >= 32 +end + +-- ============================================================================ + -- pfUI debuff time helper (Vanilla 1.12.1 / Lua 5.0 safe) local function PFUI_HasLibDebuff() return type(pfUI) == "table" @@ -83,16 +1269,125 @@ 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 + local lib = type(CleveRoids.libdebuff) == "table" and CleveRoids.libdebuff or nil + + -- Strip rank suffix for consistent matching (e.g., "Rake(Rank 4)" -> "Rake") + if auraName then + auraName = string.gsub(auraName, "%s*%(%s*Rank%s+%d+%s*%)", "") + -- Convert underscores to spaces for matching (e.g., "Thunder_Clap" -> "Thunder Clap") + auraName = string.gsub(auraName, "_", " ") + end + -- SuperWoW path: GUID-based lookup - if CleveRoids.hasSuperwow then + -- SuperWoW debuff slots: 1-16 are regular debuffs, 17-48 overflow to buff slots 1-32 + if CleveRoids.hasSuperwow and lib then local _, guid = UnitExists(unitToken) - if guid and CleveRoids.libdebuff and CleveRoids.libdebuff.objects[guid] then - for i = 1, 16 do - local effect, _, _, _, _, duration, timeleft = CleveRoids.libdebuff:UnitDebuff(unitToken, i) - if not effect then break end - if effect == auraName and timeleft and timeleft >= 0 then + if guid and lib.objects and lib.objects[guid] then + -- Check 1-48: debuff slots 1-16 + overflow debuffs in buff slots 1-32 + -- NOTE: Slots 1-16 are dense (break on nil), slots 17-48 are sparse (continue on nil) + -- Overflow debuffs in buff slots are mixed with regular buffs, so we can't break early + for i = 1, 48 do + local effect, _, _, _, _, duration, timeleft = lib:UnitDebuff(unitToken, i) + -- Only break for slots 1-16 (regular debuffs are dense) + -- For overflow slots 17-48, nil means "regular buff filtered out", not "end of list" + if not effect and i <= 16 then break end + -- Strip rank from effect name for comparison + local effectBase = effect and string.gsub(effect, "%s*%(%s*Rank%s+%d+%s*%)", "") + if effectBase and effectBase == auraName and timeleft and timeleft >= 0 then return timeleft, duration end end @@ -100,11 +1395,15 @@ local function _get_debuff_timeleft(unitToken, auraName) end -- Non-SuperWoW fallback - if CleveRoids.libdebuff and CleveRoids.libdebuff.UnitDebuff then - for idx = 1, 16 do - local effect, _, _, _, _, duration, timeleft = CleveRoids.libdebuff:UnitDebuff(unitToken, idx) - if not effect then break end - if effect == auraName and timeleft and timeleft >= 0 then + if lib and lib.UnitDebuff then + for idx = 1, 48 do + local effect, _, _, _, _, duration, timeleft = lib:UnitDebuff(unitToken, idx) + -- Only break for slots 1-16 (regular debuffs are dense) + -- For overflow slots 17-48, nil means "regular buff filtered out", not "end of list" + if not effect and idx <= 16 then break end + -- Strip rank from effect name for comparison + local effectBase = effect and string.gsub(effect, "%s*%(%s*Rank%s+%d+%s*%)", "") + if effectBase and effectBase == auraName and timeleft and timeleft >= 0 then return timeleft, duration end end @@ -215,29 +1514,20 @@ end function CleveRoids.HasGearEquipped(gearId) if not gearId then return false end + -- PERFORMANCE: Build/refresh equipment cache if needed + BuildEquipmentCache() + -- Handle both numeric IDs and string IDs like "5196" local wantId = tonumber(gearId) local wantName = (type(gearId) == "string" and not wantId) and string.lower(gearId) or nil + -- PERFORMANCE: Use cached data instead of scanning all slots for slot = 1, 19 do - local link = GetInventoryItemLink("player", slot) - if link then - local _, _, id = string.find(link, "item:(%d+)") - local _, _, nameInBrackets = string.find(link, "%[(.+)%]") - - if wantId and id and tonumber(id) == wantId then - return true - end - if wantName and nameInBrackets and string.lower(nameInBrackets) == wantName then - return true - end - -- Fallback: resolve via GetItemInfo - if wantName and not nameInBrackets and id then - local itemName = GetItemInfo(tonumber(id)) - if itemName and string.lower(itemName) == wantName then - return true - end - end + if wantId and _equippedItemIDs[slot] == wantId then + return true + end + if wantName and _equippedItemNames[slot] == wantName then + return true end end return false @@ -298,31 +1588,149 @@ function CleveRoids.GetSpammableConditional(name) return CleveRoids.spamConditions[name] or "nomybuff" end +-- PERFORMANCE: Cache for stripped spell names (removes rank suffix) +local _strippedNameCache = {} +local _strippedCacheSize = 0 +local _STRIPPED_CACHE_MAX = 128 + +local function GetStrippedSpellName(name) + if not name or name == "" then return "" end + local cached = _strippedNameCache[name] + if cached then return cached end + + local stripped = string.gsub(name, "%(.-%)%s*", "") + if _strippedCacheSize < _STRIPPED_CACHE_MAX then + _strippedNameCache[name] = stripped + _strippedCacheSize = _strippedCacheSize + 1 + end + return stripped +end + +-- Checks whether or not we're currently casting a spell with cast time +-- Returns TRUE if we should allow the cast (not casting, or not casting the specified spell) +-- Returns FALSE if we should block the cast (currently casting) +function CleveRoids.CheckCasting(castingSpell) + -- No parameter: check if we're casting ANYTHING + if not castingSpell or castingSpell == "" then + -- Time-based prediction: if we know cast duration, check if it should be done + if CleveRoids.CurrentSpell.type == "cast" and CleveRoids.castStartTime and CleveRoids.castDuration then + local elapsed = GetTime() - CleveRoids.castStartTime + local remaining = CleveRoids.castDuration - elapsed + + -- If cast should be done (with 0.1s grace period), treat as not casting + if remaining <= 0.1 then + CleveRoids.CurrentSpell.type = "" + return true + end + end + + return CleveRoids.CurrentSpell.type ~= "cast" + end + + -- With parameter: check if we're casting a specific spell + -- PERFORMANCE: Use cached stripped names to avoid string.gsub per call + local spellName = GetStrippedSpellName(CleveRoids.CurrentSpell.spellName) + local casting = GetStrippedSpellName(castingSpell) + + -- If we're casting this specific spell, block the recast + if CleveRoids.CurrentSpell.type == "cast" and spellName == casting then + return false + end + + -- Not casting the specified spell, allow the cast + return true +end + -- Checks whether or not we're currently casting a channeled spell +-- Returns TRUE if we should allow the cast (not channeling, or not channeling the specified spell) +-- Returns FALSE if we should block the cast (currently channeling) function CleveRoids.CheckChanneled(channeledSpell) - if not channeledSpell then return false end + -- No parameter: check if we're channeling ANYTHING + if not channeledSpell or channeledSpell == "" then + -- Time-based prediction: if we know channel duration, check if it should be done + if CleveRoids.CurrentSpell.type == "channeled" and CleveRoids.channelStartTime and CleveRoids.channelDuration then + local elapsed = GetTime() - CleveRoids.channelStartTime + local remaining = CleveRoids.channelDuration - elapsed + + -- If channel should be done (with 0.1s grace period), treat as not channeling + if remaining <= 0.1 then + CleveRoids.CurrentSpell.type = "" + return true + end + end + + return CleveRoids.CurrentSpell.type ~= "channeled" + end -- Remove the "(Rank X)" part from the spells name in order to allow downranking - local spellName = string.gsub(CleveRoids.CurrentSpell.spellName, "%(.-%)%s*", "") - local channeled = string.gsub(channeledSpell, "%(.-%)%s*", "") + -- PERFORMANCE: Use cached stripped names to avoid string.gsub per call + local spellName = GetStrippedSpellName(CleveRoids.CurrentSpell.spellName) + local channeled = GetStrippedSpellName(channeledSpell) + -- If we're channeling this specific spell, block the recast if CleveRoids.CurrentSpell.type == "channeled" and spellName == channeled then return false end + -- Special cases for auto-attacks if channeled == CleveRoids.Localized.Attack then - return not CleveRoids.CurrentSpell.autoAttack + -- ALWAYS check action bar state first - it's the authoritative source + -- Event-based tracking can get stale if PLAYER_LEAVE_COMBAT doesn't fire properly + local slot = CleveRoids.GetProxyActionSlot(CleveRoids.Localized.Attack) + if slot then + local isActive = IsCurrentAction(slot) + -- Sync the event-based flag with actual game state + CleveRoids.CurrentSpell.autoAttack = isActive + if isActive then + return false + end + elseif CleveRoids.CurrentSpell.autoAttack then + -- No slot found but flag is set - trust event-based tracking + -- (Attack not on action bar, but event says it's active) + return false + end + return true end if channeled == CleveRoids.Localized.AutoShot then - return not CleveRoids.CurrentSpell.autoShot + -- ALWAYS check action bar state first - it's the authoritative source + -- Event-based tracking can get stale if STOP_AUTOREPEAT_SPELL doesn't fire + local slot = CleveRoids.GetProxyActionSlot(CleveRoids.Localized.AutoShot) + if slot then + local isActive = IsAutoRepeatAction(slot) + -- Sync the event-based flag with actual game state + CleveRoids.CurrentSpell.autoShot = isActive + if isActive then + return false + end + elseif CleveRoids.CurrentSpell.autoShot then + -- No slot found but flag is set - trust event-based tracking + -- (Auto Shot not on action bar, but event says it's active) + return false + end + return true end if channeled == CleveRoids.Localized.Shoot then - return not CleveRoids.CurrentSpell.wand + -- ALWAYS check action bar state first - it's the authoritative source + -- Event-based tracking can get stale if STOP_AUTOREPEAT_SPELL doesn't fire + local slot = CleveRoids.GetProxyActionSlot(CleveRoids.Localized.Shoot) + if slot then + local isActive = IsAutoRepeatAction(slot) + -- Sync the event-based flag with actual game state + CleveRoids.CurrentSpell.wand = isActive + if isActive then + return false + end + elseif CleveRoids.CurrentSpell.wand then + -- No slot found but flag is set - trust event-based tracking + -- (Shoot not on action bar, but event says it's active) + return false + end + return true end - CleveRoids.CurrentSpell.spellName = channeled + -- If none of the special cases matched, allow the cast (not channeling the specified spell) return true end @@ -337,6 +1745,883 @@ function CleveRoids.ValidateComboPoints(operator, amount) return false end +-- Validates swing timer percentage for SP_SwingTimer addon integration +-- operator: Comparison operator (>, <, =, >=, <=, ~=) +-- amount: Percentage of swing time elapsed (e.g., 20 means 20% of swing has elapsed) +-- returns: True if percentElapsed [operator] amount +function CleveRoids.ValidateSwingTimer(operator, amount) + if not operator or not amount then return false end + + -- Check if SP_SwingTimer is loaded by checking for st_timer global + if st_timer == nil then + -- Only show error once per session + if not CleveRoids._swingTimerErrorShown then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[SuperCleveRoidMacros]|r The [swingtimer] conditional requires the SP_SwingTimer addon. Get it at: https://github.com/jrc13245/SP_SwingTimer", 1, 0.5, 0.5) + CleveRoids._swingTimerErrorShown = true + end + return false + end + + -- Get player's attack speed (main hand) + local attackSpeed = UnitAttackSpeed("player") + if not attackSpeed or attackSpeed <= 0 then return false end + + -- Calculate percentage of swing elapsed + -- st_timer counts down from attackSpeed to 0 (time remaining) + -- So: timeElapsed = attackSpeed - st_timer + local timeElapsed = attackSpeed - st_timer + local percentElapsed = (timeElapsed / attackSpeed) * 100 + + -- Compare percent elapsed against threshold + if CleveRoids.operators[operator] then + return CleveRoids.comparators[operator](percentElapsed, amount) + end + + return false +end + +-- Constants for Slam window calculations +local GCD_DURATION = 1.5 -- Global cooldown in seconds +local DEFAULT_SLAM_CAST = 2.5 -- Default Slam cast time in Turtle WoW + +-- Cache for Slam cast time from tooltip +local cachedSlamCastTime = nil +local slamCastTimeLastUpdate = 0 +local SLAM_CACHE_DURATION = 2 -- Re-scan tooltip every 2 seconds + +-- Hidden tooltip for scanning spell info +local SlamScanTooltip = nil + +-- Create hidden tooltip for scanning (once) +local function GetSlamScanTooltip() + if not SlamScanTooltip then + SlamScanTooltip = CreateFrame("GameTooltip", "CleveRoidsSlamScanTooltip", nil, "GameTooltipTemplate") + SlamScanTooltip:SetOwner(WorldFrame, "ANCHOR_NONE") + end + return SlamScanTooltip +end + +-- Parse cast time from tooltip text (e.g., "1.5 sec cast" or "1.59 sec cast") +local function ParseCastTimeFromText(text) + if not text then return nil end + -- Match patterns like "1.5 sec cast", "1.59 sec cast", "2 sec cast" + -- Use string.find for Lua 5.0 compatibility (string.match is Lua 5.1+) + local _, _, castTime = string.find(text, "(%d+%.?%d*) sec cast") + if castTime then + return tonumber(castTime) + end + return nil +end + +-- Get Slam's spellbook slot +local function GetSlamSpellSlot() + -- Search through spellbook for Slam + local i = 1 + while true do + local spellName, spellRank = GetSpellName(i, BOOKTYPE_SPELL) + if not spellName then break end + if spellName == "Slam" then + return i, BOOKTYPE_SPELL + end + i = i + 1 + end + return nil, nil +end +-- Expose for debug command +CleveRoids.GetSlamSpellSlot = GetSlamSpellSlot + +-- Scan Slam tooltip for cast time +local function ScanSlamCastTime() + local slot, bookType = GetSlamSpellSlot() + if not slot then return nil end + + local tooltip = GetSlamScanTooltip() + tooltip:ClearLines() + tooltip:SetSpell(slot, bookType) + + -- Scan tooltip lines for cast time + for i = 1, tooltip:NumLines() do + local leftText = getglobal("CleveRoidsSlamScanTooltipTextLeft" .. i) + if leftText then + local text = leftText:GetText() + local castTime = ParseCastTimeFromText(text) + if castTime then + return castTime + end + end + local rightText = getglobal("CleveRoidsSlamScanTooltipTextRight" .. i) + if rightText then + local text = rightText:GetText() + local castTime = ParseCastTimeFromText(text) + if castTime then + return castTime + end + end + end + + return nil +end + +-- Get Slam's cast time in seconds (with caching) +-- Reads from spellbook tooltip to get accurate cast time with haste/talents +function CleveRoids.GetSlamCastTime() + local now = GetTime() + + -- Use cached value if still valid + if cachedSlamCastTime and (now - slamCastTimeLastUpdate) < SLAM_CACHE_DURATION then + return cachedSlamCastTime + end + + -- Try to scan tooltip for cast time + local castTime = ScanSlamCastTime() + if castTime and castTime > 0 then + cachedSlamCastTime = castTime + slamCastTimeLastUpdate = now + return castTime + end + + -- Fall back to default + return DEFAULT_SLAM_CAST +end + +-- Force refresh of cached Slam cast time (call when buffs change) +function CleveRoids.RefreshSlamCastTime() + cachedSlamCastTime = nil + slamCastTimeLastUpdate = 0 +end + +-- Calculate maximum elapsed swing timer % to cast Slam without clipping auto-attack +-- Formula: MaxSlamPercent = (SwingTimer - SlamCastTime) / SwingTimer * 100 +-- IMPORTANT: Uses st_timerMax (SP_SwingTimer's adjusted swing timer) not UnitAttackSpeed +-- because Flurry and other buffs modify st_timerMax but not UnitAttackSpeed +function CleveRoids.GetSlamWindowPercent() + -- Use st_timerMax from SP_SwingTimer (accounts for Flurry) + -- Fall back to UnitAttackSpeed if st_timerMax not available + local attackSpeed = st_timerMax or UnitAttackSpeed("player") + if not attackSpeed or attackSpeed <= 0 then return 0 end + + local slamCastTime = CleveRoids.GetSlamCastTime() + local maxSlamStart = attackSpeed - slamCastTime + + if maxSlamStart <= 0 then return 0 end -- Slam cast time exceeds swing timer + + return (maxSlamStart / attackSpeed) * 100 +end + +-- Calculate maximum elapsed swing timer % to cast instant without clipping NEXT Slam +-- Scenario: No Slam this swing, cast instant, then Slam next swing without clipping +-- Formula: MaxInstantPercent = (2 * SwingTimer - SlamCastTime - GCD) / SwingTimer * 100 +function CleveRoids.GetInstantWindowPercent() + -- Use st_timerMax from SP_SwingTimer (accounts for Flurry) + local attackSpeed = st_timerMax or UnitAttackSpeed("player") + if not attackSpeed or attackSpeed <= 0 then return 0 end + + local slamCastTime = CleveRoids.GetSlamCastTime() + local maxInstantStart = (2 * attackSpeed) - slamCastTime - GCD_DURATION + + if maxInstantStart <= 0 then return 0 end -- Window is impossible with current timings + + return (maxInstantStart / attackSpeed) * 100 +end + +-- Validate if current swing timer is within the Slam window (no clip) +-- Returns true if casting Slam NOW will NOT clip the auto-attack +function CleveRoids.ValidateNoSlamClip() + -- Check if SP_SwingTimer is loaded (st_timer and st_timerMax are global) + if st_timer == nil or st_timerMax == nil then + if not CleveRoids._slamClipErrorShown then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[SuperCleveRoidMacros]|r The [noslamclip] conditional requires the SP_SwingTimer addon. Get it at: https://github.com/jrc13245/SP_SwingTimer", 1, 0.5, 0.5) + CleveRoids._slamClipErrorShown = true + end + return false + end + + -- Use st_timerMax (Flurry-adjusted) not UnitAttackSpeed (base speed) + local attackSpeed = st_timerMax + if not attackSpeed or attackSpeed <= 0 then return false end + + local timeElapsed = attackSpeed - st_timer + local percentElapsed = (timeElapsed / attackSpeed) * 100 + local maxPercent = CleveRoids.GetSlamWindowPercent() + + return percentElapsed <= maxPercent +end + +-- Validate if current swing timer is within the instant window for next Slam +-- Returns true if casting an instant NOW will NOT cause the NEXT Slam to clip +function CleveRoids.ValidateNoNextSlamClip() + -- Check if SP_SwingTimer is loaded (st_timer and st_timerMax are global) + if st_timer == nil or st_timerMax == nil then + if not CleveRoids._slamClipErrorShown then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[SuperCleveRoidMacros]|r The [nonextslamclip] conditional requires the SP_SwingTimer addon. Get it at: https://github.com/jrc13245/SP_SwingTimer", 1, 0.5, 0.5) + CleveRoids._slamClipErrorShown = true + end + return false + end + + -- Use st_timerMax (Flurry-adjusted) not UnitAttackSpeed (base speed) + local attackSpeed = st_timerMax + if not attackSpeed or attackSpeed <= 0 then return false end + + local timeElapsed = attackSpeed - st_timer + local percentElapsed = (timeElapsed / attackSpeed) * 100 + local maxPercent = CleveRoids.GetInstantWindowPercent() + + return percentElapsed <= maxPercent +end + +-- ============================================================================ +-- SPELL CAST TIME CONDITIONAL +-- ============================================================================ +-- Checks a spell's cast time from tooltip (guaranteed accurate with all buffs) +-- Usage: [spellcasttime:>2] - action's spell has cast time > 2 seconds +-- [spellcasttime:Frostbolt>2] - Frostbolt has cast time > 2 seconds + +-- Cache for spell cast times +local spellCastTimeCache = {} +local spellCastTimeCacheTime = {} +local SPELL_CAST_TIME_CACHE_DURATION = 0.5 -- Cache for 0.5 seconds (buffs can change) + +-- Hidden tooltip for scanning spell cast time +local SpellCastTimeScanTooltip = nil + +-- Create hidden tooltip for scanning (once) +local function GetSpellCastTimeScanTooltip() + if not SpellCastTimeScanTooltip then + SpellCastTimeScanTooltip = CreateFrame("GameTooltip", "CleveRoidsSpellCastTimeScanTooltip", nil, "GameTooltipTemplate") + SpellCastTimeScanTooltip:SetOwner(WorldFrame, "ANCHOR_NONE") + end + return SpellCastTimeScanTooltip +end + +-- Get spell slot in spellbook by name (returns highest rank) +local function GetSpellSlotByName(spellName) + if not spellName then return nil, nil end + + -- Check indexed spells first for performance + if CleveRoids.Spells and CleveRoids.Spells[BOOKTYPE_SPELL] then + local spellData = CleveRoids.Spells[BOOKTYPE_SPELL][spellName] + if spellData then + -- Prefer highest rank's slot + if spellData.highest and spellData.highest.spellSlot then + return spellData.highest.spellSlot, BOOKTYPE_SPELL + end + if spellData.spellSlot then + return spellData.spellSlot, BOOKTYPE_SPELL + end + end + end + + -- Fallback: search spellbook directly for highest rank + local foundSlot = nil + local i = 1 + while true do + local name, rank = GetSpellName(i, BOOKTYPE_SPELL) + if not name then break end + if name == spellName then + foundSlot = i -- Keep updating to get highest rank (last match) + end + i = i + 1 + end + + if foundSlot then + return foundSlot, BOOKTYPE_SPELL + end + + return nil, nil +end + +-- Scan spell tooltip for cast time +-- Returns cast time in seconds, or nil if not found +local function ScanSpellCastTime(spellName) + local slot, bookType = GetSpellSlotByName(spellName) + if not slot then return nil end + + local tooltip = GetSpellCastTimeScanTooltip() + tooltip:ClearLines() + tooltip:SetSpell(slot, bookType) + + -- Scan tooltip lines for cast time + for i = 1, tooltip:NumLines() do + local leftText = getglobal("CleveRoidsSpellCastTimeScanTooltipTextLeft" .. i) + if leftText then + local text = leftText:GetText() + if text then + -- Match patterns like "1.5 sec cast", "2 sec cast" + -- Use string.find for Lua 5.0 compatibility + local _, _, castTime = string.find(text, "(%d+%.?%d*) sec cast") + if castTime then + return tonumber(castTime) + end + -- Check for instant cast + if string.find(text, "Instant") then + return 0 + end + end + end + local rightText = getglobal("CleveRoidsSpellCastTimeScanTooltipTextRight" .. i) + if rightText then + local text = rightText:GetText() + if text then + local _, _, castTime = string.find(text, "(%d+%.?%d*) sec cast") + if castTime then + return tonumber(castTime) + end + if string.find(text, "Instant") then + return 0 + end + end + end + end + + return nil +end + +-- Get spell cast time from tooltip (includes all modifiers, even overflow buffs) +-- Returns cast time in seconds, or nil if spell not found +function CleveRoids.GetSpellCastTime(spellName) + if not spellName then return nil end + + -- Normalize spell name (underscores to spaces) + spellName = CleveRoids.NormalizeName(spellName) + + local now = GetTime() + + -- Check cache first + if spellCastTimeCache[spellName] ~= nil then + local cacheAge = now - (spellCastTimeCacheTime[spellName] or 0) + if cacheAge < SPELL_CAST_TIME_CACHE_DURATION then + return spellCastTimeCache[spellName] + end + end + + -- Scan tooltip for cast time (guaranteed accurate) + local castTime = ScanSpellCastTime(spellName) + + -- Cache the result (even nil to avoid repeated lookups) + spellCastTimeCache[spellName] = castTime + spellCastTimeCacheTime[spellName] = now + + return castTime +end + +-- Validate spell cast time conditional +-- spellName: Spell to check (or nil to use action's spell) +-- operator: Comparison operator (>, <, =, etc.) +-- amount: Time threshold in seconds +function CleveRoids.ValidateSpellCastTime(spellName, operator, amount) + if not operator or not amount then return false end + + local castTime = CleveRoids.GetSpellCastTime(spellName) + if castTime == nil then + return false -- Spell not found in spellbook + end + + if CleveRoids.operators[operator] then + return CleveRoids.comparators[operator](castTime, amount) + end + + return false +end + +-- Clear spell cast time cache (call when buffs/haste changes) +function CleveRoids.ClearSpellCastTimeCache() + spellCastTimeCache = {} + spellCastTimeCacheTime = {} +end + +-- ============================================================================ + +function CleveRoids.ValidateLevel(unit, operator, amount) + if not unit or not operator or not amount then return false end + local level = UnitLevel(unit) + + -- Treat skull/boss mobs (??) as level 63 + if level == -1 then + level = 63 + end + + if level and CleveRoids.operators[operator] then + return CleveRoids.comparators[operator](level, amount) + end + + return false +end + +--- Validates a threat percentage conditional using server threat data. +--- Usage: [threat:>80] [threat:<50] [threat:=100] +--- operator: Comparison operator (>, <, =, >=, <=, ~=) +--- amount: Threat percentage (0-100+, where 100 = will pull aggro) +--- returns: True if playerThreat [operator] amount +--- Note: Requires TWThreat addon to be running (sends threat requests to server) +function CleveRoids.ValidateThreat(operator, amount) + if not operator or not amount then return false end + + -- Get threat percentage from parsed server data + local threatpct = CleveRoids.GetPlayerThreatPercent() + + -- No threat data available + if threatpct == nil then + return false + end + + -- Compare threat percentage against threshold + if CleveRoids.operators[operator] then + return CleveRoids.comparators[operator](threatpct, amount) + end + + return false +end + +--- Validates a Time-To-Kill conditional using TimeToKill addon. +--- Usage: [ttk:<10] [ttk:>30] [ttk:=5] +--- operator: Comparison operator (>, <, =, >=, <=, ~=) +--- amount: Time in seconds until target death +--- returns: True if TTK [operator] amount +function CleveRoids.ValidateTTK(operator, amount) + if not operator or not amount then return false end + + -- Check if TimeToKill is loaded + if type(TimeToKill) ~= "table" or type(TimeToKill.GetTTK) ~= "function" then + -- Only show error once per session + if not CleveRoids._ttkErrorShown then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[SuperCleveRoidMacros]|r The [ttk] conditional requires the TimeToKill addon.", 1, 0.5, 0.5) + CleveRoids._ttkErrorShown = true + end + return false + end + + local ttk = TimeToKill.GetTTK() + if ttk == nil then + return false -- Not tracking TTK + end + + -- Compare TTK against threshold + if CleveRoids.operators[operator] then + return CleveRoids.comparators[operator](ttk, amount) + end + + return false +end + +--- Validates a Time-To-Execute conditional using TimeToKill addon. +--- Usage: [tte:<5] [tte:>10] +--- operator: Comparison operator (>, <, =, >=, <=, ~=) +--- amount: Time in seconds until target reaches 20% HP +--- returns: True if TTE [operator] amount +function CleveRoids.ValidateTTE(operator, amount) + if not operator or not amount then return false end + + -- Check if TimeToKill is loaded + if type(TimeToKill) ~= "table" or type(TimeToKill.GetTTE) ~= "function" then + if not CleveRoids._ttkErrorShown then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[SuperCleveRoidMacros]|r The [tte] conditional requires the TimeToKill addon.", 1, 0.5, 0.5) + CleveRoids._ttkErrorShown = true + end + return false + end + + local tte = TimeToKill.GetTTE() + if tte == nil then + return false -- Not tracking or already in execute phase + end + + if CleveRoids.operators[operator] then + return CleveRoids.comparators[operator](tte, amount) + end + + return false +end + +-- ============================================================================ +-- MONKEYSPEED ADDON INTEGRATION +-- ============================================================================ +-- Integrates with MonkeySpeed addon for accurate movement speed detection +-- MonkeySpeed uses SuperWoW's UnitPosition to calculate actual movement speed + +--- Check if MonkeySpeed addon is available and loaded +--- @return boolean True if MonkeySpeed is available with speed data +function CleveRoids.HasMonkeySpeed() + return type(MonkeySpeed) == "table" and + MonkeySpeed.m_bLoaded == true and + MonkeySpeed.m_fSpeed ~= nil +end + +--- Require MonkeySpeed for a feature, warn once if missing +--- @param feature string Feature name for warning message +--- @return boolean True if MonkeySpeed is available +function CleveRoids.RequireMonkeySpeed(feature) + if CleveRoids.HasMonkeySpeed() then + return true + end + if not CleveRoids._monkeySpeedErrorShown then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[SuperCleveRoidMacros]|r The [moving] speed conditional requires the MonkeySpeed addon.", 1, 0.5, 0.5) + CleveRoids._monkeySpeedErrorShown = true + end + return false +end + +--- Get current player movement speed as percentage (100 = normal run speed) +--- @return number|nil Speed percentage, or nil if MonkeySpeed not available +function CleveRoids.GetPlayerSpeed() + if not CleveRoids.HasMonkeySpeed() then + return nil + end + return MonkeySpeed.m_fSpeed or 0 +end + +--- Check if player is currently moving (speed > 0) +--- Falls back to basic position checking if MonkeySpeed isn't available +--- @return boolean True if player is moving +function CleveRoids.IsPlayerMoving() + -- If MonkeySpeed is available, use it for accurate detection + if CleveRoids.HasMonkeySpeed() then + return (MonkeySpeed.m_fSpeed or 0) > 0 + end + + -- Fallback: use continuously tracked position history from OnUpdate + -- Core.lua tracks position at ~100 Hz with 4-sample history (matches MonkeySpeed's 0.01s). + -- Movement is detected if ANY consecutive pair of samples shows movement. + -- This gives instant start-of-movement detection (first sample shows delta). + local history = CleveRoids._positionHistory + + if history and table.getn(history) >= 2 then + -- Check all consecutive pairs in history - moving if ANY shows movement + -- This detects start-of-movement immediately when first delta appears + for i = 2, table.getn(history) do + local prev = history[i - 1] + local curr = history[i] + local dx = curr.x - prev.x + local dy = curr.y - prev.y + local dist = math.sqrt(dx * dx + dy * dy) + if dist > 0.001 then + return true -- Any movement in recent history = moving + end + end + return false -- No movement in any recent sample = stopped + end + + -- Legacy fallback for code that might not have history yet + local current = CleveRoids._currentPlayerPos + local previous = CleveRoids._previousPlayerPos + + if current and previous then + local dx = current.x - previous.x + local dy = current.y - previous.y + local dist = math.sqrt(dx * dx + dy * dy) + return dist > 0.001 + end + + -- Not enough data yet (first 0.15s after login) - assume not moving + return false +end + +--- Validates a movement speed conditional. +--- Usage: [moving:>100] [moving:<50] [moving:=0] +--- operator: Comparison operator (>, <, =, >=, <=, ~=) +--- amount: Speed percentage (100 = normal run speed) +--- returns: True if speed [operator] amount +function CleveRoids.ValidateMovingSpeed(operator, amount) + if not operator or not amount then return false end + + -- Check if MonkeySpeed is available for speed comparisons + if not CleveRoids.RequireMonkeySpeed("moving speed comparison") then + return false + end + + local speed = CleveRoids.GetPlayerSpeed() + if speed == nil then + return false + end + + -- Compare speed against threshold + if CleveRoids.operators[operator] then + return CleveRoids.comparators[operator](speed, amount) + end + + return false +end + +-- ============================================================================ +-- ENEMY COUNTING FOR MULTI-UNIT CONDITIONALS +-- ============================================================================ +-- Enables conditionals like [meleerange:>1], [behind:>=2], [inrange:Spell>1] +-- Uses UnitXP to enumerate nearby enemies and count those matching a condition + +--- Count enemies matching a condition function +--- Uses UnitXP enemy iteration with cycle detection and target restoration +--- @param checkFunc function(unit) -> boolean - Returns true if unit matches condition +--- @return number - Count of matching enemies (0 if UnitXP unavailable) +function CleveRoids.CountEnemiesMatching(checkFunc) + if not CleveRoids.hasUnitXP then return 0 end + + -- Save current target for restoration + local currentTargetGuid = nil + if UnitExists("target") then + local _, guid = UnitExists("target") + currentTargetGuid = guid + end + + local count = 0 + local seenGuids = {} + local firstGuid = nil + local maxIterations = 50 + + -- Always count current target first (if valid and matches) + if currentTargetGuid and UnitCanAttack("player", "target") then + if checkFunc("target") then + count = count + 1 + end + seenGuids[currentTargetGuid] = true + end + + -- Iterate through enemies using UnitXP + for i = 1, maxIterations do + local found = UnitXP("target", "nextEnemyConsideringDistance") + if not found then break end + + if not UnitExists("target") then break end + local _, currentGuid = UnitExists("target") + if not currentGuid then break end + + -- Cycle detection: stop when we see the first target again + if firstGuid == nil then + firstGuid = currentGuid + elseif currentGuid == firstGuid then + break + end + + -- Skip already-counted targets + if not seenGuids[currentGuid] then + seenGuids[currentGuid] = true + if UnitCanAttack("player", "target") and checkFunc("target") then + count = count + 1 + end + end + end + + -- Restore original target + if currentTargetGuid then + TargetUnit(currentTargetGuid) + else + ClearTarget() + end + + return count +end + +--- Check if conditional arguments contain count mode (operator + amount) +--- @param conditionalValue any - The value from conditionals[name] +--- @return table|nil - The args table with operator/amount if count mode, nil otherwise +function CleveRoids.GetCountModeArgs(conditionalValue) + if type(conditionalValue) ~= "table" then return nil end + + local args = conditionalValue[1] + if type(args) == "table" and args.operator and args.amount then + return args + end + return nil +end + +-- ============================================================================ +-- PFUI TANK INTEGRATION +-- ============================================================================ +-- Integrates with pfUI's tank systems for targeting loose mobs +-- Checks BOTH: +-- 1. pfUI.uf.raid.tankrole (raid frame right-click toggle) +-- 2. pfUI_config.nameplates.combatofftanks (nameplate off-tank names setting) + +-- Cache for parsed nameplate off-tank names (lowercase) +local pfUIOfftankCache = nil +local pfUIOfftankCacheTime = 0 + +--- Parse pfUI nameplate off-tank names setting into a lookup table +--- @return table Lowercase name -> true lookup +local function GetPfUIOfftanks() + -- Cache for 5 seconds to avoid repeated string parsing + local now = GetTime() + if pfUIOfftankCache and (now - pfUIOfftankCacheTime) < 5 then + return pfUIOfftankCache + end + + pfUIOfftankCache = {} + pfUIOfftankCacheTime = now + + -- Check pfUI_config.nameplates.combatofftanks (# separated list) + if type(pfUI_config) == "table" and + type(pfUI_config.nameplates) == "table" and + type(pfUI_config.nameplates.combatofftanks) == "string" then + local list = pfUI_config.nameplates.combatofftanks + -- pfUI uses # as separator: "#Name1#Name2#Name3" + for name in string.gfind(list, "[^#]+") do + name = string.gsub(name, "^%s*(.-)%s*$", "%1") -- trim whitespace + if name ~= "" then + pfUIOfftankCache[string.lower(name)] = true + end + end + end + + return pfUIOfftankCache +end + +--- Check if pfUI tank data is available (either source) +--- @return boolean True if any pfUI tank system is accessible +function CleveRoids.HasPfUITanks() + -- Check raid frame tankrole + local hasRaidTanks = type(pfUI) == "table" and + type(pfUI.uf) == "table" and + type(pfUI.uf.raid) == "table" and + type(pfUI.uf.raid.tankrole) == "table" + + -- Check nameplate off-tank config + local hasNameplateTanks = type(pfUI_config) == "table" and + type(pfUI_config.nameplates) == "table" and + type(pfUI_config.nameplates.combatofftanks) == "string" and + pfUI_config.nameplates.combatofftanks ~= "" + + return hasRaidTanks or hasNameplateTanks +end + +--- Check if a player is marked as a tank in pfUI (either source) +--- @param name string - Player name to check +--- @return boolean True if player is marked as tank +function CleveRoids.IsPlayerTank(name) + if not name then return false end + + -- Check raid frame tankrole (exact case) + if type(pfUI) == "table" and + type(pfUI.uf) == "table" and + type(pfUI.uf.raid) == "table" and + type(pfUI.uf.raid.tankrole) == "table" and + pfUI.uf.raid.tankrole[name] then + return true + end + + -- Check nameplate off-tank names (lowercase comparison) + local offtanks = GetPfUIOfftanks() + if offtanks[string.lower(name)] then + return true + end + + return false +end + +--- Check if a unit is targeting any player marked as tank +--- @param unit string - Unit token to check (e.g., "target") +--- @return boolean True if unit's target is a tank +function CleveRoids.IsTargetingAnyTank(unit) + -- Default to "target" if unit is nil + unit = unit or "target" + + -- Get the unit's target + local targetOfUnit = unit .. "target" + if not UnitExists(targetOfUnit) then + return false + end + + local targetName = UnitName(targetOfUnit) + if not targetName then + return false + end + + return CleveRoids.IsPlayerTank(targetName) +end + +-- ============================================================================ +-- CURSIVE ADDON INTEGRATION +-- ============================================================================ +-- Integrates with Cursive addon for accurate debuff time tracking +-- Cursive tracks debuffs by GUID with precise timing (accounts for Dark Harvest, etc.) + +--- Check if Cursive addon is available and enabled +--- @return boolean True if Cursive is available +function CleveRoids.HasCursive() + return type(Cursive) == "table" and + type(Cursive.curses) == "table" and + type(Cursive.curses.HasCurse) == "function" +end + +--- Require Cursive for a feature, warn once if missing +--- @param feature string Feature name for warning message +--- @return boolean True if Cursive is available +function CleveRoids.RequireCursive(feature) + if CleveRoids.HasCursive() then + return true + end + if not CleveRoids._cursiveErrorShown then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[SuperCleveRoidMacros]|r The [cursive] conditional requires the Cursive addon.", 1, 0.5, 0.5) + CleveRoids._cursiveErrorShown = true + end + return false +end + +--- Get time remaining on a Cursive-tracked debuff +--- @param unit string Unit to check (will be converted to GUID) +--- @param spellName string Spell name to check (will be lowercased and rank-stripped) +--- @return number|nil Time remaining in seconds, or nil if not found +function CleveRoids.GetCursiveTimeRemaining(unit, spellName) + if not CleveRoids.HasCursive() then return nil end + if not unit or not UnitExists(unit) then return nil end + + local _, guid = UnitExists(unit) + if not guid then return nil end + + -- Normalize spell name (lowercase, no rank) to match Cursive's format + local lowercaseName = Cursive.utils.GetLowercaseSpellNameNoRank(spellName) + + local curseData = Cursive.curses:GetCurseData(lowercaseName, guid) + if not curseData then return nil end + + return Cursive.curses:TimeRemaining(curseData) +end + +--- Check if unit has a Cursive-tracked debuff with optional time comparison +--- @param unit string Unit to check +--- @param spellName string Spell name to check +--- @param operator string|nil Comparison operator (>, <, =, >=, <=, ~=) +--- @param amount number|nil Time threshold in seconds +--- @return boolean True if debuff exists and passes time check +function CleveRoids.ValidateCursiveDebuff(unit, spellName, operator, amount) + if not CleveRoids.HasCursive() then return false end + if not unit or not UnitExists(unit) then return false end + + local _, guid = UnitExists(unit) + if not guid then return false end + + -- Normalize spell name for Cursive lookup + local lowercaseName = Cursive.utils.GetLowercaseSpellNameNoRank(spellName) + + -- If no operator, just check if debuff exists with any time remaining + if not operator then + return Cursive.curses:HasCurse(lowercaseName, guid, 0) == true + end + + -- With operator, check time remaining + local timeRemaining = CleveRoids.GetCursiveTimeRemaining(unit, spellName) + -- If debuff not found, treat as 0 seconds remaining (matches [debuff] behavior) + -- This makes [cursive:Rake<5] true when Rake is missing (0 < 5 = true) + if not timeRemaining then timeRemaining = 0 end + + if CleveRoids.operators[operator] and amount then + return CleveRoids.comparators[operator](timeRemaining, amount) + end + + return false +end + +--- Check if ANY Cursive-tracked debuff exists on unit +--- @param unit string Unit to check +--- @return boolean True if unit has any tracked debuffs +function CleveRoids.HasAnyCursiveDebuff(unit) + if not CleveRoids.HasCursive() then return false end + if not unit or not UnitExists(unit) then return false end + + local _, guid = UnitExists(unit) + if not guid then return false end + + return Cursive.curses:HasAnyCurse(guid) == true +end + function CleveRoids.ValidateKnown(args) if not args then return false @@ -539,10 +2824,15 @@ function CleveRoids.ValidateCreatureType(creatureType, target) if not targetType then return false end -- ooze or silithid etc local ct = string.lower(creatureType) local cl = UnitClassification(target) - if (ct == "boss" and "worldboss" or ct) == cl then + -- Check classification: "boss" matches both "boss" and "worldboss" + if ct == "boss" then + if cl == "boss" or cl == "worldboss" then + return true + end + elseif ct == cl then return true end - if string.lower(creatureType) == "boss" then creatureType = "worldboss" end + if ct == "boss" then creatureType = "worldboss" end local englishType = CleveRoids.Localized.CreatureTypes[targetType] return ct == string.lower(targetType) or creatureType == englishType end @@ -550,50 +2840,50 @@ end -- TODO: Look into https://github.com/Stanzilla/WoWUIBugs/issues/47 if needed function CleveRoids.ValidateCooldown(args, ignoreGCD) if not args then return false end - if type(args) ~= "table" then - -- Normalize the spell name by replacing underscores with spaces - local normalized = string.gsub(args, "_", " ") - -- NEW: If this is a numeric slot (1-19), resolve to the equipped item's name - local slotNum = tonumber(normalized) + local name + if type(args) ~= "table" then + -- PERFORMANCE: Use cached normalization + name = CleveRoids.NormalizeName(args) + + -- If this is a numeric slot (1-19), resolve to the equipped item's name + local slotNum = tonumber(name) if slotNum and slotNum >= 1 and slotNum <= 19 then local link = GetInventoryItemLink("player", slotNum) if link then - -- Extract item name from link local _, _, itemName = string.find(link, "%[(.+)%]") - if itemName then - normalized = itemName - end + if itemName then name = itemName end end end - - args = {name = normalized} + args = {name = name} else - -- Also normalize if it's already a table if args.name then - args.name = string.gsub(args.name, "_", " ") + -- PERFORMANCE: Use cached normalization + name = CleveRoids.NormalizeName(args.name) - -- NEW: If this is a numeric slot (1-19), resolve to the equipped item's name - local slotNum = tonumber(args.name) + -- If this is a numeric slot (1-19), resolve to the equipped item's name + local slotNum = tonumber(name) if slotNum and slotNum >= 1 and slotNum <= 19 then local link = GetInventoryItemLink("player", slotNum) if link then - -- Extract item name from link local _, _, itemName = string.find(link, "%[(.+)%]") - if itemName then - args.name = itemName - end + if itemName then name = itemName end end end + args.name = name + else + name = args.name end end + -- PERFORMANCE: GetCooldown is now cached per-frame local expires = CleveRoids.GetCooldown(args.name, ignoreGCD) + local now = CleveRoids.GetCachedTime() if not args.operator and not args.amount then - return expires > GetTime() + return expires > now elseif CleveRoids.operators[args.operator] then - return CleveRoids.comparators[args.operator](expires - GetTime(), args.amount) + return CleveRoids.comparators[args.operator](expires - now, args.amount) end end @@ -609,6 +2899,74 @@ function CleveRoids.GetPlayerAura(index, isbuff) return GetPlayerBuffTexture(bid), GetPlayerBuffApplications(bid), spellID, GetPlayerBuffTimeLeft(bid) end +-- PERFORMANCE: Local function refs and reusable pattern for buff checking +local _string_lower = string.lower +local _string_gsub = string.gsub +local _RANK_PATTERN = "%s*%(%s*Rank%s+%d+%s*%)" + +-- PERFORMANCE: Simple cache for lowercase spell names (cleared periodically) +local _spellNameCache = {} +local _spellNameCacheSize = 0 +local _MAX_SPELL_CACHE = 200 + +-- PERFORMANCE: Cache for base spell names (rank stripped, not lowercased) +local _baseNameCache = {} +local _baseNameCacheSize = 0 + +local function GetLowercaseSpellName(spellID) + local cached = _spellNameCache[spellID] + if cached then return cached end + + local name = SpellInfo(spellID) + if not name then return nil end + + -- Strip rank and lowercase + local baseName = _string_gsub(name, _RANK_PATTERN, "") + local lowerName = _string_lower(baseName) + + -- Cache if not too large + if _spellNameCacheSize < _MAX_SPELL_CACHE then + _spellNameCache[spellID] = lowerName + _spellNameCacheSize = _spellNameCacheSize + 1 + end + + return lowerName +end + +-- PERFORMANCE: Get base spell name (rank stripped) and full name - cached +local function GetSpellNames(spellID) + local cached = _baseNameCache[spellID] + if cached then + return cached.base, cached.full + end + + local fullName = SpellInfo(spellID) + if not fullName then return nil, nil end + + local baseName = _string_gsub(fullName, _RANK_PATTERN, "") + + -- Cache if not too large + if _baseNameCacheSize < _MAX_SPELL_CACHE then + _baseNameCache[spellID] = { base = baseName, full = fullName } + _baseNameCacheSize = _baseNameCacheSize + 1 + end + + return baseName, fullName +end + +-- Clear spell name caches (called periodically from Core.lua cleanup) +function CleveRoids.ClearSpellNameCaches() + for k in pairs(_spellNameCache) do + _spellNameCache[k] = nil + end + _spellNameCacheSize = 0 + + for k in pairs(_baseNameCache) do + _baseNameCache[k] = nil + end + _baseNameCacheSize = 0 +end + function CleveRoids.ValidateAura(unit, args, isbuff) if not args or not UnitExists(unit) then return false end @@ -625,6 +2983,16 @@ function CleveRoids.ValidateAura(unit, args, isbuff) local stacks, remaining local i = isPlayer and 0 or 1 + -- Strip rank suffix for consistent matching (e.g., "Faerie Fire (Feral)(Rank 4)" -> "Faerie Fire (Feral)") + if args.name then + args.name = string.gsub(args.name, "%s*%(%s*Rank%s+%d+%s*%)", "") + -- Convert underscores to spaces for matching (e.g., "Thunder_Clap" -> "Thunder Clap") + args.name = string.gsub(args.name, "_", " ") + end + + -- PERFORMANCE: Cache lowercased search name to avoid repeated string.lower calls + local searchName = args.name and _string_lower(args.name) + -- Primary search: BUFFS if isbuff==true, DEBUFFS if isbuff==false while true do local texture @@ -638,7 +3006,7 @@ function CleveRoids.ValidateAura(unit, args, isbuff) -- UnitBuff => texture, stacks, spellID texture, stacks, current_spellID = UnitBuff(unit, i) else - -- UnitDebuff => texture, stacks, _, spellID + -- UnitDebuff => texture, stacks, debuffType, spellID texture, stacks, _, current_spellID = UnitDebuff(unit, i) end remaining = nil @@ -646,13 +3014,12 @@ function CleveRoids.ValidateAura(unit, args, isbuff) if not texture then break end - if current_spellID then - local auraName = SpellInfo(current_spellID) - if auraName and args.name then - if string.lower(auraName) == string.lower(args.name) then - found = true - break - end + if current_spellID and searchName then + -- PERFORMANCE: Use cached lowercase spell name lookup + local lowerName = GetLowercaseSpellName(current_spellID) + if lowerName and lowerName == searchName then + found = true + break end end @@ -660,7 +3027,7 @@ function CleveRoids.ValidateAura(unit, args, isbuff) end -- Overflow handling: when searching DEBUFFS on non-players, also scan BUFFS - if not isbuff and not isPlayer and not found then + if not isbuff and not isPlayer and not found and searchName then i = 1 while true do local texture @@ -671,12 +3038,11 @@ function CleveRoids.ValidateAura(unit, args, isbuff) if not texture then break end if current_spellID then - local auraName = SpellInfo(current_spellID) - if auraName and args.name then - if string.lower(auraName) == string.lower(args.name) then - found = true - break - end + -- PERFORMANCE: Use cached lowercase spell name lookup + local lowerName = GetLowercaseSpellName(current_spellID) + if lowerName and lowerName == searchName then + found = true + break end end @@ -685,12 +3051,82 @@ function CleveRoids.ValidateAura(unit, args, isbuff) end local ops = CleveRoids.operators + local cmp = CleveRoids.comparators + + -- For non-player units with time comparisons, try to get time from tracking systems + local nonPlayerAuraTimeRemaining = nil + if not isPlayer and args.name then + -- NOTE: libdebuff's UnitBuff has a bug where timeleft returns incorrect values + -- (showing ~1000s instead of actual remaining time). Skip it for buff time checks + -- and rely on all-caster tracking from AURA_CAST events instead. + + -- Second try: All-caster tracking from AURA_CAST events (works for any caster) + -- Only use if libdebuff didn't find it (libdebuff has more accurate timing for player casts) + if nonPlayerAuraTimeRemaining == nil then + local _, targetGuid = UnitExists(unit) + if targetGuid then + local remaining, casterGuid = CleveRoids.FindAllCasterAuraByName(targetGuid, args.name) + + -- Debug output when enabled + if CleveRoids.debug then + local hasData = CleveRoids.AllCasterAuraTracking[targetGuid] ~= nil + DEFAULT_CHAT_FRAME:AddMessage(string.format( + "|cffff9900[AuraLookup]|r %s on GUID %s: hasData=%s, remaining=%s", + tostring(args.name), string.sub(tostring(targetGuid), 1, 16), + tostring(hasData), tostring(remaining) + )) + end + + if remaining then + nonPlayerAuraTimeRemaining = remaining + end + end + end + end + + -- Handle multi-comparison (e.g., >0&<10) + if args.comparisons and type(args.comparisons) == "table" then + -- ALL comparisons must pass (AND logic) + for _, comp in ipairs(args.comparisons) do + if not ops[comp.operator] then + return false -- Invalid operator + end + + local value_to_check + if comp.checkStacks then + value_to_check = stacks or -1 + elseif isPlayer then + value_to_check = remaining or -1 + elseif nonPlayerAuraTimeRemaining ~= nil then + -- Non-player aura with tracked time remaining (from libdebuff or AURA_CAST events) + value_to_check = nonPlayerAuraTimeRemaining + else + -- Non-player units without tracked time: treat missing/unknown as 0 + -- This matches behavior of [mybuff:X<2] returning true when buff is missing + value_to_check = found and 0 or -1 + end + + if not cmp[comp.operator](value_to_check, comp.amount) then + return false -- One comparison failed + end + end + return true -- All comparisons passed + end + + -- Single comparison (backward compatibility) if not args.amount and not args.operator and not args.checkStacks then return found elseif isPlayer and not args.checkStacks and args.amount and ops[args.operator] then - return CleveRoids.comparators[args.operator](remaining or -1, args.amount) + return cmp[args.operator](remaining or -1, args.amount) elseif args.amount and args.checkStacks and ops[args.operator] then - return CleveRoids.comparators[args.operator](stacks or -1, args.amount) + return cmp[args.operator](stacks or -1, args.amount) + elseif not isPlayer and not args.checkStacks and args.amount and ops[args.operator] then + -- Non-player aura time comparison: use tracked time or treat as 0 if found/-1 if missing + local timeToCheck = nonPlayerAuraTimeRemaining + if timeToCheck == nil then + timeToCheck = found and 0 or -1 + end + return cmp[args.operator](timeToCheck, args.amount) else return false end @@ -707,51 +3143,271 @@ function CleveRoids.ValidateUnitDebuff(unit, args) end if not args.name then return false end + -- Strip rank suffix for consistent matching (e.g., "Faerie Fire (Feral)(Rank 4)" -> "Faerie Fire (Feral)") + args.name = string.gsub(args.name, "%s*%(%s*Rank%s+%d+%s*%)", "") + -- Convert underscores to spaces for matching (e.g., "Thunder_Clap" -> "Thunder Clap") + args.name = string.gsub(args.name, "_", " ") + local found = false local texture, stacks, spellID, remaining local i - -- Step 1: Search DEBUFFS first - i = (unit == "player") and 0 or 1 - while true do - if unit == "player" then - texture, stacks, spellID, remaining = CleveRoids.GetPlayerAura(i, false) - else - texture, stacks, _, spellID = UnitDebuff(unit, i) - end - if not texture then break end - - if (CleveRoids.hasSuperwow and args.name == SpellInfo(spellID)) - or (not CleveRoids.hasSuperwow and texture == CleveRoids.auraTextures[args.name]) then - found = true - break - end - i = i + 1 + -- PERFORMANCE: For non-SuperWoW, early return if no texture registered + if not CleveRoids.hasSuperwow and not CleveRoids.auraTextures[args.name] then + return false end - -- Step 2: If not found, search BUFFS (overflow debuffs shown as buffs on some servers) - if not found then - i = (unit == "player") and 0 or 1 - while true do - if unit == "player" then - texture, stacks, spellID, remaining = CleveRoids.GetPlayerAura(i, true) - else - texture, stacks, spellID = UnitBuff(unit, i) + -- For non-player units, check tracking table directly + -- SIMPLE: Did the player cast this spell? Is the timer still valid? + -- Defensive: verify libdebuff is a table before accessing properties + local lib = type(CleveRoids.libdebuff) == "table" and CleveRoids.libdebuff or nil + if unit ~= "player" and lib and lib.objects then + local _, guid = UnitExists(unit) + if not guid then return false end + + -- Normalize GUID to string for consistent table key lookups + guid = CleveRoids.NormalizeGUID(guid) + if not guid then return false end + + -- PERFORMANCE: Use cached spell name -> ID mapping instead of iterating every call + local matchingSpellIDs = GetSpellIDsForName(args.name) or {} + + -- Check tracking table for ANY rank of this spell: Did player cast this? Is timer valid? + if matchingSpellIDs and table.getn(matchingSpellIDs) > 0 then + for _, spellID in ipairs(matchingSpellIDs) do + local rec = lib.objects[guid] and lib.objects[guid][spellID] + -- For shared debuffs (Sunder, Faerie Fire, etc.), accept any caster (including nil) + -- For personal debuffs (Rip, Rupture, etc.), only accept player casts + local isSharedDebuff = lib.IsPersonalDebuff and lib:IsPersonalDebuff(spellID) == false + if rec and rec.duration and rec.start and (isSharedDebuff or rec.caster == "player") then + local timeRemaining = rec.duration + rec.start - GetTime() + if timeRemaining > 0 then + found = true + remaining = timeRemaining + stacks = rec.stacks or 0 + + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff00ff00[Tracking]|r %s (ID:%d): %.1fs left", args.name, spellID, timeRemaining) + ) + end + + -- Get texture (optional, just for display) + for i = 1, 16 do + local _, _, _, sid = UnitDebuff(unit, i) + if sid == spellID then + texture = UnitDebuff(unit, i) + break + end + end + if not texture then + for i = 1, 32 do + local _, _, sid = UnitBuff(unit, i) + if sid == spellID then + texture = UnitBuff(unit, i) + break + end + end + end + + -- Found active debuff, stop searching + break + elseif rec then + -- Debuff expired - remove it from tracking to avoid spam + local expiredTime = GetTime() - (rec.start + rec.duration) + if CleveRoids.debug and expiredTime < 2.0 then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cffff6600[Tracking]|r %s (ID:%d) expired %.1fs ago", + args.name, spellID, expiredTime) + ) + end + -- Remove expired debuff to prevent repeated "expired" messages + if lib.objects[guid] then + lib.objects[guid][spellID] = nil + end + end + end end + + -- Only show "not in tracking" message if CleveRoids.debugVerbose is enabled + -- (too spammy for normal debug mode) + if not found and CleveRoids.debugVerbose then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cffff0000[Tracking]|r %s not in tracking table (checked %d ranks)", + args.name, table.getn(matchingSpellIDs)) + ) + end + elseif CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cffff0000[Tracking]|r Unknown spell: %s", args.name) + ) + end + + -- FALLBACK: If not found in tracking table, scan actual debuffs on target + -- This handles: + -- 1. Shared debuffs from other players (we only track player's own casts) + -- 2. Personal debuffs during the 0.2s pending delay after casting + -- 3. Any debuffs not yet registered in tracking (edge cases) + -- + -- For simple existence checks ([nodebuff]Spell), we want to find the debuff + -- regardless of whether it's in our tracking table. Time-remaining checks + -- will use tracking table data when available, but existence is from scan. + local isSimpleExistenceCheck = not args.operator and not args.amount + + if not found and CleveRoids.hasSuperwow and isSimpleExistenceCheck then + -- FALLBACK: Only scan for SHARED debuffs (Sunder, Faerie Fire, etc.) + -- Personal debuffs (Rip, Rake, Rupture, etc.) MUST be in tracking table + -- to be considered "found" - this ensures [nodebuff] only finds YOUR debuffs + -- after /reload when tracking is cleared. + + -- Scan debuff slots + for i = 1, 16 do + local tex, debuffStacks, _, debuffSpellID = UnitDebuff(unit, i) + if not tex then break end + + if debuffSpellID then + -- PERFORMANCE: Use cached spell name lookup + local baseName, fullName = GetSpellNames(debuffSpellID) + if baseName and (baseName == args.name or fullName == args.name) then + -- IMPORTANT: Only use fallback for SHARED debuffs + -- Personal debuffs must come from tracking table (caster check) + local isShared = lib and lib.IsPersonalDebuff and lib:IsPersonalDebuff(debuffSpellID) == false + if isShared then + found = true + texture = tex + stacks = debuffStacks or 0 + spellID = debuffSpellID + -- No duration tracking for shared debuffs + remaining = nil + break + end + end + end + end + + -- If still not found, check buff slots (overflow debuffs) + if not found then + for i = 1, 32 do + local tex, buffStacks, buffSpellID = UnitBuff(unit, i) + if not tex then break end + + if buffSpellID then + -- PERFORMANCE: Use cached spell name lookup + local baseName, fullName = GetSpellNames(buffSpellID) + if baseName and (baseName == args.name or fullName == args.name) then + -- IMPORTANT: Only use fallback for SHARED debuffs + local isShared = lib and lib.IsPersonalDebuff and lib:IsPersonalDebuff(buffSpellID) == false + if isShared then + found = true + texture = tex + stacks = buffStacks or 0 + spellID = buffSpellID + remaining = nil + break + end + end + end + end + end + end + -- For player unit, use standard search (player only sees own debuffs on self) + elseif unit == "player" then + -- Search DEBUFFS first + i = 0 + while true do + texture, stacks, spellID, remaining = CleveRoids.GetPlayerAura(i, false) if not texture then break end - if (CleveRoids.hasSuperwow and args.name == SpellInfo(spellID)) - or (not CleveRoids.hasSuperwow and texture == CleveRoids.auraTextures[args.name]) then + if CleveRoids.hasSuperwow then + -- PERFORMANCE: Use cached spell name lookup + local baseName, fullName = GetSpellNames(spellID) + if baseName and (baseName == args.name or fullName == args.name) then + found = true + break + end + elseif texture == CleveRoids.auraTextures[args.name] then found = true break end i = i + 1 end + + -- If not found, search BUFFS (overflow debuffs shown as buffs on some servers) + if not found then + i = 0 + while true do + texture, stacks, spellID, remaining = CleveRoids.GetPlayerAura(i, true) + if not texture then break end + + if CleveRoids.hasSuperwow then + -- PERFORMANCE: Use cached spell name lookup + local baseName, fullName = GetSpellNames(spellID) + if baseName and (baseName == args.name or fullName == args.name) then + found = true + break + end + elseif texture == CleveRoids.auraTextures[args.name] then + found = true + break + end + i = i + 1 + end + end end -- Step 3: Perform conditional validation local ops = CleveRoids.operators local cmp = CleveRoids.comparators + + -- Handle multi-comparison (e.g., >0&<10) + if args.comparisons and type(args.comparisons) == "table" then + -- Check if ALL comparisons are stack-based + local allStackChecks = true + for _, comp in ipairs(args.comparisons) do + if not comp.checkStacks then + allStackChecks = false + break + end + end + + -- If debuff not found: stack checks treat as 0 stacks, time checks fail + if not found then + if not allStackChecks then + return false -- Time-based comparisons require debuff to exist + end + -- For pure stack checks, treat missing debuff as 0 stacks + stacks = 0 + end + + -- For non-player units, get time remaining once (used for all time comparisons) + local nonPlayerTimeRemaining = nil + if unit ~= "player" and found then + nonPlayerTimeRemaining = _get_debuff_timeleft(unit, args.name) or 0 + end + + -- ALL comparisons must pass (AND logic) + for _, comp in ipairs(args.comparisons) do + if not ops[comp.operator] then + return false -- Invalid operator + end + + local value_to_check + if comp.checkStacks then + value_to_check = stacks or 0 + elseif unit == "player" then + value_to_check = remaining or 0 + else + -- Non-player units: use time remaining from libdebuff + value_to_check = nonPlayerTimeRemaining or 0 + end + + if not cmp[comp.operator](value_to_check, comp.amount) then + return false -- One comparison failed + end + end + return true -- All comparisons passed + end + local hasNumCheck = (args.amount ~= nil) and (args.operator ~= nil) and ops[args.operator] -- Case A: No numeric/stack condition, just check for existence. @@ -784,13 +3440,49 @@ function CleveRoids.ValidateUnitDebuff(unit, args) return cmp[args.operator](tl or 0, args.amount) end - if CleveRoids.libdebuff and CleveRoids.libdebuff.UnitDebuff then + -- Defensive: verify libdebuff is a table before accessing properties + if type(CleveRoids.libdebuff) == "table" and CleveRoids.libdebuff.UnitDebuff then local atl = nil - for idx = 1, 16 do - local effect, _, _, _, _, duration, timeleft = CleveRoids.libdebuff:UnitDebuff(unit, idx) - if effect == args.name then - atl = (timeleft and timeleft >= 0) and timeleft or 0 - break + local caster = nil + + -- Auto-detect if this is a personal debuff (unless explicitly overridden) + local filterCaster = nil + if args.mine == true then + -- User explicitly requested player-only filtering + filterCaster = "player" + elseif args.mine == false then + -- User explicitly requested no filtering + filterCaster = nil + else + -- Auto-detect based on spell type (if available and personal, filter to player) + -- We'll determine this during the search + filterCaster = nil + end + + -- Check 1-48: debuff slots 1-16 + overflow debuffs in buff slots 1-32 + for idx = 1, 48 do + local effect, _, _, _, _, duration, timeleft, effectCaster = CleveRoids.libdebuff:UnitDebuff(unit, idx, filterCaster) + -- Only break for slots 1-16 (regular debuffs are dense) + -- For overflow slots 17-48, nil means "regular buff filtered out", not "end of list" + if not effect and idx <= 16 then break end + -- Strip rank from effect name for comparison + local effectBase = effect and string.gsub(effect, "%s*%(%s*Rank%s+%d+%s*%)", "") + if effectBase and effectBase == args.name then + local shouldSkip = false + + -- Auto-detect: If args.mine not specified and this is a personal debuff, only match player casts + if args.mine == nil and spellID and CleveRoids.libdebuff.IsPersonalDebuff then + if CleveRoids.libdebuff:IsPersonalDebuff(spellID) and effectCaster ~= "player" then + -- This is a personal debuff from another player, skip it + shouldSkip = true + end + end + + if not shouldSkip then + atl = (timeleft and timeleft >= 0) and timeleft or 0 + caster = effectCaster + break + end end end if atl ~= nil then @@ -813,22 +3505,160 @@ function CleveRoids.ValidateUnitDebuff(unit, args) end function CleveRoids.ValidatePlayerBuff(args) - return CleveRoids.ValidateAura("player", args, true) + -- First check regular buffs + local found = CleveRoids.ValidateAura("player", args, true) + if found then return true end + + -- Also check shapeshift forms (Cat Form, Bear Form, etc. are not regular buffs) + -- This is needed for !Cat Form syntax to work correctly + local searchName = type(args) == "table" and args.name or args + if searchName then + -- PERFORMANCE: Use cached lowercase normalization to avoid per-call string allocation + searchName = GetLowerNormalizedName(searchName) + local numForms = GetNumShapeshiftForms() + for i = 1, numForms do + local icon, name, isActive, isCastable = GetShapeshiftFormInfo(i) + -- PERFORMANCE: Use cached lowercase to avoid per-iteration string allocation + if name and isActive and GetLowercaseString(name) == searchName then + return true + end + end + end + + return false end function CleveRoids.ValidatePlayerDebuff(args) return CleveRoids.ValidateAura("player", args, false) end -function CleveRoids.ValidateWeaponImbue(slot, imbueName) - -- Check if weapon has enchant +function CleveRoids.ValidateWeaponImbue(slot, args) + -- Check if weapon has enchant via API local hasMainEnchant, mainExpiration, mainCharges, hasOffEnchant, offExpiration, offCharges = GetWeaponEnchantInfo() - local hasEnchant = (slot == "mh" and hasMainEnchant) or (slot == "oh" and hasOffEnchant) - if not hasEnchant then return false end + local hasEnchant, expiration, charges + if slot == "mh" then + hasEnchant = hasMainEnchant + expiration = mainExpiration + charges = mainCharges + else + hasEnchant = hasOffEnchant + expiration = offExpiration + charges = offCharges + end - -- If no specific imbue requested, just return enchant exists - if not imbueName or imbueName == "" then return true end + -- Only consider temporary enchants (with time or charges) + -- This filters out permanent enchants like Crusader, Lifestealing, etc. + local hasTemporaryEnchant = hasEnchant and (expiration and expiration > 0 or charges and charges > 0) + + -- Convert expiration from milliseconds to seconds + local remaining = expiration and (expiration / 1000) or -1 + local stacks = charges or 0 + + -- Normalize args to table format + if type(args) ~= "table" then + args = { name = args } + end + + local imbueName = args.name + + -- If no specific imbue requested and no comparison operators, return temporary enchant status + if (not imbueName or imbueName == "") and not args.operator and not args.comparisons then + return hasTemporaryEnchant + end + + -- If no temporary enchant, don't bother with further checks + if not hasTemporaryEnchant then + return false + end + + -- If we have a specific imbue name, verify it via tooltip scan + if imbueName and imbueName ~= "" then + local nameFound = CleveRoids.CheckWeaponImbueByName(slot, imbueName) + if not nameFound then + return false -- Specific imbue not found + end + end + + -- Handle numeric comparisons (time remaining or charges) + local ops = CleveRoids.operators + + -- Handle multi-comparison (e.g., >60&<300) + if args.comparisons and type(args.comparisons) == "table" then + -- ALL comparisons must pass (AND logic) + for _, comp in ipairs(args.comparisons) do + if not ops[comp.operator] then + return false -- Invalid operator + end + + local value_to_check + if comp.checkStacks then + value_to_check = stacks + else + value_to_check = remaining + end + + if not CleveRoids.comparators[comp.operator](value_to_check, comp.amount) then + return false -- One comparison failed + end + end + return true -- All comparisons passed + end + + -- Single comparison (backward compatibility) + if not args.amount and not args.operator and not args.checkStacks then + return true -- Name matched (or no name required), no numeric check needed + elseif not args.checkStacks and args.amount and ops[args.operator] then + -- Time remaining check + return CleveRoids.comparators[args.operator](remaining, args.amount) + elseif args.amount and args.checkStacks and ops[args.operator] then + -- Charge count check + return CleveRoids.comparators[args.operator](stacks, args.amount) + else + return false + end +end + +-- Helper function: Parse imbue conditional arguments into a normalized args table +-- Handles: boolean, string, or table with name/operator/amount/checkStacks/comparisons +function CleveRoids.ParseImbueArgs(value, conditionals) + -- Boolean true means check for any imbue + if value == true then + return nil + end + + -- Simple string means just check for name + if type(value) == "string" then + return { name = value } + end + + -- Table format - could be array of values or parsed args with operator + if type(value) == "table" then + -- Check if it's already a parsed args table (has operator or comparisons) + if value.operator or value.comparisons or value.checkStacks then + return value + end + + -- It's an array of values - use the first one + if table.getn(value) > 0 then + local first = value[1] + -- First element could be a string or a parsed args table + if type(first) == "string" then + return { name = first } + elseif type(first) == "table" then + return first -- Already parsed (has name, operator, amount, etc.) + end + end + end + + return nil -- Default: check for any imbue +end + +-- Helper function: Check if specific imbue name is on weapon via tooltip scan +function CleveRoids.CheckWeaponImbueByName(slot, imbueName) + if not imbueName or imbueName == "" then + return true -- No name to check + end -- Create tooltip scanner if needed if not CleveRoidsTooltip then @@ -840,27 +3670,39 @@ function CleveRoids.ValidateWeaponImbue(slot, imbueName) CleveRoidsTooltip:ClearLines() CleveRoidsTooltip:SetInventoryItem("player", slot == "mh" and 16 or 17) - -- Look for green text (temporary enchant) + -- PERFORMANCE: Use cached normalization to avoid per-call string allocation + local searchTerm = GetLowerNormalizedName(imbueName) + + -- Look for green text with time markers - check ALL green lines with time for i = 1, CleveRoidsTooltip:NumLines() do local text = _G["CleveRoidsTooltipTextLeft"..i] if text then local line = text:GetText() - local r, g, b = text:GetTextColor() - -- Green text indicates temporary enchant - if line and g > 0.8 and r < 0.2 and b < 0.2 then - -- Normalize and compare - line = string.lower(line) - imbueName = string.lower(string.gsub(imbueName, "_", " ")) - return string.find(line, imbueName, 1, true) ~= nil + if line then + local r, g, b = text:GetTextColor() + -- Green text indicates enchant + if g > 0.8 and r < 0.2 and b < 0.2 then + local lowerLine = string.lower(line) + -- Only check lines with time markers (temporary enchants) + -- This skips permanent weapon stats like "Equip: ... critical strike ..." + if string.find(lowerLine, "%(") and (string.find(lowerLine, " min%)") or string.find(lowerLine, " sec%)") or string.find(lowerLine, " charge")) then + -- This is a temporary enchant line, check if it matches + if string.find(lowerLine, searchTerm, 1, true) then + return true -- Found it! + end + end + end end end end + -- Checked all lines, didn't find it return false end -- TODO: Look into https://github.com/Stanzilla/WoWUIBugs/issues/47 if needed -function CleveRoids.GetCooldown(name, ignoreGCD) +-- PERFORMANCE: Uncached version - called by GetCachedCooldown +function CleveRoids._GetCooldownUncached(name, ignoreGCD) if not name then return 0 end -- Check if it's a spell first @@ -876,12 +3718,17 @@ function CleveRoids.GetCooldown(name, ignoreGCD) -- Convert remaining seconds to absolute expiry time if remaining and remaining > 0 then - return GetTime() + remaining + return CleveRoids.GetCachedTime() + remaining end return 0 end +-- PERFORMANCE: Cached wrapper - use this for conditional checks +function CleveRoids.GetCooldown(name, ignoreGCD) + return CleveRoids.GetCachedCooldown(name, ignoreGCD) +end + -- TODO: Look into https://github.com/Stanzilla/WoWUIBugs/issues/47 if needed -- Returns the cooldown of the given spellName or nil if no such spell was found function CleveRoids.GetSpellCooldown(spellName, ignoreGCD) @@ -898,12 +3745,55 @@ function CleveRoids.GetSpellCooldown(spellName, ignoreGCD) end end +-- Check if an item exists in bags or equipped +-- Returns: true if found, false otherwise +-- PERFORMANCE: Uses CleveRoids.Items cache for O(1) lookup, with fallback for substring matches +function CleveRoids.HasItem(item) + -- Fast path: check cache first (O(1) lookup) + if CleveRoids.HasItemCached(item) then + return true + end + + -- Slow path fallback: only needed for substring matching on strings + -- The cache handles exact matches by ID and name, but not partial/substring matches + if type(item) == "string" and item ~= "" then + local itemLower = string.lower(item) + + -- Check equipped slots for substring match + for slot = 0, 19 do + local link = GetInventoryItemLink("player", slot) + if link then + if string.find(string.lower(link), itemLower, 1, true) then + return true + end + end + end + + -- Check bags for substring match + for bag = 0, 4 do + local size = GetContainerNumSlots(bag) + if size and size > 0 then + for slotIndex = 1, size do + local link = GetContainerItemLink(bag, slotIndex) + if link then + if string.find(string.lower(link), itemLower, 1, true) then + return true + end + end + end + end + end + end + + return false +end + -- TODO: Look into https://github.com/Stanzilla/WoWUIBugs/issues/47 if needed -- Hardened item cooldown resolver (Vanilla 1.12.1 / Lua 5.0) -- Returns: remainingSeconds, totalDuration, enabled +-- PERFORMANCE: Uses CleveRoids.Items cache for O(1) lookup, with fallback for substring matches function CleveRoids.GetItemCooldown(item) - local start, duration, enable = nil, nil, nil - + -- Helper to normalize cooldown values local function _norm(s, d, e) s = tonumber(s) or 0 d = tonumber(d) or 0 @@ -916,94 +3806,44 @@ function CleveRoids.GetItemCooldown(item) return rem, d, e end - -- Helper: Check if a numeric value is a valid inventory slot (1-19) - local function _isInventorySlot(num) - return num and num >= 1 and num <= 19 + -- Fast path: check cache first (O(1) lookup) + local remaining, duration, enable = CleveRoids.GetItemCooldownCached(item) + if duration > 0 or remaining > 0 then + return remaining, duration, enable end - -- Case A: numeric value passed - local numericItem = tonumber(item) - if numericItem then - -- Is it an inventory slot? - if _isInventorySlot(numericItem) then - start, duration, enable = GetInventoryItemCooldown("player", numericItem) - return _norm(start, duration, enable) - end + -- If cache found the item but cooldown is 0, that's a valid result + local location = CleveRoids.FindItemLocation(item) + if location then + return 0, 0, enable or 0 + end - -- Otherwise, treat it as an item ID - search equipped items first + -- Slow path fallback: only needed for substring matching on strings + if type(item) == "string" and item ~= "" then + local itemLower = string.lower(item) + local start, dur, en + + -- Check equipped slots for substring match for slot = 0, 19 do local link = GetInventoryItemLink("player", slot) if link then - local _, _, id = string.find(link, "item:(%d+)") - if id and tonumber(id) == numericItem then - start, duration, enable = GetInventoryItemCooldown("player", slot) - return _norm(start, duration, enable) - end - end - end - - -- Then search bags for the item ID - for bag = 0, 4 do - local size = GetContainerNumSlots(bag) - if size and size > 0 then - for slotIndex = 1, size do - local link = GetContainerItemLink(bag, slotIndex) - if link then - local _, _, id = string.find(link, "item:(%d+)") - if id and tonumber(id) == numericItem then - start, duration, enable = GetContainerItemCooldown(bag, slotIndex) - return _norm(start, duration, enable) - end - end - end - end - end - - -- Item ID not found in inventory or bags - return 0, 0, 0 - end - - -- Case B: string item name -> try equipped slots first - if type(item) == "string" and item ~= "" then - local itemLower = string.lower(item) - - -- scan a few common equipment slots; expand if your engine needs more - local slots = { 13, 14, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 15, 16, 17, 18, 19 } -- trinkets first - for i = 1, table.getn(slots) do - local s = slots[i] - local link = GetInventoryItemLink("player", s) - if link then - -- Extract item name from link using pattern [ItemName] - local _, _, linkName = string.find(link, "%[(.+)%]") - if linkName and string.lower(linkName) == itemLower then - start, duration, enable = GetInventoryItemCooldown("player", s) - return _norm(start, duration, enable) - end - -- Fallback: simple substring match if string.find(string.lower(link), itemLower, 1, true) then - start, duration, enable = GetInventoryItemCooldown("player", s) - return _norm(start, duration, enable) + start, dur, en = GetInventoryItemCooldown("player", slot) + return _norm(start, dur, en) end end end - -- Case C: search bags for the named item + -- Check bags for substring match for bag = 0, 4 do local size = GetContainerNumSlots(bag) if size and size > 0 then for slotIndex = 1, size do local link = GetContainerItemLink(bag, slotIndex) if link then - -- Extract item name from link using pattern [ItemName] - local _, _, linkName = string.find(link, "%[(.+)%]") - if linkName and string.lower(linkName) == itemLower then - start, duration, enable = GetContainerItemCooldown(bag, slotIndex) - return _norm(start, duration, enable) - end - -- Fallback: simple substring match if string.find(string.lower(link), itemLower, 1, true) then - start, duration, enable = GetContainerItemCooldown(bag, slotIndex) - return _norm(start, duration, enable) + start, dur, en = GetContainerItemCooldown(bag, slotIndex) + return _norm(start, dur, en) end end end @@ -1016,17 +3856,18 @@ function CleveRoids.GetItemCooldown(item) end function CleveRoids.ValidatePlayerAuraCount(bigger, amount) - local aura_ix = -1 - local num = 0 - while true do - aura_ix = GetPlayerBuff(num,"HELPFUL|PASSIVE") - if aura_ix == -1 then break end - num = num + 1 + -- Count player buffs by iterating all 32 slots + -- (matches IsPlayerBuffCapped logic for consistency) + local count = 0 + for i = 0, 31 do + if GetPlayerBuffTexture(GetPlayerBuff(i, "HELPFUL")) then + count = count + 1 + end end if bigger == 0 then - return num < tonumber(amount) + return count < tonumber(amount) else - return num > tonumber(amount) + return count > tonumber(amount) end end @@ -1048,17 +3889,32 @@ function CleveRoids.GetActionButtonInfo(slot) end function CleveRoids.IsReactiveUsable(spellName) - -- Use Nampower's IsSpellUsable if available (more accurate) - if IsSpellUsable then - local usable, oom = IsSpellUsable(spellName) - if usable == 1 and oom ~= 1 then + -- For Overpower, Revenge, and Riposte: ONLY use combat log tracking + -- These spells have specific proc conditions tracked via combat log + if spellName == "Overpower" or spellName == "Revenge" or spellName == "Riposte" then + if CleveRoids.HasReactiveProc and CleveRoids.HasReactiveProc(spellName) then return 1 else - return nil, oom + return nil end end - -- Fallback to original method + -- For other reactive spells, use fallback methods + -- Use Nampower's IsSpellUsable if available (more accurate) + if IsSpellUsable then + -- pcall to handle spells not in spellbook (Nampower throws error) + local ok, usable, oom = pcall(IsSpellUsable, spellName) + if ok then + if usable == 1 and oom ~= 1 then + return 1 + else + return nil, oom + end + end + -- If pcall failed, spell not in spellbook - fall through to action bar check + end + + -- Fallback to action bar slot checking (requires correct stance) if not CleveRoids.reactiveSlots[spellName] then return false end local actionSlot = CleveRoids.reactiveSlots[spellName] local isUsable, oom = CleveRoids.Hooks.OriginalIsUsableAction(actionSlot) @@ -1076,8 +3932,12 @@ function CleveRoids.CheckSpellUsable(spellName) -- Use Nampower's IsSpellUsable if available if IsSpellUsable then - local usable, oom = IsSpellUsable(spellName) - return (usable == 1 and oom ~= 1) + -- pcall to handle spells not in spellbook (Nampower throws error) + local ok, usable, oom = pcall(IsSpellUsable, spellName) + if ok then + return (usable == 1 and oom ~= 1) + end + -- If pcall failed, spell not in spellbook - fall through to fallback end -- Fallback: check if spell exists and player has mana/rage/energy @@ -1100,14 +3960,48 @@ function CleveRoids.CheckSpellUsable(spellName) end function CleveRoids.CheckSpellCast(unit, spell) - if not CleveRoids.hasSuperwow then return false end - local spell = spell or "" local _,guid = UnitExists(unit) - if not guid or (guid and not CleveRoids.spell_tracking[guid]) then + if not guid then return false end + + -- Player: check CurrentSpell (event-driven, most reliable for self) + if unit == "player" then + if CleveRoids.CurrentSpell and CleveRoids.CurrentSpell.type ~= "" then + if spell == "" then + return true + end + if CleveRoids.CurrentSpell.spellName and CleveRoids.CurrentSpell.spellName == spell then + return true + end + end + -- Fallback to GUID-based tracking and spell_tracking below + end + + -- GUID-based cast tracking (pfUI 7.6 or standalone SPELL_START events) + local ct = CleveRoids.castTracking + if ct then + local normalizedGuid = CleveRoids.NormalizeGUID(guid) + local castEntry = normalizedGuid and ct[normalizedGuid] + if castEntry and castEntry.endTime and GetTime() < castEntry.endTime then + if spell == "" then + return true + end + if castEntry.spellName then + -- Strip rank suffix for comparison: "Heal(Rank 4)" -> "Heal" + local baseName = string.gsub(castEntry.spellName, "%s*%(Rank %d+%)", "") + if baseName == spell or castEntry.spellName == spell then + return true + end + end + end + end + + -- Legacy fallback: UNIT_CASTEVENT-based spell_tracking (requires SuperWoW) + if not CleveRoids.hasSuperwow then return false end + + if not CleveRoids.spell_tracking[guid] then return false else - -- are we casting a specific spell, or any spell if spell == SpellInfo(CleveRoids.spell_tracking[guid].spell_id) or (spell == "") then return true end @@ -1115,6 +4009,372 @@ function CleveRoids.CheckSpellCast(unit, spell) end end +-- ============================================================================ +-- CC (Crowd Control) Mechanic Detection +-- Uses BuffLib if available, otherwise uses built-in spell database +-- ============================================================================ + +-- Maps CC type names to mechanic constants (matches DBC mechanic IDs) +-- Note: Some types map to multiple mechanics via CCMechanicGroups below +CleveRoids.CCMechanics = { + -- Movement/control impairment + charm = 1, -- Mind Control, Seduction + disoriented = 2, -- Scatter Shot, Blind (disorient component) + disorient = 2, -- Alias for disoriented + disarm = 3, -- Disarm, Riposte disarm + distract = 4, -- Distract (Rogue ability) + fear = 5, -- Fear, Psychic Scream, Howl of Terror + grip = 6, -- Grip effects + root = 7, -- Entangling Roots, Frost Nova, Improved Hamstring + pacify = 8, -- Pacify effects + silence = 9, -- Silence, Kick, Counterspell (lockout) + sleep = 10, -- Hibernate, Wyvern Sting sleep + snare = 11, -- Hamstring, Wing Clip, Crippling Poison + slow = 11, -- Alias for snare + stun = 12, -- Consolidated: Stun(12) + Knockout(14) + Sap(30) + freeze = 13, -- Freeze effects (Frost Nova freeze) + bleed = 15, -- Rend, Garrote, Deep Wounds + polymorph = 17, -- Polymorph (all variants) + banish = 18, -- Banish (Warlock) + shackle = 20, -- Shackle Undead + horror = 24, -- Death Coil (Warlock), Intimidating Shout (horror) + daze = 27, -- Dazed effects +} + +-- Mechanic groups: CC types that check multiple DBC mechanics +-- Used when a single conditional should match several related effects +CleveRoids.CCMechanicGroups = { + stun = {12, 14, 30}, -- Stun(12), Knockout/Gouge(14), Sap(30) +} + +-- CC types that count as "crowd controlled" (loss of control) +-- Note: Mechanics 12, 14, 30 are all consolidated under "stun" for conditionals +CleveRoids.CCTypesLossOfControl = { + [1] = true, -- charm + [2] = true, -- disoriented + [5] = true, -- fear + [10] = true, -- sleep + [12] = true, -- stun (Cheap Shot, Kidney Shot, etc.) + [13] = true, -- freeze + [14] = true, -- knockout/gouge (now part of stun group) + [17] = true, -- polymorph + [18] = true, -- banish + [20] = true, -- shackle + [24] = true, -- horror + [30] = true, -- sap (now part of stun group) +} + +-- Complete spell ID to mechanic mapping from DBC data +-- Extracted from BuffLib SpellData - 785 spells with mechanics +-- Mechanic IDs: 1=Charm, 2=Disorient, 3=Disarm, 5=Fear, 7=Root, 9=Silence, +-- 10=Sleep, 11=Snare, 12=Stun, 13=Freeze, 14=Knockout, +-- 15=Bleed, 17=Polymorph, 18=Banish, 20=Shackle, 21=Mount, +-- 23=Turn, 24=Horror, 25=Invuln, 27=Daze +CleveRoids.CCSpellMechanics = { + [17] = 19, [56] = 12, [89] = 11, [113] = 7, [118] = 17, [228] = 17, + [246] = 11, [339] = 7, [408] = 12, [451] = 9, [458] = 21, [459] = 21, + [468] = 21, [470] = 21, [471] = 21, [472] = 21, [474] = 6, [498] = 25, + [507] = 6, [512] = 7, [578] = 21, [579] = 21, [580] = 21, [581] = 21, + [592] = 19, [593] = 6, [600] = 19, [605] = 1, [642] = 25, [676] = 3, + [700] = 10, [703] = 15, [710] = 18, [745] = 7, [746] = 16, [771] = 1, + [772] = 15, [835] = 12, [851] = 17, [853] = 12, [861] = 9, [867] = 6, + [998] = 6, [1020] = 25, [1022] = 25, [1062] = 7, [1079] = 15, [1090] = 10, + [1098] = 1, [1159] = 16, [1513] = 5, [1776] = 14, [1777] = 14, [1833] = 12, + [1943] = 15, [2070] = 14, [2094] = 2, [2637] = 10, [2878] = 23, [2880] = 12, + [2937] = 10, [3109] = 5, [3143] = 12, [3147] = 15, [3242] = 12, [3263] = 12, + [3267] = 16, [3268] = 16, [3355] = 13, [3363] = 21, [3409] = 11, [3410] = 11, + [3446] = 12, [3542] = 7, [3551] = 12, [3589] = 9, [3600] = 11, [3604] = 11, + [3609] = 12, [3635] = 12, [3636] = 10, [3747] = 19, [4060] = 17, [4064] = 12, + [4065] = 12, [4066] = 12, [4067] = 12, [4068] = 12, [4069] = 12, [4102] = 15, + [4244] = 15, [4962] = 7, [5106] = 12, [5116] = 11, [5134] = 5, [5159] = 11, + [5164] = 12, [5195] = 7, [5196] = 7, [5211] = 12, [5246] = 5, [5259] = 3, + [5276] = 12, [5376] = 4, [5403] = 12, [5484] = 5, [5530] = 12, [5531] = 12, + [5567] = 7, [5573] = 25, [5588] = 12, [5589] = 12, [5597] = 15, [5598] = 15, + [5599] = 25, [5627] = 23, [5648] = 12, [5649] = 12, [5703] = 12, [5708] = 12, + [5782] = 5, [5784] = 21, [5917] = 6, [5918] = 12, [6065] = 19, [6066] = 19, + [6136] = 11, [6146] = 11, [6213] = 5, [6215] = 5, [6253] = 12, [6266] = 12, + [6304] = 12, [6358] = 1, [6388] = 11, [6409] = 12, [6435] = 12, [6466] = 12, + [6524] = 12, [6533] = 7, [6546] = 15, [6547] = 15, [6548] = 15, [6605] = 5, + [6607] = 12, [6608] = 3, [6648] = 21, [6653] = 21, [6654] = 21, [6713] = 3, + [6726] = 9, [6728] = 12, [6730] = 12, [6749] = 12, [6770] = 14, [6777] = 21, + [6788] = 19, [6798] = 12, [6896] = 21, [6897] = 21, [6898] = 21, [6899] = 21, + [6927] = 12, [6942] = 9, [6945] = 12, [6982] = 12, [6984] = 11, [6985] = 11, + [7074] = 9, [7093] = 5, [7139] = 12, [7279] = 11, [7321] = 11, [7399] = 5, + [7645] = 1, [7803] = 12, [7922] = 12, [7926] = 16, [7927] = 16, [7964] = 12, + [7967] = 10, [7992] = 11, [8040] = 10, [8122] = 5, [8124] = 5, [8142] = 7, + [8150] = 12, [8208] = 12, [8225] = 5, [8242] = 12, [8281] = 9, [8285] = 12, + [8312] = 7, [8346] = 7, [8377] = 7, [8379] = 3, [8391] = 12, [8394] = 21, + [8395] = 21, [8396] = 21, [8399] = 10, [8629] = 14, [8631] = 15, [8632] = 15, + [8633] = 15, [8639] = 15, [8640] = 15, [8643] = 12, [8646] = 12, [8715] = 5, + [8716] = 11, [8818] = 15, [8901] = 10, [8902] = 10, [8980] = 21, [8983] = 12, + [8988] = 9, [8994] = 18, [9005] = 12, [9007] = 15, [9080] = 11, [9159] = 10, + [9484] = 20, [9485] = 20, [9552] = 9, [9823] = 12, [9824] = 15, [9826] = 15, + [9827] = 12, [9852] = 7, [9853] = 7, [9896] = 15, [9915] = 7, [10017] = 7, + [10234] = 10, [10253] = 17, [10266] = 15, [10278] = 25, [10308] = 12, + [10326] = 23, [10787] = 21, [10788] = 21, [10789] = 21, [10790] = 21, + [10792] = 21, [10793] = 21, [10795] = 21, [10796] = 21, [10798] = 21, + [10799] = 21, [10800] = 21, [10801] = 21, [10802] = 21, [10803] = 21, + [10804] = 21, [10838] = 16, [10839] = 16, [10851] = 3, [10852] = 7, + [10855] = 11, [10856] = 12, [10873] = 21, [10888] = 5, [10890] = 5, + [10898] = 19, [10899] = 19, [10900] = 19, [10901] = 19, [10911] = 1, + [10912] = 1, [10955] = 20, [10969] = 21, [10987] = 11, [11020] = 12, + [11201] = 11, [11264] = 7, [11273] = 15, [11274] = 15, [11275] = 15, + [11285] = 14, [11286] = 14, [11289] = 15, [11290] = 15, [11297] = 14, + [11428] = 12, [11430] = 12, [11436] = 11, [11446] = 1, [11572] = 15, + [11573] = 15, [11574] = 15, [11578] = 12, [11579] = 12, [11641] = 17, [11650] = 12, [11725] = 1, + [11726] = 1, [11820] = 7, [11831] = 7, [11836] = 12, [11876] = 12, + [11879] = 3, [11922] = 7, [11958] = 13, [11977] = 15, [12023] = 7, + [12024] = 7, [12054] = 15, [12096] = 5, [12098] = 10, [12252] = 7, + [12323] = 11, [12355] = 12, [12421] = 12, [12461] = 12, [12484] = 11, + [12485] = 11, [12486] = 11, [12494] = 7, [12528] = 9, [12531] = 11, + [12540] = 14, [12542] = 5, [12543] = 12, [12551] = 11, [12562] = 12, + [12674] = 7, [12705] = 11, [12721] = 15, [12730] = 5, [12734] = 12, + [12747] = 7, [12748] = 7, [12798] = 12, [12809] = 12, [12824] = 17, + [12825] = 17, [12826] = 17, [12946] = 9, [13005] = 12, [13099] = 7, + [13119] = 7, [13138] = 7, [13181] = 1, [13237] = 12, [13318] = 15, + [13323] = 17, [13327] = 14, [13443] = 15, [13445] = 15, [13534] = 3, + [13579] = 14, [13608] = 7, [13704] = 5, [13738] = 15, [13747] = 11, + [13808] = 12, [13810] = 11, [13819] = 21, [13902] = 12, [14030] = 7, + [14087] = 15, [14100] = 5, [14102] = 12, [14118] = 15, [14180] = 3, + [14207] = 11, [14308] = 13, [14309] = 13, [14326] = 5, [14327] = 5, + [14331] = 15, [14515] = 1, [14621] = 17, [14874] = 15, [14897] = 11, + [14902] = 12, [14903] = 15, [14907] = 7, [15063] = 7, [15091] = 14, + [15269] = 12, [15283] = 12, [15398] = 12, [15471] = 7, [15474] = 7, + [15487] = 9, [15531] = 7, [15532] = 7, [15534] = 17, [15535] = 12, + [15583] = 15, [15593] = 12, [15609] = 7, [15618] = 12, [15621] = 12, + [15652] = 12, [15655] = 12, [15744] = 14, [15752] = 3, [15753] = 12, + [15779] = 21, [15780] = 21, [15781] = 21, [15822] = 10, [15859] = 1, + [15878] = 12, [15970] = 10, [15976] = 15, [16045] = 18, [16046] = 14, + [16050] = 11, [16053] = 1, [16055] = 21, [16056] = 21, [16058] = 21, + [16059] = 21, [16060] = 21, [16075] = 12, [16080] = 21, [16081] = 21, + [16082] = 21, [16083] = 21, [16084] = 21, [16095] = 15, [16096] = 5, + [16097] = 17, [16104] = 12, [16350] = 12, [16393] = 15, [16403] = 15, + [16406] = 15, [16451] = 18, [16469] = 7, [16497] = 12, [16508] = 5, + [16509] = 15, [16566] = 7, [16568] = 11, [16600] = 12, [16707] = 17, + [16708] = 17, [16709] = 17, [16727] = 12, [16790] = 12, [16798] = 10, + [16803] = 12, [16838] = 9, [16869] = 12, [16922] = 12, [17011] = 12, + [17145] = 14, [17153] = 15, [17165] = 11, [17172] = 17, [17174] = 11, + [17229] = 21, [17276] = 12, [17277] = 14, [17286] = 12, [17293] = 12, + [17308] = 12, [17405] = 1, [17450] = 21, [17453] = 21, [17454] = 21, + [17455] = 21, [17456] = 21, [17458] = 21, [17459] = 21, [17460] = 21, + [17461] = 21, [17462] = 21, [17463] = 21, [17464] = 21, [17465] = 21, + [17481] = 21, [17500] = 12, [17504] = 15, [17738] = 17, [17928] = 5, + [18075] = 15, [18078] = 15, [18093] = 12, [18103] = 12, [18106] = 15, + [18118] = 11, [18144] = 12, [18200] = 15, [18202] = 15, [18223] = 11, + [18278] = 9, [18327] = 9, [18328] = 11, [18363] = 21, [18395] = 12, + [18425] = 9, [18431] = 5, [18469] = 9, [18498] = 9, [18503] = 17, + [18608] = 16, [18610] = 16, [18647] = 18, [18657] = 10, [18658] = 10, + [18763] = 12, [18802] = 11, [18812] = 12, [18972] = 11, [18989] = 21, + [18990] = 21, [18991] = 21, [18992] = 21, [19128] = 12, [19134] = 5, + [19136] = 12, [19137] = 11, [19185] = 7, [19229] = 7, [19364] = 12, + [19386] = 10, [19393] = 9, [19408] = 5, [19410] = 12, [19469] = 1, + [19482] = 12, [19496] = 11, [19501] = 2, [19503] = 2, [19641] = 12, + [19718] = 3, [19769] = 12, [19771] = 15, [19780] = 12, [19784] = 12, + [19821] = 9, [19970] = 7, [19971] = 7, [19972] = 7, [19973] = 7, + [19974] = 7, [19975] = 7, [20066] = 14, [20170] = 12, [20253] = 12, + [20276] = 12, [20277] = 12, [20310] = 12, [20511] = 5, [20549] = 12, + [20604] = 1, [20614] = 12, [20615] = 12, [20654] = 7, [20663] = 10, + [20669] = 10, [20683] = 12, [20685] = 12, [20699] = 7, [20706] = 19, + [20740] = 1, [20882] = 1, [20907] = 15, [20908] = 15, [20989] = 10, + [21099] = 12, [21152] = 12, [21330] = 5, [21331] = 7, [21748] = 12, + [21749] = 12, [21869] = 5, [21898] = 5, [21949] = 15, [21990] = 12, + [22127] = 7, [22274] = 17, [22289] = 12, [22356] = 11, [22415] = 7, + [22419] = 3, [22424] = 14, [22427] = 12, [22519] = 7, [22566] = 17, + [22570] = 14, [22592] = 12, [22639] = 11, [22645] = 7, [22666] = 9, + [22678] = 5, [22686] = 5, [22691] = 3, [22692] = 12, [22717] = 21, + [22718] = 21, [22719] = 21, [22720] = 21, [22721] = 21, [22722] = 21, + [22723] = 21, [22724] = 21, [22744] = 7, [22752] = 19, [22800] = 7, + [22884] = 5, [22914] = 11, [22915] = 12, [22919] = 11, [22924] = 7, + [22994] = 7, [23039] = 14, [23103] = 12, [23113] = 14, [23161] = 21, + [23207] = 9, [23214] = 21, [23219] = 21, [23220] = 21, [23221] = 21, + [23222] = 21, [23223] = 21, [23225] = 21, [23227] = 21, [23228] = 21, + [23229] = 21, [23238] = 21, [23239] = 21, [23240] = 21, [23241] = 21, + [23242] = 21, [23243] = 21, [23246] = 21, [23247] = 21, [23248] = 21, + [23249] = 21, [23250] = 21, [23251] = 21, [23252] = 21, [23275] = 5, + [23338] = 21, [23364] = 12, [23365] = 3, [23454] = 12, [23509] = 21, + [23510] = 21, [23567] = 16, [23568] = 16, [23569] = 16, [23600] = 11, + [23603] = 17, [23618] = 12, [23694] = 7, [23696] = 16, [23918] = 9, + [23919] = 12, [23953] = 11, [24004] = 10, [24053] = 17, [24110] = 7, + [24118] = 15, [24119] = 15, [24120] = 15, [24132] = 10, [24133] = 10, + [24152] = 7, [24192] = 15, [24213] = 12, [24225] = 11, [24242] = 21, + [24252] = 21, [24259] = 9, [24327] = 1, [24331] = 15, [24332] = 15, + [24333] = 12, [24335] = 10, [24360] = 10, [24375] = 12, [24394] = 12, + [24412] = 16, [24413] = 16, [24414] = 16, [24415] = 11, [24576] = 21, + [24600] = 12, [24648] = 7, [24664] = 10, [24671] = 12, [24687] = 9, + [24698] = 14, [24712] = 17, [24713] = 17, [24735] = 17, [24736] = 17, + [24778] = 10, [25022] = 11, [25049] = 14, [25056] = 12, [25057] = 3, + [25187] = 11, [25189] = 12, [25260] = 5, [25654] = 12, [25655] = 3, + [25675] = 21, [25771] = 25, [25809] = 11, [25815] = 5, [25852] = 12, + [25858] = 21, [25859] = 21, [25863] = 21, [25953] = 21, [25999] = 7, + [26042] = 5, [26054] = 21, [26055] = 21, [26056] = 21, [26069] = 9, + [26070] = 5, [26071] = 7, [26078] = 11, [26108] = 2, [26141] = 11, + [26143] = 11, [26157] = 17, [26180] = 10, [26211] = 11, [26272] = 17, + [26273] = 17, [26274] = 17, [26379] = 11, [26580] = 5, [26641] = 5, + [26655] = 21, [26740] = 1, [27555] = 15, [27556] = 15, [27559] = 9, + [27565] = 18, [27581] = 3, [27607] = 19, [27610] = 5, [27615] = 12, + [27619] = 13, [27634] = 11, [27638] = 15, [27640] = 11, [27641] = 5, + [27758] = 12, [27760] = 17, [27880] = 12, [27990] = 5, [27993] = 11, + [28270] = 17, [28271] = 17, [28272] = 17, [28314] = 12, [28315] = 5, + [28445] = 12, [28456] = 14, [28725] = 12, [28858] = 7, [28911] = 15, + [28913] = 15, [28991] = 7, [29059] = 21, [29168] = 5, [29407] = 11, + [29419] = 5, [29544] = 5, [29685] = 5, [29848] = 17, [29849] = 7, + [29915] = 15, [29943] = 9, [30001] = 5, [30002] = 5, [30020] = 16, + [30094] = 7, [30174] = 21, [30225] = 9, [30285] = 15, [31365] = 5, + [31700] = 21, +} + +-- Check if BuffLib is available with full mechanic support +function CleveRoids.HasBuffLib() + -- Defensive: ensure BuffLib.SpellData is a table, not a function + return BuffLib and BuffLib.GetUnitDebuffsByMechanic + and type(BuffLib.SpellData) == "table" + and BuffLib.SpellData.GetMechanic +end + +-- Get mechanic for a spell ID (uses BuffLib if available, otherwise built-in table) +function CleveRoids.GetSpellMechanic(spellID) + if not spellID or spellID <= 0 then return 0 end + + -- Try BuffLib first (has complete DBC data) + -- Defensive: verify SpellData is a table before indexing + if BuffLib and type(BuffLib.SpellData) == "table" and BuffLib.SpellData.GetMechanic then + local mechanic = BuffLib.SpellData:GetMechanic(spellID) + if mechanic and mechanic > 0 then + return mechanic + end + end + + -- Fall back to built-in table + return CleveRoids.CCSpellMechanics[spellID] or 0 +end + +-- Validate CC on a unit (target, focus, player, etc.) +-- Returns true if the unit has the specified CC mechanic active +function CleveRoids.ValidateUnitCC(unit, ccType) + if not unit or not UnitExists(unit) then return false end + + local ccTypeLower = string.lower(ccType or "") + + -- Special case: "cc" means any loss-of-control effect + if ccTypeLower == "cc" or ccTypeLower == "any" then + return CleveRoids.ValidateUnitAnyCrowdControl(unit) + end + + -- Check if this CC type maps to a group of mechanics + local mechanicGroup = CleveRoids.CCMechanicGroups[ccTypeLower] + if mechanicGroup then + -- Check all mechanics in the group (e.g., stun checks 12, 14, 30) + for _, mechanic in ipairs(mechanicGroup) do + if CleveRoids.ValidateUnitCCSingleMechanic(unit, mechanic) then + return true + end + end + return false + end + + -- Single mechanic lookup + local mechanic = CleveRoids.CCMechanics[ccTypeLower] + if not mechanic then return false end + + return CleveRoids.ValidateUnitCCSingleMechanic(unit, mechanic) +end + +-- Validate a single CC mechanic on a unit (helper function) +function CleveRoids.ValidateUnitCCSingleMechanic(unit, mechanic) + -- Use BuffLib if available (most accurate - tracks overflow debuffs and hidden auras) + if CleveRoids.HasBuffLib() then + local _, guid = UnitExists(unit) + if not guid then return false end + + if unit == "player" then + return BuffLib:HasDebuffOfMechanic(mechanic) + else + local debuffs = BuffLib:GetUnitDebuffsByMechanic(guid, mechanic) + return debuffs and table.getn(debuffs) > 0 + end + end + + -- Fallback: scan unit debuffs directly + return CleveRoids.ValidateUnitCCDirect(unit, mechanic) +end + +-- Check if unit has any crowd control (loss of control) effect +function CleveRoids.ValidateUnitAnyCrowdControl(unit) + if not unit or not UnitExists(unit) then return false end + + -- Use BuffLib if available + if CleveRoids.HasBuffLib() then + local _, guid = UnitExists(unit) + if not guid then return false end + + if unit == "player" then + for mechanic, _ in pairs(CleveRoids.CCTypesLossOfControl) do + if BuffLib:HasDebuffOfMechanic(mechanic) then + return true + end + end + else + for mechanic, _ in pairs(CleveRoids.CCTypesLossOfControl) do + local debuffs = BuffLib:GetUnitDebuffsByMechanic(guid, mechanic) + if debuffs and table.getn(debuffs) > 0 then + return true + end + end + end + return false + end + + -- Fallback: check all loss-of-control mechanics directly + for mechanic, _ in pairs(CleveRoids.CCTypesLossOfControl) do + if CleveRoids.ValidateUnitCCDirect(unit, mechanic) then + return true + end + end + return false +end + +-- Direct CC check - scans unit debuffs using SuperWoW API +-- Works without BuffLib by using built-in spell mechanic table +function CleveRoids.ValidateUnitCCDirect(unit, mechanic) + if not CleveRoids.hasSuperwow then return false end + + -- Players only have 16 debuff slots, no overflow + -- Non-player units can have overflow debuffs in buff slots (17-48) + local isPlayer = (unit == "player") + local maxDebuffSlots = 16 + local maxOverflowSlots = isPlayer and 0 or 32 + + -- Scan regular debuff slots (1-16) + for i = 1, maxDebuffSlots do + local texture, stacks, debuffType, spellID = UnitDebuff(unit, i) + if not texture then break end + + if spellID and spellID > 0 then + local spellMechanic = CleveRoids.GetSpellMechanic(spellID) + if spellMechanic == mechanic then + return true + end + end + end + + -- Scan overflow slots in buff bar (non-player units only) + if maxOverflowSlots > 0 then + for i = 1, maxOverflowSlots do + local texture, stacks, spellID = UnitBuff(unit, i) + if not texture then break end + + if spellID and spellID > 0 then + local spellMechanic = CleveRoids.GetSpellMechanic(spellID) + if spellMechanic == mechanic then + return true + end + end + end + end + + return false +end + -- A list of Conditionals and their functions to validate them CleveRoids.Keywords = { exists = function(conditionals) @@ -1125,19 +4385,47 @@ CleveRoids.Keywords = { return not UnitExists(conditionals.target) end, + -- Check if player has NO current target (target frame is empty) + -- Different from noexists: notarget checks player's target, noexists checks @unit + -- Usage: /target [notarget,@mouseover] - target mouseover only if no current target + notarget = function(conditionals) + return not UnitExists("target") + end, + + -- Check if player HAS a current target (target frame is occupied) + -- Usage: /cast [hastarget] Spell - only cast if player has a target selected + hastarget = function(conditionals) + return UnitExists("target") + end, + help = function(conditionals) return conditionals.help and conditionals.target and UnitExists(conditionals.target) and UnitCanAssist("player", conditionals.target) end, + -- [nohelp] - Target is NOT friendly (cannot assist) + nohelp = function(conditionals) + if not conditionals.target or not UnitExists(conditionals.target) then + return true + end + return not UnitCanAssist("player", conditionals.target) + end, + harm = function(conditionals) return conditionals.harm and conditionals.target and UnitExists(conditionals.target) and UnitCanAttack("player", conditionals.target) end, + -- [noharm] - Target is NOT hostile (cannot attack) + noharm = function(conditionals) + if not conditionals.target or not UnitExists(conditionals.target) then + return true + end + return not UnitCanAttack("player", conditionals.target) + end, + stance = function(conditionals) local i = CleveRoids.GetCurrentShapeshiftIndex() - return Or(conditionals.stance, function (v) - return (i == tonumber(v)) - end) + -- PERFORMANCE: Use specialized function to avoid closure allocation + return OrEqualsNumber(conditionals.stance, i) end, nostance = function(conditionals) @@ -1146,9 +4434,8 @@ CleveRoids.Keywords = { if type(forbiddenStances) ~= "table" then return i == 0 end - return And(forbiddenStances, function (v) - return (i ~= tonumber(v)) - end) + -- PERFORMANCE: Use specialized function to avoid closure allocation + return AndNotEqualsNumber(forbiddenStances, i) end, noform = function(conditionals) @@ -1157,34 +4444,32 @@ CleveRoids.Keywords = { if type(forbiddenForms) ~= "table" then return i == 0 end - return And(forbiddenForms, function (v) - return (i ~= tonumber(v)) - end) + -- PERFORMANCE: Use specialized function to avoid closure allocation + return AndNotEqualsNumber(forbiddenForms, i) end, form = function(conditionals) local i = CleveRoids.GetCurrentShapeshiftIndex() - return Or(conditionals.form, function (v) - return (i == tonumber(v)) - end) + -- PERFORMANCE: Use specialized function to avoid closure allocation + return OrEqualsNumber(conditionals.form, i) end, mod = function(conditionals) if type(conditionals.mod) ~= "table" then return CleveRoids.kmods.mod() end - return Or(conditionals.mod, function(mod) + return Multi(conditionals.mod, function(mod) return CleveRoids.kmods[mod]() - end) + end, conditionals, "mod") end, nomod = function(conditionals) if type(conditionals.nomod) ~= "table" then return CleveRoids.kmods.nomod() end - return And(conditionals.nomod, function(mod) + return NegatedMulti(conditionals.nomod, function(mod) return not CleveRoids.kmods[mod]() - end) + end, conditionals, "nomod") end, target = function(conditionals) @@ -1195,12 +4480,17 @@ CleveRoids.Keywords = { -- Check if an argument like :target or :focus was provided. The parser turns this into a table. if type(conditionals.combat) == "table" then -- If so, run the check on the provided unit(s). - return Or(conditionals.combat, function(unit) + return Multi(conditionals.combat, function(unit) return UnitExists(unit) and UnitAffectingCombat(unit) - end) + end, conditionals, "combat") else -- Otherwise, this is a bare [combat]. The value might be 'true' or a spell name. - -- In either case, it should safely default to checking the player. + -- PERFORMANCE: Use event-driven cache for player combat state + local cached = CleveRoids._cachedPlayerInCombat + if cached ~= nil then + return cached + end + -- Fallback if cache not yet initialized return UnitAffectingCombat("player") end end, @@ -1209,14 +4499,20 @@ CleveRoids.Keywords = { -- Check if an argument like :target or :focus was provided. if type(conditionals.nocombat) == "table" then -- If so, run the check on the provided unit(s). - return And(conditionals.nocombat, function(unit) + return NegatedMulti(conditionals.nocombat, function(unit) if not UnitExists(unit) then return true end return not UnitAffectingCombat(unit) - end) + end, conditionals, "nocombat") else -- Otherwise, this is a bare [nocombat]. Default to checking the player. + -- PERFORMANCE: Use event-driven cache for player combat state + local cached = CleveRoids._cachedPlayerInCombat + if cached ~= nil then + return not cached + end + -- Fallback if cache not yet initialized return not UnitAffectingCombat("player") end end, @@ -1243,10 +4539,51 @@ CleveRoids.Keywords = { end, nocasting = function(conditionals) - if type(conditionals.nocasting) ~= "table" then return CleveRoids.CheckSpellCast(conditionals.target, "") end - return And(conditionals.nocasting, function (spell) + if type(conditionals.nocasting) ~= "table" then return not CleveRoids.CheckSpellCast(conditionals.target, "") end + return NegatedMulti(conditionals.nocasting, function (spell) return not CleveRoids.CheckSpellCast(conditionals.target, spell) - end) + end, conditionals, "nocasting") + end, + + -- NEW: Direct player casting check with time-based prediction + -- Uses our accurate state tracking instead of GetCurrentCastingInfo polling + selfcasting = function(conditionals) + -- Check for cast with time-based prediction + if CleveRoids.CurrentSpell.type == "cast" and CleveRoids.castStartTime and CleveRoids.castDuration then + local remaining = CleveRoids.castDuration - (GetTime() - CleveRoids.castStartTime) + if remaining <= 0.1 then + return false -- Cast is done + end + end + + -- Check for channel with time-based prediction + if CleveRoids.CurrentSpell.type == "channeled" and CleveRoids.channelStartTime and CleveRoids.channelDuration then + local remaining = CleveRoids.channelDuration - (GetTime() - CleveRoids.channelStartTime) + if remaining <= 0.1 then + return false -- Channel is done + end + end + + return CleveRoids.CurrentSpell.type == "cast" or CleveRoids.CurrentSpell.type == "channeled" + end, + + noselfcasting = function(conditionals) + -- Inverse of selfcasting with same prediction logic + if CleveRoids.CurrentSpell.type == "cast" and CleveRoids.castStartTime and CleveRoids.castDuration then + local remaining = CleveRoids.castDuration - (GetTime() - CleveRoids.castStartTime) + if remaining <= 0.1 then + return true -- Cast is done + end + end + + if CleveRoids.CurrentSpell.type == "channeled" and CleveRoids.channelStartTime and CleveRoids.channelDuration then + local remaining = CleveRoids.channelDuration - (GetTime() - CleveRoids.channelStartTime) + if remaining <= 0.1 then + return true -- Channel is done + end + end + + return CleveRoids.CurrentSpell.type ~= "cast" and CleveRoids.CurrentSpell.type ~= "channeled" end, zone = function(conditionals) @@ -1260,9 +4597,9 @@ CleveRoids.Keywords = { nozone = function(conditionals) local zone = GetRealZoneText() local sub_zone = GetSubZoneText() - return And(conditionals.nozone, function (v) + return NegatedMulti(conditionals.nozone, function (v) return not ((sub_zone ~= "" and v == sub_zone)) or (v == zone) - end) + end, conditionals, "nozone") end, equipped = function(conditionals) @@ -1330,19 +4667,19 @@ CleveRoids.Keywords = { end, reactive = function(conditionals) - return Or(conditionals.reactive, function (v) + return Multi(conditionals.reactive, function (v) return CleveRoids.IsReactiveUsable(v) - end) + end, conditionals, "reactive") end, noreactive = function(conditionals) - return And(conditionals.noreactive, function (v) + return NegatedMulti(conditionals.noreactive, function (v) return not CleveRoids.IsReactiveUsable(v) - end) + end, conditionals, "noreactive") end, usable = function(conditionals) - return Or(conditionals.usable, function(name) + return Multi(conditionals.usable, function(name) -- If checking a reactive spell, use reactive logic if CleveRoids.reactiveSpells[name] then return CleveRoids.IsReactiveUsable(name) @@ -1368,14 +4705,19 @@ CleveRoids.Keywords = { end end + -- Check if item exists in bags/equipped first + if not CleveRoids.HasItem(itemName) then + return false + end + -- Check item cooldown (0 remaining = usable) local remaining = CleveRoids.GetItemCooldown(itemName) return remaining == 0 - end) + end, conditionals, "usable") end, nousable = function(conditionals) - return And(conditionals.nousable, function(name) + return NegatedMulti(conditionals.nousable, function(name) -- If checking a reactive spell, use reactive logic if CleveRoids.reactiveSpells[name] then return not CleveRoids.IsReactiveUsable(name) @@ -1401,10 +4743,15 @@ CleveRoids.Keywords = { end end + -- Item not existing counts as "not usable" + if not CleveRoids.HasItem(itemName) then + return true + end + -- Check item cooldown (>0 remaining = not usable) local remaining = CleveRoids.GetItemCooldown(itemName) return remaining > 0 - end) + end, conditionals, "nousable") end, member = function(conditionals) @@ -1415,178 +4762,465 @@ CleveRoids.Keywords = { end) end, + -- [nomember] - check if target is NOT in party or raid + nomember = function(conditionals) + return not ( + CleveRoids.IsTargetInGroupType(conditionals.target, "party") + or CleveRoids.IsTargetInGroupType(conditionals.target, "raid") + ) + end, + + -- [party] or [party:unitid] - check if unit is in your party + -- Default unit is conditionals.target party = function(conditionals) - return CleveRoids.IsTargetInGroupType(conditionals.target, "party") - end, - - noparty = function(conditionals) - return not CleveRoids.IsTargetInGroupType(conditionals.target, "party") - end, - - raid = function(conditionals) - return CleveRoids.IsTargetInGroupType(conditionals.target, "raid") - end, - - noraid = function(conditionals) - return not CleveRoids.IsTargetInGroupType(conditionals.target, "raid") - end, - - group = function(conditionals) - if type(conditionals.group) ~= "table" then - conditionals.group = { "party", "raid" } + local unit = conditionals.party + if unit == true or unit == nil then + unit = conditionals.target end - return Or(conditionals.group, function(groups) - if groups == "party" then + return CleveRoids.IsTargetInGroupType(unit, "party") + end, + + -- [noparty] or [noparty:unitid] - check if unit is NOT in your party + noparty = function(conditionals) + local unit = conditionals.noparty + if unit == true or unit == nil then + unit = conditionals.target + end + return not CleveRoids.IsTargetInGroupType(unit, "party") + end, + + -- [raid] or [raid:unitid] - check if unit is in your raid + -- Default unit is conditionals.target + raid = function(conditionals) + local unit = conditionals.raid + if unit == true or unit == nil then + unit = conditionals.target + end + return CleveRoids.IsTargetInGroupType(unit, "raid") + end, + + -- [noraid] or [noraid:unitid] - check if unit is NOT in your raid + noraid = function(conditionals) + local unit = conditionals.noraid + if unit == true or unit == nil then + unit = conditionals.target + end + return not CleveRoids.IsTargetInGroupType(unit, "raid") + end, + + -- [tag] - target is tapped (tagged) by anyone + tag = function(conditionals) + return conditionals.target and UnitIsTapped(conditionals.target) + end, + + -- [notag] - target is not tapped + notag = function(conditionals) + return conditionals.target and not UnitIsTapped(conditionals.target) + end, + + -- [mytag] - target is tapped by the player + mytag = function(conditionals) + return conditionals.target and UnitIsTappedByPlayer(conditionals.target) + end, + + -- [nomytag] - target is not tapped by the player + nomytag = function(conditionals) + return conditionals.target and not UnitIsTappedByPlayer(conditionals.target) + end, + + -- [othertag] - target is tapped by someone else (not the player) + othertag = function(conditionals) + return conditionals.target and UnitIsTapped(conditionals.target) and not UnitIsTappedByPlayer(conditionals.target) + end, + + -- [noothertag] - target is not tapped by someone else (not tapped, or tapped by player) + noothertag = function(conditionals) + return conditionals.target and (not UnitIsTapped(conditionals.target) or UnitIsTappedByPlayer(conditionals.target)) + end, + + -- [group] or [group:party] or [group:raid] or [group:party/raid] + -- Checks if the PLAYER is in a group (not unit membership) + group = function(conditionals) + local groupVal = conditionals.group + -- Boolean form [group] - check if in any group + if groupVal == true then + return GetNumPartyMembers() > 0 or GetNumRaidMembers() > 0 + end + -- Value form [group:party] or [group:raid] or [group:party/raid] + return Multi(groupVal, function(groupType) + if groupType == "party" then return GetNumPartyMembers() > 0 - elseif groups == "raid" then + elseif groupType == "raid" then return GetNumRaidMembers() > 0 end - end) + return false + end, conditionals, "group") + end, + + -- [nogroup] or [nogroup:party] or [nogroup:raid] or [nogroup:party/raid] + -- Checks if the PLAYER is NOT in a group + nogroup = function(conditionals) + local groupVal = conditionals.nogroup + -- Boolean form [nogroup] - check if not in any group + if groupVal == true then + return GetNumPartyMembers() == 0 and GetNumRaidMembers() == 0 + end + -- Value form with De Morgan's law via NegatedMulti + return NegatedMulti(groupVal, function(groupType) + if groupType == "party" then + return GetNumPartyMembers() == 0 + elseif groupType == "raid" then + return GetNumRaidMembers() == 0 + end + return true + end, conditionals, "nogroup") end, checkchanneled = function(conditionals) - return And(conditionals.checkchanneled, function(channeledSpells) - return CleveRoids.CheckChanneled(channeledSpells) - end) + if conditionals.checkchanneled == true then + -- Boolean form [checkchanneled] - check if NOT channeling anything + return CleveRoids.CheckChanneled(nil) + else + -- String form [checkchanneled:SpellName] - check if NOT channeling that spell + return Multi(conditionals.checkchanneled, function(channeledSpells) + return CleveRoids.CheckChanneled(channeledSpells) + end, conditionals, "checkchanneled") + end + end, + + checkcasting = function(conditionals) + if conditionals.checkcasting == true then + -- Boolean form [checkcasting] - check if NOT casting anything + return CleveRoids.CheckCasting(nil) + else + -- String form [checkcasting:SpellName] - check if NOT casting that spell + return Multi(conditionals.checkcasting, function(castingSpells) + return CleveRoids.CheckCasting(castingSpells) + end, conditionals, "checkcasting") + end end, buff = function(conditionals) - return And(conditionals.buff, function(v) + return Multi(conditionals.buff, function(v) return CleveRoids.ValidateUnitBuff(conditionals.target, v) - end) + end, conditionals, "buff") end, nobuff = function(conditionals) - return And(conditionals.nobuff, function(v) + return NegatedMulti(conditionals.nobuff, function(v) return not CleveRoids.ValidateUnitBuff(conditionals.target, v) - end) + end, conditionals, "nobuff") end, debuff = function(conditionals) - return And(conditionals.debuff, function(v) + return Multi(conditionals.debuff, function(v) return CleveRoids.ValidateUnitDebuff(conditionals.target, v) - end) + end, conditionals, "debuff") end, nodebuff = function(conditionals) - return And(conditionals.nodebuff, function(v) + 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) + end, conditionals, "nodebuff") end, mybuff = function(conditionals) - return And(conditionals.mybuff, function(v) + return Multi(conditionals.mybuff, function(v) return CleveRoids.ValidatePlayerBuff(v) - end) + end, conditionals, "mybuff") end, nomybuff = function(conditionals) - return And(conditionals.nomybuff, function(v) + return NegatedMulti(conditionals.nomybuff, function(v) return not CleveRoids.ValidatePlayerBuff(v) - end) + end, conditionals, "nomybuff") end, mydebuff = function(conditionals) - return And(conditionals.mydebuff, function(v) + return Multi(conditionals.mydebuff, function(v) return CleveRoids.ValidatePlayerDebuff(v) - end) + end, conditionals, "mydebuff") end, nomydebuff = function(conditionals) - return And(conditionals.nomydebuff, function(v) + return NegatedMulti(conditionals.nomydebuff, function(v) return not CleveRoids.ValidatePlayerDebuff(v) - end) + end, conditionals, "nomydebuff") end, power = function(conditionals) - return And(conditionals.power, function(args) + return Multi(conditionals.power, function(args) if type(args) ~= "table" then return false end - return CleveRoids.ValidatePower(conditionals.target, args.operator, args.amount) - end) + + -- Handle multi-comparison (e.g., >50&<80) + if args.comparisons and type(args.comparisons) == "table" then + local unit = conditionals.target or "target" + if not UnitExists(unit) then return false end + local powerPercent = 100 / UnitManaMax(unit) * UnitMana(unit) + + -- ALL comparisons must pass (AND logic) + for _, comp in ipairs(args.comparisons) do + if not CleveRoids.operators[comp.operator] then + return false + end + if not CleveRoids.comparators[comp.operator](powerPercent, comp.amount) then + return false + end + end + return true + end + + return CleveRoids.ValidatePower(conditionals.target or "target", args.operator, args.amount) + end, conditionals, "power") end, mypower = function(conditionals) - return And(conditionals.mypower, function(args) + return Multi(conditionals.mypower, function(args) if type(args) ~= "table" then return false end + + -- Handle multi-comparison (e.g., >50&<80) + if args.comparisons and type(args.comparisons) == "table" then + -- PERFORMANCE: Use cached player power + local powerPercent = CleveRoids.GetCachedPlayerPowerPercent() + + -- ALL comparisons must pass (AND logic) + for _, comp in ipairs(args.comparisons) do + if not CleveRoids.operators[comp.operator] then + return false + end + if not CleveRoids.comparators[comp.operator](powerPercent, comp.amount) then + return false + end + end + return true + end + return CleveRoids.ValidatePower("player", args.operator, args.amount) - end) + end, conditionals, "mypower") end, rawpower = function(conditionals) - return And(conditionals.rawpower, function(args) + return Multi(conditionals.rawpower, function(args) if type(args) ~= "table" then return false end - return CleveRoids.ValidateRawPower(conditionals.target, args.operator, args.amount) - end) + + -- Handle multi-comparison (e.g., >500&<1000) + if args.comparisons and type(args.comparisons) == "table" then + local unit = conditionals.target or "target" + if not UnitExists(unit) then return false end + local power = UnitMana(unit) + + -- ALL comparisons must pass (AND logic) + for _, comp in ipairs(args.comparisons) do + if not CleveRoids.operators[comp.operator] then + return false + end + if not CleveRoids.comparators[comp.operator](power, comp.amount) then + return false + end + end + return true + end + + return CleveRoids.ValidateRawPower(conditionals.target or "target", args.operator, args.amount) + end, conditionals, "rawpower") end, myrawpower = function(conditionals) - return And(conditionals.myrawpower, function(args) + return Multi(conditionals.myrawpower, function(args) if type(args) ~= "table" then return false end + + -- Handle multi-comparison (e.g., >500&<1000) + if args.comparisons and type(args.comparisons) == "table" then + -- PERFORMANCE: Use cached player power + local power = CleveRoids.GetCachedPlayerPower() + + -- ALL comparisons must pass (AND logic) + for _, comp in ipairs(args.comparisons) do + if not CleveRoids.operators[comp.operator] then + return false + end + if not CleveRoids.comparators[comp.operator](power, comp.amount) then + return false + end + end + return true + end + return CleveRoids.ValidateRawPower("player", args.operator, args.amount) - end) + end, conditionals, "myrawpower") end, druidmana = function(conditionals) - return And(conditionals.druidmana, function(args) + return Multi(conditionals.druidmana, function(args) if type(args) ~= "table" then return false end return CleveRoids.ValidateDruidRawMana("player", args.operator, args.amount) - end) + end, conditionals, "druidmana") end, powerlost = function(conditionals) - return And(conditionals.powerlost, function(args) + return Multi(conditionals.powerlost, function(args) if type(args) ~= "table" then return false end return CleveRoids.ValidatePowerLost(conditionals.target, args.operator, args.amount) - end) + end, conditionals, "powerlost") end, mypowerlost = function(conditionals) - return And(conditionals.mypowerlost, function(args) + return Multi(conditionals.mypowerlost, function(args) if type(args) ~= "table" then return false end return CleveRoids.ValidatePowerLost("player", args.operator, args.amount) - end) + end, conditionals, "mypowerlost") end, hp = function(conditionals) - return And(conditionals.hp, function(args) + return Multi(conditionals.hp, function(args) if type(args) ~= "table" then return false end - return CleveRoids.ValidateHp(conditionals.target, args.operator, args.amount) - end) + + -- Handle multi-comparison (e.g., >50&<80) + if args.comparisons and type(args.comparisons) == "table" then + local unit = conditionals.target or "target" + if not UnitExists(unit) then return false end + + -- PERFORMANCE: Use cached health for target + local hp + if unit == "target" then + hp = CleveRoids.GetCachedTargetHealthPercent() + else + local maxHp = UnitHealthMax(unit) + hp = maxHp > 0 and (100 * UnitHealth(unit) / maxHp) or 0 + end + + -- ALL comparisons must pass (AND logic) + for _, comp in ipairs(args.comparisons) do + if not CleveRoids.operators[comp.operator] then + return false + end + if not CleveRoids.comparators[comp.operator](hp, comp.amount) then + return false + end + end + return true + end + + return CleveRoids.ValidateHp(conditionals.target or "target", args.operator, args.amount) + end, conditionals, "hp") + end, + + level = function(conditionals) + return Multi(conditionals.level, function(args) + if type(args) ~= "table" then return false end + + -- Handle multi-comparison (e.g., >50&<60) + if args.comparisons and type(args.comparisons) == "table" then + local unit = conditionals.target or "target" + if not UnitExists(unit) then return false end + local level = UnitLevel(unit) + + -- Treat skull/boss mobs (??) as level 63 + if level == -1 then + level = 63 + end + + -- ALL comparisons must pass (AND logic) + for _, comp in ipairs(args.comparisons) do + if not CleveRoids.operators[comp.operator] then + return false + end + if not CleveRoids.comparators[comp.operator](level, comp.amount) then + return false + end + end + return true + end + + return CleveRoids.ValidateLevel(conditionals.target or "target", args.operator, args.amount) + end, conditionals, "level") + end, + + mylevel = function(conditionals) + return Multi(conditionals.mylevel, function(args) + if type(args) ~= "table" then return false end + + -- Handle multi-comparison (e.g., >50&<60) + if args.comparisons and type(args.comparisons) == "table" then + local level = UnitLevel("player") + + -- ALL comparisons must pass (AND logic) + for _, comp in ipairs(args.comparisons) do + if not CleveRoids.operators[comp.operator] then + return false + end + if not CleveRoids.comparators[comp.operator](level, comp.amount) then + return false + end + end + return true + end + + return CleveRoids.ValidateLevel("player", args.operator, args.amount) + end, conditionals, "mylevel") end, myhp = function(conditionals) - return And(conditionals.myhp, function(args) + return Multi(conditionals.myhp, function(args) if type(args) ~= "table" then return false end + + -- Handle multi-comparison (e.g., >50&<80) + if args.comparisons and type(args.comparisons) == "table" then + -- PERFORMANCE: Use cached player health + local hp = CleveRoids.GetCachedPlayerHealthPercent() + + -- ALL comparisons must pass (AND logic) + for _, comp in ipairs(args.comparisons) do + if not CleveRoids.operators[comp.operator] then + return false + end + if not CleveRoids.comparators[comp.operator](hp, comp.amount) then + return false + end + end + return true + end + return CleveRoids.ValidateHp("player", args.operator, args.amount) - end) + end, conditionals, "myhp") end, rawhp = function(conditionals) - return And(conditionals.rawhp, function(args) + return Multi(conditionals.rawhp, function(args) if type(args) ~= "table" then return false end - return CleveRoids.ValidateRawHp(conditionals.target, args.operator, args.amount) - end) + return CleveRoids.ValidateRawHp(conditionals.target or "target", args.operator, args.amount) + end, conditionals, "rawhp") end, myrawhp = function(conditionals) - return And(conditionals.myrawhp, function(args) + return Multi(conditionals.myrawhp, function(args) if type(args) ~= "table" then return false end return CleveRoids.ValidateRawHp("player", args.operator, args.amount) - end) + end, conditionals, "myrawhp") end, hplost = function(conditionals) - return And(conditionals.hplost, function(args) + return Multi(conditionals.hplost, function(args) if type(args) ~= "table" then return false end return CleveRoids.ValidateHpLost(conditionals.target, args.operator, args.amount) - end) + end, conditionals, "hplost") end, myhplost = function(conditionals) - return And(conditionals.myhplost, function(args) + return Multi(conditionals.myhplost, function(args) if type(args) ~= "table" then return false end return CleveRoids.ValidateHpLost("player", args.operator, args.amount) - end) + end, conditionals, "myhplost") end, type = function(conditionals) @@ -1596,145 +5230,504 @@ CleveRoids.Keywords = { end, notype = function(conditionals) - return And(conditionals.notype, function(unittype) + return NegatedMulti(conditionals.notype, function(unittype) return not CleveRoids.ValidateCreatureType(unittype, conditionals.target) - end) + end, conditionals, "notype") end, cooldown = function(conditionals) - return And(conditionals.cooldown,function (v) + return Multi(conditionals.cooldown,function (v) return CleveRoids.ValidateCooldown(v, true) - end) + end, conditionals, "cooldown") end, nocooldown = function(conditionals) - return And(conditionals.nocooldown,function (v) + return NegatedMulti(conditionals.nocooldown,function (v) return not CleveRoids.ValidateCooldown(v, true) - end) + end, conditionals, "nocooldown") end, cdgcd = function(conditionals) - return And(conditionals.cdgcd,function (v) + return Multi(conditionals.cdgcd,function (v) return CleveRoids.ValidateCooldown(v, false) - end) + end, conditionals, "cdgcd") end, nocdgcd = function(conditionals) - return And(conditionals.nocdgcd,function (v) + return NegatedMulti(conditionals.nocdgcd,function (v) return not CleveRoids.ValidateCooldown(v, false) - end) + end, conditionals, "nocdgcd") + end, + + -- GCD conditional - check if GCD is active/remaining + -- Usage: [gcd] - true if GCD is active + -- [gcd:<1] - true if GCD has less than 1 second remaining + -- [nogcd] - true if GCD is not active + gcd = function(conditionals) + return Multi(conditionals.gcd, function(args) + -- Get GCD remaining in seconds + local gcdRemaining = 0 + + -- Try Nampower API first (most accurate) + if CleveRoids.NampowerAPI and CleveRoids.NampowerAPI.GetGCDRemainingMs then + local gcdMs = CleveRoids.NampowerAPI.GetGCDRemainingMs() + if gcdMs and gcdMs > 0 then + gcdRemaining = gcdMs / 1000 + end + else + -- Fallback: check the first spell in spellbook for GCD + -- GCD shows as cooldown <= 1.5s on any GCD-triggering spell + for i = 1, 200 do + local spellName = GetSpellName(i, BOOKTYPE_SPELL) + if not spellName then break end + local start, duration = GetSpellCooldown(i, BOOKTYPE_SPELL) + if start and duration and duration > 0 and duration <= 1.5 then + gcdRemaining = (start + duration) - GetTime() + if gcdRemaining < 0 then gcdRemaining = 0 end + break + end + end + end + + -- No args or just [gcd] - check if GCD is active + if not args or args == "" or (type(args) == "table" and not args.operator) then + return gcdRemaining > 0 + end + + -- With operator: [gcd:<1] etc + if type(args) == "table" and args.operator and args.amount then + if CleveRoids.operators[args.operator] then + return CleveRoids.comparators[args.operator](gcdRemaining, args.amount) + end + end + + return gcdRemaining > 0 + end, conditionals, "gcd") + end, + + nogcd = function(conditionals) + return NegatedMulti(conditionals.nogcd, function(args) + -- Get GCD remaining in seconds + local gcdRemaining = 0 + + -- Try Nampower API first (most accurate) + if CleveRoids.NampowerAPI and CleveRoids.NampowerAPI.GetGCDRemainingMs then + local gcdMs = CleveRoids.NampowerAPI.GetGCDRemainingMs() + if gcdMs and gcdMs > 0 then + gcdRemaining = gcdMs / 1000 + end + else + -- Fallback: check the first spell in spellbook for GCD + for i = 1, 200 do + local spellName = GetSpellName(i, BOOKTYPE_SPELL) + if not spellName then break end + local start, duration = GetSpellCooldown(i, BOOKTYPE_SPELL) + if start and duration and duration > 0 and duration <= 1.5 then + gcdRemaining = (start + duration) - GetTime() + if gcdRemaining < 0 then gcdRemaining = 0 end + break + end + end + end + + -- [nogcd] - true if GCD is NOT active + if not args or args == "" or (type(args) == "table" and not args.operator) then + return gcdRemaining <= 0 + end + + -- With operator: [nogcd:<1] means NOT (gcd < 1), i.e., gcd >= 1 + if type(args) == "table" and args.operator and args.amount then + if CleveRoids.operators[args.operator] then + return not CleveRoids.comparators[args.operator](gcdRemaining, args.amount) + end + end + + return gcdRemaining <= 0 + end, conditionals, "nogcd") end, channeled = function(conditionals) - if GetCurrentCastingInfo then - local _, _, _, _, channeling = GetCurrentCastingInfo() - return channeling == 1 + -- Use time-based prediction for accuracy + if CleveRoids.CurrentSpell.type == "channeled" and CleveRoids.channelStartTime and CleveRoids.channelDuration then + local remaining = CleveRoids.channelDuration - (GetTime() - CleveRoids.channelStartTime) + if remaining <= 0.1 then + return false -- Channel is done + end end return CleveRoids.CurrentSpell.type == "channeled" end, nochanneled = function(conditionals) - if GetCurrentCastingInfo then - local _, _, _, _, channeling = GetCurrentCastingInfo() - return channeling ~= 1 + -- Use time-based prediction for accuracy + if CleveRoids.CurrentSpell.type == "channeled" and CleveRoids.channelStartTime and CleveRoids.channelDuration then + local remaining = CleveRoids.channelDuration - (GetTime() - CleveRoids.channelStartTime) + if remaining <= 0.1 then + return true -- Channel is done + end end return CleveRoids.CurrentSpell.type ~= "channeled" end, + channeltime = function(conditionals) + -- Calculate remaining time (0 if not channeling) + local timeLeft = 0 + + if CleveRoids.CurrentSpell.type == "channeled" and CleveRoids.channelStartTime and CleveRoids.channelDuration then + local elapsed = GetTime() - CleveRoids.channelStartTime + timeLeft = CleveRoids.channelDuration - elapsed + -- Don't allow negative time + if timeLeft < 0 then timeLeft = 0 end + end + + local check = conditionals.channeltime + + -- channeltime is stored as an array by the parser, get the first element + if type(check) == "table" and type(check[1]) == "table" then + check = check[1] + end + + if type(check) == "table" and check.operator and check.amount then + -- Now compare: if not channeling, timeLeft is 0, so [channeltime:<0.5] returns true + return CleveRoids.comparators[check.operator](timeLeft, check.amount) + end + + return false + end, + + -- [nochanneltime] - Negated channel time comparison + -- Usage: [nochanneltime:<0.5] = true if NOT (channeltime < 0.5), i.e., >= 0.5 seconds remaining + nochanneltime = function(conditionals) + -- Calculate remaining time (0 if not channeling) + local timeLeft = 0 + + if CleveRoids.CurrentSpell.type == "channeled" and CleveRoids.channelStartTime and CleveRoids.channelDuration then + local elapsed = GetTime() - CleveRoids.channelStartTime + timeLeft = CleveRoids.channelDuration - elapsed + if timeLeft < 0 then timeLeft = 0 end + end + + local check = conditionals.nochanneltime + + if type(check) == "table" and type(check[1]) == "table" then + check = check[1] + end + + if type(check) == "table" and check.operator and check.amount then + return not CleveRoids.comparators[check.operator](timeLeft, check.amount) + end + + return true + end, + + casttime = function(conditionals) + -- Calculate remaining time (0 if not casting) + local timeLeft = 0 + + if CleveRoids.CurrentSpell.type == "cast" and CleveRoids.castStartTime and CleveRoids.castDuration then + local elapsed = GetTime() - CleveRoids.castStartTime + timeLeft = CleveRoids.castDuration - elapsed + -- Don't allow negative time + if timeLeft < 0 then timeLeft = 0 end + end + + local check = conditionals.casttime + + -- casttime is stored as an array by the parser, get the first element + if type(check) == "table" and type(check[1]) == "table" then + check = check[1] + end + + if type(check) == "table" and check.operator and check.amount then + -- Now compare: if not casting, timeLeft is 0, so [casttime:<0.5] returns true + return CleveRoids.comparators[check.operator](timeLeft, check.amount) + end + + return false + end, + + -- [nocasttime] - Negated cast time comparison + -- Usage: [nocasttime:<0.5] = true if NOT (casttime < 0.5), i.e., >= 0.5 seconds remaining + nocasttime = function(conditionals) + -- Calculate remaining time (0 if not casting) + local timeLeft = 0 + + if CleveRoids.CurrentSpell.type == "cast" and CleveRoids.castStartTime and CleveRoids.castDuration then + local elapsed = GetTime() - CleveRoids.castStartTime + timeLeft = CleveRoids.castDuration - elapsed + if timeLeft < 0 then timeLeft = 0 end + end + + local check = conditionals.nocasttime + + if type(check) == "table" and type(check[1]) == "table" then + check = check[1] + end + + if type(check) == "table" and check.operator and check.amount then + return not CleveRoids.comparators[check.operator](timeLeft, check.amount) + end + + return true + end, + + -- [spellcasttime] - Check a spell's total cast time from tooltip (includes haste/talents) + -- Usage: [spellcasttime:>2] - action's spell has cast time > 2 seconds + -- [spellcasttime:Frostbolt>2] - specific spell has cast time > 2 seconds + -- [spellcasttime:=0] - spell is instant cast + spellcasttime = function(conditionals) + local check = conditionals.spellcasttime + + -- Handle array format from parser + if type(check) == "table" and type(check[1]) == "table" then + check = check[1] + end + + if type(check) == "table" and check.operator and check.amount then + -- Use spell name from conditional, or fall back to action's spell + local spellName = check.name + if not spellName or spellName == "" then + spellName = conditionals.action + end + + return CleveRoids.ValidateSpellCastTime(spellName, check.operator, check.amount) + end + + return false + end, + + -- [nospellcasttime] - Negated spell cast time check + -- Usage: [nospellcasttime:>2] = true if NOT (cast time > 2), i.e., <= 2 seconds + nospellcasttime = function(conditionals) + local check = conditionals.nospellcasttime + + -- Handle array format from parser + if type(check) == "table" and type(check[1]) == "table" then + check = check[1] + end + + if type(check) == "table" and check.operator and check.amount then + local spellName = check.name + if not spellName or spellName == "" then + spellName = conditionals.action + end + + return not CleveRoids.ValidateSpellCastTime(spellName, check.operator, check.amount) + end + + return true + end, + + -- [targeting:unit] - Target is targeting specified unit + -- [targeting:tank] - Target is targeting ANY player marked as tank in pfUI targeting = function(conditionals) - return Or(conditionals.targeting, function (unit) - return (UnitIsUnit("targettarget", unit) == 1) + local target = conditionals.target or "target" + + -- Handle single "tank" keyword directly (most common case) + local val = conditionals.targeting + if val == "tank" or (type(val) == "table" and val[1] == "tank" and not val[2]) then + return CleveRoids.IsTargetingAnyTank(target) + end + + return Or(val, function (unit) + if unit == "tank" then + return CleveRoids.IsTargetingAnyTank(target) + end + return (UnitIsUnit(target .. "target", unit) == 1) end) end, + -- [notargeting:unit] - Target is NOT targeting specified unit + -- [notargeting:tank] - Target is NOT targeting ANY tank (loose mob!) notargeting = function(conditionals) - return And(conditionals.notargeting, function (unit) - return UnitIsUnit("targettarget", unit) ~= 1 - end) + local target = conditionals.target or "target" + + -- Handle single "tank" keyword directly (most common case) + local val = conditionals.notargeting + if val == "tank" or (type(val) == "table" and val[1] == "tank" and not val[2]) then + return not CleveRoids.IsTargetingAnyTank(target) + end + + return NegatedMulti(val, function (unit) + if unit == "tank" then + return not CleveRoids.IsTargetingAnyTank(target) + end + return UnitIsUnit(target .. "target", unit) ~= 1 + end, conditionals, "notargeting") end, isplayer = function(conditionals) return UnitIsPlayer(conditionals.target) end, + -- [noisplayer] - Target is NOT a player (same as isnpc) + noisplayer = function(conditionals) + return not UnitIsPlayer(conditionals.target) + end, + isnpc = function(conditionals) return not UnitIsPlayer(conditionals.target) end, + -- [noisnpc] - Target is NOT an NPC (same as isplayer) + noisnpc = function(conditionals) + return UnitIsPlayer(conditionals.target) + end, + + -- [istank] - Target unit is marked as tank in pfUI + -- [istank:unit] - Specified unit is marked as tank + istank = function(conditionals) + if conditionals.istank and type(conditionals.istank) == "table" then + -- Check specific unit: [istank:focus] + return Or(conditionals.istank, function(unit) + local name = UnitName(unit) + return CleveRoids.IsPlayerTank(name) + end) + end + -- Check target: [istank] + local target = conditionals.target or "target" + local name = UnitName(target) + return CleveRoids.IsPlayerTank(name) + end, + + -- [noistank] - Target unit is NOT marked as tank in pfUI + noistank = function(conditionals) + if conditionals.noistank and type(conditionals.noistank) == "table" then + -- Check specific unit: [noistank:focus] + return NegatedMulti(conditionals.noistank, function(unit) + return not CleveRoids.IsPlayerTank(UnitName(unit)) + end, conditionals, "noistank") + end + -- Check target: [noistank] + local target = conditionals.target or "target" + local name = UnitName(target) + return not CleveRoids.IsPlayerTank(name) + end, + + -- [inrange] or [inrange:Spell] - Target is in range of spell + -- [inrange:Spell>N] - More than N enemies are in range of spell (count mode) inrange = function(conditionals) if not IsSpellInRange then return end - return And(conditionals.inrange, function(spellName) - local target = conditionals.target or "target" - local checkValue = spellName or conditionals.action - - -- Try to convert spell name to ID for better accuracy (Nampower) - if type(checkValue) == "string" and GetSpellIdForName then - local spellId = GetSpellIdForName(checkValue) - if spellId and spellId > 0 then - checkValue = spellId - end + local API = CleveRoids.NampowerAPI + return Multi(conditionals.inrange, function(args) + -- Check for count mode: [inrange:Multi-Shot>1] + if type(args) == "table" and args.operator and args.amount then + local spellName = args.name or conditionals.action + local count = CleveRoids.CountEnemiesMatching(function(unit) + local result = API.IsSpellInRange(spellName, unit) + return result == 1 + end) + return CleveRoids.comparators[args.operator](count, args.amount) end - return IsSpellInRange(checkValue, target) == 1 - end) + -- Original single-target behavior + local target = conditionals.target or "target" + local checkValue = (type(args) == "string" and args) or conditionals.action + + -- Use API wrapper which handles self-cast spells correctly + local result = API.IsSpellInRange(checkValue, target) + return result == 1 + end, conditionals, "inrange") end, + -- [noinrange] or [noinrange:Spell] - Target is NOT in range of spell + -- [noinrange:Spell>N] - More than N enemies are NOT in range of spell (count mode) noinrange = function(conditionals) if not IsSpellInRange then return end - return And(conditionals.noinrange, function(spellName) - local target = conditionals.target or "target" - local checkValue = spellName or conditionals.action - - if type(checkValue) == "string" and GetSpellIdForName then - local spellId = GetSpellIdForName(checkValue) - if spellId and spellId > 0 then - checkValue = spellId - end + local API = CleveRoids.NampowerAPI + return NegatedMulti(conditionals.noinrange, function(args) + -- Check for count mode: [noinrange:Multi-Shot>1] + if type(args) == "table" and args.operator and args.amount then + local spellName = args.name or conditionals.action + local count = CleveRoids.CountEnemiesMatching(function(unit) + local result = API.IsSpellInRange(spellName, unit) + return result == 0 + end) + return CleveRoids.comparators[args.operator](count, args.amount) end - return IsSpellInRange(checkValue, target) == 0 - end) + -- Original single-target behavior + local target = conditionals.target or "target" + local checkValue = (type(args) == "string" and args) or conditionals.action + + -- Use API wrapper which handles self-cast spells correctly + local result = API.IsSpellInRange(checkValue, target) + return result == 0 + end, conditionals, "noinrange") end, + -- [outrange] or [outrange:Spell] - Target is out of range of spell + -- [outrange:Spell>N] - More than N enemies are out of range of spell (count mode) outrange = function(conditionals) if not IsSpellInRange then return end - return And(conditionals.outrange, function(spellName) - local target = conditionals.target or "target" - local checkValue = spellName or conditionals.action - - if type(checkValue) == "string" and GetSpellIdForName then - local spellId = GetSpellIdForName(checkValue) - if spellId and spellId > 0 then - checkValue = spellId - end + local API = CleveRoids.NampowerAPI + return Multi(conditionals.outrange, function(args) + -- Check for count mode: [outrange:Multi-Shot>1] + if type(args) == "table" and args.operator and args.amount then + local spellName = args.name or conditionals.action + local count = CleveRoids.CountEnemiesMatching(function(unit) + local result = API.IsSpellInRange(spellName, unit) + return result == 0 + end) + return CleveRoids.comparators[args.operator](count, args.amount) end - return IsSpellInRange(checkValue, target) == 0 - end) + -- Original single-target behavior + local target = conditionals.target or "target" + local checkValue = (type(args) == "string" and args) or conditionals.action + + -- Use API wrapper which handles self-cast spells correctly + local result = API.IsSpellInRange(checkValue, target) + return result == 0 + end, conditionals, "outrange") + end, + + -- [nooutrange] or [nooutrange:Spell] - Target is NOT out of range (same as inrange) + -- [nooutrange:Spell>N] - More than N enemies are NOT out of range (count mode) + nooutrange = function(conditionals) + if not IsSpellInRange then return end + local API = CleveRoids.NampowerAPI + return NegatedMulti(conditionals.nooutrange, function(args) + -- Check for count mode: [nooutrange:Multi-Shot>1] + if type(args) == "table" and args.operator and args.amount then + local spellName = args.name or conditionals.action + local count = CleveRoids.CountEnemiesMatching(function(unit) + local result = API.IsSpellInRange(spellName, unit) + return result == 1 + end) + return CleveRoids.comparators[args.operator](count, args.amount) + end + + -- Original single-target behavior + local target = conditionals.target or "target" + local checkValue = (type(args) == "string" and args) or conditionals.action + + local result = API.IsSpellInRange(checkValue, target) + return result == 1 -- In range = nooutrange passes + end, conditionals, "nooutrange") end, combo = function(conditionals) - return And(conditionals.combo, function(args) + return Multi(conditionals.combo, function(args) return CleveRoids.ValidateComboPoints(args.operator, args.amount) - end) + end, conditionals, "combo") end, nocombo = function(conditionals) - return And(conditionals.nocombo, function(args) + return NegatedMulti(conditionals.nocombo, function(args) return not CleveRoids.ValidateComboPoints(args.operator, args.amount) - end) + end, conditionals, "nocombo") end, known = function(conditionals) - return And(conditionals.known, function(args) + return Multi(conditionals.known, function(args) return CleveRoids.ValidateKnown(args) - end) + end, conditionals, "known") end, noknown = function(conditionals) - return And(conditionals.noknown, function(args) + return NegatedMulti(conditionals.noknown, function(args) return not CleveRoids.ValidateKnown(args) - end) + end, conditionals, "noknown") end, resting = function() @@ -1746,7 +5739,7 @@ CleveRoids.Keywords = { end, stat = function(conditionals) - return And(conditionals.stat, function(args) + return Multi(conditionals.stat, function(args) if type(args) ~= "table" or not args.name then return false -- Malformed arguments from the parser. end @@ -1781,7 +5774,45 @@ CleveRoids.Keywords = { end return CleveRoids.comparators[args.operator](current_value, args.amount) end - end) + end, conditionals, "stat") + end, + + -- [nostat] - Negated stat comparison + -- Usage: [nostat:agi>100] = true if NOT (agi > 100), i.e., agi <= 100 + nostat = function(conditionals) + return NegatedMulti(conditionals.nostat, function(args) + if type(args) ~= "table" or not args.name then + return true -- Malformed = negation passes + end + + local stat_key = string.lower(args.name) + local get_stat_func = stat_checks[stat_key] + + if not get_stat_func then + return true -- Invalid stat = negation passes + end + + local current_value = get_stat_func() + if not current_value then return true end + + -- Handle multi-comparison + if args.comparisons and type(args.comparisons) == "table" then + for _, comp in ipairs(args.comparisons) do + if not CleveRoids.comparators[comp.operator] then + return true + end + if not CleveRoids.comparators[comp.operator](current_value, comp.amount) then + return true -- One failed = negation passes + end + end + return false -- All passed = negation fails + else + if not args.operator or not args.amount then + return true + end + return not CleveRoids.comparators[args.operator](current_value, args.amount) + end + end, conditionals, "nostat") end, class = function(conditionals) @@ -1822,10 +5853,10 @@ CleveRoids.Keywords = { -- A player should always have a class, but if not, this condition is still met. if not localizedClass then return true end - -- The "And" helper ensures the player's class is not any of the forbidden classes. - return And(conditionals.noclass, function(forbiddenClass) + -- The "NegatedMulti" helper ensures the player's class is not any of the forbidden classes. + return NegatedMulti(conditionals.noclass, function(forbiddenClass) return string.lower(forbiddenClass) ~= string.lower(localizedClass) and string.lower(forbiddenClass) ~= string.lower(englishClass) - end) + end, conditionals, "noclass") end, pet = function(conditionals) @@ -1847,13 +5878,13 @@ CleveRoids.Keywords = { return true end - return And(conditionals.nopet, function(petType) + return NegatedMulti(conditionals.nopet, function(petType) local currentPet = UnitCreatureFamily("pet") if not currentPet then return true end return string.lower(currentPet) ~= string.lower(petType) - end) + end, conditionals, "nopet") end, swimming = function(conditionals) @@ -1869,7 +5900,7 @@ CleveRoids.Keywords = { distance = function(conditionals) if not CleveRoids.hasUnitXP then return false end - return And(conditionals.distance, function(args) + return Multi(conditionals.distance, function(args) if type(args) ~= "table" or not args.operator or not args.amount then return false end @@ -1881,13 +5912,13 @@ CleveRoids.Keywords = { if not distance then return false end return CleveRoids.comparators[args.operator](distance, args.amount) - end) + end, conditionals, "distance") end, nodistance = function(conditionals) if not CleveRoids.hasUnitXP then return false end - return And(conditionals.nodistance, function(args) + return NegatedMulti(conditionals.nodistance, function(args) if type(args) ~= "table" or not args.operator or not args.amount then return false end @@ -1899,46 +5930,111 @@ CleveRoids.Keywords = { if not distance then return false end return not CleveRoids.comparators[args.operator](distance, args.amount) - end) + end, conditionals, "nodistance") end, + -- [behind] - Player is behind target + -- [behind:>N] - Player is behind more than N enemies (count mode) behind = function(conditionals) if not CleveRoids.hasUnitXP then return false end + -- Check for count mode: [behind:>1] + local countArgs = CleveRoids.GetCountModeArgs(conditionals.behind) + if countArgs then + local count = CleveRoids.CountEnemiesMatching(function(unit) + return UnitXP("behind", "player", unit) == true + end) + return CleveRoids.comparators[countArgs.operator](count, countArgs.amount) + end + + -- Original single-target behavior local unit = conditionals.target or "target" if not UnitExists(unit) then return false end return UnitXP("behind", "player", unit) == true end, + -- [nobehind] - Player is NOT behind target + -- [nobehind:>N] - Player is NOT behind more than N enemies (count mode) nobehind = function(conditionals) if not CleveRoids.hasUnitXP then return false end + -- Check for count mode: [nobehind:>1] + local countArgs = CleveRoids.GetCountModeArgs(conditionals.nobehind) + if countArgs then + local count = CleveRoids.CountEnemiesMatching(function(unit) + return UnitXP("behind", "player", unit) ~= true + end) + return CleveRoids.comparators[countArgs.operator](count, countArgs.amount) + end + + -- Original single-target behavior local unit = conditionals.target or "target" if not UnitExists(unit) then return false end return UnitXP("behind", "player", unit) ~= true end, + -- [insight] - Target is in line of sight + -- [insight:>N] - More than N enemies are in line of sight (count mode) insight = function(conditionals) if not CleveRoids.hasUnitXP then return false end + -- Check for count mode: [insight:>1] + local countArgs = CleveRoids.GetCountModeArgs(conditionals.insight) + if countArgs then + local count = CleveRoids.CountEnemiesMatching(function(unit) + return UnitXP("inSight", "player", unit) == true + end) + return CleveRoids.comparators[countArgs.operator](count, countArgs.amount) + end + + -- Original single-target behavior local unit = conditionals.target or "target" if not UnitExists(unit) then return false end return UnitXP("inSight", "player", unit) == true end, + -- [noinsight] - Target is NOT in line of sight + -- [noinsight:>N] - More than N enemies are NOT in line of sight (count mode) noinsight = function(conditionals) if not CleveRoids.hasUnitXP then return false end + -- Check for count mode: [noinsight:>1] + local countArgs = CleveRoids.GetCountModeArgs(conditionals.noinsight) + if countArgs then + local count = CleveRoids.CountEnemiesMatching(function(unit) + return UnitXP("inSight", "player", unit) ~= true + end) + return CleveRoids.comparators[countArgs.operator](count, countArgs.amount) + end + + -- Original single-target behavior local unit = conditionals.target or "target" if not UnitExists(unit) then return false end return UnitXP("inSight", "player", unit) ~= true end, + -- [meleerange] - Target is in melee range + -- [meleerange:>N] - More than N enemies are in melee range (count mode) meleerange = function(conditionals) + -- Check for count mode: [meleerange:>1] + local countArgs = CleveRoids.GetCountModeArgs(conditionals.meleerange) + if countArgs then + local count = CleveRoids.CountEnemiesMatching(function(unit) + if CleveRoids.hasUnitXP then + local distance = UnitXP("distanceBetween", "player", unit, "meleeAutoAttack") + return distance and distance <= 5 + else + return CheckInteractDistance(unit, 3) + end + end) + return CleveRoids.comparators[countArgs.operator](count, countArgs.amount) + end + + -- Original single-target behavior local unit = conditionals.target or "target" if not UnitExists(unit) then return false end @@ -1951,7 +6047,24 @@ CleveRoids.Keywords = { end end, + -- [nomeleerange] - Target is NOT in melee range + -- [nomeleerange:>N] - More than N enemies are NOT in melee range (count mode) nomeleerange = function(conditionals) + -- Check for count mode: [nomeleerange:>1] + local countArgs = CleveRoids.GetCountModeArgs(conditionals.nomeleerange) + if countArgs then + local count = CleveRoids.CountEnemiesMatching(function(unit) + if CleveRoids.hasUnitXP then + local distance = UnitXP("distanceBetween", "player", unit, "meleeAutoAttack") + return not distance or distance > 5 + else + return not CheckInteractDistance(unit, 3) + end + end) + return CleveRoids.comparators[countArgs.operator](count, countArgs.amount) + end + + -- Original single-target behavior local unit = conditionals.target or "target" if not UnitExists(unit) then return true end @@ -1994,11 +6107,11 @@ CleveRoids.Keywords = { return true end - return And(conditionals.noqueuedspell, function(spellName) + return NegatedMulti(conditionals.noqueuedspell, function(spellName) local queuedName = string.gsub(CleveRoids.queuedSpell.spellName, "%s*%(.-%)%s*$", "") local checkName = string.gsub(spellName, "%s*%(.-%)%s*$", "") return string.lower(queuedName) ~= string.lower(checkName) - end) + end, conditionals, "noqueuedspell") end, onswingpending = function(conditionals) @@ -2016,22 +6129,1371 @@ CleveRoids.Keywords = { end, mybuffcount = function(conditionals) - return And(conditionals.mybuffcount,function (v) return CleveRoids.ValidatePlayerAuraCount(v.bigger, v.amount) end) + return Multi(conditionals.mybuffcount,function (v) return CleveRoids.ValidatePlayerAuraCount(v.bigger, v.amount) end, conditionals, "mybuffcount") end, + -- [nomybuffcount] - Negated buff count comparison + -- Usage: [nomybuffcount:>15] = true if NOT (buffcount > 15), i.e., buffcount <= 15 + nomybuffcount = function(conditionals) + return NegatedMulti(conditionals.nomybuffcount, function(v) + return not CleveRoids.ValidatePlayerAuraCount(v.bigger, v.amount) + end, conditionals, "nomybuffcount") + end, + + -- [mhimbue] - Check main hand has temporary imbue (poison, oil, sharpening stone, etc.) + -- [mhimbue:Name] - Check for specific imbue name + -- [mhimbue:<300] - Check imbue time remaining < 300 seconds + -- [mhimbue:Name<300] - Specific imbue with time check + -- [mhimbue:>#5] - Check imbue charges > 5 mhimbue = function(conditionals) - return CleveRoids.ValidateWeaponImbue("mh", conditionals.value) + local args = CleveRoids.ParseImbueArgs(conditionals.mhimbue, conditionals) + return CleveRoids.ValidateWeaponImbue("mh", args) end, nomhimbue = function(conditionals) - return not CleveRoids.ValidateWeaponImbue("mh", conditionals.value) + local args = CleveRoids.ParseImbueArgs(conditionals.nomhimbue, conditionals) + return not CleveRoids.ValidateWeaponImbue("mh", args) end, + -- [ohimbue] - Check off hand has temporary imbue + -- Same syntax as mhimbue ohimbue = function(conditionals) - return CleveRoids.ValidateWeaponImbue("oh", conditionals.value) + local args = CleveRoids.ParseImbueArgs(conditionals.ohimbue, conditionals) + return CleveRoids.ValidateWeaponImbue("oh", args) end, noohimbue = function(conditionals) - return not CleveRoids.ValidateWeaponImbue("oh", conditionals.value) + local args = CleveRoids.ParseImbueArgs(conditionals.noohimbue, conditionals) + return not CleveRoids.ValidateWeaponImbue("oh", args) + end, + + immune = function(conditionals) + -- Check if target is immune to the spell being cast or damage school + -- Usage: [immune] SpellName OR [immune:SpellName] OR [immune:fire] + local checkValue = nil + + -- Case 1: [immune:SpellName] or [immune:fire] + if type(conditionals.immune) == "table" and table.getn(conditionals.immune) > 0 then + checkValue = conditionals.immune[1] + elseif type(conditionals.immune) == "string" then + checkValue = conditionals.immune + -- Case 2: [immune] SpellName (check the action being cast) + elseif conditionals.action then + checkValue = conditionals.action + end + + if not checkValue then + return false + end + + return CleveRoids.CheckImmunity(conditionals.target or "target", checkValue) + end, + + noimmune = function(conditionals) + -- Check if target is NOT immune to the spell being cast or damage school + -- Usage: [noimmune] SpellName OR [noimmune:SpellName] OR [noimmune:fire] + local checkValue = nil + + -- Case 1: [noimmune:SpellName] or [noimmune:fire] + if type(conditionals.noimmune) == "table" and table.getn(conditionals.noimmune) > 0 then + checkValue = conditionals.noimmune[1] + elseif type(conditionals.noimmune) == "string" then + checkValue = conditionals.noimmune + -- Case 2: [noimmune] SpellName (check the action being cast) + elseif conditionals.action then + checkValue = conditionals.action + end + + if not checkValue then + return true -- If we can't determine spell/school, assume not immune + end + + local isImmune = CleveRoids.CheckImmunity(conditionals.target or "target", checkValue) + return not isImmune + end, + + -- SP_SwingTimer integration conditionals + -- Checks percentage of swing time that has elapsed + -- Usage: [swingtimer:<15] = less than 15% of swing has elapsed (early in swing) + -- [swingtimer:>80] = more than 80% of swing has elapsed (late in swing) + swingtimer = function(conditionals) + return Multi(conditionals.swingtimer, function(args) + if type(args) ~= "table" then return false end + + -- Handle multi-comparison (e.g., >50&<80) + if args.comparisons and type(args.comparisons) == "table" then + -- Check if SP_SwingTimer is loaded + if st_timer == nil then + if not CleveRoids._swingTimerErrorShown then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[SuperCleveRoidMacros]|r The [swingtimer] conditional requires the SP_SwingTimer addon. Get it at: https://github.com/jrc13245/SP_SwingTimer", 1, 0.5, 0.5) + CleveRoids._swingTimerErrorShown = true + end + return false + end + + local attackSpeed = UnitAttackSpeed("player") + if not attackSpeed or attackSpeed <= 0 then return false end + + local timeElapsed = attackSpeed - st_timer + local percentElapsed = (timeElapsed / attackSpeed) * 100 + + -- ALL comparisons must pass (AND logic) + for _, comp in ipairs(args.comparisons) do + if not CleveRoids.operators[comp.operator] then + return false + end + if not CleveRoids.comparators[comp.operator](percentElapsed, comp.amount) then + return false + end + end + return true + end + + return CleveRoids.ValidateSwingTimer(args.operator, args.amount) + end, conditionals, "swingtimer") + end, + + -- Alias for swingtimer + stimer = function(conditionals) + return Multi(conditionals.stimer, function(args) + if type(args) ~= "table" then return false end + + -- Handle multi-comparison (e.g., >50&<80) + if args.comparisons and type(args.comparisons) == "table" then + -- Check if SP_SwingTimer is loaded + if st_timer == nil then + if not CleveRoids._swingTimerErrorShown then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[SuperCleveRoidMacros]|r The [swingtimer] conditional requires the SP_SwingTimer addon. Get it at: https://github.com/jrc13245/SP_SwingTimer", 1, 0.5, 0.5) + CleveRoids._swingTimerErrorShown = true + end + return false + end + + local attackSpeed = UnitAttackSpeed("player") + if not attackSpeed or attackSpeed <= 0 then return false end + + local timeElapsed = attackSpeed - st_timer + local percentElapsed = (timeElapsed / attackSpeed) * 100 + + -- ALL comparisons must pass (AND logic) + for _, comp in ipairs(args.comparisons) do + if not CleveRoids.operators[comp.operator] then + return false + end + if not CleveRoids.comparators[comp.operator](percentElapsed, comp.amount) then + return false + end + end + return true + end + + return CleveRoids.ValidateSwingTimer(args.operator, args.amount) + end, conditionals, "stimer") + end, + + -- Negated swingtimer + noswingtimer = function(conditionals) + return NegatedMulti(conditionals.noswingtimer, function(args) + if type(args) ~= "table" then return false end + + -- Handle multi-comparison by checking positive and negating + if args.comparisons and type(args.comparisons) == "table" then + -- Check if SP_SwingTimer is loaded + if st_timer == nil then + if not CleveRoids._swingTimerErrorShown then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[SuperCleveRoidMacros]|r The [swingtimer] conditional requires the SP_SwingTimer addon. Get it at: https://github.com/jrc13245/SP_SwingTimer", 1, 0.5, 0.5) + CleveRoids._swingTimerErrorShown = true + end + return true + end + + local attackSpeed = UnitAttackSpeed("player") + if not attackSpeed or attackSpeed <= 0 then return true end + + local timeElapsed = attackSpeed - st_timer + local percentElapsed = (timeElapsed / attackSpeed) * 100 + + -- Check if ALL comparisons pass + for _, comp in ipairs(args.comparisons) do + if not CleveRoids.operators[comp.operator] then + return true + end + if not CleveRoids.comparators[comp.operator](percentElapsed, comp.amount) then + return true -- One failed, so positive=false, negated=true + end + end + return false -- All passed, so positive=true, negated=false + end + + return not CleveRoids.ValidateSwingTimer(args.operator, args.amount) + end, conditionals, "noswingtimer") + end, + + -- Alias for noswingtimer + nostimer = function(conditionals) + return NegatedMulti(conditionals.nostimer, function(args) + if type(args) ~= "table" then return false end + + -- Handle multi-comparison by checking positive and negating + if args.comparisons and type(args.comparisons) == "table" then + -- Check if SP_SwingTimer is loaded + if st_timer == nil then + if not CleveRoids._swingTimerErrorShown then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[SuperCleveRoidMacros]|r The [swingtimer] conditional requires the SP_SwingTimer addon. Get it at: https://github.com/jrc13245/SP_SwingTimer", 1, 0.5, 0.5) + CleveRoids._swingTimerErrorShown = true + end + return true + end + + local attackSpeed = UnitAttackSpeed("player") + if not attackSpeed or attackSpeed <= 0 then return true end + + local timeElapsed = attackSpeed - st_timer + local percentElapsed = (timeElapsed / attackSpeed) * 100 + + -- Check if ALL comparisons pass + for _, comp in ipairs(args.comparisons) do + if not CleveRoids.operators[comp.operator] then + return true + end + if not CleveRoids.comparators[comp.operator](percentElapsed, comp.amount) then + return true -- One failed, so positive=false, negated=true + end + end + return false -- All passed, so positive=true, negated=false + end + + return not CleveRoids.ValidateSwingTimer(args.operator, args.amount) + end, conditionals, "nostimer") + end, + + -- Threat percentage conditional (reads server data via CHAT_MSG_ADDON) + -- Usage: [threat:>80] - true if threat is above 80% + -- 100% = will pull aggro + -- Note: Requires TWThreat addon to request threat data from server + threat = function(conditionals) + return Multi(conditionals.threat, function(args) + if type(args) ~= "table" then return false end + + -- Handle multi-comparison (e.g., >50&<90) + if args.comparisons and type(args.comparisons) == "table" then + local threatpct = CleveRoids.GetPlayerThreatPercent() + if threatpct == nil then return false end + + -- ALL comparisons must pass (AND logic) + for _, comp in ipairs(args.comparisons) do + if not CleveRoids.operators[comp.operator] then + return false + end + if not CleveRoids.comparators[comp.operator](threatpct, comp.amount) then + return false + end + end + return true + end + + return CleveRoids.ValidateThreat(args.operator, args.amount) + end, conditionals, "threat") + end, + + -- Negated threat conditional + nothreat = function(conditionals) + return NegatedMulti(conditionals.nothreat, function(args) + if type(args) ~= "table" then return false end + + if args.comparisons and type(args.comparisons) == "table" then + local threatpct = CleveRoids.GetPlayerThreatPercent() + if threatpct == nil then return true end + + for _, comp in ipairs(args.comparisons) do + if not CleveRoids.operators[comp.operator] then + return true + end + if not CleveRoids.comparators[comp.operator](threatpct, comp.amount) then + return true + end + end + return false + end + + return not CleveRoids.ValidateThreat(args.operator, args.amount) + end, conditionals, "nothreat") + end, + + -- Time-To-Kill conditional (requires TimeToKill addon) + -- Usage: [ttk:<10] - true if target will die in less than 10 seconds + ttk = function(conditionals) + return Multi(conditionals.ttk, function(args) + if type(args) ~= "table" then return false end + + -- Handle multi-comparison (e.g., >5&<15) + if args.comparisons and type(args.comparisons) == "table" then + if type(TimeToKill) ~= "table" or type(TimeToKill.GetTTK) ~= "function" then + if not CleveRoids._ttkErrorShown then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[SuperCleveRoidMacros]|r The [ttk] conditional requires the TimeToKill addon.", 1, 0.5, 0.5) + CleveRoids._ttkErrorShown = true + end + return false + end + + local ttk = TimeToKill.GetTTK() + if ttk == nil then return false end + + for _, comp in ipairs(args.comparisons) do + if not CleveRoids.operators[comp.operator] then + return false + end + if not CleveRoids.comparators[comp.operator](ttk, comp.amount) then + return false + end + end + return true + end + + return CleveRoids.ValidateTTK(args.operator, args.amount) + end, conditionals, "ttk") + end, + + -- Negated TTK conditional + nottk = function(conditionals) + return NegatedMulti(conditionals.nottk, function(args) + if type(args) ~= "table" then return false end + + if args.comparisons and type(args.comparisons) == "table" then + if type(TimeToKill) ~= "table" or type(TimeToKill.GetTTK) ~= "function" then + if not CleveRoids._ttkErrorShown then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[SuperCleveRoidMacros]|r The [ttk] conditional requires the TimeToKill addon.", 1, 0.5, 0.5) + CleveRoids._ttkErrorShown = true + end + return true + end + + local ttk = TimeToKill.GetTTK() + if ttk == nil then return true end + + for _, comp in ipairs(args.comparisons) do + if not CleveRoids.operators[comp.operator] then + return true + end + if not CleveRoids.comparators[comp.operator](ttk, comp.amount) then + return true + end + end + return false + end + + return not CleveRoids.ValidateTTK(args.operator, args.amount) + end, conditionals, "nottk") + end, + + -- Time-To-Execute conditional (requires TimeToKill addon) + -- Usage: [tte:<5] - true if target will reach 20% HP in less than 5 seconds + tte = function(conditionals) + return Multi(conditionals.tte, function(args) + if type(args) ~= "table" then return false end + + if args.comparisons and type(args.comparisons) == "table" then + if type(TimeToKill) ~= "table" or type(TimeToKill.GetTTE) ~= "function" then + if not CleveRoids._ttkErrorShown then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[SuperCleveRoidMacros]|r The [tte] conditional requires the TimeToKill addon.", 1, 0.5, 0.5) + CleveRoids._ttkErrorShown = true + end + return false + end + + local tte = TimeToKill.GetTTE() + if tte == nil then return false end + + for _, comp in ipairs(args.comparisons) do + if not CleveRoids.operators[comp.operator] then + return false + end + if not CleveRoids.comparators[comp.operator](tte, comp.amount) then + return false + end + end + return true + end + + return CleveRoids.ValidateTTE(args.operator, args.amount) + end, conditionals, "tte") + end, + + -- Negated TTE conditional + notte = function(conditionals) + return NegatedMulti(conditionals.notte, function(args) + if type(args) ~= "table" then return false end + + if args.comparisons and type(args.comparisons) == "table" then + if type(TimeToKill) ~= "table" or type(TimeToKill.GetTTE) ~= "function" then + if not CleveRoids._ttkErrorShown then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[SuperCleveRoidMacros]|r The [tte] conditional requires the TimeToKill addon.", 1, 0.5, 0.5) + CleveRoids._ttkErrorShown = true + end + return true + end + + local tte = TimeToKill.GetTTE() + if tte == nil then return true end + + for _, comp in ipairs(args.comparisons) do + if not CleveRoids.operators[comp.operator] then + return true + end + if not CleveRoids.comparators[comp.operator](tte, comp.amount) then + return true + end + end + return false + end + + return not CleveRoids.ValidateTTE(args.operator, args.amount) + end, conditionals, "notte") + end, + + -- ========================================================================= + -- CURSIVE ADDON INTEGRATION + -- ========================================================================= + -- Checks debuffs using Cursive's GUID-based tracking for accurate time remaining + -- Supports time comparisons: [cursive:Rake>3] [cursive:Rip<5] + -- Works with multiscan for intelligent target selection + + -- [cursive] - Check if target has ANY Cursive-tracked debuff + -- [cursive:SpellName] - Check if target has specific debuff tracked by Cursive + -- [cursive:SpellName>3] - Check if debuff has more than 3 seconds remaining + -- [cursive:SpellName<5] - Check if debuff has less than 5 seconds remaining + -- Examples: [cursive:Rake] [@focus,cursive:Rip>5] [cursive:Corruption<3] + cursive = function(conditionals) + -- Check if Cursive addon is available + if not CleveRoids.RequireCursive("cursive") then + return false + end + + local target = conditionals.target or "target" + + -- Boolean form [cursive] - check if target has ANY tracked debuff + if not conditionals.cursive or + conditionals.cursive == true or + (type(conditionals.cursive) == "table" and table.getn(conditionals.cursive) == 0) then + return CleveRoids.HasAnyCursiveDebuff(target) + end + + -- Spell name form with optional time comparison + return Multi(conditionals.cursive, function(args) + if type(args) == "string" then + -- Simple spell name check: [cursive:Rake] + return CleveRoids.ValidateCursiveDebuff(target, args, nil, nil) + elseif type(args) == "table" then + -- Time comparison: [cursive:Rake>3] or multi-comparison [cursive:Rake>3&<10] + local spellName = args.name + + -- Handle multi-comparison (e.g., >3&<10) + if args.comparisons and type(args.comparisons) == "table" then + local timeRemaining = CleveRoids.GetCursiveTimeRemaining(target, spellName) + -- If debuff not found, treat as 0 seconds remaining (matches [debuff] behavior) + if not timeRemaining then timeRemaining = 0 end + + -- ALL comparisons must pass (AND logic) + for _, comp in ipairs(args.comparisons) do + if not CleveRoids.operators[comp.operator] then + return false + end + if not CleveRoids.comparators[comp.operator](timeRemaining, comp.amount) then + return false + end + end + return true + end + + -- Single comparison: [cursive:Rake>3] + return CleveRoids.ValidateCursiveDebuff(target, spellName, args.operator, args.amount) + end + return false + end, conditionals, "cursive") + end, + + -- [nocursive] - Check if target does NOT have any Cursive-tracked debuff + -- [nocursive:SpellName] - Check if target does NOT have specific debuff + -- [nocursive:SpellName>3] - Check if debuff does NOT exist with >3 seconds remaining + -- (true if missing OR has <=3 seconds) + nocursive = function(conditionals) + -- Check if Cursive addon is available (return true if missing = treat as "no debuff") + if not CleveRoids.HasCursive() then + return true + end + + local target = conditionals.target or "target" + + -- Boolean form [nocursive] - check if target has NO tracked debuffs + if not conditionals.nocursive or + conditionals.nocursive == true or + (type(conditionals.nocursive) == "table" and table.getn(conditionals.nocursive) == 0) then + return not CleveRoids.HasAnyCursiveDebuff(target) + end + + -- 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 + spellName = args.name + + -- Handle multi-comparison negation + if args.comparisons and type(args.comparisons) == "table" then + local timeRemaining = CleveRoids.GetCursiveTimeRemaining(target, spellName) + -- If debuff missing, negation passes + if not timeRemaining then return true end + + -- Negated: true if ANY comparison fails + for _, comp in ipairs(args.comparisons) do + if not CleveRoids.operators[comp.operator] then + return true + end + if not CleveRoids.comparators[comp.operator](timeRemaining, comp.amount) then + return true + end + end + return false + end + + -- Single comparison negation + return not CleveRoids.ValidateCursiveDebuff(target, spellName, args.operator, args.amount) + end + return true + end, conditionals, "nocursive") + end, + + -- Slam clip window conditionals for Warrior Slam rotation optimization + -- Based on math: MaxSlamPercent = (SwingTimer - SlamCastTime) / SwingTimer * 100 + -- Requires SP_SwingTimer addon and Nampower for cast time lookup + + -- [noslamclip] - True if casting Slam NOW will NOT clip the auto-attack + -- Usage: /cast [noslamclip] Slam + noslamclip = function(conditionals) + return CleveRoids.ValidateNoSlamClip() + end, + + -- [slamclip] - True if casting Slam NOW WILL clip the auto-attack (negated) + -- Usage: /cast [slamclip] Heroic Strike -- Use HS instead when past slam window + slamclip = function(conditionals) + return not CleveRoids.ValidateNoSlamClip() + end, + + -- [nonextslamclip] - True if casting an instant NOW will NOT cause NEXT Slam to clip + -- Scenario: Skip Slam this swing, cast instant, then Slam next swing without clipping + -- Formula: MaxInstantPercent = (2 * SwingTimer - SlamCastTime - GCD) / SwingTimer * 100 + -- Usage: /cast [nonextslamclip] Bloodthirst + nonextslamclip = function(conditionals) + return CleveRoids.ValidateNoNextSlamClip() + end, + + -- [nextslamclip] - True if casting an instant NOW WILL cause NEXT Slam to clip (negated) + -- Usage: Use this when you want to know you're past the instant window + nextslamclip = function(conditionals) + return not CleveRoids.ValidateNoNextSlamClip() + end, + + -- Checks if the target uses a specific power type (mana, rage, energy) + powertype = function(conditionals) + local unit = conditionals.target or "target" + if not UnitExists(unit) then return false end + + return Or(conditionals.powertype, function(powerTypeName) + local powerType = UnitPowerType(unit) + local powerTypeLower = string.lower(powerTypeName or "") + + if powerTypeLower == "mana" then + return powerType == 0 + elseif powerTypeLower == "rage" then + return powerType == 1 + elseif powerTypeLower == "focus" then + return powerType == 2 + elseif powerTypeLower == "energy" then + return powerType == 3 + end + + return false + end) + end, + + -- Checks if the target does NOT use a specific power type + nopowertype = function(conditionals) + local unit = conditionals.target or "target" + if not UnitExists(unit) then return true end + + return NegatedMulti(conditionals.nopowertype, function(powerTypeName) + local powerType = UnitPowerType(unit) + local powerTypeLower = string.lower(powerTypeName or "") + + if powerTypeLower == "mana" then + return powerType ~= 0 + elseif powerTypeLower == "rage" then + return powerType ~= 1 + elseif powerTypeLower == "focus" then + return powerType ~= 2 + elseif powerTypeLower == "energy" then + return powerType ~= 3 + end + + return true + end, conditionals, "nopowertype") + end, + + -- ======================================================================== + -- CC (Crowd Control) Conditionals - Requires BuffLib for full functionality + -- ======================================================================== + + -- [cc:type] - Check if target has a specific CC effect + -- Types: stun, fear, root, snare/slow, sleep, charm, polymorph, banish, horror, + -- disarm, silence, daze, sap, freeze, knockout/incap, disorient, shackle + -- Special: [cc] or [cc:any] checks for any loss-of-control effect + -- Examples: [cc:stun] [@focus,cc:fear] [cc:stun/fear/root] + cc = function(conditionals) + local target = conditionals.target or "target" + + -- If no specific CC type, check for ANY loss-of-control + if not conditionals.cc or (type(conditionals.cc) == "table" and table.getn(conditionals.cc) == 0) then + return CleveRoids.ValidateUnitAnyCrowdControl(target) + end + + -- Check for specific CC type(s) - OR logic + return Or(conditionals.cc, function(ccType) + return CleveRoids.ValidateUnitCC(target, ccType) + end) + end, + + -- [nocc:type] - Check if target does NOT have a specific CC effect + -- Uses AND logic for negation: [nocc:stun/fear] = not stunned AND not feared + nocc = function(conditionals) + local target = conditionals.target or "target" + + -- If no specific CC type, check for NO loss-of-control + if not conditionals.nocc or (type(conditionals.nocc) == "table" and table.getn(conditionals.nocc) == 0) then + return not CleveRoids.ValidateUnitAnyCrowdControl(target) + end + + -- Check for absence of specific CC type(s) - AND logic (must be missing ALL) + return NegatedMulti(conditionals.nocc, function(ccType) + return not CleveRoids.ValidateUnitCC(target, ccType) + end, conditionals, "nocc") + end, + + -- [mycc:type] - Check if PLAYER has a specific CC effect + -- Same types as [cc], but always checks the player + -- Examples: [mycc:stun] [mycc:fear/charm] [mycc] (any CC) + mycc = function(conditionals) + -- If no specific CC type, check for ANY loss-of-control on player + if not conditionals.mycc or (type(conditionals.mycc) == "table" and table.getn(conditionals.mycc) == 0) then + return CleveRoids.ValidateUnitAnyCrowdControl("player") + end + + -- Check for specific CC type(s) - OR logic + return Or(conditionals.mycc, function(ccType) + return CleveRoids.ValidateUnitCC("player", ccType) + end) + end, + + -- [nomycc:type] - Check if PLAYER does NOT have a specific CC effect + -- Uses AND logic for negation: [nomycc:stun/fear] = not stunned AND not feared + nomycc = function(conditionals) + -- If no specific CC type, check for NO loss-of-control on player + if not conditionals.nomycc or (type(conditionals.nomycc) == "table" and table.getn(conditionals.nomycc) == 0) then + return not CleveRoids.ValidateUnitAnyCrowdControl("player") + end + + -- Check for absence of specific CC type(s) - AND logic + return NegatedMulti(conditionals.nomycc, function(ccType) + return not CleveRoids.ValidateUnitCC("player", ccType) + end, conditionals, "nomycc") + end, + + -- ======================================================================== + -- RESIST TRACKING CONDITIONALS + -- ======================================================================== + + -- [resisted] - Check if last spell was resisted by current target + -- [resisted:type] - Check for specific resist type (full, partial) + -- [resisted:full/partial] - OR logic for multiple types + -- Examples: [resisted] [@target,resisted:full] [resisted:partial] + resisted = function(conditionals) + -- If no specific resist type, check for ANY resist on current target + if not conditionals.resisted or + conditionals.resisted == true or + (type(conditionals.resisted) == "table" and table.getn(conditionals.resisted) == 0) then + return CleveRoids.CheckResistState(nil) + end + + -- Check for specific resist type(s) - OR logic + return Multi(conditionals.resisted, function(resistType) + return CleveRoids.CheckResistState(resistType) + end, conditionals, "resisted") + end, + + -- [noresisted] - Check if last spell was NOT resisted by current target + -- [noresisted:type] - Check target does NOT have specific resist type + -- [noresisted:full/partial] - AND logic: not full AND not partial + noresisted = function(conditionals) + -- If no specific resist type, check for NO resist on current target + if not conditionals.noresisted or + conditionals.noresisted == true or + (type(conditionals.noresisted) == "table" and table.getn(conditionals.noresisted) == 0) then + return not CleveRoids.CheckResistState(nil) + end + + -- Check for absence of specific resist type(s) - AND logic + return NegatedMulti(conditionals.noresisted, function(resistType) + return not CleveRoids.CheckResistState(resistType) + end, conditionals, "noresisted") + end, + + -- ======================================================================== + -- EXACT NAME MATCHING CONDITIONALS + -- ======================================================================== + + -- [name:UnitName] - Check if target's name EXACTLY matches (case-insensitive) + -- Unlike /target which fuzzy-matches, this requires an exact match + -- Supports underscores for spaces: [name:Onyxia] or [name:Kor_kron_Elite] + -- Multi-value OR: [name:Onyxia/Nefarian] = Onyxia OR Nefarian + -- Examples: [name:Onyxia] [@focus,name:Ragnaros] [name:"Kor'kron Elite"] + name = function(conditionals) + local target = conditionals.target or "target" + + -- Unit must exist + if not UnitExists(target) then + return false + end + + local unitName = UnitName(target) + if not unitName then + return false + end + + -- Lowercase for case-insensitive comparison + local unitNameLower = string.lower(unitName) + + -- Check for exact match with any provided name (OR logic) + return Or(conditionals.name, function(requiredName) + -- Normalize underscores to spaces and lowercase + local normalizedRequired = string.lower(CleveRoids.NormalizeName(requiredName)) + return unitNameLower == normalizedRequired + end) + end, + + -- [noname:UnitName] - Check if target's name does NOT match (case-insensitive) + -- Uses AND logic: [noname:Onyxia/Nefarian] = not Onyxia AND not Nefarian + -- Examples: [noname:Onyxia] [@mouseover,noname:"Training Dummy"] + noname = function(conditionals) + local target = conditionals.target or "target" + + -- No unit = no name = passes the "not this name" check + if not UnitExists(target) then + return true + end + + local unitName = UnitName(target) + if not unitName then + return true + end + + -- Lowercase for case-insensitive comparison + local unitNameLower = string.lower(unitName) + + -- Check that name does NOT match any provided name (AND logic - must not match ALL) + return NegatedMulti(conditionals.noname, function(forbiddenName) + -- Normalize underscores to spaces and lowercase + local normalizedForbidden = string.lower(CleveRoids.NormalizeName(forbiddenName)) + return unitNameLower ~= normalizedForbidden + end, conditionals, "noname") + end, + + -- ======================================================================== + -- AUTO-ATTACK CONDITIONALS (Nampower v2.24+) + -- ======================================================================== + -- Requires NP_EnableAutoAttackEvents=1 CVar + + -- [lastswing:type] - Check result of player's last melee swing + -- Types: crit, glancing, miss, dodge, parry, blocked, offhand/oh, mainhand/mh, hit + -- Time check: [lastswing:<2] = last swing was within 2 seconds + -- Multi-value: [lastswing:crit/glancing] = was crit OR glancing (OR logic) + -- Examples: [lastswing:dodge] [@target,lastswing:crit] [lastswing:offhand] + lastswing = function(conditionals) + -- Boolean form [lastswing] - any recent swing + if not conditionals.lastswing or + conditionals.lastswing == true or + (type(conditionals.lastswing) == "table" and table.getn(conditionals.lastswing) == 0) then + return CleveRoids.ValidateLastSwing(nil, nil, nil) + end + + -- Check for specific swing type(s) - OR logic + return Or(conditionals.lastswing, function(args) + if type(args) == "string" then + return CleveRoids.ValidateLastSwing(args, nil, nil) + elseif type(args) == "table" then + return CleveRoids.ValidateLastSwing(args.name, args.operator, args.amount) + end + return false + end) + end, + + -- [nolastswing:type] - Check that last swing was NOT a specific type + -- Uses AND logic: [nolastswing:crit/glancing] = was NOT crit AND NOT glancing + nolastswing = function(conditionals) + -- Boolean form [nolastswing] - no recent swing + if not conditionals.nolastswing or + conditionals.nolastswing == true or + (type(conditionals.nolastswing) == "table" and table.getn(conditionals.nolastswing) == 0) then + return not CleveRoids.ValidateLastSwing(nil, nil, nil) + end + + -- Check that swing was NOT any of the specified types - AND logic + return NegatedMulti(conditionals.nolastswing, function(args) + if type(args) == "string" then + return not CleveRoids.ValidateLastSwing(args, nil, nil) + elseif type(args) == "table" then + return not CleveRoids.ValidateLastSwing(args.name, args.operator, args.amount) + end + return true + end, conditionals, "nolastswing") + end, + + -- [incominghit:type] - Check result of last attack received by player + -- Types: crit, crushing, glancing, miss, dodge, parry, blocked, hit + -- Time check: [incominghit:<1] = received hit within last 1 second + -- Examples: [incominghit:crushing] [incominghit:crit/crushing] [incominghit:dodge] + incominghit = function(conditionals) + -- Boolean form [incominghit] - any recent incoming hit + if not conditionals.incominghit or + conditionals.incominghit == true or + (type(conditionals.incominghit) == "table" and table.getn(conditionals.incominghit) == 0) then + return CleveRoids.ValidateIncomingHit(nil, nil, nil) + end + + -- Check for specific hit type(s) - OR logic + return Or(conditionals.incominghit, function(args) + if type(args) == "string" then + return CleveRoids.ValidateIncomingHit(args, nil, nil) + elseif type(args) == "table" then + return CleveRoids.ValidateIncomingHit(args.name, args.operator, args.amount) + end + return false + end) + end, + + -- [noincominghit:type] - Check that last incoming hit was NOT a specific type + noincominghit = function(conditionals) + -- Boolean form [noincominghit] - no recent incoming hit + if not conditionals.noincominghit or + conditionals.noincominghit == true or + (type(conditionals.noincominghit) == "table" and table.getn(conditionals.noincominghit) == 0) then + return not CleveRoids.ValidateIncomingHit(nil, nil, nil) + end + + -- Check that hit was NOT any of the specified types - AND logic + return NegatedMulti(conditionals.noincominghit, function(args) + if type(args) == "string" then + return not CleveRoids.ValidateIncomingHit(args, nil, nil) + elseif type(args) == "table" then + return not CleveRoids.ValidateIncomingHit(args.name, args.operator, args.amount) + end + return true + end, conditionals, "noincominghit") + end, + + -- ======================================================================== + -- AURA CAP CONDITIONALS (Nampower v2.20+ with AURA_CAST events) + -- ======================================================================== + -- Requires NP_EnableAuraCastEvents=1 CVar for accurate tracking + -- Falls back to manual counting if events unavailable + + -- [mybuffcapped] - Player's buff bar is at capacity (32 buffs) + -- Usage: [mybuffcapped] to prevent buff waste + mybuffcapped = function(conditionals) + return CleveRoids.IsPlayerBuffCapped() + end, + + -- [nomybuffcapped] - Player's buff bar has room + nomybuffcapped = function(conditionals) + return not CleveRoids.IsPlayerBuffCapped() + end, + + -- [mydebuffcapped] - Player's debuff bar is at capacity (16 debuffs) + mydebuffcapped = function(conditionals) + return CleveRoids.IsPlayerDebuffCapped() + end, + + -- [nomydebuffcapped] - Player's debuff bar has room + nomydebuffcapped = function(conditionals) + return not CleveRoids.IsPlayerDebuffCapped() + end, + + -- [debuffcapped] - Target's debuff bar is at capacity + -- For NPCs: 16 debuff slots (+ 32 overflow into buff slots = 48 visual total) + -- Usage: [debuffcapped] to stop DoT spam when target is capped + debuffcapped = function(conditionals) + local target = conditionals.target or "target" + return CleveRoids.IsTargetDebuffCapped(target) + end, + + -- [nodebuffcapped] - Target's debuff bar has room + nodebuffcapped = function(conditionals) + local target = conditionals.target or "target" + return not CleveRoids.IsTargetDebuffCapped(target) + end, + + -- [buffcapped] - Target's buff bar is at capacity (32 buffs) + buffcapped = function(conditionals) + local target = conditionals.target or "target" + return CleveRoids.IsTargetBuffCapped(target) + end, + + -- [nobuffcapped] - Target's buff bar has room + nobuffcapped = function(conditionals) + local target = conditionals.target or "target" + return not CleveRoids.IsTargetBuffCapped(target) + end, + + -- ========================================================================= + -- MOVEMENT SPEED CONDITIONALS (MonkeySpeed integration) + -- ========================================================================= + -- [moving] - Player is moving (speed > 0) + -- [moving:>100] - Player speed is above 100% (faster than normal run) + -- [moving:<50] - Player speed is below 50% (slower than half speed) + -- Requires MonkeySpeed addon for speed comparisons (basic moving check uses fallback) + moving = function(conditionals) + -- Boolean form [moving] - just check if moving at all + if conditionals.moving == true then + return CleveRoids.IsPlayerMoving() + end + + -- Operator form [moving:>100] - check speed percentage + return Multi(conditionals.moving, function(args) + if type(args) ~= "table" then return false end + + -- Handle multi-comparison (e.g., >50&<150) + if args.comparisons and type(args.comparisons) == "table" then + local speed = CleveRoids.GetPlayerSpeed() + if speed == nil then + CleveRoids.RequireMonkeySpeed("moving speed comparison") + return false + end + + -- ALL comparisons must pass (AND logic) + for _, comp in ipairs(args.comparisons) do + if not CleveRoids.operators[comp.operator] then + return false + end + if not CleveRoids.comparators[comp.operator](speed, comp.amount) then + return false + end + end + return true + end + + return CleveRoids.ValidateMovingSpeed(args.operator, args.amount) + end, conditionals, "moving") + end, + + -- [nomoving] - Player is NOT moving (speed == 0) + -- [nomoving:>100] - Player speed is NOT above 100% (at or below normal run) + nomoving = function(conditionals) + -- Boolean form [nomoving] - check if NOT moving + if conditionals.nomoving == true then + return not CleveRoids.IsPlayerMoving() + end + + -- Operator form [nomoving:>100] - negate speed comparison + return NegatedMulti(conditionals.nomoving, function(args) + if type(args) ~= "table" then return false end + + if args.comparisons and type(args.comparisons) == "table" then + local speed = CleveRoids.GetPlayerSpeed() + if speed == nil then + -- If MonkeySpeed not available, negated returns true (fail-safe) + return true + end + + for _, comp in ipairs(args.comparisons) do + if not CleveRoids.operators[comp.operator] then + return true + end + if not CleveRoids.comparators[comp.operator](speed, comp.amount) then + return true + end + end + return false + end + + return not CleveRoids.ValidateMovingSpeed(args.operator, args.amount) + end, conditionals, "nomoving") end } + +-- ============================================================================= +-- MULTISCAN SYSTEM +-- ============================================================================= +-- [multiscan:priority] - Scan nearby enemies and find best target matching priority +-- Requires UnitXP_SP3 for enemy enumeration +-- Uses SuperWoW for soft-casting (CastSpellByName with GUID) + +-- Raid mark indices (WoW API GetRaidTargetIndex values) +CleveRoids.RAID_MARKS = { + star = 1, circle = 2, diamond = 3, triangle = 4, + moon = 5, square = 6, cross = 7, skull = 8, +} + +-- Priority types for multiscan +-- String values = custom handling, number values = raid mark index +CleveRoids.MULTISCAN_PRIORITIES = { + -- Distance-based (use UnitXP direct targeting where possible) + nearest = "nearest", + farthest = "farthest", + -- Health percentage + highesthp = "highesthp", + lowesthp = "lowesthp", + -- Raw health value + highestrawhp = "highestrawhp", + lowestrawhp = "lowestrawhp", + -- Raid mark order (skull → cross → square → moon → triangle → diamond → circle → star) + markorder = "markorder", + -- Individual raid marks (direct unit reference via SuperWoW "mark#" tokens) + skull = 8, cross = 7, square = 6, moon = 5, + triangle = 4, diamond = 3, circle = 2, star = 1, +} + +-- Static conditionals that don't depend on target (checked before scanning) +CleveRoids.STATIC_CONDITIONALS = { + group = true, nogroup = true, + combat = true, nocombat = true, + zone = true, nozone = true, + stealth = true, nostealth = true, + form = true, noform = true, stance = true, nostance = true, + equipped = true, noequipped = true, + mod = true, nomod = true, + swimming = true, noswimming = true, + resting = true, noresting = true, +} + +--- Calculate score for a unit based on priority type +--- @param unit string Unit token or GUID +--- @param priority string Priority type (e.g., "nearest", "highesthp") +--- @param currentTargetGuid string|nil GUID of player's current target (exempt from combat check) +--- @param specifiedUnitGuid string|nil GUID of @unit specified in macro (also exempt from combat check) +--- @return number|nil Score (lower = better) or nil if invalid candidate +function CleveRoids.GetMultiscanScore(unit, priority, currentTargetGuid, specifiedUnitGuid) + if not unit or not UnitExists(unit) then return nil end + if UnitIsDeadOrGhost(unit) then return nil end + + -- Must be attackable + if not UnitCanAttack("player", unit) then return nil end + + -- Combat check: must be in combat with player, UNLESS it's current target OR specified @unit + local _, unitGuid = UnitExists(unit) + local isCurrentTarget = currentTargetGuid and unitGuid == currentTargetGuid + local isSpecifiedUnit = specifiedUnitGuid and unitGuid == specifiedUnitGuid + if not isCurrentTarget and not isSpecifiedUnit and not UnitAffectingCombat(unit) then + return nil + end + + -- Calculate score based on priority + if priority == "nearest" then + if CleveRoids.hasUnitXP then + local distance = UnitXP("distanceBetween", "player", unit) + return distance or 9999 + end + return 0 -- No distance info, treat as equal + + elseif priority == "farthest" then + if CleveRoids.hasUnitXP then + local distance = UnitXP("distanceBetween", "player", unit) + return distance and -distance or -9999 + end + return 0 + + elseif priority == "highesthp" then + local maxHp = UnitHealthMax(unit) + if maxHp <= 0 then return nil end + local hpPct = 100 * UnitHealth(unit) / maxHp + return -hpPct -- Negative so highest becomes lowest score + + elseif priority == "lowesthp" then + local maxHp = UnitHealthMax(unit) + if maxHp <= 0 then return nil end + local hpPct = 100 * UnitHealth(unit) / maxHp + return hpPct + + elseif priority == "highestrawhp" then + local rawHp = UnitHealth(unit) + return -rawHp -- Negative so highest becomes lowest score + + elseif priority == "lowestrawhp" then + local rawHp = UnitHealth(unit) + return rawHp + end + + return 0 -- Unknown priority, treat as equal +end + +--- Check if a candidate passes all target-dependent conditionals +--- @param conditionals table The conditionals table +--- @param candidateUnit string Unit token or GUID to test +--- @return boolean True if candidate passes all conditionals +function CleveRoids.ValidateMultiscanCandidate(conditionals, candidateUnit) + local originalTarget = conditionals.target + conditionals.target = candidateUnit + + local passes = true + for k, _ in pairs(conditionals) do + -- Skip static conditionals (already checked) and metadata + if not CleveRoids.ignoreKeywords[k] and not CleveRoids.STATIC_CONDITIONALS[k] then + local fn = CleveRoids.Keywords[k] + if fn and not fn(conditionals) then + passes = false + break + end + end + end + + conditionals.target = originalTarget + return passes +end + +--- Check static conditionals before scanning +--- @param conditionals table The conditionals table +--- @return boolean True if all static conditionals pass +function CleveRoids.CheckStaticConditionals(conditionals) + for k, _ in pairs(conditionals) do + if CleveRoids.STATIC_CONDITIONALS[k] then + local fn = CleveRoids.Keywords[k] + if fn and not fn(conditionals) then + return false + end + end + end + return true +end + +--- Main multiscan resolution function +--- Scans enemies using UnitXP, finds best target matching priority and conditionals +--- @param conditionals table The conditionals table containing multiscan value +--- @param specifiedUnit string|nil The @unit specified in the macro (exempt from combat check) +--- @return string|nil GUID of best target, or nil if none found +function CleveRoids.ResolveMultiscanTarget(conditionals, specifiedUnit) + -- Require UnitXP for enemy scanning + if not CleveRoids.hasUnitXP then + return nil + end + + -- Parse priority from conditionals.multiscan + local priority = nil + if type(conditionals.multiscan) == "table" then + priority = conditionals.multiscan[1] + elseif type(conditionals.multiscan) == "string" then + priority = conditionals.multiscan + end + + if not priority then return nil end + priority = string.lower(CleveRoids.Trim(priority)) + + -- Validate priority type + local priorityType = CleveRoids.MULTISCAN_PRIORITIES[priority] + if not priorityType then + CleveRoids.Print("|cffff0000[multiscan]|r Unknown priority: " .. priority) + return nil + end + + -- Check static conditionals first (group, combat, zone, etc.) + if not CleveRoids.CheckStaticConditionals(conditionals) then + return nil + end + + -- Save current target for restoration and combat-check exemption + local currentTargetGuid = nil + if UnitExists("target") then + local _, guid = UnitExists("target") + currentTargetGuid = guid + end + + -- Resolve specified @unit GUID (also exempt from combat check) + local specifiedUnitGuid = nil + if specifiedUnit and UnitExists(specifiedUnit) then + local _, guid = UnitExists(specifiedUnit) + specifiedUnitGuid = guid + end + + -- Handle raid mark priorities (direct unit reference) + if type(priorityType) == "number" then + local markUnit = "mark" .. priorityType + if UnitExists(markUnit) and UnitCanAttack("player", markUnit) then + -- Validate against target-dependent conditionals + if CleveRoids.ValidateMultiscanCandidate(conditionals, markUnit) then + local _, guid = UnitExists(markUnit) + return guid + end + end + return nil -- Raid mark not found or doesn't pass conditionals + end + + -- Handle markorder priority (skull → cross → square → moon → triangle → diamond → circle → star) + if priorityType == "markorder" then + -- Iterate marks from skull (8) down to star (1) in standard kill order + for markIndex = 8, 1, -1 do + local markUnit = "mark" .. markIndex + if UnitExists(markUnit) and UnitCanAttack("player", markUnit) then + -- Validate against target-dependent conditionals + if CleveRoids.ValidateMultiscanCandidate(conditionals, markUnit) then + local _, guid = UnitExists(markUnit) + return guid + end + end + end + return nil -- No valid marked target found + end + + -- Handle UnitXP direct targeting for simple priorities + if priorityType == "nearest" then + -- If no specified unit, use UnitXP's nearestEnemy directly for efficiency + if not specifiedUnitGuid then + local found = UnitXP("target", "nearestEnemy") + if found and UnitExists("target") then + local _, foundGuid = UnitExists("target") + -- Restore original target + if currentTargetGuid then + TargetUnit(currentTargetGuid) + else + ClearTarget() + end + -- Validate the found target + if CleveRoids.ValidateMultiscanCandidate(conditionals, foundGuid) then + return foundGuid + end + end + -- Restore target if nearestEnemy failed + if currentTargetGuid then + TargetUnit(currentTargetGuid) + else + ClearTarget() + end + return nil + end + -- Fall through to custom scan if there's a specified unit to consider + end + + if priorityType == "highesthp" then + -- If no specified unit, try UnitXP's mostHP directly for efficiency + if not specifiedUnitGuid then + local found = UnitXP("target", "mostHP") + if found and UnitExists("target") then + local _, foundGuid = UnitExists("target") + -- Restore original target + if currentTargetGuid then + TargetUnit(currentTargetGuid) + else + ClearTarget() + end + -- Validate the found target + if CleveRoids.ValidateMultiscanCandidate(conditionals, foundGuid) then + return foundGuid + end + end + -- Restore target if mostHP failed validation + if currentTargetGuid then + TargetUnit(currentTargetGuid) + else + ClearTarget() + end + end + -- Fall through to custom scan if there's a specified unit or built-in didn't pass validation + end + + -- Custom scan for complex priorities (lowesthp, highestrawhp, lowestrawhp, farthest) + -- Also used as fallback if UnitXP direct targeting didn't pass validation + local candidates = {} -- { { guid = X, score = Y }, ... } + local seenGuids = {} + + -- Helper to evaluate a candidate + local function evaluateCandidate(unit) + if not UnitExists(unit) then return end + + local _, guid = UnitExists(unit) + if not guid or seenGuids[guid] then return end + seenGuids[guid] = true + + -- Cache GUID -> name mapping for immunity checks + -- This is needed because immune enemies may never have debuffs tracked, + -- but we still need their name for immunity lookups + local unitName = UnitName(unit) + if unitName and unitName ~= "" and unitName ~= "Unknown" then + local lib = CleveRoids.libdebuff + if lib and lib.guidToName then + local normalizedGuid = CleveRoids.NormalizeGUID(guid) + if normalizedGuid then + lib.guidToName[normalizedGuid] = unitName + end + end + end + + local score = CleveRoids.GetMultiscanScore(unit, priorityType, currentTargetGuid, specifiedUnitGuid) + if not score then return end + + -- Validate against target-dependent conditionals + if CleveRoids.ValidateMultiscanCandidate(conditionals, guid) then + table.insert(candidates, { guid = guid, score = score }) + end + end + + -- Always consider current target (exempt from combat check via GetMultiscanScore) + if currentTargetGuid then + evaluateCandidate("target") + end + + -- Also consider specified @unit (exempt from combat check via GetMultiscanScore) + if specifiedUnitGuid and specifiedUnitGuid ~= currentTargetGuid then + evaluateCandidate(specifiedUnit) + end + + -- Scan via UnitXP enemy iteration + local firstGuid = nil + local maxIterations = 50 + + for i = 1, maxIterations do + local found = UnitXP("target", "nextEnemyConsideringDistance") + if not found then break end + + if not UnitExists("target") then break end + local _, currentGuid = UnitExists("target") + if not currentGuid then break end + + if firstGuid == nil then + firstGuid = currentGuid + elseif currentGuid == firstGuid then + break -- Completed full cycle + end + + evaluateCandidate("target") + end + + -- Restore original target + if currentTargetGuid then + TargetUnit(currentTargetGuid) + else + ClearTarget() + end + + -- Find best candidate (lowest score wins) + if table.getn(candidates) == 0 then + return nil + end + + table.sort(candidates, function(a, b) return a.score < b.score end) + return candidates[1].guid +end diff --git a/Console.lua b/Console.lua index ab9ef5c..3df5607 100644 --- a/Console.lua +++ b/Console.lua @@ -43,7 +43,7 @@ SlashCmdList.EQUIP = CleveRoids.DoUse -- take back supermacro and pfUI /equip and /use SlashCmdList.SMEQUIP = CleveRoids.DoUse SlashCmdList.PFEQUIP = CleveRoids.DoUse -SlashCmdList.PFUSE = CleveRoids.DoUse +SlashCmdList.PFUSE = CleveRoids.DoUse SLASH_EQUIPMH1 = "/equipmh" SlashCmdList.EQUIPMH = CleveRoids.DoEquipMainhand @@ -51,6 +51,18 @@ SlashCmdList.EQUIPMH = CleveRoids.DoEquipMainhand SLASH_EQUIPOH1 = "/equipoh" SlashCmdList.EQUIPOH = CleveRoids.DoEquipOffhand +SLASH_EQSLOT111 = "/equip11" +SlashCmdList.EQSLOT11 = CleveRoids.DoEquipRing1 + +SLASH_EQSLOT121 = "/equip12" +SlashCmdList.EQSLOT12 = CleveRoids.DoEquipRing2 + +SLASH_EQSLOT131 = "/equip13" +SlashCmdList.EQSLOT13 = CleveRoids.DoEquipTrinket1 + +SLASH_EQSLOT141 = "/equip14" +SlashCmdList.EQSLOT14 = CleveRoids.DoEquipTrinket2 + SLASH_UNSHIFT1 = "/unshift" SlashCmdList.UNSHIFT = CleveRoids.DoUnshift @@ -74,10 +86,27 @@ end -- This ensures we have a fallback for non-conditional use. local StartAttack = function(msg) if not UnitExists("target") or UnitIsDead("target") then TargetNearestEnemy() end - if not CleveRoids.CurrentSpell.autoAttack and not CleveRoids.CurrentSpell.autoAttackLock and UnitExists("target") and UnitCanAttack("player","target") then + -- Check both event-based flag AND action bar state for reliable detection + local isAttacking = CleveRoids.CurrentSpell.autoAttack + if not isAttacking then + -- Fallback: check action bar state via IsCurrentAction + local slot = CleveRoids.GetProxyActionSlot(CleveRoids.Localized.Attack) + if slot and IsCurrentAction(slot) then + CleveRoids.CurrentSpell.autoAttack = true + isAttacking = true + end + end + if not isAttacking and not CleveRoids.CurrentSpell.autoAttackLock and UnitExists("target") and UnitCanAttack("player","target") then 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 @@ -99,13 +128,30 @@ SlashCmdList.STOPATTACK = StopAttack SLASH_STOPCASTING1 = "/stopcasting" SlashCmdList.STOPCASTING = SpellStopCasting +SLASH_CLEARTARGET1 = "/cleartarget" +SlashCmdList.CLEARTARGET = ClearTarget + ---------------------------------- -- HOOK DEFINITIONS START ---------------------------------- +-- /cleartarget hook +CleveRoids.Hooks.CLEARTARGET_SlashCmd = SlashCmdList.CLEARTARGET +SlashCmdList.CLEARTARGET = function(msg) + msg = msg or "" + if string.find(msg, "%[") then + -- If conditionals are present, let the function handle it. + CleveRoids.DoConditionalClearTarget(msg) + else + -- If no conditionals, run the original command. + CleveRoids.Hooks.CLEARTARGET_SlashCmd() + end +end + -- /startattack hook CleveRoids.Hooks.STARTATTACK_SlashCmd = SlashCmdList.STARTATTACK SlashCmdList.STARTATTACK = function(msg) + if CleveRoids.stopMacroFlag then return end msg = msg or "" if string.find(msg, "%[") then CleveRoids.DoConditionalStartAttack(msg) @@ -117,6 +163,7 @@ end -- /stopattack hook CleveRoids.Hooks.STOPATTACK_SlashCmd = SlashCmdList.STOPATTACK SlashCmdList.STOPATTACK = function(msg) + if CleveRoids.stopMacroFlag then return end msg = msg or "" if string.find(msg, "%[") then -- If conditionals are present, let the function handle it. @@ -131,6 +178,7 @@ end -- /stopcasting hook CleveRoids.Hooks.STOPCASTING_SlashCmd = SlashCmdList.STOPCASTING SlashCmdList.STOPCASTING = function(msg) + if CleveRoids.stopMacroFlag then return end msg = msg or "" if string.find(msg, "%[") then -- If conditionals are present, let the function handle it. @@ -142,8 +190,10 @@ SlashCmdList.STOPCASTING = function(msg) end end +-- /unqueue hook CleveRoids.Hooks.UNQUEUE_SlashCmd = SlashCmdList.UNQUEUE SlashCmdList.UNQUEUE = function(msg) + if CleveRoids.stopMacroFlag then return end msg = msg or "" if string.find(msg, "%[") then -- If conditionals are present, let the function handle it. @@ -157,9 +207,82 @@ end -- /cast hook CleveRoids.Hooks.CAST_SlashCmd = SlashCmdList.CAST SlashCmdList.CAST = function(msg) + if CleveRoids.stopMacroFlag or CleveRoids.skipMacroFlag then return end if msg and string.find(msg, "[%[%?!~{]") then CleveRoids.DoCast(msg) else + -- Use lastComboPoints which is updated on every OnUpdate tick + -- This is critical for instant-cast finishers where GetComboPoints() returns 0 immediately + local currentCP = CleveRoids.lastComboPoints or 0 + + -- Also try GetComboPoints as a fallback + if currentCP == 0 and GetComboPoints then + currentCP = GetComboPoints() + end + + if currentCP > 0 then + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cffaaff00[/cast Hook]|r Using %d CP for %s", + currentCP, msg) + ) + end + + -- Pre-inject combo duration into pfUI for instant-cast combo finishers + -- Get the spell data to find the proper spell name (handles case-insensitive input) + local spellData = CleveRoids.GetSpell and CleveRoids.GetSpell(msg) + local spellName = spellData and spellData.name or msg + + -- If GetSpell didn't find it, capitalize first letter as fallback + if not spellData and spellName then + spellName = string.upper(string.sub(spellName, 1, 1)) .. string.sub(spellName, 2) + end + + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cffcccccc[/cast Debug]|r input='%s', spellName='%s', GetSpell=%s, IsComboScalingSpell=%s", + msg, spellName or "nil", tostring(spellData ~= nil), tostring(CleveRoids.IsComboScalingSpell ~= nil)) + ) + end + + if CleveRoids.IsComboScalingSpell and CleveRoids.IsComboScalingSpell(spellName) then + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[/cast Debug]|r IS combo scaling spell") + end + local duration = CleveRoids.CalculateComboScaledDuration and + CleveRoids.CalculateComboScaledDuration(spellName, currentCP) + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cffcccccc[/cast Debug]|r duration=%s, pfUI=%s, pfUI.api=%s, pfUI.api.libdebuff=%s, debuffs=%s", + tostring(duration), tostring(pfUI ~= nil), + tostring(pfUI and pfUI.api ~= nil), + tostring(pfUI and pfUI.api and pfUI.api.libdebuff ~= nil), + tostring(pfUI and pfUI.api and pfUI.api.libdebuff and pfUI.api.libdebuff.debuffs ~= nil)) + ) + end + if duration and CleveRoids.ComboPointTracking then + -- Remove rank from spell name for pfUI compatibility + local baseName = string.gsub(spellName, "%s*%(Rank %d+%)", "") + -- Populate name-based tracking BEFORE the spell is cast + -- This allows pfUI's AddEffect hook to find it + CleveRoids.ComboPointTracking[baseName] = { + combo_points = currentCP, + duration = duration, + cast_time = GetTime(), + target = UnitName("target") or "Unknown", + confirmed = true + } + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cffff00ff[/cast Pre-Tracking]|r Set tracking['%s'] = %ds (%d CP)", + baseName, duration, currentCP) + ) + end + end + elseif CleveRoids.debug and currentCP > 0 then + DEFAULT_CHAT_FRAME:AddMessage("|cffff9900[/cast Debug]|r NOT a combo scaling spell") + end + end CleveRoids.Hooks.CAST_SlashCmd(msg) end end @@ -199,6 +322,15 @@ SlashCmdList.RUNMACRO = function(msg) return CleveRoids.ExecuteMacroByName(CleveRoids.Trim(msg)) end +-- Global RunMacro wrapper for user convenience (delegates to namespaced internal function) +-- This pattern ensures internal logic uses CleveRoids.ExecuteMacroByName and won't break +-- if another addon overwrites the global RunMacro +-- NOTE: When SuperMacro is also loaded, Compatibility/SuperMacro.lua redirects this to +-- SuperMacro_RunMacro so macros go through RunLine (where CRM commands are intercepted) +function RunMacro(name) + return CleveRoids.ExecuteMacroByName(name) +end + SLASH_RETARGET1 = "/retarget" SlashCmdList.RETARGET = function(msg) CleveRoids.DoRetarget() @@ -209,3 +341,116 @@ SlashCmdList.STOPMACRO = function(msg) CleveRoids.DoStopMacro(msg) end +SLASH_SKIPMACRO1 = "/skipmacro" +SlashCmdList.SKIPMACRO = function(msg) + CleveRoids.DoSkipMacro(msg) +end + +-- Enable "first action only" mode - stop evaluation after first successful /cast or /use +-- Example: +-- /firstaction +-- /cast [myrawpower:>48] Shred +-- /cast [myrawpower:>40] Claw +-- Result: Only Shred casts if energy >= 48, Claw won't be queued +SLASH_FIRSTACTION1 = "/firstaction" +SlashCmdList.FIRSTACTION = function(msg) + CleveRoids.DoFirstAction(msg) +end + +-- Re-enable multi-queue behavior after /firstaction +-- Use this to restore normal evaluation where multiple casts can queue +-- Example: +-- /firstaction +-- /cast [myrawpower:>48] Shred -- Priority section +-- /cast [myrawpower:>40] Claw +-- /nofirstaction +-- /cast Tiger's Fury -- Can queue alongside above +SLASH_NOFIRSTACTION1 = "/nofirstaction" +SlashCmdList.NOFIRSTACTION = function(msg) + CleveRoids.DoNoFirstAction(msg) +end + +-- QuickHeal with conditionals support (requires QuickHeal addon) +-- Usage: /quickheal [conditionals] [target] [type] +-- Examples: +-- /quickheal -- Smart heal (auto-select target) +-- /quickheal target -- Heal current target +-- /quickheal [combat] party -- Heal party member if in combat +-- /quickheal [mypower:>50] mt -- Heal tank if mana > 50% +-- /quickheal [threat:<80] hot -- Apply HoT if threat is low +SLASH_QUICKHEAL1 = "/quickheal" +SLASH_QUICKHEAL2 = "/qh" +SlashCmdList.QUICKHEAL = function(msg) + CleveRoids.DoQuickHeal(msg) +end + +--- Execute QuickHeal with optional conditionals +--- @param msg string The command arguments (conditionals + QuickHeal params) +function CleveRoids.DoQuickHeal(msg) + -- Check if QuickHeal addon is loaded + if type(QuickHeal) ~= "function" then + if not CleveRoids._quickHealErrorShown then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[SuperCleveRoidMacros]|r The /quickheal command requires the QuickHeal addon.", 1, 0.5, 0.5) + CleveRoids._quickHealErrorShown = true + end + return + end + + msg = CleveRoids.Trim(msg or "") + + -- Check if there are conditionals + if string.find(msg, "^%[") then + -- Parse the conditionals and remaining args + local actions = CleveRoids.ParseMsg(msg) + + if not actions or table.getn(actions) == 0 then + -- No valid actions parsed, just run QuickHeal + QuickHeal() + return + end + + -- Find the first action whose conditionals pass + for i = 1, table.getn(actions) do + local action = actions[i] + if CleveRoids.TestAction(action) then + -- Conditionals passed - extract the target/type from action args + local healTarget = nil + local healType = nil + + if action.args then + -- Parse args - could be "target", "party", "mt", "hot", etc. + local args = CleveRoids.Trim(action.args) + if args ~= "" then + -- Check if it's a target or type keyword + local lowerArgs = string.lower(args) + if lowerArgs == "hot" or lowerArgs == "heal" or lowerArgs == "hs" or lowerArgs == "chainheal" then + healType = args + else + -- Assume it's a target specifier + healTarget = args + end + end + end + + -- Execute QuickHeal with parsed parameters + QuickHeal(healTarget, nil, nil, nil) + return + end + end + -- No conditions matched - don't heal + return + else + -- No conditionals, pass through to QuickHeal directly + -- Parse basic args: target and/or type + local args = msg + if args == "" then + QuickHeal() + else + -- QuickHeal accepts: Target, SpellID, extParam, forceMaxHPS + -- Common targets: player, target, targettarget, party, mt, nonmt, subgroup + -- Common types: heal, hot, hs (paladin), chainheal (shaman) + QuickHeal(args) + end + end +end + diff --git a/Core.lua b/Core.lua index 2d61106..4b7916f 100644 --- a/Core.lua +++ b/Core.lua @@ -3,8 +3,24 @@ License: MIT License ]] --- Setup to wrap our stuff in a table so we don't pollute the global environment +-- DEBUG: Error catcher for "attempt to index a function value" +-- Remove this block once the error is identified local _G = _G or getfenv(0) +local originalErrorHandler = geterrorhandler and geterrorhandler() +if seterrorhandler then + seterrorhandler(function(msg) + if msg and string.find(msg, "index a function value") then + local trace = debugstack and debugstack(2, 20, 0) or "no stack available" + DEFAULT_CHAT_FRAME:AddMessage("|cFFFF0000[SCRM DEBUG] index function error:|r " .. tostring(msg)) + DEFAULT_CHAT_FRAME:AddMessage("|cFFFFFF00Stack:|r " .. tostring(trace)) + end + if originalErrorHandler then + return originalErrorHandler(msg) + end + end) +end + +-- Setup to wrap our stuff in a table so we don't pollute the global environment local CleveRoids = _G.CleveRoids or {} _G.CleveRoids = CleveRoids CleveRoids.lastItemIndexTime = 0 @@ -14,6 +30,76 @@ CleveRoids.lastEquipTime = CleveRoids.lastEquipTime or {} CleveRoids.lastWeaponSwapTime = 0 CleveRoids.equipInProgress = false +-- PERFORMANCE: Event throttling to reduce spam from high-frequency events +CleveRoids.lastUnitAuraUpdate = 0 +CleveRoids.lastUnitHealthUpdate = 0 +CleveRoids.lastUnitPowerUpdate = 0 +CleveRoids.EVENT_THROTTLE = 0.1 -- 100ms throttle for high-frequency events + +-- PERFORMANCE: Spell ID cache to avoid repeated GetSpellIdForName lookups +CleveRoids.spellIdCache = {} + +-- PERFORMANCE: Spell name construction cache +CleveRoids.spellNameCache = {} + +-- PERFORMANCE: Upvalues for frequently called global functions (avoid global lookups) +local GetTime = GetTime +local UnitExists = UnitExists +local UnitAffectingCombat = UnitAffectingCombat +local GetContainerItemLink = GetContainerItemLink +local GetContainerItemInfo = GetContainerItemInfo +local GetContainerNumSlots = GetContainerNumSlots +local GetInventoryItemLink = GetInventoryItemLink +local GetItemInfo = GetItemInfo +local PickupContainerItem = PickupContainerItem +local PickupInventoryItem = PickupInventoryItem +local EquipCursorItem = EquipCursorItem +local CursorHasItem = CursorHasItem +local ClearCursor = ClearCursor +local TargetUnit = TargetUnit +local pcall = pcall +local pairs = pairs +local ipairs = ipairs +local type = type +local tonumber = tonumber +local tostring = tostring +local string_find = string.find +local string_lower = string.lower +local string_gsub = string.gsub +local table_insert = table.insert +local table_getn = table.getn + +-- PERFORMANCE: Module-level constant for boolean conditionals (avoid per-call table creation) +local BOOLEAN_CONDITIONALS = { + combat = true, + nocombat = true, + stealth = true, + nostealth = true, + channeled = true, + nochanneled = true, + checkchanneled = true, + checkcasting = true, + dead = true, + alive = true, + help = true, + harm = true, + exists = true, + party = true, + raid = true, + resting = true, + noresting = true, + isplayer = true, + isnpc = true, + mhimbue = true, + nomhimbue = true, + ohimbue = true, + noohimbue = true, + group = true, + nogroup = true, + moving = true, + nomoving = true, +} + local requirementCheckFrame = CreateFrame("Frame") requirementCheckFrame:RegisterEvent("ADDON_LOADED") requirementCheckFrame:SetScript("OnEvent", function() @@ -31,7 +117,7 @@ requirementCheckFrame:SetScript("OnEvent", function() CleveRoids.Print("https://github.com/balakethelock/SuperWoW") end if not hasNampower then - CleveRoids.Print("|cFFFF0000SuperCleveRoidMacros|r requires |cFF00FFFFpepopo978's Nampower|r:") + CleveRoids.Print("|cFFFF0000SuperCleveRoidMacros|r requires |cFF00FFFFAvitasia's Nampower|r:") CleveRoids.Print("https://gitea.com/avitasia/nampower") end if not hasUnitXP then @@ -80,79 +166,140 @@ local function GetInventoryIdFromSlot(slotName) return SLOT_TO_INVID[slotName] or GetInventorySlotInfo(slotName) end -local function IsSlotOnCooldown(slot) - local now = GetTime() +-- Check if slot 18 is a relic (no GCD) for current player class +local function IsRelicSlot(slot) + if slot ~= 18 then return false end + local playerClass = CleveRoids.playerClass + return playerClass == "PALADIN" or playerClass == "DRUID" or playerClass == "SHAMAN" +end + +-- PERFORMANCE: Cache GetTime() result for cooldown checks within same frame +-- Slot 18 (relic/idol/libram/totem) has no GCD for Paladin/Druid/Shaman +local function IsSlotOnCooldown(slot, now) + -- Relics have no GCD - skip cooldown check entirely + if IsRelicSlot(slot) then + return false + end + + now = now or GetTime() local slotTime = CleveRoids.lastEquipTime[slot] or 0 local globalTime = CleveRoids.lastGlobalEquipTime or 0 - if (now - slotTime) < CleveRoids.EQUIP_COOLDOWN then - return true - end - - if (now - globalTime) < CleveRoids.EQUIP_GLOBAL_COOLDOWN then - return true - end - - return false + return (now - slotTime) < CleveRoids.EQUIP_COOLDOWN or + (now - globalTime) < CleveRoids.EQUIP_GLOBAL_COOLDOWN end -local function PerformEquipSwap(item, inventoryId) +-- PERFORMANCE: Consolidated cursor handling, fewer CursorHasItem() calls +local function PerformEquipSwap(item, inventoryId, useQueueScript) if not item or not inventoryId then return false end -- Check if in combat and swapping weapons - local inCombat = UnitAffectingCombat("player") - local isWeapon = (inventoryId == 16 or inventoryId == 17 or inventoryId == 18) + -- Slot 18 (ranged) is only a weapon for Hunter/Warrior/Rogue/Mage/Warlock/Priest + -- For Druid/Paladin/Shaman, slot 18 is idol/libram/totem - can swap freely + local isWeapon = (inventoryId == 16 or inventoryId == 17) + if inventoryId == 18 then + -- Use cached playerClass for performance + local playerClass = CleveRoids.playerClass + -- Only treat slot 18 as weapon for classes that use ranged weapons + isWeapon = (playerClass == "HUNTER" or playerClass == "WARRIOR" or + playerClass == "ROGUE" or playerClass == "MAGE" or + playerClass == "WARLOCK" or playerClass == "PRIEST") + end - if inCombat and isWeapon then + if isWeapon and UnitAffectingCombat("player") then -- Don't swap while casting if CleveRoids.CurrentSpell.type ~= "" then return false end - -- Check for on-swing spells if available + -- Check for on-swing spells if available (Nampower) if GetCurrentCastingInfo then - local _, _, _, _, _, onswing = GetCurrentCastingInfo() + local castId, _, _, casting, channeling, onswing = GetCurrentCastingInfo() + if onswing == 1 then return false end + + -- If casting/channeling and QueueScript available, queue the swap for after cast + if (casting == 1 or channeling == 1) and QueueScript and useQueueScript and item.name then + local script = string.format('EquipItemByName("%s",%d)', item.name, inventoryId) + QueueScript(script, 3) -- Priority 3 = after queued spells + return true + end end end - -- Try to equip - local success = false + -- PERFORMANCE: Try EquipItemByName first if available (handles everything internally) + if item.name and EquipItemByName then + local ok = pcall(EquipItemByName, item.name, inventoryId) + if ok then return true end + end - -- Method 1: Use item by bag/slot + -- Fallback: Manual pickup and equip if item.bagID and item.slot then PickupContainerItem(item.bagID, item.slot) - if CursorHasItem() then - EquipCursorItem(inventoryId) - success = not CursorHasItem() - end - end - - -- Method 2: Use item by inventory ID - if not success and item.inventoryID then + elseif item.inventoryID then PickupInventoryItem(item.inventoryID) - if CursorHasItem() then - EquipCursorItem(inventoryId) - success = not CursorHasItem() - end + else + return false end - -- Method 3: Use EquipItemByName (SuperWoW) - if not success and item.name and EquipItemByName then - local ok = pcall(EquipItemByName, item.name, inventoryId) - success = ok - end - - -- Clear cursor - if CursorHasItem() then - ClearCursor() + -- Single cursor check after pickup attempt + if not CursorHasItem() then + return false end + EquipCursorItem(inventoryId) + local success = not CursorHasItem() + ClearCursor() return success end +-- PERFORMANCE: Get a queue entry from pool or create new +local function GetQueueEntry() + local pool = CleveRoids.queueEntryPool + local n = pool and table_getn(pool) or 0 + if n > 0 then + local entry = pool[n] + pool[n] = nil + return entry + end + return {} +end + +-- PERFORMANCE: Return entry to pool for reuse +local function ReleaseQueueEntry(entry) + if not entry then return end + -- Clear the entry (set to nil, don't create new table) + entry.item = nil + entry.slotName = nil + entry.inventoryId = nil + entry.queueTime = nil + entry.retries = nil + entry.maxRetries = nil + entry.itemId = nil + -- Add to pool (max 10 pooled entries) + local pool = CleveRoids.queueEntryPool + if table_getn(pool) < 10 then + table_insert(pool, entry) + end +end + +-- PERFORMANCE: Swap-and-pop removal (O(1) instead of O(n)) +local function RemoveQueueEntry(i) + local queue = CleveRoids.equipmentQueue + local n = CleveRoids.equipmentQueueLen + local entry = queue[i] + + if i < n then + queue[i] = queue[n] + end + queue[n] = nil + CleveRoids.equipmentQueueLen = n - 1 + + ReleaseQueueEntry(entry) +end + -- Queue equipment swap function function CleveRoids.QueueEquipItem(item, slotName) if not item or not slotName then return false end @@ -161,56 +308,84 @@ function CleveRoids.QueueEquipItem(item, slotName) if not inventoryId then return false end local now = GetTime() + local itemId = item.id - -- Try immediate equip if not on cooldown - if not IsSlotOnCooldown(inventoryId) then - local success = PerformEquipSwap(item, inventoryId) + -- PERFORMANCE: Check for duplicate queue entries before adding + local queue = CleveRoids.equipmentQueue + local queueLen = CleveRoids.equipmentQueueLen + for i = 1, queueLen do + local q = queue[i] + if q and q.inventoryId == inventoryId and q.itemId == itemId then + return false -- Already queued + end + end + + -- Try immediate equip if not on cooldown (use QueueScript for smoother mid-cast swaps) + if not IsSlotOnCooldown(inventoryId, now) then + local success = PerformEquipSwap(item, inventoryId, true) if success then - CleveRoids.lastEquipTime[inventoryId] = now - CleveRoids.lastGlobalEquipTime = now + -- Don't set cooldowns for relic slots (no GCD) + if not IsRelicSlot(inventoryId) then + CleveRoids.lastEquipTime[inventoryId] = now + CleveRoids.lastGlobalEquipTime = now + end return true end end - -- Queue for later - table.insert(CleveRoids.equipmentQueue, { - item = item, - slotName = slotName, - inventoryId = inventoryId, - queueTime = now, - retries = 0, - maxRetries = 5 - }) + -- Queue for later using pooled entry + local entry = GetQueueEntry() + entry.item = item + entry.slotName = slotName + entry.inventoryId = inventoryId + entry.queueTime = now + entry.retries = 0 + entry.maxRetries = 5 + entry.itemId = itemId + + CleveRoids.equipmentQueueLen = queueLen + 1 + queue[CleveRoids.equipmentQueueLen] = entry + + -- PERFORMANCE: Start the queue processing frame + if CleveRoids.equipQueueFrame then + CleveRoids.equipQueueFrame:Show() + end return false end --- Process equipment queue (called from OnUpdate) +-- PERFORMANCE: Process equipment queue (called from self-disabling frame) function CleveRoids.ProcessEquipmentQueue() - if not CleveRoids.equipmentQueue or table.getn(CleveRoids.equipmentQueue) == 0 then - return - end + local queueLen = CleveRoids.equipmentQueueLen + if queueLen == 0 then return end local now = GetTime() + local queue = CleveRoids.equipmentQueue local i = 1 - while i <= table.getn(CleveRoids.equipmentQueue) do - local queued = CleveRoids.equipmentQueue[i] + while i <= CleveRoids.equipmentQueueLen do + local queued = queue[i] - -- Check if cooldown passed - if not IsSlotOnCooldown(queued.inventoryId) then - local success = PerformEquipSwap(queued.item, queued.inventoryId) + -- Remove expired entries first (>10 seconds old) + if (now - queued.queueTime) > 10 then + RemoveQueueEntry(i) + -- Don't increment i, we swapped in the last element + elseif not IsSlotOnCooldown(queued.inventoryId, now) then + -- Try to equip (use QueueScript for smoother mid-cast swaps) + local success = PerformEquipSwap(queued.item, queued.inventoryId, true) if success then - CleveRoids.lastEquipTime[queued.inventoryId] = now - CleveRoids.lastGlobalEquipTime = now - table.remove(CleveRoids.equipmentQueue, i) + -- Don't set cooldowns for relic slots (no GCD) + if not IsRelicSlot(queued.inventoryId) then + CleveRoids.lastEquipTime[queued.inventoryId] = now + CleveRoids.lastGlobalEquipTime = now + end + RemoveQueueEntry(i) else queued.retries = queued.retries + 1 - if queued.retries >= queued.maxRetries then - table.remove(CleveRoids.equipmentQueue, i) + RemoveQueueEntry(i) else i = i + 1 end @@ -218,14 +393,46 @@ function CleveRoids.ProcessEquipmentQueue() else i = i + 1 end - - -- Remove expired entries (>10 seconds old) - if queued and (now - queued.queueTime) > 10 then - table.remove(CleveRoids.equipmentQueue, i) - end end end +-- PERFORMANCE: Self-disabling frame for queue processing with throttling +-- Only processes every EQUIP_QUEUE_INTERVAL instead of every frame +CleveRoids.EQUIP_QUEUE_INTERVAL = 0.1 -- 100ms between queue checks (was 50ms) +CleveRoids.equipQueueLastUpdate = 0 +CleveRoids.equipQueueFrame = CreateFrame("Frame") +CleveRoids.equipQueueFrame:Hide() + +-- PERFORMANCE: Upvalue for faster access in OnUpdate +local equipQueueFrame = CleveRoids.equipQueueFrame +local equipQueueInterval = CleveRoids.EQUIP_QUEUE_INTERVAL + +equipQueueFrame:SetScript("OnUpdate", function() + -- Prevent operations during shutdown (crash prevention) + if CleveRoids.isShuttingDown then return end + + -- PERFORMANCE: Use arg1 (elapsed time) if available, otherwise GetTime() + local elapsed = arg1 + if elapsed then + CleveRoids.equipQueueLastUpdate = (CleveRoids.equipQueueLastUpdate or 0) + elapsed + if CleveRoids.equipQueueLastUpdate < equipQueueInterval then + return -- Throttle: skip this frame + end + CleveRoids.equipQueueLastUpdate = 0 + else + local now = GetTime() + if (now - (CleveRoids.equipQueueLastUpdate or 0)) < equipQueueInterval then + return + end + CleveRoids.equipQueueLastUpdate = now + end + + CleveRoids.ProcessEquipmentQueue() + if CleveRoids.equipmentQueueLen == 0 then + this:Hide() + end +end) + -- Improved DisableAddon function function CleveRoids.DisableAddon(reason) -- Prevent multiple disable calls @@ -252,7 +459,7 @@ function CleveRoids.DisableAddon(reason) (reason and (": " .. tostring(reason)) or "")) end - SlashCmdList.CLEVEROIDS = disabledMsg + SlashCmdList.CLEVEROID = disabledMsg SlashCmdList.CAST = disabledMsg SlashCmdList.USE = disabledMsg SlashCmdList.EQUIP = disabledMsg @@ -290,6 +497,12 @@ end) function CleveRoids.QueueActionUpdate() if CleveRoidMacros.realtime == 0 then CleveRoids.isActionUpdateQueued = true + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cffff00ff[QueueActionUpdate]|r Queued, isActionUpdateQueued = %s", + tostring(CleveRoids.isActionUpdateQueued)) + ) + end end end @@ -304,6 +517,9 @@ local _ReagentBySpell = { ["Blind"] = "Blinding Powder", -- 5530 } +-- Expose for use in Generic.lua (IndexSpells fallback) +CleveRoids.ReagentBySpell = _ReagentBySpell + -- Minimal map for rogue reagents; extend as needed local _ReagentIdByName = { ["Flash Powder"] = 5140, -- Vanish @@ -333,34 +549,74 @@ local function CRM_GetBagScanTip() end -- Return total in *bags only* (not bank), by id when possible; fallback to name +-- PERFORMANCE: Uses CleveRoids.Items cache for O(1) lookup when available function CleveRoids.GetReagentCount(reagentName) if not reagentName or reagentName == "" then return 0 end + -- Fast path: check cache first + local Items = CleveRoids.Items + if Items then + local wantId = _ReagentIdByName[reagentName] + + -- Try by ID first if we have a mapping + if wantId then + local itemName = Items[wantId] + if itemName then + local itemData = Items[itemName] + if itemData and itemData.count and not itemData.inventoryID then + -- Item is in bags (not equipped), return cached count + return itemData.count + end + end + end + + -- Try by name + local itemData = Items[reagentName] + if type(itemData) == "string" then + itemData = Items[itemData] -- Resolve indirection + end + if itemData and type(itemData) == "table" and itemData.count and not itemData.inventoryID then + return itemData.count + end + + -- Try lowercase + local lowerName = string.lower(reagentName) + local resolved = Items[lowerName] + if type(resolved) == "string" then + itemData = Items[resolved] + elseif type(resolved) == "table" then + itemData = resolved + end + if itemData and type(itemData) == "table" and itemData.count and not itemData.inventoryID then + return itemData.count + end + end + + -- Slow path fallback: full bag scan (only when cache miss) local wantId = _ReagentIdByName[reagentName] local total = 0 for bag = 0, 4 do local slots = GetContainerNumSlots(bag) or 0 for slot = 1, slots do - local _, count = GetContainerItemInfo(bag, slot) - count = count or 0 - -- Prefer link → id when available - local link = (GetContainerItemLink and GetContainerItemLink(bag, slot)) or nil + local _, count = GetContainerItemInfo(bag, slot) + count = count or 0 + local link = GetContainerItemLink and GetContainerItemLink(bag, slot) if link then - local _, _, idstr = string.find(link, "item:(%d+)") - local id = idstr and tonumber(idstr) or nil + local _, _, idstr = string.find(link, "item:(%d+)") + local id = idstr and tonumber(idstr) or nil if (wantId and id == wantId) or (not wantId and string.find(link, "%["..reagentName.."%]")) then - total = total + (count or 0) + total = total + count end else - -- Fallback: scan bag slot tooltip for the name + -- Fallback: scan bag slot tooltip for the name (expensive, only when no link) local tip = CRM_GetBagScanTip() tip:ClearLines() tip:SetBagItem(bag, slot) local left1 = _G[tip:GetName().."TextLeft1"] local name = left1 and left1:GetText() if name and name == reagentName then - total = total + (count or 0) + total = total + count end end end @@ -439,7 +695,7 @@ function CleveRoids.GetSpellCost(spellSlot, bookType) if not reagent then local name = GetSpellName(spellSlot, bookType) if name then - name = string.gsub(name, "%s*%(.-%)%s*$", "") -- strip "(Rank X)" + name = string.gsub(name, "%s*%(%s*Rank%s+%d+%s*%)%s*$", "") -- strip "(Rank X)" only reagent = _ReagentBySpell[name] end end @@ -452,6 +708,46 @@ function CleveRoids.GetProxyActionSlot(slot) return CleveRoids.actionSlots[slot] or CleveRoids.actionSlots[slot.."()"] end +-- Resolves nested macro references for #showtooltip propagation +-- If an action is a {MacroName} reference and that macro has #showtooltip, +-- returns the inner macro's active action; otherwise returns nil +-- depth parameter prevents infinite recursion +function CleveRoids.ResolveNestedMacroActive(action, depth) + if not action or not action.action then return nil end + depth = depth or 0 + if depth > 5 then return nil end -- prevent infinite recursion + + -- Check if this action is a macro reference {MacroName} + local macroName = CleveRoids.GetMacroNameFromAction(action.action) + if not macroName then return nil end + + -- Get or parse the inner macro + local innerMacro = CleveRoids.GetMacro(macroName) + if not innerMacro then + innerMacro = CleveRoids.ParseMacro(macroName) + end + if not innerMacro or not innerMacro.actions then return nil end + + -- Check if inner macro has #showtooltip (indicated by having a tooltip or action list) + if not innerMacro.actions.tooltip and table.getn(innerMacro.actions.list or {}) == 0 then + return nil + end + + -- Run TestForActiveAction on inner macro to get its current active + CleveRoids.TestForActiveAction(innerMacro.actions) + + -- If inner macro has an active action, check if it's also a nested macro + if innerMacro.actions.active then + local deeperActive = CleveRoids.ResolveNestedMacroActive(innerMacro.actions.active, depth + 1) + if deeperActive then + return deeperActive + end + return innerMacro.actions.active + end + + return nil +end + function CleveRoids.TestForActiveAction(actions) if not actions then return end local currentActive = actions.active @@ -459,27 +755,99 @@ function CleveRoids.TestForActiveAction(actions) local hasActive = false local newActiveAction = nil local newSequence = nil + local firstUnconditional = nil - 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 - actions.active = actions.tooltip + newActiveAction = actions.tooltip + 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) + if resolved then + newActiveAction = resolved + else + -- Inner macro didn't resolve - no active action + hasActive = false + newActiveAction = nil + end + end end else + -- First pass: find first action with conditionals that passes for _, action in ipairs(actions.list) do - -- break on first action that passes tests - if CleveRoids.TestAction(action.cmd, action.args) then + local result = CleveRoids.TestAction(action.cmd, action.args) + + -- Check if action has conditionals + local _, conditionals = CleveRoids.GetParsedMsg(action.args) + + -- Skip this action for icon display if it has the '?' prefix + -- Note: CleveRoids._ignoretooltip is a side-effect flag set by GetParsedMsg + local shouldSkipForIcon = CleveRoids._ignoretooltip == 1 + + -- break on first action that passes tests (unless it should be skipped for icon) + if result and not shouldSkipForIcon then hasActive = true 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 + -- Resolve nested macro references for #showtooltip propagation + -- If inner macro doesn't resolve, continue to next action + local macroName = CleveRoids.GetMacroNameFromAction(action.action) + if macroName then + local resolved = CleveRoids.ResolveNestedMacroActive(action, 0) + if resolved then + newActiveAction = resolved + else + -- Inner macro didn't resolve - try next action + hasActive = false + newActiveAction = nil + end + end end if hasActive then break end end + + -- Track first unconditional non-macro action as fallback + if not conditionals and not firstUnconditional and not shouldSkipForIcon then + -- Don't use unresolvable macro refs as fallback + local macroName = CleveRoids.GetMacroNameFromAction(action.action) + if not macroName then + firstUnconditional = action + end + end + end + + -- If no conditional action passed, use first unconditional action + if not hasActive and firstUnconditional then + hasActive = true + newActiveAction = firstUnconditional end end @@ -505,7 +873,9 @@ function CleveRoids.TestForActiveAction(actions) local previousInRange = actions.active.inRange if actions.active.spell then - actions.active.inRange = 1 + -- Default to -1 (unknown) so we fall through to proxy slot or original function + -- Only set to 1/0 when we have a definitive answer from IsSpellInRange + actions.active.inRange = -1 -- Enhanced nampower range check with spell ID support if IsSpellInRange then @@ -513,53 +883,174 @@ function CleveRoids.TestForActiveAction(actions) if unit == "focus" and pfUI and pfUI.uf and pfUI.uf.focus and pfUI.uf.focus.label and pfUI.uf.focus.id then unit = pfUI.uf.focus.label .. pfUI.uf.focus.id end + + -- PERFORMANCE: Cache spell name construction using two-level cache (no string concat for lookup) local castName = actions.active.action if actions.active.spell and actions.active.spell.name then + local spellName = actions.active.spell.name local rank = actions.active.spell.rank or (actions.active.spell.highest and actions.active.spell.highest.rank) if rank and rank ~= "" then - castName = actions.active.spell.name .. "(" .. rank .. ")" - end - end - if UnitExists(unit) then - -- Try to get spell ID for more accurate range check - local checkValue = castName - if GetSpellIdForName then - local spellId = GetSpellIdForName(castName) - if spellId and spellId > 0 then - checkValue = spellId + -- Two-level cache: spellNameCache[spellName][rank] = "SpellName(Rank X)" + local nameCache = CleveRoids.spellNameCache[spellName] + if not nameCache then + nameCache = {} + CleveRoids.spellNameCache[spellName] = nameCache + end + castName = nameCache[rank] + if not castName then + castName = spellName .. "(" .. rank .. ")" + nameCache[rank] = castName end end + end - local r = IsSpellInRange(checkValue, unit) - if r ~= nil then - actions.active.inRange = r + -- PERFORMANCE: Try to get spell ID with caching + local spellId = nil + if GetSpellIdForName then + -- Check cache first + local cachedId = CleveRoids.spellIdCache[castName] + if cachedId then + spellId = cachedId + else + spellId = GetSpellIdForName(castName) + if spellId and spellId > 0 then + CleveRoids.spellIdCache[castName] = spellId + end end end + + -- Check range using API wrapper (handles all cases properly) + local API = CleveRoids.NampowerAPI + local checkValue = spellId and spellId > 0 and spellId or castName + + if UnitExists(unit) then + -- We have a target - use API wrapper for proper range checking + -- API.IsSpellInRange handles: native check, -1 for self-cast, UnitXP fallback + if API then + local r = API.IsSpellInRange(checkValue, unit) + if r ~= nil then + actions.active.inRange = r + end + elseif IsSpellInRange then + -- No API, use native directly + local nativeResult = IsSpellInRange(checkValue, unit) + if nativeResult == 0 or nativeResult == 1 then + actions.active.inRange = nativeResult + elseif nativeResult == -1 then + -- Self-cast/ground-targeted spell, always in range + actions.active.inRange = 1 + end + end + end + -- No target: don't set inRange, let proxy slot / original behavior handle it end - actions.active.oom = (UnitMana("player") < actions.active.spell.cost) + -- Check if spell is usable first (handles forms, stances, and power type correctly) + local isUsableBySpell, notEnoughPower = nil, nil + if IsSpellUsable then + -- pcall to handle spells not in spellbook (Nampower throws error) + local ok, usable, oom = pcall(IsSpellUsable, actions.active.action) + if ok then + isUsableBySpell, notEnoughPower = usable, oom + end + end + + -- For OOM check, use proper mana source + if notEnoughPower ~= nil then + -- Prefer IsSpellUsable result if available (Nampower) + actions.active.oom = (notEnoughPower == 1) + else + -- SuperWoW: UnitMana returns (current power, caster mana) for druids + local currentPower, casterMana = UnitMana("player") + + -- For druids with SuperWoW, use caster mana for spell cost checks + local manaToCheck = currentPower + if CleveRoids.playerClass == "DRUID" and type(casterMana) == "number" then + manaToCheck = casterMana + end + + actions.active.oom = (manaToCheck < actions.active.spell.cost) + end local start, duration = GetSpellCooldown(actions.active.spell.spellSlot, actions.active.spell.bookType) local onCooldown = (start > 0 and duration > 0) if actions.active.isReactive then - -- Use Nampower's IsSpellUsable if available for better detection - if IsSpellUsable then - local usable, oom = IsSpellUsable(actions.active.action) - if usable == 1 and oom ~= 1 then - actions.active.usable = (pfUI and pfUI.bars) and nil or 1 + -- For Overpower, Revenge, Riposte: ONLY use combat log tracking + local spellName = actions.active.action + local useCombatLogOnly = (spellName == "Overpower" or spellName == "Revenge" or spellName == "Riposte") + + if useCombatLogOnly then + -- Only trust HasReactiveProc for these spells + local hasProc = CleveRoids.HasReactiveProc and CleveRoids.HasReactiveProc(spellName) + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff00ff00[UPDATE USABLE]|r %s: hasProc=%s, previousUsable=%s, inRange=%s, oom=%s", + spellName, tostring(hasProc), tostring(previousUsable), tostring(actions.active.inRange), tostring(actions.active.oom)) + ) + end + if hasProc then + -- Proc is active, show as usable if in range and have enough rage/mana + if actions.active.inRange ~= 0 and not actions.active.oom then + actions.active.usable = 1 + elseif pfUI and pfUI.bars and actions.active.oom then + actions.active.usable = 2 -- pfUI: out of mana/rage + else + actions.active.usable = nil + end + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff00ff00[UPDATE USABLE]|r %s: SET usable=%s (proc active)", + spellName, tostring(actions.active.usable)) + ) + end else + -- No proc = not usable actions.active.usable = nil + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff00ff00[UPDATE USABLE]|r %s: SET usable=nil (no proc)", spellName) + ) + end end - actions.active.oom = false - elseif not CleveRoids.IsReactiveUsable(actions.active.action) then - actions.active.oom = false - actions.active.usable = nil else - actions.active.usable = (pfUI and pfUI.bars) and nil or 1 + -- For other reactive spells, use the original fallback logic + -- Check combat log-based proc tracking first (stance-independent) + if CleveRoids.HasReactiveProc and CleveRoids.HasReactiveProc(actions.active.action) then + -- Proc is active, show as usable if in range and have enough rage/mana + if actions.active.inRange ~= 0 and not actions.active.oom then + actions.active.usable = 1 + elseif pfUI and pfUI.bars and actions.active.oom then + actions.active.usable = 2 -- pfUI: out of mana/rage + else + actions.active.usable = nil + end + -- Use Nampower's IsSpellUsable if available (stance-aware fallback) + elseif IsSpellUsable then + -- pcall to handle spells not in spellbook (Nampower throws error) + local ok, usable, oom = pcall(IsSpellUsable, actions.active.action) + if ok and usable == 1 and oom ~= 1 then + actions.active.usable = (pfUI and pfUI.bars) and nil or 1 + else + actions.active.usable = nil + end + actions.active.oom = false + elseif not CleveRoids.IsReactiveUsable(actions.active.action) then + actions.active.oom = false + actions.active.usable = nil + else + actions.active.usable = (pfUI and pfUI.bars) and nil or 1 + end end + elseif isUsableBySpell == 1 and actions.active.inRange ~= 0 then + -- Use IsUsableSpell result if available (handles forms/stances correctly) + actions.active.usable = 1 + elseif isUsableBySpell == 0 then + -- IsSpellUsable returned 0 = wrong stance/form, not enough power type, etc. + actions.active.usable = nil elseif actions.active.inRange ~= 0 and not actions.active.oom then + -- Fallback to mana check ONLY if IsSpellUsable not available actions.active.usable = 1 -- pfUI:actionbar.lua -- update usable [out-of-range = 1, oom = 2, not-usable = 3, default = 0] @@ -568,6 +1059,25 @@ function CleveRoids.TestForActiveAction(actions) else actions.active.usable = nil end + + -- Check if this is a toggled buff ability (Prowl, Shadowmeld) and darken if buff is active + -- This must come AFTER all other usability checks to have final say + -- PERFORMANCE: Cache normalized spell name on the action object to avoid gsub per-frame + local spellName = actions.active._normalizedName + if not spellName then + spellName = string.gsub(actions.active.action, "%s*%(.-%)%s*$", "") + spellName = string.gsub(spellName, "_", " ") + actions.active._normalizedName = spellName + end + + -- PERFORMANCE: Use cached lookup instead of creating table per-call + if CleveRoids.IsToggledBuffAbility(spellName) then + if CleveRoids.ValidatePlayerBuff(spellName) then + -- Buff is active, darken the icon like "wrong stance" (grayed out, not red) + actions.active.usable = nil + actions.active.oom = false -- Make sure we don't show red tint + end + end else actions.active.inRange = 1 actions.active.usable = 1 @@ -576,17 +1086,73 @@ function CleveRoids.TestForActiveAction(actions) actions.active.oom ~= previousOom or actions.active.inRange ~= previousInRange then changed = true + if CleveRoids.debug and actions.active.isReactive then + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cffff00ff[STATE CHANGED]|r %s: usable %s->%s, will send ACTIONBAR_SLOT_CHANGED", + actions.active.action, tostring(previousUsable), tostring(actions.active.usable)) + ) + end end end return changed end +-- PERFORMANCE: Static buffer references for hot path +local _actionsToSlotsBuffer = CleveRoids._actionsToSlotsBuffer +local _slotsBuffer = CleveRoids._slotsBuffer +local _actionsListBuffer = CleveRoids._actionsListBuffer + function CleveRoids.TestForAllActiveActions() - for slot, actions in pairs(CleveRoids.Actions) do + -- PERFORMANCE: Reuse static buffers instead of creating new tables each call + -- Group slots by their actions object to handle shared macro references + local actionsToSlots = _actionsToSlotsBuffer + local actionsList = _actionsListBuffer + local actionsCount = 0 + + -- PERFORMANCE: Use next() directly instead of pairs() to avoid iterator allocation + local Actions = CleveRoids.Actions + local slot, actions = next(Actions) + while slot do + if not actionsToSlots[actions] then + -- Reuse or create slots array from pool + local slots = _slotsBuffer[actions] + if not slots then + slots = {} + _slotsBuffer[actions] = slots + end + slots[1] = slot + slots._count = 1 + actionsToSlots[actions] = slots + actionsCount = actionsCount + 1 + actionsList[actionsCount] = actions + else + local slots = actionsToSlots[actions] + local count = slots._count + 1 + slots[count] = slot + slots._count = count + end + slot, actions = next(Actions, slot) + end + + -- Test each unique actions object once and send events to ALL slots that share it + for i = 1, actionsCount do + local actions = actionsList[i] + local slots = actionsToSlots[actions] local stateChanged = CleveRoids.TestForActiveAction(actions) if stateChanged then - CleveRoids.SendEventForAction(slot, "ACTIONBAR_SLOT_CHANGED", slot) + -- Send event to ALL slots that use this macro + local count = slots._count + for j = 1, count do + CleveRoids.SendEventForAction(slots[j], "ACTIONBAR_SLOT_CHANGED", slots[j]) + end end + -- Clear for reuse (reset count and clear buffer reference) + for j = 1, slots._count do + slots[j] = nil + end + slots._count = 0 + actionsToSlots[actions] = nil + actionsList[i] = nil -- Clear actionsList entry for reuse end end @@ -622,10 +1188,14 @@ function CleveRoids.GetActiveAction(slot) return action and action.active end +-- PERFORMANCE: Static buffer for arg backup +local _originalArgsBuffer = CleveRoids._originalArgsBuffer + function CleveRoids.SendEventForAction(slot, event, ...) local old_this = this - local original_global_args = {} + -- PERFORMANCE: Reuse static buffer instead of creating table each call + local original_global_args = _originalArgsBuffer for i = 1, 10 do original_global_args[i] = _G["arg" .. i] end @@ -684,6 +1254,11 @@ function CleveRoids.SendEventForAction(slot, event, ...) _G["arg" .. i] = original_global_args[i] end + -- FIX: Skip addon handler calls during initial indexing to prevent 120 rapid calls + if CleveRoids._suppressActionHandlers then + return + end + if type(arg) == "table" and arg.n then if arg.n == 0 then @@ -713,18 +1288,154 @@ end -- Executes the given Macro's body -- body: The Macro's body +-- Note on macro flags: +-- stopMacroFlag: Stops ALL remaining lines (propagates to parent macros) +-- skipMacroFlag: Stops only current macro (cleared on return, parent continues) function CleveRoids.ExecuteMacroBody(body,inline) local lines = CleveRoids.splitString(body, "\n") if inline then lines = CleveRoids.splitString(body, "\\n"); end - for k,v in pairs(lines) do - if CleveRoids.stopmacro then - CleveRoids.stopmacro = false - return true - end - ChatFrameEditBox:SetText(v) - ChatEdit_SendText(ChatFrameEditBox) + -- Save parent's stopMacroFlag state (in case we need to preserve it) + local parentStopFlag = CleveRoids.stopMacroFlag + + -- Clear flags at start of this macro execution + -- Note: We DON'T clear if parent already set stopMacroFlag (shouldn't execute at all) + if not parentStopFlag then + CleveRoids.stopMacroFlag = false end + CleveRoids.skipMacroFlag = false -- Always start fresh for skipmacro + + if CleveRoids.macroRefDebug then + CleveRoids.Print("|cff00ffff[MacroRef]|r ExecuteMacroBody called with " .. table.getn(lines) .. " lines") + end + + for k,v in pairs(lines) do + local trimmed = CleveRoids.Trim(v) + local cmdHandled = false + + -- Skip #showtooltip lines - they're only meaningful for action bar display, + -- not for execution. This allows inner macros to have #showtooltip without + -- causing issues when called from other macros. + if string.find(trimmed, "^#showtooltip") then + if CleveRoids.macroRefDebug then + CleveRoids.Print("|cff888888[MacroRef]|r Skipping #showtooltip line in inner macro") + end + cmdHandled = true + end + + -- IMPORTANT: Check for /nofirstaction BEFORE the stop flag check + -- This allows /nofirstaction to clear the stopMacroFlag set by /firstaction + local _, _, nofirstactionArgs = string.find(trimmed, "^/nofirstaction%s*(.*)") + if nofirstactionArgs then + -- Capture stopOnCastFlag BEFORE DoNoFirstAction clears it + -- If it was true, stopMacroFlag was set by /firstaction, not /stopmacro + local wasFirstActionActive = CleveRoids.stopOnCastFlag + CleveRoids.DoNoFirstAction(nofirstactionArgs) + -- Also clear stopMacroFlag if it was set by firstaction mechanism + -- (but NOT if it was set by explicit /stopmacro) + if wasFirstActionActive and CleveRoids.stopMacroFlag then + -- stopOnCastFlag was true before we cleared it, and stopMacroFlag is set + -- This means stopMacroFlag was set by the firstaction mechanism, clear it + CleveRoids.stopMacroFlag = false + if CleveRoids.macroRefDebug then + CleveRoids.Print("|cff00ff00[MacroRef]|r /nofirstaction cleared stopMacroFlag - resuming macro") + end + end + if CleveRoids.macroRefDebug then + CleveRoids.Print("|cff88ff88[MacroRef]|r Executing line " .. k .. ": " .. string.sub(v, 1, 60)) + end + cmdHandled = true + end + + -- Check both macro stop flags before each line (but skip if we just handled /nofirstaction) + -- stopMacroFlag: stop this AND parent macros + -- skipMacroFlag: stop only this macro (parent continues) + if not cmdHandled and (CleveRoids.stopMacroFlag or CleveRoids.skipMacroFlag) then + -- If stopOnCastFlag is true, the stop was caused by the firstaction mechanism + -- Don't break the loop - just skip this line (allows /nofirstaction to be reached) + if CleveRoids.stopOnCastFlag and CleveRoids.stopMacroFlag and not CleveRoids.skipMacroFlag then + if CleveRoids.macroRefDebug then + CleveRoids.Print("|cffff8800[MacroRef]|r Skipping line " .. k .. " (firstaction mode)") + end + cmdHandled = true -- Skip this line but continue loop to allow /nofirstaction + else + -- This is a /stopmacro or /skipmacro stop - actually break + if CleveRoids.macroRefDebug then + local reason = CleveRoids.stopMacroFlag and "/stopmacro" or "/skipmacro" + CleveRoids.Print("|cffff8800[MacroRef]|r Stopped at line " .. k .. " due to " .. reason) + end + break + end + end + + if not cmdHandled then + if CleveRoids.macroRefDebug then + CleveRoids.Print("|cff88ff88[MacroRef]|r Executing line " .. k .. ": " .. string.sub(v, 1, 60)) + end + + -- IMPORTANT: Handle macro control commands directly to bypass Blizzard's built-in /stopmacro + -- Blizzard intercepts bare /stopmacro before it reaches our SlashCmdList handler + + -- Check for /stopmacro (with or without conditionals) + local _, _, stopmacroArgs = string.find(trimmed, "^/stopmacro%s*(.*)") + if stopmacroArgs then + CleveRoids.DoStopMacro(stopmacroArgs) + cmdHandled = true + end + + -- Check for /skipmacro (with or without conditionals) + if not cmdHandled then + local _, _, skipmacroArgs = string.find(trimmed, "^/skipmacro%s*(.*)") + if skipmacroArgs then + CleveRoids.DoSkipMacro(skipmacroArgs) + cmdHandled = true + end + end + + -- Check for /firstaction (with or without conditionals) + if not cmdHandled then + local _, _, firstactionArgs = string.find(trimmed, "^/firstaction%s*(.*)") + if firstactionArgs then + CleveRoids.DoFirstAction(firstactionArgs) + cmdHandled = true + end + end + + -- FIX: Handle /startattack directly - not a native vanilla WoW command + -- Without SuperMacro, ChatEdit_SendText may not route addon slash commands properly + if not cmdHandled then + local _, _, startattackArgs = string.find(trimmed, "^/startattack%s*(.*)") + if startattackArgs then + if SlashCmdList.STARTATTACK then + SlashCmdList.STARTATTACK(startattackArgs) + end + cmdHandled = true + end + end + + -- FIX: Handle /stopattack directly - not a native vanilla WoW command + if not cmdHandled then + local _, _, stopattackArgs = string.find(trimmed, "^/stopattack%s*(.*)") + if stopattackArgs then + if SlashCmdList.STOPATTACK then + SlashCmdList.STOPATTACK(stopattackArgs) + end + cmdHandled = true + end + end + end + + -- For all other commands, use ChatEdit_SendText + if not cmdHandled then + ChatFrameEditBox:SetText(v) + ChatEdit_SendText(ChatFrameEditBox) + end + end + + -- Clear skipMacroFlag after this macro completes (don't propagate to parent) + -- stopMacroFlag is intentionally LEFT SET so it propagates up + CleveRoids.skipMacroFlag = false + return true end @@ -736,40 +1447,86 @@ function CleveRoids.GetMacroBody(name) return macro and macro.body end --- Attempts to execute a macro by the given name (Blizzard or Super tab) +-- Attempts to execute a macro by the given name or index (Blizzard or Super tab) -- Returns: true if something was executed, false otherwise +-- Supports: macro name (string), macro index (number or numeric string like "19") +-- Uses CleveRoids' own ExecuteMacroBody for processing (supports enhanced syntax) function CleveRoids.ExecuteMacroByName(name) - if not name or name == "" then return false end + if not name or name == "" then + if CleveRoids.macroRefDebug then + CleveRoids.Print("|cffff0000[MacroRef]|r ExecuteMacroByName called with empty name") + end + return false + end + + if CleveRoids.macroRefDebug then + CleveRoids.Print("|cff00ffff[MacroRef]|r ExecuteMacroByName called with: '" .. name .. "'") + end local body + local source = nil - -- 1) Blizzard macro runner by name/ID - local id = GetMacroIndexByName(name) - if id and id ~= 0 and type(RunMacro) == "function" then - if pcall(RunMacro, name) then return true end - if pcall(RunMacro, id) then return true end - local _n, _tex, b2 = GetMacroInfo(id) - if b2 and b2 ~= "" then body = body or b2 end - end + -- Check if input is a numeric index (e.g., "19" or 19) + local numericIndex = tonumber(name) - -- 2) SuperMacro runner by name - if type(GetSuperMacroInfo) == "function" and type(RunSuperMacro) == "function" then - local _n2, _t2, b3 = GetSuperMacroInfo(name) - if b3 and b3 ~= "" then - if pcall(RunSuperMacro, name) then return true end - body = body or b3 + -- 1) Try Blizzard macro (by index or name) + if numericIndex then + -- Numeric index - get body directly (supports character macros 19-36) + local _n, _tex, b = GetMacroInfo(numericIndex) + if b and b ~= "" then + body = b + source = "Blizzard (index " .. numericIndex .. ")" + end + else + -- String name - look up by name + local id = GetMacroIndexByName(name) + if CleveRoids.macroRefDebug then + CleveRoids.Print("|cff888888[MacroRef]|r GetMacroIndexByName('" .. name .. "') = " .. tostring(id)) + end + if id and id ~= 0 then + local _n, _tex, b = GetMacroInfo(id) + if b and b ~= "" then + body = b + source = "Blizzard (slot " .. id .. ")" + end end end - -- 3) CRM cache fallback - if not body and type(CleveRoids.GetMacro) == "function" then - local m = CleveRoids.GetMacro(name) - if m and m.body and m.body ~= "" then - body = m.body + -- 2) SuperMacro's Super macros (by name only, not index) + -- These are the 7000-char extended macros stored in SM_SUPER + if not body and not numericIndex then + if type(GetSuperMacroInfo) == "function" then + local _n2, _t2, b2 = GetSuperMacroInfo(name) + if b2 and b2 ~= "" then + body = b2 + source = "SuperMacro" + end end end - if not body or body == "" then return false end + -- 3) CRM cache fallback (by name only) + if not body and not numericIndex then + if type(CleveRoids.GetMacro) == "function" then + local m = CleveRoids.GetMacro(name) + if m and m.body and m.body ~= "" then + body = m.body + source = "CRM cache" + end + end + end + + if not body or body == "" then + if CleveRoids.macroRefDebug then + CleveRoids.Print("|cffff0000[MacroRef]|r Macro '" .. name .. "' not found in any source") + end + return false + end + + if CleveRoids.macroRefDebug then + CleveRoids.Print("|cff00ff00[MacroRef]|r Found macro '" .. name .. "' from " .. source) + end + + -- Execute using CleveRoids' processor (supports conditionals, extended syntax) return CleveRoids.ExecuteMacroBody(body) end @@ -948,7 +1705,9 @@ function CleveRoids.ParseSequence(text) status = 0, lastUpdate = 0, args = args, + cond = condBlock or "", -- Store conditional block for evaluation in DoCastSequence list = {}, + perTargetState = {}, -- [guid] = {index = N, lastUpdate = T} for reset=target } -- fill reset rules: seconds or flags (target/combat/alt/ctrl/shift) @@ -1007,30 +1766,31 @@ function CleveRoids.ParseMacro(name) line = CleveRoids.Trim(line) local cmd, args = CleveRoids.SplitCommandAndArgs(line) - -- check for #showtooltip + -- check for #showtooltip on first line if i == 1 then local _, _, st, _, tt = string.find(line, "(#showtooltip)(%s?(.*))") - -- if no #showtooltip, nothing to keep track of - if not st then - break - end + if st then + hasShowTooltip = true + tt = CleveRoids.Trim(tt) - hasShowTooltip = true - tt = CleveRoids.Trim(tt) - - -- #showtooltip and item/spell/macro specified, only use this tooltip - if st and tt ~= "" then - for _, arg in ipairs(CleveRoids.splitStringIgnoringQuotes(tt)) do - macro.actions.tooltip = CleveRoids.CreateActionInfo(arg) - local action = CleveRoids.CreateActionInfo(CleveRoids.GetParsedMsg(arg)) - action.cmd = "/cast" - action.args = arg - action.isReactive = CleveRoids.reactiveSpells[action.action] - table.insert(macro.actions.list, action) + -- #showtooltip with explicit item/spell/macro specified + if tt ~= "" then + showTooltipHasArg = true + for _, arg in ipairs(CleveRoids.splitStringIgnoringQuotes(tt)) do + -- Parse the arg to extract the spell name from any conditionals + local parsedArg = CleveRoids.GetParsedMsg(arg) + macro.actions.tooltip = CleveRoids.CreateActionInfo(parsedArg) + local action = CleveRoids.CreateActionInfo(parsedArg) + action.cmd = "/cast" + action.args = arg + action.isReactive = CleveRoids.reactiveSpells[action.action] + table.insert(macro.actions.list, action) + end end - break + -- Continue parsing remaining lines for action list (needed for nested macro resolution) end + -- No #showtooltip: still continue parsing to build action list for inner macro resolution else -- make sure we have a testable action if line ~= "" and args ~= "" and CleveRoids.dynamicCmds[cmd] then @@ -1052,6 +1812,14 @@ function CleveRoids.ParseMacro(name) end end + -- If #showtooltip was present but had no argument, use the first action as the tooltip + if hasShowTooltip and not showTooltipHasArg and table.getn(macro.actions.list) > 0 then + macro.actions.tooltip = macro.actions.list[1] + end + + -- Store whether #showtooltip had an explicit argument (for icon fallback logic) + macro.actions.explicitTooltip = showTooltipHasArg + CleveRoids.Macros[name] = macro return macro end @@ -1081,7 +1849,7 @@ function CleveRoids.ParseMsg(msg) -- store the raw action for callers and strip trailing "(Rank X)" for comparisons conditionals.action = action - action = string.gsub(action, "%s*%(.-%)%s*$", "") + action = string.gsub(action, "%s*%(%s*Rank%s+%d+%s*%)%s*$", "") -- IMPORTANT: if there's NO conditional block, return nil conditionals so -- DoWithConditionals will hit the {macroName} execution branch. @@ -1092,6 +1860,11 @@ function CleveRoids.ParseMsg(msg) local spamCond = CleveRoids.GetSpammableConditional(action) if spamCond then conditionals[spamCond] = { action } + -- Also create _groups entry for consistency with Multi() + if not conditionals._groups then + conditionals._groups = {} + end + conditionals._groups[spamCond] = { { values = { action }, operator = "OR" } } end end if cancelAura ~= "" then @@ -1109,6 +1882,11 @@ function CleveRoids.ParseMsg(msg) local spamCond = CleveRoids.GetSpammableConditional(action) if spamCond then conditionals[spamCond] = { action } + -- Also create _groups entry so Multi() finds it when combined with explicit conditionals + if not conditionals._groups then + conditionals._groups = {} + end + conditionals._groups[spamCond] = { { values = { action }, operator = "OR" } } end end if cancelAura and cancelAura ~= "" then @@ -1133,31 +1911,16 @@ function CleveRoids.ParseMsg(msg) -- No args → the action is the implicit argument if not args or args == "" then if not conditionals[condition] then - -- Check if this is a boolean conditional (combat, stealth, channeled, etc.) - local booleanConditionals = { - combat = true, - nocombat = true, - stealth = true, - nostealth = true, - channeled = true, - nochanneled = true, - dead = true, - alive = true, - help = true, - harm = true, - exists = true, - party = true, - raid = true, - resting = true, - noresting = true, - isplayer = true, - isnpc = true, - } - - if booleanConditionals[condition] then + -- PERFORMANCE: Use module-level constant instead of creating table per-call + if BOOLEAN_CONDITIONALS[condition] then conditionals[condition] = true else conditionals[condition] = conditionals.action + -- Create first group for non-boolean conditionals + if not conditionals._groups then + conditionals._groups = {} + end + conditionals._groups[condition] = { { values = { conditionals.action }, operator = "OR" } } end else -- existing code for when conditionals[condition] already exists @@ -1165,17 +1928,78 @@ function CleveRoids.ParseMsg(msg) conditionals[condition] = { conditionals[condition] } end table.insert(conditionals[condition], conditionals.action) + + -- Multiple instances of same conditional = AND logic + if not conditionals._operators then + conditionals._operators = {} + end + conditionals._operators[condition] = "AND" + + -- Add new group for this instance + if not conditionals._groups then + conditionals._groups = {} + end + if not conditionals._groups[condition] then + conditionals._groups[condition] = {} + end + table.insert(conditionals._groups[condition], { values = { conditionals.action }, operator = "OR" }) end else -- Has args. Ensure the key's value is a table and add new arguments. + -- Track if this conditional already existed (repeated via comma) + local conditionAlreadyExists = conditionals[condition] ~= nil + if not conditionals[condition] then conditionals[condition] = {} elseif type(conditionals[condition]) ~= "table" then conditionals[condition] = { conditionals[condition] } end - -- Split args by '/' for multiple values - for _, arg_item in CleveRoids.splitString(args, "/") do + -- Detect which separator is used: / (OR) or & (AND) + -- Initialize metadata tables if needed + if not conditionals._operators then + conditionals._operators = {} + end + if not conditionals._groups then + conditionals._groups = {} + end + + -- Check which separator is present in the CURRENT args + local hasSlash = string.find(args, "/") + local hasAmpersand = string.find(args, "&") + + -- Detect if & is part of a multi-comparison pattern (e.g., >0&<10) + -- Pattern: operator+number followed by & (with optional whitespace) followed by operator + -- IMPORTANT: Only whitespace allowed around &, NOT letters (to distinguish from Rip>3&Rake>3) + local isMultiComparison = hasAmpersand and string.find(args, "[>~=<]+%d+%.?%d*%s*&%s*[>~=<]") + + local separator = "/" + local operatorType = "OR" + + if hasAmpersand and not hasSlash and not isMultiComparison then + separator = "&" + operatorType = "AND" + elseif hasAmpersand and hasSlash then + -- Both separators present - default to / (OR) and warn + -- Could add a warning here in the future + separator = "/" + operatorType = "OR" + end + + -- Store the operator type for this conditional (for backwards compat) + -- Note: when groups exist, Multi/NegatedMulti will use per-group operators + conditionals._operators[condition] = operatorType + + -- Create a new group for this conditional instance + -- Structure: { values = { ... }, operator = "OR" or "AND" } + if not conditionals._groups[condition] then + conditionals._groups[condition] = {} + end + local currentGroup = { values = {}, operator = operatorType } + table.insert(conditionals._groups[condition], currentGroup) + + -- Split args by the determined separator + for _, arg_item in CleveRoids.splitString(args, separator) do local processed_arg = CleveRoids.Trim(arg_item) processed_arg = string.gsub(processed_arg, '"', "") @@ -1193,15 +2017,18 @@ function CleveRoids.ParseMsg(msg) if not operator or not amount then -- No operator found, treat as simple string argument table.insert(conditionals[condition], processed_arg) + table.insert(currentGroup.values, processed_arg) else local name_to_use = (name and name ~= "") and name or conditionals.action local final_amount_str, num_replacements = string.gsub(amount, "#", "") local should_check_stacks = (num_replacements == 1) - -- SPECIAL HANDLING FOR STAT CONDITIONALS WITH MULTIPLE COMPARISONS - -- Detect if this is a stat conditional with multiple operators - -- Example: "ap>1800/<2200" should create comparisons for both >1800 and <2200 - if (condition == "stat" or condition == "nostat") and string.find(processed_arg, "[>~=<]+%d+[^%d]+[>~=<]") then + -- SPECIAL HANDLING FOR CONDITIONALS WITH MULTIPLE COMPARISONS + -- Detect if this conditional has multiple operators + -- Example: "ap>1800/<2200" or "Recently_Bandaged>0&<10" or "health>50&<80" + -- Works with ANY conditional that supports numeric operators + -- Pattern: only whitespace or separators allowed between comparisons, NOT letters + if string.find(processed_arg, "[>~=<]+%d+%.?%d*%s*[/&]%s*[>~=<]") then -- This arg has multiple comparisons, parse them all local stat_name = name_to_use local comparisons = {} @@ -1220,27 +2047,33 @@ function CleveRoids.ParseMsg(msg) end if table.getn(comparisons) > 0 then - table.insert(conditionals[condition], { + local entry = { name = CleveRoids.Trim(stat_name), comparisons = comparisons -- Store all comparisons - }) + } + table.insert(conditionals[condition], entry) + table.insert(currentGroup.values, entry) else -- Fallback to single comparison if parsing failed - table.insert(conditionals[condition], { + local entry = { name = CleveRoids.Trim(name_to_use), operator = operator, amount = tonumber(final_amount_str), checkStacks = should_check_stacks - }) + } + table.insert(conditionals[condition], entry) + table.insert(currentGroup.values, entry) end else -- Normal single-comparison conditional (existing behavior) - table.insert(conditionals[condition], { + local entry = { name = CleveRoids.Trim(name_to_use), operator = operator, amount = tonumber(final_amount_str), checkStacks = should_check_stacks - }) + } + table.insert(conditionals[condition], entry) + table.insert(currentGroup.values, entry) end end end @@ -1256,17 +2089,20 @@ end function CleveRoids.GetParsedMsg(msg) if not msg then return end - -- ALWAYS refresh the side-flag for '?' even when we hit the cache - local _, ignorecount = string.gsub(CleveRoids.Trim(msg), "^%?", "") - CleveRoids._ignoretooltip = ignorecount - + -- PERFORMANCE: Check cache first before doing string operations local cached = CleveRoids.ParsedMsg[msg] if cached then - -- keep a per-msg copy too (helps future readers/tools) - cached.ignoretooltip = cached.ignoretooltip or ignorecount + -- Use cached ignoretooltip value (already computed during first parse) + CleveRoids._ignoretooltip = cached.ignoretooltip or 0 return cached.action, cached.conditionals end + -- Only compute ignoretooltip for new messages (not cache hits) + -- PERFORMANCE: Use string.sub for simple prefix check instead of gsub + local trimmed = CleveRoids.Trim(msg) + local ignorecount = (string.sub(trimmed, 1, 1) == "?") and 1 or 0 + CleveRoids._ignoretooltip = ignorecount + local action, conditionals = CleveRoids.ParseMsg(msg) CleveRoids.ParsedMsg[msg] = { action = action, @@ -1290,13 +2126,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 @@ -1377,13 +2241,16 @@ function CleveRoids.TestAction(cmd, args) CleveRoids.FixEmptyTarget(conditionals) - for k, v in pairs(conditionals) do + -- PERFORMANCE: Use next() directly instead of pairs() to avoid iterator allocation + local k, v = next(conditionals) + while k do if not CleveRoids.ignoreKeywords[k] then if not CleveRoids.Keywords[k] or not CleveRoids.Keywords[k](conditionals) then conditionals.target = origTarget return end end + k, v = next(conditionals, k) end conditionals.target = origTarget @@ -1391,8 +2258,19 @@ function CleveRoids.TestAction(cmd, args) end function CleveRoids.DoWithConditionals(msg, hook, fixEmptyTargetFunc, targetBeforeAction, action) + -- Check macro stop flags (skip non-control commands when flag is set) + -- This enables /stopmacro, /skipmacro, /firstaction, /nofirstaction to work without SuperMacro for vanilla macros + if (CleveRoids.stopMacroFlag or CleveRoids.skipMacroFlag) and action ~= "STOPMACRO" and action ~= "SKIPMACRO" and action ~= "FIRSTACTION" and action ~= "NOFIRSTACTION" then + return false + end + local msg, conditionals = CleveRoids.GetParsedMsg(msg) + -- Debug: Log parsed msg and action type + if CleveRoids.equipDebugLog and action and action ~= CastSpellByName then + CleveRoids.Print("|cff888888[EquipLog] DoWithConditionals: parsed msg='" .. tostring(msg) .. "' action=" .. tostring(action) .. "|r") + end + -- No conditionals. Just exit. if not conditionals then if not msg then -- if not even an empty string @@ -1406,6 +2284,26 @@ function CleveRoids.DoWithConditionals(msg, hook, fixEmptyTargetFunc, targetBefo end end + -- Handle STOPMACRO/SKIPMACRO/FIRSTACTION/NOFIRSTACTION without conditionals (bare command) + if action == "STOPMACRO" then + CleveRoids.stopMacroFlag = true + return true + elseif action == "SKIPMACRO" then + CleveRoids.skipMacroFlag = true + return true + elseif action == "FIRSTACTION" then + CleveRoids.stopOnCastFlag = true + return true + elseif action == "NOFIRSTACTION" then + -- Clear flags - same logic as DoNoFirstAction + local wasFirstActionActive = CleveRoids.stopOnCastFlag + CleveRoids.stopOnCastFlag = false + if wasFirstActionActive and CleveRoids.stopMacroFlag then + CleveRoids.stopMacroFlag = false + end + return true + end + if hook then hook(msg) end @@ -1464,9 +2362,38 @@ function CleveRoids.DoWithConditionals(msg, hook, fixEmptyTargetFunc, targetBefo end end + -- Handle [multiscan:priority] - scan enemies and find best target + -- Must be processed BEFORE the Keywords loop since it sets conditionals.target + -- Pass origTarget so @unit syntax makes that unit exempt from combat check + if conditionals.multiscan then + local scanResult = CleveRoids.ResolveMultiscanTarget(conditionals, origTarget) + if not scanResult then + -- No valid target found - fail this conditional line (try next) + conditionals.target = origTarget + return false + end + -- Set target to the found GUID for soft-casting via SuperWoW + conditionals.target = scanResult + -- Don't need to retarget since we're using GUID directly + needRetarget = false + end + for k, v in pairs(conditionals) do if not CleveRoids.ignoreKeywords[k] then - if not CleveRoids.Keywords[k] or not CleveRoids.Keywords[k](conditionals) then + local result = CleveRoids.Keywords[k] and CleveRoids.Keywords[k](conditionals) + -- Debug logging for equipped conditional when equipDebugLog is enabled + if CleveRoids.equipDebugLog and (k == "equipped" or k == "noequipped") then + local valStr = type(v) == "table" and table.concat(v, ", ") or tostring(v) + CleveRoids.Print("|cff888888[EquipLog] Conditional [" .. k .. ":" .. valStr .. "] = " .. + (result and "|cff00ff00PASS|r" or "|cffff0000FAIL|r") .. "|r") + end + -- Debug logging for macro reference debug + if CleveRoids.macroRefDebug then + local valStr = (v == true) and "" or (type(v) == "table" and table.concat(v, ", ") or tostring(v)) + CleveRoids.Print("|cff888888[MacroRef]|r [" .. k .. (valStr ~= "" and (":" .. valStr) or "") .. "] = " .. + (result and "|cff00ff00PASS|r" or "|cffff0000FAIL|r")) + end + if not result then if needRetarget then TargetLastTarget() needRetarget = false @@ -1493,33 +2420,78 @@ function CleveRoids.DoWithConditionals(msg, hook, fixEmptyTargetFunc, targetBefo end if action == "STOPMACRO" then - CleveRoids.stopmacro = true + -- Set flag to stop subsequent lines in current macro AND parent macros + CleveRoids.stopMacroFlag = true + return true + elseif action == "SKIPMACRO" then + -- Set flag to skip remaining lines in current submacro only + CleveRoids.skipMacroFlag = true + return true + elseif action == "FIRSTACTION" then + -- Set flag to stop on first successful cast/use + CleveRoids.stopOnCastFlag = true + return true + elseif action == "NOFIRSTACTION" then + -- Clear flags to re-enable multi-queue behavior + -- Also clear stopMacroFlag if it was set by the firstaction mechanism + local wasFirstActionActive = CleveRoids.stopOnCastFlag + CleveRoids.stopOnCastFlag = false + if wasFirstActionActive and CleveRoids.stopMacroFlag then + CleveRoids.stopMacroFlag = false + end return true end local result = true if string.sub(msg, 1, 1) == "{" and string.sub(msg, -1) == "}" then + if CleveRoids.macroRefDebug then + CleveRoids.Print("|cff00ffff[MacroRef]|r Detected macro reference: " .. msg) + end if string.sub(msg, 2, 2) == "\"" and string.sub(msg, -2,-2) == "\"" then result = CleveRoids.ExecuteMacroBody(string.sub(msg, 3, -3), true) else result = CleveRoids.ExecuteMacroByName(string.sub(msg, 2, -2)) end - else -- This 'else' corresponds to 'if string.sub(msg, 1, 1) == "{"...' + elseif msg == "" or msg == nil then + -- Empty action (conditionals passed but no spell to cast) + -- For non-spell actions (pet commands, etc.), still execute the action + if CleveRoids.equipDebugLog and action and action ~= CastSpellByName then + CleveRoids.Print("|cffff8800[EquipLog] Empty msg branch - calling action() with no args|r") + end + if action and action ~= CastSpellByName then + action() + result = true + else + result = false + end + else local castMsg = msg -- FLEXIBLY check for any rank text like "(Rank 9)" before adding the highest rank - if action == CastSpellByName and not string.find(msg, "%(.*%)") then + -- Use specific "(Rank" check instead of any parentheses, so spells like + -- "Faerie Fire (Feral)" still get their rank appended automatically + if action == CastSpellByName and not string.find(msg, "%(Rank") then local sp = CleveRoids.GetSpell(msg) local rank = sp and (sp.rank or (sp.highest and sp.highest.rank)) if rank and rank ~= "" then castMsg = msg .. "(" .. rank .. ")" end end - if CleveRoids.hasSuperwow and action == CastSpellByName and conditionals.target then - CastSpellByName(castMsg, conditionals.target) -- SuperWoW handles targeting via argument - elseif action == CastSpellByName then - action(castMsg) + if action == CastSpellByName then + -- Special case: !Attack should use AttackTarget() which doesn't toggle + -- This is more reliable than CastSpellByName("Attack") which toggles on/off + if msg == CleveRoids.Localized.Attack and conditionals.checkchanneled then + AttackTarget() + elseif CleveRoids.hasSuperwow and conditionals.target then + -- Let Nampower DLL handle queuing natively via its CastSpellByName hook + CastSpellByName(castMsg, conditionals.target) + else + CastSpellByName(castMsg) + end else -- For other actions like UseContainerItem etc. + if CleveRoids.equipDebugLog then + CleveRoids.Print("|cff00ff00[EquipLog] Calling action('" .. tostring(msg) .. "')|r") + end action(msg) end end @@ -1533,69 +2505,55 @@ function CleveRoids.DoWithConditionals(msg, hook, fixEmptyTargetFunc, targetBefo end function CleveRoids.DoCast(msg) - local handled = false - - for k, v in pairs(CleveRoids.splitStringIgnoringQuotes(msg)) do - -- Define a custom action that handles both regular and pet spells - local castAction = function(spellName) - -- First try regular spell - local spell = CleveRoids.GetSpell(spellName) - if spell then - if CleveRoids.hasSuperwow then - local castMsg = spellName - if not string.find(spellName, "%(.*%)") then - local rank = spell.rank or (spell.highest and spell.highest.rank) - if rank and rank ~= "" then - castMsg = spellName .. "(" .. rank .. ")" - end - end - CastSpellByName(castMsg) - else - CastSpellByName(spellName) - end - return true - end - - -- If not a regular spell, try pet spell - local petSpell = CleveRoids.GetPetSpell(spellName) - if petSpell and petSpell.slot then - CastPetAction(petSpell.slot) - return true - end - - return false + if CleveRoids.ChannelTimeDebug then + if msg and string.find(msg, "Arcane") then + DEFAULT_CHAT_FRAME:AddMessage("|cffff00ff[/cast BUTTON PRESS]|r " .. msg) end + end + -- PERFORMANCE: Use numeric iteration to avoid pairs() iterator allocation + local parts = CleveRoids.splitStringIgnoringQuotes(msg) + for i = 1, table.getn(parts) do + local v = parts[i] if CleveRoids.DoWithConditionals(v, CleveRoids.Hooks.CAST_SlashCmd, CleveRoids.FixEmptyTarget, not CleveRoids.hasSuperwow, CastSpellByName) then + -- If /firstaction was used, stop macro evaluation after first successful cast + if CleveRoids.stopOnCastFlag then + CleveRoids.stopMacroFlag = true + end return true end end return false end -function CleveRoids.DoCastPet(msg) - local handled = false +-- PERFORMANCE: Module-level pet cast action to avoid closure allocation per call +local function _petCastAction(spellName) + local petSpell = CleveRoids.GetPetSpell(spellName) + if petSpell and petSpell.slot then + CastPetAction(petSpell.slot) + return true + end + return false +end - local action = function(spellName) - local petSpell = CleveRoids.GetPetSpell(spellName) - if petSpell and petSpell.slot then - CastPetAction(petSpell.slot) +function CleveRoids.DoCastPet(msg) + -- PERFORMANCE: Use numeric iteration to avoid pairs() iterator allocation + local parts = CleveRoids.splitStringIgnoringQuotes(msg) + for i = 1, table.getn(parts) do + local v = parts[i] + if CleveRoids.DoWithConditionals(v, _petCastAction, CleveRoids.FixEmptyTarget, false, _petCastAction) then return true end - return false end - - for k, v in pairs(CleveRoids.splitStringIgnoringQuotes(msg)) do - if CleveRoids.DoWithConditionals(v, action, CleveRoids.FixEmptyTarget, false, action) then - handled = true - break - end - end - - return handled + return false end function CleveRoids.DoTarget(msg) + -- Check macro stop flags + if CleveRoids.stopMacroFlag or CleveRoids.skipMacroFlag then + return false + end + local action, conditionals = CleveRoids.GetParsedMsg(msg) if action ~= "" or type(conditionals) ~= "table" or not next(conditionals) then @@ -1620,6 +2578,12 @@ function CleveRoids.DoTarget(msg) return ok end + -- Save original target GUID for potential restoration (SuperWoW returns GUID as 2nd value) + local _, originalTargetGuid = UnitExists("target") + + -- Track if an explicit target was specified via @unit syntax + local explicitTarget = conditionals.target + do local unitTok = conditionals.target @@ -1646,6 +2610,13 @@ function CleveRoids.DoTarget(msg) TargetUnit(unitTok) return true end + + -- If an explicit target was specified via @unit syntax but doesn't exist or isn't valid, + -- return false instead of falling through to candidate search. + -- This ensures "/target [@mouseover,harm,alive]" does nothing when mouse is over empty ground. + if explicitTarget then + return false + end end if UnitExists("target") and IsGuidValid("target", conditionals) then @@ -1722,84 +2693,173 @@ function CleveRoids.DoTarget(msg) end end + -- UnitXP 3D enemy scanning: cycles through enemies in world space (no nameplate required) + -- This is the most powerful scan - finds enemies by line of sight and distance + -- Always enabled unless explicitly looking for friendlies only ([help] without [harm]) + -- UnitXP only finds enemies, so this is safe for any /target with conditionals + local wantsFriendlyOnly = wantsHelp and not wantsHarm + if not wantsFriendlyOnly and CleveRoids.hasUnitXP then + -- Try nearestEnemy first - most common case and most efficient + local found = UnitXP("target", "nearestEnemy") + if found and UnitExists("target") and IsGuidValid("target", conditionals) then + return true + end + + -- Determine scan mode: use distance-priority for melee conditionals + local wantsMelee = conditionals.meleerange or conditionals.nomeleerange + local scanMode = wantsMelee and "nextEnemyConsideringDistance" or "nextEnemyInCycle" + + local seenGuids = {} + local firstGuid = nil + local maxIterations = 50 -- Safety limit + + for i = 1, maxIterations do + found = UnitXP("target", scanMode) + if not found then break end + + local _, currentGuid = UnitExists("target") + if not currentGuid then break end + + -- Check if we've cycled back to start + if firstGuid == nil then + firstGuid = currentGuid + elseif currentGuid == firstGuid then + break -- Completed full cycle + end + + -- Skip already-seen targets + if not seenGuids[currentGuid] then + seenGuids[currentGuid] = true + + -- Test this target against all conditionals + if IsGuidValid("target", conditionals) then + return true -- Found matching target + end + end + end + + -- No match found via UnitXP scan - restore original target if we had one + if originalTargetGuid and UnitExists(originalTargetGuid) then + TargetUnit(originalTargetGuid) + elseif not originalTargetGuid then + ClearTarget() + end + end + return true end -- Attempts to attack a unit by a set of conditionals -- msg: The raw message intercepted from a /petattack command function CleveRoids.DoPetAction(action, msg) - local handled = false + -- PERFORMANCE: Use numeric iteration to avoid pairs() iterator allocation + local parts = CleveRoids.splitStringIgnoringQuotes(msg) + for i = 1, table.getn(parts) do + if CleveRoids.DoWithConditionals(parts[i], action, CleveRoids.FixEmptyTarget, true, action) then + return true + end + end + return false +end - for k, v in pairs(CleveRoids.splitStringIgnoringQuotes(msg)) do - if CleveRoids.DoWithConditionals(v, action, CleveRoids.FixEmptyTarget, true, action) then - handled = true - break +-- PERFORMANCE: Module-level action to avoid closure allocation per call +local function _startAttackAction() + if not UnitExists("target") or UnitIsDead("target") then TargetNearestEnemy() end + -- Check both event-based flag AND action bar state for reliable detection + local isAttacking = CleveRoids.CurrentSpell.autoAttack + if not isAttacking then + -- Fallback: check action bar state via IsCurrentAction + local slot = CleveRoids.GetProxyActionSlot(CleveRoids.Localized.Attack) + if slot and IsCurrentAction(slot) then + CleveRoids.CurrentSpell.autoAttack = true + isAttacking = true + end + end + if not isAttacking and not CleveRoids.CurrentSpell.autoAttackLock and UnitExists("target") and UnitCanAttack("player", "target") then + 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 - return handled end -- Attempts to conditionally start an attack. Returns false if no conditionals are found. function CleveRoids.DoConditionalStartAttack(msg) if not string.find(msg, "%[") then return false end - local handled = false - local action = function() - if not UnitExists("target") or UnitIsDead("target") then TargetNearestEnemy() end - if not CleveRoids.CurrentSpell.autoAttack and not CleveRoids.CurrentSpell.autoAttackLock and UnitExists("target") and UnitCanAttack("player", "target") then - CleveRoids.CurrentSpell.autoAttackLock = true - CleveRoids.autoAttackLockElapsed = GetTime() - AttackTarget() - end - end - - for k, v in pairs(CleveRoids.splitStringIgnoringQuotes(msg)) do + -- PERFORMANCE: Use numeric iteration to avoid pairs() iterator allocation + local parts = CleveRoids.splitStringIgnoringQuotes(msg) + for i = 1, table.getn(parts) do -- We pass 'nil' for the hook, so DoWithConditionals does nothing if it fails to parse conditionals. - if CleveRoids.DoWithConditionals(v, nil, CleveRoids.FixEmptyTarget, false, action) then - handled = true - break + if CleveRoids.DoWithConditionals(parts[i], nil, CleveRoids.FixEmptyTarget, false, _startAttackAction) then + return true end end - return handled + return false +end + +-- PERFORMANCE: Module-level actions to avoid closure allocation per call +local function _stopAttackAction() + if CleveRoids.CurrentSpell.autoAttack and UnitExists("target") then + AttackTarget() + CleveRoids.CurrentSpell.autoAttack = false + end +end + +local function _stopCastingAction() + SpellStopCasting() +end + +local function _clearTargetAction() + ClearTarget() end -- Attempts to conditionally stop an attack. Returns false if no conditionals are found. function CleveRoids.DoConditionalStopAttack(msg) if not string.find(msg, "%[") then return false end - local handled = false - local action = function() - if CleveRoids.CurrentSpell.autoAttack and UnitExists("target") then - AttackTarget() - CleveRoids.CurrentSpell.autoAttack = false + -- PERFORMANCE: Use numeric iteration to avoid pairs() iterator allocation + local parts = CleveRoids.splitStringIgnoringQuotes(msg) + for i = 1, table.getn(parts) do + if CleveRoids.DoWithConditionals(parts[i], nil, CleveRoids.FixEmptyTarget, false, _stopAttackAction) then + return true end end - - for k, v in pairs(CleveRoids.splitStringIgnoringQuotes(msg)) do - if CleveRoids.DoWithConditionals(v, nil, CleveRoids.FixEmptyTarget, false, action) then - handled = true - break - end - end - return handled + return false end -- Attempts to conditionally stop casting. Returns false if no conditionals are found. function CleveRoids.DoConditionalStopCasting(msg) if not string.find(msg, "%[") then return false end - local handled = false - local action = function() - SpellStopCasting() - end - - for k, v in pairs(CleveRoids.splitStringIgnoringQuotes(msg)) do - if CleveRoids.DoWithConditionals(v, nil, CleveRoids.FixEmptyTarget, false, action) then - handled = true - break + -- PERFORMANCE: Use numeric iteration to avoid pairs() iterator allocation + local parts = CleveRoids.splitStringIgnoringQuotes(msg) + for i = 1, table.getn(parts) do + if CleveRoids.DoWithConditionals(parts[i], nil, CleveRoids.FixEmptyTarget, false, _stopCastingAction) then + return true end end - return handled + return false +end + +-- Attempts to conditionally clear target. Returns false if no conditionals are found. +function CleveRoids.DoConditionalClearTarget(msg) + if not string.find(msg, "%[") then return false end + + -- PERFORMANCE: Use numeric iteration to avoid pairs() iterator allocation + local parts = CleveRoids.splitStringIgnoringQuotes(msg) + for i = 1, table.getn(parts) do + if CleveRoids.DoWithConditionals(parts[i], nil, CleveRoids.FixEmptyTarget, false, _clearTargetAction) then + return true + end + end + return false end @@ -1807,6 +2867,11 @@ end -- Also checks if a condition is a spell so that you can mix item and spell use -- msg: The raw message intercepted from a /use or /equip command function CleveRoids.DoUse(msg) + -- Check macro stop flags + if CleveRoids.stopMacroFlag or CleveRoids.skipMacroFlag then + return false + end + local handled = false local action = function(msg) @@ -1822,232 +2887,554 @@ function CleveRoids.DoUse(msg) return end - -- NEW: Try to interpret as an item ID (numeric but > 19) + -- Try to interpret as item ID (numbers > 19) + -- v2.18+: Use FindPlayerItemSlot directly for item IDs (no name resolution needed) if slotId and slotId > 19 then - -- Search equipped slots for this item ID - for slot = 0, 19 do - local link = GetInventoryItemLink("player", slot) + local API = CleveRoids.NampowerAPI + -- v2.18+: Native lookup can find item directly by ID + if API and API.features and API.features.hasFindPlayerItemSlot then + local itemInfo = API.FindItemFast(slotId) + if itemInfo then + ClearCursor() + if itemInfo.inventoryID then + if CleveRoids.equipDebugLog then + CleveRoids.Print("|cff888888[UseLog] /use " .. slotId .. " via UseInventoryItem(" .. itemInfo.inventoryID .. ") [v2.18 ID lookup]|r") + end + UseInventoryItem(itemInfo.inventoryID) + return + elseif itemInfo.bagID and itemInfo.slot then + if CleveRoids.equipDebugLog then + CleveRoids.Print("|cff888888[UseLog] /use " .. slotId .. " via UseContainerItem(" .. itemInfo.bagID .. "," .. itemInfo.slot .. ") [v2.18 ID lookup]|r") + end + UseContainerItem(itemInfo.bagID, itemInfo.slot) + return + end + end + -- Item not found by ID - fail + if CleveRoids.equipDebugLog then + CleveRoids.Print("|cffff8800[UseLog] Item ID " .. slotId .. " not found in inventory [v2.18]|r") + end + return + end + + -- Fallback: Resolve item ID to name for legacy lookup + local itemName = nil + if API and API.GetItemName then + itemName = API.GetItemName(slotId) + end + -- Fall back to GetItemInfo + if not itemName and GetItemInfo then + itemName = GetItemInfo(slotId) + end + if itemName then + if CleveRoids.equipDebugLog then + CleveRoids.Print("|cff888888[UseLog] Resolved item ID " .. slotId .. " to '" .. itemName .. "'|r") + end + msg = itemName -- Replace ID with name for subsequent lookups + else + if CleveRoids.equipDebugLog then + CleveRoids.Print("|cffff8800[UseLog] Could not resolve item ID " .. slotId .. " - item not in cache|r") + end + -- Item not in client cache - can't resolve without seeing it first + return + end + end + + -- v2.18+: Use native fast lookup (much faster than Lua cache + scan) + local API = CleveRoids.NampowerAPI + if API and API.features and API.features.hasFindPlayerItemSlot then + local itemInfo = API.FindItemFast(msg) + if itemInfo then + ClearCursor() + if itemInfo.inventoryID then + if CleveRoids.equipDebugLog then + CleveRoids.Print("|cff888888[UseLog] /use " .. msg .. " via UseInventoryItem(" .. itemInfo.inventoryID .. ") [v2.18 native]|r") + end + UseInventoryItem(itemInfo.inventoryID) + return + elseif itemInfo.bagID and itemInfo.slot then + if CleveRoids.equipDebugLog then + CleveRoids.Print("|cff888888[UseLog] /use " .. msg .. " via UseContainerItem(" .. itemInfo.bagID .. "," .. itemInfo.slot .. ") [v2.18 native]|r") + end + UseContainerItem(itemInfo.bagID, itemInfo.slot) + return + end + end + -- v2.18 lookup didn't find item - fall through to legacy path + -- (might be partial match or different case that native doesn't handle) + end + + -- PERFORMANCE: Try cache lookup first (O(1) instead of O(n) scan) + -- IMPORTANT: Validate cache hits to prevent stale data during combat + -- (IndexItems() is skipped during combat, so cache may have old bag/slot locations) + local location = CleveRoids.FindItemLocation(msg) + if location then + local cacheValid = false + local qname = string_lower(msg) + + if location.type == "inventory" then + -- Validate: check if this slot actually contains the item we want + local link = GetInventoryItemLink("player", location.inventoryID) if link then - local _, _, id = string.find(link, "item:(%d+)") - if id and tonumber(id) == slotId then + local _, _, nm = string_find(link, "|h%[(.-)%]|h") + if nm and string_lower(nm) == qname then + cacheValid = true + end + end + else + -- Validate: check if this bag slot actually contains the item we want + local link = GetContainerItemLink(location.bag, location.slot) + if link then + local _, _, nm = string_find(link, "|h%[(.-)%]|h") + if nm and string_lower(nm) == qname then + cacheValid = true + end + end + end + + if cacheValid then + ClearCursor() + if location.type == "inventory" then + if CleveRoids.equipDebugLog then + CleveRoids.Print("|cff888888[UseLog] /use " .. msg .. " via UseInventoryItem(" .. location.inventoryID .. ") [cached]|r") + end + UseInventoryItem(location.inventoryID) + else + if CleveRoids.equipDebugLog then + CleveRoids.Print("|cff888888[UseLog] /use " .. msg .. " via UseContainerItem(" .. location.bag .. "," .. location.slot .. ") [cached]|r") + end + UseContainerItem(location.bag, location.slot) + end + return + elseif CleveRoids.equipDebugLog then + CleveRoids.Print("|cff888888[UseLog] /use " .. msg .. " - cache STALE, falling back to scan|r") + end + end + + -- Slow path fallback: full scan for substring matches or cache miss + local qname = string_lower(msg) + + -- Search equipped inventory slots first (for trinkets, etc.) + for slot = 0, 19 do + local link = GetInventoryItemLink("player", slot) + if link then + local _, _, nm = string_find(link, "|h%[(.-)%]|h") + if nm and string_lower(nm) == qname then + if CleveRoids.equipDebugLog then + CleveRoids.Print("|cff888888[UseLog] /use " .. msg .. " via UseInventoryItem(" .. slot .. ")|r") + end + ClearCursor() + UseInventoryItem(slot) + return + end + end + end + + -- Then search bags + for bag = 0, 4 do + local numSlots = GetContainerNumSlots(bag) or 0 + for bagSlot = 1, numSlots do + local link = GetContainerItemLink(bag, bagSlot) + if link then + local _, _, nm = string_find(link, "|h%[(.-)%]|h") + if nm and string_lower(nm) == qname then + if CleveRoids.equipDebugLog then + CleveRoids.Print("|cff888888[UseLog] /use " .. msg .. " via UseContainerItem(" .. bag .. "," .. bagSlot .. ")|r") + end ClearCursor() - UseInventoryItem(slot) + UseContainerItem(bag, bagSlot) return end end end + end - -- Search bags for this item ID - for bag = 0, 4 do - local size = GetContainerNumSlots(bag) or 0 - for bagSlot = 1, size do - local link = GetContainerItemLink(bag, bagSlot) - if link then - local _, _, id = string.find(link, "item:(%d+)") - if id and tonumber(id) == slotId then - ClearCursor() - UseContainerItem(bag, bagSlot) - return - end - end - end + if CleveRoids.equipDebugLog then + CleveRoids.Print("|cff888888[UseLog] /use " .. msg .. " - not found in equipped slots or bags|r") + end + end + + -- PERFORMANCE: Use numeric iteration to avoid pairs() iterator allocation + local parts = CleveRoids.splitStringIgnoringQuotes(msg) + for i = 1, table.getn(parts) do + if CleveRoids.DoWithConditionals(parts[i], action, CleveRoids.FixEmptyTarget, false, action) then + -- If /firstaction was used, stop macro evaluation after first successful use + if CleveRoids.stopOnCastFlag then + CleveRoids.stopMacroFlag = true end - - -- Item ID not found - return - end - - -- Resolve by name/id via our cache - local item = CleveRoids.GetItem(msg) -- looks in equipped, then bags - if not item then return end - - -- If it's an equipped item (trinket etc.), use it directly - if item.inventoryID then - ClearCursor() - UseInventoryItem(item.inventoryID) - return - end - - -- Otherwise, use the bag item - if item.bagID and item.slot then - -- If we tracked multiple stacks, advance politely - CleveRoids.GetNextBagSlotForUse(item, msg) - - ClearCursor() - UseContainerItem(item.bagID, item.slot) - return - end - end - - for _, v in pairs(CleveRoids.splitStringIgnoringQuotes(msg)) do - if CleveRoids.DoWithConditionals(v, action, CleveRoids.FixEmptyTarget, false, action) then - handled = true - break - end - end - return handled -end - -function CleveRoids.EquipBagItem(msg, offhand) - if CleveRoids.equipInProgress then - return false - end - - local now = GetTime() - if (now - (CleveRoids.lastItemIndexTime or 0)) > 0.5 then - CleveRoids.IndexItems() - CleveRoids.lastItemIndexTime = now - end - - local item = CleveRoids.GetItem(msg) - if not item or not item.name then - CleveRoids.IndexItems() - CleveRoids.lastItemIndexTime = now - item = CleveRoids.GetItem(msg) - if not item or not item.name then - return false - end - end - - local invslot = offhand and 17 or 16 - local throttleKey = invslot .. "_" .. (item.id or item.name) - - if UnitAffectingCombat("player") and (invslot == 16 or invslot == 17) then - local timeSinceLastSwap = now - CleveRoids.lastWeaponSwapTime - if timeSinceLastSwap < 1.5 then - return false - end - end - - if CleveRoids.lastEquipTime[throttleKey] and (now - CleveRoids.lastEquipTime[throttleKey]) < 0.2 then - return false - end - - local currentItemLink = GetInventoryItemLink("player", invslot) - if currentItemLink then - local _, _, currentID = string.find(currentItemLink, "item:(%d+)") - local currentItemName = GetItemInfo(currentItemLink) - - if (currentID and item.id and tonumber(currentID) == tonumber(item.id)) or - (currentItemName and currentItemName == item.name) then - CleveRoids.lastEquipTime[throttleKey] = now return true end end + return false +end + +function CleveRoids.EquipBagItem(msg, slotOrOffhand) + if CleveRoids.equipDebugLog then + CleveRoids.Print("|cff00ffff[EquipLog] EquipBagItem called: '" .. tostring(msg) .. "' slot=" .. tostring(slotOrOffhand) .. "|r") + end + + if CleveRoids.equipInProgress then + if CleveRoids.equipDebugLog then + CleveRoids.Print("|cffff0000[EquipLog] Equip already in progress, skipping|r") + end + return false + end + + -- Accept slot number directly, or boolean for backward compatibility (false=16/MH, true=17/OH) + local invslot + if type(slotOrOffhand) == "number" then + invslot = slotOrOffhand + else + invslot = slotOrOffhand and 17 or 16 + end + local API = CleveRoids.NampowerAPI + + -- v2.18+: Use native fast lookup for item ID or name + -- Guard against API being a function instead of table (addon conflict protection) + if type(API) == "table" and type(API.features) == "table" and API.features.hasFindPlayerItemSlot then + local searchTerm = msg + local itemId = tonumber(msg) + + if CleveRoids.equipDebugLog then + CleveRoids.Print("|cff888888[EquipLog] Searching for '" .. tostring(searchTerm) .. "' (slot " .. invslot .. ")|r") + end + + -- Check if already equipped in target slot + if API.IsItemInSlot(searchTerm, invslot) then + if CleveRoids.equipDebugLog then + CleveRoids.Print("|cff00ff00[EquipLog] '" .. tostring(searchTerm) .. "' already in slot " .. invslot .. "|r") + end + return true + end + + -- Find the item (works with both ID and name) + local itemInfo = API.FindItemFast(searchTerm) + if CleveRoids.equipDebugLog then + if itemInfo then + CleveRoids.Print("|cff888888[EquipLog] FindItemFast found: invID=" .. tostring(itemInfo.inventoryID) .. " bag=" .. tostring(itemInfo.bagID) .. " slot=" .. tostring(itemInfo.slot) .. "|r") + else + CleveRoids.Print("|cffff8800[EquipLog] FindItemFast returned nil|r") + end + end + if itemInfo then + -- Already equipped in different slot - need to swap + if itemInfo.inventoryID then + if itemInfo.inventoryID == invslot then + return true -- Already in correct slot + end + -- Pick up from current slot and equip to target + ClearCursor() + PickupInventoryItem(itemInfo.inventoryID) + if CursorHasItem and CursorHasItem() then + EquipCursorItem(invslot) + ClearCursor() + return true + end + elseif itemInfo.bagID and itemInfo.slot then + -- In bag - equip to target slot + ClearCursor() + PickupContainerItem(itemInfo.bagID, itemInfo.slot) + if CursorHasItem and CursorHasItem() then + EquipCursorItem(invslot) + ClearCursor() + if CleveRoids.equipDebugLog then + CleveRoids.Print("|cff00ff00[EquipLog] Equipped '" .. tostring(msg) .. "' from bag " .. itemInfo.bagID .. " slot " .. itemInfo.slot .. "|r") + end + return true + end + end + end + + -- Item not found via v2.18 lookup + if CleveRoids.equipDebugLog then + CleveRoids.Print("|cffff8800[EquipLog] Item '" .. tostring(msg) .. "' not found via v2.18 FindPlayerItemSlot|r") + -- Debug: Try direct FindPlayerItemSlot call + if FindPlayerItemSlot then + local bag, slot = FindPlayerItemSlot(msg) + CleveRoids.Print("|cff888888[EquipLog] Direct FindPlayerItemSlot('" .. msg .. "') = bag:" .. tostring(bag) .. " slot:" .. tostring(slot) .. "|r") + end + end + return false + end + + -- Fallback for older Nampower versions + -- Try to interpret as item ID (numbers > 19) + local itemId = tonumber(msg) + if itemId and itemId > 19 then + local itemName = nil + if API and API.GetItemName then + itemName = API.GetItemName(itemId) + end + if not itemName and GetItemInfo then + itemName = GetItemInfo(itemId) + end + if itemName then + msg = itemName + else + if CleveRoids.equipDebugLog then + CleveRoids.Print("|cffff8800[EquipLog] Can't resolve item ID " .. tostring(itemId) .. "|r") + end + return false -- Can't resolve item ID + end + end + + -- PERFORMANCE: Fast check if already equipped (single function call) + if CleveRoids.IsItemEquipped and CleveRoids.IsItemEquipped(msg, invslot) then + return true + end + + -- Note what's currently in the target slot so we can invalidate its cache + local oldSlotLink = GetInventoryItemLink("player", invslot) + local oldSlotName = nil + if oldSlotLink then + local _, _, name = string_find(oldSlotLink, "|h%[(.-)%]|h") + oldSlotName = name + end + + -- Helper to invalidate displaced item's cache + local function InvalidateDisplacedItem() + if oldSlotName and CleveRoids.Items then + CleveRoids.Items[oldSlotName] = nil + CleveRoids.Items[string_lower(oldSlotName)] = nil + end + end + + -- Check if item is already equipped in the paired slot (swap case) + -- EquipItemByName doesn't handle swapping equipped items, so we must do it manually + -- Paired slots: trinkets (13<->14), weapons (16<->17), rings (11<->12) + local pairedSlots = {[13] = 14, [14] = 13, [16] = 17, [17] = 16, [11] = 12, [12] = 11} + local checkSlot = pairedSlots[invslot] + if checkSlot then + local link = GetInventoryItemLink("player", checkSlot) + if link then + local _, _, slotItemName = string_find(link, "|h%[(.-)%]|h") + if slotItemName and string_lower(slotItemName) == string_lower(msg) then + -- Found item in paired slot - swap it manually + if CleveRoids.equipDebugLog then + CleveRoids.Print("|cff00ffff[EquipLog] Swapping from slot " .. checkSlot .. " to slot " .. invslot .. "|r") + end + ClearCursor() + PickupInventoryItem(checkSlot) + if CursorHasItem and CursorHasItem() then + EquipCursorItem(invslot) + ClearCursor() + if CleveRoids.Items then + CleveRoids.Items[msg] = nil + CleveRoids.Items[string_lower(msg)] = nil + end + InvalidateDisplacedItem() + return true + end + ClearCursor() + end + end + end + + -- PERFORMANCE: Try EquipItemByName for bag items (fast path) + -- This is the fastest path - no item lookup, no cursor operations + if EquipItemByName then + local ok = pcall(EquipItemByName, msg, invslot) + if ok then + -- Invalidate cache entry if it exists + if CleveRoids.Items then + CleveRoids.Items[msg] = nil + CleveRoids.Items[string_lower(msg)] = nil + end + InvalidateDisplacedItem() + return true + end + end + + -- PERFORMANCE: Use fast lookup first, fall back to full scan + -- Full scan is now optimized with GetNameFromLink() instead of GetItemInfo() + local item = CleveRoids.GetItemFast and CleveRoids.GetItemFast(msg) + if not item then + -- Try quick targeted scan (stops when found) + item = CleveRoids.FindItemQuick and CleveRoids.FindItemQuick(msg) + end + if not item then + -- Full scan fallback (now optimized, safe during combat) + item = CleveRoids.GetItem(msg) + end + + if not item or not item.name then + if CleveRoids.equipDebugLog then + CleveRoids.Print("|cffff8800[EquipLog] Item '" .. tostring(msg) .. "' not found in bags or equipped|r") + end + return false + end + + -- Already equipped check (by item ID from lookup) + if item.inventoryID == invslot then + return true + end if not item.bagID and not item.inventoryID then return false end + -- Try EquipItemByName with resolved name (in case msg was partial/different case) + if item.name and EquipItemByName and item.name ~= msg then + local ok = pcall(EquipItemByName, item.name, invslot) + if ok then + if CleveRoids.Items then + CleveRoids.Items[item.name] = nil + CleveRoids.Items[string_lower(item.name)] = nil + end + InvalidateDisplacedItem() + return true + end + end + + -- Fallback: Manual pickup and equip CleveRoids.equipInProgress = true - if type(CloseStackSplitFrame) == "function" then - CloseStackSplitFrame() - end + -- PERFORMANCE: Single cursor check at start if CursorHasItem and CursorHasItem() then ClearCursor() - CleveRoids.equipInProgress = false - return false end local pickupSuccess = false if item.bagID and item.slot then - CleveRoids.GetNextBagSlotForUse(item, msg) - - local link = GetContainerItemLink(item.bagID, item.slot) - if link then - local _, _, bagItemID = string.find(link, "item:(%d+)") - if bagItemID and item.id and tonumber(bagItemID) == tonumber(item.id) then - PickupContainerItem(item.bagID, item.slot) - pickupSuccess = true - end - end + PickupContainerItem(item.bagID, item.slot) + pickupSuccess = CursorHasItem and CursorHasItem() elseif item.inventoryID then PickupInventoryItem(item.inventoryID) - pickupSuccess = true + pickupSuccess = CursorHasItem and CursorHasItem() end if not pickupSuccess then - CleveRoids.equipInProgress = false - CleveRoids.lastItemIndexTime = 0 - return false - end - - if not CursorHasItem or not CursorHasItem() then ClearCursor() CleveRoids.equipInProgress = false - CleveRoids.lastItemIndexTime = 0 return false end EquipCursorItem(invslot) - ClearCursor() - CleveRoids.lastEquipTime[throttleKey] = now - - if UnitAffectingCombat("player") and (invslot == 16 or invslot == 17) then - CleveRoids.lastWeaponSwapTime = now + if CleveRoids.Items and item.name then + CleveRoids.Items[item.name] = nil + CleveRoids.Items[string_lower(item.name)] = nil end - + InvalidateDisplacedItem() CleveRoids.equipInProgress = false - - CleveRoids.IndexItems() - CleveRoids.lastItemIndexTime = now - return true end -function CleveRoids.DoEquipMainhand(msg) - local handled = false - local action = function(msg) - return CleveRoids.EquipBagItem(msg, false) +-- PERFORMANCE: Module-level actions to avoid closure allocation per call +local function _equipMainhandAction(msg) + return CleveRoids.EquipBagItem(msg, false) +end + +local function _equipOffhandAction(msg) + return CleveRoids.EquipBagItem(msg, true) +end + +local function _equipTrinket1Action(msg) + return CleveRoids.EquipBagItem(msg, 13) +end + +local function _equipTrinket2Action(msg) + return CleveRoids.EquipBagItem(msg, 14) +end + +local function _equipRing1Action(msg) + return CleveRoids.EquipBagItem(msg, 11) +end + +local function _equipRing2Action(msg) + return CleveRoids.EquipBagItem(msg, 12) +end + +local function _unshiftAction() + local currentShapeshiftIndex = CleveRoids.GetCurrentShapeshiftIndex() + if currentShapeshiftIndex ~= 0 then + CastShapeshiftForm(currentShapeshiftIndex) end - for k, v in pairs(CleveRoids.splitStringIgnoringQuotes(msg)) do - v = string.gsub(v, "^%?", "") - if CleveRoids.DoWithConditionals(v, action, CleveRoids.FixEmptyTarget, false, action) then - handled = true - break +end + +function CleveRoids.DoEquipMainhand(msg) + -- PERFORMANCE: Use numeric iteration to avoid pairs() iterator allocation + local parts = CleveRoids.splitStringIgnoringQuotes(msg) + for i = 1, table.getn(parts) do + local v = string.gsub(parts[i], "^%?", "") + if CleveRoids.DoWithConditionals(v, _equipMainhandAction, CleveRoids.FixEmptyTarget, false, _equipMainhandAction) then + return true end end - return handled + return false end function CleveRoids.DoEquipOffhand(msg) - local handled = false - local action = function(msg) - return CleveRoids.EquipBagItem(msg, true) - end - for k, v in pairs(CleveRoids.splitStringIgnoringQuotes(msg)) do - v = string.gsub(v, "^%?", "") - if CleveRoids.DoWithConditionals(v, action, CleveRoids.FixEmptyTarget, false, action) then - handled = true - break + -- PERFORMANCE: Use numeric iteration to avoid pairs() iterator allocation + local parts = CleveRoids.splitStringIgnoringQuotes(msg) + for i = 1, table.getn(parts) do + local v = string.gsub(parts[i], "^%?", "") + if CleveRoids.DoWithConditionals(v, _equipOffhandAction, CleveRoids.FixEmptyTarget, false, _equipOffhandAction) then + return true end end - return handled + return false +end + +function CleveRoids.DoEquipTrinket1(msg) + local parts = CleveRoids.splitStringIgnoringQuotes(msg) + for i = 1, table.getn(parts) do + local v = string.gsub(parts[i], "^%?", "") + if CleveRoids.DoWithConditionals(v, _equipTrinket1Action, CleveRoids.FixEmptyTarget, false, _equipTrinket1Action) then + return true + end + end + return false +end + +function CleveRoids.DoEquipTrinket2(msg) + local parts = CleveRoids.splitStringIgnoringQuotes(msg) + for i = 1, table.getn(parts) do + local v = string.gsub(parts[i], "^%?", "") + if CleveRoids.DoWithConditionals(v, _equipTrinket2Action, CleveRoids.FixEmptyTarget, false, _equipTrinket2Action) then + return true + end + end + return false +end + +function CleveRoids.DoEquipRing1(msg) + local parts = CleveRoids.splitStringIgnoringQuotes(msg) + for i = 1, table.getn(parts) do + local v = string.gsub(parts[i], "^%?", "") + if CleveRoids.DoWithConditionals(v, _equipRing1Action, CleveRoids.FixEmptyTarget, false, _equipRing1Action) then + return true + end + end + return false +end + +function CleveRoids.DoEquipRing2(msg) + local parts = CleveRoids.splitStringIgnoringQuotes(msg) + for i = 1, table.getn(parts) do + local v = string.gsub(parts[i], "^%?", "") + if CleveRoids.DoWithConditionals(v, _equipRing2Action, CleveRoids.FixEmptyTarget, false, _equipRing2Action) then + return true + end + end + return false end function CleveRoids.DoUnshift(msg) local handled - - local action = function(msg) - local currentShapeshiftIndex = CleveRoids.GetCurrentShapeshiftIndex() - if currentShapeshiftIndex ~= 0 then - CastShapeshiftForm(currentShapeshiftIndex) - end - end - - for k, v in pairs(CleveRoids.splitStringIgnoringQuotes(msg)) do + -- PERFORMANCE: Use numeric iteration to avoid pairs() iterator allocation + local parts = CleveRoids.splitStringIgnoringQuotes(msg) + for i = 1, table.getn(parts) do handled = false - if CleveRoids.DoWithConditionals(v, action, CleveRoids.FixEmptyTarget, false, action) then + if CleveRoids.DoWithConditionals(parts[i], _unshiftAction, CleveRoids.FixEmptyTarget, false, _unshiftAction) then handled = true break end end if handled == nil then - action() + _unshiftAction() end return handled @@ -2063,16 +3450,88 @@ function CleveRoids.DoRetarget() end end --- Attempts to stop macro - function CleveRoids.DoStopMacro(msg) - local handled = false - for k, v in pairs(CleveRoids.splitStringIgnoringQuotes(CleveRoids.Trim(msg))) do - if CleveRoids.DoWithConditionals(msg, nil, nil, not CleveRoids.hasSuperwow, "STOPMACRO") then - handled = true -- we parsed at least one command - break +-- Attempts to stop macro (stops ALL remaining lines including parent macros) +function CleveRoids.DoStopMacro(msg) + -- PERFORMANCE: Use numeric iteration to avoid pairs() iterator allocation + local parts = CleveRoids.splitStringIgnoringQuotes(CleveRoids.Trim(msg)) + for i = 1, table.getn(parts) do + if CleveRoids.DoWithConditionals(parts[i], nil, nil, not CleveRoids.hasSuperwow, "STOPMACRO") then + return true end end - return handled + return false +end + +-- Attempts to skip remaining lines in current submacro ONLY (returns to parent) +function CleveRoids.DoSkipMacro(msg) + -- PERFORMANCE: Use numeric iteration to avoid pairs() iterator allocation + local parts = CleveRoids.splitStringIgnoringQuotes(CleveRoids.Trim(msg)) + for i = 1, table.getn(parts) do + if CleveRoids.DoWithConditionals(parts[i], nil, nil, not CleveRoids.hasSuperwow, "SKIPMACRO") then + return true + end + end + return false +end + +-- Enables "stop on first successful cast" mode for the rest of the macro +-- When set, successful /cast or /use commands will set stopMacroFlag automatically +-- This allows priority-based macro evaluation: first matching cast wins +-- Example usage: +-- /firstaction +-- /cast [myrawpower:>48] Shred +-- /cast [myrawpower:>40] Claw +-- In this case, if Shred casts, Claw will NOT be queued +function CleveRoids.DoFirstAction(msg) + -- Check if there are conditionals + if msg and CleveRoids.Trim(msg) ~= "" then + -- Has conditionals - use DoWithConditionals to evaluate them + local parts = CleveRoids.splitStringIgnoringQuotes(CleveRoids.Trim(msg)) + for i = 1, table.getn(parts) do + if CleveRoids.DoWithConditionals(parts[i], nil, nil, not CleveRoids.hasSuperwow, "FIRSTACTION") then + return true + end + end + return false + else + -- No conditionals - just set the flag + CleveRoids.stopOnCastFlag = true + return true + end +end + +-- Disables "stop on first successful cast" mode, re-enabling multi-queue behavior +-- Use this to restore normal evaluation after /firstaction +-- Example usage: +-- /firstaction +-- /cast [myrawpower:>48] Shred -- Priority section +-- /cast [myrawpower:>40] Claw +-- /nofirstaction +-- /cast Tiger's Fury -- Can queue alongside above +-- /startattack +function CleveRoids.DoNoFirstAction(msg) + -- Check if there are conditionals + if msg and CleveRoids.Trim(msg) ~= "" then + -- Has conditionals - use DoWithConditionals to evaluate them + local parts = CleveRoids.splitStringIgnoringQuotes(CleveRoids.Trim(msg)) + for i = 1, table.getn(parts) do + if CleveRoids.DoWithConditionals(parts[i], nil, nil, not CleveRoids.hasSuperwow, "NOFIRSTACTION") then + return true + end + end + return false + else + -- No conditionals - clear the flags + -- Capture stopOnCastFlag BEFORE clearing it to know if firstaction was active + local wasFirstActionActive = CleveRoids.stopOnCastFlag + CleveRoids.stopOnCastFlag = false + -- Also clear stopMacroFlag if it was set by the firstaction mechanism + -- (stopOnCastFlag being true means firstaction mode caused stopMacroFlag to be set) + if wasFirstActionActive and CleveRoids.stopMacroFlag then + CleveRoids.stopMacroFlag = false + end + return true + end end function CleveRoids.DoCastSequence(sequence) @@ -2108,10 +3567,38 @@ 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 + -- Capture target GUID for reset=target (only resets on NEW target, not same target) + if sequence.reset and sequence.reset.target and UnitExists("target") then + local _, targetGuid = UnitExists("target") + sequence.lastTargetGuid = targetGuid + end + local prevSeq = CleveRoids.currentSequence CleveRoids.currentSequence = sequence @@ -2131,11 +3618,14 @@ function CleveRoids.DoCastSequence(sequence) local function cast_by_name(msg) msg = msg or "" - if not string.find(msg, "%(%s*.-%s*%)%s*$") then + -- Check specifically for "(Rank" to allow spells like "Faerie Fire (Feral)" + -- to still get their rank appended automatically + if not string.find(msg, "%(Rank") then local sp = CleveRoids.GetSpell(msg) local r = (sp and sp.rank) or (sp and sp.highest and sp.highest.rank) if r and r ~= "" then msg = msg .. "(" .. r .. ")" end end + -- Let Nampower DLL handle queuing natively via its CastSpellByName hook CastSpellByName(msg) return true end @@ -2145,7 +3635,16 @@ function CleveRoids.DoCastSequence(sequence) attempted = cast_by_name(resolvedText or active.action) else local final = CleveRoids.DoWithConditionals(actionText, nil, CleveRoids.FixEmptyTarget, false, CastSpellByName) - if final then attempted = cast_by_name(final) end + if final then + -- DoWithConditionals returns true (boolean) when it already cast the spell, + -- or a string spell name when conditionals were skipped. Only call cast_by_name + -- if we got a string back (spell wasn't cast yet). + if type(final) == "string" then + attempted = cast_by_name(final) + else + attempted = true -- Spell was already cast inside DoWithConditionals + end + end end if not attempted then @@ -2154,6 +3653,9 @@ function CleveRoids.DoCastSequence(sequence) end CleveRoids.DoConditionalCancelAura = function(msg) + -- Check stopmacro flag + if CleveRoids.stopMacroFlag then return false end + local s = CleveRoids.Trim(msg or "") if s == "" then return false end @@ -2166,34 +3668,204 @@ CleveRoids.DoConditionalCancelAura = function(msg) return CleveRoids.DoWithConditionals(s, nil, CleveRoids.FixEmptyTarget, false, CleveRoids.CancelAura) or false end +-- PERFORMANCE: Separate periodic cleanup timer (runs every 5 seconds instead of every frame) +CleveRoids.lastCleanupTime = 0 +CleveRoids.CLEANUP_INTERVAL = 5 -- Run cleanup every 5 seconds + +-- PERFORMANCE: Upvalues for OnUpdate hot path +local GetTime = GetTime +local UnitAffectingCombat = UnitAffectingCombat +local pairs = pairs + function CleveRoids.OnUpdate(self) - -- Process equipment queue - if CleveRoids.ProcessEquipmentQueue then - CleveRoids.ProcessEquipmentQueue() - end + -- Prevent SuperWoW API calls during shutdown (crash prevention) + if CleveRoids.isShuttingDown then return end - -- Update casting state from Nampower - if CleveRoids.UpdateCastingState then - CleveRoids.UpdateCastingState() + -- Clear macro stop flags at the start of each frame + -- This ensures /stopmacro and /skipmacro only affect commands in the same frame (same macro execution) + -- Without SuperMacro, this is necessary because we can't hook into macro line execution + if CleveRoids.stopMacroFlag then + CleveRoids.stopMacroFlag = false + end + if CleveRoids.skipMacroFlag then + CleveRoids.skipMacroFlag = false + end + if CleveRoids.stopOnCastFlag then + CleveRoids.stopOnCastFlag = false end + -- PERFORMANCE: Single GetTime() call per frame local time = GetTime() - local refreshRate = CleveRoidMacros.refresh or 5 - refreshRate = 1/refreshRate - if CleveRoids.initializationTimer and time >= CleveRoids.initializationTimer then - CleveRoids.IndexItems() - CleveRoids.IndexActionBars() - CleveRoids.ready = true - CleveRoids.initializationTimer = nil - CleveRoids.TestForAllActiveActions() - CleveRoids.lastUpdate = time + + -- PERFORMANCE: Early exit if not ready (before any other checks) + if not CleveRoids.ready then + -- Handle initialization timer only when not ready + if CleveRoids.initializationTimer and time >= CleveRoids.initializationTimer then + CleveRoids.initializationTimer = nil + CleveRoids.IndexItems() + + -- FIX: Set ready=true BEFORE IndexActionBars so GetAction() works + -- IndexActionSlot calls GetAction() which has an early-exit if ready=false, + -- preventing TestForActiveAction from populating actions.active + CleveRoids.ready = true + + -- FIX: Suppress action event handlers during initial indexing + -- IndexActionBars sends ACTIONBAR_SLOT_CHANGED for each slot which triggers + -- addon handlers (pfUI, Bongos). 120 rapid calls can cause issues. + CleveRoids._suppressActionHandlers = true + CleveRoids.IndexActionBars() + CleveRoids._suppressActionHandlers = false + + CleveRoids.TestForAllActiveActions() + CleveRoids.lastUpdate = time + + -- FIX: Force refresh ALL Blizzard action buttons after initialization + -- Blizzard queries IsUsableAction during login before we're ready, + -- caching the wrong state. We must force a re-query for all buttons. + -- SKIP for pfUI users - pfUI handles its own button updates and this + -- would trigger 120 rapid handler calls causing issues. + if not (pfUI and pfUI.bars) then + for slot = 1, 120 do + if CleveRoids.Actions[slot] then + CleveRoids.SendEventForAction(slot, "ACTIONBAR_SLOT_CHANGED", slot) + end + end + end + end return end - if not CleveRoids.ready then return end - -- Throttle the update loop to avoid excessive CPU usage. - if (time - CleveRoids.lastUpdate) < refreshRate then return end + -- ========================================================================= + -- CONTINUOUS POSITION TRACKING (for [moving] fallback without MonkeySpeed) + -- ========================================================================= + -- Track player position at ~100 Hz with 4-sample history for snappy movement detection. + -- Matches MonkeySpeed's 0.01s detection interval for similar responsiveness. + -- This provides instant start-of-movement detection and quick stop detection. + -- Without this, [moving] only updates when macros execute (stale data). + local posTrackInterval = 0.01 -- 10ms between position samples (100 Hz, matches MonkeySpeed) + local lastPosTime = CleveRoids._positionTrackTime or 0 + + if (time - lastPosTime) >= posTrackInterval then + CleveRoids._positionTrackTime = time + + if UnitPosition then + local x, y = UnitPosition("player") + if x and y then + -- Initialize history buffer if needed + if not CleveRoids._positionHistory then + CleveRoids._positionHistory = {} + end + + -- Add new sample to history + local history = CleveRoids._positionHistory + table.insert(history, { x = x, y = y, time = time }) + + -- Keep only last 4 samples (40ms window at 10ms intervals) + while table.getn(history) > 4 do + table.remove(history, 1) + end + + -- Also maintain legacy vars for any code that uses them directly + if table.getn(history) >= 2 then + CleveRoids._previousPlayerPos = history[table.getn(history) - 1] + CleveRoids._currentPlayerPos = history[table.getn(history)] + end + end + end + end + + -- PERFORMANCE: Delayed WDB warmup after login (ensures GetItemInfo works after WDB clear) + if CleveRoids.wdbWarmupTime and time >= CleveRoids.wdbWarmupTime then + CleveRoids.wdbWarmupTime = nil + CleveRoids.DoWDBWarmup() + end + + -- PERFORMANCE: Cache refresh rate calculation (avoid per-frame division) + local refreshRate = CleveRoids.cachedRefreshRate + if not refreshRate then + refreshRate = 1 / (CleveRoidMacros.refresh or 5) + CleveRoids.cachedRefreshRate = refreshRate + end + + -- PERFORMANCE: Throttle check FIRST - skip most work on non-throttled frames + local lastUpdate = CleveRoids.lastUpdate or 0 + local bypassThrottle = CleveRoids.isActionUpdateQueued and CleveRoidMacros.realtime == 0 + local shouldUpdate = bypassThrottle or (time - lastUpdate) >= refreshRate + + if not shouldUpdate then + return -- Early exit for non-throttled frames + end + CleveRoids.lastUpdate = time + + -- Process deferred equipment index updates (for throttled UNIT_INVENTORY_CHANGED) + -- PERFORMANCE: Skip check entirely if no pending update + local pendingTime = CleveRoids.equipIndexPendingTime + if pendingTime and not UnitAffectingCombat("player") then + if (time - (CleveRoids.lastEquipIndexTime or 0)) >= 0.2 then + CleveRoids.lastEquipIndexTime = time + CleveRoids.equipIndexPendingTime = nil + CleveRoids.lastItemIndexTime = time + CleveRoids.IndexItems() + CleveRoids.Actions = {} + CleveRoids.Macros = {} + CleveRoids.IndexActionBars() + + if CleveRoidMacros.realtime == 0 then + CleveRoids.QueueActionUpdate() + end + end + end + + -- PERFORMANCE: Check for expired reactive procs only if we have any + -- Use statically allocated removal buffer to avoid per-frame allocation + local reactiveProcs = CleveRoids.reactiveProcs + if reactiveProcs then + local hasExpiredProc = false + local toRemove = CleveRoids._procRemovalBuffer + local removeCount = 0 + + -- PERFORMANCE: Use next() directly instead of pairs() to avoid iterator allocation + local spellName, procData = next(reactiveProcs) + while spellName do + if procData and procData.expiry and time >= procData.expiry then + removeCount = removeCount + 1 + toRemove[removeCount] = spellName + hasExpiredProc = true + end + spellName, procData = next(reactiveProcs, spellName) + end + + -- Remove expired procs using indexed array (no pairs() overhead) + for i = 1, removeCount do + reactiveProcs[toRemove[i]] = nil + toRemove[i] = nil -- Clear for next use + end + + -- If any proc expired, immediately update all actions + if hasExpiredProc then + CleveRoids.TestForAllActiveActions() + CleveRoids.isActionUpdateQueued = false + end + end + -- PERFORMANCE: Check for modifier key state changes (no events for these in vanilla WoW) + -- Only queue update if modifier state actually changed - avoids full TestForAllActiveActions + local altDown = IsAltKeyDown() and true or false + local shiftDown = IsShiftKeyDown() and true or false + local ctrlDown = IsControlKeyDown() and true or false + + if altDown ~= CleveRoids._lastAltDown or + shiftDown ~= CleveRoids._lastShiftDown or + ctrlDown ~= CleveRoids._lastCtrlDown then + CleveRoids._lastAltDown = altDown + CleveRoids._lastShiftDown = shiftDown + CleveRoids._lastCtrlDown = ctrlDown + -- Modifier changed - queue update in event-driven mode, or just mark for realtime + if CleveRoidMacros.realtime == 0 then + CleveRoids.isActionUpdateQueued = true + end + end + -- Check the saved variable to decide which update mode to use. if CleveRoidMacros.realtime == 1 then -- Realtime Mode: Force an update on every throttled tick for maximum responsiveness. @@ -2201,8 +3873,14 @@ function CleveRoids.OnUpdate(self) else -- Event-Driven Mode (Default): Only update if a relevant game event has queued it. if CleveRoids.isActionUpdateQueued then + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage("|cffff00ff[OnUpdate]|r Processing queued action update") + end CleveRoids.TestForAllActiveActions() CleveRoids.isActionUpdateQueued = false -- Reset the flag after updating + if CleveRoids.debug then + DEFAULT_CHAT_FRAME:AddMessage("|cffff00ff[OnUpdate]|r Action update complete, flag reset") + end end end @@ -2212,15 +3890,94 @@ function CleveRoids.OnUpdate(self) CleveRoids.autoAttackLockElapsed = nil end - for _, sequence in pairs(CleveRoids.Sequences) do + -- PERFORMANCE: Use next() directly instead of pairs() to avoid iterator allocation + local Sequences = CleveRoids.Sequences + local seqKey, sequence = next(Sequences) + while seqKey do if sequence.index > 1 and sequence.reset.secs and (time - (sequence.lastUpdate or 0)) >= sequence.reset.secs then CleveRoids.ResetSequence(sequence) end + seqKey, sequence = next(Sequences, seqKey) end - for guid,cast in pairs(CleveRoids.spell_tracking) do - if time > cast.expires then - CleveRoids.spell_tracking[guid] = nil + -- PERFORMANCE: Use next() directly instead of pairs() to avoid iterator allocation + local spell_tracking = CleveRoids.spell_tracking + local guid, cast = next(spell_tracking) + while guid do + local nextGuid = next(spell_tracking, guid) -- Get next before potential removal + if cast.expires and time > cast.expires then + spell_tracking[guid] = nil + end + guid, cast = nextGuid, nextGuid and spell_tracking[nextGuid] + end + + -- Clean stale castTracking entries (standalone mode only, pfUI 7.6 manages its own) + if not CleveRoids.hasPfUI76 then + local ct = CleveRoids.castTracking + local ctGuid, ctEntry = next(ct) + while ctGuid do + local nextCtGuid = next(ct, ctGuid) + if ctEntry.endTime and time > ctEntry.endTime + 0.5 then + ct[ctGuid] = nil + end + ctGuid = nextCtGuid + ctEntry = nextCtGuid and ct[nextCtGuid] + end + end + + -- PERFORMANCE OPTIMIZATION: Run memory cleanup less frequently (every 5 seconds instead of every frame) + -- This reduces CPU usage while maintaining effective memory management + if (time - CleveRoids.lastCleanupTime) >= CleveRoids.CLEANUP_INTERVAL then + CleveRoids.lastCleanupTime = time + + -- MEMORY: Clean up carnageDurationOverrides older than 30 seconds + -- PERFORMANCE: Use next() directly instead of pairs() to avoid iterator allocation + local carnageOverrides = CleveRoids.carnageDurationOverrides + if carnageOverrides then + local spellID, data = next(carnageOverrides) + while spellID do + local nextID = next(carnageOverrides, spellID) + if data.timestamp and (time - data.timestamp) > 30 then + carnageOverrides[spellID] = nil + end + spellID, data = nextID, nextID and carnageOverrides[nextID] + end + end + + -- MEMORY: Clean up old ComboPointTracking entries (older than 60 seconds) + -- PERFORMANCE: Use next() directly instead of pairs() to avoid iterator allocation + local comboTracking = CleveRoids.ComboPointTracking + if comboTracking then + local trackName, data = next(comboTracking) + while trackName do + local nextName = next(comboTracking, trackName) + if data.cast_time and (time - data.cast_time) > 60 then + comboTracking[trackName] = nil + end + trackName, data = nextName, nextName and comboTracking[nextName] + end + end + + -- MEMORY: Clear spell name caches every 60 seconds (12 cleanup cycles) + CleveRoids._spellCacheCleanupCounter = (CleveRoids._spellCacheCleanupCounter or 0) + 1 + if CleveRoids._spellCacheCleanupCounter >= 12 then + CleveRoids._spellCacheCleanupCounter = 0 + if CleveRoids.ClearSpellNameCaches then + 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 @@ -2235,12 +3992,21 @@ CleveRoids.Hooks.GameTooltip.SetAction = GameTooltip.SetAction function GameTooltip.SetAction(self, slot) local actions = CleveRoids.GetAction(slot) + -- If this is our macro but has no active action, show just the macro name + if actions and not actions.active then + local macroName = GetActionText(slot) + if macroName then + GameTooltip:SetText(macroName) + GameTooltip:Show() + return + end + end + local action_to_display_info = nil if actions then + -- Only show spell/item tooltip when there's an active action if actions.active then action_to_display_info = actions.active - elseif actions.tooltip then - action_to_display_info = actions.tooltip end end @@ -2325,6 +4091,7 @@ end CleveRoids.Hooks.PickupAction = PickupAction function PickupAction(slot) + if not slot then return end CleveRoids.ClearAction(slot) CleveRoids.ClearSlot(CleveRoids.actionSlots, slot) CleveRoids.ClearAction(CleveRoids.reactiveSlots, slot) @@ -2333,48 +4100,204 @@ end CleveRoids.Hooks.ActionHasRange = ActionHasRange function ActionHasRange(slot) + if not slot then return nil end local actions = CleveRoids.GetAction(slot) - if actions and actions.active then - return (1 and actions.active.inRange ~= -1 or nil) - else - return CleveRoids.Hooks.ActionHasRange(slot) + -- Only override for our macros with #showtooltip + 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 + -- Not a macro we're tracking - pass through to original + return CleveRoids.Hooks.ActionHasRange(slot) end CleveRoids.Hooks.IsActionInRange = IsActionInRange function IsActionInRange(slot, unit) + if not slot then return nil end local actions = CleveRoids.GetAction(slot) - if actions and actions.active and actions.active.type == "spell" then - return actions.active.inRange - else - return CleveRoids.Hooks.IsActionInRange(slot, unit) + -- Only override for our macros with #showtooltip + 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) + end + end end + -- Not a macro we're tracking - pass through to original + return CleveRoids.Hooks.IsActionInRange(slot, unit) end CleveRoids.Hooks.OriginalIsUsableAction = IsUsableAction CleveRoids.Hooks.IsUsableAction = IsUsableAction function IsUsableAction(slot, unit) + if not slot then return nil, nil end local actions = CleveRoids.GetAction(slot) - if actions and actions.active then - return actions.active.usable, actions.active.oom + + -- If this is one of our macros AND it uses #showtooltip + if actions and actions.tooltip then + -- IMPORTANT: Only override usability when #showtooltip is present + -- Macros without #showtooltip should use default game behavior + if actions.active then + -- We have an active action - return its usable state + -- FIX: Convert oom boolean to number (1/nil) for Blizzard's button code + local oomValue = actions.active.oom and 1 or nil + return actions.active.usable, oomValue + else + -- 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 + elseif actions.tooltip.item then + -- FIX: Handle item tooltips - items are always usable unless on cooldown + -- The cooldown is handled separately by GetActionCooldown hook + return 1, nil + end + -- No tooltip spell or item - this macro's conditionals all failed + -- Return nil to make the icon dark + return nil, nil + end else + -- Not our macro OR no #showtooltip - use game's default behavior return CleveRoids.Hooks.IsUsableAction(slot, unit) end end CleveRoids.Hooks.IsCurrentAction = IsCurrentAction function IsCurrentAction(slot) - local active = CleveRoids.GetActiveAction(slot) + if not slot then return nil end + local actions = CleveRoids.GetAction(slot) - if not active then + -- When no action is active (all conditionals failed), don't check tooltip's "current" status + if actions and not actions.active and not actions.explicitTooltip and actions.list and table.getn(actions.list) > 0 then + return CleveRoids.Hooks.IsCurrentAction(slot) + end + + -- Use the same priority as GetActionTexture: active first, then tooltip + local actionToCheck = (actions and actions.active) or (actions and actions.tooltip) + + if not actionToCheck then return CleveRoids.Hooks.IsCurrentAction(slot) else local name - if active.spell then - local rank = active.spell.rank or active.spell.highest.rank - name = active.spell.name..(rank and ("("..rank..")")) - elseif active.item then - name = active.item.name + if actionToCheck.spell then + local rank = actionToCheck.spell.rank or actionToCheck.spell.highest.rank + name = actionToCheck.spell.name..(rank and ("("..rank..")")) + + -- Check if this spell is currently queued or being cast via Nampower + -- Get spell ID for comparison + local spellId = actionToCheck.spell.id + if not spellId and GetSpellIdForName then + spellId = GetSpellIdForName(name) + end + + if spellId then + -- Prefer GetCastInfo (Nampower 2.18+) for cleaner API + if GetCastInfo then + local ok, info = pcall(GetCastInfo) + if ok and info and info.spellId == spellId then + -- Spell is actively being cast/channeled + return true + end + end + + -- Also check GetCurrentCastingInfo for queued spell detection + if GetCurrentCastingInfo then + local castId, visId, autoId, casting, channeling = GetCurrentCastingInfo() + + -- Show glow if actively casting/channeling this spell + if (casting == 1 and castId == spellId) or (channeling == 1 and visId == spellId) then + return true + end + -- Show glow if this spell is queued (castId set but not yet casting) + if casting == 0 and channeling == 0 and castId == spellId then + return true + end + end + end + elseif actionToCheck.item then + name = actionToCheck.item.name end return CleveRoids.Hooks.IsCurrentAction(CleveRoids.GetProxyActionSlot(name) or slot) @@ -2383,39 +4306,148 @@ end CleveRoids.Hooks.GetActionTexture = GetActionTexture function GetActionTexture(slot) + if not slot then return nil end local actions = CleveRoids.GetAction(slot) + -- Check if this is one of our macros if actions and (actions.active or actions.tooltip) then + + -- This block handles the case where all conditionals fail and no explicit + -- #showtooltip was set. It defaults to the macro's chosen icon. + if not actions.active and not actions.explicitTooltip and actions.list and table.getn(actions.list) > 0 then + -- Get the macro's own icon as fallback + local macroTexture = nil + local macroName = GetActionText(slot) + if macroName then + local macroID = GetMacroIndexByName(macroName) + if macroID and macroID > 0 then + local _, texture = GetMacroInfo(macroID) + macroTexture = texture + end + end + + -- When no conditionals pass, use macro icon (not first action's icon) + -- The actions.tooltip is just the first action which didn't pass conditionals + if macroTexture then + return macroTexture + end + + -- Should never reach here, but return unknown as last resort + return CleveRoids.unknownTexture + end + -- Prioritize active action, fall back to tooltip local a = actions.active or actions.tooltip - - -- NEW: For slot-based actions, always fetch current equipment texture + + -- Handle numeric slot actions (e.g., /use 13) local slotId = tonumber(a.action) if slotId and slotId >= 1 and slotId <= 19 then local currentTexture = GetInventoryItemTexture("player", slotId) if currentTexture then return currentTexture end - -- No item equipped in that slot, return unknown texture + + -- Slot is empty, fall back to macro icon + local macroName = GetActionText(slot) + if macroName then + local macroID = GetMacroIndexByName(macroName) + if macroID and macroID > 0 then + local _, macroTexture = GetMacroInfo(macroID) + if macroTexture then + return macroTexture + end + end + end return CleveRoids.unknownTexture end - - local proxySlot = (actions.active and actions.active.spell) and CleveRoids.GetProxyActionSlot(actions.active.spell.name) - if proxySlot and CleveRoids.Hooks.GetActionTexture(proxySlot) ~= actions.active.spell.texture then - return CleveRoids.Hooks.GetActionTexture(proxySlot) - else - return (actions.active and actions.active.texture) or (actions.tooltip and actions.tooltip.texture) or CleveRoids.unknownTexture + + -- *** THIS IS THE FIX *** + -- If an action is active, return its texture directly. + -- If no action is active, return the tooltip's texture. + -- If neither has a texture, fall back to the macro's icon. + local texture = (actions.active and actions.active.texture) or (actions.tooltip and actions.tooltip.texture) + + -- Check if this is a shapeshift form spell and use active texture if toggled on + if a and a.spell and a.action then + -- Strip rank info and underscores from spell name for comparison + local spellName = string.gsub(a.action, "%s*%(.-%)%s*$", "") + spellName = string.gsub(spellName, "_", " ") + -- Check all shapeshift forms to see if this spell matches and is active + for i = 1, GetNumShapeshiftForms() do + local icon, name, isActive, isCastable = GetShapeshiftFormInfo(i) + if name and string.lower(name) == string.lower(spellName) and isActive and icon then + texture = icon + break + end + end end + + -- Check if this is a toggled buff ability (Prowl, Shadowmeld) and swap icon based on buff state + -- Note: Stealth is handled above by shapeshift form logic + if a and a.spell and a.action then + local spellName = string.gsub(a.action, "%s*%(.-%)%s*$", "") + spellName = string.gsub(spellName, "_", " ") + + -- Check if this is one of our toggled buff abilities (not shapeshift forms) + local toggledAbilities = { + [CleveRoids.Localized.Spells["Prowl"]] = true, + [CleveRoids.Localized.Spells["Shadowmeld"]] = true, + } + + if toggledAbilities[spellName] then + -- Check if the buff is active + if CleveRoids.ValidatePlayerBuff(spellName) then + -- Buff is active, use the active texture from auraTextures + local activeTexture = CleveRoids.auraTextures[spellName] + if activeTexture then + texture = activeTexture + end + end + end + end + + if texture then + return texture + end + + -- Final fallback: get the macro's icon + local macroName = GetActionText(slot) + if macroName then + local macroID = GetMacroIndexByName(macroName) + if macroID and macroID > 0 then + local _, macroTexture = GetMacroInfo(macroID) + if macroTexture then + return macroTexture + end + end + end + + -- Should never reach here + return CleveRoids.unknownTexture + end + + -- Not one of our macros, use the original function return CleveRoids.Hooks.GetActionTexture(slot) end -- TODO: Look into https://github.com/Stanzilla/WoWUIBugs/issues/47 if needed CleveRoids.Hooks.GetActionCooldown = GetActionCooldown function GetActionCooldown(slot) + -- Guard against nil/invalid slot + if not slot then return 0, 0, 0 end + local actions = CleveRoids.GetAction(slot) -- Check for actions.active OR actions.tooltip if actions and (actions.active or actions.tooltip) then + + -- When no action is active (all conditionals failed) but tooltip exists from #showtooltip, + -- don't show the tooltip's cooldown - fall back to the original function (no cooldown) + -- This matches the icon behavior in GetActionTexture (lines 3958-3977) + if not actions.active and not actions.explicitTooltip and actions.list and table.getn(actions.list) > 0 then + return CleveRoids.Hooks.GetActionCooldown(slot) + end + -- Prioritize the active action, but fall back to the tooltip action local a = actions.active or actions.tooltip @@ -2433,7 +4465,23 @@ function GetActionCooldown(slot) return GetInventoryItemCooldown("player", a.item.inventoryID) end end - return 0, 0, 0 + + -- DEBUG: Log when we fall through without finding spell/item data (once per slot) + if CleveRoids.cooldownDebug and not CleveRoids.cooldownDebugLogged[slot] then + CleveRoids.cooldownDebugLogged[slot] = true + DEFAULT_CHAT_FRAME:AddMessage(string.format( + "|cffff0000[CD Issue]|r Slot %d: No cooldown data! action='%s' type=%s spell=%s item=%s", + slot, + tostring(a.action), + tostring(a.type), + tostring(a.spell ~= nil), + tostring(a.item ~= nil) + )) + DEFAULT_CHAT_FRAME:AddMessage("|cffff8800[CD Issue]|r This slot will show incorrect cooldown. Report macro text to developer.") + end + + -- Fallback to original function (preserves SuperWoW's #showtooltip handling) + return CleveRoids.Hooks.GetActionCooldown(slot) else return CleveRoids.Hooks.GetActionCooldown(slot) end @@ -2441,30 +4489,41 @@ end CleveRoids.Hooks.GetActionCount = GetActionCount function GetActionCount(slot) + -- Guard against nil/invalid slot + if not slot then return 0 end + local action = CleveRoids.GetAction(slot) local count - if action and action.active then - local slotId = tonumber(action.active.action) + -- When no action is active (all conditionals failed), don't show tooltip's count + if action and not action.active and not action.explicitTooltip and action.list and table.getn(action.list) > 0 then + return CleveRoids.Hooks.GetActionCount(slot) + end + + -- Use the same priority as GetActionTexture: active first, then tooltip + local actionToCheck = (action and action.active) or (action and action.tooltip) + if actionToCheck then + + local slotId = tonumber(actionToCheck.action) if slotId and slotId >= 1 and slotId <= 19 then return GetInventoryItemCount("player", slotId) end - if action.active.item then - count = action.active.item.count + if actionToCheck.item then + count = actionToCheck.item.count - elseif action.active.spell then - local reagent = action.active.spell.reagent + elseif actionToCheck.spell then + local reagent = actionToCheck.spell.reagent if not reagent then - local ss, bt = action.active.spell.spellSlot, action.active.spell.bookType + local ss, bt = actionToCheck.spell.spellSlot, actionToCheck.spell.bookType if ss and bt then local _, r = CleveRoids.GetSpellCost(ss, bt) reagent = r end - if (not reagent) and _ReagentBySpell and action.active.spell.name then - reagent = _ReagentBySpell[action.active.spell.name] -- e.g., Vanish → Flash Powder + if (not reagent) and _ReagentBySpell and actionToCheck.spell.name then + reagent = _ReagentBySpell[actionToCheck.spell.name] -- e.g., Vanish → Flash Powder end - action.active.spell.reagent = reagent -- cache it so we don’t re-scan every frame + actionToCheck.spell.reagent = reagent -- cache it so we don't re-scan every frame end if reagent then count = CleveRoids.GetReagentCount(reagent) -- id-first bag scan, falls back to name/tooltip @@ -2477,24 +4536,35 @@ end CleveRoids.Hooks.IsConsumableAction = IsConsumableAction function IsConsumableAction(slot) - local action = CleveRoids.GetAction(slot) - if action and action.active then + -- Guard against nil/invalid slot + if not slot then return nil end - local slotId = tonumber(action.active.action) + local action = CleveRoids.GetAction(slot) + + -- When no action is active (all conditionals failed), don't show tooltip's consumable status + if action and not action.active and not action.explicitTooltip and action.list and table.getn(action.list) > 0 then + return CleveRoids.Hooks.IsConsumableAction(slot) + end + + -- Use the same priority as GetActionTexture: active first, then tooltip + local actionToCheck = (action and action.active) or (action and action.tooltip) + if actionToCheck then + + local slotId = tonumber(actionToCheck.action) if slotId and slotId >= 1 and slotId <= 19 then local _, count = GetInventoryItemCount("player", slotId) if count and count > 0 then return 1 end end - if action.active.item and - (CleveRoids.countedItemTypes[action.active.item.type] - or CleveRoids.countedItemTypes[action.active.item.name]) + if actionToCheck.item and + (CleveRoids.countedItemTypes[actionToCheck.item.type] + or CleveRoids.countedItemTypes[actionToCheck.item.name]) then return 1 end - if action.active.spell and action.active.spell.reagent then + if actionToCheck.spell and actionToCheck.spell.reagent then return 1 end end @@ -2502,6 +4572,69 @@ function IsConsumableAction(slot) return CleveRoids.Hooks.IsConsumableAction(slot) end +-- ============================================================================ +-- RunMacro Hook - Use our own macro execution system +-- ============================================================================ +-- This allows /stopmacro, /skipmacro, /firstaction to work across parent/child +-- macro boundaries without requiring SuperMacro addon. +-- +-- When SuperMacro is installed, its RunLine hook takes precedence for extended +-- macro features. This hook handles vanilla Blizzard macros. +-- ============================================================================ +if not CleveRoids.RunMacroHooked then + CleveRoids.Hooks.RunMacro = RunMacro + + function RunMacro(indexOrName) + -- Skip if SuperMacro is handling macro execution (it has its own hooks) + if CleveRoids.SM_RunLineHooked then + return CleveRoids.Hooks.RunMacro(indexOrName) + end + + -- Resolve macro index + local macroIndex + if type(indexOrName) == "number" then + macroIndex = indexOrName + elseif type(indexOrName) == "string" then + -- Could be a name or a numeric string + local num = tonumber(indexOrName) + if num then + macroIndex = num + else + macroIndex = GetMacroIndexByName(indexOrName) + end + end + + if not macroIndex or macroIndex == 0 then + -- Fallback to original if we can't resolve + return CleveRoids.Hooks.RunMacro(indexOrName) + end + + -- Get macro body + local name, icon, body = GetMacroInfo(macroIndex) + if not body or body == "" then + return CleveRoids.Hooks.RunMacro(indexOrName) + end + + -- Clear macro flags at the start of top-level macro execution + CleveRoids.stopMacroFlag = false + CleveRoids.skipMacroFlag = false + -- Note: stopOnCastFlag is intentionally NOT cleared here - it persists + -- within a frame and is cleared by OnUpdate + + if CleveRoids.macroRefDebug then + CleveRoids.Print("|cff00ff00[RunMacro Hook]|r Executing macro '" .. (name or macroIndex) .. "' via CleveRoids") + end + + -- Execute through our system + CleveRoids.ExecuteMacroBody(body) + + -- Don't call original - we've handled it + return + end + + CleveRoids.RunMacroHooked = true +end + -- Create a hidden tooltip frame to read buff names if not AuraScanTooltip and not CleveRoids.hasSuperwow then CreateFrame("GameTooltip", "AuraScanTooltip") @@ -2547,9 +4680,16 @@ CleveRoids.Frame:SetScript("OnEvent", function(...) CleveRoids.Frame[event](this,arg1,arg2,arg3,arg4,arg5,arg6,arg7,arg8,arg9,arg10) end) +-- == SHUTDOWN PROTECTION == +-- Flag to prevent SuperWoW API calls during logout (crash prevention) +CleveRoids.isShuttingDown = false + -- == CORE EVENT REGISTRATION == CleveRoids.Frame:RegisterEvent("PLAYER_LOGIN") CleveRoids.Frame:RegisterEvent("ADDON_LOADED") +CleveRoids.Frame:RegisterEvent("PLAYER_LOGOUT") +CleveRoids.Frame:RegisterEvent("PLAYER_LEAVING_WORLD") +CleveRoids.Frame:RegisterEvent("PLAYER_ENTERING_WORLD") CleveRoids.Frame:RegisterEvent("UPDATE_MACROS") CleveRoids.Frame:RegisterEvent("SPELLS_CHANGED") CleveRoids.Frame:RegisterEvent("ACTIONBAR_SLOT_CHANGED") @@ -2560,8 +4700,10 @@ CleveRoids.Frame:RegisterEvent("UNIT_PET") -- == STATE CHANGE EVENT REGISTRATION (for performance) == CleveRoids.Frame:RegisterEvent("PLAYER_TARGET_CHANGED") CleveRoids.Frame:RegisterEvent("PLAYER_FOCUS_CHANGED") -- For focus addons -CleveRoids.Frame:RegisterEvent("PLAYER_ENTER_COMBAT") -CleveRoids.Frame:RegisterEvent("PLAYER_LEAVE_COMBAT") +CleveRoids.Frame:RegisterEvent("PLAYER_ENTER_COMBAT") -- Auto-attack started +CleveRoids.Frame:RegisterEvent("PLAYER_LEAVE_COMBAT") -- Auto-attack stopped +CleveRoids.Frame:RegisterEvent("PLAYER_REGEN_DISABLED") -- Entered actual combat (has threat) +CleveRoids.Frame:RegisterEvent("PLAYER_REGEN_ENABLED") -- Left actual combat (no threat) CleveRoids.Frame:RegisterEvent("UPDATE_SHAPESHIFT_FORM") CleveRoids.Frame:RegisterEvent("SPELL_UPDATE_COOLDOWN") CleveRoids.Frame:RegisterEvent("UNIT_AURA") @@ -2578,65 +4720,20 @@ CleveRoids.Frame:RegisterEvent("START_AUTOREPEAT_SPELL") CleveRoids.Frame:RegisterEvent("STOP_AUTOREPEAT_SPELL") CleveRoids.Frame:RegisterEvent("SPELLCAST_CHANNEL_START") CleveRoids.Frame:RegisterEvent("SPELLCAST_CHANNEL_STOP") +CleveRoids.Frame:RegisterEvent("SPELLCAST_START") +CleveRoids.Frame:RegisterEvent("SPELLCAST_STOP") +CleveRoids.Frame:RegisterEvent("SPELLCAST_FAILED") +CleveRoids.Frame:RegisterEvent("SPELLCAST_INTERRUPTED") - - --- Order-agnostic SuperMacro hook installer -local function CRM_SM_InstallHook() - if CleveRoids.SM_RunLineHooked then return end - if not SuperMacroFrame or type(RunLine) ~= "function" then return end - - local orig_RunLine = RunLine - - -- Fast-path targets for extended/bracket syntax - local tokenHooks = { - cast = CleveRoids.DoCast, - target = CleveRoids.DoTarget, - use = CleveRoids.DoUse, - } - - RunLine = function(...) - local text = (arg and arg[1]) or nil - - if CleveRoids.stopmacro then - CleveRoids.stopmacro = false - return true - end - - if type(text) == "string" then - -- 1) SPECIAL-CASE: /castsequence (no token required) - local b, e, rest = string.find(text, "^%s*/castsequence%s*(.*)") - if b then - if type(CleveRoids.DoCastSequence) == "function" then - pcall(CleveRoids.DoCastSequence, rest or "") - return true - end - local fn = _G.SlashCmdList and _G.SlashCmdList["CASTSEQUENCE"] - if type(fn) == "function" then - pcall(fn, rest or "") - return true - end - -- fall through to SM if no handler - else - -- 2) FAST-PATH: /cast|/use|/target followed by extended token - for k, fn in pairs(tokenHooks) do - if type(fn) == "function" and string.find(text, "^%s*/"..k.."%s+[!%[%{%?~]") then - -- IMPORTANT: keep the opening token — grab the entire remainder - local _, _, remainder = string.find(text, "^%s*/"..k.."%s+(.*)$") - remainder = remainder and string.gsub(remainder, "^%s+", "") or "" - pcall(fn, remainder) - return true - end - end - end - end - - return orig_RunLine(text) - end - - CleveRoids.SM_RunLineHooked = true +-- Nampower SPELL_CAST_EVENT for reliable channel tracking +if GetCurrentCastingInfo then + CleveRoids.Frame:RegisterEvent("SPELL_CAST_EVENT") end + +-- NOTE: SuperMacro hook installation is handled by Compatibility/SuperMacro.lua +-- which has the complete implementation including the INTERCEPT path for all commands + function CleveRoids.Frame:UNIT_PET() if arg1 == "player" then CleveRoids.IndexPetSpells() @@ -2646,6 +4743,50 @@ function CleveRoids.Frame:UNIT_PET() end end +-- Handle shutdown to prevent SuperWoW API crashes during logout +function CleveRoids.Frame:PLAYER_LOGOUT() + CleveRoids.isShuttingDown = true + this:UnregisterAllEvents() + this:SetScript("OnUpdate", nil) + this:SetScript("OnEvent", nil) + -- Clear spell caches to prevent stale data + if CleveRoids.spellIdCache then + for k in pairs(CleveRoids.spellIdCache) do + CleveRoids.spellIdCache[k] = nil + end + end +end + +function CleveRoids.Frame:PLAYER_LEAVING_WORLD() + -- FIX: Only set loading flag, don't unregister events + -- PLAYER_LEAVING_WORLD fires for both logout AND zone transitions + -- We need to stay alive for zone transitions; PLAYER_LOGOUT handles actual logout + CleveRoids.isLoading = true +end + +-- FIX: Handle zone transitions (instance entry, etc.) +-- Refresh action button state after loading screen completes +function CleveRoids.Frame:PLAYER_ENTERING_WORLD() + CleveRoids.isLoading = false + + -- Skip if not yet initialized + if not CleveRoids.ready then return end + + -- Refresh all action states after zone transition + CleveRoids.TestForAllActiveActions() + + -- FIX: Force Blizzard buttons to re-query IsUsableAction + -- Loading screen causes UI rebuild which can cache stale button states + -- SKIP for pfUI users - pfUI handles its own button updates + if not (pfUI and pfUI.bars) then + for slot = 1, 120 do + if CleveRoids.Actions[slot] then + CleveRoids.SendEventForAction(slot, "ACTIONBAR_SLOT_CHANGED", slot) + end + end + end +end + -- Simplified PLAYER_LOGIN - requirements already checked function CleveRoids.Frame:PLAYER_LOGIN() -- Skip if already disabled @@ -2656,34 +4797,197 @@ function CleveRoids.Frame:PLAYER_LOGIN() CleveRoids.IndexSpells() CleveRoids.IndexPetSpells() CleveRoids.initializationTimer = GetTime() + 1.5 - CRM_SM_InstallHook() + + -- PERFORMANCE: Initialize event-driven cache states + CleveRoids._cachedPlayerInCombat = UnitAffectingCombat("player") and true or false + + -- Schedule delayed WDB warmup (loads items into client cache via tooltip scan) + -- This ensures GetItemInfo() works for all inventory items after a WDB clear + CleveRoids.wdbWarmupTime = GetTime() + 3.0 -- 3 second delay after login +end + +-- PERFORMANCE: WDB warmup - tooltip scan all bag items to ensure they're cached +-- This prevents GetItemInfo() returning nil for items after a WDB clear +function CleveRoids.DoWDBWarmup() + if CleveRoids.wdbWarmupDone then return end + CleveRoids.wdbWarmupDone = true + + -- Create a hidden tooltip for scanning if it doesn't exist + local tip = CleveRoidsWDBTip + if not tip then + tip = CreateFrame("GameTooltip", "CleveRoidsWDBTip", UIParent, "GameTooltipTemplate") + tip:SetOwner(WorldFrame, "ANCHOR_NONE") + end + + local scanned = 0 + + -- Scan all bag slots + for bag = 0, 4 do + local slots = GetContainerNumSlots(bag) or 0 + for slot = 1, slots do + local link = GetContainerItemLink(bag, slot) + if link then + -- Tooltip scan loads the item into WDB + tip:ClearLines() + tip:SetBagItem(bag, slot) + scanned = scanned + 1 + end + end + end + + -- Scan equipped items + for slot = 0, 19 do + local link = GetInventoryItemLink("player", slot) + if link then + tip:ClearLines() + tip:SetInventoryItem("player", slot) + scanned = scanned + 1 + end + end + + -- Now trigger a full item index to populate the cache with valid data + if CleveRoids.IndexItems then + CleveRoids.IndexItems() + end + + if CleveRoids.debug then + CleveRoids.Print("|cff88ff88[WDB Warmup]|r Scanned " .. scanned .. " items into cache") + end end function CleveRoids.Frame:ADDON_LOADED(addon) -- keep your existing init for CRM: - if addon == "CleveRoidMacros" then + if addon == "CleveRoidMacros" or addon == "SuperCleveRoidMacros" then CleveRoids.InitializeExtensions() end - -- (re)attempt hook when either addon arrives - if addon == "SuperMacro" or addon == "CleveRoidMacros" then - CRM_SM_InstallHook() - end + -- NOTE: SuperMacro hook installation is handled by Compatibility/SuperMacro.lua end function CleveRoids.Frame:UNIT_CASTEVENT(caster,target,action,spell_id,cast_time) - if action == "MAINHAND" or action == "OFFHAND" then return end + -- Handle melee swings for judgement refresh + if action == "MAINHAND" or action == "OFFHAND" then + -- Only process if this is the player's melee swing + if caster == CleveRoids.playerGuid and CleveRoids.playerClass == "PALADIN" then + -- Refresh judgements on the target + -- Defensive: verify libdebuff is a table before accessing properties + local lib = type(CleveRoids.libdebuff) == "table" and CleveRoids.libdebuff or nil + 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 + for spellID, rec in pairs(lib.objects[normalizedTarget]) do + -- 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() + + 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[pfUI Judgement Refresh]|r Synced %s refresh to pfUI", baseName) + ) + end + end + end + end + end + end + end + end + end + return -- Still return early after processing melee + end + + -- Debug channel tracking + if CleveRoids.ChannelTimeDebug then + local spellName = spell_id and SpellInfo and SpellInfo(spell_id) or "Unknown" + if string.find(spellName, "Arcane") then + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[UNIT_CASTEVENT]|r %s: %s (ID:%s) caster=%s player=%s", + action, spellName, tostring(spell_id), tostring(caster), tostring(CleveRoids.playerGuid))) + end + end -- handle cast spell tracking local cast = CleveRoids.spell_tracking[caster] if cast_time > 0 and action == "START" or action == "CHANNEL" then CleveRoids.spell_tracking[caster] = { spell_id = spell_id, expires = GetTime() + cast_time/1000, type = action } + + -- ALSO store under "player" literal for easier lookup + if caster == CleveRoids.playerGuid then + CleveRoids.spell_tracking["player"] = CleveRoids.spell_tracking[caster] + + -- For CHANNEL events, capture duration for checkchanneled conditional + if action == "CHANNEL" then + CleveRoids.channelStartTime = GetTime() + -- Try to get accurate duration from spell tooltip (reflects haste) + local tooltipDuration = CleveRoids.GetChannelDurationFromTooltipByID(spell_id) + if tooltipDuration then + CleveRoids.channelDuration = tooltipDuration + else + -- Fallback to UNIT_CASTEVENT duration (may not reflect haste) + CleveRoids.channelDuration = cast_time / 1000 + end + end + + -- For START events, capture cast time for checkcasting conditional + if action == "START" then + CleveRoids.castStartTime = GetTime() + -- For cast-time spells, UNIT_CASTEVENT duration is usually accurate + -- but we could add tooltip scanning here too if needed + CleveRoids.castDuration = cast_time / 1000 + end + end + + if CleveRoids.ChannelTimeDebug and caster == CleveRoids.playerGuid then + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[Tracking]|r Set spell_tracking[%s] AND [player]: type=%s, expires=%.2f", + tostring(caster), action, GetTime() + cast_time/1000)) + end + -- Always show for channels if debug is on + if CleveRoids.ChannelTimeDebug and action == "CHANNEL" then + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[Tracking]|r caster=%s, playerGuid=%s, match=%s", + tostring(caster), tostring(CleveRoids.playerGuid), tostring(caster == CleveRoids.playerGuid))) + end elseif cast and ( (cast.spell_id == spell_id and (action == "FAIL" or action == "CAST")) or (GetTime() > cast.expires) ) then + if CleveRoids.ChannelTimeDebug and caster == CleveRoids.playerGuid then + local reason = "" + if cast.spell_id == spell_id and (action == "FAIL" or action == "CAST") then + reason = string.format("spell_id match (%s) and action=%s", tostring(spell_id), action) + elseif GetTime() > cast.expires then + reason = string.format("expired (%.2f > %.2f)", GetTime(), cast.expires) + end + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cffff0000[Tracking]|r CLEARING spell_tracking - %s", reason)) + end CleveRoids.spell_tracking[caster] = nil + -- Also clear "player" literal if this is the player + if caster == CleveRoids.playerGuid then + CleveRoids.spell_tracking["player"] = nil + end end -- handle cast sequence (SuperWoW) @@ -2720,21 +5024,167 @@ function CleveRoids.Frame:UNIT_CASTEVENT(caster,target,action,spell_id,cast_time end end -function CleveRoids.Frame:SPELLCAST_CHANNEL_START() - CleveRoids.CurrentSpell.type = "channeled" - if CleveRoidMacros.realtime == 0 then - CleveRoids.QueueActionUpdate() +-- Nampower SPELL_CAST_EVENT handler for reliable channel tracking +-- This is the PRIMARY source of truth for channel state (not GetCurrentCastingInfo polling) +function CleveRoids.Frame:SPELL_CAST_EVENT(success, spellId, castType, targetGuid, itemId) + local CHANNEL = 4 + + if castType == CHANNEL and success == 1 then + -- Channel started successfully + CleveRoids.CurrentSpell.type = "channeled" + CleveRoids.CurrentSpell.castingSpellId = spellId + + local spellName = SpellInfo and SpellInfo(spellId) + if spellName then + CleveRoids.CurrentSpell.spellName = spellName + end + + -- Force immediate action update + CleveRoids.TestForAllActiveActions() end end +function CleveRoids.Frame:SPELLCAST_CHANNEL_START() + -- Set channel state immediately when event fires + -- Duration is captured by UNIT_CASTEVENT which fires earlier + CleveRoids.CurrentSpell.type = "channeled" + + -- Try to get spell info - prefer GetCastInfo (Nampower 2.18+) for better data + local spellId = nil + if GetCastInfo then + local ok, info = pcall(GetCastInfo) + if ok and info and info.spellId and info.spellId > 0 then + spellId = info.spellId + -- Also capture timing data + CleveRoids.CurrentSpell.castRemainingMs = info.castRemainingMs + CleveRoids.CurrentSpell.castEndTime = info.castEndS + end + end + -- Fallback to GetCurrentCastingInfo for older Nampower + if not spellId and GetCurrentCastingInfo then + local _, visId = GetCurrentCastingInfo() + if visId and visId > 0 then + spellId = visId + end + end + -- Update spell info + if spellId then + CleveRoids.CurrentSpell.castingSpellId = spellId + local spellName = SpellInfo(spellId) + if spellName then + CleveRoids.CurrentSpell.spellName = spellName + end + end + + -- Force immediate action update + CleveRoids.TestForAllActiveActions() +end + function CleveRoids.Frame:SPELLCAST_CHANNEL_STOP() + -- Channel ended - clear state immediately CleveRoids.CurrentSpell.type = "" CleveRoids.CurrentSpell.spellName = "" - if CleveRoidMacros.realtime == 0 then - CleveRoids.QueueActionUpdate() + CleveRoids.CurrentSpell.castingSpellId = nil + + -- WARLOCK DARK HARVEST: Mark channeling as ended + -- Credits: Avitasia / Cursive addon + if CleveRoids.darkHarvestData and CleveRoids.darkHarvestData.isActive then + CleveRoids.darkHarvestData.isActive = false + CleveRoids.darkHarvestData.endTime = GetTime() + + -- Apply Dark Harvest end to all DoTs on target (finalizes reduction) + if CleveRoids.libdebuff and CleveRoids.libdebuff.ApplyDarkHarvestEnd then + CleveRoids.libdebuff.ApplyDarkHarvestEnd(CleveRoids.darkHarvestData.targetGUID) + end + + if CleveRoids.debug then + local activeTime = CleveRoids.darkHarvestData.endTime - CleveRoids.darkHarvestData.startTime + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff9482c9[Dark Harvest]|r Channel ended after %.1fs (DoT acceleration stopped)", + activeTime) + ) + end + end + + -- Force immediate action update + CleveRoids.TestForAllActiveActions() +end + +function CleveRoids.Frame:SPELLCAST_START() + -- Cast-time spell started + -- Duration is captured by UNIT_CASTEVENT which fires earlier + CleveRoids.CurrentSpell.type = "cast" + + -- Try to get spell info - prefer GetCastInfo (Nampower 2.18+) for better data + local spellId = nil + if GetCastInfo then + local ok, info = pcall(GetCastInfo) + if ok and info and info.spellId and info.spellId > 0 then + spellId = info.spellId + -- Also capture timing data + CleveRoids.CurrentSpell.castRemainingMs = info.castRemainingMs + CleveRoids.CurrentSpell.castEndTime = info.castEndS + CleveRoids.CurrentSpell.gcdRemainingMs = info.gcdRemainingMs + CleveRoids.CurrentSpell.gcdEndTime = info.gcdEndS + end + end + -- Fallback to GetCurrentCastingInfo for older Nampower + if not spellId and GetCurrentCastingInfo then + local castId = GetCurrentCastingInfo() + if castId and castId > 0 then + spellId = castId + end + end + -- Update spell info + if spellId then + CleveRoids.CurrentSpell.castingSpellId = spellId + local spellName = SpellInfo(spellId) + if spellName then + CleveRoids.CurrentSpell.spellName = spellName + end + end + + -- Force immediate action update + CleveRoids.TestForAllActiveActions() +end + +function CleveRoids.Frame:SPELLCAST_STOP() + -- Cast finished - clear state immediately + if CleveRoids.CurrentSpell.type == "cast" then + CleveRoids.CurrentSpell.type = "" + CleveRoids.CurrentSpell.spellName = "" + CleveRoids.CurrentSpell.castingSpellId = nil + + -- Force immediate action update + CleveRoids.TestForAllActiveActions() end end +function CleveRoids.Frame:SPELLCAST_FAILED() + -- Cast failed - clear state immediately + if CleveRoids.CurrentSpell.type == "cast" then + CleveRoids.CurrentSpell.type = "" + CleveRoids.CurrentSpell.spellName = "" + CleveRoids.CurrentSpell.castingSpellId = nil + + -- Force immediate action update + CleveRoids.TestForAllActiveActions() + end +end + +function CleveRoids.Frame:SPELLCAST_INTERRUPTED() + -- Cast interrupted - clear state immediately + if CleveRoids.CurrentSpell.type == "cast" then + CleveRoids.CurrentSpell.type = "" + CleveRoids.CurrentSpell.spellName = "" + CleveRoids.CurrentSpell.castingSpellId = nil + + -- Force immediate action update + CleveRoids.TestForAllActiveActions() + end +end + +-- PLAYER_ENTER_COMBAT/PLAYER_LEAVE_COMBAT are for AUTO-ATTACK state (not actual combat) function CleveRoids.Frame:PLAYER_ENTER_COMBAT() CleveRoids.CurrentSpell.autoAttack = true CleveRoids.CurrentSpell.autoAttackLock = false @@ -2747,7 +5197,38 @@ function CleveRoids.Frame:PLAYER_LEAVE_COMBAT() CleveRoids.CurrentSpell.autoAttack = false CleveRoids.CurrentSpell.autoAttackLock = false + -- Full re-index after combat to refresh any items/bags that were skipped + -- during combat for performance. Use a slight delay to avoid spam. + local now = GetTime() + if (now - (CleveRoids.lastItemIndexTime or 0)) > 0.5 then + CleveRoids.lastItemIndexTime = now + CleveRoids.IndexItems() + CleveRoids.Actions = {} + CleveRoids.Macros = {} + CleveRoids.IndexActionBars() + end + + if CleveRoidMacros.realtime == 0 then + CleveRoids.QueueActionUpdate() + end +end + +-- PLAYER_REGEN_DISABLED/PLAYER_REGEN_ENABLED are for ACTUAL combat state (threat/aggro) +-- These events track what UnitAffectingCombat("player") reports +function CleveRoids.Frame:PLAYER_REGEN_DISABLED() + -- PERFORMANCE: Cache actual combat state for event-driven [combat]/[nocombat] conditionals + CleveRoids._cachedPlayerInCombat = true + if CleveRoidMacros.realtime == 0 then + CleveRoids.QueueActionUpdate() + end +end + +function CleveRoids.Frame:PLAYER_REGEN_ENABLED() + -- PERFORMANCE: Cache actual combat state for event-driven [combat]/[nocombat] conditionals + CleveRoids._cachedPlayerInCombat = false + -- Reset any sequence with reset=combat that has progressed past the first step + -- Uses PLAYER_REGEN_ENABLED (actual combat ends) instead of PLAYER_LEAVE_COMBAT (auto-attack stops) for _, sequence in pairs(CleveRoids.Sequences) do if sequence.index > 1 and sequence.reset and sequence.reset.combat then CleveRoids.ResetSequence(sequence) @@ -2763,10 +5244,47 @@ function CleveRoids.Frame:PLAYER_TARGET_CHANGED() CleveRoids.CurrentSpell.autoAttack = false CleveRoids.CurrentSpell.autoAttackLock = false - -- Reset any sequence with reset=target that has progressed past the first step + -- Clear resist state when target changes + CleveRoids.ClearResistState() + + -- 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 - 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 @@ -2800,6 +5318,9 @@ function CleveRoids.Frame:UPDATE_MACROS() end function CleveRoids.Frame:SPELLS_CHANGED() + -- PERFORMANCE: Clear spell caches when spells change (learn new ranks, etc.) + CleveRoids.spellIdCache = {} + CleveRoids.spellNameCache = {} CleveRoids.Frame:UPDATE_MACROS() end @@ -2812,8 +5333,13 @@ function CleveRoids.Frame:ACTIONBAR_SLOT_CHANGED() end function CleveRoids.Frame:BAG_UPDATE() + -- In combat: Skip entirely for zero lag + if UnitAffectingCombat("player") then + return + end + + -- Out of combat: Full indexing with throttle local now = GetTime() - -- Only index items if more than 1 second has passed since the last index if (now - (CleveRoids.lastItemIndexTime or 0)) > 1.0 then CleveRoids.lastItemIndexTime = now CleveRoids.IndexItems() @@ -2821,7 +5347,6 @@ function CleveRoids.Frame:BAG_UPDATE() -- Directly clear all relevant caches and force a UI refresh for all buttons. CleveRoids.Actions = {} CleveRoids.Macros = {} - --CleveRoids.ParsedMsg = {} CleveRoids.IndexActionBars() if CleveRoidMacros.realtime == 0 then CleveRoids.QueueActionUpdate() @@ -2831,17 +5356,33 @@ end function CleveRoids.Frame:UNIT_INVENTORY_CHANGED() if arg1 ~= "player" then return end - - -- Equipment changes need immediate response, bypass BAG_UPDATE throttle + + -- PERFORMANCE: Invalidate equipment cache for HasGearEquipped + if CleveRoids.InvalidateEquipmentCache then + CleveRoids.InvalidateEquipmentCache() + end + + -- In combat: Skip ALL processing - EquipBagItem already handles cache invalidation + -- This eliminates lag from IndexEquippedItems during rapid gear swapping + if UnitAffectingCombat("player") then + return + end + + -- Out of combat: Full indexing with throttle local now = GetTime() + if (now - (CleveRoids.lastEquipIndexTime or 0)) < 0.2 then + CleveRoids.equipIndexPendingTime = now + return + end + + CleveRoids.lastEquipIndexTime = now + CleveRoids.equipIndexPendingTime = nil CleveRoids.lastItemIndexTime = now CleveRoids.IndexItems() - - -- Directly clear all relevant caches and force a UI refresh for all buttons CleveRoids.Actions = {} CleveRoids.Macros = {} CleveRoids.IndexActionBars() - + if CleveRoidMacros.realtime == 0 then CleveRoids.QueueActionUpdate() end @@ -2886,20 +5427,65 @@ function CleveRoids.Frame:SPELL_UPDATE_COOLDOWN() if CleveRoidMacros.realtime == 0 then CleveRoids.QueueActionUpdate() end + + -- COOLDOWN FIX: Explicitly update cooldowns on all managed action buttons + -- This ensures cooldowns display correctly even when the active action hasn't changed + CleveRoids.UpdateAllManagedCooldowns() end + +-- Helper function to notify addon handlers about cooldown updates +-- NOTE: Blizzard action buttons are handled natively via ActionButton_OnEvent which calls +-- our GetActionCooldown hook. We do NOT call CooldownFrame_SetTimer directly to avoid +-- conflicts with SuperWoW's own GetActionCooldown handling for #showtooltip macros. +-- Track which slots have been logged to avoid spam +CleveRoids.cooldownDebugLogged = {} + +function CleveRoids.UpdateAllManagedCooldowns() + local Actions = CleveRoids.Actions + if not Actions then return end + + -- Only notify third-party handlers (pfUI/Bongos) + -- Blizzard buttons update themselves via GetActionCooldown hook + local handlerCount = CleveRoids.actionEventHandlers and table.getn(CleveRoids.actionEventHandlers) or 0 + if handlerCount == 0 then return end + + for slot, actions in pairs(Actions) do + if actions then + -- Notify action event handlers (for pfUI/Bongos) about the cooldown update + for _, fn_h in ipairs(CleveRoids.actionEventHandlers) do + fn_h(slot, "ACTIONBAR_UPDATE_COOLDOWN") + end + end + end +end +-- PERFORMANCE OPTIMIZATION: Throttled event handlers to reduce CPU spam +-- UNIT_AURA can fire dozens of times per second during combat +-- 100ms throttle reduces calls by 90%+ while maintaining responsiveness function CleveRoids.Frame:UNIT_AURA() if CleveRoidMacros.realtime == 0 then - CleveRoids.QueueActionUpdate() + local now = GetTime() + if (now - CleveRoids.lastUnitAuraUpdate) >= CleveRoids.EVENT_THROTTLE then + CleveRoids.lastUnitAuraUpdate = now + CleveRoids.QueueActionUpdate() + end end end function CleveRoids.Frame:UNIT_HEALTH() if CleveRoidMacros.realtime == 0 then - CleveRoids.QueueActionUpdate() + local now = GetTime() + if (now - CleveRoids.lastUnitHealthUpdate) >= CleveRoids.EVENT_THROTTLE then + CleveRoids.lastUnitHealthUpdate = now + CleveRoids.QueueActionUpdate() + end end end function CleveRoids.Frame:UNIT_POWER() if CleveRoidMacros.realtime == 0 then - CleveRoids.QueueActionUpdate() + local now = GetTime() + if (now - CleveRoids.lastUnitPowerUpdate) >= CleveRoids.EVENT_THROTTLE then + CleveRoids.lastUnitPowerUpdate = now + CleveRoids.QueueActionUpdate() + end end end @@ -2927,9 +5513,17 @@ function CleveRoids.Frame:SPELL_QUEUE_EVENT() CleveRoids.queuedSpell.spellName = name end end + -- BUGFIX: Update casting state when spell is queued (for [casting] conditional) + if CleveRoids.UpdateCastingState then + CleveRoids.UpdateCastingState() + end CleveRoids.QueueActionUpdate() elseif eventCode == NORMAL_QUEUE_POPPED or eventCode == NON_GCD_QUEUE_POPPED or eventCode == ON_SWING_QUEUE_POPPED then CleveRoids.queuedSpell = nil + -- BUGFIX: Update casting state when spell queue pops (for [casting] conditional) + if CleveRoids.UpdateCastingState then + CleveRoids.UpdateCastingState() + end CleveRoids.QueueActionUpdate() end end @@ -2940,6 +5534,11 @@ function CleveRoids.Frame:SPELL_CAST_EVENT() local success = arg1 local spellId = arg2 + -- BUGFIX: Update casting state on spell cast events (for [casting] conditional) + if CleveRoids.UpdateCastingState then + CleveRoids.UpdateCastingState() + end + if success == 1 then CleveRoids.lastCastSpell = { spellId = spellId, @@ -3058,17 +5657,55 @@ SlashCmdList["CLEVEROID"] = function(msg) msg = "" end local cmd, val, val2 - local s, e, a, b, c = string.find(msg, "^(%S*)%s*(%S*)%s*(%S*)$") + local s, e, a, b, c = string.find(msg, "^(%S*)%s*(%S*)%s*(.*)$") if a then cmd = a else cmd = "" end if b then val = b else val = "" end if c then val2 = c else val2 = "" end - -- No command: show current value + -- No command: show current value and available commands if cmd == "" then CleveRoids.Print("Current Settings:") DEFAULT_CHAT_FRAME:AddMessage("realtime (force fast updates, CPU intensive) = " .. CleveRoidMacros.realtime .. " (Default: 0)") DEFAULT_CHAT_FRAME:AddMessage("refresh (updates per second) = " .. CleveRoidMacros.refresh .. " (Default: 5)") DEFAULT_CHAT_FRAME:AddMessage("debug (show learning messages) = " .. (CleveRoids.debug and "1" or "0") .. " (Default: 0)") + DEFAULT_CHAT_FRAME:AddMessage(" ") + CleveRoids.Print("Available Commands:") + DEFAULT_CHAT_FRAME:AddMessage("/cleveroid realtime 0 or 1 - Force realtime updates") + DEFAULT_CHAT_FRAME:AddMessage("/cleveroid refresh X - Set refresh rate (1-10 updates/sec)") + if CleveRoids.hasSuperwow then + DEFAULT_CHAT_FRAME:AddMessage("/cleveroid learn - Manually set spell duration") + DEFAULT_CHAT_FRAME:AddMessage("/cleveroid forget - Forget learned duration(s)") + DEFAULT_CHAT_FRAME:AddMessage("/cleveroid debug [0|1] - Toggle learning debug messages") + end + DEFAULT_CHAT_FRAME:AddMessage("|cffffaa00Spell Schools:|r") + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid listschools - List all learned spell schools') + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid clearschools - Clear learned spell school data') + DEFAULT_CHAT_FRAME:AddMessage("|cffffaa00Immunity Tracking:|r") + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid listimmune [school] - List immunity data') + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid addimmune "" [buff] - Add immunity') + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid removeimmune "" - Remove immunity') + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid clearimmune [school] - Clear immunity data') + DEFAULT_CHAT_FRAME:AddMessage("|cffffaa00CC Immunity Tracking:|r") + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid listccimmune [type] - List CC immunity data') + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid addccimmune "" [buff] - Add CC immunity') + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid removeccimmune "" - Remove CC immunity') + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid clearccimmune [type] - Clear CC immunity data') + DEFAULT_CHAT_FRAME:AddMessage(" CC types: stun, fear, root, silence, sleep, charm, polymorph, banish, horror, disorient, snare") + DEFAULT_CHAT_FRAME:AddMessage("|cffffaa00Combo Point Tracking:|r") + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid combotrack - Show combo point tracking info') + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid comboclear - Clear combo tracking data') + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid combolearn - Show learned combo durations (per CP)') + DEFAULT_CHAT_FRAME:AddMessage("|cffffaa00Talent Modifiers:|r") + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid talenttabs - Show talent tab IDs for your class') + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid listtab - List all talents in a tab with their IDs') + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid talents - Show current talent ranks') + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid testtalent - Test talent modifier for a spell') + DEFAULT_CHAT_FRAME:AddMessage("|cffffaa00Debuff Tracking Debug:|r") + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid debuffdebug [spell] - Debug debuff tracking on target') + DEFAULT_CHAT_FRAME:AddMessage("|cffffaa00pfUI Tank Integration:|r") + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid tankdebug - Debug tank targeting conditionals') + DEFAULT_CHAT_FRAME:AddMessage("|cffffaa00All-Caster Aura Tracking:|r") + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid auradebug - Debug buff/debuff time tracking from all casters') return end @@ -3090,6 +5727,7 @@ SlashCmdList["CLEVEROID"] = function(msg) local num = tonumber(val) if num and num >= 1 and num <= 10 then CleveRoidMacros.refresh = num + CleveRoids.cachedRefreshRate = nil -- Invalidate cached rate CleveRoids.Print("refresh set to " .. num .. " times per second") else CleveRoids.Print("Usage: /cleveroid refresh X - Set refresh rate. (1 to 10 updates per second. Default: 5)") @@ -3158,6 +5796,651 @@ SlashCmdList["CLEVEROID"] = function(msg) return end + -- listimmune (list immunity data) + if cmd == "listimmune" or cmd == "immunelist" then + CleveRoids.ListImmunities(val ~= "" and val or nil) + return + end + + -- clearimmune (clear immunity data) + if cmd == "clearimmune" then + CleveRoids.ClearImmunities(val ~= "" and val or nil) + return + end + + -- addimmune (manually add immunity) + if cmd == "addimmune" then + -- Parse: /cleveroid addimmune [buff] + -- Example: /cleveroid addimmune "Golemagg the Incinerator" fire + -- Example: /cleveroid addimmune Vaelastrasz fire "Burning Adrenaline" + local npcName, school, buffName = nil, nil, nil + + -- Try to extract quoted NPC name + local _, _, quotedNpc, rest = string.find(msg, '^addimmune%s+"([^"]+)"%s*(.*)$') + if quotedNpc then + npcName = quotedNpc + -- Parse school and optional buff from rest + local _, _, sch, buff = string.find(rest, "^(%S+)%s*(.*)$") + school = sch + if buff and buff ~= "" then + -- Check if buff is quoted + local _, _, quotedBuff = string.find(buff, '^"([^"]+)"$') + buffName = quotedBuff or buff + end + else + -- No quoted NPC, use simple parsing + npcName = val + school = val2 + end + + CleveRoids.AddImmunity(npcName, school, buffName) + return + end + + -- removeimmune (manually remove immunity) + if cmd == "removeimmune" then + -- Parse: /cleveroid removeimmune + local npcName, school = nil, nil + + -- Try to extract quoted NPC name + local _, _, quotedNpc, sch = string.find(msg, '^removeimmune%s+"([^"]+)"%s*(%S*)$') + if quotedNpc then + npcName = quotedNpc + school = sch + else + npcName = val + school = val2 + end + + CleveRoids.RemoveImmunity(npcName, school) + return + end + + -- ========== CC IMMUNITY COMMANDS ========== + + -- listccimmune (list CC immunity data) + if cmd == "listccimmune" or cmd == "ccimmunelist" then + CleveRoids.ListCCImmunities(val ~= "" and val or nil) + return + end + + -- clearccimmune (clear CC immunity data) + if cmd == "clearccimmune" then + CleveRoids.ClearCCImmunities(val ~= "" and val or nil) + return + end + + -- addccimmune (manually add CC immunity) + if cmd == "addccimmune" then + -- Parse: /cleveroid addccimmune [buff] + -- Example: /cleveroid addccimmune "Stone Guardian" stun + -- Example: /cleveroid addccimmune "Boss Name" fear "Enrage" + local npcName, ccType, buffName = nil, nil, nil + + -- Try to extract quoted NPC name + local _, _, quotedNpc, rest = string.find(msg, '^addccimmune%s+"([^"]+)"%s*(.*)$') + if quotedNpc then + npcName = quotedNpc + -- Parse ccType and optional buff from rest + local _, _, cct, buff = string.find(rest, "^(%S+)%s*(.*)$") + ccType = cct + if buff and buff ~= "" then + -- Check if buff is quoted + local _, _, quotedBuff = string.find(buff, '^"([^"]+)"$') + buffName = quotedBuff or buff + end + else + -- No quoted NPC, use simple parsing + npcName = val + ccType = val2 + end + + CleveRoids.AddCCImmunity(npcName, ccType, buffName) + return + end + + -- removeccimmune (manually remove CC immunity) + if cmd == "removeccimmune" then + -- Parse: /cleveroid removeccimmune + local npcName, ccType = nil, nil + + -- Try to extract quoted NPC name + local _, _, quotedNpc, cct = string.find(msg, '^removeccimmune%s+"([^"]+)"%s*(%S*)$') + if quotedNpc then + npcName = quotedNpc + ccType = cct + else + npcName = val + ccType = val2 + end + + CleveRoids.RemoveCCImmunityCommand(npcName, ccType) + return + end + + -- listschools (show learned spell schools) + if cmd == "listschools" or cmd == "schools" then + CleveRoids.Print("|cff88ff88=== Learned Spell Schools ===|r") + if not CleveRoids_SpellSchools or not next(CleveRoids_SpellSchools) then + CleveRoids.Print("No spell schools learned yet. Deal damage to enemies!") + CleveRoids.Print("Nampower damage events will automatically track spell schools.") + else + local count = 0 + local bySchool = {} + + -- Group by school + for spellID, school in pairs(CleveRoids_SpellSchools) do + if not bySchool[school] then + bySchool[school] = {} + end + table.insert(bySchool[school], spellID) + count = count + 1 + end + + CleveRoids.Print("Total spells tracked: " .. count) + CleveRoids.Print(" ") + + -- Display by school + for school, spellIDs in pairs(bySchool) do + local schoolColor = "|cff88ff88" + if school == "fire" then schoolColor = "|cffff4400" + elseif school == "frost" then schoolColor = "|cff00ffff" + elseif school == "nature" then schoolColor = "|cff00ff00" + elseif school == "shadow" then schoolColor = "|cff8800ff" + elseif school == "arcane" then schoolColor = "|cffff00ff" + elseif school == "holy" then schoolColor = "|cffffff00" + elseif school == "bleed" then schoolColor = "|cffff0000" + end + + CleveRoids.Print(schoolColor .. string.upper(school) .. "|r (" .. table.getn(spellIDs) .. " spells):") + for _, spellID in ipairs(spellIDs) do + local spellName = SpellInfo and SpellInfo(spellID) or "Unknown" + CleveRoids.Print(" " .. spellName .. " (ID:" .. spellID .. ")") + end + end + end + return + end + + -- clearschools (clear learned spell schools) + if cmd == "clearschools" then + local count = 0 + if CleveRoids_SpellSchools then + for _ in pairs(CleveRoids_SpellSchools) do + count = count + 1 + end + end + CleveRoids_SpellSchools = {} + CleveRoids.spellSchoolMapping = CleveRoids_SpellSchools + CleveRoids.Print("Cleared " .. count .. " learned spell schools") + return + end + + -- combotrack (show combo point tracking info) + if cmd == "combotrack" or cmd == "combo" then + CleveRoids.ShowComboTracking() + return + end + + -- comboclear (clear combo tracking data) + if cmd == "comboclear" then + CleveRoids.ComboPointTracking = {} + CleveRoids.ComboPointTracking.byID = {} + CleveRoids.Print("Combo point tracking data cleared") + return + end + + -- combolearn (show learned combo durations) + if cmd == "combolearn" or cmd == "combodurations" then + CleveRoids.Print("=== Learned Combo Durations ===") + if not CleveRoids_ComboDurations or not next(CleveRoids_ComboDurations) then + CleveRoids.Print("No learned combo durations yet. Cast finishers and let them expire!") + else + for spellID, cpData in pairs(CleveRoids_ComboDurations) do + local spellName = SpellInfo(spellID) or ("Spell " .. spellID) + CleveRoids.Print(spellName .. " (ID:" .. spellID .. "):") + for cp = 1, 5 do + if cpData[cp] then + CleveRoids.Print(" " .. cp .. " CP = " .. cpData[cp] .. "s") + end + end + end + end + return + end + + -- talenttabs (show talent tab IDs) + if cmd == "talenttabs" or cmd == "tabs" then + CleveRoids.Print("=== Talent Tabs ===") + for i = 1, GetNumTalentTabs() do + local name, _, pointsSpent = GetTalentTabInfo(i) + CleveRoids.Print("Tab " .. i .. ": " .. name .. " (" .. pointsSpent .. " points)") + end + return + end + + -- listtab (show all talents in a specific tab with their IDs) + if cmd == "listtab" or cmd == "tab" then + local tabNum = tonumber(val) + if not tabNum or tabNum < 1 or tabNum > GetNumTalentTabs() then + CleveRoids.Print("Usage: /cleveroid listtab ") + CleveRoids.Print("Example: /cleveroid listtab 2") + CleveRoids.Print("Use /cleveroid talenttabs to see your tab numbers") + return + end + + local tabName = GetTalentTabInfo(tabNum) + CleveRoids.Print("=== " .. tabName .. " (Tab " .. tabNum .. ") ===") + + local numTalents = GetNumTalents(tabNum) + for i = 1, numTalents do + local name, _, _, _, rank, maxRank = GetTalentInfo(tabNum, i) + if name then + local rankText = rank .. "/" .. maxRank + CleveRoids.Print("ID " .. i .. ": " .. name .. " [" .. rankText .. "]") + end + end + return + end + + -- talents (show current talent ranks) + if cmd == "talents" or cmd == "talent" then + CleveRoids.Print("=== Current Talents ===") + + -- Ensure talents are indexed + if not CleveRoids.Talents or table.getn(CleveRoids.Talents) == 0 then + if CleveRoids.IndexTalents then + CleveRoids.IndexTalents() + end + end + + local count = 0 + for name, rank in pairs(CleveRoids.Talents) do + if type(name) == "string" and tonumber(rank) and tonumber(rank) > 0 then + CleveRoids.Print(name .. ": Rank " .. rank) + count = count + 1 + end + end + + if count == 0 then + CleveRoids.Print("No talents learned yet!") + else + CleveRoids.Print("Total: " .. count .. " talents") + end + return + end + + -- testtalent (test talent modifier for a spell) + if cmd == "testtalent" or cmd == "talenttest" then + local spellID = tonumber(val) + if not spellID then + CleveRoids.Print("Usage: /cleveroid testtalent ") + CleveRoids.Print("Example: /cleveroid testtalent 1943 (Rupture Rank 1)") + return + end + + local spellName = SpellInfo(spellID) or ("Spell " .. spellID) + local modifier = CleveRoids.talentModifiers and CleveRoids.talentModifiers[spellID] + + if not modifier then + CleveRoids.Print(spellName .. " (ID:" .. spellID .. ") has no talent modifier configured") + return + end + + -- Get talent rank using position-based or name-based lookup + local talentRank = 0 + local maxRank = 3 + local talentName = modifier.talent or ("Tab " .. tostring(modifier.tab) .. " ID " .. tostring(modifier.id)) + + -- Position-based lookup (preferred) + if modifier.tab and modifier.id then + local _, name, _, _, rank, max = GetTalentInfo(modifier.tab, modifier.id) + talentRank = tonumber(rank) or 0 + maxRank = tonumber(max) or 3 + if name then + talentName = name + end + end + + -- Name-based fallback + if talentRank == 0 and modifier.talent and CleveRoids.GetTalentRank then + talentRank = CleveRoids.GetTalentRank(modifier.talent) + end + + CleveRoids.Print("=== Talent Modifier Test ===") + CleveRoids.Print("Spell: " .. spellName .. " (ID:" .. spellID .. ")") + CleveRoids.Print("Talent: " .. talentName) + CleveRoids.Print("Your Rank: " .. talentRank .. "/" .. maxRank) + + if talentRank == 0 then + CleveRoids.Print("|cffff0000You don't have this talent!|r") + else + -- Test with a base duration (use 10s as example) + local baseDur = 10 + local modDur = modifier.modifier(baseDur, talentRank) + CleveRoids.Print("Example: 10s base -> " .. modDur .. "s modified (+" .. (modDur - baseDur) .. "s)") + end + return + end + + -- debuffdebug (debug debuff tracking on target) + if cmd == "debuffdebug" or cmd == "debuff" or cmd == "trackdebug" then + local searchName = val + if val2 and val2 ~= "" then + searchName = val .. " " .. val2 + end + -- Strip underscores and quotes + if searchName and searchName ~= "" then + searchName = string.gsub(searchName, "_", " ") + searchName = string.gsub(searchName, '"', "") + end + + CleveRoids.Print("|cff88ff88=== Debuff Tracking Debug ===|r") + + local _, guid = UnitExists("target") + if not guid then + CleveRoids.Print("|cffff0000No target selected!|r") + return + end + + local targetName = UnitName("target") or "Unknown" + guid = CleveRoids.NormalizeGUID(guid) + CleveRoids.Print("Target: " .. targetName .. " (GUID: " .. tostring(guid) .. ")") + + -- Show tracking table for this target + CleveRoids.Print(" ") + CleveRoids.Print("|cff00ff00=== Tracked Debuffs (libdebuff.objects) ===|r") + local lib = CleveRoids.libdebuff + if lib and lib.objects and lib.objects[guid] then + local count = 0 + for spellID, rec in pairs(lib.objects[guid]) do + if rec and rec.start and rec.duration then + local timeRemaining = rec.duration + rec.start - GetTime() + local spellName = SpellInfo and SpellInfo(spellID) or "Unknown" + local caster = rec.caster or "unknown" + local stacks = rec.stacks or 0 + if timeRemaining > 0 then + CleveRoids.Print(string.format(" |cff00ff00[%d]|r %s: %.1fs left (caster: %s, stacks: %d)", + spellID, spellName, timeRemaining, caster, stacks)) + count = count + 1 + else + CleveRoids.Print(string.format(" |cffff0000[%d]|r %s: EXPIRED %.1fs ago (caster: %s)", + spellID, spellName, -timeRemaining, caster)) + end + end + end + if count == 0 then + CleveRoids.Print(" (no active tracked debuffs)") + end + else + CleveRoids.Print(" (no tracking data for this target)") + end + + -- Show actual debuff slots (1-16 via UnitDebuff, 17-48 via overflow) + CleveRoids.Print(" ") + CleveRoids.Print("|cff00ff00=== Debuff Slots (UnitDebuff 1-16) ===|r") + local debuffCount = 0 + for i = 1, 16 do + local texture, stacks, debuffType, spellID = UnitDebuff("target", i) + if texture then + local spellName = SpellInfo and SpellInfo(spellID) or "slot" .. i + CleveRoids.Print(string.format(" Slot %d: [%d] %s (stacks: %d)", + i, spellID or 0, spellName, stacks or 0)) + debuffCount = debuffCount + 1 + end + end + if debuffCount == 0 then + CleveRoids.Print(" (no debuffs in slots 1-16)") + end + + -- Show overflow debuffs in buff slots (17-48) + CleveRoids.Print(" ") + CleveRoids.Print("|cff00ff00=== Overflow Debuffs (UnitBuff 1-32 as debuffs 17-48) ===|r") + local overflowCount = 0 + for i = 1, 32 do + local texture, stacks, spellID = UnitBuff("target", i) + if texture and spellID then + -- Check if this might be an overflow debuff by checking libdebuff durations + local isDebuff = lib and lib.durations and lib.durations[spellID] + if isDebuff then + local spellName = SpellInfo and SpellInfo(spellID) or "slot" .. i + CleveRoids.Print(string.format(" Buff Slot %d (=Debuff %d): [%d] %s (stacks: %d) |cffff8800OVERFLOW|r", + i, i + 16, spellID, spellName, stacks or 0)) + overflowCount = overflowCount + 1 + end + end + end + if overflowCount == 0 then + CleveRoids.Print(" (no overflow debuffs detected)") + end + + -- If a specific debuff name was provided, test the conditional + if searchName and searchName ~= "" then + CleveRoids.Print(" ") + CleveRoids.Print("|cff00ff00=== Testing [debuff:\"" .. searchName .. "\"] ===|r") + + -- Test ValidateUnitDebuff + local result = CleveRoids.ValidateUnitDebuff("target", { name = searchName }) + CleveRoids.Print("ValidateUnitDebuff(target, {name='" .. searchName .. "'}): " .. + (result and "|cff00ff00true|r" or "|cffff0000false|r")) + + -- Test with time conditional + local resultTime = CleveRoids.ValidateUnitDebuff("target", { name = searchName, operator = "<", amount = 99999 }) + CleveRoids.Print("ValidateUnitDebuff(target, {name='" .. searchName .. "', operator='<', amount=99999}): " .. + (resultTime and "|cff00ff00true|r" or "|cffff0000false|r")) + + -- Look for spell IDs matching this name + CleveRoids.Print(" ") + CleveRoids.Print("|cff00ff00=== Spell ID Lookup for \"" .. searchName .. "\" ===|r") + local foundIDs = {} + -- Check Spells table + if CleveRoids.Spells then + for id, name in pairs(CleveRoids.Spells) do + if type(name) == "string" and string.lower(name) == string.lower(searchName) then + table.insert(foundIDs, id) + end + end + end + -- Also check SpellInfo + if SpellInfo then + for id = 1, 30000 do + local name = SpellInfo(id) + if name and string.lower(name) == string.lower(searchName) then + local found = false + for _, existingID in ipairs(foundIDs) do + if existingID == id then found = true break end + end + if not found then + table.insert(foundIDs, id) + end + end + -- Stop early if we found some + if table.getn(foundIDs) > 10 then break end + end + end + + if table.getn(foundIDs) > 0 then + for _, id in ipairs(foundIDs) do + local tracked = lib and lib.objects and lib.objects[guid] and lib.objects[guid][id] + local trackedStr = tracked and "|cff00ff00TRACKED|r" or "|cff888888not tracked|r" + CleveRoids.Print(" SpellID " .. id .. ": " .. trackedStr) + if tracked then + local remaining = tracked.duration + tracked.start - GetTime() + CleveRoids.Print(" -> " .. string.format("%.1fs remaining (caster: %s)", remaining, tracked.caster or "?")) + end + end + else + CleveRoids.Print(" (no spell IDs found for this name)") + end + end + + return + end + + -- tankdebug (debug pfUI tank integration) + if cmd == "tankdebug" then + CleveRoids.Print("=== Tank Debug ===") + + -- Check pfUI availability + if not CleveRoids.HasPfUITanks() then + CleveRoids.Print("|cffff0000pfUI tank system not available!|r") + CleveRoids.Print(" pfUI loaded: " .. tostring(type(pfUI) == "table")) + CleveRoids.Print(" pfUI_config: " .. tostring(type(pfUI_config) == "table")) + return + end + + CleveRoids.Print("|cff00ff00pfUI tank system available|r") + + -- List raid frame tanks + CleveRoids.Print(" ") + CleveRoids.Print("|cffffaa00Raid Frame Tanks (right-click toggle):|r") + local raidTankCount = 0 + if type(pfUI) == "table" and type(pfUI.uf) == "table" and + type(pfUI.uf.raid) == "table" and type(pfUI.uf.raid.tankrole) == "table" then + for name, isTank in pairs(pfUI.uf.raid.tankrole) do + if isTank then + CleveRoids.Print(" - " .. name) + raidTankCount = raidTankCount + 1 + end + end + end + if raidTankCount == 0 then + CleveRoids.Print(" (none)") + end + + -- List nameplate off-tank names + CleveRoids.Print(" ") + CleveRoids.Print("|cffffaa00Nameplate Off-Tank Names (config setting):|r") + local npTankCount = 0 + if type(pfUI_config) == "table" and type(pfUI_config.nameplates) == "table" then + local list = pfUI_config.nameplates.combatofftanks or "" + if list ~= "" then + CleveRoids.Print(" Raw setting: \"" .. list .. "\"") + -- pfUI uses # as separator + for name in string.gfind(list, "[^#]+") do + name = string.gsub(name, "^%s*(.-)%s*$", "%1") -- trim + if name ~= "" then + CleveRoids.Print(" - " .. name .. " (lowercase: " .. string.lower(name) .. ")") + npTankCount = npTankCount + 1 + end + end + end + end + if npTankCount == 0 then + CleveRoids.Print(" (none)") + end + + -- Check current target + CleveRoids.Print(" ") + CleveRoids.Print("|cffffaa00Current Target Check:|r") + if not UnitExists("target") then + CleveRoids.Print(" No target selected") + else + local targetName = UnitName("target") or "Unknown" + CleveRoids.Print(" Your target: " .. targetName) + + if UnitExists("targettarget") then + local ttName = UnitName("targettarget") or "Unknown" + CleveRoids.Print(" Target's target: " .. ttName) + local isTank = CleveRoids.IsPlayerTank(ttName) + if isTank then + CleveRoids.Print(" |cff00ff00" .. ttName .. " IS marked as tank|r") + else + CleveRoids.Print(" |cffff8800" .. ttName .. " is NOT marked as tank|r") + CleveRoids.Print(" (lowercase check: " .. string.lower(ttName) .. ")") + end + else + CleveRoids.Print(" Target's target: (none)") + end + + -- Test the conditional functions + CleveRoids.Print(" ") + CleveRoids.Print("|cffffaa00Conditional Results:|r") + local isTargetingTank = CleveRoids.IsTargetingAnyTank("target") + CleveRoids.Print(" IsTargetingAnyTank('target'): " .. tostring(isTargetingTank)) + CleveRoids.Print(" [targeting:tank] would be: " .. tostring(isTargetingTank)) + CleveRoids.Print(" [notargeting:tank] would be: " .. tostring(not isTargetingTank)) + end + + return + end + + -- auradebug (debug all-caster aura tracking) + if cmd == "auradebug" or cmd == "bufftracking" then + CleveRoids.Print("=== All-Caster Aura Tracking Debug ===") + + -- Check CVar status + local auraCastEnabled = GetCVar("NP_EnableAuraCastEvents") + if auraCastEnabled == "1" then + CleveRoids.Print("|cff00ff00NP_EnableAuraCastEvents = 1 (ENABLED)|r") + else + CleveRoids.Print("|cffff0000NP_EnableAuraCastEvents = " .. tostring(auraCastEnabled) .. " (DISABLED)|r") + CleveRoids.Print(" Enable with: |cff00ffff/run SetCVar(\"NP_EnableAuraCastEvents\", \"1\")|r") + CleveRoids.Print(" Then /reload") + end + + -- Check Nampower version + local API = CleveRoids.NampowerAPI + if API and API.features then + CleveRoids.Print("Nampower hasAuraCastEvents: " .. tostring(API.features.hasAuraCastEvents)) + else + CleveRoids.Print("|cffff0000Nampower API not available|r") + end + + -- Show tracking data for current target/mouseover + CleveRoids.Print(" ") + CleveRoids.Print("|cffffaa00Tracked Auras:|r") + local trackingCount = 0 + local now = GetTime() + for targetGuid, spells in pairs(CleveRoids.AllCasterAuraTracking or {}) do + local unitName = nil + -- Try to find unit name for this GUID (use pcall to handle invalid units like "focus") + for _, testUnit in ipairs({"target", "mouseover", "party1", "party2", "party3", "party4"}) do + local ok, exists, testGuid = pcall(function() return UnitExists(testUnit) end) + if ok and exists then + _, testGuid = UnitExists(testUnit) + if testGuid == targetGuid then + unitName = UnitName(testUnit) + break + end + end + end + + for spellId, auraData in pairs(spells) do + if auraData.start and auraData.duration then + local remaining = auraData.duration + auraData.start - now + if remaining > 0 then + local spellName = SpellInfo(spellId) or ("ID:" .. spellId) + local display = unitName or (string.sub(targetGuid, 1, 16) .. "...") + CleveRoids.Print(string.format(" %s on %s: %.1fs left", spellName, display, remaining)) + trackingCount = trackingCount + 1 + end + end + end + end + if trackingCount == 0 then + CleveRoids.Print(" (no auras currently tracked)") + end + + -- Check specific target + if UnitExists("target") then + CleveRoids.Print(" ") + CleveRoids.Print("|cffffaa00Target Buff Check:|r") + local _, targetGuid = UnitExists("target") + CleveRoids.Print(" Target GUID: " .. tostring(targetGuid)) + local targetData = CleveRoids.AllCasterAuraTracking[targetGuid] + if targetData then + local count = 0 + for _ in pairs(targetData) do count = count + 1 end + CleveRoids.Print(" Tracked spells on target: " .. count) + else + CleveRoids.Print(" |cffff9900No tracking data for this target|r") + end + end + + return + end + -- Unknown command fallback CleveRoids.Print("Usage:") DEFAULT_CHAT_FRAME:AddMessage("/cleveroid - Show current settings") @@ -3168,23 +6451,141 @@ SlashCmdList["CLEVEROID"] = function(msg) DEFAULT_CHAT_FRAME:AddMessage("/cleveroid forget - Forget learned duration(s)") DEFAULT_CHAT_FRAME:AddMessage("/cleveroid debug [0|1] - Toggle learning debug messages") end + DEFAULT_CHAT_FRAME:AddMessage("|cffffaa00Immunity Tracking:|r") + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid listimmune [school] - List immunity data') + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid addimmune "" [buff] - Add immunity') + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid removeimmune "" - Remove immunity') + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid clearimmune [school] - Clear immunity data') + DEFAULT_CHAT_FRAME:AddMessage("|cffffaa00CC Immunity Tracking:|r") + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid listccimmune [type] - List CC immunity data') + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid addccimmune "" [buff] - Add CC immunity') + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid removeccimmune "" - Remove CC immunity') + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid clearccimmune [type] - Clear CC immunity data') + DEFAULT_CHAT_FRAME:AddMessage("|cffffaa00Combo Point Tracking:|r") + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid combotrack - Show combo point tracking info') + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid comboclear - Clear combo tracking data') + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid combolearn - Show learned combo durations (per CP)') end SLASH_CLEAREQUIPQUEUE1 = "/clearequipqueue" SlashCmdList.CLEAREQUIPQUEUE = function() - CleveRoids.equipmentQueue = {} + -- Release all entries back to pool + for i = 1, CleveRoids.equipmentQueueLen do + local entry = CleveRoids.equipmentQueue[i] + if entry then + CleveRoids.equipmentQueue[i] = nil + -- Return to pool if space available + if table.getn(CleveRoids.queueEntryPool) < 10 then + entry.item = nil + entry.slotName = nil + entry.inventoryId = nil + table.insert(CleveRoids.queueEntryPool, entry) + end + end + end + CleveRoids.equipmentQueueLen = 0 + if CleveRoids.equipQueueFrame then + CleveRoids.equipQueueFrame:Hide() + end CleveRoids.Print("Equipment queue cleared") end SLASH_EQUIPQUEUESTATUS1 = "/equipqueuestatus" SlashCmdList.EQUIPQUEUESTATUS = function() - local count = table.getn(CleveRoids.equipmentQueue) + local count = CleveRoids.equipmentQueueLen CleveRoids.Print("Equipment queue has " .. count .. " pending items") - for i, entry in ipairs(CleveRoids.equipmentQueue) do - local itemName = entry.item.name or "Unknown" - local slotName = entry.slotName or "Unknown" - local retries = entry.retries or 0 - CleveRoids.Print(i .. ". " .. itemName .. " -> " .. slotName .. " (retries: " .. retries .. ")") + for i = 1, count do + local entry = CleveRoids.equipmentQueue[i] + if entry then + local itemName = (entry.item and entry.item.name) or "Unknown" + local slotName = entry.slotName or "Unknown" + local retries = entry.retries or 0 + CleveRoids.Print(i .. ". " .. itemName .. " -> " .. slotName .. " (retries: " .. retries .. ")") + end end end + +-- Apply temporary weapon enchants (poisons, oils, sharpening stones, etc.) +-- Usage: /applymain [conditionals] ItemName +-- Usage: /applyoff [conditionals] ItemName +-- Example: /applymain [nomhimbue] Instant Poison +-- Example: /applyoff [combat] Crippling Poison + +local function FindItemInBags(itemName) + -- Require a non-empty item name to prevent matching everything + if not itemName or itemName == "" then + return nil + end + + local searchName = string.lower(itemName) + for bag = 0, 4 do + for slot = 1, GetContainerNumSlots(bag) do + local link = GetContainerItemLink(bag, slot) + if link then + local _, _, foundName = string.find(link, "%[(.+)%]") + if foundName and string.find(string.lower(foundName), searchName, 1, true) then + return bag, slot, foundName + end + end + end + end + return nil +end + +function CleveRoids.DoApply(hand, msg) + local weaponSlots = { + ["main"] = 16, + ["off"] = 17, + } + + local slot = weaponSlots[hand] + if not slot then + CleveRoids.Print("Invalid hand: " .. tostring(hand)) + return false + end + + local action = function(itemName) + local bag, bagSlot, foundName = FindItemInBags(itemName) + if not bag then + CleveRoids.Print("Item not found: " .. itemName) + return false + end + + -- Apply the item to the weapon + UseContainerItem(bag, bagSlot) + PickupInventoryItem(slot) + ReplaceEnchant() + ClearCursor() + return true + end + + local handled = false + for _, v in pairs(CleveRoids.splitStringIgnoringQuotes(msg)) do + if CleveRoids.DoWithConditionals(v, action, CleveRoids.FixEmptyTarget, false, action) then + handled = true + break + end + end + return handled +end + +SLASH_APPLYMAIN1 = "/applymain" +SlashCmdList.APPLYMAIN = function(msg) + -- Require an item name argument + if not msg or msg == "" or string.gsub(msg, "%s+", "") == "" then + CleveRoids.Print("Usage: /applymain [conditionals] ItemName") + return + end + CleveRoids.DoApply("main", msg) +end + +SLASH_APPLYOFF1 = "/applyoff" +SlashCmdList.APPLYOFF = function(msg) + -- Require an item name argument + if not msg or msg == "" or string.gsub(msg, "%s+", "") == "" then + CleveRoids.Print("Usage: /applyoff [conditionals] ItemName") + return + end + CleveRoids.DoApply("off", msg) +end diff --git a/Extensions/CursiveCustomSpells.lua b/Extensions/CursiveCustomSpells.lua new file mode 100644 index 0000000..9b8fdc8 --- /dev/null +++ b/Extensions/CursiveCustomSpells.lua @@ -0,0 +1,607 @@ +--[[ + CursiveCustomSpells Extension + + Automatically injects custom spell definitions into Cursive's tracking system. + Any spell in this list will be tracked by Cursive when you cast it normally. + + Format: + [spellID] = { name = "lowercase spell name", rank = X, duration = Y }, + + Optional fields: + variableDuration = true -- Duration can be modified by talents/haste + calculateDuration = func -- Function returning dynamic duration + numTicks = N -- Number of DoT ticks (for tick tracking) + darkHarvest = true -- Affected by Dark Harvest talent +]] + +local _G = _G or getfenv(0) +local CleveRoids = _G.CleveRoids + +--============================================================================ +-- CUSTOM SPELL DEFINITIONS +-- Add your custom debuffs here. They will be auto-tracked when cast. +--============================================================================ + +CleveRoids.CustomCursiveSpells = CleveRoids.CustomCursiveSpells or { + --======================================================================== + -- WARRIOR DEBUFFS (from database.turtlecraft.gg) + --======================================================================== + + -- Deep Wounds (talent proc, bleed over 12 sec - ticks every 3s) + [12721] = { name = "deep wounds", rank = 1, duration = 6 }, + [12162] = { name = "deep wounds", rank = 1, duration = 6 }, + [12850] = { name = "deep wounds", rank = 2, duration = 6 }, + [12868] = { name = "deep wounds", rank = 3, duration = 6 }, + + -- Hamstring (15 sec snare, 50% movement speed) + [1715] = { name = "hamstring", rank = 1, duration = 15 }, + [7372] = { name = "hamstring", rank = 2, duration = 15 }, + [7373] = { name = "hamstring", rank = 3, duration = 15 }, + + --======================================================================== + -- TAUNTS (personal debuffs) + --======================================================================== + + -- Taunt (Warrior, 3 sec) + [355] = { name = "taunt", rank = 1, duration = 3 }, + [29060] = { name = "taunt", rank = 1, duration = 3 }, -- variant + + -- Growl (Druid Bear, 3 sec) + [6795] = { name = "growl", rank = 1, duration = 3 }, + + -- Mocking Blow (Warrior, 6 sec) + [694] = { name = "mocking blow", rank = 1, duration = 6 }, + [7400] = { name = "mocking blow", rank = 2, duration = 6 }, + [7402] = { name = "mocking blow", rank = 3, duration = 6 }, + [20559] = { name = "mocking blow", rank = 4, duration = 6 }, + [20560] = { name = "mocking blow", rank = 5, duration = 6 }, + + -- Hand of Reckoning (Paladin, 3 sec) + [62124] = { name = "hand of reckoning", rank = 1, duration = 3 }, + + --======================================================================== + -- PALADIN JUDGEMENT DEBUFFS + -- Generic 20271 is handled specially below to detect seal type + --======================================================================== + + -- Judgement of the Crusader (10 sec, +holy damage taken) + [21183] = { name = "judgement of the crusader", rank = 1, duration = 10 }, + [20188] = { name = "judgement of the crusader", rank = 2, duration = 10 }, + [20300] = { name = "judgement of the crusader", rank = 3, duration = 10 }, + [20301] = { name = "judgement of the crusader", rank = 4, duration = 10 }, + [20302] = { name = "judgement of the crusader", rank = 5, duration = 10 }, + [20303] = { name = "judgement of the crusader", rank = 6, duration = 10 }, + [20304] = { name = "judgement of the crusader", rank = 6, duration = 10 }, -- variant + [25942] = { name = "judgement of the crusader", rank = 1, duration = 10 }, -- variant + + -- Judgement of Light (10 sec, heals attackers) + [20185] = { name = "judgement of light", rank = 1, duration = 10 }, + [20344] = { name = "judgement of light", rank = 2, duration = 10 }, + [20345] = { name = "judgement of light", rank = 3, duration = 10 }, + [20346] = { name = "judgement of light", rank = 4, duration = 10 }, + + -- Judgement of Wisdom (10 sec, restores mana to attackers) + [20186] = { name = "judgement of wisdom", rank = 1, duration = 10 }, + [20354] = { name = "judgement of wisdom", rank = 2, duration = 10 }, + [20355] = { name = "judgement of wisdom", rank = 3, duration = 10 }, + [51750] = { name = "judgement of wisdom", rank = 5, duration = 10 }, -- Rank 5 cast (confirmed) + [51751] = { name = "judgement of wisdom", rank = 4, duration = 10 }, -- Rank 4 cast + [51752] = { name = "judgement of wisdom", rank = 5, duration = 10 }, -- debuff effect ID + [25757] = { name = "judgement of wisdom", rank = 1, duration = 10 }, -- variant + + -- Judgement of Justice (10 sec, prevents fleeing) + [20184] = { name = "judgement of justice", rank = 1, duration = 10 }, + [25945] = { name = "judgement of justice", rank = 2, duration = 10 }, + + --======================================================================== + -- ROGUE POISONS + --======================================================================== + + -- Deadly Poison (12 sec, stacking nature DoT) + [2818] = { name = "deadly poison", rank = 1, duration = 12 }, + [2819] = { name = "deadly poison", rank = 2, duration = 12 }, + [11353] = { name = "deadly poison", rank = 3, duration = 12 }, + [11354] = { name = "deadly poison", rank = 4, duration = 12 }, + [25349] = { name = "deadly poison", rank = 5, duration = 12 }, + [3583] = { name = "deadly poison", rank = 1, duration = 12 }, -- proc effect + [10022] = { name = "deadly poison", rank = 2, duration = 12 }, -- proc effect + [13582] = { name = "deadly poison", rank = 3, duration = 12 }, -- proc effect + [21787] = { name = "deadly poison", rank = 4, duration = 12 }, -- proc effect + [21788] = { name = "deadly poison", rank = 5, duration = 12 }, -- proc effect + + -- Crippling Poison (12 sec, -50% movement speed) + [3409] = { name = "crippling poison", rank = 1, duration = 12 }, + [11201] = { name = "crippling poison", rank = 2, duration = 12 }, + [25809] = { name = "crippling poison", rank = 2, duration = 12 }, -- variant + + -- Wound Poison (15 sec, -healing received) + [13218] = { name = "wound poison", rank = 1, duration = 15 }, + [13222] = { name = "wound poison", rank = 2, duration = 15 }, + [13223] = { name = "wound poison", rank = 3, duration = 15 }, + [13224] = { name = "wound poison", rank = 4, duration = 15 }, + [25648] = { name = "wound poison", rank = 5, duration = 15 }, + + -- Mind-numbing Poison (10 sec, -casting speed) + [5760] = { name = "mind-numbing poison", rank = 1, duration = 10 }, + [8692] = { name = "mind-numbing poison", rank = 2, duration = 10 }, + [11398] = { name = "mind-numbing poison", rank = 3, duration = 10 }, + [25810] = { name = "mind-numbing poison", rank = 2, duration = 10 }, -- variant + + --======================================================================== + -- THUNDERFURY PROC DEBUFFS + --======================================================================== + + -- Thunderfury - Nature Resist Reduction (12 sec, chains to 5 targets) + [21992] = { name = "thunderfury", rank = 1, duration = 12 }, + -- Thunderfury - Attack Speed Slow (12 sec, -20% attack speed on primary target) + [27648] = { name = "thunderfury slow", rank = 1, duration = 12 }, + + --======================================================================== + -- CUSTOM/SERVER-SPECIFIC (add your own below) + --======================================================================== + -- [12345] = { name = "custom debuff", rank = 1, duration = 18 }, +} + +--============================================================================ +-- PALADIN JUDGEMENT SEAL DETECTION +-- Maps seal buff textures to judgement debuff names and their reference spellIDs +--============================================================================ + +local sealToJudgement = { + -- Seal texture -> { name, referenceSpellID (must be in trackedCurseIds), judgementTexture } + ["Interface\\Icons\\Spell_Holy_RighteousnessAura"] = { + name = "judgement of wisdom", + spellID = 20186, -- JoW rank 1 (in our custom list) + texture = "Interface\\Icons\\Spell_Holy_RighteousnessAura", + }, + ["Interface\\Icons\\Spell_Holy_HealingAura"] = { + name = "judgement of light", + spellID = 20185, -- JoL rank 1 (in our custom list) + texture = "Interface\\Icons\\Spell_Holy_HealingAura", + }, + ["Interface\\Icons\\Spell_Holy_HolySmite"] = { + name = "judgement of the crusader", + spellID = 21183, -- JotC rank 1 (in our custom list) + texture = "Interface\\Icons\\Spell_Holy_HolySmite", + }, + ["Interface\\Icons\\Spell_Holy_SealOfWrath"] = { + name = "judgement of justice", + spellID = 20184, -- JoJ rank 1 (in our custom list) + texture = "Interface\\Icons\\Spell_Holy_SealOfWrath", + }, + ["Interface\\Icons\\Ability_ThunderClap"] = { + name = "judgement of righteousness", + spellID = 20187, -- fallback, may need correct ID + texture = "Interface\\Icons\\Ability_ThunderClap", + }, + ["Interface\\Icons\\Spell_Holy_SealOfMight"] = { + name = "judgement of command", + spellID = 20467, -- fallback, may need correct ID + texture = "Interface\\Icons\\Spell_Holy_SealOfMight", + }, +} + +-- Detect active seal from player buffs and return judgement info +local function GetActiveSealJudgementInfo() + for i = 1, 32 do + local texture = GetPlayerBuffTexture(i) + if not texture then break end + if sealToJudgement[texture] then + return sealToJudgement[texture] + end + end + -- Fallback to generic judgement + return { name = "judgement", spellID = 20271, texture = "Interface\\Icons\\Spell_Holy_RighteousFury" } +end + +--============================================================================ +-- AUTOMATIC INJECTION INTO CURSIVE +-- Hooks Cursive's LoadCurses to inject our spells after every reload +--============================================================================ + +local injectedCount = 0 + +local function InjectCustomSpells() + if not Cursive or not Cursive.curses or not Cursive.curses.trackedCurseIds then + return 0 + end + + local count = 0 + for spellID, data in pairs(CleveRoids.CustomCursiveSpells) do + -- Get texture from SpellInfo (SuperWoW API) + local name, rank, texture = SpellInfo(spellID) + if texture then + -- Always update/add (in case Cursive reloaded and cleared them) + Cursive.curses.trackedCurseIds[spellID] = { + name = data.name, + rank = data.rank or 1, + duration = data.duration, + texture = texture, + variableDuration = data.variableDuration, + calculateDuration = data.calculateDuration, + numTicks = data.numTicks, + darkHarvest = data.darkHarvest, + } + Cursive.curses.trackedCurseNamesToTextures[data.name] = texture + count = count + 1 + end + end + + injectedCount = count + return count +end + +-- Hook Cursive's LoadCurses function to inject after every reload +local function HookCursiveLoadCurses() + if not Cursive or not Cursive.curses or not Cursive.curses.LoadCurses then + return false + end + + -- Check if already hooked + if Cursive.curses._customSpellsHooked then + return true + end + + -- Store original function + local originalLoadCurses = Cursive.curses.LoadCurses + + -- Replace with hooked version + Cursive.curses.LoadCurses = function(self) + -- Call original first + originalLoadCurses(self) + + -- Then inject our custom spells (silent) + InjectCustomSpells() + end + + Cursive.curses._customSpellsHooked = true + return true +end + +--============================================================================ +-- EXTENSION REGISTRATION +--============================================================================ + +local ext = CleveRoids.RegisterExtension("CursiveCustomSpells") +local hooked = false +local retryFrame = nil + +-- Retry injection periodically until successful +local function StartRetryTimer() + if retryFrame then return end + + retryFrame = CreateFrame("Frame") + local elapsed = 0 + local attempts = 0 + + retryFrame:SetScript("OnUpdate", function() + elapsed = elapsed + arg1 + if elapsed >= 0.5 then -- Check every 0.5 seconds + elapsed = 0 + attempts = attempts + 1 + + if HookCursiveLoadCurses() then + hooked = true + InjectCustomSpells() + retryFrame:SetScript("OnUpdate", nil) + retryFrame = nil + elseif attempts >= 20 then -- Give up after 10 seconds + retryFrame:SetScript("OnUpdate", nil) + retryFrame = nil + end + end + end) +end + +ext.OnLoad = function() + -- Try to hook immediately (Cursive might already be loaded) + if HookCursiveLoadCurses() then + hooked = true + InjectCustomSpells() + else + -- Start retry timer and also register events as backup + StartRetryTimer() + ext.RegisterEvent("PLAYER_ENTERING_WORLD", "OnPlayerEnteringWorld") + end +end + +ext.OnPlayerEnteringWorld = function() + if not hooked then + if HookCursiveLoadCurses() then + hooked = true + InjectCustomSpells() + end + end + -- Don't unregister - keep checking on zone changes +end + +--============================================================================ +-- PALADIN JUDGEMENT HOOK +-- Intercepts generic Judgement (20271) and applies correct debuff name +--============================================================================ + +local JUDGEMENT_SPELL_ID = 20271 +local judgementHooked = false + +local function HookJudgementDetection() + if judgementHooked then return end + if not Cursive or not Cursive.curses then return end + + -- DO NOT register 20271 in trackedCurseIds - we handle it ourselves + -- This prevents Cursive's native handler from creating a duplicate + + -- Register our own UNIT_CASTEVENT handler + local eventFrame = CreateFrame("Frame") + eventFrame:RegisterEvent("UNIT_CASTEVENT") + eventFrame:SetScript("OnEvent", function() + local casterGuid, targetGuid, event, spellID = arg1, arg2, arg3, arg4 + + -- Only handle our Judgement casts + if event ~= "CAST" or spellID ~= JUDGEMENT_SPELL_ID then return end + + -- Check if it's the player casting + local _, playerGuid = UnitExists("player") + if casterGuid ~= playerGuid then return end + + -- Detect which seal is active and get full judgement info + local info = GetActiveSealJudgementInfo() + + -- Make sure the reference spellID is in trackedCurseIds + if not Cursive.curses.trackedCurseIds[info.spellID] then + Cursive.curses.trackedCurseIds[info.spellID] = { + name = info.name, + rank = 1, + duration = 10, + texture = info.texture, + } + end + + -- Apply the curse manually with the correct name + if not Cursive.curses.guids[targetGuid] then + Cursive.curses.guids[targetGuid] = {} + end + + Cursive.curses.guids[targetGuid][info.name] = { + rank = 1, + duration = 10, + start = GetTime(), + spellID = info.spellID, -- Use the seal-specific spellID + targetGuid = targetGuid, + currentPlayer = true, + } + + -- Add texture mapping for display + Cursive.curses.trackedCurseNamesToTextures[info.name] = info.texture + end) + + judgementHooked = true +end + +-- Hook judgement detection when Cursive is ready +local judgementRetryFrame = CreateFrame("Frame") +judgementRetryFrame:RegisterEvent("PLAYER_ENTERING_WORLD") +judgementRetryFrame:SetScript("OnEvent", function() + -- Delay slightly to ensure Cursive is fully loaded + local elapsed = 0 + judgementRetryFrame:SetScript("OnUpdate", function() + elapsed = elapsed + arg1 + if elapsed >= 1 then + HookJudgementDetection() + judgementRetryFrame:SetScript("OnUpdate", nil) + end + end) +end) + +--============================================================================ +-- JUDGEMENT REFRESH ON MELEE HIT +-- Judgement of Light/Wisdom refresh their duration when the Paladin hits +--============================================================================ + +local judgementNames = { + ["judgement of wisdom"] = true, + ["judgement of light"] = true, + ["judgement of the crusader"] = true, + ["judgement of justice"] = true, + ["judgement of righteousness"] = true, + ["judgement of command"] = true, +} + +-- Refresh all active judgements on a target +local function RefreshJudgementsOnTarget(targetGuid) + if not Cursive or not Cursive.curses or not Cursive.curses.guids then return end + if not targetGuid or not Cursive.curses.guids[targetGuid] then return end + + local now = GetTime() + for curseName, curseData in pairs(Cursive.curses.guids[targetGuid]) do + if judgementNames[curseName] and curseData.currentPlayer then + -- Refresh the timer + curseData.start = now + end + end +end + +-- Hook melee combat events for auto attacks +local meleeRefreshFrame = CreateFrame("Frame") +meleeRefreshFrame:RegisterEvent("CHAT_MSG_COMBAT_SELF_HITS") +meleeRefreshFrame:SetScript("OnEvent", function() + -- Auto attack hit patterns (vanilla WoW format) + -- "You hit TargetName for X damage." + -- "You crit TargetName for X damage." + local msg = arg1 + if not msg then return end + + -- Parse target name from combat message + local targetName = nil + + -- Pattern: "You hit TargetName for X" + local _, _, name = string.find(msg, "^You hit (.+) for") + if name then targetName = name end + + -- Pattern: "You crit TargetName for X" + if not targetName then + _, _, name = string.find(msg, "^You crit (.+) for") + if name then targetName = name end + end + + if not targetName then return end + + -- Check if current target matches the hit target + local currentTargetName = UnitName("target") + if currentTargetName and currentTargetName == targetName then + local _, targetGuid = UnitExists("target") + if targetGuid then + RefreshJudgementsOnTarget(targetGuid) + end + end +end) + +-- Hook UNIT_CASTEVENT for melee ability hits (Crusader Strike, etc.) +local meleeAbilityFrame = CreateFrame("Frame") +meleeAbilityFrame:RegisterEvent("UNIT_CASTEVENT") +meleeAbilityFrame:SetScript("OnEvent", function() + local casterGuid, targetGuid, event, spellID = arg1, arg2, arg3, arg4 + + -- Only handle CAST events (successful casts) + if event ~= "CAST" then return end + + -- Check if it's the player casting + local _, playerGuid = UnitExists("player") + if casterGuid ~= playerGuid then return end + + -- Skip Judgement itself (20271) - it applies, doesn't refresh + if spellID == JUDGEMENT_SPELL_ID then return end + + -- Only refresh for melee abilities (instant casts with target) + -- We can't easily detect "melee only" so refresh on any successful cast on target + if targetGuid and targetGuid ~= "" then + RefreshJudgementsOnTarget(targetGuid) + end +end) + +--============================================================================ +-- CONSOLE COMMANDS: /cleveroid cursive +--============================================================================ + +local originalConsoleHandler = CleveRoids.HandleConsoleCommand + +CleveRoids.HandleConsoleCommand = function(msg) + local cmd, args = string.match(msg or "", "^(%S+)%s*(.*)$") + cmd = cmd and string.lower(cmd) or "" + + if cmd == "cursive" then + local subcmd, subargs = string.match(args or "", "^(%S+)%s*(.*)$") + subcmd = subcmd and string.lower(subcmd) or "list" + + if subcmd == "list" then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[CursiveCustomSpells]|r Custom spell list:") + local count = 0 + for spellID, data in pairs(CleveRoids.CustomCursiveSpells) do + local status = "" + if Cursive and Cursive.curses and Cursive.curses.trackedCurseIds[spellID] then + status = " |cff00ff00[active]|r" + else + status = " |cffff0000[not loaded]|r" + end + DEFAULT_CHAT_FRAME:AddMessage(string.format(" [%d] %s (rank %d, %ds)%s", + spellID, data.name, data.rank or 1, data.duration, status)) + count = count + 1 + end + if count == 0 then + DEFAULT_CHAT_FRAME:AddMessage(" (none - edit CursiveCustomSpells.lua to add)") + end + DEFAULT_CHAT_FRAME:AddMessage(string.format(" Hook status: %s", + hooked and "|cff00ff00active|r" or "|cffff0000not hooked|r")) + + elseif subcmd == "add" then + local spellID, duration, customName = string.match(subargs, "^(%d+)%s+(%d+)%s*(.*)$") + spellID = tonumber(spellID) + duration = tonumber(duration) + + if not spellID or not duration then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000Usage:|r /cleveroid cursive add [name]") + return + end + + local name, rank, texture = SpellInfo(spellID) + if not name then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000Error:|r Spell ID " .. spellID .. " not found.") + return + end + + local spellName = customName ~= "" and string.lower(customName) or string.lower(name) + + CleveRoids.CustomCursiveSpells[spellID] = { + name = spellName, + rank = 1, + duration = duration, + } + + -- Inject immediately + if Cursive and Cursive.curses then + Cursive.curses.trackedCurseIds[spellID] = { + name = spellName, + rank = 1, + duration = duration, + texture = texture, + } + Cursive.curses.trackedCurseNamesToTextures[spellName] = texture + end + + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[CursiveCustomSpells]|r Added: [%d] %s (%ds) - will auto-track on cast", + spellID, spellName, duration)) + DEFAULT_CHAT_FRAME:AddMessage(" Note: Edit CursiveCustomSpells.lua to make permanent.") + + elseif subcmd == "remove" then + local spellID = tonumber(subargs) + if not spellID then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000Usage:|r /cleveroid cursive remove ") + return + end + + if CleveRoids.CustomCursiveSpells[spellID] then + local name = CleveRoids.CustomCursiveSpells[spellID].name + CleveRoids.CustomCursiveSpells[spellID] = nil + + if Cursive and Cursive.curses and Cursive.curses.trackedCurseIds[spellID] then + Cursive.curses.trackedCurseIds[spellID] = nil + end + + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[CursiveCustomSpells]|r Removed: [%d] %s", + spellID, name)) + else + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000Error:|r Spell ID " .. spellID .. " not in custom list.") + end + + elseif subcmd == "inject" then + local count = InjectCustomSpells() + DEFAULT_CHAT_FRAME:AddMessage(string.format("|cff00ff00[CursiveCustomSpells]|r Injected %d spell(s).", count)) + + elseif subcmd == "debugseal" then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[CursiveCustomSpells]|r Scanning player buffs for seals:") + for i = 1, 32 do + local texture = GetPlayerBuffTexture(i) + if not texture then break end + local match = sealToJudgement[texture] and "|cff00ff00 MATCH|r" or "" + DEFAULT_CHAT_FRAME:AddMessage(string.format(" [%d] %s%s", i, texture, match)) + end + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[CursiveCustomSpells]|r Known seal textures:") + for tex, data in pairs(sealToJudgement) do + DEFAULT_CHAT_FRAME:AddMessage(string.format(" %s -> %s", tex, data.name)) + end + + else + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[CursiveCustomSpells]|r Commands:") + DEFAULT_CHAT_FRAME:AddMessage(" /cleveroid cursive list - Show custom spells and status") + DEFAULT_CHAT_FRAME:AddMessage(" /cleveroid cursive add [name]") + DEFAULT_CHAT_FRAME:AddMessage(" /cleveroid cursive remove ") + DEFAULT_CHAT_FRAME:AddMessage(" /cleveroid cursive inject - Force re-injection") + end + return + end + + if originalConsoleHandler then + originalConsoleHandler(msg) + end +end + +return ext diff --git a/Extensions/MacroLengthWarn.lua b/Extensions/MacroLengthWarn.lua index cf17f1d..e55bfe8 100644 --- a/Extensions/MacroLengthWarn.lua +++ b/Extensions/MacroLengthWarn.lua @@ -4,38 +4,200 @@ ]] local _G = _G or getfenv(0) local CleveRoids = _G.CleveRoids or {} --- CleveRoids.mouseoverUnit = CleveRoids.mouseoverUnit or nil local Extension = CleveRoids.RegisterExtension("MacroLengthWarn") -local edit_orig = EditMacro +-- Set a global flag so we can check if this file loaded +CleveRoids.MacroLengthWarnLoaded = true -function Extension.SafeEditMacro(macro_id, x, y, body) - if SuperMacroFrame ~= nil and x and y and body == nil then - -- super will handle macro - edit_orig(macro_id, x, y, body) - return - end +-- Maximum line length for macros (including spaces) +local MAX_LINE_LENGTH = 261 - if not body then -- can't be too long without a body - edit_orig(macro_id, x, y, body) - return +-- Store original functions +local edit_orig = nil +local macroframe_save_orig = nil + +-- Validation function - returns true if valid, false if too long +local function ValidateMacroBody(body, macroName) + if not body then + return true -- No body means it's valid end for line in string.gfind(body, "([^\n]+)") do - if string.len(line) > 261 then - DEFAULT_CHAT_FRAME:AddMessage("ERROR: A line in the macro < |cffffffff"..GetMacroInfo(macro_id).."|r > is too long and will |cffff0000CRASH|r the client and may |cffff0000DELETE|r all macros on exit! Max line length: |cffffffff261|r",1,1,0) + local lineLen = string.len(line) + if lineLen > MAX_LINE_LENGTH then + local name = macroName or "Unknown" + DEFAULT_CHAT_FRAME:AddMessage(string.format( + "|cffff0000ERROR:|r A line in the macro < |cffffffff%s|r > is |cffff0000%d characters|r long (max: |cffffffff%d|r).", + name, lineLen, MAX_LINE_LENGTH + ), 1, 0.82, 0) + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000This will CRASH the client and may DELETE all macros!|r Macro NOT saved.", 1, 0.82, 0) + DEFAULT_CHAT_FRAME:AddMessage("Line: |cffffffff" .. string.sub(line, 1, 100) .. "...|r", 1, 1, 1) + return false + end + end + return true +end + +-- Hook for EditMacro API function +function Extension.SafeEditMacro(macro_id, x, y, body) + if CleveRoids.MacroLengthDebug then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[MacroLengthWarn]|r EditMacro called for ID: " .. tostring(macro_id)) + end + + -- SuperMacro compatibility - bypass validation if SuperMacro is handling it + if SuperMacroFrame ~= nil and x and y and body == nil then + if edit_orig then edit_orig(macro_id, x, y, body) end + return + end + + -- Get macro name for error messages + local macroName = GetMacroInfo(macro_id) + + -- Validate the macro body + if not ValidateMacroBody(body, macroName) then + -- Validation failed - do NOT save + if CleveRoids.MacroLengthDebug then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[MacroLengthWarn]|r BLOCKED save - line too long") + end + return + end + + -- Validation passed - call original + if edit_orig then + if CleveRoids.MacroLengthDebug then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[MacroLengthWarn]|r Validation passed, saving...") + end + edit_orig(macro_id, x, y, body) + end +end + +-- Hook for MacroFrame_SaveMacro (Blizzard UI function) +function Extension.SafeMacroFrameSave() + if CleveRoids.MacroLengthDebug then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[MacroLengthWarn]|r MacroFrame_SaveMacro called") + end + + -- This is called by the macro UI, so we need to get the text from the edit box + if MacroFrameText and MacroFrame.selectedMacro then + local body = MacroFrameText:GetText() + local macroName = GetMacroInfo(MacroFrame.selectedMacro) + + -- Validate before allowing the save + if not ValidateMacroBody(body, macroName) then + if CleveRoids.MacroLengthDebug then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[MacroLengthWarn]|r BLOCKED MacroFrame save") + end + -- Don't call original - block the save return end end - edit_orig(macro_id, x, y, body) + + -- Validation passed or no text to validate + if macroframe_save_orig then + if CleveRoids.MacroLengthDebug then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[MacroLengthWarn]|r MacroFrame validation passed") + end + macroframe_save_orig() + end end +-- Install hooks when macro frame loads +function Extension.OnMacroFrameLoad() + if Extension.macroFrameHooked then + return + end + + if CleveRoids.MacroLengthDebug then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[MacroLengthWarn]|r Hooking MacroFrame_SaveMacro") + end + + -- Hook MacroFrame_SaveMacro if it exists + if MacroFrame_SaveMacro then + macroframe_save_orig = MacroFrame_SaveMacro + MacroFrame_SaveMacro = function(...) + local success, err = pcall(Extension.SafeMacroFrameSave, unpack(arg)) + if not success then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[MacroLengthWarn]|r MacroFrame hook error: " .. tostring(err)) + -- Call original on error + if macroframe_save_orig then + return macroframe_save_orig(unpack(arg)) + end + end + end + Extension.macroFrameHooked = true + + if CleveRoids.MacroLengthDebug then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[MacroLengthWarn]|r MacroFrame_SaveMacro hooked successfully") + end + end +end + +function Extension.OnAddonLoaded() + if arg1 == "Blizzard_MacroUI" then + Extension.OnMacroFrameLoad() + end +end function Extension.OnLoad() - -- how do I use this with a global function? - -- CleveRoids.RegisterMethodHook("MacroLengthWarn", _G, "EditMacro", "SafeEditMacro", true) - EditMacro = Extension.SafeEditMacro + -- Schedule messages to show after UI is ready + local function ShowMessages() + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[MacroLengthWarn]|r Extension loaded (Max line length: " .. MAX_LINE_LENGTH .. ")") + + if not EditMacro then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[MacroLengthWarn]|r ERROR: EditMacro function not found!") + else + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[MacroLengthWarn]|r EditMacro hook: " .. (edit_orig and "SUCCESS" or "FAILED")) + end + + if MacroFrame_SaveMacro then + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[MacroLengthWarn]|r MacroFrame_SaveMacro hook: " .. (Extension.macroFrameHooked and "SUCCESS" or "PENDING")) + else + DEFAULT_CHAT_FRAME:AddMessage("|cffffff00[MacroLengthWarn]|r MacroFrame_SaveMacro: Will hook when Blizzard UI loads") + end + + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[MacroLengthWarn]|r Use /cleveroid macrodebug to toggle debug output") + end + + -- Check if EditMacro exists + if not EditMacro then + CleveRoids.Print("[MacroLengthWarn] ERROR: EditMacro not found!") + return + end + + -- Capture and hook EditMacro immediately with error protection + edit_orig = EditMacro + EditMacro = function(...) + local success, err = pcall(Extension.SafeEditMacro, unpack(arg)) + if not success then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[MacroLengthWarn]|r Hook error: " .. tostring(err)) + -- Call original on error to prevent breaking macros + if edit_orig then + return edit_orig(unpack(arg)) + end + end + end + + -- Listen for macro UI loading + Extension.RegisterEvent("ADDON_LOADED", "OnAddonLoaded") + + -- Also try to hook MacroFrame_SaveMacro if it already exists + if MacroFrame_SaveMacro then + Extension.OnMacroFrameLoad() + end + + -- Register PLAYER_LOGIN to show status messages + Extension.RegisterEvent("PLAYER_LOGIN", "OnPlayerLogin") + + -- Store the message function for later + Extension.ShowMessages = ShowMessages +end + +function Extension.OnPlayerLogin() + -- Status messages disabled by default - use /cleveroid macrostatus to check + -- if Extension.ShowMessages then + -- Extension.ShowMessages() + -- end end _G["CleveRoids"] = CleveRoids \ No newline at end of file diff --git a/Extensions/Mouseover/Blizzard.lua b/Extensions/Mouseover/Blizzard.lua index fef1ac9..59ab6aa 100644 --- a/Extensions/Mouseover/Blizzard.lua +++ b/Extensions/Mouseover/Blizzard.lua @@ -6,7 +6,6 @@ ]] local _G = _G or getfenv(0) local CleveRoids = _G.CleveRoids or {} -CleveRoids.mouseoverUnit = CleveRoids.mouseoverUnit or nil local Extension = CleveRoids.RegisterExtension("Blizzard") @@ -17,14 +16,18 @@ function Extension.RegisterMouseoverForFrame(frame, unit) local onleave = frame:GetScript("OnLeave") frame:SetScript("OnEnter", function() - CleveRoids.mouseoverUnit = unit + CleveRoids.SetMouseoverFrom("blizz", unit) if onenter then onenter() end end) frame:SetScript("OnLeave", function() - CleveRoids.mouseoverUnit = nil + CleveRoids.ClearMouseoverFrom("blizz") + -- Also clear "native" source to prevent sticky highlights. + -- When SetMouseoverUnit("") is called, UPDATE_MOUSEOVER_UNIT fires but + -- selfTriggered causes it to skip, leaving "native" stale. + CleveRoids.ClearMouseoverFrom("native") if onleave then onleave() end @@ -75,3 +78,5 @@ do end end end + +_G["CleveRoids"] = CleveRoids diff --git a/Extensions/Mouseover/CT_RaidAssist.lua b/Extensions/Mouseover/CT_RaidAssist.lua index 4e3337c..16b7276 100644 --- a/Extensions/Mouseover/CT_RaidAssist.lua +++ b/Extensions/Mouseover/CT_RaidAssist.lua @@ -4,7 +4,6 @@ ]] local _G = _G or getfenv(0) local CleveRoids = _G.CleveRoids or {} -CleveRoids.mouseoverUnit = CleveRoids.mouseoverUnit or nil local Extension = CleveRoids.RegisterExtension("CT_RaidAssist") Extension.RegisterEvent("ADDON_LOADED", "OnLoad") @@ -41,11 +40,12 @@ function Extension.OnEnter() end end end - CleveRoids.mouseoverUnit = "raid"..id + CleveRoids.SetMouseoverFrom("ctra", "raid"..id) end function Extension.OnLeave() - CleveRoids.mouseoverUnit = nil + CleveRoids.ClearMouseoverFrom("ctra") + CleveRoids.ClearMouseoverFrom("native") end function Extension.OnLoad() @@ -59,4 +59,4 @@ function Extension.OnLoad() end -_G["CleveRoids"] = CleveRoids \ No newline at end of file +_G["CleveRoids"] = CleveRoids diff --git a/Extensions/Mouseover/CT_UnitFrames.lua b/Extensions/Mouseover/CT_UnitFrames.lua index da7db4d..6b6b607 100644 --- a/Extensions/Mouseover/CT_UnitFrames.lua +++ b/Extensions/Mouseover/CT_UnitFrames.lua @@ -4,7 +4,6 @@ ]] local _G = _G or getfenv(0) local CleveRoids = _G.CleveRoids or {} -CleveRoids.mouseoverUnit = CleveRoids.mouseoverUnit or nil local Extension = CleveRoids.RegisterExtension("CT_UnitFrames") Extension.RegisterEvent("ADDON_LOADED", "OnLoad") @@ -15,12 +14,13 @@ function Extension.SetHook(widget) widget:SetScript("OnEnter", function() hookedOnEnter() - CleveRoids.mouseoverUnit = "targettarget" + CleveRoids.SetMouseoverFrom("ctuf", "targettarget") end) widget:SetScript("OnLeave", function() hookedOnLeave() - CleveRoids.mouseoverUnit = nil + CleveRoids.ClearMouseoverFrom("ctuf") + CleveRoids.ClearMouseoverFrom("native") end) end @@ -36,4 +36,4 @@ function Extension.OnLoad() Extension.SetHook(CT_AssistFrame_Drag) end -_G["CleveRoids"] = CleveRoids \ No newline at end of file +_G["CleveRoids"] = CleveRoids diff --git a/Extensions/Mouseover/Cursive.lua b/Extensions/Mouseover/Cursive.lua new file mode 100644 index 0000000..335015b --- /dev/null +++ b/Extensions/Mouseover/Cursive.lua @@ -0,0 +1,86 @@ +--[[ + Cursive mouseover support. + Hooks into Cursive's bar OnEnter/OnLeave to track mouseover unit by GUID. + Enables [@mouseover] macros when hovering over Cursive DoT timer bars. +]] +local _G = _G or getfenv(0) +local CleveRoids = _G.CleveRoids or {} + +CleveRoids.Hooks = CleveRoids.Hooks or {} + +local Extension = CleveRoids.RegisterExtension("CursiveMouseover") +Extension.RegisterEvent("ADDON_LOADED", "OnLoad") + +local hooked = false + +function Extension.OnEnter(guid) + if guid and guid ~= 0 then + -- SuperWoW's SetMouseoverUnit supports GUIDs directly + CleveRoids.SetMouseoverFrom("cursive", guid) + end +end + +function Extension.OnLeave() + CleveRoids.ClearMouseoverFrom("cursive") +end + +local function HookCursiveUI() + -- Check if Cursive and its UI module exist + if not Cursive or not Cursive.ui then + return false + end + + -- Already hooked + if hooked then + return true + end + + local ui = Cursive.ui + + -- Hook BarEnter if it exists + if ui.BarEnter then + CleveRoids.Hooks.CursiveBarEnter = ui.BarEnter + ui.BarEnter = function() + -- Call original + CleveRoids.Hooks.CursiveBarEnter() + -- Set mouseover from the bar's parent guid + if this and this.parent and this.parent.guid then + Extension.OnEnter(this.parent.guid) + end + end + end + + -- Hook BarLeave if it exists + if ui.BarLeave then + CleveRoids.Hooks.CursiveBarLeave = ui.BarLeave + ui.BarLeave = function() + -- Clear mouseover first + Extension.OnLeave() + -- Call original + CleveRoids.Hooks.CursiveBarLeave() + end + end + + hooked = true + return true +end + +function Extension.OnLoad() + -- Try to hook when Cursive loads + if arg1 == "Cursive" then + -- Delay slightly to ensure Cursive.ui is initialized + local frame = CreateFrame("Frame") + frame:SetScript("OnUpdate", function() + if HookCursiveUI() then + this:Hide() + end + end) + end +end + +-- Also try to hook immediately in case Cursive is already loaded +if Cursive and Cursive.ui then + HookCursiveUI() +end + +_G["CleveRoids"] = CleveRoids diff --git a/Extensions/Mouseover/DiscordUnitFrames.lua b/Extensions/Mouseover/DiscordUnitFrames.lua index fa43dee..5e3dbfb 100644 --- a/Extensions/Mouseover/DiscordUnitFrames.lua +++ b/Extensions/Mouseover/DiscordUnitFrames.lua @@ -4,25 +4,26 @@ ]] local _G = _G or getfenv(0) local CleveRoids = _G.CleveRoids or {} -CleveRoids.mouseoverUnit = CleveRoids.mouseoverUnit or nil local Extension = CleveRoids.RegisterExtension("DiscordUnitFrames") Extension.RegisterEvent("ADDON_LOADED", "OnLoad") function Extension.OnEnterFrame() - CleveRoids.mouseoverUnit = this.unit + CleveRoids.SetMouseoverFrom("duf", this.unit) end function Extension.OnLeaveFrame() - CleveRoids.mouseoverUnit = nil + CleveRoids.ClearMouseoverFrom("duf") + CleveRoids.ClearMouseoverFrom("native") end function Extension.OnEnterElement() - CleveRoids.mouseoverUnit = this:GetParent().unit + CleveRoids.SetMouseoverFrom("duf", this:GetParent().unit) end function Extension.OnLeaveElement() - CleveRoids.mouseoverUnit = nil + CleveRoids.ClearMouseoverFrom("duf") + CleveRoids.ClearMouseoverFrom("native") end function Extension.OnLoad() @@ -38,4 +39,4 @@ function Extension.OnLoad() Extension.Hook("DUF_Element_OnLeave", "OnLeaveElement") end -_G["CleveRoids"] = CleveRoids \ No newline at end of file +_G["CleveRoids"] = CleveRoids diff --git a/Extensions/Mouseover/Dragonflight3.lua b/Extensions/Mouseover/Dragonflight3.lua new file mode 100644 index 0000000..20d7c73 --- /dev/null +++ b/Extensions/Mouseover/Dragonflight3.lua @@ -0,0 +1,79 @@ +--[[ + Author: SuperCleveRoidMacros + License: MIT License + + Dragonflight3 mouseover support. + Hooks into Dragonflight3's custom unit frames. + + Dragonflight3 uses the DF global namespace. + Frame naming pattern: DF_Frame (e.g., DF_PlayerFrame, DF_TargetFrame) + Each frame has a .unit property containing the unit ID. +]] +local _G = _G or getfenv(0) +local CleveRoids = _G.CleveRoids or {} + +local Extension = CleveRoids.RegisterExtension("Dragonflight3") +Extension.RegisterEvent("PLAYER_ENTERING_WORLD", "DelayedInit") + +local hookedFrames = {} + +function Extension.HookFrame(frame) + if not frame or hookedFrames[frame] then return end + + local origOnEnter = frame:GetScript("OnEnter") + local origOnLeave = frame:GetScript("OnLeave") + + frame:SetScript("OnEnter", function() + local u = this.unit + if u then + CleveRoids.SetMouseoverFrom("df3", u) + end + if origOnEnter then origOnEnter() end + end) + + frame:SetScript("OnLeave", function() + CleveRoids.ClearMouseoverFrom("df3") + CleveRoids.ClearMouseoverFrom("native") + if origOnLeave then origOnLeave() end + end) + + hookedFrames[frame] = true +end + +function Extension.HookAllFrames() + -- Dragonflight3 frame naming: DF_Frame + -- Unit names are capitalized: player -> Player, party1 -> Party1 + local units = { + "Player", "Target", "Targettarget", "Pet", "Pettarget" + } + + for _, unit in ipairs(units) do + local frame = _G["DF_" .. unit .. "Frame"] + if frame then + Extension.HookFrame(frame) + end + end + + -- Party frames: DF_Party1Frame through DF_Party4Frame + for i = 1, 4 do + local frame = _G["DF_Party" .. i .. "Frame"] + if frame then + Extension.HookFrame(frame) + end + end +end + +function Extension.DelayedInit() + -- Check if Dragonflight3 is loaded + if not DF then + return + end + + Extension.HookAllFrames() +end + +function Extension.OnLoad() + -- Nothing needed here, we use DelayedInit +end + +_G["CleveRoids"] = CleveRoids diff --git a/Extensions/Mouseover/DragonflightReloaded.lua b/Extensions/Mouseover/DragonflightReloaded.lua new file mode 100644 index 0000000..e367fea --- /dev/null +++ b/Extensions/Mouseover/DragonflightReloaded.lua @@ -0,0 +1,26 @@ +--[[ + Author: SuperCleveRoidMacros + License: MIT License + + DragonflightReloaded mouseover support. + + NOTE: DragonflightReloaded (DFRL namespace) modifies the default Blizzard + unit frames (PlayerFrame, TargetFrame, etc.) rather than creating its own. + The Blizzard.lua extension already handles these frames with priority 3. + + This extension is intentionally minimal - we just register to ensure + the extension system doesn't complain, but we don't need to hook anything. +]] +local _G = _G or getfenv(0) +local CleveRoids = _G.CleveRoids or {} + +local Extension = CleveRoids.RegisterExtension("DragonflightReloaded") + +function Extension.OnLoad() + -- DragonflightReloaded modifies Blizzard's default unit frames + -- (PlayerFrame, TargetFrame, PetFrame, PartyMemberFrameX) + -- These are already handled by the Blizzard.lua extension + -- No additional hooks needed! +end + +_G["CleveRoids"] = CleveRoids diff --git a/Extensions/Mouseover/FocusFrame.lua b/Extensions/Mouseover/FocusFrame.lua index fce6358..67713c8 100644 --- a/Extensions/Mouseover/FocusFrame.lua +++ b/Extensions/Mouseover/FocusFrame.lua @@ -4,7 +4,6 @@ ]] local _G = _G or getfenv(0) local CleveRoids = _G.CleveRoids or {} -CleveRoids.mouseoverUnit = CleveRoids.mouseoverUnit or nil local Extension = CleveRoids.RegisterExtension("FocusFrame") @@ -13,14 +12,15 @@ function Extension.RegisterMouseoverForFrame(frame) local onleave = frame:GetScript("OnLeave") frame:SetScript("OnEnter", function() - CleveRoids.mouseoverUnit = "focus" + CleveRoids.SetMouseoverFrom("focus", "focus") if onenter then onenter() end end) frame:SetScript("OnLeave", function() - CleveRoids.mouseoverUnit = nil + CleveRoids.ClearMouseoverFrom("focus") + CleveRoids.ClearMouseoverFrom("native") if onleave then onleave() end @@ -39,4 +39,4 @@ function Extension.OnLoad() Extension.RegisterMouseoverForFrame(FocusFrameManaBar) Extension.RegisterMouseoverForFrame(FocusFrameTextureFrame) Extension.RegisterMouseoverForFrame(FocusFrame) -end \ No newline at end of file +end diff --git a/Extensions/Mouseover/GameTooltip.lua b/Extensions/Mouseover/GameTooltip.lua index b9acfa8..797460a 100644 --- a/Extensions/Mouseover/GameTooltip.lua +++ b/Extensions/Mouseover/GameTooltip.lua @@ -6,8 +6,7 @@ local _G = _G or getfenv(0) local CleveRoids = _G.CleveRoids or {} local Extension = CleveRoids.RegisterExtension("GameTooltipMouseover") -local lastUnit, setByTooltip = nil, false - +-- Tooltip-based mouseover (lowest priority fallback) function Extension.SetUnit(_, unit) if unit and unit ~= "" then CleveRoids.SetMouseoverFrom("tooltip", unit) @@ -16,13 +15,89 @@ end function Extension.OnClose() CleveRoids.ClearMouseoverFrom("tooltip") + -- Also clear native source when tooltip closes to prevent sticky highlights + -- The native source may have been set from hovering units in the 3D world + CleveRoids.ClearMouseoverFrom("native") end +-- Native UPDATE_MOUSEOVER_UNIT handler (priority 2) +-- This works even when tooltips are hidden by other addons +-- NOTE: We skip when selfTriggered is set to prevent TRP tooltip conflicts. +-- When we call SetMouseoverUnit ourselves, it triggers UPDATE_MOUSEOVER_UNIT, +-- which would add a redundant "native" source that persists after the real +-- source (pfUI, etc.) clears - causing TRP to show stale profile info. +function Extension.UPDATE_MOUSEOVER_UNIT() + -- Skip if we ourselves triggered this event by calling SetMouseoverUnit + if CleveRoids.__mo and CleveRoids.__mo.selfTriggered then + CleveRoids.__mo.selfTriggered = false + return + end + + if UnitExists("mouseover") then + CleveRoids.SetMouseoverFrom("native", "mouseover") + else + CleveRoids.ClearMouseoverFrom("native") + end +end + +-- Clear mouseover when tooltip is set to a non-unit (spell, item, action, etc.) +-- This prevents TurtleRP from showing stale RP profile info for spells/items. +-- TurtleRP hooks GameTooltip:OnShow and checks UnitIsPlayer("mouseover") - +-- if that returns true (from a stale unit frame hover), it overwrites the tooltip. +-- By clearing mouseover when non-unit tooltips are shown, we prevent this. +function Extension.OnNonUnitTooltip() + -- Clear the tooltip source (lowest priority fallback) + CleveRoids.ClearMouseoverFrom("tooltip") + + -- Also clear the "native" source since we're explicitly clearing mouseover. + -- Without this, a stale "native" source (from hovering units in 3D world) + -- would persist and cause sticky highlights/tooltips. + CleveRoids.ClearMouseoverFrom("native") + + -- Also explicitly clear the game's mouseover to ensure UnitIsPlayer returns false. + -- This is defensive - the main fix is in Utility.lua:apply() using "" instead of nil. + if CleveRoids.hasSuperwow and _G.SetMouseoverUnit then + CleveRoids.__mo.selfTriggered = true + _G.SetMouseoverUnit("") + end +end function Extension.OnLoad() Extension.HookMethod(_G["GameTooltip"], "SetUnit", "SetUnit") Extension.HookMethod(_G["GameTooltip"], "Hide", "OnClose") Extension.HookMethod(_G["GameTooltip"], "FadeOut", "OnClose") + Extension.RegisterEvent("UPDATE_MOUSEOVER_UNIT", "UPDATE_MOUSEOVER_UNIT") + + -- Hook non-unit tooltip methods to clear stale mouseover state. + -- This prevents TurtleRP from showing RP profiles for spells/items. + Extension.HookMethod(_G["GameTooltip"], "SetSpell", "OnNonUnitTooltip") + Extension.HookMethod(_G["GameTooltip"], "SetBagItem", "OnNonUnitTooltip") + Extension.HookMethod(_G["GameTooltip"], "SetInventoryItem", "OnNonUnitTooltip") + Extension.HookMethod(_G["GameTooltip"], "SetAction", "OnNonUnitTooltip") + Extension.HookMethod(_G["GameTooltip"], "SetQuestLogItem", "OnNonUnitTooltip") + Extension.HookMethod(_G["GameTooltip"], "SetLootItem", "OnNonUnitTooltip") + Extension.HookMethod(_G["GameTooltip"], "SetMerchantItem", "OnNonUnitTooltip") + Extension.HookMethod(_G["GameTooltip"], "SetCraftItem", "OnNonUnitTooltip") + Extension.HookMethod(_G["GameTooltip"], "SetTradeSkillItem", "OnNonUnitTooltip") + Extension.HookMethod(_G["GameTooltip"], "SetTrainerService", "OnNonUnitTooltip") + Extension.HookMethod(_G["GameTooltip"], "SetHyperlink", "OnNonUnitTooltip") + + -- Additional tooltip methods that can cause sticky mouseover state + -- Pet action bar, stance bar, buffs/debuffs, and other UI elements + Extension.HookMethod(_G["GameTooltip"], "SetPetAction", "OnNonUnitTooltip") + Extension.HookMethod(_G["GameTooltip"], "SetShapeshift", "OnNonUnitTooltip") + Extension.HookMethod(_G["GameTooltip"], "SetUnitBuff", "OnNonUnitTooltip") + Extension.HookMethod(_G["GameTooltip"], "SetUnitDebuff", "OnNonUnitTooltip") + Extension.HookMethod(_G["GameTooltip"], "SetPlayerBuff", "OnNonUnitTooltip") + + -- Auction and mail tooltips (guild bank doesn't exist in 1.12.1) + Extension.HookMethod(_G["GameTooltip"], "SetAuctionItem", "OnNonUnitTooltip") + Extension.HookMethod(_G["GameTooltip"], "SetAuctionSellItem", "OnNonUnitTooltip") + Extension.HookMethod(_G["GameTooltip"], "SetInboxItem", "OnNonUnitTooltip") + Extension.HookMethod(_G["GameTooltip"], "SetSendMailItem", "OnNonUnitTooltip") + + -- Note: SetOwner and SetText are NOT hooked because they're used for ALL tooltips + -- including unit tooltips. Hooking them would incorrectly clear mouseover for units. end _G["CleveRoids"] = CleveRoids diff --git a/Extensions/Mouseover/Grid.lua b/Extensions/Mouseover/Grid.lua index 6795832..3122375 100644 --- a/Extensions/Mouseover/Grid.lua +++ b/Extensions/Mouseover/Grid.lua @@ -6,17 +6,17 @@ local _G = _G or getfenv(0) local CleveRoids = _G.CleveRoids or {} CleveRoids.Hooks = CleveRoids.Hooks or {} -CleveRoids.mouseoverUnit = CleveRoids.mouseoverUnit or nil local Extension = CleveRoids.RegisterExtension("Grid") Extension.RegisterEvent("ADDON_LOADED", "OnLoad") function Extension.OnEnter(unit) - CleveRoids.mouseoverUnit = unit + CleveRoids.SetMouseoverFrom("grid", unit) end function Extension.OnLeave() - CleveRoids.mouseoverUnit = nil + CleveRoids.ClearMouseoverFrom("grid") + CleveRoids.ClearMouseoverFrom("native") end function Extension.OnLoad() @@ -111,4 +111,4 @@ function CleveRoids:GrdCreateFrames() ClickCastFrames[self.frame] = true end -_G["CleveRoids"] = CleveRoids \ No newline at end of file +_G["CleveRoids"] = CleveRoids diff --git a/Extensions/Mouseover/LunaUnitFrames.lua b/Extensions/Mouseover/LunaUnitFrames.lua new file mode 100644 index 0000000..f8415f9 --- /dev/null +++ b/Extensions/Mouseover/LunaUnitFrames.lua @@ -0,0 +1,27 @@ +--[[ + Author: SuperCleveRoidMacros + License: MIT License + + LunaUnitFrames mouseover support. + + NOTE: LunaUnitFrames already has native SuperWoW integration! + It calls SetMouseoverUnit(this.unit) directly in its OnEnter handler. + This triggers the UPDATE_MOUSEOVER_UNIT event which our system picks up + as the "native" source with priority 2. + + This extension is intentionally minimal - we just register to ensure + the extension system doesn't complain, but we don't need to hook anything. +]] +local _G = _G or getfenv(0) +local CleveRoids = _G.CleveRoids or {} + +local Extension = CleveRoids.RegisterExtension("LunaUnitFrames") + +function Extension.OnLoad() + -- LunaUnitFrames already has native SuperWoW integration + -- It calls SetMouseoverUnit(this.unit) in modules/units.lua lines 186-207 + -- This is picked up by our UPDATE_MOUSEOVER_UNIT handler as "native" source + -- No hooks needed! +end + +_G["CleveRoids"] = CleveRoids diff --git a/Extensions/Mouseover/NotGrid.lua b/Extensions/Mouseover/NotGrid.lua index 74128b8..b677124 100644 --- a/Extensions/Mouseover/NotGrid.lua +++ b/Extensions/Mouseover/NotGrid.lua @@ -6,17 +6,17 @@ local _G = _G or getfenv(0) local CleveRoids = _G.CleveRoids or {} local CreateFrames = nil -CleveRoids.mouseoverUnit = CleveRoids.mouseoverUnit or nil local Extension = CleveRoids.RegisterExtension("NotGrid") Extension.RegisterEvent("ADDON_LOADED", "OnLoad") function Extension.OnEnter() - CleveRoids.mouseoverUnit = this.unit + CleveRoids.SetMouseoverFrom("ngrid", this.unit) end function Extension.OnLeave() - CleveRoids.mouseoverUnit = nil + CleveRoids.ClearMouseoverFrom("ngrid") + CleveRoids.ClearMouseoverFrom("native") end function CleveRoids:NotGrid_CreateFrames() diff --git a/Extensions/Mouseover/PerfectRaid.lua b/Extensions/Mouseover/PerfectRaid.lua index 152420d..38ba175 100644 --- a/Extensions/Mouseover/PerfectRaid.lua +++ b/Extensions/Mouseover/PerfectRaid.lua @@ -6,17 +6,17 @@ local _G = _G or getfenv(0) local CleveRoids = _G.CleveRoids or {} CleveRoids.Hooks = CleveRoids.Hooks or {} -CleveRoids.mouseoverUnit = CleveRoids.mouseoverUnit or nil local Extension = CleveRoids.RegisterExtension("PerfectRaid") Extension.RegisterEvent("ADDON_LOADED", "OnLoad") function Extension.OnEnter(unit) - CleveRoids.mouseoverUnit = unit + CleveRoids.SetMouseoverFrom("praid", unit) end function Extension.OnLeave() - CleveRoids.mouseoverUnit = nil + CleveRoids.ClearMouseoverFrom("praid") + CleveRoids.ClearMouseoverFrom("native") end function Extension.OnLoad() @@ -134,4 +134,4 @@ function CleveRoids.PerfectRaidCreateFrame(self, num) --]] end -_G["CleveRoids"] = CleveRoids \ No newline at end of file +_G["CleveRoids"] = CleveRoids diff --git a/Extensions/Mouseover/XPerl.lua b/Extensions/Mouseover/XPerl.lua new file mode 100644 index 0000000..511bd3b --- /dev/null +++ b/Extensions/Mouseover/XPerl.lua @@ -0,0 +1,52 @@ +--[[ + Author: SuperCleveRoidMacros + License: MIT License + + X-Perl UnitFrames mouseover support. + Hooks into X-Perl's tooltip functions to track mouseover unit. + + X-Perl uses XPerl_PlayerTip(unitid) for OnEnter and XPerl_PlayerTipHide() for OnLeave. + These are global functions defined in XPerl/XPerl.lua. +]] +local _G = _G or getfenv(0) +local CleveRoids = _G.CleveRoids or {} + +local Extension = CleveRoids.RegisterExtension("XPerl") +-- Use PLAYER_ENTERING_WORLD instead of ADDON_LOADED for reliable timing +Extension.RegisterEvent("PLAYER_ENTERING_WORLD", "DelayedInit") + +local hooked = false + +function Extension.OnEnter(unitid) + if unitid and unitid ~= "" then + CleveRoids.SetMouseoverFrom("xperl", unitid) + end +end + +function Extension.OnLeave() + CleveRoids.ClearMouseoverFrom("xperl") + CleveRoids.ClearMouseoverFrom("native") +end + +function Extension.DelayedInit() + if hooked then return end + + -- Check if X-Perl is loaded by looking for its global functions + if not XPerl_PlayerTip then + return + end + + -- Hook the tooltip show function + Extension.Hook("XPerl_PlayerTip", "OnEnter") + + -- Hook the tooltip hide function + Extension.Hook("XPerl_PlayerTipHide", "OnLeave") + + hooked = true +end + +function Extension.OnLoad() + -- Nothing needed here, we use DelayedInit +end + +_G["CleveRoids"] = CleveRoids diff --git a/Extensions/Mouseover/ag_UnitFrames.lua b/Extensions/Mouseover/ag_UnitFrames.lua index c429480..3493d76 100644 --- a/Extensions/Mouseover/ag_UnitFrames.lua +++ b/Extensions/Mouseover/ag_UnitFrames.lua @@ -2,17 +2,17 @@ local _G = _G or getfenv(0) local CleveRoids = _G.CleveRoids or {} CleveRoids.Hooks = CleveRoids.Hooks or {} -CleveRoids.mouseoverUnit = CleveRoids.mouseoverUnit or nil local Extension = CleveRoids.RegisterExtension("ag_UnitFrames") Extension.RegisterEvent("ADDON_LOADED", "OnLoad") function Extension.OnEnter(unit) - CleveRoids.mouseoverUnit = unit + CleveRoids.SetMouseoverFrom("aguf", unit) end function Extension.OnLeave() - CleveRoids.mouseoverUnit = nil + CleveRoids.ClearMouseoverFrom("aguf") + CleveRoids.ClearMouseoverFrom("native") end -- Because AddOns are loaded in alphabetical order, this callback will never see the aUF loaded message, had to do a workaround... diff --git a/Extensions/Mouseover/pfUI.lua b/Extensions/Mouseover/pfUI.lua index 787f9e3..325312a 100644 --- a/Extensions/Mouseover/pfUI.lua +++ b/Extensions/Mouseover/pfUI.lua @@ -88,6 +88,10 @@ local function PfClear(frame) if frame and frame.__cr_src then CleveRoids.ClearMouseoverFrom(frame.__cr_src) frame.__cr_src = nil + -- Also clear "native" source to prevent sticky highlights. + -- When SetMouseoverUnit("") is called, UPDATE_MOUSEOVER_UNIT fires but + -- selfTriggered causes it to skip, leaving "native" stale. + CleveRoids.ClearMouseoverFrom("native") end end diff --git a/Extensions/Mouseover/sRaidFrames.lua b/Extensions/Mouseover/sRaidFrames.lua index a4ec4df..a6f2524 100644 --- a/Extensions/Mouseover/sRaidFrames.lua +++ b/Extensions/Mouseover/sRaidFrames.lua @@ -4,13 +4,17 @@ ]] local _G = _G or getfenv(0) local CleveRoids = _G.CleveRoids or {} -CleveRoids.mouseoverUnit = CleveRoids.mouseoverUnit or nil local Extension = CleveRoids.RegisterExtension("sRaidFrames") Extension.RegisterEvent("ADDON_LOADED", "OnLoad") function Extension:OnEnter(frame) - CleveRoids.mouseoverUnit = frame.unit + CleveRoids.SetMouseoverFrom("sraid", frame.unit) +end + +function Extension.OnLeave() + CleveRoids.ClearMouseoverFrom("sraid") + CleveRoids.ClearMouseoverFrom("native") end function Extension.OnLoad() @@ -19,6 +23,8 @@ function Extension.OnLoad() end Extension.HookMethod(sRaidFrames, "UnitTooltip", "OnEnter") + Extension.HookMethod(_G["GameTooltip"], "Hide", "OnLeave") + Extension.HookMethod(_G["GameTooltip"], "FadeOut", "OnLeave") end -_G["CleveRoids"] = CleveRoids \ No newline at end of file +_G["CleveRoids"] = CleveRoids diff --git a/Extensions/Tooltip/Generic.lua b/Extensions/Tooltip/Generic.lua index 0029301..224fc05 100644 --- a/Extensions/Tooltip/Generic.lua +++ b/Extensions/Tooltip/Generic.lua @@ -5,6 +5,33 @@ local _G = _G or getfenv(0) local CleveRoids = _G.CleveRoids or {} +-- PERFORMANCE: Upvalues for frequently called functions +local GetTime = GetTime +local GetContainerItemLink = GetContainerItemLink +local GetContainerItemInfo = GetContainerItemInfo +local GetContainerNumSlots = GetContainerNumSlots +local GetInventoryItemLink = GetInventoryItemLink +local GetInventoryItemTexture = GetInventoryItemTexture +local GetInventoryItemCount = GetInventoryItemCount +local GetItemInfo = GetItemInfo +local GetSpellName = GetSpellName +local GetSpellTexture = GetSpellTexture +local tonumber = tonumber +local type = type +local pairs = pairs +local string_find = string.find +local string_lower = string.lower +local string_gsub = string.gsub +local table_insert = table.insert +local table_getn = table.getn + +-- PERFORMANCE: Extract item name directly from link without GetItemInfo call +-- Link format: |cFFFFFFFF|Hitem:12345:0:0:0|h[Item Name]|h|r +local function GetNameFromLink(link) + if not link then return nil end + local _, _, name = string_find(link, "|h%[(.-)%]|h") + return name +end -- Indexes all spells the current player and pet knows function CleveRoids.IndexSpells() @@ -30,6 +57,10 @@ function CleveRoids.IndexSpells() spells[bookType] = {} else local cost, reagent = CleveRoids.GetSpellCost(i, bookType) + -- Fallback for known reagent spells if tooltip scan failed + if not reagent and CleveRoids.ReagentBySpell then + reagent = CleveRoids.ReagentBySpell[spellName] + end local texture = GetSpellTexture(i, bookType) if not spells[bookType][spellName] then spells[bookType][spellName] = { @@ -111,40 +142,10 @@ function CleveRoids.IndexTalents() end -function CleveRoids.IndexItems() - local items = {} - for bagID = 0, NUM_BAG_SLOTS do - for slot = GetContainerNumSlots(bagID), 1, -1 do - local link = GetContainerItemLink(bagID, slot) - if link then - local _, _, itemID = string.find(link, "item:(%d+)") - local name, link, _, _, itemType, itemSubType, _, _, texture = GetItemInfo(itemID) - - if name then - local _, count = GetContainerItemInfo(bagID, slot) - if not items[name] then - items[name] = { - bagID = bagID, - slot = slot, - id = itemID, - name = name, - type = itemType, - count = count, - texture = texture, - link = link, - bagSlots = {{bagID, slot}}, - slotsIndex = 1, - } - items[itemID] = name - items[string.lower(name)] = name - else - items[name].count = (items[name].count or 0) + (count or 0) - table.insert(items[name].bagSlots, {bagID, slot}) - end - end - end - end - end +-- Lightweight equipment-only indexing for combat situations +-- Updates existing cache rather than rebuilding it +function CleveRoids.IndexEquippedItems() + local items = CleveRoids.Items or {} for inventoryID = 0, 19 do local link = GetInventoryItemLink("player", inventoryID) @@ -163,10 +164,182 @@ function CleveRoids.IndexItems() link = link, } items[itemID] = name - items[string.lower(name)] = name + local lowerName = string.lower(name) + if lowerName ~= name then + items[lowerName] = name + end else + -- Update existing entry with current equipment state items[name].inventoryID = inventoryID - items[name].count = (items[name].count or 0) + (count or 0) + items[name].count = count + end + end + else + -- Slot is now empty - clear inventoryID from any item that was there + -- This is handled lazily by GetItem() fallback, so we skip expensive iteration + end + end + + CleveRoids.lastGetItem = nil + CleveRoids.Items = items +end + +-- PERFORMANCE: Index a single equipment slot instead of all 20 +-- Use when we know exactly which slot changed (e.g., from EquipBagItem) +function CleveRoids.IndexEquipSlot(inventoryID) + if not inventoryID then return end + + local items = CleveRoids.Items or {} + local link = GetInventoryItemLink("player", inventoryID) + + if link then + local _, _, itemID = string.find(link, "item:(%d+)") + local name, itemLink, _, _, itemType, itemSubType, _, _, texture = GetItemInfo(itemID) + if name then + local count = GetInventoryItemCount("player", inventoryID) + if not items[name] then + items[name] = { + inventoryID = inventoryID, + id = itemID, + name = name, + count = count, + texture = texture, + link = itemLink, + } + items[itemID] = name + local lowerName = string.lower(name) + if lowerName ~= name then + items[lowerName] = name + end + else + items[name].inventoryID = inventoryID + items[name].count = count + end + end + end + + CleveRoids.lastGetItem = nil + CleveRoids.Items = items +end + + +-- PERFORMANCE: Persistent cache for GetItemInfo results (survives between IndexItems calls) +-- This prevents repeated GetItemInfo calls for the same itemID +local _itemInfoCache = {} +local _itemInfoCacheSize = 0 +local _ITEM_INFO_CACHE_MAX = 500 -- Limit cache size + +local function GetCachedItemInfo(itemID) + local cached = _itemInfoCache[itemID] + if cached then + return cached.name, cached.link, cached.quality, cached.level, cached.type, cached.subType, cached.stackCount, cached.equipLoc, cached.texture + end + + local name, link, quality, level, itemType, subType, stackCount, equipLoc, texture = GetItemInfo(itemID) + if name then + -- Cache the result + if _itemInfoCacheSize < _ITEM_INFO_CACHE_MAX then + _itemInfoCache[itemID] = { + name = name, link = link, quality = quality, level = level, + type = itemType, subType = subType, stackCount = stackCount, + equipLoc = equipLoc, texture = texture + } + _itemInfoCacheSize = _itemInfoCacheSize + 1 + end + end + return name, link, quality, level, itemType, subType, stackCount, equipLoc, texture +end + +function CleveRoids.IndexItems() + local items = {} + local NUM_BAG_SLOTS = NUM_BAG_SLOTS -- Upvalue for bag constant + + -- PERFORMANCE: Local function references + local GetContainerNumSlots = GetContainerNumSlots + local GetContainerItemLink = GetContainerItemLink + local GetContainerItemInfo = GetContainerItemInfo + local GetInventoryItemLink = GetInventoryItemLink + local GetInventoryItemCount = GetInventoryItemCount + + -- Scan bags (reverse order to prefer first stack) + for bagID = 0, NUM_BAG_SLOTS do + local numSlots = GetContainerNumSlots(bagID) + for slot = numSlots, 1, -1 do + local link = GetContainerItemLink(bagID, slot) + if link then + local _, _, itemID = string_find(link, "item:(%d+)") + + -- PERFORMANCE: Try to extract name from link first to check for duplicates + local _, _, linkName = string_find(link, "%[(.+)%]") + local existing = linkName and items[linkName] + + if existing then + -- Item already seen - just update count and add bag slot (skip GetItemInfo) + local _, count = GetContainerItemInfo(bagID, slot) + existing.count = (existing.count or 0) + (count or 0) + table_insert(existing.bagSlots, {bagID, slot}) + else + -- New item - need full info + local name, itemLink, _, _, itemType, itemSubType, _, _, texture = GetCachedItemInfo(itemID) + + if name then + local _, count = GetContainerItemInfo(bagID, slot) + items[name] = { + bagID = bagID, + slot = slot, + id = itemID, + name = name, + type = itemType, + count = count, + texture = texture, + link = itemLink, + bagSlots = {{bagID, slot}}, + slotsIndex = 1, + } + items[itemID] = name + local lowerName = string_lower(name) + if lowerName ~= name then + items[lowerName] = name + end + end + end + end + end + end + + -- Scan equipped items + for inventoryID = 0, 19 do + local link = GetInventoryItemLink("player", inventoryID) + if link then + local _, _, itemID = string_find(link, "item:(%d+)") + + -- PERFORMANCE: Try to extract name from link first + local _, _, linkName = string_find(link, "%[(.+)%]") + local existing = linkName and items[linkName] + + if existing then + -- Item in bags AND equipped - update existing entry + existing.inventoryID = inventoryID + local count = GetInventoryItemCount("player", inventoryID) + existing.count = (existing.count or 0) + (count or 0) + else + -- New equipped-only item + local name, itemLink, _, _, itemType, itemSubType, _, _, texture = GetCachedItemInfo(itemID) + if name then + local count = GetInventoryItemCount("player", inventoryID) + items[name] = { + inventoryID = inventoryID, + id = itemID, + name = name, + count = count, + texture = texture, + link = itemLink, + } + items[itemID] = name + local lowerName = string_lower(name) + if lowerName ~= name then + items[lowerName] = name + end end end end @@ -225,18 +398,38 @@ end function CleveRoids.IndexActionSlot(slot) if not HasAction(slot) then CleveRoids.Actions[slot] = nil + -- When clearing a reactive slot, check if we need to find it elsewhere + local clearedReactiveName = CleveRoids.reactiveSlots[slot] CleveRoids.ClearSlot(CleveRoids.reactiveSlots, slot) CleveRoids.ClearSlot(CleveRoids.actionSlots, slot) + + -- If we cleared a reactive spell, rescan to find it in another slot + if clearedReactiveName then + for i = 1, 120 do + if i ~= slot and HasAction(i) then + local _, _, scanName = CleveRoids.GetActionButtonInfo(i) + if scanName == clearedReactiveName then + CleveRoids.reactiveSlots[clearedReactiveName] = i + CleveRoids.reactiveSlots[i] = clearedReactiveName + break + end + end + end + end else local actionType, _, name, rank = CleveRoids.GetActionButtonInfo(slot) if name then local reactiveName = CleveRoids.reactiveSpells[name] and name local actionSlotName = name..(rank and ("("..rank..")") or "") if reactiveName then - if not CleveRoids.reactiveSlots[reactiveName] then - CleveRoids.reactiveSlots[reactiveName] = slot - CleveRoids.reactiveSlots[slot] = reactiveName + -- Always update the mapping to ensure we track the correct slot + -- Clear old mapping for this spell name first + local oldSlot = CleveRoids.reactiveSlots[reactiveName] + if oldSlot and oldSlot ~= slot then + CleveRoids.reactiveSlots[oldSlot] = nil end + CleveRoids.reactiveSlots[reactiveName] = slot + CleveRoids.reactiveSlots[slot] = reactiveName elseif not reactiveName then CleveRoids.ClearSlot(CleveRoids.reactiveSlots, slot) end @@ -275,157 +468,162 @@ function CleveRoids.GetTalent(text) return CleveRoids.Talents[text] end +-- PERFORMANCE: Helper functions defined once (not inline per-call) +local function makeInventoryItem(inventoryID, link, Items) + if not link then link = GetInventoryItemLink("player", inventoryID) end + if not link then return end + + local _, _, itemID = string_find(link, "item:(%d+)") + itemID = itemID and tonumber(itemID) or nil + + local name = itemID and GetItemInfo(itemID) or nil + local texture = GetInventoryItemTexture("player", inventoryID) + local count = GetInventoryItemCount("player", inventoryID) + + local it = { + inventoryID = inventoryID, + id = itemID, + name = name, + texture = texture, + link = link, + count = count + } + if name then + Items[name] = it + local lowerName = string_lower(name) + if lowerName ~= name then + Items[lowerName] = name + end + end + if itemID then Items[itemID] = name end + return it +end + +local function makeBagItem(bagID, slot, link, Items) + if not link then + link = GetContainerItemLink(bagID, slot) + end + if not link then return end + + local _, _, itemID = string_find(link, "item:(%d+)") + itemID = itemID and tonumber(itemID) or nil + + local name, _, _, _, _, _, _, _, texture = GetItemInfo(itemID) + local count = 0 + local tex, itemCount = GetContainerItemInfo(bagID, slot) + if itemCount then count = itemCount end + if not texture then texture = tex end + + local it = { + bagID = bagID, + slot = slot, + id = itemID, + name = name, + texture = texture, + link = link, + count = count, + bagSlots = { { bagID, slot } }, + slotsIndex = 1 + } + if name then + Items[name] = it + local lowerName = string_lower(name) + if lowerName ~= name then + Items[lowerName] = name + end + end + if itemID then Items[itemID] = name end + return it +end + function CleveRoids.GetItem(text) if not text or text == "" then return end - local item = CleveRoids.Items[text] or CleveRoids.Items[tostring(text)] + local Items = CleveRoids.Items + local item = Items[text] or Items[tostring(text)] if not item then - local lowerText = string.lower(text) - local canonicalName = CleveRoids.Items[lowerText] + local lowerText = string_lower(text) + local canonicalName = Items[lowerText] if canonicalName and type(canonicalName) == "string" then - item = CleveRoids.Items[canonicalName] + item = Items[canonicalName] end end - + if item then if type(item) == "table" then return item else - return CleveRoids.Items[tostring(item)] + return Items[tostring(item)] end end - local function makeInv(inventoryID, link) - if not link then link = GetInventoryItemLink("player", inventoryID) end - if not link then return end - - local _, _, itemID = string.find(link, "item:(%d+)") - itemID = itemID and tonumber(itemID) or nil - - local name = itemID and GetItemInfo(itemID) or nil - local texture = GetInventoryItemTexture("player", inventoryID) - local count = GetInventoryItemCount("player", inventoryID) - - local it = { - inventoryID = inventoryID, - id = itemID, - name = name, - texture = texture, - link = link, - count = count - } - if name then - CleveRoids.Items[name] = it - CleveRoids.Items[string.lower(name)] = name - end - if itemID then CleveRoids.Items[itemID] = name end - return it - end - - local function makeBag(bagID, slot, link) - if not link and GetContainerItemLink then - link = GetContainerItemLink(bagID, slot) - end - if not link then return end - - local _, _, itemID = string.find(link, "item:(%d+)") - itemID = itemID and tonumber(itemID) or nil - - local name, _, _, _, _, _, _, _, texture = GetItemInfo(itemID or text) - local count = 0 - if GetContainerItemInfo then - local tex, itemCount = GetContainerItemInfo(bagID, slot) - if itemCount then count = itemCount end - if not texture then texture = tex end - end - - local it = { - bagID = bagID, - slot = slot, - id = itemID, - name = name, - texture = texture, - link = link, - count = count, - bagSlots = { { bagID, slot } }, - slotsIndex = 1 - } - if name then - CleveRoids.Items[name] = it - CleveRoids.Items[string.lower(name)] = name - end - if itemID then CleveRoids.Items[itemID] = name end - return it - end - local qid = tonumber(text) - local qname = (type(text) == "string") and string.lower(text) or nil + local qname = (type(text) == "string") and string_lower(text) or nil + -- Direct inventory slot lookup by ID if qid and qid >= 1 and qid <= 19 then - local it = makeInv(qid) + local it = makeInventoryItem(qid, nil, Items) if it then return it end end - local inv = 1 - while inv <= 19 do + -- Scan equipped items + for inv = 1, 19 do local link = GetInventoryItemLink("player", inv) if link then - local _, _, itemID = string.find(link, "item:(%d+)") + local _, _, itemID = string_find(link, "item:(%d+)") itemID = itemID and tonumber(itemID) or nil if qid and itemID and qid == itemID then - local it = makeInv(inv, link) - if it then return it end - elseif qname and itemID then - local nm = GetItemInfo(itemID) - if nm and string.lower(nm) == qname then - local it = makeInv(inv, link) - if it then return it end + return makeInventoryItem(inv, link, Items) + elseif qname then + -- PERFORMANCE: Extract name from link directly instead of GetItemInfo + local nm = GetNameFromLink(link) + if nm and string_lower(nm) == qname then + return makeInventoryItem(inv, link, Items) end end end - inv = inv + 1 end - local bag = 0 - while bag <= 4 do - local slots = (GetContainerNumSlots and GetContainerNumSlots(bag)) or 0 - local slot = 1 - while slot <= slots do - local link = GetContainerItemLink and GetContainerItemLink(bag, slot) + -- Scan bags + for bag = 0, 4 do + local slots = GetContainerNumSlots(bag) or 0 + for slot = 1, slots do + local link = GetContainerItemLink(bag, slot) if link then - local _, _, itemID = string.find(link, "item:(%d+)") + local _, _, itemID = string_find(link, "item:(%d+)") itemID = itemID and tonumber(itemID) or nil if qid and itemID and qid == itemID then - local it = makeBag(bag, slot, link) - if it then return it end - elseif qname and itemID then - local nm = GetItemInfo(itemID) - if nm and string.lower(nm) == qname then - local it = makeBag(bag, slot, link) - if it then return it end + return makeBagItem(bag, slot, link, Items) + elseif qname then + -- PERFORMANCE: Extract name from link directly instead of GetItemInfo + local nm = GetNameFromLink(link) + if nm and string_lower(nm) == qname then + return makeBagItem(bag, slot, link, Items) end end end - slot = slot + 1 end - bag = bag + 1 end local name, link, _, _, _, _, _, _, texture = GetItemInfo(text) if not name then return end local fallback = { id = text, name = name, link = link, texture = texture } CleveRoids.Items[name] = fallback - CleveRoids.Items[string.lower(name)] = name + local lowerName = string.lower(name) + if lowerName ~= name then + CleveRoids.Items[lowerName] = name + end return fallback end function CleveRoids.GetNextBagSlotForUse(item, text) if not item then return end - if CleveRoids.lastGetItem == CleveRoids.GetItem(text) then - if table.getn(item.bagSlots) > item.slotsIndex then + -- PERFORMANCE: Compare item directly instead of calling GetItem(text) again + if CleveRoids.lastGetItem == item then + if item.bagSlots and table.getn(item.bagSlots) > item.slotsIndex then item.slotsIndex = item.slotsIndex + 1 item.bagID, item.slot = unpack(item.bagSlots[item.slotsIndex]) end @@ -435,6 +633,211 @@ function CleveRoids.GetNextBagSlotForUse(item, text) return item end +-------------------------------------------------------------------------------- +-- FAST ITEM LOOKUP (avoids full bag scan) +-------------------------------------------------------------------------------- + +-- PERFORMANCE: Fast item lookup by name only - no full scan fallback +-- Returns item table or nil. Does NOT trigger IndexItems(). +-- Use this for equipment swapping where we know the exact item name. +function CleveRoids.GetItemFast(text) + if not text or text == "" then return nil end + + local Items = CleveRoids.Items + if not Items then return nil end + + -- Direct cache lookup + local item = Items[text] + if item and type(item) == "table" then + return item + end + + -- Try lowercase + local lowerText = string_lower(text) + local canonicalName = Items[lowerText] + if canonicalName and type(canonicalName) == "string" then + item = Items[canonicalName] + if item and type(item) == "table" then + return item + end + end + + -- Try as item ID + local itemId = tonumber(text) + if itemId then + local name = Items[itemId] + if name and type(name) == "string" then + item = Items[name] + if item and type(item) == "table" then + return item + end + end + end + + return nil +end + +-- PERFORMANCE: Quick scan for a single item by name (doesn't update full cache) +-- Only scans until item is found, then stops. +-- Optimized: extracts name from link directly instead of calling GetItemInfo. +-- Uses cache with VALIDATION - checks if item is actually at cached location before trusting it. +-- v2.18+: Uses native FindPlayerItemSlot for O(1) lookup when available. +function CleveRoids.FindItemQuick(text) + if not text or text == "" then return nil end + + -- v2.18+: Try native fast lookup first (much faster than Lua scanning) + local API = CleveRoids.NampowerAPI + if API and API.features and API.features.hasFindPlayerItemSlot then + local itemInfo = API.FindItemFast(text) + if itemInfo then + -- Update cache with the found item for future lookups + local Items = CleveRoids.Items or {} + if itemInfo.name then + Items[itemInfo.name] = itemInfo + Items[string_lower(itemInfo.name)] = itemInfo.name + if itemInfo.itemId then + Items[itemInfo.itemId] = itemInfo.name + end + end + return itemInfo + end + -- Item not found via native lookup - no need to scan + return nil + end + + -- Fallback: no v2.18 API, use existing cache + scan logic + local Items = CleveRoids.Items or {} + local qid = tonumber(text) + -- Only compute lowercase name if we're NOT searching by ID + local qname = (not qid) and string_lower(text) or nil + + -- Try cache first, but VALIDATE the cached location is still correct + local cached = CleveRoids.GetItemFast(text) + if cached then + -- Validate: check if item is actually at the cached location + if cached.inventoryID then + local link = GetInventoryItemLink("player", cached.inventoryID) + if link then + local nm = GetNameFromLink(link) + if nm and qname and string_lower(nm) == qname then + cached._validated = true + return cached -- Cache is valid + elseif qid then + local _, _, itemID = string_find(link, "item:(%d+)") + if itemID and tonumber(itemID) == qid then + cached._validated = true + return cached -- Cache is valid + end + end + end + -- Cache is stale - item not at cached equipped slot, invalidate + if cached.name then + Items[cached.name] = nil + Items[string_lower(cached.name)] = nil + end + elseif cached.bagID and cached.slot then + local link = GetContainerItemLink(cached.bagID, cached.slot) + if link then + local nm = GetNameFromLink(link) + if nm and qname and string_lower(nm) == qname then + cached._validated = true + return cached -- Cache is valid + elseif qid then + local _, _, itemID = string_find(link, "item:(%d+)") + if itemID and tonumber(itemID) == qid then + cached._validated = true + return cached -- Cache is valid + end + end + end + -- Cache is stale - item not at cached bag slot, invalidate + if cached.name then + Items[cached.name] = nil + Items[string_lower(cached.name)] = nil + end + end + end + + -- Cache miss or stale - do a fresh scan + -- Quick scan equipped items first (only 19 slots) + for inv = 1, 19 do + local link = GetInventoryItemLink("player", inv) + if link then + local _, _, itemID = string_find(link, "item:(%d+)") + if itemID then + itemID = tonumber(itemID) + -- ID match: fast path + if qid and qid == itemID then + return makeInventoryItem(inv, link, Items) + end + -- Name match: extract name from link (faster than GetItemInfo) + if qname then + local nm = GetNameFromLink(link) + if nm and string_lower(nm) == qname then + return makeInventoryItem(inv, link, Items) + end + end + end + end + end + + -- Quick scan bags - stop as soon as found + for bag = 0, 4 do + local slots = GetContainerNumSlots(bag) or 0 + for slot = 1, slots do + local link = GetContainerItemLink(bag, slot) + if link then + local _, _, itemID = string_find(link, "item:(%d+)") + if itemID then + itemID = tonumber(itemID) + -- ID match: fast path + if qid and qid == itemID then + return makeBagItem(bag, slot, link, Items) + end + -- Name match: extract name from link (faster than GetItemInfo) + if qname then + local nm = GetNameFromLink(link) + if nm and string_lower(nm) == qname then + return makeBagItem(bag, slot, link, Items) + end + end + end + end + end + end + + return nil +end + +-- PERFORMANCE: Check if item is already equipped in slot (fast path) +-- Returns true if the item (by name or ID) is already in the specified slot +function CleveRoids.IsItemEquipped(text, inventoryId) + if not text or not inventoryId then return false end + + local link = GetInventoryItemLink("player", inventoryId) + if not link then return false end + + local _, _, currentID = string_find(link, "item:(%d+)") + if not currentID then return false end + + -- Check by ID (fast path) + local textId = tonumber(text) + if textId and textId == tonumber(currentID) then + return true + end + + -- Check by name - extract from link instead of GetItemInfo for performance + local currentName = GetNameFromLink(link) + if currentName then + local textLower = string_lower(text) + if string_lower(currentName) == textLower then + return true + end + end + + return false +end + local Extension = CleveRoids.RegisterExtension("Generic_show") Extension.RegisterEvent("SPELLS_CHANGED", "SPELLS_CHANGED") diff --git a/ExtensionsManager.lua b/ExtensionsManager.lua index c2d984b..b0cb84f 100644 --- a/ExtensionsManager.lua +++ b/ExtensionsManager.lua @@ -58,6 +58,7 @@ function CleveRoids.RegisterExtension(name) eventHandlers = {}, hooks = {}, memberHooks = {}, + activeHooks = {}, -- Re-entrancy guard: tracks hooks currently executing }, } @@ -89,18 +90,41 @@ function CleveRoids.RegisterExtension(name) -- This is a function wrapper that we swap with the function that we want to hook -- @return Value of Callback() or Origininal() if dontCallOriginal is false extension.internal.OnHook = function(object, functionName, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10) - local hook + -- Generate a unique key for this hook (handles both global and method hooks) + local hookKey = tostring(object or "global") .. ":" .. functionName + -- Re-entrancy guard: if this hook is already executing, call original directly + if extension.internal.activeHooks[hookKey] then + local hook + if object then + hook = extension.internal.memberHooks[object][functionName] + else + hook = extension.internal.hooks[functionName] + end + if hook and hook.original then + return hook.original(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10) + end + return + end + + local hook if object then hook = extension.internal.memberHooks[object][functionName] else hook = extension.internal.hooks[functionName] end + -- Mark this hook as active + extension.internal.activeHooks[hookKey] = true + local retval = hook.callback(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10) if not hook.dontCallOriginal then retval = hook.original(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10) end + + -- Mark this hook as inactive + extension.internal.activeHooks[hookKey] = nil + return retval end diff --git a/Init.lua b/Init.lua index 6e7f033..f6b2274 100644 --- a/Init.lua +++ b/Init.lua @@ -38,6 +38,21 @@ CleveRoids.unknownTexture = "Interface\\Icons\\INV_Misc_QuestionMark" CleveRoids.spell_tracking = {} +-- GUID-based cast tracking (populated by pfUI 7.6 or standalone SPELL_START events) +-- Format: [casterGuid] = {spellID, spellName, icon, startTime, duration, endTime} +CleveRoids.castTracking = {} + +-- pfUI 7.6+ with Nampower 2.27.2+ detected (GUID-based cast tracking available) +CleveRoids.hasPfUI76 = false + +-- Combo point tracking (initialized early for /cast hook) +CleveRoids.lastComboPoints = 0 +CleveRoids.lastComboPointsTime = 0 + +-- Resist tracking state +-- Structure: { resistType = "full"|"partial", targetGUID = guid } +CleveRoids.resistState = nil + -- Holds information about the currently cast spell CleveRoids.CurrentSpell = { -- "channeled" or "cast" @@ -59,22 +74,40 @@ CleveRoids.UpdateCastingState = function() local castId, visId, autoId, casting, channeling, onswing, autoattack = GetCurrentCastingInfo() -- Update CurrentSpell based on actual cast state + -- NOTE: Channel state is EXCLUSIVELY managed by SPELLCAST_CHANNEL_START/STOP events + -- This function NEVER touches channel state, only regular casts if casting == 1 then CleveRoids.CurrentSpell.type = "cast" CleveRoids.CurrentSpell.castingSpellId = castId - elseif channeling == 1 then - CleveRoids.CurrentSpell.type = "channeled" - CleveRoids.CurrentSpell.castingSpellId = visId - else + elseif CleveRoids.CurrentSpell.type == "cast" then + -- Only clear if we were in a regular cast (not channel) CleveRoids.CurrentSpell.type = "" CleveRoids.CurrentSpell.castingSpellId = nil end + -- DO NOT touch channel state here - events handle it + -- Always update metadata from GetCurrentCastingInfo (onswing/autoattack only here) CleveRoids.CurrentSpell.autoAttack = (autoattack == 1) CleveRoids.CurrentSpell.onSwingPending = (onswing == 1) CleveRoids.CurrentSpell.visualSpellId = visId CleveRoids.CurrentSpell.autoRepeatSpellId = autoId + -- Enhanced timing data from GetCastInfo (Nampower 2.18+) + if GetCastInfo then + local ok, info = pcall(GetCastInfo) + if ok and info then + CleveRoids.CurrentSpell.castRemainingMs = info.castRemainingMs + CleveRoids.CurrentSpell.castEndTime = info.castEndS + CleveRoids.CurrentSpell.gcdRemainingMs = info.gcdRemainingMs + CleveRoids.CurrentSpell.gcdEndTime = info.gcdEndS + else + CleveRoids.CurrentSpell.castRemainingMs = nil + CleveRoids.CurrentSpell.castEndTime = nil + CleveRoids.CurrentSpell.gcdRemainingMs = nil + CleveRoids.CurrentSpell.gcdEndTime = nil + end + end + return true end @@ -86,23 +119,51 @@ CleveRoids.dynamicCmds = { ["/equip"] = true, ["/equipmh"] = true, ["/equipoh"] = true, + ["/equip11"] = true, + ["/equip12"] = true, + ["/equip13"] = true, + ["/equip14"] = true, } -- Equipment swap queue system CleveRoids.equipmentQueue = {} +CleveRoids.equipmentQueueLen = 0 -- PERFORMANCE: Track length to avoid table.getn() every frame CleveRoids.lastEquipTime = {} CleveRoids.lastGlobalEquipTime = 0 CleveRoids.EQUIP_COOLDOWN = 1.5 -- Per-slot cooldown CleveRoids.EQUIP_GLOBAL_COOLDOWN = 0.5 -- Global cooldown +-- PERFORMANCE: Table pool for queue entries to reduce garbage collection +CleveRoids.queueEntryPool = {} + +-- PERFORMANCE: Static buffer for proc removal to avoid per-frame allocation +CleveRoids._procRemovalBuffer = {} + +-- PERFORMANCE: Static buffer for action grouping to avoid per-call allocation +CleveRoids._actionsToSlotsBuffer = {} +CleveRoids._slotsBuffer = {} +CleveRoids._actionsListBuffer = {} + +-- PERFORMANCE: Static buffer for arg backup in SendEventForAction +CleveRoids._originalArgsBuffer = {} + -- Spell queue state (Nampower) CleveRoids.queuedSpell = nil CleveRoids.lastCastSpell = nil +-- Macro execution control +CleveRoids.stopMacroFlag = false + +-- PERFORMANCE: Event-driven cached state (updated on events, not polled) +CleveRoids._cachedPlayerInCombat = nil -- Updated on PLAYER_REGEN_DISABLED, PLAYER_REGEN_ENABLED + CleveRoids.ignoreKeywords = { action = true, ignoretooltip = true, cancelaura = true, + _operators = true, -- Metadata for AND/OR operator tracking + _groups = true, -- Grouped conditional values for AND/OR evaluation + multiscan = true, -- Processed before Keywords loop (target resolution) } -- TODO: Localize? @@ -135,10 +196,21 @@ CleveRoids.spamConditions = { [CleveRoids.Localized.Shoot] = "checkchanneled", } +-- PERFORMANCE: Static lookup for toggled buff abilities (built once, used per-frame) +CleveRoids._toggledBuffAbilities = { + [CleveRoids.Localized.Spells["Prowl"]] = true, + [CleveRoids.Localized.Spells["Shadowmeld"]] = true, +} + +function CleveRoids.IsToggledBuffAbility(spellName) + return CleveRoids._toggledBuffAbilities[spellName] +end + CleveRoids.auraTextures = { [CleveRoids.Localized.Spells["Stealth"]] = "Interface\\Icons\\Ability_Stealth", - [CleveRoids.Localized.Spells["Prowl"]] = "Interface\\Icons\\Ability_Ambush", + [CleveRoids.Localized.Spells["Prowl"]] = "Interface\\Icons\\Spell_Nature_Invisibilty", [CleveRoids.Localized.Spells["Shadowform"]] = "Interface\\Icons\\Spell_Shadow_Shadowform", + [CleveRoids.Localized.Spells["Shadowmeld"]] = "Interface\\Icons\\Spell_Nature_WispSplode", ["Seal of Wisdom"] = "Interface\\Icons\\Spell_Holy_RighteousnessAura", ["Seal of the Crusader"] = "Interface\\Icons\\Spell_Holy_HolySmite", ["Seal of Light"] = "Interface\\Icons\\Spell_Holy_HealingAura", @@ -177,11 +249,22 @@ CleveRoids.WeaponTypeNames = { CleveRoids.hasNampower = (QueueSpellByName ~= nil) CleveRoids.hasUnitXP = pcall(UnitXP, "nop", "nop") +-- Extended Nampower feature flags (populated by NampowerAPI.lua) +CleveRoids.nampowerVersion = { major = 0, minor = 0, patch = 0 } +CleveRoids.hasExtendedNampower = false -- True if v2.12+ with new API functions + -- Feature detection messages local function PrintFeatures() local features = {} if CleveRoids.hasSuperwow then table.insert(features, "SuperWoW") end - if CleveRoids.hasNampower then table.insert(features, "Nampower") end + if CleveRoids.hasNampower then + local ver = CleveRoids.nampowerVersion + if ver.major > 0 then + table.insert(features, string.format("Nampower v%d.%d.%d", ver.major, ver.minor, ver.patch)) + else + table.insert(features, "Nampower") + end + end if CleveRoids.hasUnitXP then table.insert(features, "UnitXP") end if CleveRoids.hasTurtle then table.insert(features, "Turtle") end @@ -190,11 +273,60 @@ local function PrintFeatures() end end +-- Immunity data version - increment this when changing immunity data format +-- This will cause all immunity data to be reset on addon update +-- v3: Fixed Master Strike false physical immunity recording (split CC spell handling) +CleveRoids.IMMUNITY_DATA_VERSION = 3 + -- Call on next frame to ensure everything is loaded local initFrame = CreateFrame("Frame") initFrame:RegisterEvent("PLAYER_ENTERING_WORLD") initFrame:SetScript("OnEvent", function() this:UnregisterAllEvents() + + -- Check immunity data version and reset if outdated + CleveRoidMacros = CleveRoidMacros or {} + CleveRoids_ImmunityData = CleveRoids_ImmunityData or {} + local savedVersion = CleveRoidMacros.immunityDataVersion or 0 + + if savedVersion < CleveRoids.IMMUNITY_DATA_VERSION then + -- Check if there was existing data to clear + local hadData = next(CleveRoids_ImmunityData) ~= nil + + -- Version changed - reset all immunity data + CleveRoids_ImmunityData = {} + CleveRoidMacros.immunityDataVersion = CleveRoids.IMMUNITY_DATA_VERSION + + if hadData then + -- Show message if we actually cleared existing data + CleveRoids.Print("|cffff9900Immunity data reset|r - addon updated to data version " .. CleveRoids.IMMUNITY_DATA_VERSION) + end + end + + -- Initialize NampowerAPI if available + if CleveRoids.NampowerAPI then + local API = CleveRoids.NampowerAPI + + -- Get version info + local major, minor, patch = API.GetVersion() + CleveRoids.nampowerVersion = { major = major, minor = minor, patch = patch } + + -- Check for extended API (v2.12+) + CleveRoids.hasExtendedNampower = API.HasMinimumVersion(2, 12, 0) + + -- Sync feature flags + if API.features then + CleveRoids.hasGetSpellRec = API.features.hasGetSpellRec + CleveRoids.hasGetItemStats = API.features.hasGetItemStats + CleveRoids.hasGetUnitData = API.features.hasGetUnitData + CleveRoids.hasGetSpellModifiers = API.features.hasGetSpellModifiers + CleveRoids.hasEnhancedSpellFunctions = API.features.hasEnhancedSpellFunctions + end + + -- Initialize the API + API.Initialize() + end + PrintFeatures() end) diff --git a/Localization.lua b/Localization.lua index 39fbd8e..2550a98 100644 --- a/Localization.lua +++ b/Localization.lua @@ -49,6 +49,7 @@ if CleveRoids.Locale == "enUS" or CleveRoids.Locale == "enGB" then ["Shadowform"] = "Shadowform", ["Stealth"] = "Stealth", ["Prowl"] = "Prowl", + ["Shadowmeld"] = "Shadowmeld", ["Revenge"] = "Revenge", ["Overpower"] = "Overpower", ["Riposte"] = "Riposte", @@ -106,6 +107,7 @@ elseif CleveRoids.Locale == "deDE" then ["Shadowform"] = "Schattengestalt", ["Stealth"] = "Verstohlenheit", ["Prowl"] = "Schleichen", + ["Shadowmeld"] = "Schattenmimik", ["Revenge"] = "Rache", ["Overpower"] = "Überwältigen", ["Riposte"] = "Riposte", @@ -161,6 +163,7 @@ elseif CleveRoids.Locale == "frFR" then ["Shadowform"] = "Forme d'Ombre", ["Stealth"] = "Camouflage", ["Prowl"] = "Rôder", + ["Shadowmeld"] = "Camouflage dans l'ombre", ["Revenge"] = "Vengeance", ["Overpower"] = "Fulgurance", ["Riposte"] = "Riposte", @@ -215,6 +218,7 @@ elseif CleveRoids.Locale == "koKR" then ["Shadowform"] = "어둠의 형상", ["Stealth"] = "은신", ["Prowl"] = "숨기", + ["Shadowmeld"] = "그림자 숨기", ["Revenge"] = "복수", ["Overpower"] = "제압", ["Riposte"] = "반격", @@ -269,6 +273,7 @@ elseif CleveRoids.Locale == "zhCN" then ["Shadowform"] = "暗影形态", ["Stealth"] = "潜行", ["Prowl"] = "潜行", + ["Shadowmeld"] = "影遁", ["Revenge"] = "复仇", ["Overpower"] = "压制", ["Riposte"] = "还击", @@ -323,6 +328,7 @@ elseif CleveRoids.Locale == "zhTW" then ["Shadowform"] = "暗影形態", ["Stealth"] = "隱形", ["Prowl"] = "徘徊", + ["Shadowmeld"] = "影遁", ["Revenge"] = "復仇", ["Overpower"] = "壓倒", ["Riposte"] = "還擊", @@ -377,6 +383,7 @@ elseif CleveRoids.Locale == "ruRU" then ["Shadowform"] = "Облик Тени", ["Stealth"] = "Незаметность", ["Prowl"] = "Крадущийся зверь", + ["Shadowmeld"] = "Слияние с тенью", ["Revenge"] = "Реванш", ["Overpower"] = "Превосходство", ["Riposte"] = "Ответный удар", @@ -431,6 +438,7 @@ elseif CleveRoids.Locale == "esES" then ["Shadowform"] = "Forma de las Sombras", ["Stealth"] = "Sigilo", ["Prowl"] = "Acechar", + ["Shadowmeld"] = "Fusión con las sombras", ["Revenge"] = "Revancha", ["Overpower"] = "Abrumar", ["Riposte"] = "Estocada", diff --git a/NampowerAPI.lua b/NampowerAPI.lua new file mode 100644 index 0000000..4ce5385 --- /dev/null +++ b/NampowerAPI.lua @@ -0,0 +1,2772 @@ +--[[ + NampowerAPI.lua - Nampower API Integration Layer + + Provides wrapper functions for Nampower's extended Lua API with fallbacks + for older versions or when functions are unavailable. + + Core Nampower Functions Wrapped (v2.8+): + - GetSpellRec / GetSpellRecField - Spell record data from client DB + - GetItemStats / GetItemStatsField - Item stats from client DB + - GetUnitData / GetUnitField - Low-level unit field access + - GetSpellModifiers - Spell modifier calculations (talents, buffs, etc.) + + Inventory/Equipment Functions (v2.18+): + - FindPlayerItemSlot - Fast item location lookup + - GetEquippedItems / GetEquippedItem - Equipment inspection + - GetBagItems / GetBagItem - Bag contents inspection + - GetCastInfo - Detailed cast/channel/GCD information + - GetSpellIdCooldown / GetItemIdCooldown - Detailed cooldown info with item metadata + + Trinket/Item Functions (v2.20+): + - GetTrinkets - Enumerate equipped and bagged trinkets + - GetTrinketCooldown - Get trinket cooldown by slot/ID/name + - UseTrinket - Use trinket by slot/ID/name with optional target + - UseItemIdOrName - Use any item by ID/name with optional target + + Enhanced Functions (accept spell name or "spellId:number"): + - GetSpellTexture, GetSpellName, GetSpellCooldown, GetSpellAutocast + - ToggleSpellAutocast, PickupSpell, CastSpell, IsCurrentCast, IsSpellPassive + + Copy Parameter (v2.20+): + - Most table-returning functions now accept [copy] parameter + - Pass 1 to get independent copy safe for storage + - Without copy, table references are reused - extract values immediately! + + Utility Functions (v2.22+): + - DisenchantAll - Auto-disenchant items by ID/name or quality + + Cooldown Detail Tables (v2.23+): + - GetSpellIdCooldown/GetItemIdCooldown now include item metadata: + itemId, itemHasActiveSpell, itemActiveSpellId + + Auto-Attack Events (v2.24+): + - AUTO_ATTACK_SELF / AUTO_ATTACK_OTHER events + - 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 + + Texture Lookup Functions (v2.27+): + - GetItemIconTexture(displayInfoId) - Get item texture path from display info ID + - GetSpellIconTexture(spellIconId) - Get spell texture path from spell icon ID + + Settings Integration: + - Reads from NampowerSettings addon when available + - Falls back to CVars when addon not present + + Current version: v2.27.0 +]] + +local _G = _G or getfenv(0) +local CleveRoids = _G.CleveRoids + +-- Nampower API namespace +-- Force table creation if not already a table (guards against addon conflicts) +if type(CleveRoids.NampowerAPI) ~= "table" then + CleveRoids.NampowerAPI = {} +end +local API = CleveRoids.NampowerAPI + +-------------------------------------------------------------------------------- +-- VERSION DETECTION AND FEATURE FLAGS +-------------------------------------------------------------------------------- + +-- Cached version info (set during initialization) +API._version = { major = 0, minor = 0, patch = 0, initialized = false } + +-- Get Nampower version as major, minor, patch +-- Returns 0, 0, 0 if Nampower not installed +function API.GetVersion() + if API._version.initialized then + return API._version.major, API._version.minor, API._version.patch + end + + if GetNampowerVersion then + local major, minor, patch = GetNampowerVersion() + API._version.major = major or 0 + API._version.minor = minor or 0 + API._version.patch = patch or 0 + end + API._version.initialized = true + + return API._version.major, API._version.minor, API._version.patch +end + +-- Get version as a single comparable number (e.g., 2.18.3 -> 2018003) +function API.GetVersionNumber() + local major, minor, patch = API.GetVersion() + return (major * 1000000) + (minor * 1000) + patch +end + +-- Check if installed version meets minimum requirement +function API.HasMinimumVersion(reqMajor, reqMinor, reqPatch) + local major, minor, patch = API.GetVersion() + + if major > reqMajor then + return true + elseif major == reqMajor then + if minor > reqMinor then + return true + elseif minor == reqMinor and patch >= (reqPatch or 0) then + return true + end + end + return false +end + +-- Version requirements for each feature group +-- Format: { major, minor, patch, globalFunction (optional verification) } +API.VERSION_REQUIREMENTS = { + -- v2.0+ - Base spell queuing + ["SpellQueue"] = { 2, 0, 0, "QueueSpellByName" }, + ["GetCurrentCastingInfo"] = { 2, 0, 0, "GetCurrentCastingInfo" }, + + -- v2.8+ - DBC data access + ["GetSpellRec"] = { 2, 8, 0, "GetSpellRec" }, + ["GetSpellRecField"] = { 2, 8, 0, "GetSpellRecField" }, + ["GetItemStats"] = { 2, 8, 0, "GetItemStats" }, + ["GetItemStatsField"] = { 2, 8, 0, "GetItemStatsField" }, + ["GetUnitData"] = { 2, 8, 0, "GetUnitData" }, + ["GetUnitField"] = { 2, 8, 0, "GetUnitField" }, + ["GetSpellModifiers"] = { 2, 8, 0, "GetSpellModifiers" }, + + -- v2.11+ - GetNampowerVersion itself (meta-feature) + ["GetNampowerVersion"] = { 2, 11, 0, "GetNampowerVersion" }, + + -- v2.12+ - Enhanced spell functions and range/usable checks + ["EnhancedSpellFunctions"] = { 2, 12, 0 }, -- No single function to verify + ["IsSpellInRange"] = { 2, 12, 0, "IsSpellInRange" }, + ["IsSpellUsable"] = { 2, 12, 0, "IsSpellUsable" }, + ["GetSpellIdForName"] = { 2, 12, 0, "GetSpellIdForName" }, + ["GetSpellNameAndRankForId"]= { 2, 12, 0, "GetSpellNameAndRankForId" }, + ["GetSpellSlotTypeIdForName"]={ 2, 12, 0, "GetSpellSlotTypeIdForName" }, + ["CastSpellByNameNoQueue"] = { 2, 12, 0, "CastSpellByNameNoQueue" }, + ["QueueScript"] = { 2, 12, 0, "QueueScript" }, + + -- v2.13+ - QueueSpellByName with target parameter + ["QueueSpellWithTarget"] = { 2, 13, 0 }, -- Same function, new parameter + + -- v2.14+ - GetItemLevel standalone function + ["GetItemLevel"] = { 2, 14, 0, "GetItemLevel" }, + + -- v2.18+ - Inventory, equipment, detailed cast/cooldown info + ["FindPlayerItemSlot"] = { 2, 18, 0, "FindPlayerItemSlot" }, + ["GetEquippedItems"] = { 2, 18, 0, "GetEquippedItems" }, + ["GetEquippedItem"] = { 2, 18, 0, "GetEquippedItem" }, + ["GetBagItems"] = { 2, 18, 0, "GetBagItems" }, + ["GetBagItem"] = { 2, 18, 0, "GetBagItem" }, + ["GetCastInfo"] = { 2, 18, 0, "GetCastInfo" }, + ["GetSpellIdCooldown"] = { 2, 18, 0, "GetSpellIdCooldown" }, + ["GetItemIdCooldown"] = { 2, 18, 0, "GetItemIdCooldown" }, + ["ChannelStopCastingNextTick"]={ 2, 18, 0, "ChannelStopCastingNextTick" }, + + -- v2.20+ - Trinket API, item usage, copy parameter support, aura cast events + ["GetTrinkets"] = { 2, 20, 0, "GetTrinkets" }, + ["GetTrinketCooldown"] = { 2, 20, 0, "GetTrinketCooldown" }, + ["UseTrinket"] = { 2, 20, 0, "UseTrinket" }, + ["UseItemIdOrName"] = { 2, 20, 0, "UseItemIdOrName" }, + ["CopyParameter"] = { 2, 20, 0 }, -- Table copy support + ["AuraCastEvents"] = { 2, 20, 0 }, -- AURA_CAST_ON_SELF/OTHER events + + -- v2.22+ - Utility functions + ["DisenchantAll"] = { 2, 22, 0, "DisenchantAll" }, + + -- v2.23+ - Enhanced bag queries, cooldown item metadata + ["GetBagItemsWithIndex"] = { 2, 23, 0 }, -- GetBagItems(bagIndex) parameter + ["CooldownItemMetadata"] = { 2, 23, 0 }, -- itemId, itemHasActiveSpell, itemActiveSpellId + + -- 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 + + -- v2.27+ - Texture lookup functions + ["GetItemIconTexture"] = { 2, 27, 0, "GetItemIconTexture" }, + ["GetSpellIconTexture"] = { 2, 27, 0, "GetSpellIconTexture" }, +} + +-- Check if a specific feature is available +-- Uses both version check AND function existence verification +function API.HasFeature(featureName) + local req = API.VERSION_REQUIREMENTS[featureName] + if not req then + return false + end + + -- Check version requirement + if not API.HasMinimumVersion(req[1], req[2], req[3]) then + return false + end + + -- If a global function is specified, verify it exists + if req[4] then + return _G[req[4]] ~= nil + end + + return true +end + +-- Get the minimum version required for a feature +-- Returns major, minor, patch or nil if feature unknown +function API.GetFeatureVersion(featureName) + local req = API.VERSION_REQUIREMENTS[featureName] + if req then + return req[1], req[2], req[3] + end + return nil +end + +-- Feature flags (populated during initialization) +-- These provide quick boolean lookups after init +API.features = {} + +-- Initialize all feature flags based on version detection +local function InitializeFeatures() + local f = API.features + + -- Initialize version cache first + API.GetVersion() + + -- Base Nampower detection (v2.0+) + f.hasNampower = API.HasFeature("SpellQueue") + f.hasSpellQueue = f.hasNampower + f.hasGetCurrentCastingInfo = API.HasFeature("GetCurrentCastingInfo") + + -- v2.8+ DBC data access + f.hasGetSpellRec = API.HasFeature("GetSpellRec") + f.hasGetSpellRecField = API.HasFeature("GetSpellRecField") + f.hasGetItemStats = API.HasFeature("GetItemStats") + f.hasGetItemStatsField = API.HasFeature("GetItemStatsField") + f.hasGetUnitData = API.HasFeature("GetUnitData") + f.hasGetUnitField = API.HasFeature("GetUnitField") + f.hasGetSpellModifiers = API.HasFeature("GetSpellModifiers") + + -- v2.11+ GetNampowerVersion + f.hasGetNampowerVersion = API.HasFeature("GetNampowerVersion") + + -- v2.12+ Enhanced spell functions + f.hasEnhancedSpellFunctions = API.HasFeature("EnhancedSpellFunctions") + f.hasIsSpellInRange = API.HasFeature("IsSpellInRange") + f.hasIsSpellUsable = API.HasFeature("IsSpellUsable") + f.hasGetSpellIdForName = API.HasFeature("GetSpellIdForName") + f.hasGetSpellNameAndRankForId = API.HasFeature("GetSpellNameAndRankForId") + f.hasGetSpellSlotTypeIdForName = API.HasFeature("GetSpellSlotTypeIdForName") + f.hasCastSpellByNameNoQueue = API.HasFeature("CastSpellByNameNoQueue") + f.hasQueueScript = API.HasFeature("QueueScript") + + -- v2.13+ QueueSpellByName with target + f.hasQueueSpellWithTarget = API.HasFeature("QueueSpellWithTarget") + + -- v2.14+ GetItemLevel + f.hasGetItemLevel = API.HasFeature("GetItemLevel") + + -- v2.18+ Inventory/Equipment/Cast info + f.hasFindPlayerItemSlot = API.HasFeature("FindPlayerItemSlot") + f.hasGetEquippedItems = API.HasFeature("GetEquippedItems") + f.hasGetEquippedItem = API.HasFeature("GetEquippedItem") + f.hasGetBagItems = API.HasFeature("GetBagItems") + f.hasGetBagItem = API.HasFeature("GetBagItem") + f.hasGetCastInfo = API.HasFeature("GetCastInfo") + f.hasGetSpellIdCooldown = API.HasFeature("GetSpellIdCooldown") + f.hasGetItemIdCooldown = API.HasFeature("GetItemIdCooldown") + f.hasChannelStopCastingNextTick = API.HasFeature("ChannelStopCastingNextTick") + + -- v2.20+ Trinket/Item API and Aura Cast Events + f.hasGetTrinkets = API.HasFeature("GetTrinkets") + f.hasGetTrinketCooldown = API.HasFeature("GetTrinketCooldown") + f.hasUseTrinket = API.HasFeature("UseTrinket") + f.hasUseItemIdOrName = API.HasFeature("UseItemIdOrName") + f.hasCopyParameter = API.HasFeature("CopyParameter") + f.hasAuraCastEvents = API.HasFeature("AuraCastEvents") + + -- v2.22+ Utility functions + f.hasDisenchantAll = API.HasFeature("DisenchantAll") + + -- v2.23+ Enhanced features + f.hasGetBagItemsWithIndex = API.HasFeature("GetBagItemsWithIndex") + f.hasCooldownItemMetadata = API.HasFeature("CooldownItemMetadata") + + -- 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") + + -- v2.27+ Texture lookup functions + f.hasGetItemIconTexture = API.HasFeature("GetItemIconTexture") + f.hasGetSpellIconTexture = API.HasFeature("GetSpellIconTexture") + + -- Runtime detection for enhanced spell functions (verify by testing) + if f.hasEnhancedSpellFunctions and GetSpellTexture then + local success, result = pcall(function() + return GetSpellTexture("Attack") + end) + f.hasEnhancedSpellFunctions = success and (result ~= nil) + end +end + +-- Legacy function for compatibility (now just calls InitializeFeatures) +local function DetectEnhancedSpellFunctions() + return API.features.hasEnhancedSpellFunctions +end + +-- Initialize features immediately on load +InitializeFeatures() + +-------------------------------------------------------------------------------- +-- NAMPOWER SETTINGS ACCESS +-------------------------------------------------------------------------------- + +-- Cache for settings to avoid repeated lookups +API.settingsCache = {} +API.settingsCacheTime = 0 +API.SETTINGS_CACHE_DURATION = 5 -- Refresh every 5 seconds + +-- All known Nampower CVars with their defaults +API.defaultSettings = { + NP_QueueCastTimeSpells = "1", + NP_QueueInstantSpells = "1", + NP_QueueChannelingSpells = "1", + NP_QueueTargetingSpells = "1", + NP_QueueOnSwingSpells = "0", + NP_QueueSpellsOnCooldown = "1", + NP_InterruptChannelsOutsideQueueWindow = "0", + NP_SpellQueueWindowMs = "500", + NP_OnSwingBufferCooldownMs = "500", + NP_ChannelQueueWindowMs = "1500", + NP_TargetingQueueWindowMs = "500", + NP_CooldownQueueWindowMs = "250", + NP_MinBufferTimeMs = "55", + NP_NonGcdBufferTimeMs = "100", + NP_MaxBufferIncreaseMs = "30", + NP_RetryServerRejectedSpells = "1", + NP_QuickcastTargetingSpells = "0", + NP_QuickcastOnDoubleCast = "0", -- v2.18+: cast targeting spells by double-casting + NP_ReplaceMatchingNonGcdCategory = "0", + NP_OptimizeBufferUsingPacketTimings = "0", + NP_PreventRightClickTargetChange = "0", + NP_PreventRightClickPvPAttack = "1", + NP_DoubleCastToEndChannelEarly = "0", + NP_SpamProtectionEnabled = "1", + NP_ChannelLatencyReductionPercentage = "75", + NP_NameplateDistance = "41", + -- v2.20+ CVars + NP_PreventMountingWhenBuffCapped = "1", -- Prevent mounting when buff capped (32 buffs) + 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 +-- Priority: NampowerSettings addon (per-character) > CVar > default +function API.GetSetting(settingName) + -- Check if NampowerSettings addon is loaded and has per-character settings enabled + if Nampower and Nampower.db and Nampower.db.profile then + if Nampower.db.profile.per_character_settings and Nampower.db.profile[settingName] ~= nil then + return Nampower.db.profile[settingName] + end + end + + -- Fall back to CVar + local cvarValue = GetCVar(settingName) + if cvarValue then + return cvarValue + end + + -- Return default if known + return API.defaultSettings[settingName] +end + +-- Get a Nampower setting as a boolean +function API.GetSettingBool(settingName) + local value = API.GetSetting(settingName) + if type(value) == "boolean" then + return value + end + return value == "1" or value == true +end + +-- Get a Nampower setting as a number +function API.GetSettingNumber(settingName) + local value = API.GetSetting(settingName) + return tonumber(value) or 0 +end + +-- Check if spell queuing is enabled for a given spell type +function API.IsQueueingEnabled(spellType) + if not API.features.hasNampower then + return false + end + + if spellType == "cast" or spellType == "casttime" then + return API.GetSettingBool("NP_QueueCastTimeSpells") + elseif spellType == "instant" or spellType == "gcd" then + return API.GetSettingBool("NP_QueueInstantSpells") + elseif spellType == "channel" or spellType == "channeling" then + return API.GetSettingBool("NP_QueueChannelingSpells") + elseif spellType == "targeting" or spellType == "aoe" then + return API.GetSettingBool("NP_QueueTargetingSpells") + elseif spellType == "onswing" or spellType == "swing" then + return API.GetSettingBool("NP_QueueOnSwingSpells") + elseif spellType == "cooldown" then + return API.GetSettingBool("NP_QueueSpellsOnCooldown") + end + + -- Default: all queuing enabled + return API.GetSettingBool("NP_QueueCastTimeSpells") +end + +-- Check if ANY spell queuing is enabled +function API.IsAnyQueueingEnabled() + if not API.features.hasNampower then + return false + end + + -- Check all spell type queuing settings + return API.GetSettingBool("NP_QueueCastTimeSpells") + or API.GetSettingBool("NP_QueueInstantSpells") + or API.GetSettingBool("NP_QueueChannelingSpells") + or API.GetSettingBool("NP_QueueOnSwingSpells") +end + +-- Get the queue window for a given spell type (in seconds) +function API.GetQueueWindow(spellType) + local ms = 500 -- default + + if spellType == "channel" or spellType == "channeling" then + ms = API.GetSettingNumber("NP_ChannelQueueWindowMs") + elseif spellType == "targeting" or spellType == "aoe" then + ms = API.GetSettingNumber("NP_TargetingQueueWindowMs") + elseif spellType == "cooldown" then + ms = API.GetSettingNumber("NP_CooldownQueueWindowMs") + elseif spellType == "onswing" or spellType == "swing" then + ms = API.GetSettingNumber("NP_OnSwingBufferCooldownMs") + else + ms = API.GetSettingNumber("NP_SpellQueueWindowMs") + end + + return ms / 1000 -- Convert to seconds +end + +-------------------------------------------------------------------------------- +-- SPELL RECORD API (GetSpellRec / GetSpellRecField) +-------------------------------------------------------------------------------- + +-- Cache for spell records +API.spellRecCache = {} + +-- Get full spell record data +-- Returns nil if spell not found or API unavailable (requires v2.8+) +-- Pass copy=1 to get an independent copy safe for storage (v2.20+) +-- WARNING: Without copy, table references are reused - extract values immediately! +function API.GetSpellRecord(spellId, copy) + if not spellId or spellId == 0 then return nil end + + -- Requires v2.8+ for GetSpellRec + if not API.features.hasGetSpellRec then + return nil + end + + -- Check cache first (only for non-copy requests) + if not copy and API.spellRecCache[spellId] then + return API.spellRecCache[spellId] + end + + -- Copy parameter requires v2.20+ + -- When caching (not copy), also request copy=1 if available for safe storage + -- This prevents issues with Nampower's table reuse + local useCopy = nil + if API.features.hasCopyParameter then + useCopy = copy or 1 -- Use caller's copy value, or 1 for caching + end + + -- Use native function + if GetSpellRec then + local rec = GetSpellRec(spellId, useCopy) + if rec then + if not copy then + API.spellRecCache[spellId] = rec + end + return rec + end + end + + return nil +end + +-- Get a specific field from spell record +-- Returns nil if not found (requires v2.8+) +-- Pass copy=1 to get an independent copy for array fields (v2.20+) +function API.GetSpellField(spellId, fieldName, copy) + if not spellId or spellId == 0 then return nil end + + -- Requires v2.8+ for GetSpellRecField + if not API.features.hasGetSpellRecField then + return nil + end + + -- Copy parameter requires v2.20+ + local useCopy = copy and API.features.hasCopyParameter and copy or nil + + -- Use native function (more efficient for single field) + if GetSpellRecField then + local success, result = pcall(GetSpellRecField, spellId, fieldName, useCopy) + if success then + return result + end + return nil + end + + -- Fallback to full record lookup + local rec = API.GetSpellRecord(spellId, copy) + if rec then + return rec[fieldName] + end + + return nil +end + +-- Get spell name by ID (enhanced, uses new API when available) +function API.GetSpellNameById(spellId) + if not spellId or spellId == 0 then return nil end + + -- Try GetSpellNameAndRankForId first (Nampower function) + if GetSpellNameAndRankForId then + local name, rank = GetSpellNameAndRankForId(spellId) + if name then return name, rank end + end + + -- Fall back to SpellInfo (SuperWoW) + if SpellInfo then + local name = SpellInfo(spellId) + if name then return name end + end + + -- Fall back to GetSpellRecField + if GetSpellRecField then + local name = GetSpellRecField(spellId, "name") + local rank = GetSpellRecField(spellId, "rank") + if name then return name, rank end + end + + return nil +end + +-- Get spell cast time in seconds +function API.GetSpellCastTime(spellId) + local castTime = API.GetSpellField(spellId, "castTime") + if castTime then + return castTime / 1000 -- Convert from ms to seconds + end + return nil +end + +-- Get spell school (0=Physical, 1=Holy, 2=Fire, 3=Nature, 4=Frost, 5=Shadow, 6=Arcane) +function API.GetSpellSchool(spellId) + return API.GetSpellField(spellId, "school") +end + +-- Get spell mana cost +function API.GetSpellManaCost(spellId) + return API.GetSpellField(spellId, "manaCost") +end + +-- SpellRange.dbc lookup table: rangeIndex -> maxRange (in yards) +-- From vanilla 1.12.1 client data (corrected based on actual spell ranges) +API.SpellRangeTable = { + [0] = 0, -- Self Only + [1] = 5, -- Combat Range (Melee) + [2] = 30, -- 30 yard range (Frostbolt, etc.) + [3] = 35, -- 35 yard range + [4] = 30, -- 30 yard range (Arcane Missiles, etc.) + [5] = 40, -- 40 yard range + [6] = 45, -- 45 yard range + [7] = 100, -- Vision Range + [8] = 20, -- 20 yard range + [9] = 10, -- 10 yard range + [10] = 8, -- 8 yard range + [11] = 15, -- 15 yard range (Charge) + [12] = 25, -- 25 yard range + [13] = 100, -- Anywhere/Unlimited + [14] = 0, -- Self Only (alternate) + [15] = 80, -- 80 yard range (hunters) + [16] = 18, -- 18 yard range + [17] = 60, -- 60 yard range + [18] = 5, -- Melee (alternate) + [19] = 25, -- 25 yard range (alternate) + [20] = 30, -- 30 yard range (alternate) + [21] = 35, -- 35 yard range (alternate) + [22] = 40, -- 40 yard range (alternate) + [23] = 0, -- Touch + [24] = 41, -- 41 yard range + [25] = 10, -- 10 yard range (alternate) + [26] = 50, -- 50 yard range + [27] = 55, -- 55 yard range + [28] = 65, -- 65 yard range + [29] = 70, -- 70 yard range + [30] = 50000, -- Unlimited + [31] = 8, -- 8 yard range (alternate) + [32] = 7, -- 7 yard range + [33] = 11, -- 11 yard range + [34] = 12, -- 12 yard range + [35] = 28, -- 28 yard range + [36] = 6, -- 6 yard range + [37] = 13, -- 13 yard range + [38] = 15, -- 15 yard range (alternate) + [39] = 100, -- 100 yard range (alternate) + [40] = 150, -- 150 yard range +} + +-- Unit target types that require distance checking (from DBC) +-- These target OTHER units, not self - so distance matters +-- Excludes TARGET_UNIT_CASTER (1) which is self-cast and always in range +local UNIT_TARGET_TYPES_NEED_RANGE = { + [5] = true, -- TARGET_UNIT_PET + [6] = true, -- TARGET_UNIT_TARGET_ENEMY + [21] = true, -- TARGET_UNIT_TARGET_ALLY + [22] = true, -- TARGET_UNIT_PARTY + [23] = true, -- TARGET_UNIT_PARTY_AROUND_CASTER + [25] = true, -- TARGET_UNIT_PET (alternate) + [38] = true, -- TARGET_UNIT_TARGET_ANY +} + +-- Helper to check a target value or array for unit targeting +local function checkTargetForUnitType(target) + if not target then return nil end + + -- If it's a table (array of 3 effect targets), check each element + if type(target) == "table" then + for i = 1, 3 do + local val = target[i] + if val and UNIT_TARGET_TYPES_NEED_RANGE[val] then + return true -- Found a unit-targeting effect + end + end + -- Check if any element has a value (even if not unit-targeting) + for i = 1, 3 do + if target[i] and target[i] ~= 0 then + return false -- Has target data but not unit-targeting + end + end + return nil + end + + -- If it's a number, check directly + if type(target) == "number" then + if UNIT_TARGET_TYPES_NEED_RANGE[target] then + return true + end + if target ~= 0 then + return false -- Has target data but not unit-targeting + end + end + + return nil +end + +-- Check if a spell requires distance checking to another unit +-- Returns true if the spell targets other units (needs range check) +-- Returns false if self-cast, ground-targeted, or area effect (always "in range") +-- Returns nil if unknown +function API.IsUnitTargetedSpell(spellId) + if not spellId or spellId == 0 then return nil end + + -- Check effectImplicitTargetA + local targetA = API.GetSpellField(spellId, "effectImplicitTargetA") + local resultA = checkTargetForUnitType(targetA) + if resultA == true then + return true -- Targets other units, needs range check + end + + -- Also check targetB + local targetB = API.GetSpellField(spellId, "effectImplicitTargetB") + local resultB = checkTargetForUnitType(targetB) + if resultB == true then + return true -- Targets other units, needs range check + end + + -- If either returned false (has data but not unit-targeting), spell doesn't need range check + if resultA == false or resultB == false then + return false + end + + return nil -- Unknown +end + +-- Get spell range (max range in yards) +function API.GetSpellRange(spellId) + if not spellId or spellId == 0 then return nil end + + -- First try rangeMax (Nampower may provide this as a resolved field) + local rangeMax = API.GetSpellField(spellId, "rangeMax") + if rangeMax and rangeMax > 0 then + return rangeMax / 10 -- Convert from game units to yards + end + + -- Fallback: lookup rangeIndex in SpellRange table + local rangeIndex = API.GetSpellField(spellId, "rangeIndex") + if rangeIndex then + local range = API.SpellRangeTable[rangeIndex] + if range then + return range + end + end + + return nil +end + +-- Get spell cooldown from record (base cooldown, not current) +function API.GetSpellBaseCooldown(spellId) + local recoveryTime = API.GetSpellField(spellId, "recoveryTime") + if recoveryTime then + return recoveryTime / 1000 -- Convert from ms to seconds + end + return nil +end + +-------------------------------------------------------------------------------- +-- ITEM STATS API (GetItemStats / GetItemStatsField) +-------------------------------------------------------------------------------- + +-- Cache for item stats +API.itemStatsCache = {} + +-- Get full item stats data +-- Requires v2.8+ for GetItemStats +-- Pass copy=1 to get an independent copy safe for storage (v2.20+) +-- WARNING: Without copy, table references are reused - extract values immediately! +function API.GetItemRecord(itemId, copy) + if not itemId or itemId == 0 then return nil end + + -- Requires v2.8+ for GetItemStats + if not API.features.hasGetItemStats then + return nil + end + + -- Check cache first (only for non-copy requests) + if not copy and API.itemStatsCache[itemId] then + return API.itemStatsCache[itemId] + end + + -- Copy parameter requires v2.20+ + -- When caching (not copy), also request copy=1 if available for safe storage + -- This prevents issues with Nampower's table reuse + local useCopy = nil + if API.features.hasCopyParameter then + useCopy = copy or 1 -- Use caller's copy value, or 1 for caching + end + + -- Use native function + if GetItemStats then + local ok, stats = pcall(GetItemStats, itemId, useCopy) + if ok and stats then + if not copy then + API.itemStatsCache[itemId] = stats + end + return stats + end + end + + return nil +end + +-- Get a specific field from item stats +-- Requires v2.8+ for GetItemStatsField +-- Pass copy=1 to get an independent copy for array fields (v2.20+) +function API.GetItemField(itemId, fieldName, copy) + if not itemId or itemId == 0 then return nil end + + -- Requires v2.8+ for GetItemStatsField + if not API.features.hasGetItemStatsField then + return nil + end + + -- Copy parameter requires v2.20+ + local useCopy = copy and API.features.hasCopyParameter and copy or nil + + -- Use native function (more efficient) + if GetItemStatsField then + local success, result = pcall(GetItemStatsField, itemId, fieldName, useCopy) + if success then + return result + end + return nil + end + + -- Fallback to full record lookup + local stats = API.GetItemRecord(itemId, copy) + if stats then + return stats[fieldName] + end + + return nil +end + +-- Get item level +-- Uses standalone GetItemLevel (v2.14+) or falls back to GetItemStatsField (v2.8+) +function API.GetItemLevel(itemId) + -- Use dedicated function if available (v2.14+) + if API.features.hasGetItemLevel and GetItemLevel then + local success, result = pcall(GetItemLevel, itemId) + if success then return result end + end + + -- Fallback to GetItemField (v2.8+) + return API.GetItemField(itemId, "itemLevel") +end + +-- Get item name +function API.GetItemName(itemId) + return API.GetItemField(itemId, "displayName") +end + +-- Get item quality (0=Poor, 1=Common, 2=Uncommon, 3=Rare, 4=Epic, 5=Legendary) +function API.GetItemQuality(itemId) + return API.GetItemField(itemId, "quality") +end + +-- Get weapon speed in seconds +function API.GetWeaponSpeed(itemId) + local delay = API.GetItemField(itemId, "delay") + if delay then + return delay / 1000 + end + return nil +end + +-- Get item inventory type (equipment slot) +-- Returns: inventoryType number, or nil if not equippable +-- Common types: 0=Non-equip, 1=Head, 2=Neck, 3=Shoulder, 4=Shirt, 5=Chest, +-- 6=Waist, 7=Legs, 8=Feet, 9=Wrist, 10=Hands, 11=Finger, 12=Trinket, +-- 13=One-Hand, 14=Shield, 15=Ranged, 16=Back, 17=Two-Hand, +-- 18=Bag, 20=Robe, 21=Main Hand, 22=Off Hand, 23=Holdable, +-- 24=Ammo, 25=Thrown, 26=Ranged Right, 28=Relic +function API.GetItemInventoryType(itemId) + local ok, result = pcall(API.GetItemField, itemId, "inventoryType") + if ok then return result end + return nil +end + +-- Convert inventoryType to equipment slot ID(s) +-- Returns slotId, altSlotId (for rings/trinkets) +API.inventoryTypeToSlot = { + [1] = 1, -- Head -> HeadSlot + [2] = 2, -- Neck -> NeckSlot + [3] = 3, -- Shoulder -> ShoulderSlot + [4] = 4, -- Shirt -> ShirtSlot + [5] = 5, -- Chest -> ChestSlot + [6] = 6, -- Waist -> WaistSlot + [7] = 7, -- Legs -> LegsSlot + [8] = 8, -- Feet -> FeetSlot + [9] = 9, -- Wrist -> WristSlot + [10] = 10, -- Hands -> HandsSlot + [11] = {11, 12}, -- Finger -> Finger0Slot or Finger1Slot + [12] = {13, 14}, -- Trinket -> Trinket0Slot or Trinket1Slot + [13] = 16, -- One-Hand -> MainHandSlot (can also go off-hand) + [14] = 17, -- Shield -> SecondaryHandSlot + [15] = 18, -- Ranged -> RangedSlot + [16] = 15, -- Back -> BackSlot + [17] = 16, -- Two-Hand -> MainHandSlot + [20] = 5, -- Robe -> ChestSlot + [21] = 16, -- Main Hand -> MainHandSlot + [22] = 17, -- Off Hand -> SecondaryHandSlot + [23] = 17, -- Holdable -> SecondaryHandSlot + [24] = 0, -- Ammo -> AmmoSlot + [25] = 18, -- Thrown -> RangedSlot + [26] = 18, -- Ranged Right -> RangedSlot + [28] = 18, -- Relic -> RangedSlot +} + +-- Get equipment slot ID for an item +-- Returns slotId, altSlotId for items that can go in multiple slots +function API.GetItemEquipSlot(itemId) + local ok, invType = pcall(API.GetItemInventoryType, itemId) + if not ok or not invType or invType == 0 then return nil end + + local slotInfo = API.inventoryTypeToSlot[invType] + if type(slotInfo) == "table" then + return slotInfo[1], slotInfo[2] -- Primary and alt slot + end + return slotInfo, nil +end + +-------------------------------------------------------------------------------- +-- UNIT DATA API (GetUnitData / GetUnitField) - Requires v2.8+ +-------------------------------------------------------------------------------- + +-- Get full unit data (no caching - unit data changes frequently) +-- Requires v2.8+ for GetUnitData +-- Pass copy=1 to get an independent copy safe for storage (v2.20+) +-- WARNING: Without copy, table references are reused - extract values immediately! +function API.GetUnitRecord(unitToken, copy) + if not unitToken then return nil end + + -- Requires v2.8+ for GetUnitData + if not API.features.hasGetUnitData then + return nil + end + + -- Copy parameter requires v2.20+ + local useCopy = copy and API.features.hasCopyParameter and copy or nil + + if GetUnitData then + return GetUnitData(unitToken, useCopy) + end + + return nil +end + +-- Get a specific unit field +-- Requires v2.8+ for GetUnitField +-- Pass copy=1 to get an independent copy for array fields (v2.20+) +function API.GetUnitFieldValue(unitToken, fieldName, copy) + if not unitToken then return nil end + + -- Requires v2.8+ for GetUnitField + if not API.features.hasGetUnitField then + return nil + end + + -- Copy parameter requires v2.20+ + local useCopy = copy and API.features.hasCopyParameter and copy or nil + + -- Use native function (more efficient) + if GetUnitField then + local success, result = pcall(GetUnitField, unitToken, fieldName, useCopy) + if success then + return result + end + return nil + end + + -- Fallback to full record + local data = API.GetUnitRecord(unitToken, copy) + if data then + return data[fieldName] + end + + return nil +end + +-- Get unit's current auras as spell IDs +-- Pass copy=1 to get an independent copy safe for storage (v2.20+) +function API.GetUnitAuras(unitToken, copy) + return API.GetUnitFieldValue(unitToken, "aura", copy) +end + +-- Get unit resistances table +-- Pass copy=1 to get an independent copy safe for storage (v2.20+) +function API.GetUnitResistances(unitToken, copy) + return API.GetUnitFieldValue(unitToken, "resistances", copy) +end + +-- Get specific resistance value +-- school: 1=Armor, 2=Holy, 3=Fire, 4=Nature, 5=Frost, 6=Shadow, 7=Arcane +function API.GetUnitResistance(unitToken, school) + local resistances = API.GetUnitResistances(unitToken) + if resistances and school then + return resistances[school] + end + return nil +end + +-- Check if unit has a specific aura by spell ID +function API.UnitHasAura(unitToken, spellId) + local auras = API.GetUnitAuras(unitToken) + if not auras or not spellId then return false end + + for _, auraId in ipairs(auras) do + if auraId == spellId then + return true + end + end + return false +end + +-------------------------------------------------------------------------------- +-- INVENTORY & EQUIPMENT API (v2.18+) +-------------------------------------------------------------------------------- + +-- Find an item in player's inventory by ID or name +-- Requires v2.18+ for native FindPlayerItemSlot (with Lua fallback) +-- Returns: bagIndex, slot (see wiki for bag index meanings) +-- bagIndex nil + slot = equipped item (slot is equipment slot 0-18) +-- bagIndex + slot = item in bag +-- nil, nil = item not found +function API.FindPlayerItemSlot(itemIdOrName) + if not itemIdOrName then return nil, nil end + + -- Use native function if available (v2.18+) + if API.features.hasFindPlayerItemSlot and FindPlayerItemSlot then + return FindPlayerItemSlot(itemIdOrName) + end + + -- Fallback: manual search for older versions + -- This is much slower but works without Nampower 2.18 + local itemName = itemIdOrName + if type(itemIdOrName) == "number" then + -- Try to get item name from ID + itemName = API.GetItemName(itemIdOrName) + if not itemName then + -- Try GetItemInfo as fallback (only works for cached items) + itemName = GetItemInfo(itemIdOrName) + end + if not itemName then + return nil, nil -- Can't resolve item ID + end + end + + -- Search equipped items first (WoW API uses 1-19 for inventory slots) + for slot = 1, 19 do + local link = GetInventoryItemLink("player", slot) + if link then + local name = GetItemInfo(link) + if name and string.lower(name) == string.lower(itemName) then + return nil, slot -- Equipped (1-indexed, matches Nampower behavior) + end + end + end + + -- Search bags (0 = backpack, 1-4 = bags) + for bag = 0, 4 do + local numSlots = GetContainerNumSlots(bag) + for slot = 1, numSlots do + local link = GetContainerItemLink(bag, slot) + if link then + local name = GetItemInfo(link) + if name and string.lower(name) == string.lower(itemName) then + return bag, slot + end + end + end + end + + return nil, nil -- Not found +end + +-- Get all equipped items for a unit +-- Requires v2.18+ for native GetEquippedItems (with Lua fallback for player only) +-- Returns table with slot indices (0-18) as keys, item info tables as values +function API.GetEquippedItems(unitToken) + unitToken = unitToken or "player" + + -- Use native function if available (v2.18+) + if API.features.hasGetEquippedItems and GetEquippedItems then + return GetEquippedItems(unitToken) + end + + -- Fallback: manual lookup for player only + if unitToken ~= "player" then + return nil -- Can't inspect other units without native API + end + + local items = {} + for slot = 0, 18 do + local link = GetInventoryItemLink("player", slot + 1) + if link then + local _, _, itemId = string.find(link, "item:(%d+)") + if itemId then + items[slot] = { + itemId = tonumber(itemId), + -- Other fields not available without native API + } + end + end + end + + return items +end + +-- Get equipped item info for a specific slot +-- Requires v2.18+ for native GetEquippedItem (with Lua fallback for player only) +-- Slot numbers: 1=Head, 2=Neck, 3=Shoulder... 16=MainHand, 17=OffHand, 18=Ranged +function API.GetEquippedItem(unitToken, slot) + unitToken = unitToken or "player" + + -- Use native function if available (v2.18+) + if API.features.hasGetEquippedItem and GetEquippedItem then + return GetEquippedItem(unitToken, slot) + end + + -- Fallback: manual lookup for player + if unitToken ~= "player" then + return nil + end + + local link = GetInventoryItemLink("player", slot + 1) -- 1-indexed + if link then + local _, _, itemId = string.find(link, "item:(%d+)") + if itemId then + return { + itemId = tonumber(itemId), + } + end + end + + return nil +end + +-- Get all items in bags, or items in a specific bag +-- Requires v2.18+ for native GetBagItems (with Lua fallback) +-- bagIndex parameter requires v2.23+ +-- If bagIndex is nil: returns nested table: bagIndex -> { slot -> itemInfo } +-- If bagIndex is specified: returns single table: slot -> itemInfo for that bag only +-- Bag indices: 0=backpack, 1-4=bags, -1=bank slots, 5-10=bank bags, -2=keyring +function API.GetBagItems(bagIndex) + -- Use native function if available (v2.18+) + if API.features.hasGetBagItems and GetBagItems then + -- bagIndex parameter requires v2.23+ + if bagIndex ~= nil and not API.features.hasGetBagItemsWithIndex then + -- Fall through to Lua fallback for single-bag query on older versions + else + return GetBagItems(bagIndex) + end + end + + -- Fallback: manual enumeration + if bagIndex ~= nil then + -- Single bag query + local bagContents = {} + local numSlots = GetContainerNumSlots(bagIndex) or 0 + for slot = 1, numSlots do + local link = GetContainerItemLink(bagIndex, slot) + if link then + local _, _, itemId = string.find(link, "item:(%d+)") + local _, count = GetContainerItemInfo(bagIndex, slot) + if itemId then + bagContents[slot] = { + itemId = tonumber(itemId), + stackCount = count or 1, + } + end + end + end + return bagContents + end + + -- All bags query + local bags = {} + for bag = 0, 4 do + local numSlots = GetContainerNumSlots(bag) + if numSlots > 0 then + bags[bag] = {} + for slot = 1, numSlots do + local link = GetContainerItemLink(bag, slot) + if link then + local _, _, itemId = string.find(link, "item:(%d+)") + local _, count = GetContainerItemInfo(bag, slot) + if itemId then + bags[bag][slot] = { + itemId = tonumber(itemId), + stackCount = count or 1, + } + end + end + end + end + end + + return bags +end + +-- Get item info for a specific bag slot +-- Requires v2.18+ for native GetBagItem (with Lua fallback) +function API.GetBagItem(bagIndex, slot) + -- Use native function if available (v2.18+) + if API.features.hasGetBagItem and GetBagItem then + return GetBagItem(bagIndex, slot) + end + + -- Fallback: manual lookup + local link = GetContainerItemLink(bagIndex, slot) + if link then + local _, _, itemId = string.find(link, "item:(%d+)") + local _, count = GetContainerItemInfo(bagIndex, slot) + if itemId then + return { + itemId = tonumber(itemId), + stackCount = count or 1, + } + end + end + + return nil +end + +-------------------------------------------------------------------------------- +-- OPTIMIZED ITEM FINDING (v2.18+) +-- These functions provide fast item lookup compatible with CleveRoids.Items format +-------------------------------------------------------------------------------- + +-- Fast item lookup using v2.18 FindPlayerItemSlot +-- Returns item info table compatible with CleveRoids.Items format: +-- { name, itemId, inventoryID } for equipped items +-- { name, itemId, bagID, slot } for bag items +-- Returns nil if item not found +function API.FindItemFast(itemIdOrName) + if not itemIdOrName then return nil end + + -- Use native v2.18 function if available (much faster than Lua scan) + if FindPlayerItemSlot then + local bag, slot = FindPlayerItemSlot(itemIdOrName) + + if slot then + local itemInfo = {} + + if bag == nil then + -- Equipped item - Nampower returns 1-indexed slot directly + -- (matches WoW API's GetInventoryItemLink which is also 1-indexed) + local invSlot = slot + local link = GetInventoryItemLink("player", invSlot) + if link then + local _, _, itemId = string.find(link, "item:(%d+)") + local _, _, name = string.find(link, "|h%[(.-)%]|h") + itemInfo.inventoryID = invSlot + itemInfo.itemId = tonumber(itemId) + itemInfo.name = name + itemInfo._source = "nampower_equipped" + return itemInfo + end + else + -- Bag item + local link = GetContainerItemLink(bag, slot) + if link then + local _, _, itemId = string.find(link, "item:(%d+)") + local _, _, name = string.find(link, "|h%[(.-)%]|h") + local _, count = GetContainerItemInfo(bag, slot) + itemInfo.bagID = bag + itemInfo.slot = slot + itemInfo.itemId = tonumber(itemId) + itemInfo.name = name + itemInfo.count = count or 1 + itemInfo._source = "nampower_bag" + return itemInfo + end + end + end + + return nil -- Not found via native lookup + end + + -- Fallback: no v2.18 API, return nil (caller should use existing methods) + return nil +end + +-- Check if an item is equipped in a specific slot (fast) +-- Returns true if item matches by ID or name +function API.IsItemInSlot(itemIdOrName, inventorySlot) + if not itemIdOrName or not inventorySlot then return false end + + -- Use native v2.18 GetEquippedItem if available + if GetEquippedItem then + local slotInfo = GetEquippedItem("player", inventorySlot - 1) -- 0-indexed + if slotInfo and slotInfo.itemId then + local checkId = tonumber(itemIdOrName) + if checkId then + return slotInfo.itemId == checkId + else + -- Check by name + local itemName = API.GetItemName(slotInfo.itemId) + if itemName then + return string.lower(itemName) == string.lower(itemIdOrName) + end + end + end + return false + end + + -- Fallback: use GetInventoryItemLink + local link = GetInventoryItemLink("player", inventorySlot) + if not link then return false end + + local checkId = tonumber(itemIdOrName) + if checkId then + local _, _, currentId = string.find(link, "item:(%d+)") + return currentId and tonumber(currentId) == checkId + else + local _, _, currentName = string.find(link, "|h%[(.-)%]|h") + return currentName and string.lower(currentName) == string.lower(itemIdOrName) + end +end + +-- Check if item is equipped anywhere (fast) +-- Returns inventorySlot if equipped, nil otherwise +function API.FindEquippedItem(itemIdOrName) + if not itemIdOrName then return nil end + + -- Use native v2.18 FindPlayerItemSlot if available + if FindPlayerItemSlot then + local bag, slot = FindPlayerItemSlot(itemIdOrName) + if bag == nil and slot then + return slot -- Nampower returns 1-indexed slot directly + end + return nil -- Not equipped (might be in bag or not found) + end + + -- Fallback: scan equipped slots + local checkId = tonumber(itemIdOrName) + local checkName = (not checkId) and string.lower(itemIdOrName) or nil + + for invSlot = 1, 19 do + local link = GetInventoryItemLink("player", invSlot) + if link then + if checkId then + local _, _, currentId = string.find(link, "item:(%d+)") + if currentId and tonumber(currentId) == checkId then + return invSlot + end + elseif checkName then + local _, _, currentName = string.find(link, "|h%[(.-)%]|h") + if currentName and string.lower(currentName) == checkName then + return invSlot + end + end + end + end + + return nil +end + +-- Find item in bags only (not equipped) - fast +-- Returns bag, slot if found, nil otherwise +function API.FindBagItem(itemIdOrName) + if not itemIdOrName then return nil, nil end + + -- Use native v2.18 FindPlayerItemSlot if available + if FindPlayerItemSlot then + local bag, slot = FindPlayerItemSlot(itemIdOrName) + if bag ~= nil and slot then + return bag, slot + end + return nil, nil -- Not in bags (might be equipped or not found) + end + + -- Fallback: scan bags + local checkId = tonumber(itemIdOrName) + local checkName = (not checkId) and string.lower(itemIdOrName) or nil + + for bag = 0, 4 do + local numSlots = GetContainerNumSlots(bag) or 0 + for slot = 1, numSlots do + local link = GetContainerItemLink(bag, slot) + if link then + if checkId then + local _, _, currentId = string.find(link, "item:(%d+)") + if currentId and tonumber(currentId) == checkId then + return bag, slot + end + elseif checkName then + local _, _, currentName = string.find(link, "|h%[(.-)%]|h") + if currentName and string.lower(currentName) == checkName then + return bag, slot + end + end + end + end + end + + return nil, nil +end + +-- Use an item by ID or name (fast lookup) +-- Returns true if item was used, false otherwise +function API.UseItem(itemIdOrName) + if not itemIdOrName then return false end + + -- Try native v2.18 lookup first + local itemInfo = API.FindItemFast(itemIdOrName) + if itemInfo then + ClearCursor() + if itemInfo.inventoryID then + UseInventoryItem(itemInfo.inventoryID) + return true + elseif itemInfo.bagID and itemInfo.slot then + UseContainerItem(itemInfo.bagID, itemInfo.slot) + return true + end + end + + -- No v2.18 API or item not found via native lookup + return false +end + +-- Equip an item from bags to a specific slot (fast lookup) +-- Returns true if equip was attempted, false if item not found +function API.EquipItem(itemIdOrName, targetSlot) + if not itemIdOrName then return false end + + -- Check if already equipped in target slot + if targetSlot and API.IsItemInSlot(itemIdOrName, targetSlot) then + return true -- Already equipped + end + + -- Find the item + local itemInfo = API.FindItemFast(itemIdOrName) + if not itemInfo then return false end + + -- If already equipped (but not in target slot), need to swap + if itemInfo.inventoryID then + if targetSlot and itemInfo.inventoryID ~= targetSlot then + -- Item equipped in wrong slot - pick up and move + ClearCursor() + PickupInventoryItem(itemInfo.inventoryID) + if CursorHasItem and CursorHasItem() then + EquipCursorItem(targetSlot) + ClearCursor() + return true + end + end + return true -- Already equipped (and no target slot specified) + end + + -- Item in bag - equip it + if itemInfo.bagID and itemInfo.slot then + ClearCursor() + PickupContainerItem(itemInfo.bagID, itemInfo.slot) + if CursorHasItem and CursorHasItem() then + if targetSlot then + EquipCursorItem(targetSlot) + else + -- Auto-equip to appropriate slot + AutoEquipCursorItem() + end + ClearCursor() + return true + end + end + + return false +end + +-------------------------------------------------------------------------------- +-- CAST INFO API (v2.18+) +-------------------------------------------------------------------------------- + +-- Get detailed information about current cast/channel +-- Requires v2.18+ for native GetCastInfo (with GetCurrentCastingInfo fallback for v2.0+) +-- Returns table with: castId, spellId, guid, castType, castStartS, castEndS, +-- castRemainingMs, castDurationMs, gcdEndS, gcdRemainingMs +-- Returns nil if no active cast +function API.GetCastInfo() + -- Use native function if available (v2.18+) + if API.features.hasGetCastInfo and GetCastInfo then + local ok, result = pcall(GetCastInfo) + if ok then + return result + end + -- If pcall failed, fall through to fallback + end + + -- Fallback: use GetCurrentCastingInfo (less detailed, v2.0+) + if API.features.hasGetCurrentCastingInfo and GetCurrentCastingInfo then + local castId, visId, autoId, casting, channeling, onswing, autoattack = GetCurrentCastingInfo() + if castId and castId > 0 then + return { + spellId = castId, + castType = channeling == 1 and 3 or 0, -- 3 = CHANNEL, 0 = NORMAL + -- Timing info not available without native GetCastInfo + } + end + end + + return nil +end + +-- Check if currently casting (convenience wrapper) +function API.IsCasting() + local info = API.GetCastInfo() + return info ~= nil +end + +-- Get GCD remaining in milliseconds +function API.GetGCDRemainingMs() + local info = API.GetCastInfo() + if info and info.gcdRemainingMs then + return info.gcdRemainingMs + end + return 0 +end + +-- Get cast remaining in milliseconds +function API.GetCastRemainingMs() + local info = API.GetCastInfo() + if info and info.castRemainingMs then + return info.castRemainingMs + end + return 0 +end + +-------------------------------------------------------------------------------- +-- COOLDOWN API (v2.18+) +-------------------------------------------------------------------------------- + +-- Get detailed cooldown information for a spell +-- Requires v2.18+ for native GetSpellIdCooldown (with GetSpellCooldown fallback) +-- Returns table with: isOnCooldown, cooldownRemainingMs, +-- individual cooldown: individualStartS, individualDurationMs, individualRemainingMs, isOnIndividualCooldown +-- category cooldown: categoryId, categoryStartS, categoryDurationMs, categoryRemainingMs, isOnCategoryCooldown +-- GCD: gcdCategoryId, gcdCategoryStartS, gcdCategoryDurationMs, gcdCategoryRemainingMs, isOnGcdCategoryCooldown +-- item metadata (v2.23+): itemId, itemHasActiveSpell, itemActiveSpellId +function API.GetSpellCooldownInfo(spellId) + if not spellId or spellId == 0 then return nil end + + -- Use native function if available (v2.18+) + if API.features.hasGetSpellIdCooldown and GetSpellIdCooldown then + return GetSpellIdCooldown(spellId) + end + + -- Fallback: use GetSpellCooldown (less detailed) + local spellName = API.GetSpellNameById(spellId) + if spellName then + local start, duration = GetSpellCooldown(spellName, BOOKTYPE_SPELL) + if start and start > 0 then + local remaining = (start + duration) - GetTime() + if remaining > 0 then + return { + isOnCooldown = 1, + cooldownRemainingMs = remaining * 1000, + } + end + end + end + + return { isOnCooldown = 0, cooldownRemainingMs = 0 } +end + +-- Get detailed cooldown information for an item +-- Requires v2.18+ for native GetItemIdCooldown (with GetItemCooldown fallback) +-- Returns same table structure as GetSpellCooldownInfo +-- v2.23+ adds: itemId, itemHasActiveSpell, itemActiveSpellId +function API.GetItemCooldownInfo(itemId) + if not itemId or itemId == 0 then return nil end + + -- Use native function if available (v2.18+) + if API.features.hasGetItemIdCooldown and GetItemIdCooldown then + return GetItemIdCooldown(itemId) + end + + -- Fallback: use GetItemCooldown (less detailed) + local start, duration = GetItemCooldown(itemId) + if start and start > 0 then + local remaining = (start + duration) - GetTime() + if remaining > 0 then + return { + isOnCooldown = 1, + cooldownRemainingMs = remaining * 1000, + } + end + end + + return { isOnCooldown = 0, cooldownRemainingMs = 0 } +end + +-- Check if a spell is on cooldown (ignoring GCD) +function API.IsSpellOnCooldown(spellId, ignoreGCD) + local info = API.GetSpellCooldownInfo(spellId) + if not info then return false end + + if ignoreGCD then + -- Only check individual and category cooldowns + return (info.isOnIndividualCooldown == 1) or (info.isOnCategoryCooldown == 1) + end + + return info.isOnCooldown == 1 +end + +-- Get remaining cooldown for a spell in seconds +function API.GetSpellCooldownRemaining(spellId) + local info = API.GetSpellCooldownInfo(spellId) + if info and info.cooldownRemainingMs then + return info.cooldownRemainingMs / 1000 + end + return 0 +end + +-------------------------------------------------------------------------------- +-- CHANNEL CONTROL API (v2.18+) +-------------------------------------------------------------------------------- + +-- Stop channeling early on the next tick +-- Requires v2.18+ for ChannelStopCastingNextTick +-- Only works if NP_QueueChannelingSpells is enabled +function API.StopChannelNextTick() + if API.features.hasChannelStopCastingNextTick and ChannelStopCastingNextTick then + ChannelStopCastingNextTick() + return true + end + return false +end + +-------------------------------------------------------------------------------- +-- TRINKET API (v2.20+) +-------------------------------------------------------------------------------- + +-- Trinket slot constants +API.TRINKET_SLOT = { + FIRST = 13, -- First trinket slot (slot 13 in equipment) + SECOND = 14, -- Second trinket slot (slot 14 in equipment) +} + +-- Get all trinkets (equipped and in bags) +-- Requires v2.20+ for native GetTrinkets (with Lua fallback) +-- Returns table with: itemId, trinketName, texture, itemLevel, bagIndex (nil=equipped), slotIndex +-- Pass copy=1 to get an independent copy safe for storage (v2.20+) +function API.GetTrinkets(copy) + -- Use native function if available (v2.20+) + if API.features.hasGetTrinkets and GetTrinkets then + -- Copy parameter supported in v2.20+ + return GetTrinkets(copy) + end + + -- Fallback: manual enumeration + local trinkets = {} + local index = 1 + + -- Check equipped trinket slots (13 and 14) + for _, slot in ipairs({13, 14}) do + local link = GetInventoryItemLink("player", slot) + if link then + local _, _, itemId = string.find(link, "item:(%d+)") + local _, _, name = string.find(link, "|h%[(.-)%]|h") + local texture = GetInventoryItemTexture("player", slot) + if itemId then + local numItemId = tonumber(itemId) + local itemLevel = API.GetItemLevel(numItemId) + trinkets[index] = { + itemId = numItemId, + trinketName = name or "Unknown", + texture = texture, + itemLevel = itemLevel, + bagIndex = nil, -- nil means equipped + slotIndex = slot == 13 and 1 or 2, + } + index = index + 1 + end + end + end + + -- Check bags for trinkets (inventoryType 12 = Trinket) + for bag = 0, 4 do + local numSlots = GetContainerNumSlots(bag) or 0 + for slot = 1, numSlots do + local link = GetContainerItemLink(bag, slot) + if link then + local _, _, itemId = string.find(link, "item:(%d+)") + if itemId then + local numItemId = tonumber(itemId) + local invType = API.GetItemInventoryType(numItemId) + if invType == 12 then -- Trinket + local _, _, name = string.find(link, "|h%[(.-)%]|h") + local texture = GetContainerItemInfo(bag, slot) + local itemLevel = API.GetItemLevel(numItemId) + trinkets[index] = { + itemId = numItemId, + trinketName = name or "Unknown", + texture = texture, + itemLevel = itemLevel, + bagIndex = bag, + slotIndex = slot, + } + index = index + 1 + end + end + end + end + end + + return trinkets +end + +-- Get cooldown for an equipped trinket +-- Requires v2.20+ for native GetTrinketCooldown (with Lua fallback) +-- slot: 1 or 13 = first trinket, 2 or 14 = second trinket +-- OR item ID (number) or item name (string) to match +-- Returns cooldown detail table (same as GetSpellIdCooldown/GetItemIdCooldown) +-- Returns -1 if no matching trinket is equipped +function API.GetTrinketCooldown(slot) + -- Use native function if available (v2.20+) + if API.features.hasGetTrinketCooldown and GetTrinketCooldown then + return GetTrinketCooldown(slot) + end + + -- Normalize slot number + local equipSlot + if type(slot) == "number" then + if slot == 1 or slot == 13 then + equipSlot = 13 + elseif slot == 2 or slot == 14 then + equipSlot = 14 + else + -- Treat as item ID - find in trinket slots + for _, checkSlot in ipairs({13, 14}) do + local link = GetInventoryItemLink("player", checkSlot) + if link then + local _, _, currentId = string.find(link, "item:(%d+)") + if currentId and tonumber(currentId) == slot then + equipSlot = checkSlot + break + end + end + end + end + elseif type(slot) == "string" then + -- Match by name + local slotLower = string.lower(slot) + for _, checkSlot in ipairs({13, 14}) do + local link = GetInventoryItemLink("player", checkSlot) + if link then + local _, _, name = string.find(link, "|h%[(.-)%]|h") + if name and string.lower(name) == slotLower then + equipSlot = checkSlot + break + end + end + end + end + + if not equipSlot then + return -1 -- No matching trinket found + end + + -- Get item ID from equipped slot + local link = GetInventoryItemLink("player", equipSlot) + if not link then + return -1 + end + + local _, _, itemId = string.find(link, "item:(%d+)") + if not itemId then + return -1 + end + + -- Get cooldown info + return API.GetItemCooldownInfo(tonumber(itemId)) +end + +-- Use an equipped trinket +-- Requires v2.20+ for native UseTrinket (with Lua fallback) +-- slot: 1 or 13 = first trinket, 2 or 14 = second trinket +-- OR item ID (number) or item name (string) to match +-- target: optional unit token or GUID +-- Returns: 1 if used, 0 if use failed, -1 if trinket not found +function API.UseTrinket(slot, target) + -- Use native function if available (v2.20+) + if API.features.hasUseTrinket and UseTrinket then + return UseTrinket(slot, target) + end + + -- Normalize slot number + local equipSlot + if type(slot) == "number" then + if slot == 1 or slot == 13 then + equipSlot = 13 + elseif slot == 2 or slot == 14 then + equipSlot = 14 + else + -- Treat as item ID - find in trinket slots + for _, checkSlot in ipairs({13, 14}) do + local link = GetInventoryItemLink("player", checkSlot) + if link then + local _, _, currentId = string.find(link, "item:(%d+)") + if currentId and tonumber(currentId) == slot then + equipSlot = checkSlot + break + end + end + end + end + elseif type(slot) == "string" then + -- Match by name + local slotLower = string.lower(slot) + for _, checkSlot in ipairs({13, 14}) do + local link = GetInventoryItemLink("player", checkSlot) + if link then + local _, _, name = string.find(link, "|h%[(.-)%]|h") + if name and string.lower(name) == slotLower then + equipSlot = checkSlot + break + end + end + end + end + + if not equipSlot then + return -1 -- No matching trinket found + end + + -- Use the trinket + ClearCursor() + UseInventoryItem(equipSlot) + return 1 +end + +-------------------------------------------------------------------------------- +-- ITEM USAGE API (v2.20+) +-------------------------------------------------------------------------------- + +-- Use an item by ID or name +-- Requires v2.20+ for native UseItemIdOrName (with Lua fallback) +-- itemIdOrName: item ID (number) or item name (string) +-- target: optional unit token or GUID +-- Returns: 1 if used successfully, 0 if not found or use failed +function API.UseItemIdOrName(itemIdOrName, target) + -- Use native function if available (v2.20+) + if API.features.hasUseItemIdOrName and UseItemIdOrName then + return UseItemIdOrName(itemIdOrName, target) + end + + -- Fallback: use existing API.UseItem + if API.UseItem(itemIdOrName) then + return 1 + end + + return 0 +end + +-------------------------------------------------------------------------------- +-- DISENCHANT API (v2.22+) +-------------------------------------------------------------------------------- + +-- Automatically disenchant items in inventory +-- Requires v2.22+ for DisenchantAll (no fallback available) +-- Mode 1: DisenchantAll(itemIdOrName, [includeSoulbound]) - specific item by ID/name +-- Mode 2: DisenchantAll(quality, [includeSoulbound]) - "greens", "blues", or "greens|blues" (v2.23+) +-- includeSoulbound: pass 1 to include soulbound items (default: 0) +-- Returns: 1 if first disenchant succeeded, 0 if no items found or failed +-- +-- WARNING: This function WILL disenchant items without confirmation! +-- Quest items are always protected. Soulbound items protected by default. +-- Only affects bags 0-4 (not bank). +function API.DisenchantAll(itemIdOrNameOrQuality, includeSoulbound) + -- Use native function if available (v2.22+) + if API.features.hasDisenchantAll and DisenchantAll then + return DisenchantAll(itemIdOrNameOrQuality, includeSoulbound) + end + + -- No fallback available - requires Nampower 2.22+ + return 0 +end + +-------------------------------------------------------------------------------- +-- EVENT CONSTANTS (v2.18+, updated v2.20) +-------------------------------------------------------------------------------- + +-- Spell queue event codes +API.QUEUE_EVENT = { + ON_SWING_QUEUED = 0, + ON_SWING_QUEUE_POPPED = 1, + NORMAL_QUEUED = 2, + NORMAL_QUEUE_POPPED = 3, + NON_GCD_QUEUED = 4, + NON_GCD_QUEUE_POPPED = 5, +} + +-- Spell cast event types +API.CAST_TYPE = { + NORMAL = 1, + NON_GCD = 2, + ON_SWING = 3, + CHANNEL = 4, + TARGETING = 5, + TARGETING_NON_GCD = 6, +} + +-- Buff/debuff event names (v2.18+) +-- Parameters: guid, slot, spellId, stackCount, auraLevel (v2.20+) +API.AURA_EVENTS = { + "BUFF_ADDED_SELF", + "BUFF_REMOVED_SELF", + "BUFF_ADDED_OTHER", + "BUFF_REMOVED_OTHER", + "DEBUFF_ADDED_SELF", + "DEBUFF_REMOVED_SELF", + "DEBUFF_ADDED_OTHER", + "DEBUFF_REMOVED_OTHER", +} + +-- Aura cast event names (v2.20+, requires NP_EnableAuraCastEvents=1) +-- Parameters: spellId, casterGuid, targetGuid, effect, effectAuraName, +-- effectAmplitude, effectMiscValue, durationMs, auraCapStatus +-- auraCapStatus bitfield: 1 = buff bar full, 2 = debuff bar full +API.AURA_CAST_EVENTS = { + "AURA_CAST_ON_SELF", -- Fires when aura lands on active player + "AURA_CAST_ON_OTHER", -- Fires when aura lands on other units +} + +-- Aura cap status bitfield values (for AURA_CAST events) +API.AURA_CAP_STATUS = { + BUFF_BAR_FULL = 1, + DEBUFF_BAR_FULL = 2, + BOTH_FULL = 3, +} + +-- Auto-attack event names (v2.24+, requires NP_EnableAutoAttackEvents=1) +-- Parameters: attackerGuid, targetGuid, totalDamage, hitInfo, victimState, +-- subDamageCount, blockedAmount, totalAbsorb, totalResist +API.AUTO_ATTACK_EVENTS = { + "AUTO_ATTACK_SELF", -- Fires when active player is the target + "AUTO_ATTACK_OTHER", -- Fires when active player is attacker or different unit is target +} + +-- HitInfo bitfield values (for AUTO_ATTACK events) +-- Converted to decimal for Lua 5.0 compatibility (no hex literals) +API.HITINFO = { + NORMALSWING = 0, + UNK0 = 1, + AFFECTS_VICTIM = 2, + LEFTSWING = 4, -- Off-hand attack + UNK3 = 8, + MISS = 16, -- 0x10 + ABSORB = 32, -- 0x20 + RESIST = 64, -- 0x40 + CRITICALHIT = 128, -- 0x80 + UNK8 = 256, -- 0x100 + UNK9 = 8192, -- 0x2000 + GLANCING = 16384, -- 0x4000 + CRUSHING = 32768, -- 0x8000 + NOACTION = 65536, -- 0x10000 + SWINGNOHITSOUND = 524288, -- 0x80000 +} + +-- VictimState values (for AUTO_ATTACK events) +API.VICTIMSTATE = { + UNAFFECTED = 0, -- Seen with HITINFO_MISS + NORMAL = 1, + DODGE = 2, + PARRY = 3, + INTERRUPT = 4, + BLOCKS = 5, + EVADES = 6, + IS_IMMUNE = 7, + 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 +} + +-- Spell school constants (for reference) +API.SPELL_SCHOOL = { + PHYSICAL = 0, + HOLY = 1, + FIRE = 2, + NATURE = 3, + FROST = 4, + SHADOW = 5, + ARCANE = 6, +} + +-- Resistance indices (1-indexed for Lua tables) +API.RESISTANCE_INDEX = { + ARMOR = 1, + HOLY = 2, + FIRE = 3, + NATURE = 4, + FROST = 5, + SHADOW = 6, + ARCANE = 7, +} + +-------------------------------------------------------------------------------- +-- SPELL MODIFIERS API (GetSpellModifiers) +-------------------------------------------------------------------------------- + +-- Modifier type constants +API.MODIFIER_DAMAGE = 0 +API.MODIFIER_DURATION = 1 +API.MODIFIER_THREAT = 2 +API.MODIFIER_ATTACK_POWER = 3 +API.MODIFIER_CHARGES = 4 +API.MODIFIER_RANGE = 5 +API.MODIFIER_RADIUS = 6 +API.MODIFIER_CRITICAL_CHANCE = 7 +API.MODIFIER_ALL_EFFECTS = 8 +API.MODIFIER_NOT_LOSE_CASTING_TIME = 9 +API.MODIFIER_CASTING_TIME = 10 +API.MODIFIER_COOLDOWN = 11 +API.MODIFIER_SPEED = 12 +API.MODIFIER_COST = 14 +API.MODIFIER_CRIT_DAMAGE_BONUS = 15 +API.MODIFIER_RESIST_MISS_CHANCE = 16 +API.MODIFIER_JUMP_TARGETS = 17 +API.MODIFIER_CHANCE_OF_SUCCESS = 18 +API.MODIFIER_ACTIVATION_TIME = 19 +API.MODIFIER_EFFECT_PAST_FIRST = 20 +API.MODIFIER_CASTING_TIME_OLD = 21 +API.MODIFIER_DOT = 22 +API.MODIFIER_HASTE = 23 +API.MODIFIER_SPELL_BONUS_DAMAGE = 24 +API.MODIFIER_MULTIPLE_VALUE = 27 +API.MODIFIER_RESIST_DISPEL_CHANCE = 28 + +-- Get spell modifiers +-- Requires v2.8+ for GetSpellModifiers +-- Returns: flatMod, percentMod, hasModifier +function API.GetModifiers(spellId, modifierType) + if not API.features.hasGetSpellModifiers or not GetSpellModifiers then + return 0, 0, false + end + + if not spellId or spellId == 0 then + return 0, 0, false + end + + local flat, percent, ret = GetSpellModifiers(spellId, modifierType) + return flat or 0, percent or 0, ret and ret ~= 0 +end + +-- Get duration modifier for a spell (useful for debuff tracking) +-- Returns modified duration given base duration +function API.GetModifiedDuration(spellId, baseDuration) + if not baseDuration then return nil end + + local flat, percent, hasModifier = API.GetModifiers(spellId, API.MODIFIER_DURATION) + + if not hasModifier then + return baseDuration + end + + -- Apply flat modifier first, then percentage + -- Nampower returns percent as final multiplier (90 = 90% of original, not -10% change) + local modified = baseDuration + flat + if percent ~= 0 then + modified = modified * (percent / 100) + end + + return modified +end + +-- Get damage modifier for a spell +function API.GetDamageModifier(spellId) + local flat, percent = API.GetModifiers(spellId, API.MODIFIER_DAMAGE) + return flat, percent +end + +-- Get cooldown modifier for a spell +function API.GetCooldownModifier(spellId) + local flat, percent = API.GetModifiers(spellId, API.MODIFIER_COOLDOWN) + return flat, percent +end + +-- Get cast time modifier for a spell +function API.GetCastTimeModifier(spellId) + local flat, percent = API.GetModifiers(spellId, API.MODIFIER_CASTING_TIME) + return flat, percent +end + +-- Get cost modifier for a spell +function API.GetCostModifier(spellId) + local flat, percent = API.GetModifiers(spellId, API.MODIFIER_COST) + return flat, percent +end + +-------------------------------------------------------------------------------- +-- ENHANCED SPELL LOOKUP (using new "spellId:number" and name syntax) +-------------------------------------------------------------------------------- + +-- Convert spell identifier to spellId:number format if needed +-- Input can be: spellSlot (number), "spellId:123", or "Spell Name" +function API.NormalizeSpellIdentifier(identifier, bookType) + if not identifier then return nil end + + -- Already in spellId:number format + if type(identifier) == "string" and string.find(identifier, "^spellId:") then + return identifier + end + + -- If it's a number and enhanced functions exist, keep as-is (slot) or convert to spellId + if type(identifier) == "number" then + if API.features.hasEnhancedSpellFunctions then + -- Could be a spell slot - check if we have GetSpellSlotTypeIdForName to get the ID + if identifier > 0 and identifier < 1000 then + -- Likely a spell slot, use as-is + return identifier, bookType or BOOKTYPE_SPELL + else + -- Likely a spell ID, convert to spellId:number format + return "spellId:" .. identifier + end + end + return identifier, bookType + end + + -- It's a spell name + if type(identifier) == "string" then + -- If enhanced functions available, pass name directly + if API.features.hasEnhancedSpellFunctions then + return identifier + end + + -- Fall back to manual lookup + local spell = CleveRoids.GetSpell and CleveRoids.GetSpell(identifier) + if spell then + return spell.spellSlot, spell.bookType + end + end + + return nil +end + +-- Get spell texture (works with name, spellId:number, or slot) +function API.GetSpellTexture(identifier, bookType) + if API.features.hasEnhancedSpellFunctions then + -- Pass directly to native function + local id = API.NormalizeSpellIdentifier(identifier) + if id then + return GetSpellTexture(id, bookType) + end + else + -- Fall back to slot-based lookup + local slot, book = API.NormalizeSpellIdentifier(identifier, bookType) + if slot then + return GetSpellTexture(slot, book or BOOKTYPE_SPELL) + end + end + return nil +end + +-- Get item icon texture by display info ID (v2.27+) +-- Parameters: +-- displayInfoId (number): The item's display info ID from GetItemStatsField(itemId, "displayInfoID") +-- Returns: +-- Texture path string (e.g., "Interface\Icons\INV_Sword_04"), or nil if not found +function API.GetItemIconTexture(displayInfoId) + if not displayInfoId or type(displayInfoId) ~= "number" then + return nil + end + + if API.features.hasGetItemIconTexture and GetItemIconTexture then + return GetItemIconTexture(displayInfoId) + end + + return nil +end + +-- Get item icon texture by item ID (convenience wrapper) +-- Parameters: +-- itemId (number): The item's ID +-- Returns: +-- Texture path string, or nil if not found +function API.GetItemIconTextureByItemId(itemId) + if not itemId or type(itemId) ~= "number" then + return nil + end + + -- Get displayInfoID from item stats + if GetItemStatsField then + local displayInfoId = GetItemStatsField(itemId, "displayInfoID") + if displayInfoId then + return API.GetItemIconTexture(displayInfoId) + end + end + + return nil +end + +-- Get spell icon texture by spell icon ID (v2.27+) +-- Parameters: +-- spellIconId (number): The spell's icon ID from GetSpellRecField(spellId, "spellIconID") +-- Returns: +-- Texture path string with Interface\Icons\ prefix (e.g., "Interface\Icons\Spell_Frost_FrostBolt02"), or nil if not found +function API.GetSpellIconTexture(spellIconId) + if not spellIconId or type(spellIconId) ~= "number" then + return nil + end + + if API.features.hasGetSpellIconTexture and GetSpellIconTexture then + return GetSpellIconTexture(spellIconId) + end + + return nil +end + +-- Get spell icon texture by spell ID (convenience wrapper) +-- Parameters: +-- spellId (number): The spell's ID +-- Returns: +-- Texture path string, or nil if not found +function API.GetSpellIconTextureBySpellId(spellId) + if not spellId or type(spellId) ~= "number" then + return nil + end + + -- Get spellIconID from spell record + if GetSpellRecField then + local spellIconId = GetSpellRecField(spellId, "spellIconID") + if spellIconId then + return API.GetSpellIconTexture(spellIconId) + end + end + + return nil +end + +-- Get spell name (works with name, spellId:number, or slot) +function API.GetSpellName(identifier, bookType) + if API.features.hasEnhancedSpellFunctions then + local id = API.NormalizeSpellIdentifier(identifier) + if id then + return GetSpellName(id, bookType) + end + else + local slot, book = API.NormalizeSpellIdentifier(identifier, bookType) + if slot then + return GetSpellName(slot, book or BOOKTYPE_SPELL) + end + end + return nil +end + +-- Get spell cooldown (works with name, spellId:number, or slot) +-- Returns: start, duration (same as native) +function API.GetSpellCooldown(identifier, bookType) + if API.features.hasEnhancedSpellFunctions then + local id = API.NormalizeSpellIdentifier(identifier) + if id then + return GetSpellCooldown(id, bookType) + end + else + local slot, book = API.NormalizeSpellIdentifier(identifier, bookType) + if slot then + return GetSpellCooldown(slot, book or BOOKTYPE_SPELL) + end + end + return nil +end + +-- Check if spell is passive +function API.IsSpellPassive(identifier, bookType) + if API.features.hasEnhancedSpellFunctions then + local id = API.NormalizeSpellIdentifier(identifier) + if id then + return IsSpellPassive(id, bookType) + end + else + local slot, book = API.NormalizeSpellIdentifier(identifier, bookType) + if slot then + return IsSpellPassive(slot, book or BOOKTYPE_SPELL) + end + end + return nil +end + +-------------------------------------------------------------------------------- +-- SPELL ID LOOKUP UTILITIES +-------------------------------------------------------------------------------- + +-- Cache for spell ID lookups +API.spellIdCache = {} + +-- Get spell ID from name (cached) +function API.GetSpellIdFromName(spellName) + if not spellName then return nil end + + -- Check cache + if API.spellIdCache[spellName] then + return API.spellIdCache[spellName] + end + + -- Use Nampower's GetSpellIdForName + if GetSpellIdForName then + local spellId = GetSpellIdForName(spellName) + if spellId and spellId > 0 then + API.spellIdCache[spellName] = spellId + return spellId + end + end + + return nil +end + +-- Get spell slot, book type, and ID from name +function API.GetSpellSlotInfo(spellName) + if not spellName then return nil end + + -- Use Nampower's GetSpellSlotTypeIdForName + if GetSpellSlotTypeIdForName then + local slot, bookType, spellId = GetSpellSlotTypeIdForName(spellName) + if slot and slot > 0 then + return slot, bookType, spellId + end + end + + -- Fallback to CleveRoids.GetSpell + if CleveRoids.GetSpell then + local spell = CleveRoids.GetSpell(spellName) + if spell then + local spellId = API.GetSpellIdFromName(spellName) + return spell.spellSlot, spell.bookType, spellId + end + end + + return nil +end + +-------------------------------------------------------------------------------- +-- INITIALIZATION +-------------------------------------------------------------------------------- + +-- Initialize the API module +function API.Initialize() + -- Detect enhanced spell functions + DetectEnhancedSpellFunctions() + +end + +-- Clear caches (call on respec/spell change) +function API.ClearCaches() + API.spellRecCache = {} + API.itemStatsCache = {} + API.spellIdCache = {} + API.settingsCache = {} + API.spellTypeCache = {} +end + +-------------------------------------------------------------------------------- +-- CONVENIENCE WRAPPERS FOR EXISTING CODE +-------------------------------------------------------------------------------- + +-- Check if a spell is in range (wrapper around IsSpellInRange with fallbacks) +function API.IsSpellInRange(spellIdentifier, unit) + unit = unit or "target" + + -- Convert name to ID if needed + local spellId = nil + local checkValue = spellIdentifier + if type(spellIdentifier) == "string" and not string.find(spellIdentifier, "^spellId:") then + spellId = API.GetSpellIdFromName(spellIdentifier) + if spellId and spellId > 0 then + checkValue = spellId + end + elseif type(spellIdentifier) == "number" then + spellId = spellIdentifier + end + + -- Check for self-cast spells FIRST before native range check + -- Self-targeted spells are always in range regardless of what IsSpellInRange returns + if spellId and spellId > 0 then + local rangeIndex = API.GetSpellField(spellId, "rangeIndex") + -- rangeIndex 0 = Self Only, 14 = Self Only (alternate), 23 = Touch + if rangeIndex == 0 or rangeIndex == 14 or rangeIndex == 23 then + return 1 -- Self-targeted spells are always in range + end + -- Fallback: if rangeIndex lookup failed but spell range is 0, it's self-only + if rangeIndex == nil then + local spellRange = API.GetSpellRange(spellId) + if spellRange == 0 or spellRange == nil then + return 1 -- Assume self-only if range is 0 or unknown + end + end + end + + -- Try native IsSpellInRange (wrapped in pcall to handle invalid spell IDs) + local nativeResult = nil + if IsSpellInRange then + local success, result = pcall(IsSpellInRange, checkValue, unit) + if success then + nativeResult = result + -- result == 1 (in range), 0 (out of range), -1 (invalid/non-unit-targeted), nil (error) + if nativeResult == 0 or nativeResult == 1 then + return nativeResult + end + end + -- If pcall failed, nativeResult stays nil and we fall through to UnitXP + end + + -- If native returned -1, check DBC target type to determine handling + if nativeResult == -1 and spellId and spellId > 0 then + local isUnitTargeted = API.IsUnitTargetedSpell(spellId) + + if isUnitTargeted then + -- Unit-targeted spell (like channeled Arcane Missiles) - use distance check + local spellRange = API.GetSpellRange(spellId) + if spellRange and spellRange > 0 and CleveRoids.hasUnitXP and UnitExists(unit) then + local distance = UnitXP("distanceBetween", "player", unit) + if distance then + return (distance <= spellRange) and 1 or 0 + end + end + -- Fall through to return 1 if we can't check distance + end + + -- Non-unit-targeted spell (self-cast like Presence of Mind, or ground-targeted like Blizzard) + -- Always in range + return 1 + end + + -- For nil results (native couldn't determine), try UnitXP distance check + if spellId and spellId > 0 and CleveRoids.hasUnitXP and UnitExists(unit) then + local spellRange = API.GetSpellRange(spellId) + if spellRange and spellRange > 0 then + local distance = UnitXP("distanceBetween", "player", unit) + if distance then + return (distance <= spellRange) and 1 or 0 + end + end + end + + return nil -- Can't determine +end + +-- Check if a spell is usable (wrapper around IsSpellUsable) +-- Returns usable, oom on success; nil on error (spell not in spellbook) +function API.IsSpellUsable(spellIdentifier) + if not _G.IsSpellUsable then + return nil + end + + -- If enhanced functions available, pass directly + if API.features.hasEnhancedSpellFunctions then + local ok, usable, oom = pcall(_G.IsSpellUsable, spellIdentifier) + if ok then return usable, oom end + return nil + end + + -- Convert to ID if name + if type(spellIdentifier) == "string" then + local spellId = API.GetSpellIdFromName(spellIdentifier) + if spellId then + local ok, usable, oom = pcall(_G.IsSpellUsable, spellId) + if ok then return usable, oom end + return nil + end + end + + local ok, usable, oom = pcall(_G.IsSpellUsable, spellIdentifier) + if ok then return usable, oom end + return nil +end + +-------------------------------------------------------------------------------- +-- SMART CASTING SYSTEM +-------------------------------------------------------------------------------- + +-- Spell type constants +API.SPELL_TYPE_UNKNOWN = 0 +API.SPELL_TYPE_CAST = 1 -- Has cast time +API.SPELL_TYPE_INSTANT = 2 -- Instant, on GCD +API.SPELL_TYPE_CHANNEL = 3 -- Channeled spell +API.SPELL_TYPE_ON_SWING = 4 -- Next-melee (Heroic Strike, etc.) +API.SPELL_TYPE_NON_GCD = 5 -- Instant, not on GCD (trinkets, etc.) + +-- Known on-swing spells (by name, localized via CleveRoids.Localized when available) +API.onSwingSpells = { + ["Heroic Strike"] = true, + ["Cleave"] = true, + ["Maul"] = true, + ["Slam"] = true, + ["Raptor Strike"] = true, + ["Mongoose Bite"] = true, +} + +-- Known channeled spells (by name) +API.channeledSpells = { + ["Arcane Missiles"] = true, + ["Blizzard"] = true, + ["Drain Life"] = true, + ["Drain Mana"] = true, + ["Drain Soul"] = true, + ["Evocation"] = true, + ["Health Funnel"] = true, + ["Hellfire"] = true, + ["Hurricane"] = true, + ["Mind Flay"] = true, + ["Rain of Fire"] = true, + ["Tranquility"] = true, + ["Volley"] = true, + ["Mind Soothe"] = true, + ["Mind Vision"] = true, + ["First Aid"] = true, -- Bandaging +} + +-- Cache for spell type lookups +API.spellTypeCache = {} + +-- Determine spell type for a given spell +-- Returns: SPELL_TYPE_* constant +function API.GetSpellType(spellName) + if not spellName then return API.SPELL_TYPE_UNKNOWN end + + -- Strip rank from name for cache lookup + local baseName = string.gsub(spellName, "%s*%([Rr]ank%s*%d+%)%s*$", "") + + -- Check cache first + if API.spellTypeCache[baseName] then + return API.spellTypeCache[baseName] + end + + local spellType = API.SPELL_TYPE_UNKNOWN + + -- Check known on-swing spells first (highest priority) + if API.onSwingSpells[baseName] then + spellType = API.SPELL_TYPE_ON_SWING + API.spellTypeCache[baseName] = spellType + return spellType + end + + -- Check known channeled spells + if API.channeledSpells[baseName] then + spellType = API.SPELL_TYPE_CHANNEL + API.spellTypeCache[baseName] = spellType + return spellType + end + + -- Try to use GetSpellRec for accurate detection + local spellId = API.GetSpellIdFromName(spellName) + if spellId and spellId > 0 and API.features.hasGetSpellRec then + local rec = API.GetSpellRecord(spellId) + if rec then + -- Check for channeled via attributes + -- SPELL_ATTR_EX_CHANNELED = 4 (0x00000004) in attributesEx + local attrEx = rec.attributesEx or 0 + if bit and bit.band(attrEx, 4) ~= 0 then + spellType = API.SPELL_TYPE_CHANNEL + API.spellTypeCache[baseName] = spellType + return spellType + end + + -- Check cast time + local castTime = rec.castTime or 0 + if castTime > 0 then + spellType = API.SPELL_TYPE_CAST + else + -- Instant spell - check if it's on GCD + -- startRecoveryTime > 0 means it triggers GCD + local recoveryTime = rec.startRecoveryTime or 0 + if recoveryTime > 0 then + spellType = API.SPELL_TYPE_INSTANT + else + spellType = API.SPELL_TYPE_NON_GCD + end + end + + API.spellTypeCache[baseName] = spellType + return spellType + end + end + + -- Fallback: assume instant if no other info + -- This is safe because Nampower will handle it correctly anyway + spellType = API.SPELL_TYPE_INSTANT + API.spellTypeCache[baseName] = spellType + return spellType +end + +-- Get the queue setting name for a spell type +local function GetQueueSettingForType(spellType) + if spellType == API.SPELL_TYPE_CAST then + return "NP_QueueCastTimeSpells" + elseif spellType == API.SPELL_TYPE_INSTANT then + return "NP_QueueInstantSpells" + elseif spellType == API.SPELL_TYPE_CHANNEL then + return "NP_QueueChannelingSpells" + elseif spellType == API.SPELL_TYPE_ON_SWING then + return "NP_QueueOnSwingSpells" + elseif spellType == API.SPELL_TYPE_NON_GCD then + return "NP_QueueInstantSpells" -- Non-GCD uses instant setting + end + return nil +end + +-- Check if queuing is enabled for a specific spell +function API.IsSpellQueueingEnabled(spellName) + if not API.features.hasSpellQueue then + return false + end + + local spellType = API.GetSpellType(spellName) + local settingName = GetQueueSettingForType(spellType) + + if settingName then + return API.GetSettingBool(settingName) + end + + -- Default: use cast time spell setting + return API.GetSettingBool("NP_QueueCastTimeSpells") +end + +-- Smart cast function that uses QueueSpellByName when appropriate +-- Returns: true if cast was attempted, false otherwise +-- Parameters: +-- spellName: The spell name (with optional rank) +-- target: Optional target unit (for SuperWoW CastSpellByName) +-- forceQueue: If true, always use QueueSpellByName (if available, v2.0+) +-- forceNoQueue: If true, never use QueueSpellByName +function API.SmartCast(spellName, target, forceQueue, forceNoQueue) + if not spellName then return false end + + -- Determine whether to use queuing + local useQueue = false + + if forceNoQueue then + useQueue = false + elseif forceQueue then + useQueue = API.features.hasSpellQueue + else + -- Check if queuing is enabled for this spell type + useQueue = API.IsSpellQueueingEnabled(spellName) + end + + -- Check if we have a non-standard target (e.g., @mouseover) + -- QueueSpellByName in older Nampower versions doesn't support target parameter + -- Only Nampower v2.13+ supports QueueSpellByName with target + local hasNonStandardTarget = target and target ~= "target" + local canQueueWithTarget = API.features.hasQueueSpellWithTarget + + -- Cast the spell + if useQueue and API.features.hasSpellQueue and QueueSpellByName then + if hasNonStandardTarget then + if canQueueWithTarget then + -- Nampower v2.13+ supports target parameter in QueueSpellByName + QueueSpellByName(spellName, target) + return true + else + -- Older Nampower: can't queue with non-standard target + -- Fall through to CastSpellByName with target + end + else + -- No target or target is "target" - safe to use queue + QueueSpellByName(spellName) + return true + end + end + + -- Use standard casting (either queuing wasn't possible or we need target support) + if CastSpellByName then + if target and CleveRoids.hasSuperwow then + -- SuperWoW supports target parameter + CastSpellByName(spellName, target) + else + CastSpellByName(spellName) + end + return true + end + + return false +end + +-- Cast without queuing (uses CastSpellByNameNoQueue if available, v2.12+) +function API.CastNoQueue(spellName, target) + if not spellName then return false end + + -- Use Nampower's no-queue function if available (v2.12+) + if API.features.hasCastSpellByNameNoQueue and CastSpellByNameNoQueue then + CastSpellByNameNoQueue(spellName) + return true + end + + -- Fall back to standard cast + if CastSpellByName then + if target and CleveRoids.hasSuperwow then + CastSpellByName(spellName, target) + else + CastSpellByName(spellName) + end + return true + end + + return false +end + +-- Force queue a spell (uses QueueSpellByName directly, v2.0+) +function API.ForceQueue(spellName) + if not spellName then return false end + + if API.features.hasSpellQueue and QueueSpellByName then + QueueSpellByName(spellName) + return true + end + + -- Fall back to standard cast if queue not available + if CastSpellByName then + CastSpellByName(spellName) + return true + end + + return false +end + +-- Queue a script to run after current cast (uses QueueScript if available, v2.12+) +function API.QueueScript(script, priority) + if not script then return false end + + if API.features.hasQueueScript and QueueScript then + QueueScript(script, priority or 1) + return true + end + + return false +end + +-- Clear spell type cache (call on spec change) +function API.ClearSpellTypeCache() + API.spellTypeCache = {} +end + +-- Expose API globally for other addons +_G.CleveRoidsNampowerAPI = API diff --git a/README.md b/README.md index 4ec847a..d37dc04 100644 --- a/README.md +++ b/README.md @@ -1,498 +1,723 @@ # SuperCleveRoid Macros -This was originally an effort to bring the dynamic tooltip and cast sequence functionality of [CleverMacro](https://github.com/DanielAdolfsson/CleverMacro) into [Roid-Macros](https://github.com/MarcelineVQ/Roid-Macros). It has since expanded after some additional changes I wanted along with feedback from others. The majority of credit goes to the [original addon authors](#original-addons--authors). Still a work in progress. -# REQUIRED DLLS -[SuperWoW](https://github.com/balakethelock/SuperWoW) -[Nampower](https://github.com/pepopo978/nampower) -[UnitXP_SP3](https://codeberg.org/konaka/UnitXP_SP3) -# ALL 3 ARE REQUIRED!!! -Check slash command and all conditional lists for new usages! +Enhanced macro addon for World of Warcraft 1.12.1 (Vanilla/Turtle WoW) with dynamic tooltips, conditional execution, and extended syntax. ---- +## Requirements + +| Mod | Required | Purpose | +|-----|:--------:|---------| +| [SuperWoW](https://github.com/balakethelock/SuperWoW/releases) | ✅ | Extended API (addon won't load without it) | +| [Nampower](https://gitea.com/avitasia/nampower/releases) (v2.24+) | ✅ | Spell queueing, DBC data, auto-attack events | +| [UnitXP_SP3](https://codeberg.org/konaka/UnitXP_SP3/releases) | ✅ | Distance checks, `[multiscan]` enemy scanning | ## Installation -### Manual -1. Download a zip from the green Code button or by clicking [here](https://github.com/jrc13245/SuperCleveRoidMacros/archive/refs/heads/main.zip) -2. Unzip the file and place the folder into your `Interface/Addons` folder. -3. Rename the `SuperCleveRoidMacros-main` folder to `SuperCleveRoidMacros` -4. Check that it is enabled in your addon list in-game. -5. Make sure you don't have other macro addons that may interfere. -6. SUPERWOW ,NAMPOWER, and UNITXP_SP3 are REQUIRED! -7. Disable Macrotweak module in pfui, and disable macrotweak in shagutweaks! -8. Disable Scan Macros for spells in the actionbar section of pfui! -* [SuperWoW](https://github.com/balakethelock/SuperWoW) dll mod is required -* [Nampower](https://github.com/pepopo978/nampower) dll mod is required -* [UnitXP_SP3](https://codeberg.org/konaka/UnitXP_SP3) dll mod is required +1. Download and extract to `Interface/AddOns/SuperCleveRoidMacros` +2. **Recommended pfUI:** Use [jrc13245/pfUI](https://github.com/jrc13245/pfUI) for full compatibility with macro spell scanning and action bar features -### SuperCleveRoidMacros Settings -* `/cleveroid` - View current settings -* `/cleveroid realtime 0 or 1` - Force realtime updates rather than event based updates (Default: 0. 1 = on, increases CPU load) -* `/cleveroid refresh X` - Set refresh rate (1 to 10 updates per second. Default: 5) -* `/cleveroid learn ` - Manually set spell duration in seconds -* `/cleveroid forget ` - Forget learned spell duration(s) -* `/cleveroid debug [0|1]` - Toggle debug messages for spell duration learning +--- ---- +## Quick Start + +### Basic Syntax +```lua +#showtooltip +/cast [mod:alt] Frostbolt; [mod:ctrl] Fire Blast; Blink +``` + +- **Conditionals** in `[]` brackets, space or comma separated +- **Arguments** use colon: `[mod:alt]`, `[hp:>50]` +- **Negation** with `no` prefix: `[nobuff]`, `[nomod:alt]` +- **Target** with `@`: `[@mouseover,help]`, `[@party1,hp:<50]` +- **Spell names** with spaces: `"Mark of the Wild"` or `Mark_of_the_Wild` + +### Multi-Value Logic +```lua +[buff:X/Y] -- X OR Y (has either) +[buff:X&Y] -- X AND Y (has both) +[nobuff:X/Y] -- NOT X AND NOT Y (missing both) - operators flip for negation +``` + +### Comparisons +```lua +[hp:>50] -- Health above 50% +[buff:"Name"<5] -- Less than 5 seconds remaining +[debuff:"Name">#3] -- 3+ stacks (use ># for stacks) +``` + +### Special Prefixes +| Prefix | Example | Description | +|:------:|---------|-------------| +| `!` | `!Attack` | Only use if not active | +| `?` | `?[equipped:Swords] Ability` | Hide from tooltip | +| `~` | `~Slow Fall` | Toggle buff on/off | + +--- + +## Conditionals Reference + +All conditionals support negation with `no` prefix (e.g., `[nocombat]`, `[nobuff]`, `[nohelp]`). Some also have semantic opposites: `help`/`harm`, `isplayer`/`isnpc`, `alive`/`dead`, `inrange`/`outrange`. + +### Modifiers & Player State +| Conditional | Example | Description | +|-------------|---------|-------------| +| `mod` | `[mod:alt/ctrl/shift]` | Modifier key pressed | +| `combat` | `[combat]` `[combat:target]` | In combat (player or unit) | +| `form/stance` | `[form:1]` `[stance:2]` | Shapeshift/stance index | +| `stealth` | `[stealth]` | In stealth (Rogue/Druid) | +| `group` | `[group]` `[group:party/raid]` | Player in group type | +| `resting` | `[resting]` | In rest area | +| `swimming` | `[swimming]` | Can use aquatic form | +| `moving` | `[moving]` `[moving:>100]` | Moving / speed % (MonkeySpeed) | +| `zone` | `[zone:"Ironforge"]` | Current zone name | + +### Resources +| Conditional | Example | Description | +|-------------|---------|-------------| +| `myhp` | `[myhp:<30]` | Player HP % | +| `myrawhp` | `[myrawhp:>1000]` | Player raw HP value | +| `myhplost` | `[myhplost:>500]` | Player HP lost (max - current) | +| `mypower` | `[mypower:>50]` | Player mana/rage/energy % | +| `myrawpower` | `[myrawpower:>500]` | Player raw power value | +| `mypowerlost` | `[mypowerlost:>200]` | Player power lost | +| `druidmana` | `[druidmana:>=500]` | Druid mana while shapeshifted | +| `combo` | `[combo:>=4]` | Combo points | +| `stat` | `[stat:agi>100]` `[stat:ap>1000]` | Player stats (see below) | + +**Stat types:** `str`, `agi`, `stam`, `int`, `spi`, `ap`, `rap`, `healing`, `armor`, `defense`, `arcane_power`, `fire_power`, `frost_power`, `nature_power`, `shadow_power`, `arcane_res`, `fire_res`, `frost_res`, `nature_res`, `shadow_res` + +### Buffs & Debuffs +| Conditional | Example | Description | +|-------------|---------|-------------| +| `mybuff` | `[mybuff:"Name"<5]` | Player has buff (with time check) | +| `mydebuff` | `[mydebuff:"Name"]` | Player has debuff | +| `mybuffcount` | `[mybuffcount:>15]` `[nomybuffcount:>28]` | Player buff slot count (32 max) | +| `buff` | `[buff:"Name">#3]` | Target has buff (with stacks) | +| `debuff` | `[debuff:"Sunder">20]` | Target has debuff (with time) | +| `cursive` | `[cursive:Rake<3]` | Cursive addon tracking (GUID-based) | + +### Cooldowns & Casting +| Conditional | Example | Description | +|-------------|---------|-------------| +| `cooldown` | `[cooldown:"Spell"<5]` | CD remaining (ignores GCD) | +| `cdgcd` | `[cdgcd:"Spell">0]` | CD remaining (includes GCD) | +| `gcd` | `[gcd]` `[gcd:<1]` | GCD is active / remaining time | +| `usable` | `[usable:"Spell"]` | Spell/item is usable | +| `reactive` | `[reactive:Overpower]` | Reactive ability available | +| `known` | `[known:"Spell">#2]` | Spell/talent known (with rank) | +| `channeled` | `[channeled]` | Currently channeling | +| `channeltime` | `[channeltime:<0.5]` | Channel time remaining (seconds) | +| `selfcasting` | `[selfcasting]` | Player is casting/channeling | +| `casttime` | `[casttime:<0.5]` | Player cast time remaining | +| `checkcasting` | `[checkcasting]` `[checkcasting:Frostbolt]` | NOT casting (specific spell) | +| `checkchanneled` | `[checkchanneled]` | NOT channeling (specific spell) | +| `queuedspell` | `[queuedspell]` `[queuedspell:Fireball]` | Spell queued (Nampower) | +| `onswingpending` | `[onswingpending]` | On-swing spell pending | + +### Target Checks +| Conditional | Example | Description | +|-------------|---------|-------------| +| `exists` | `[@mouseover,exists]` | Unit exists | +| `alive/dead` | `[alive]` `[dead]` | Alive or dead | +| `help/harm` | `[help]` `[harm]` | Friendly or hostile | +| `hp` | `[hp:<20]` `[hp:>30&<70]` | Target HP % | +| `rawhp` | `[rawhp:>5000]` | Target raw HP value | +| `hplost` | `[hplost:>1000]` | Target HP lost | +| `power` | `[power:<30]` | Target power % | +| `rawpower` | `[rawpower:>500]` | Target raw power value | +| `powerlost` | `[powerlost:>100]` | Target power lost | +| `powertype` | `[powertype:mana/rage/energy]` | Target's power type | +| `level` | `[level:>60]` `[mylevel:=60]` | Unit level (skull = 63) | +| `class` | `[class:Warrior/Priest]` | Target class (players only) | +| `type` | `[type:Undead/Beast]` | Creature type | +| `isplayer` | `[isplayer]` | Target is a player | +| `isnpc` | `[isnpc]` | Target is an NPC | +| `targeting` | `[targeting:player]` `[targeting:tank]` | Unit targeting you / any tank | +| `istank` | `[istank]` `[@focus,istank]` | Unit is marked as tank (pfUI) | +| `casting` | `[casting:"Spell"]` | Unit casting spell | +| `party` | `[party]` `[party:focus]` | Unit in your party | +| `raid` | `[raid]` `[raid:mouseover]` | Unit in your raid | +| `member` | `[member]` | Target in party OR raid | +| `hastarget` | `[hastarget]` | Player has a target | +| `notarget` | `[notarget]` | Player has no target | +| `pet` | `[pet]` `[pet:Cat/Wolf]` | Has pet (with family) | +| `name` | `[name:Onyxia]` | Exact name match (case-insensitive) | +| `tag` | `[tag]` | Target is tapped (by anyone) | +| `notag` | `[notag]` | Target is not tapped | +| `mytag` | `[mytag]` | Target is tapped by you | +| `nomytag` | `[nomytag]` | Target is not tapped by you | +| `othertag` | `[othertag]` | Target is tapped by someone else | +| `noothertag` | `[noothertag]` | Not tapped by others (yours or unclaimed) | + +### Range & Position +| Conditional | Example | Description | +|-------------|---------|-------------| +| `distance` | `[distance:<40]` | Distance in yards (UnitXP) | +| `inrange` | `[inrange:"Spell"]` `[inrange:Spell>N]` | In spell range / count in range | +| `outrange` | `[outrange:"Spell"]` `[outrange:Spell>N]` | Out of spell range / count out | +| `meleerange` | `[meleerange]` `[meleerange:>N]` | In melee range / count in melee | +| `behind` | `[behind]` `[behind:>N]` | Behind target / count behind | +| `insight` | `[insight]` `[insight:>N]` | In line of sight / count in LoS | + +**Multi-Unit Count Mode:** Add operator + number to count enemies matching the condition (requires UnitXP). Operators: `>`, `<`, `>=`, `<=`, `=`, `~=` + +```lua +/cast [meleerange:>1] Whirlwind -- AoE if 2+ enemies in melee +/cast [behind:>=2] Blade Flurry -- Cleave if behind 2+ enemies +/cast [inrange:Multi-Shot>1] Multi-Shot -- AoE if 2+ in spell range +/cast [insight:>0] Arcane Explosion -- AoE if any enemy in LoS +``` + +### Equipment +| Conditional | Example | Description | +|-------------|---------|-------------| +| `equipped` | `[equipped:Daggers]` | Item/type equipped | +| `mhimbue` | `[mhimbue]` `[mhimbue:Instant_Poison]` | Main-hand has temporary imbue | +| `ohimbue` | `[ohimbue]` `[ohimbue:Crippling_Poison]` | Off-hand has temporary imbue | + +**Imbue Conditionals (Poisons, Oils, Sharpening Stones):** +```lua +[mhimbue] -- Has any temporary imbue +[mhimbue:Instant_Poison] -- Has specific imbue (tooltip match) +[mhimbue:<300] -- Imbue expires in < 300 seconds (5 min) +[mhimbue:Instant_Poison<300] -- Specific imbue with time check +[mhimbue:>#5] -- Has > 5 charges remaining +[nomhimbue] -- No temporary imbue (apply needed) +``` + +**Note:** Temporary imbues are detected via tooltip time/charge markers, filtering out permanent enchants like Crusader. + +### CC & Immunity +| Conditional | Example | Description | +|-------------|---------|-------------| +| `cc` | `[cc]` `[cc:stun/fear]` | Target has CC effect | +| `mycc` | `[mycc]` `[mycc:silence]` | Player has CC effect | +| `immune` | `[immune:fire]` `[immune:stun]` | Target IS immune (skip cast) | +| `noimmune` | `[noimmune]` `[noimmune:bleed]` | Target NOT immune (allow cast) | +| `resisted` | `[resisted]` `[resisted:full/partial]` | Last spell was resisted | + +**CC Types:** stun, fear, root, snare/slow, sleep, charm, polymorph, banish, horror, disorient, silence, disarm, daze, freeze, shackle + +**Loss-of-control** (checked by bare `[cc]`): stun, fear, sleep, charm, polymorph, banish, horror, freeze, disorient, shackle + +**Damage Schools:** physical, fire, frost, nature, shadow, arcane, holy, bleed + +### Addon Integrations +| Conditional | Addon | Example | Description | +|-------------|-------|---------|-------------| +| `swingtimer` | SP_SwingTimer | `[swingtimer:<15]` | Swing % elapsed | +| `stimer` | SP_SwingTimer | `[stimer:>80]` | Alias for swingtimer | +| `threat` | TWThreat | `[threat:>80]` | Threat % (100=pull) | +| `ttk` | TimeToKill | `[ttk:<10]` | Time to kill (seconds) | +| `tte` | TimeToKill | `[tte:<5]` | Time to execute (20% HP) | +| `cursive` | Cursive | `[cursive:Rake>3]` | GUID debuff tracking | +| `moving` | MonkeySpeed | `[moving:>100]` | Speed % (100=normal run) | +| `targeting:tank` | pfUI | `[targeting:tank]` | Target is attacking any tank | +| `istank` | pfUI | `[istank]` | Target is marked as tank | + +### pfUI Tank Integration + +Identify loose mobs in trash pulls by checking if enemies are targeting designated tanks. Works with pfUI's tank marking systems. + +**Tank Conditionals:** +| Conditional | Description | +|-------------|-------------| +| `[targeting:tank]` | Target IS attacking any player marked as tank | +| `[notargeting:tank]` | Target is NOT attacking any tank (loose mob!) | +| `[istank]` | Target unit IS marked as tank | +| `[noistank]` | Target unit is NOT marked as tank | + +**Setting Up Tanks in pfUI:** + +1. **Nameplate Off-Tank Names** (recommended): `/pfui` → Nameplates → Off-Tank Names + - Add tank names separated by `#`: `#TankName1#TankName2#TankName3` + - These names will show different colored nameplates AND work with `[targeting:tank]` + +2. **Raid Frame Toggle**: Right-click a player in raid frames → "Toggle as Tank" + - Only works when raid frames are visible + +**Example Macros:** +```lua +-- Pick up loose mobs (not targeting any tank) +/cast [multiscan:nearest,notargeting:tank,harm] Taunt + +-- Only taunt if mob is targeting you (tank) +/cast [targeting:player] Sunder Armor + +-- Assist tanks - attack what they're tanking +/cast [multiscan:nearest,targeting:tank,harm] Sunder Armor + +-- Emergency taunt on loose mob hitting a healer +/cast [notargeting:tank,notargeting:player] Taunt +``` + +**Debug Command:** +``` +/cleveroid tankdebug +``` +Shows all marked tanks, current target info, and conditional results. + +### Warrior Slam Conditionals +For optimizing Slam rotations without clipping auto-attacks: + +| Conditional | Description | +|-------------|-------------| +| `noslamclip` | True if Slam NOW won't clip auto-attack | +| `slamclip` | True if Slam NOW WILL clip auto-attack | +| `nonextslamclip` | True if instant NOW won't cause NEXT Slam to clip | +| `nextslamclip` | True if instant NOW WILL cause NEXT Slam to clip | + +```lua +/cast [noslamclip] Slam +/cast [slamclip] Heroic Strike -- Use HS when past slam window +/cast [nonextslamclip] Bloodthirst +``` + +### Auto-Attack Conditionals (Nampower v2.24+) + +Track player melee swings and incoming attacks. Requires `NP_EnableAutoAttackEvents=1` CVar. + +| Conditional | Example | Description | +|-------------|---------|-------------| +| `lastswing` | `[lastswing]` | Any melee swing in last 5 seconds | +| `lastswing:type` | `[lastswing:crit]` | Last swing was specific type | +| `lastswing:48] Shred +/cast [myrawpower:>40] Claw + +-- WITH /firstaction: Only Shred casts if energy >= 48, Claw is skipped +/firstaction +/cast [myrawpower:>48] Shred +/cast [myrawpower:>40] Claw + +-- Mixed mode: priority section + always-queue section +/firstaction +/cast [myrawpower:>48] Shred -- Priority: only one of these +/cast [myrawpower:>40] Claw +/nofirstaction +/cast Tiger's Fury -- Always evaluates (can queue alongside) +/startattack + +-- With conditionals +/firstaction [group] -- Priority mode only in groups +/cast [myrawpower:>48] Shred +/cast [myrawpower:>40] Claw +``` + +**Child Macro Behavior:** `/firstaction` and `/nofirstaction` in child macros (called via `{MacroName}`) affect subsequent lines in the parent macro. Think of the child's lines as being "inlined" at the call site. + +**Comparison:** +- `/stopmacro [cond]` - Stop if condition is true (regardless of cast success) +- `/skipmacro [cond]` - Stop current submacro only +- `/firstaction [cond]` - Stop on first **successful** cast/use (priority mode) +- `/nofirstaction [cond]` - Re-enable multi-queue behavior after `/firstaction` + +### Commands without Conditional Support + +| Command | Description | +|---------|-------------| +| `/retarget` | Clear invalid target, target nearest enemy | +| `/runmacro Name` | Execute macro by name (use `{MacroName}` in `/cast` for conditionals) | +| `/rl` | Reload UI | + +### UnitXP 3D Enemy Scanning + +`/target` with any conditionals automatically uses UnitXP 3D scanning to find enemies in line of sight, even without nameplates visible. The only exception is `[help]` without `[harm]` (friendly-only targeting). + +```lua +/target [name:Onyxia] -- Scans for exact name match +/target [nodead,harm] -- Scans for living enemies +/target [hp:<30] -- Scans for low HP enemies +/target [cc:stun] -- Scans for stunned enemies + +-- Kara 40 Mage Incantagos example -- +/target [name:Red_Affinity] +/cast [name:Red_Affinity] Fireball +/target [name:Blue_Affinity] +/cast [name:Blue_Affinity] Frostbolt +/target [name:Mana_Affinity] +/cast [name:Mana_Affinity] Arcane Missiles +``` + +If no matching target is found, your original target is preserved. + +--- + +## Features + +### Debuff Timer System +Auto-learns debuff durations from your casts. 335+ spells pre-configured. +```lua +/cast [nodebuff:Moonfire] Moonfire -- Apply if missing +/cast [debuff:Moonfire<4] Moonfire -- Refresh if < 4 sec left +/cast Wrath -- Filler +``` + +### Combo Point Tracking +Tracks finisher durations (Rip, Rupture, Kidney Shot) accounting for combo points spent. + +### Talent & Equipment Modifiers +Automatically adjusts tracked durations for talents (Imp. Gouge, Taste for Blood, etc.) and equipment (Idol of Savagery). + +### Special Mechanics (TWoW) +- **Carnage** (Druid): Detects Rip/Rake refresh from Ferocious Bite proc +- **Molten Blast** (Shaman): Flame Shock refresh detection +- **Conflagrate** (Warlock): Immolate reduction tracking +- **Dark Harvest** (Warlock): DoT acceleration compensation + +--- + +## Immunity Tracking Guide + +The addon auto-learns NPC immunities from combat. When a spell fails with "immune", the addon remembers it for that NPC. + +### Using `[noimmune]` + +| Usage | What it checks | +|-------|----------------| +| `[noimmune]` | Auto-detects spell's school from action | +| `[noimmune:fire]` | Fire immunity specifically | +| `[noimmune:bleed]` | Bleed immunity specifically | +| `[noimmune:stun]` | Stun CC immunity | + +### Common Examples + +```lua +-- Fire Mage: Skip immune targets +/cast [noimmune] Fireball + +-- Warlock: Check shadow immunity +/cast [noimmune:shadow] Corruption + +-- Rogue: Skip stun-immune targets +/cast [noimmune:stun] Cheap Shot +/cast Sinister Strike + +-- Druid: Check fear immunity for Hibernate +/cast [noimmune:sleep,type:Beast/Dragonkin] Hibernate +``` + +### Split Damage Spells (Rake, Pounce, Garrote) + +These spells have an initial physical hit + a bleed DoT. The addon **automatically checks BOTH immunities**: + +```lua +-- Checks both physical AND bleed immunity +/cast [noimmune] Rake + +-- With time check using Cursive +/cast [noimmune, cursive:Rake<1.5] Rake + +-- Multi-DoT rotation +/cast [noimmune, nocursive:Rake] Rake +/cast [noimmune, nocursive:Rip, combo:>=4] Rip +``` + +`[noimmune]` for these spells returns false (skip cast) if target is immune to **either** component. You can also check specific schools: +- `[noimmune:physical]` - only checks physical immunity +- `[noimmune:bleed]` - only checks bleed immunity + +### Pure Bleed Spells (Rip, Rupture, Rend) + +These are pure bleeds with no initial hit: + +```lua +-- Rip (pure bleed) +/cast [noimmune, combo:>=4] Rip + +-- Rupture (pure bleed) +/cast [noimmune, combo:>=4] Rupture +``` + +### Manually Adding Immunities + +If you know an NPC is immune before fighting: +```lua +/cleveroid addimmune "Boss Name" bleed -- Permanent bleed immunity +/cleveroid addimmune "Boss Name" fire "Shield" -- Fire immune only when buffed +/cleveroid addccimmune "Boss Name" stun -- Permanent stun immunity +``` + +### Viewing Learned Immunities + +```lua +/cleveroid listimmune -- All school immunities +/cleveroid listimmune bleed -- Only bleed immunities +/cleveroid listccimmune -- All CC immunities +/cleveroid listccimmune stun -- Only stun immunities +``` + +--- + +## Debuff Tracking Guide + +### Built-in Tracking (`[debuff]`) + +The addon checks debuffs on the target: + +```lua +-- Basic: Only cast if target doesn't have Moonfire +/cast [nodebuff:Moonfire] Moonfire + +-- Time check: Refresh when < 4 seconds left +/cast [debuff:Moonfire<4] Moonfire +``` + +**Notes:** +- Existence checks (`[nodebuff]`, `[debuff]`) detect ANY debuff on target +- Time-remaining checks (`[debuff:X<5]`) use internal tracking from your casts +- Shared debuffs (Sunder, Faerie Fire) are detected from any source + +### Cursive Integration (`[cursive]`) + +[Cursive](https://github.com/avitasia/Cursive) provides more accurate GUID-based tracking: + +```lua +-- Check if target has Rake (any time remaining) +/cast [nocursive:Rake] Rake + +-- Refresh when < 3 seconds remaining +/cast [cursive:Rake<3] Rake + +-- Complex: Only if missing OR about to expire +/cast [nocursive:Rake>1.5] Rake +``` + +**Advantages of Cursive:** +- GUID-based (survives target switching) +- Tracks pending casts +- Works at debuff cap +- More accurate timing + +### MonkeySpeed Integration (`[moving]`) + +[MonkeySpeed](https://github.com/jrc13245/MonkeySpeed) provides accurate movement speed detection via SuperWoW's `UnitPosition` API: + +```lua +-- Only cast if standing still +/cast [nomoving] Aimed Shot + +-- Cast instant ability while moving +/cast [moving] Arcane Shot + +-- Check speed percentage (100 = normal run speed) +/cast [moving:>100] Sprint -- Already faster than normal +/cast [moving:<50] Escape Artist -- Currently slowed + +-- Combined with other conditionals +/cast [nomoving, harm] Aimed Shot +/cast [moving, harm] Arcane Shot +``` + +**Speed Reference:** +| Speed | Description | +|-------|-------------| +| `0` | Standing still | +| `100` | Normal run speed | +| `160` | Level 40 mount | +| `200` | Epic mount | + +**Notes:** +- Basic `[moving]`/`[nomoving]` works without MonkeySpeed (position fallback) +- Speed comparisons (`[moving:>100]`) require MonkeySpeed addon +- Speed values vary with buffs/debuffs affecting movement + +### Recommended DoT Macro Patterns + +**Druid Feral (with Cursive):** +```lua +#showtooltip +/cast [noimmune, nocursive:Rake] Rake +``` + +**Druid Feral (without Cursive):** +```lua +#showtooltip +/cast [noimmune, nodebuff] Rake +``` + +**Warlock Multi-DoT:** +```lua +#showtooltip +/cast [noimmune:shadow, nodebuff] Corruption +``` + +**Rogue Rupture:** +```lua +#showtooltip +/cast [noimmune, nodebuff, combo:>=4] Rupture +``` + +--- + +## Settings + +```lua +/cleveroid -- View settings +/cleveroid realtime 0|1 -- Instant updates (more CPU) +/cleveroid refresh 1-10 -- Update rate in Hz +/cleveroid debug 0|1 -- Debug messages +/cleveroid learn -- Manually set spell duration +/cleveroid forget -- Forget duration(s) +``` + +### Immunity Commands +```lua +/cleveroid listimmune [school] +/cleveroid addimmune "NPC" school [buff] +/cleveroid listccimmune [type] +/cleveroid addccimmune "NPC" type [buff] +``` + +### Combo Tracking +```lua +/combotrack show|clear|debug +``` + +--- + +## Supported Addons + +**Unit Frames:** [pfUI](https://github.com/jrc13245/pfUI), LunaUnitFrames, XPerl, Grid, CT_UnitFrames, agUnitFrames, and more + +**Action Bars:** Blizzard, [pfUI](https://github.com/jrc13245/pfUI), Bongos, Discord Action Bars + +**Integrations:** SP_SwingTimer, TWThreat, TimeToKill, QuickHeal, Cursive (with mouseover support for DoT timer bars), ClassicFocus, SuperMacro, MonkeySpeed + +> **Note:** For pfUI users, the [jrc13245/pfUI fork](https://github.com/jrc13245/pfUI) includes native SuperCleveRoidMacros integration for proper cooldown, icon, and tooltip display on conditional macros. + +--- ## Known Issues -* By default, conditional checks to update icons are event based with realtime set to 0. This means icons using some conditionals that arent triggered by events are slow to change. example: @unitid mod etc. Enable realtime setting, but beware increase in cpu usage and lag. -* ALL macros must be given unique names, no blank names or muliple of the same name or using spell names. -* If any of your macros have syntax errors, it will affect all macros when it comes to errors. -* Spells with parenthesis ie: Faerie Fire (Feral) or Barkskin (Feral) MUST be written using ranks, becomming `Faerie Fire (Feral)(Rank X)` -* Minor: If you improperly create a macro, in some certain cases it can cause strange UI issues including not displaying any icons, other macros not working or lua errors. Fix the macro or remove it from your actionbar and it should go back to normal automatically. Depending on the cause, you may need to reload your UI (/rl). If you find one of these and can reproduce the issue, let me know. -* Minor: HealComm support. See [below](#healcomm-support) -* I have not tested all possible combinations of conditionals or ways to break things. Find me on Discord or open an issue if you find any bugs or have feedback. + +- Unique macro names required (no blanks, duplicates, or spell names) +- Reactive abilities must be on action bars for detection +- HealComm requires MarcelineVQ's [LunaUnitFrames](https://github.com/MarcelineVQ/LunaUnitFrames) for SuperWoW compatibility --- -# What's Different -### ***Important! Spells, Items, conditionals, etc are case sensitive. Barring a bug, if there is an issue, it's almost always because something is typed incorrectly.*** -## Dynamic Icons and Tooltips -* The icon and tooltip for a macro will automatically update to the first true condition's action. Left to right, top to bottom. -* Consumables and certain other item types will now show a count on the action bar. -* Spells with reagent costs will now show a count of how many uses you have. -* Full `#showtooltip` support with dynamic icons and tooltips - * A macro must start with `#showtooltip` or `#showtooltip spell/item/itemid`. The icons and tooltips will update on your bars as the conditions are met. +## Credits - ```lua - #showtooltip - /cast [mod:alt] Frostbolt; [mod:ctrl] Fire Blast; [mod:shift] {MyMacro}; Blink - ``` - ```lua - #showtooltip - /cast [stance:1/3, nocooldown, notargeting:player] Mocking Blow - /cast [stance:2/3, nocooldown, notargeting:player] Taunt - /cast Shield Slam - ``` - - One line or separate lines are valid, sometimes one is better than the other +Based on [CleverMacro](https://github.com/DanielAdolfsson/CleverMacro) and [Roid-Macros](https://github.com/DennisWG/Roid-Macros). Cursive integration by Avitasia. - ``` - #showtooltip - /cast [zone:Ahn'Qiraj] Yellow Qiraji Battle Tank;[nozone:Orgrimmar/Undercity/"Thunder Bluff" nomybuff:"Water Walking"] Red Goblin Shredder;Plainsrunning - ``` - Is the same as - ``` - #showtooltip - /cast [zone:Ahn'Qiraj] Yellow Qiraji Battle Tank - /cast [nozone:Orgrimmar/Undercity/"Thunder Bluff" nomybuff:"Water Walking"] Red Goblin Shredder - /cast Plainsrunning - ``` - - -* Updated `/castsequence` support. See [below](#cast-sequence) -* Added support for updating to the icon/tooltip of the {macro} named in a another macro - * Macro 1 calls Macro 2 and will update the icon when out of combat with Arcane Missiles from Macro2's `#showtooltip` - - ***Note: This will only use the icon/tooltip of either the referenced `#showtooltip ` or the icon you set for the macro.*** - - Macro 1: - ```lua - #showtooltip - /cast [nocombat] {Macro2}; Fireball - ``` - Macro 2 - ```lua - #showtooltip Arcane Missiles - /cast [mod] Conjure Food - /use 2288 - ``` -* Added support for itemid lookup instead of the item's name. Using this will show the icon/tooltip even if you don't have the item cached or in your inventory so you don't see ? icons. e.g. No Mage food/water on login - - ```lua - #showtooltip - /use [nomod] 5350 - /cast [mod] Conjure Water - ``` -*Added support of using equipped items by slot number - - ```lua - #showtooltip - /use 16 - ``` ---- - -## Debuff Timer System (SuperWoW) BY: yani9o -SuperCleveRoidMacros includes a built-in **debuff timer tracking system** that accurately tracks your debuff durations using SuperWoW's advanced features: - -### Features -* ✅ **Auto-Learning** - Automatically learns debuff durations as you cast them -* ✅ **Accurate Tracking** - Only tracks debuffs that successfully land (no false timers on misses) -* ✅ **329+ Debuffs Pre-configured** - Extensive database of vanilla 1.12.1 spell IDs -* ✅ **Per-Caster Storage** - Handles talent variations by tracking per player GUID -* ✅ **GUID-Based** - Correctly handles multiple mobs with the same name -* ✅ **No pfUI Required** - Fully independent debuff tracking system - -### How It Works -1. When you cast a debuff, the system records the timestamp -2. When the debuff fades, it calculates the actual duration -3. Duration is saved and used for all future casts of that spell -4. You can check debuff timers in macros using conditionals like `[debuff:Sunder Armor>25]` - -### Manual Control -```lua -/cleveroid learn 11597 30 -- Set Sunder Armor (ID 11597) to 30 seconds -/cleveroid forget 11597 -- Forget Sunder Armor duration -/cleveroid forget all -- Clear all learned durations -/cleveroid debug 1 -- Enable learning messages in chat -``` - -### Macro Examples -```lua --- Maintain Sunder Armor with 25s buffer -/cast [debuff:Sunder Armor>25] Heroic Strike; Sunder Armor - --- Refresh Corruption when <4s remain -/cast [debuff:Corruption<4] Corruption; Shadow Bolt - --- Apply Serpent Sting if missing or <5s -/cast [nodebuff:Serpent Sting] Serpent Sting; [debuff:Serpent Sting<5] Serpent Sting; Steady Shot - --- Multi-DoT priority system -/cast [nodebuff:Moonfire] Moonfire -/cast [debuff:Moonfire<4] Moonfire -/cast [nodebuff:Insect Swarm] Insect Swarm -/cast Wrath -``` - -### Supported Debuffs -The system includes pre-configured durations for 329+ debuffs across all classes: -- **Warrior:** Sunder Armor, Rend, Hamstring, Thunder Clap, Demoralizing Shout, etc. -- **Rogue:** Rupture, Garrote, Expose Armor, Poisons, etc. -- **Hunter:** Serpent Sting, Hunter's Mark, Wing Clip, Wyvern Sting, etc. -- **Druid:** Rip, Rake, Moonfire, Insect Swarm, Faerie Fire, Entangling Roots, etc. -- **Warlock:** Corruption, Curses, Immolate, Siphon Life, etc. -- **Mage:** Polymorph (all variants), Slow effects, etc. -- **Priest:** Shadow Word: Pain, Devouring Plague, Mind Flay, etc. -- **Paladin:** Judgements, Hammer of Justice, etc. -- **Shaman:** Flame Shock, Frost Shock, etc. - -### Technical Details -- Uses **UNIT_CASTEVENT** for precise cast detection (only tracks successful hits) -- Uses **RAW_COMBATLOG** to detect when debuffs fade -- Stores durations in **SavedVariables** (persists between sessions) -- Falls back to static database for unknown spells -- No dependency on pfUI or other addons - ---- -# Usage -## Slash Commands -| Command | Conditionals Supported | Purpose | -|-----------------------| :-: |---------| -| /cleveroid | | show update settings. /cleveroid realtime 0/1 : /cleveroid refresh 1-10 | -| /target | * | Targets a unit that matchets the conditionals. Requires friendly nameplates for friendly non-party/raid units or their pets. Requires enemy nameplates for non-friendly units. | -| /retarget | | Clears your target if it doesn't exist, has 0 hp or if you can't attack it and then targets the nearest enemy. | -| /startattack | * | Starts auto-attacking. | -| /stopattack | * | Stops auto-attacking. | -| /stopcasting | * | Stops casting. | -| /unqueue | * | Stops casting. | -| /petattack | * | Command pet to Attack. | -| /petfollow | * | Command pet to Follow. | -| /petwait | * | Command pet to Wait (Stay). | -| /petaggressive | * | Set pet mode to Aggressive. | -| /petdefensive | * | Set pet mode to Defensive. | -| /petpassive | * | Set pet mode to Passive. | -| /castsequence | * | Performs a cast sequence. See [below](#cast-sequence) for more infomation. | -| /equip | * | Equips an item by name or itemid. | -| /equipmh | * | Equips an item by name or itemid into your mainhand slot. | -| /equipoh | * | Equips an item by name or itemid into your offhand slot. | -| /unshift | * | Cancels your current shapeshift form. | -| /cancelaura, /unbuff | | Cancels a valid buff/aura. | -| /runmacro | | Runs a macro. Use /runmacro {macroname} | -| /use | * | Uses an item by name or id | -| /cast | * | Casts a spell by name | -| /stopmacro | * | prevent any lines under /stopmacro from being run unless conditionals are met | - ---- - -## Cast Sequence - ```lua - #showtooltip - /castsequence reset=3 Fireball, Frostbolt, Arcane Explosion, Fire Blast - ``` -* Dynamic icons and tooltips on each sequence with range/mana/usable checks -* Supports reset=#/mod/target/combat -* Supports conditionals for the entire sequence, not individual actions within the sequence. - ```lua - #showtooltip - /castsequence [group:party/raid] reset=3/target Sweaty Spell 1, Sweaty Spell 2, Sweaty Spell 3 - /castsequence reset=target/combat Casual Spell 1, Casual Spell 2, Casual Spell 3 - ``` - -## Conditionals -* Conditionals are basically if/then/else statements which are evaluated left to right, top to bottom. -* The first set of conditions that are all true will be the action that happens so the order you put things in is very important. -* Multiple conditionals can be used, separated with a space or comma. -* Spells and items with spaces in the name need to use underscores (`_`) instead of spaces or be enclosed in quotations. -* Each action can be written on one line or on separate. Both are effectively the same however WoW executes every line of a macro so it is technically better to use one line where possible. I usually prefer separate lines because it's easier to read and troubleshoot at a quick glance. You do you. - - - **startattack, stopattack, stopacasting:** - ``` - #showtooltip - /startattack [harm alive] - - #showtooltip - /stopattack [form:0/1/2/3] - - #showtooltip - /stopcasting [hp:<=20] - ``` - **Item Slots 1-19 supports /use only right now:** - ``` - #showtooltip - /use [combat,hp:<=20]13 - ``` - **Separate Lines:** - ``` - #showtooltip - /use [@mouseover alive help hp:<70 nodebuff:"Recently Bandaged"] Runecloth Bandage - /use [@target, alive, help, hp:<70, nodebuff:Recently_Bandaged] Runecloth Bandage - /use [@player] Runecloth Bandage - ``` - **One Line:** - ``` - #showtooltip - /use [@mouseover alive help hp:<70 nodebuff:"Recently Bandaged"] Runecloth Bandage; [@target, alive, help, hp:<70, nodebuff:Recently_Bandaged] Runecloth Bandage; [@player] Runecloth Bandage - ``` - **Note: Classic/Retail macros allow for cascading conditional blocks like below. This is NOT supported.** - ``` - #showtooltip - /use [@mouseover alive help hp:<70][@target alive help hp:<70][@player][] Runecloth Bandage - ``` - -* If a conditional on the table is marked as **Multi** you can provide multiple values in the same condition. *Only one needs to be true.* - `[targeting:party1/party2/party3/party4]` -- targeting one of your party members - `[zone:Stormwind/Ironforge]` -- in Stormwind OR Ironforge -* If a conditional is marked as **Noable** you can prefix the condition with "no" to test the opposite condition. *All need to be true* - `[notargeting:player]` -- not targeting the player - `[nozone:Stormwind/Ironforge]` -- not in Stormwind AND not in Ironforge -* You can specifiy a target unit for the macro by adding in a valid @unitid - `/cast [@party1 combat] Intervene` -- casts Intervene on party member 1 if you (player) are in combat -* @focus / focus can be used as an @unitid / conditional unitid however Vanilla does not support focus targets, a compatible addon is required to make this function. -* If the conditional allows for a numerical comparison, the format is `condition:>X` or `condition:"Some Value">X`. If you want to compare stacks/rank (where applicable) and not remaining time, use `>#X`. - * **Only the player's buffs/debufs can be checked for remaining time** - * Valid operators are =, ~=, >, >=, <, <= -* You can omit the value of a conditional if you want to check the same spell/item that you are using in the action. - `[debuff:"Sunder Armor"<#5] Sunder Armor` == `[debuff:<#5] Sunder Armor` - `[nobuff:"Mark of the Wild"] Mark of the Wild` == `[nobuff] Mark of the Wild` -* [SuperWoW](https://github.com/balakethelock/SuperWoW) dll mod is required -* [Nampower](https://github.com/pepopo978/nampower) dll mod required -* [UnitXP_SP3](https://codeberg.org/konaka/UnitXP_SP3) dll mod required - -### Special Characters -| Character | Syntax Examples | Description | -| :-: |---------------|-------------| -| ! | !Attack
!Cat Form
!Spell Name | Prefix on spell name.
Will only use the spell if it's not already active.
Can also be used with other spells as shorthand for `nomybuff` | -| ? | /use ?[equipped:Swords]
/cast ?Presence of Mind | Prevents the icon and tooltip from showing.
Must be the first character. | -| ~ | ~Slow Fall | Prefix on spell name. Acts as a toggle ability.
Will cast or cancel the buff/aura if possible. | - -### Key Modifiers -| Conditional | Syntax Examples | Multi | Noable | Tests For | -|----------------|---------------| :-: | :-: |-----------| -| mod | [mod]
[mod:ctrl/alt/shift] | * | * | If any mod key is pressed.
If one of the listed modifier keys are pressed. | - - -### Player Only -| Conditional | Syntax Examples | Multi | Noable | Tests For | -|----------------|---------------| :-: | :-: |-----------| -| cdgcd | [cdgcd]
[cdgcd:"Name"]
[cdgcd:"Name">X] | * | * | If the Spell or Item is on cooldown and optionally if the amount of time left is >= or <= than X seconds. **GCD NOT IGNORED** | -| channeled | [channeled] | | * | If the player is currently channeling a spell. | -| combo | [combo:>#3]
[combo:#2]
[combo:<#5] | * | * | If the player has the specified number of combo points. | -| cooldown | [cooldown]
[cooldown:"Name"]
[cooldown:"Name"= or <= than X seconds. **GCD (if exatly 1.5 sec) IGNORED** | -| equipped | [equipped:"Name"]
[equipped:Shields]
[equipped:Daggers2] | * | * | If the player has an item name/id or item type equipped. See [below](#weapon-types) for a list of valid Weapon Types. | -| form | [form:0/1/2/3/4/5] | * | * | Alias of `stance` | -| group | [group]
[group:party/raid] | * | * | If the player is in any group or specific group type. | -| known | [known]
[known:"Name"]
[known:"Name">#2] | * | * | If the player knows a spell or talent. Can optionally check the rank. | -| mybuff | [mybuff]
[mybuff:"Name"]
[mybuff:"Name">#X]
[mybuff:Optionally compared to X number of stacks.
Optionally compared to X time remaining. | -| mydebuff | [mydebuff]
[mydebuff:"Name"]
[mydebuff:"Name">#X]
[mydebuff:Optionally compared to X number of stacks.
Optionally compared to X time remaining. | -| myhp | [myhp:<=X]
[myhp:>=X/<=Y] | * | * | The player's health **PERCENT** compared to X. | -| myhplost | [myhplost:>=X]
[myhplost:>=X/<=Y] | * | | The player's lost health compared to X. | -| mypower | [mypower:>=X]
[mypower:>=X/<=Y] | * | * | The player's power (mana/rage/energy) **PERCENT** compared to X. | -| mypowerlost | [mypowerlost:>=X]
[mypowerlost:>=X/<=Y] | * | | The player's lost power (mana/rage/energy) compared to X. | -| myrawhp | [myrawhp:>=X]
[myrawhp:>=X/<=Y] | * | * | The player's health compared to X. | -| myrawpower | [myrawpower:>=X]
[myrawpower:>=X/<=Y] | * | * | The player's power (mana/rage/energy) compared to X. | -| druidmana | [druidmana:>=X]
[druidmana:>=X/<=Y] | * | | The druid's mana compared to X. **ONLY FOR DRUIDS** **Works in Cat or Bear form.** | -| reactive | [reactive]
[reactive:Overpower] | * | * | If the player has the reactive ability (Revenge, Overpower, Riposte, etc.) available to use.

**NOTE: Currently requires the reactive ability to be somewhere on your actionbars in addition to any macros you're using it in. A planned future update will remove this requirement if using [Nampower](https://github.com/pepopo978/nampower).** | -| resting | [resting] | | * | If the player is resting (in an inn/capital city/etc.) | -| stance | [stance:0/1/2/3/4/5] | * | * | If the player is in stance #.
Supports Shadowform and Stealth as stance 1.| -| stealth | [stealth] | | * | If the player is in Stealth or Prowl. | -| zone | [zone:"Zone"]
[zone:"Zone"/"Another Zone"] | * | * | If the player is in one or more zones of the given name. | -| checkchanneled | [checkchanneled] | * | | Prevents a spell from being cast if you are already channeling it. | -| stat | [stat:stat>=x/<=y] | * | | Check if one of the players statistics is greater or less than a specific number. Available Stats: str/strength, agi/agility, stam/stamina, int/intellect, spi/spirit, ap/attackpower, rap/rangedattackpower, healing/healingpower, arcane_power, fire_power, frost_power, nature_power, shadow_power, armor, defense, arcane_res, fire_res, frost_res, nature_res, shadow_res. | -| pet | [pet]
[pet:Voidwalker]
[pet:Imp/Felhunter] | * | * | If the player has a pet summoned and optionally if it matches the specified pet type(s). Works for Warlock demons and Hunter pets. | -| swimming | [swimming] | | * | Druid only, works like reactive but for aquatic form, must have aquatic form on one of your non-stance actionbars.*** | -| mybuffcount | [mybuffcount:>=X]
[mybuffcount:<=X] | | | If the player has more or less auras present than X.| -| queuedspell | [queuedspell]
[queuedspell:X] | | * | if the player has any or a specific spell queued with nampower. | -| onswingpending | [onswingpending] | | * | If the player has a on swing spell pending.| -| mhimbue/ohimbue | [mhimbue:Flametongue]
[ohimbue] | | * | If the player has weapon imbue on their mh/oh.| - -### Unit Based -### The default @unitid is usually @target if you don't specify one -### The only conditionals that take conditional:unitid are combat/nocombat and targeting/notargeting -| Conditional | Syntax | Multi | Noable | Tests For | -|----------------|---------------| :-: | :-: |-----------| -| alive | [alive] | | * | If the @unitid is NOT dead or a ghost. | -| buff | [buff]
[buff:"Name"]
[buff:"Name">#X]
[buff:"Name"= or <= than X number of stacks. | -| casting | [casting]
[casting:"Spell Name"] | * | * | If the @unitid is casting any or one or more specific spells. | -| combat | [combat]
[combat:target] | * | * | If the unitid (default is player) is in combat. | -| dead | [dead] | | * | If the @unitid is dead or a ghost. | -| debuff | [debuff]
[debuff:"Name"]
[debuff:"Name">#X]
[debuff:= or <= than X number of stacks. | -| harm | [harm] | | * | If the @unitid is an enemy. | -| help | [help] | | * | If the @unitid is friendly. | -| hp | [hp:>=X]
[hp:>=X/<=Y] | * | | The @unitid health **PERCENT** compared to X. | -| hplost | [hplost:>=X]
[hplost:>=X/<=Y] | * | | The @unitid health lost compared to X. | -| inrange | [inrange]
[inrange:"Name"] | * | * | If the specified @unitid is in range of the spell. | -| outrange | [outrange]
[outrange:"Name"] | * | | If the specified @unitid is out of range of the spell. | -| isnpc | [isnpc] | | | If the @unitid is an npc.
See this [article](https://wowpedia.fandom.com/wiki/UnitId) for a list of unitids.
Not all units are valid in vanilla. | -| isplayer | [isplayer] | | | If the @unitid is a player.
See this [article](https://wowpedia.fandom.com/wiki/UnitId) for a list of unitids.
Not all units are valid in vanilla. | -| member | [member] | * | | If the @unitid is in your party OR raid. | -| party | [party] | | * | If the @unitid is in your party. | -| power | [power:>=X]
[power:>=X/<=Y] | * | | The @unitid power (mana/rage/energy) **PERCENT** compared to X. | -| powerlost | [powerlost:>=X]
[powerlost:>=X/<=Y] | * | | The @unitid power (mana/rage/energy) lost compared to X. | -| raid | [raid] | | * | If the @unitid is in your raid. | -| rawphp | [rawhp:>=X]
[rawhp:>=X/<=Y] | * | | The @unitid health compared to X. | -| rawpower | [rawpower:>=X]
[rawpower:>=X/<=Y] | * | | The @unitid power (mana/rage/energy) compared to X. | -| type | [type:"Creature Type"] | * | * | If the @unitid is the specified creature type. See [below](#creature-types) for a list of valid Creature Types. | -| targeting | [targeting:unitid] | * | * | If the @unitid is targeting the specified unitid.
See this [article](https://wowpedia.fandom.com/wiki/UnitId) for a list of unitids.
Not all units are valid in vanilla. | -| exists | [exists] | | * | If the @unitid exists. | -| @unitid | [@mouseover] | | | The @unitid is a valid target. | -| class | [class:classname1/classname2]
[class:Warrior/Priest] | * | * | The target is a player of the specified class/classes. | -| distance | [distance:>X]
[distance: