diff --git a/ComboPointTracker.lua b/ComboPointTracker.lua index a670a0a..636bcc0 100644 --- a/ComboPointTracker.lua +++ b/ComboPointTracker.lua @@ -574,25 +574,7 @@ function CleveRoids.ShowComboTracking() 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 +-- Combo commands now consolidated under /cleveroid combotrack, comboclear, combodebug -- Export to global namespace NOW, before Extension registration _G["CleveRoids"] = CleveRoids diff --git a/Conditionals.lua b/Conditionals.lua index a397732..c11c358 100644 --- a/Conditionals.lua +++ b/Conditionals.lua @@ -4815,6 +4815,31 @@ CleveRoids.MULTISCAN_PRIORITIES = { triangle = 4, diamond = 3, circle = 2, star = 1, } +-- Multiscan options (Cursive-inspired filters that can combine with any priority) +-- Usage: [multiscan:priority,option1,option2,minhp=1000,refreshtime=3,name="Mob Name"] +-- Options: +-- allowooc - Allow out of combat targets (default: only in-combat) +-- priotarget - Always prioritize current target first +-- ignoretarget - Ignore current target when scanning +-- playeronly - Only target players, ignore NPCs +-- npconly - Only target NPCs, ignore players +-- tracked - Only consider targets in MultiTargetTracker +-- minhp=X - Minimum HP threshold (raw value) +-- maxhp=X - Maximum HP threshold (raw value) +-- minhppct=X - Minimum HP percentage (0-100) +-- maxhppct=X - Maximum HP percentage (0-100) +-- refreshtime=X - Only target if debuff has less than X seconds remaining (for DoT refresh) +-- name=X - Filter by name (partial match, use quotes for spaces: name="Mob Name") +-- noname=X - Exclude targets with name containing X +CleveRoids.MULTISCAN_OPTIONS = { + allowooc = true, + priotarget = true, + ignoretarget = true, + playeronly = true, + npconly = true, + tracked = true, +} + -- Static conditionals that don't depend on target (checked before scanning) CleveRoids.STATIC_CONDITIONALS = { group = true, nogroup = true, @@ -4841,19 +4866,30 @@ CleveRoids.STATIC_CONDITIONALS = { --- @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) +--- @param options table|nil Parsed multiscan options +--- @param conditionals table|nil Full conditionals for options checking --- @return number|nil Score (lower = better) or nil if invalid candidate -function CleveRoids.GetMultiscanScore(unit, priority, currentTargetGuid, specifiedUnitGuid) +function CleveRoids.GetMultiscanScore(unit, priority, currentTargetGuid, specifiedUnitGuid, options, conditionals) 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 + + -- Combat check: must be in combat with player, UNLESS: + -- - it's current target OR specified @unit + -- - OR allowooc option is set + local allowOOC = options and options.allowooc + if not allowOOC and not isCurrentTarget and not isSpecifiedUnit and not UnitAffectingCombat(unit) then + return nil + end + + -- Check additional options filters + if options and not CleveRoids.CheckMultiscanOptions(unit, unitGuid, options, conditionals, currentTargetGuid) then return nil end @@ -4935,6 +4971,212 @@ function CleveRoids.CheckStaticConditionals(conditionals) return true end +--- Parse multiscan options from the conditional value +--- @param multiscanValue string|table The raw multiscan conditional value +--- @return string priority, table options +function CleveRoids.ParseMultiscanOptions(multiscanValue) + local options = { + allowooc = false, + priotarget = false, + ignoretarget = false, + playeronly = false, + npconly = false, + tracked = false, + minhp = nil, + maxhp = nil, + minhppct = nil, + maxhppct = nil, + refreshtime = nil, + name = nil, + noname = nil, + } + + local priority = nil + local values = {} + + -- Collect all values + if type(multiscanValue) == "table" then + for i = 1, table.getn(multiscanValue) do + table.insert(values, multiscanValue[i]) + end + elseif type(multiscanValue) == "string" then + -- Single value, might contain commas? No, conditionals system splits by / + -- The value is just the priority in this case + table.insert(values, multiscanValue) + end + + -- First value is always the priority + if table.getn(values) > 0 then + priority = string.lower(CleveRoids.Trim(values[1])) + end + + -- Remaining values are options + for i = 2, table.getn(values) do + local opt = CleveRoids.Trim(values[i]) + local optLower = string.lower(opt) + + -- Check for key=value options + local eqStart, eqEnd = string.find(optLower, "=") + if eqStart then + local key = string.sub(optLower, 1, eqStart - 1) + local value = string.sub(opt, eqEnd + 1) -- Keep original case for name + + -- Remove quotes from value if present + if string.sub(value, 1, 1) == '"' and string.sub(value, -1) == '"' then + value = string.sub(value, 2, -2) + end + + if key == "minhp" then + options.minhp = tonumber(value) + elseif key == "maxhp" then + options.maxhp = tonumber(value) + elseif key == "minhppct" then + options.minhppct = tonumber(value) + elseif key == "maxhppct" then + options.maxhppct = tonumber(value) + elseif key == "refreshtime" then + options.refreshtime = tonumber(value) + elseif key == "name" then + options.name = value + elseif key == "noname" then + options.noname = value + end + else + -- Boolean flag options + if optLower == "allowooc" then + options.allowooc = true + elseif optLower == "priotarget" then + options.priotarget = true + elseif optLower == "ignoretarget" then + options.ignoretarget = true + elseif optLower == "playeronly" then + options.playeronly = true + elseif optLower == "npconly" then + options.npconly = true + elseif optLower == "tracked" then + options.tracked = true + end + end + end + + return priority, options +end + +--- Check if a unit passes multiscan options filters +--- @param unit string Unit token +--- @param guid string Unit GUID +--- @param options table Parsed options +--- @param conditionals table Full conditionals table (for refreshtime spell check) +--- @param currentTargetGuid string|nil Current target GUID +--- @return boolean passes +function CleveRoids.CheckMultiscanOptions(unit, guid, options, conditionals, currentTargetGuid) + -- ignoretarget: Skip current target + if options.ignoretarget and currentTargetGuid and guid == currentTargetGuid then + return false + end + + -- playeronly: Only players + if options.playeronly and not UnitIsPlayer(unit) then + return false + end + + -- npconly: Only NPCs + if options.npconly and UnitIsPlayer(unit) then + return false + end + + -- tracked: Only tracked targets + if options.tracked then + local tracker = CleveRoids.MultiTargetTracker + if tracker and tracker.IsTracked then + if not tracker.IsTracked(guid) then + return false + end + else + return false -- Tracker not available + end + end + + -- minhp: Minimum raw HP + if options.minhp then + local hp = UnitHealth(unit) + if hp < options.minhp then + return false + end + end + + -- maxhp: Maximum raw HP + if options.maxhp then + local hp = UnitHealth(unit) + if hp > options.maxhp then + return false + end + end + + -- minhppct: Minimum HP percentage + if options.minhppct then + local maxHp = UnitHealthMax(unit) + if maxHp > 0 then + local pct = (UnitHealth(unit) / maxHp) * 100 + if pct < options.minhppct then + return false + end + end + end + + -- maxhppct: Maximum HP percentage + if options.maxhppct then + local maxHp = UnitHealthMax(unit) + if maxHp > 0 then + local pct = (UnitHealth(unit) / maxHp) * 100 + if pct > options.maxhppct then + return false + end + end + end + + -- name: Filter by name (partial match) + if options.name then + local unitName = UnitName(unit) or "" + if not string.find(string.lower(unitName), string.lower(options.name)) then + return false + end + end + + -- noname: Exclude by name (partial match) + if options.noname then + local unitName = UnitName(unit) or "" + if string.find(string.lower(unitName), string.lower(options.noname)) then + return false + end + end + + -- refreshtime: Only target if debuff expires within X seconds + -- Uses the spell from conditionals.action (the spell being cast) + if options.refreshtime then + local tracker = CleveRoids.MultiTargetTracker + if tracker and tracker.GetActiveDebuffs then + local debuffs, count = tracker.GetActiveDebuffs(guid) + if debuffs and count > 0 then + -- Check if any debuff has more than refreshtime remaining + local hasLongDebuff = false + for i = 1, count do + if debuffs[i].remaining > options.refreshtime then + hasLongDebuff = true + break + end + end + if hasLongDebuff then + return false -- Has debuff with too much time remaining + end + end + -- No debuffs or all debuffs are expiring soon - pass + 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 @@ -4946,16 +5188,10 @@ function CleveRoids.ResolveMultiscanTarget(conditionals, specifiedUnit) 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 + -- Parse priority and options from conditionals.multiscan + local priority, options = CleveRoids.ParseMultiscanOptions(conditionals.multiscan) if not priority then return nil end - priority = string.lower(CleveRoids.Trim(priority)) -- Validate priority type local priorityType = CleveRoids.MULTISCAN_PRIORITIES[priority] @@ -5095,7 +5331,7 @@ function CleveRoids.ResolveMultiscanTarget(conditionals, specifiedUnit) end end - local score = CleveRoids.GetMultiscanScore(unit, priorityType, currentTargetGuid, specifiedUnitGuid) + local score = CleveRoids.GetMultiscanScore(unit, priorityType, currentTargetGuid, specifiedUnitGuid, options, conditionals) if not score then return end -- Validate against target-dependent conditionals @@ -5104,8 +5340,18 @@ function CleveRoids.ResolveMultiscanTarget(conditionals, specifiedUnit) end end + -- Handle priotarget option: always check current target first + if options.priotarget and currentTargetGuid then + evaluateCandidate("target") + -- If current target is valid, return it immediately + if table.getn(candidates) > 0 then + return candidates[1].guid + end + end + -- Always consider current target (exempt from combat check via GetMultiscanScore) - if currentTargetGuid then + -- Skip if priotarget already checked it + if currentTargetGuid and not options.priotarget then evaluateCandidate("target") end diff --git a/Core.lua b/Core.lua index dfe41dd..71f12de 100644 --- a/Core.lua +++ b/Core.lua @@ -4772,6 +4772,7 @@ SlashCmdList["CLEVEROID"] = function(msg) 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('/cleveroid combodebug - Toggle combo tracking debug output') 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') @@ -4782,6 +4783,8 @@ SlashCmdList["CLEVEROID"] = function(msg) DEFAULT_CHAT_FRAME:AddMessage('/cleveroid testequip - Test equipment modifier for a spell') DEFAULT_CHAT_FRAME:AddMessage('/cleveroid equipdebug - Debug item lookup for /equip') DEFAULT_CHAT_FRAME:AddMessage('/cleveroid equiplog - Toggle real-time equip command logging') + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid equipqueue clear - Clear pending equipment queue') + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid equipqueue status - Show queue status') DEFAULT_CHAT_FRAME:AddMessage("|cffffaa00Reactive Proc Tracking:|r") DEFAULT_CHAT_FRAME:AddMessage('/cleveroid listprocs - Show active reactive ability procs') DEFAULT_CHAT_FRAME:AddMessage('/cleveroid setproc [duration] - Manually set proc (testing)') @@ -4797,6 +4800,8 @@ SlashCmdList["CLEVEROID"] = function(msg) DEFAULT_CHAT_FRAME:AddMessage('/cleveroid slamdebug - Show Slam cast time and clip window calculations') DEFAULT_CHAT_FRAME:AddMessage("|cffffaa00Debuff Tracking Debug:|r") DEFAULT_CHAT_FRAME:AddMessage('/cleveroid debuffdebug [spell] - Debug debuff tracking on target') + DEFAULT_CHAT_FRAME:AddMessage("|cffffaa00Multi-Target Tracker:|r") + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid mtt - Show Multi-Target Tracker commands') return end @@ -5456,6 +5461,13 @@ SlashCmdList["CLEVEROID"] = function(msg) return end + -- combodebug (toggle combo tracking debug) + if cmd == "combodebug" then + CleveRoids.debug = not CleveRoids.debug + CleveRoids.Print("Combo tracking debug: " .. (CleveRoids.debug and "ON" or "OFF")) + return + end + -- talenttabs (show talent tab IDs) if cmd == "talenttabs" or cmd == "tabs" then CleveRoids.Print("=== Talent Tabs ===") @@ -6214,6 +6226,91 @@ SlashCmdList["CLEVEROID"] = function(msg) return end + -- equipqueue (Equipment Queue management) + if cmd == "equipqueue" or cmd == "eq" then + if val == "clear" then + -- 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") + elseif val == "status" then + local count = CleveRoids.equipmentQueueLen + CleveRoids.Print("Equipment queue has " .. count .. " pending items") + 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 + else + CleveRoids.Print("Equipment Queue commands:") + CleveRoids.Print(" /cleveroid equipqueue clear - Clear pending equipment queue") + CleveRoids.Print(" /cleveroid equipqueue status - Show queue status") + end + return + end + + -- mtt (Multi-Target Tracker) + if cmd == "mtt" then + local tracker = CleveRoids.MultiTargetTracker + if not tracker then + CleveRoids.Print("Multi-Target Tracker not loaded!") + return + end + + if val == "unlock" then + tracker.Unlock() + elseif val == "lock" then + tracker.Lock() + elseif val == "show" then + tracker.Show() + CleveRoids.Print("Multi-Target Tracker: Shown") + elseif val == "hide" then + tracker.Hide() + CleveRoids.Print("Multi-Target Tracker: Hidden") + elseif val == "toggle" then + tracker.Toggle() + elseif val == "clear" then + tracker.ClearTargets() + elseif val == "reset" then + tracker.ResetPosition() + elseif val == "debug" then + tracker.Debug() + elseif val == "dump" then + tracker.DumpState() + else + CleveRoids.Print("Multi-Target Tracker commands:") + CleveRoids.Print(" /cleveroid mtt unlock - Show frame for positioning") + CleveRoids.Print(" /cleveroid mtt lock - Lock and resume auto-hide") + CleveRoids.Print(" /cleveroid mtt show - Show the tracker frame") + CleveRoids.Print(" /cleveroid mtt hide - Hide the tracker frame") + CleveRoids.Print(" /cleveroid mtt toggle - Toggle frame visibility") + CleveRoids.Print(" /cleveroid mtt clear - Clear all tracked targets") + CleveRoids.Print(" /cleveroid mtt reset - Reset frame position") + CleveRoids.Print(" /cleveroid mtt debug - Toggle debug mode") + CleveRoids.Print(" /cleveroid mtt dump - Dump tracking state") + end + return + end + -- Unknown command fallback CleveRoids.Print("Usage:") DEFAULT_CHAT_FRAME:AddMessage("/cleveroid - Show current settings") @@ -6238,43 +6335,12 @@ SlashCmdList["CLEVEROID"] = function(msg) 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('/cleveroid combodebug - Toggle combo tracking debug output') + DEFAULT_CHAT_FRAME:AddMessage("|cffffaa00Equipment Queue:|r") + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid equipqueue clear - Clear pending equipment queue') + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid equipqueue status - Show queue status') + DEFAULT_CHAT_FRAME:AddMessage("|cffffaa00Multi-Target Tracker:|r") + DEFAULT_CHAT_FRAME:AddMessage('/cleveroid mtt - Show Multi-Target Tracker commands') end -SLASH_CLEAREQUIPQUEUE1 = "/clearequipqueue" -SlashCmdList.CLEAREQUIPQUEUE = function() - -- 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 = CleveRoids.equipmentQueueLen - CleveRoids.Print("Equipment queue has " .. count .. " pending items") - - 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 +-- Equipment queue commands consolidated under /cleveroid equipqueue clear|status diff --git a/Extensions/MultiTargetTracker.lua b/Extensions/MultiTargetTracker.lua new file mode 100644 index 0000000..76bf6ca --- /dev/null +++ b/Extensions/MultiTargetTracker.lua @@ -0,0 +1,2565 @@ +--[[ + Multi-Target Debuff Tracker Extension + Tracks player's debuffs across multiple enemy targets + Displays: Target indicator, raid icon, health bar with name, debuff icons with timers + + Styled to match Cursive/ShaguScan UI +]] + +local _G = _G or getfenv(0) +local CleveRoids = _G.CleveRoids or {} + +local Extension = CleveRoids.RegisterExtension("MultiTargetTracker") + +-- ============================================================================ +-- Configuration +-- ============================================================================ + +local MAX_TRACKED_TARGETS = 15 +local MAX_ICONS_PER_ROW = 8 +local UPDATE_THROTTLE = 0.1 -- 10 Hz update rate + +-- Layout dimensions (Cursive-style) +local CONFIG = { + -- Bar dimensions + barHeight = 20, + barSpacing = 2, + + -- First section (target indicator + raid icon) + targetIndicatorSize = 8, + raidIconSize = 16, + + -- Second section (health bar) + healthBarWidth = 140, + + -- Third section (debuff icons) + debuffIconSize = 20, + debuffIconSpacing = 2, + + -- General + padding = 2, + titleHeight = 16, + textSize = 10, +} + +-- Tooltip-style backdrop (matching Cursive) +local BACKDROP_BORDER = { + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, tileSize = 16, edgeSize = 8, + insets = { left = 2, right = 2, top = 2, bottom = 2 } +} + +local BACKDROP_BACKGROUND = { + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + tile = true, tileSize = 16, + insets = { left = 0, right = 0, top = 0, bottom = 0 } +} + +-- Colors +local COLORS = { + borderNormal = { 0.2, 0.2, 0.2, 1 }, + borderHover = { 1, 1, 1, 1 }, + borderCombat = { 0.8, 0.2, 0.2, 1 }, + healthDefault = { 1, 0.8, 0.2, 1 }, -- Gold/yellow + timerNormal = { 1, 1, 1 }, + timerExpiring = { 1, 0.2, 0.2 }, -- Red when <3s + nameText = { 1, 1, 1, 1 }, + titleText = { 1, 0.82, 0, 1 }, -- Gold +} + +-- ============================================================================ +-- Local State +-- ============================================================================ + +local trackedTargets = {} -- Key: normalized GUID, Value: { guid, name, addedTime } +local trackedOrder = {} -- Array of GUIDs in order added (for FIFO removal) +local targetRows = {} -- Pool of row frames +local mainFrame = nil +local contentFrame = nil +local lastUpdate = 0 +local isFrameVisible = false +local isUnlocked = false +local lib = nil -- Reference to CleveRoids.libdebuff + +-- Debug flag for MultiTargetTracker +local MTT_DEBUG = false + +-- Cache for enemy status +local confirmedEnemies = {} + +-- Cache for player's known spells (populated on load) +local playerKnownSpells = {} + +-- Build cache of player's known spell IDs by scanning spellbook +local function BuildPlayerSpellCache() + playerKnownSpells = {} + local spellIndex = 1 + while true do + local spellName, spellRank = GetSpellName(spellIndex, BOOKTYPE_SPELL) + if not spellName then break end + + -- Get the spell ID using SpellInfo (SuperWoW) + if SpellInfo then + local id = CleveRoids.Spells and CleveRoids.Spells[spellName] + if id then + playerKnownSpells[id] = true + end + end + spellIndex = spellIndex + 1 + end + + -- Also check CleveRoids.Spells directly (more reliable) + if CleveRoids.Spells then + for name, id in pairs(CleveRoids.Spells) do + playerKnownSpells[id] = true + end + end +end + +-- Check if player can cast a spell (has it in spellbook) +local function PlayerCanCastSpell(spellID) + if not spellID then return false end + + -- Check our cache first + if playerKnownSpells[spellID] then + return true + end + + -- For spells not in cache, check if base spell name is known + -- (handles different ranks of the same spell) + if SpellInfo then + local spellName = SpellInfo(spellID) + if spellName and CleveRoids.Spells then + -- Strip rank suffix for comparison + local baseName = string.gsub(spellName, "%s*%(%s*Rank%s+%d+%s*%)", "") + for knownName, knownID in pairs(CleveRoids.Spells) do + local knownBase = string.gsub(knownName, "%s*%(%s*Rank%s+%d+%s*%)", "") + if knownBase == baseName then + return true + end + end + end + end + + return false +end + +-- ============================================================================ +-- Player Cast Tracking (Clean Implementation) +-- ============================================================================ +-- +-- This tracking system uses Nampower's SPELL_CAST_EVENT which ONLY fires for +-- the local player's casts. This eliminates the GUID comparison issues between +-- Nampower's internal GUID system and SuperWoW's GUID system. +-- +-- Data Flow: +-- 1. SPELL_CAST_EVENT fires → Record pending cast with target +-- 2. DEBUFF_ADDED_OTHER fires → Match against pending casts +-- 3. If match → Mark as player's debuff, add to tracker +-- +-- ============================================================================ + +-- Pending casts waiting for debuff confirmation +-- Key: "spellID_normalizedTargetGUID", Value: { time, spellID, targetGuid, targetName } +local pendingCasts = {} +local CAST_CONFIRMATION_WINDOW = 3.0 -- Time window to match cast with debuff application + +-- AoE debuff spells - these hit multiple targets, so pending cast tracking +-- uses spell-only keys instead of spell+target keys +local AOE_DEBUFF_SPELLS = { + -- Warrior - Thunder Clap + [6343] = true, -- Thunder Clap (Rank 1) + [8198] = true, -- Thunder Clap (Rank 2) + [8205] = true, -- Thunder Clap (Rank 3) + [11580] = true, -- Thunder Clap (Rank 4) + [11581] = true, -- Thunder Clap (Rank 5) + -- Warrior - Challenging Shout (AoE taunt) + [1161] = true, -- Challenging Shout + -- Druid - Demoralizing Roar (AoE debuff) + [99] = true, -- Demoralizing Roar (Rank 1) + [1735] = true, -- Demoralizing Roar (Rank 2) + [9490] = true, -- Demoralizing Roar (Rank 3) + [9747] = true, -- Demoralizing Roar (Rank 4) + [9898] = true, -- Demoralizing Roar (Rank 5) + -- Warrior - Demoralizing Shout (AoE debuff) + [1160] = true, -- Demoralizing Shout (Rank 1) + [6190] = true, -- Demoralizing Shout (Rank 2) + [11554] = true, -- Demoralizing Shout (Rank 3) + [11555] = true, -- Demoralizing Shout (Rank 4) + [11556] = true, -- Demoralizing Shout (Rank 5) + -- Warlock - Howl of Terror (AoE fear) + [5484] = true, -- Howl of Terror (Rank 1) + [17928] = true, -- Howl of Terror (Rank 2) + -- Priest - Psychic Scream (AoE fear) + [8122] = true, -- Psychic Scream (Rank 1) + [8124] = true, -- Psychic Scream (Rank 2) + [10888] = true, -- Psychic Scream (Rank 3) + [10890] = true, -- Psychic Scream (Rank 4) +} + +-- Pending AoE casts - separate table for AoE spells that uses spell-only keys +-- Key: spellID, Value: { time, spellID } +local pendingAoECasts = {} + +-- Excluded spells - abilities that fire SPELL_CAST_EVENT but aren't debuffs +-- These are filtered out to reduce debug spam +local EXCLUDED_SPELLS = { + -- Direct damage abilities (not debuffs) + [23881] = true, -- Bloodthirst + [23892] = true, -- Bloodthirst (higher rank) + [23893] = true, -- Bloodthirst + [23894] = true, -- Bloodthirst + [29707] = true, -- Heroic Strike (various ranks) + [11567] = true, -- Heroic Strike + [11566] = true, -- Heroic Strike + [11565] = true, -- Heroic Strike + [1608] = true, -- Heroic Strike + [285] = true, -- Heroic Strike + [284] = true, -- Heroic Strike + [78] = true, -- Heroic Strike (Rank 1) + + -- Self-buffs (not enemy debuffs) + [12970] = true, -- Flurry (self-buff) + +} + +-- Non-stacking debuffs - don't show stack count in UI for these +local NON_STACKING_DEBUFFS = { + -- Thunderfury (both effects) + [21992] = true, -- Thunderfury main + [27648] = true, -- Thunderfury chain + + -- Warrior debuffs that don't stack + [355] = true, -- Taunt + [1161] = true, -- Challenging Shout +} + +-- Proc debuffs and special spells that need UNIT_CASTEVENT tracking +-- (SPELL_CAST_EVENT may not provide target GUID for these) +local PROC_DEBUFFS = { + -- Thunderfury, Blessed Blade of the Windseeker + [21992] = true, -- Thunderfury (Nature damage + resistance reduction) + [27648] = true, -- Thunderfury chain effect + + -- Warrior - Thunder Clap (AoE spell - cast ID = debuff ID) + [6343] = true, -- Thunder Clap (Rank 1) + [8198] = true, -- Thunder Clap (Rank 2) + [8205] = true, -- Thunder Clap (Rank 3) + [11580] = true, -- Thunder Clap (Rank 4) + [11581] = true, -- Thunder Clap (Rank 5) + + -- Warrior - Taunts (SPELL_CAST_EVENT doesn't provide target GUID) + [355] = true, -- Taunt + [1161] = true, -- Challenging Shout + [694] = true, -- Mocking Blow (Rank 1) + [7400] = true, -- Mocking Blow (Rank 2) + [7402] = true, -- Mocking Blow (Rank 3) + [20559] = true, -- Mocking Blow (Rank 4) + [20560] = true, -- Mocking Blow (Rank 5) +} + +-- Duration cache from AURA_CAST_ON_OTHER events +-- Key: spellID, Value: duration in seconds +local auraCastDurations = {} + +-- ============================================================================ +-- Duration Lookup System +-- Priority: AURA_CAST cache → lib tables → GetSpellRec → default +-- ============================================================================ + +-- SpellDuration.dbc lookup table (common duration indices) +-- Format: durationIndex = baseDurationMs +local SPELL_DURATION_INDEX = { + [1] = 0, -- Instant + [3] = 10000, -- 10 seconds + [4] = 15000, -- 15 seconds + [5] = 30000, -- 30 seconds + [6] = 60000, -- 1 minute + [7] = 120000, -- 2 minutes + [8] = 180000, -- 3 minutes + [9] = 300000, -- 5 minutes + [11] = 600000, -- 10 minutes + [12] = 900000, -- 15 minutes + [21] = 6000, -- 6 seconds + [22] = 12000, -- 12 seconds + [28] = 20000, -- 20 seconds + [29] = 45000, -- 45 seconds + [35] = 8000, -- 8 seconds + [36] = 18000, -- 18 seconds + [37] = 24000, -- 24 seconds + [39] = 21000, -- 21 seconds + [85] = 9000, -- 9 seconds + [557] = 22000, -- 22 seconds (Thunder Clap R4) + [558] = 14000, -- 14 seconds (Thunder Clap R2) +} + +-- Get duration for a spell using all available sources +local function GetSpellDuration(spellId) + local lib = CleveRoids.libdebuff + + -- 1. Check AURA_CAST cache (most accurate, includes modifiers) + if auraCastDurations[spellId] then + return auraCastDurations[spellId] + end + + -- 2. Check libdebuff tables + if lib then + if lib.personalDebuffs and lib.personalDebuffs[spellId] then + return lib.personalDebuffs[spellId] + end + if lib.sharedDebuffs and lib.sharedDebuffs[spellId] then + return lib.sharedDebuffs[spellId] + end + if lib.durations and lib.durations[spellId] then + return lib.durations[spellId] + end + end + + -- 3. Try GetSpellRec from Nampower + if GetSpellRec then + local spellRec = GetSpellRec(spellId) + if spellRec and spellRec.durationIndex then + local durationMs = SPELL_DURATION_INDEX[spellRec.durationIndex] + if durationMs then + return durationMs / 1000 + end + end + end + + -- 4. Default fallback + return 30 +end + +-- Check if a spell is a known debuff (should be tracked) +local function IsKnownDebuffSpell(spellId) + -- FIRST: Check exclusion list (passive procs, abilities we don't want) + if EXCLUDED_SPELLS[spellId] then + return false + end + + local lib = CleveRoids.libdebuff + + -- Check our tracking tables + if PROC_DEBUFFS[spellId] then return true end + if AOE_DEBUFF_SPELLS[spellId] then return true end + + -- Check libdebuff tables + if lib then + if lib.personalDebuffs and lib.personalDebuffs[spellId] then return true end + if lib.sharedDebuffs and lib.sharedDebuffs[spellId] then return true end + if lib.durations and lib.durations[spellId] then return true end + end + + return false +end + +-- Forward declaration for ResolveTargetName (defined after GetUnitForGUID) +local ResolveTargetName + +-- Helper to create a cast key from spell and target +local function MakeCastKey(spellID, targetGuid) + local normalizedGuid = CleveRoids.NormalizeGUID(targetGuid) + if normalizedGuid then + return tostring(spellID) .. "_" .. normalizedGuid + end + return nil +end + +-- Record a player cast (called from SPELL_CAST_EVENT) +local function RecordPlayerCast(spellID, targetGuid, targetName) + if not spellID then return end + + local key = MakeCastKey(spellID, targetGuid) + if key then + pendingCasts[key] = { + time = GetTime(), + spellID = spellID, + targetGuid = CleveRoids.NormalizeGUID(targetGuid), + targetName = targetName or "Unknown" + } + end + + -- For AoE spells, also record in the AoE table (spell-only key) + if AOE_DEBUFF_SPELLS[spellID] then + pendingAoECasts[spellID] = { + time = GetTime(), + spellID = spellID + } + end + + if MTT_DEBUG then + local spellName = SpellInfo and SpellInfo(spellID) or "?" + local isAoE = AOE_DEBUFF_SPELLS[spellID] and " (AoE)" or "" + DEFAULT_CHAT_FRAME:AddMessage(string.format( + "|cff00ffff[MTT]|r Cast: %s on %s%s", + spellName, targetName or "?", isAoE)) + end +end + +-- Check if we have a pending cast for this spell+target +local function GetPendingCast(spellID, targetGuid) + -- First check target-specific pending casts + local key = MakeCastKey(spellID, targetGuid) + if key then + local pending = pendingCasts[key] + if pending and (GetTime() - pending.time) <= CAST_CONFIRMATION_WINDOW then + return pending + end + end + + -- For AoE spells, also check the AoE pending cast table + if AOE_DEBUFF_SPELLS[spellID] then + local aoePending = pendingAoECasts[spellID] + if aoePending and (GetTime() - aoePending.time) <= CAST_CONFIRMATION_WINDOW then + return aoePending + end + end + + return nil +end + +-- Consume (remove) a pending cast after confirmation +local function ConsumePendingCast(spellID, targetGuid) + local key = MakeCastKey(spellID, targetGuid) + if key then + pendingCasts[key] = nil + end +end + +-- Cleanup old pending casts +local function CleanupPendingCasts() + local now = GetTime() + local expireTime = CAST_CONFIRMATION_WINDOW * 2 + + -- Cleanup target-specific pending casts + for key, data in pairs(pendingCasts) do + if (now - data.time) > expireTime then + pendingCasts[key] = nil + end + end + + -- Cleanup AoE pending casts + for spellID, data in pairs(pendingAoECasts) do + if (now - data.time) > expireTime then + pendingAoECasts[spellID] = nil + end + end +end + +-- Helper function to check if a caster GUID matches the player +-- The caster field might be the string "player" (set by us) or the actual player GUID (from Nampower) +local function IsCasterPlayer(casterGuid) + if not casterGuid then return false end + if casterGuid == "player" then return true end + + -- Compare against actual player GUID + local _, playerGuid = UnitExists("player") + if playerGuid then + return CleveRoids.GUIDsMatch(casterGuid, playerGuid) + end + return false +end + +-- ============================================================================ +-- Utility Functions +-- ============================================================================ + +local function FormatTimer(seconds) + if seconds <= 0 then + return "" + elseif seconds < 10 then + return string.format("%.1f", seconds) + elseif seconds < 60 then + return string.format("%d", seconds) + else + local mins = math.floor(seconds / 60) + local secs = math.mod(seconds, 60) + return string.format("%d:%02d", mins, secs) + end +end + +local function FormatHealth(hp) + if not hp then return "?" end + if hp >= 1000000 then + return string.format("%.1fm", hp / 1000000) + elseif hp >= 1000 then + return string.format("%.1fk", hp / 1000) + else + return tostring(hp) + end +end + +local function TruncateName(name, maxLen) + if not name then return "?" end + if string.len(name) <= maxLen then + return name + end + return string.sub(name, 1, maxLen - 2) .. ".." +end + +local function GetTimerColor(seconds) + if seconds < 3 then + return COLORS.timerExpiring[1], COLORS.timerExpiring[2], COLORS.timerExpiring[3] + else + return COLORS.timerNormal[1], COLORS.timerNormal[2], COLORS.timerNormal[3] + end +end + +-- Check if focus unit is available +local hasFocusUnit = false +if pcall(function() return UnitExists("focus") end) then + hasFocusUnit = true +end + +local function CheckUnit(unit) + local exists, unitGUID = UnitExists(unit) + if exists and unitGUID then + return true, unitGUID + end + return false, nil +end + +-- Calculate section widths +local function GetFirstSectionWidth() + return CONFIG.targetIndicatorSize + CONFIG.raidIconSize + CONFIG.padding +end + +local function GetSecondSectionWidth() + return CONFIG.healthBarWidth + CONFIG.padding +end + +local function GetThirdSectionWidth() + return MAX_ICONS_PER_ROW * (CONFIG.debuffIconSize + CONFIG.debuffIconSpacing) +end + +local function GetBarWidth() + return GetFirstSectionWidth() + GetSecondSectionWidth() + GetThirdSectionWidth() + CONFIG.padding * 2 +end + +-- ============================================================================ +-- GUID Health/Unit Lookup +-- ============================================================================ + +local function GetUnitForGUID(guid) + local priorityUnits = { "target", "mouseover", "targettarget", "pettarget" } + if hasFocusUnit then + table.insert(priorityUnits, "focus") + table.insert(priorityUnits, "focustarget") + end + + for i = 1, table.getn(priorityUnits) do + local unit = priorityUnits[i] + local exists, unitGUID = CheckUnit(unit) + if exists and unitGUID then + if CleveRoids.NormalizeGUID(unitGUID) == guid then + return unit + end + end + end + + -- Check party targets + for i = 1, 4 do + local unit = "party" .. i .. "target" + local exists, unitGUID = CheckUnit(unit) + if exists and unitGUID then + if CleveRoids.NormalizeGUID(unitGUID) == guid then + return unit + end + end + end + + -- Check raid targets + for i = 1, 40 do + local unit = "raid" .. i .. "target" + local exists, unitGUID = CheckUnit(unit) + if exists and unitGUID then + if CleveRoids.NormalizeGUID(unitGUID) == guid then + return unit + end + end + end + + return nil +end + +-- Helper function to resolve target name from GUID using multiple methods +-- (Defined here after GetUnitForGUID is available) +ResolveTargetName = function(guid) + if not guid then return nil end + + -- Method 1: Try UnitName directly with GUID (SuperWoW may support this) + local name = UnitName(guid) + if name and name ~= "" and name ~= "Unknown" then + return name + end + + -- Method 2: Try to find a unit token for this GUID and use that + local unit = GetUnitForGUID(CleveRoids.NormalizeGUID(guid)) + if unit then + name = UnitName(unit) + if name and name ~= "" then + return name + end + end + + -- Method 3: Check libdebuff's GUID-to-name cache + if lib and lib.guidToName then + local normalizedGuid = CleveRoids.NormalizeGUID(guid) + if normalizedGuid and lib.guidToName[normalizedGuid] then + return lib.guidToName[normalizedGuid] + end + end + + -- Method 4: Check our tracked targets (we might have cached the name before) + local normalizedGuid = CleveRoids.NormalizeGUID(guid) + if normalizedGuid and trackedTargets[normalizedGuid] then + return trackedTargets[normalizedGuid].name + end + + return nil -- Could not resolve name +end + +local function GetHealthForGUID(guid) + -- SuperWoW allows UnitHealth/UnitHealthMax to take GUIDs directly + if CleveRoids.hasSuperwow and guid then + local health = UnitHealth(guid) + local maxHealth = UnitHealthMax(guid) + if health and maxHealth and maxHealth > 0 then + return health, maxHealth, nil + end + end + + -- Fallback: Check via unit lookup + local unit = GetUnitForGUID(guid) + if unit then + local health = UnitHealth(unit) + local maxHealth = UnitHealthMax(unit) + if maxHealth and maxHealth > 0 then + return health, maxHealth, unit + end + end + return nil, nil, nil +end + +local function IsGUIDDead(guid) + -- SuperWoW allows UnitIsDead to take a GUID directly + if CleveRoids.hasSuperwow and guid then + local isDead = UnitIsDead(guid) + if isDead then + return true + end + end + + -- Fallback: Check via unit lookup + local unit = GetUnitForGUID(guid) + if unit then + return UnitIsDead(unit) + end + + -- If we can't find the unit, assume not dead (will be pruned when debuffs expire) + return false +end + +local function IsEnemyGUID(guid) + if not guid then return false, nil end + + -- Check if this is the player's GUID + local _, playerGuid = UnitExists("player") + if playerGuid and CleveRoids.GUIDsMatch(playerGuid, guid) then + return false, "player" + end + + local unit = GetUnitForGUID(guid) + if unit then + -- Check if it's the player or a friendly unit + if UnitIsUnit(unit, "player") then + return false, unit + end + local isEnemy = UnitIsEnemy("player", unit) or UnitCanAttack("player", unit) + return isEnemy, unit + end + + return nil, nil -- Unknown +end + +local function GetSpellTexture(spellID) + if SpellInfo then + local name, rank, icon = SpellInfo(spellID) + return icon + end + return nil +end + +-- ============================================================================ +-- Target Management +-- ============================================================================ + +local function AddTrackedTarget(guid, name, skipEnemyCheck, rawGuid) + if not guid then return end + + -- Keep raw GUID before normalizing (for API calls like UnitDebuff) + local originalGuid = rawGuid or guid + guid = CleveRoids.NormalizeGUID(guid) + if not guid then return end + + -- Already tracking? + if trackedTargets[guid] then + if name then + trackedTargets[guid].name = name + end + -- Update raw GUID if provided (might have better format) + if rawGuid and rawGuid ~= trackedTargets[guid].rawGuid then + trackedTargets[guid].rawGuid = rawGuid + end + return + end + + -- Enemy check + if not skipEnemyCheck then + if confirmedEnemies[guid] then + -- Already confirmed + else + local isEnemy, unit = IsEnemyGUID(guid) + if isEnemy == false then + return + elseif isEnemy == true then + confirmedEnemies[guid] = true + else + -- Assume enemy when casting debuffs + confirmedEnemies[guid] = true + end + end + end + + -- Enforce max limit (FIFO) + while table.getn(trackedOrder) >= MAX_TRACKED_TARGETS do + local oldestGUID = trackedOrder[1] + table.remove(trackedOrder, 1) + trackedTargets[oldestGUID] = nil + end + + -- Add new target + trackedTargets[guid] = { + guid = guid, + rawGuid = originalGuid, -- Store raw GUID for API calls like UnitDebuff + name = name or (lib and lib.guidToName and lib.guidToName[guid]) or "Unknown", + addedTime = GetTime(), + } + table.insert(trackedOrder, guid) + + -- Show frame + if mainFrame and not isFrameVisible then + mainFrame:Show() + isFrameVisible = true + end +end + +local function RemoveTrackedTarget(guid) + if not guid then return end + + trackedTargets[guid] = nil + confirmedEnemies[guid] = nil + + for i = table.getn(trackedOrder), 1, -1 do + if trackedOrder[i] == guid then + table.remove(trackedOrder, i) + break + end + end + + if table.getn(trackedOrder) == 0 and mainFrame and isFrameVisible and not isUnlocked then + mainFrame:Hide() + isFrameVisible = false + end +end + +local function GetActivePlayerDebuffs(guid) + if not lib or not lib.objects or not lib.objects[guid] then + return nil, 0 + end + + local debuffs = {} + local count = 0 + local now = GetTime() + + for spellID, rec in pairs(lib.objects[guid]) do + -- FIRST: Only show debuffs we actually want to track + -- This filters out abilities like Bloodthirst, Master Strike, Deep Wounds (talent proc) + if not IsKnownDebuffSpell(spellID) then + -- Skip unknown spells entirely + else + -- Check if this debuff should be shown as player's + local isPlayerDebuff = false + + if IsCasterPlayer(rec.caster) then + -- Explicitly marked as player + isPlayerDebuff = true + elseif PROC_DEBUFFS[spellID] then + -- Proc debuffs (items/talents) are always from the player + isPlayerDebuff = true + elseif lib.sharedDebuffs and lib.sharedDebuffs[spellID] then + -- For shared debuffs, include if caster is player OR we have a pending cast + if IsCasterPlayer(rec.caster) then + isPlayerDebuff = true + elseif GetPendingCast(spellID, guid) then + isPlayerDebuff = true + elseif rec.caster == nil and PlayerCanCastSpell(spellID) then + -- Shared debuff with no caster - only claim if player can cast this spell + -- This prevents showing other players' Sunder Armor on a Druid, etc. + isPlayerDebuff = true + end + end + + if isPlayerDebuff then + if rec.start and rec.duration then + local remaining = rec.duration + rec.start - now + if remaining > 0 then + count = count + 1 + debuffs[count] = { + spellID = spellID, + remaining = remaining, + duration = rec.duration, + stacks = rec.stacks or 0, + } + end + end + end + end -- end IsKnownDebuffSpell check + end + + return debuffs, count +end + +local function IsGUIDInCombat(guid) + -- SuperWoW allows UnitAffectingCombat to take a GUID directly + if CleveRoids.hasSuperwow and guid then + local inCombat = UnitAffectingCombat(guid) + if inCombat then + return true + end + end + + -- Fallback: Check via unit lookup + local unit = GetUnitForGUID(guid) + if unit then + return UnitAffectingCombat(unit) + end + + return false +end + +local function CheckAndPruneTargets() + local toRemove = {} + local playerOutOfCombat = not UnitAffectingCombat("player") + + for guid, info in pairs(trackedTargets) do + -- Check if dead - always remove dead targets + if IsGUIDDead(guid) then + table.insert(toRemove, guid) + else + -- Check if no active debuffs + local debuffs, count = GetActivePlayerDebuffs(guid) + if count == 0 then + -- Remove if target out of combat OR player out of combat + if not IsGUIDInCombat(guid) or playerOutOfCombat then + table.insert(toRemove, guid) + end + end + end + end + + for i = 1, table.getn(toRemove) do + RemoveTrackedTarget(toRemove[i]) + end +end + +local function ClearAllTargets() + trackedTargets = {} + trackedOrder = {} + confirmedEnemies = {} + + if mainFrame and isFrameVisible then + mainFrame:Hide() + isFrameVisible = false + end + + CleveRoids.Print("Multi-Target Tracker: Cleared all targets") +end + +-- Forward declarations +local ScanCombatTargets +local ScanAllTrackedDebuffs + +-- ============================================================================ +-- UI Creation (Cursive-style) +-- ============================================================================ + +local function CreateDebuffIcon(parent, index) + local icon = CreateFrame("Frame", nil, parent) + icon:SetWidth(CONFIG.debuffIconSize) + icon:SetHeight(CONFIG.debuffIconSize) + + -- Icon texture + icon.texture = icon:CreateTexture(nil, "ARTWORK") + icon.texture:SetPoint("TOPLEFT", icon, "TOPLEFT", 1, -1) + icon.texture:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", -1, 1) + icon.texture:SetTexCoord(0.08, 0.92, 0.08, 0.92) + + -- Timer text (centered on icon like Cursive) + icon.timer = icon:CreateFontString(nil, "OVERLAY", "GameFontNormal") + icon.timer:SetFont(STANDARD_TEXT_FONT, CONFIG.textSize, "OUTLINE") + icon.timer:SetTextColor(1, 1, 1) + icon.timer:SetAllPoints(icon) + + -- Stack count (bottom right) + icon.stacks = icon:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + icon.stacks:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", -1, 1) + icon.stacks:SetFont(STANDARD_TEXT_FONT, 9, "OUTLINE") + icon.stacks:SetTextColor(1, 1, 1, 1) + + icon:Hide() + return icon +end + +local function CreateTargetRow(parent, index) + local barWidth = GetBarWidth() + local row = CreateFrame("Frame", nil, parent) + row:SetWidth(barWidth) + row:SetHeight(CONFIG.barHeight) + row.hover = false + + -- ======================================== + -- First Section: Target Indicator + Raid Icon + -- ======================================== + local firstSection = CreateFrame("Frame", nil, row) + firstSection:SetPoint("LEFT", row, "LEFT", 0, 0) + firstSection:SetWidth(GetFirstSectionWidth()) + firstSection:SetHeight(CONFIG.barHeight) + row.firstSection = firstSection + + -- Target indicator arrow (shows when this unit is targeted) + local targetIndicator = firstSection:CreateTexture(nil, "OVERLAY") + targetIndicator:SetWidth(CONFIG.targetIndicatorSize) + targetIndicator:SetHeight(CONFIG.targetIndicatorSize) + targetIndicator:SetPoint("LEFT", firstSection, "LEFT", 0, 0) + -- Use play button triangle as arrow pointing right (towards the target) + targetIndicator:SetTexture("Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up") + targetIndicator:SetVertexColor(1, 1, 0, 1) -- Yellow for visibility + targetIndicator:Hide() + row.targetIndicator = targetIndicator + + -- Raid icon + local raidIcon = firstSection:CreateTexture(nil, "OVERLAY") + raidIcon:SetWidth(CONFIG.raidIconSize) + raidIcon:SetHeight(CONFIG.raidIconSize) + raidIcon:SetPoint("RIGHT", firstSection, "RIGHT", 0, 0) + raidIcon:SetTexture("Interface\\TargetingFrame\\UI-RaidTargetingIcons") + raidIcon:Hide() + row.raidIcon = raidIcon + + -- ======================================== + -- Second Section: Health Bar (clickable) + -- ======================================== + local secondSection = CreateFrame("Button", nil, row) + secondSection:SetPoint("LEFT", firstSection, "RIGHT", 0, 0) + secondSection:SetWidth(GetSecondSectionWidth()) + secondSection:SetHeight(CONFIG.barHeight) + secondSection.parent = row + row.secondSection = secondSection + + -- Health bar with backdrop + local healthBar = CreateFrame("StatusBar", nil, secondSection) + healthBar:SetPoint("LEFT", secondSection, "LEFT", CONFIG.padding, 0) + healthBar:SetWidth(CONFIG.healthBarWidth) + healthBar:SetHeight(CONFIG.barHeight - 4) + healthBar:SetStatusBarTexture("Interface\\TargetingFrame\\UI-StatusBar") + healthBar:SetStatusBarColor(COLORS.healthDefault[1], COLORS.healthDefault[2], COLORS.healthDefault[3], 1) + healthBar:SetMinMaxValues(0, 100) + healthBar:SetValue(100) + row.healthBar = healthBar + + -- Health bar background + healthBar:SetBackdrop(BACKDROP_BACKGROUND) + healthBar:SetBackdropColor(0, 0, 0, 0.8) + + -- Health bar border + local healthBorder = CreateFrame("Frame", nil, healthBar) + healthBorder:SetBackdrop(BACKDROP_BORDER) + healthBorder:SetBackdropBorderColor(COLORS.borderNormal[1], COLORS.borderNormal[2], COLORS.borderNormal[3], 1) + healthBorder:SetPoint("TOPLEFT", healthBar, "TOPLEFT", -2, 2) + healthBorder:SetPoint("BOTTOMRIGHT", healthBar, "BOTTOMRIGHT", 2, -2) + row.healthBorder = healthBorder + + -- Name text (on health bar) + local nameText = healthBar:CreateFontString(nil, "OVERLAY", "GameFontWhite") + nameText:SetPoint("TOPLEFT", healthBar, "TOPLEFT", 2, -2) + nameText:SetWidth(CONFIG.healthBarWidth - 35) + nameText:SetHeight(CONFIG.barHeight - 4) + nameText:SetFont(STANDARD_TEXT_FONT, CONFIG.textSize, "THINOUTLINE") + nameText:SetJustifyH("LEFT") + nameText:SetTextColor(COLORS.nameText[1], COLORS.nameText[2], COLORS.nameText[3], 1) + row.nameText = nameText + + -- HP text (right side of health bar) + local hpText = healthBar:CreateFontString(nil, "OVERLAY", "GameFontWhite") + hpText:SetPoint("TOPRIGHT", healthBar, "TOPRIGHT", -2, -2) + hpText:SetWidth(30) + hpText:SetHeight(CONFIG.barHeight - 4) + hpText:SetFont(STANDARD_TEXT_FONT, CONFIG.textSize, "THINOUTLINE") + hpText:SetJustifyH("RIGHT") + row.hpText = hpText + + -- Click handlers for targeting + secondSection:RegisterForClicks("LeftButtonUp", "RightButtonUp") + secondSection:SetScript("OnClick", function() + if this.parent.guid then + if arg1 == "LeftButton" then + TargetUnit(this.parent.guid) + elseif arg1 == "RightButton" then + RemoveTrackedTarget(this.parent.guid) + end + end + end) + + -- Hover effects (white border on hover like Cursive) + secondSection:SetScript("OnEnter", function() + this.parent.hover = true + if this.parent.healthBorder then + this.parent.healthBorder:SetBackdropBorderColor(COLORS.borderHover[1], COLORS.borderHover[2], COLORS.borderHover[3], 1) + end + -- Show tooltip + if this.parent.guid then + GameTooltip_SetDefaultAnchor(GameTooltip, this) + GameTooltip:SetUnit(this.parent.guid) + GameTooltip:Show() + end + end) + + secondSection:SetScript("OnLeave", function() + this.parent.hover = false + GameTooltip:Hide() + end) + + -- ======================================== + -- Third Section: Debuff Icons + -- ======================================== + local thirdSection = CreateFrame("Frame", nil, row) + thirdSection:SetPoint("LEFT", secondSection, "RIGHT", 0, 0) + thirdSection:SetWidth(GetThirdSectionWidth()) + thirdSection:SetHeight(CONFIG.barHeight) + row.thirdSection = thirdSection + + -- Create debuff icon pool + row.icons = {} + for i = 1, MAX_ICONS_PER_ROW do + row.icons[i] = CreateDebuffIcon(thirdSection, i) + local xOffset = (i - 1) * (CONFIG.debuffIconSize + CONFIG.debuffIconSpacing) + row.icons[i]:SetPoint("LEFT", thirdSection, "LEFT", xOffset, 0) + end + + row:Hide() + return row +end + +local function CreateMainFrame() + local barWidth = GetBarWidth() + + local frame = CreateFrame("Frame", "CleveRoidsMultiTargetFrame", UIParent) + frame:SetWidth(barWidth + 8) + frame:SetHeight(CONFIG.titleHeight + 50) + frame:SetPoint("CENTER", UIParent, "CENTER", 200, 100) + frame:SetFrameStrata("MEDIUM") + frame:SetMovable(true) + frame:EnableMouse(true) + frame:SetClampedToScreen(true) + + -- No background - transparent frame + frame:SetBackdrop(nil) + + -- Title bar + local titleBar = CreateFrame("Frame", nil, frame) + titleBar:SetPoint("TOPLEFT", frame, "TOPLEFT", 2, -2) + titleBar:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -2, -2) + titleBar:SetHeight(CONFIG.titleHeight) + titleBar:EnableMouse(true) + frame.titleBar = titleBar + + -- Title text + local titleText = titleBar:CreateFontString(nil, "OVERLAY", "GameFontNormal") + titleText:SetPoint("LEFT", titleBar, "LEFT", 4, 0) + titleText:SetFont(STANDARD_TEXT_FONT, 11, "THINOUTLINE") + titleText:SetText("Debuff Tracker") + titleText:SetTextColor(COLORS.titleText[1], COLORS.titleText[2], COLORS.titleText[3], 1) + frame.titleText = titleText + + -- Close button + local closeBtn = CreateFrame("Button", nil, titleBar, "UIPanelCloseButton") + closeBtn:SetPoint("RIGHT", titleBar, "RIGHT", 2, 0) + closeBtn:SetWidth(18) + closeBtn:SetHeight(18) + closeBtn:SetScript("OnClick", function() + if mainFrame then + mainFrame:Hide() + isFrameVisible = false + end + end) + + -- Dragging + titleBar:SetScript("OnMouseDown", function() + if arg1 == "LeftButton" then + frame:StartMoving() + end + end) + titleBar:SetScript("OnMouseUp", function() + frame:StopMovingOrSizing() + SaveFramePosition() + end) + + -- Content frame for rows + contentFrame = CreateFrame("Frame", nil, frame) + contentFrame:SetPoint("TOPLEFT", titleBar, "BOTTOMLEFT", 2, -CONFIG.padding) + contentFrame:SetPoint("TOPRIGHT", titleBar, "BOTTOMRIGHT", -2, -CONFIG.padding) + contentFrame:SetHeight(1) + + -- Create row pool + for i = 1, MAX_TRACKED_TARGETS do + targetRows[i] = CreateTargetRow(contentFrame, i) + targetRows[i]:SetPoint("TOPLEFT", contentFrame, "TOPLEFT", 0, -((i - 1) * (CONFIG.barHeight + CONFIG.barSpacing))) + end + + -- OnUpdate handler + frame:SetScript("OnUpdate", function() + local now = GetTime() + if now - lastUpdate < UPDATE_THROTTLE then + return + end + lastUpdate = now + + -- Scan for combat targets + ScanCombatTargets() + + -- Scan debuffs directly from game (catches missed events) + ScanAllTrackedDebuffs() + + if table.getn(trackedOrder) == 0 and not isUnlocked then + return + end + + UpdateFrame() + end) + + frame:Hide() + return frame +end + +-- ============================================================================ +-- UI Update Functions +-- ============================================================================ + +local function UpdateDebuffIcons(row, guid) + local debuffs, count = GetActivePlayerDebuffs(guid) + + -- Hide all icons first + for i = 1, MAX_ICONS_PER_ROW do + if row.icons[i] then + row.icons[i]:Hide() + row.icons[i].timer:Hide() + end + end + + if not debuffs or count == 0 then + return + end + + -- Sort by remaining time (shortest first) + table.sort(debuffs, function(a, b) + return a.remaining < b.remaining + end) + + -- Show active debuffs + for i = 1, math.min(count, MAX_ICONS_PER_ROW) do + local debuff = debuffs[i] + local icon = row.icons[i] + + if icon and debuff then + local texture = GetSpellTexture(debuff.spellID) + if texture then + icon.texture:SetTexture(texture) + else + icon.texture:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark") + end + + -- Timer text (Cursive style - centered) + local timerText = FormatTimer(debuff.remaining) + icon.timer:SetText(timerText) + + local r, g, b = GetTimerColor(debuff.remaining) + icon.timer:SetTextColor(r, g, b, 1) + icon.timer:Show() + + -- Stack count (only show if stacks > 1 AND not a known non-stacking debuff) + local showStacks = debuff.stacks and debuff.stacks > 1 and not NON_STACKING_DEBUFFS[debuff.spellID] + if showStacks then + icon.stacks:SetText(tostring(debuff.stacks)) + icon.stacks:Show() + else + icon.stacks:SetText("") + icon.stacks:Hide() + end + + icon:Show() + end + end +end + +local function UpdateRow(row, guid, info) + if not row or not guid or not info then + row:Hide() + return + end + + row.guid = guid + + -- Get unit for this GUID (may be nil if not currently visible as a unit) + local unit = GetUnitForGUID(guid) + + -- For SuperWoW, we can use GUID directly for many functions + local unitOrGuid = unit or (CleveRoids.hasSuperwow and guid) or nil + + -- Update target indicator (shows if this is player's target) + local isCurrentTarget = false + if CleveRoids.hasSuperwow then + -- Check if this GUID matches current target's GUID + local _, targetGUID = UnitExists("target") + if targetGUID then + isCurrentTarget = (CleveRoids.NormalizeGUID(targetGUID) == guid) + end + elseif unit then + isCurrentTarget = UnitIsUnit("target", unit) + end + + if isCurrentTarget then + row.targetIndicator:Show() + else + row.targetIndicator:Hide() + end + + -- Update raid icon + local raidIndex = nil + if unitOrGuid then + raidIndex = GetRaidTargetIndex(unitOrGuid) + end + if raidIndex then + SetRaidTargetIconTexture(row.raidIcon, raidIndex) + row.raidIcon:Show() + else + row.raidIcon:Hide() + end + + -- Update name + local displayName = TruncateName(info.name, 14) + row.nameText:SetText(displayName) + + -- Update health (SuperWoW can use GUID directly) + local health, maxHealth = GetHealthForGUID(guid) + if health and maxHealth and maxHealth > 0 then + local percent = (health / maxHealth) * 100 + row.healthBar:SetValue(percent) + row.hpText:SetText(FormatHealth(health)) + + -- Color based on unit type (like Cursive) + local r, g, b = 1, 0.8, 0.2 -- Default gold + if unitOrGuid then + if UnitIsPlayer(unitOrGuid) then + local _, class = UnitClass(unitOrGuid) + if class and RAID_CLASS_COLORS and RAID_CLASS_COLORS[class] then + r = RAID_CLASS_COLORS[class].r + g = RAID_CLASS_COLORS[class].g + b = RAID_CLASS_COLORS[class].b + end + end + end + row.healthBar:SetStatusBarColor(r, g, b, 1) + else + row.healthBar:SetValue(100) + row.hpText:SetText("?") + row.healthBar:SetStatusBarColor(0.4, 0.4, 0.4, 1) + end + + -- Update border color based on hover/combat state (Cursive style) + if not row.hover then + local inCombat = false + if unitOrGuid then + inCombat = UnitAffectingCombat(unitOrGuid) + end + if inCombat then + row.healthBorder:SetBackdropBorderColor(COLORS.borderCombat[1], COLORS.borderCombat[2], COLORS.borderCombat[3], 1) + else + row.healthBorder:SetBackdropBorderColor(COLORS.borderNormal[1], COLORS.borderNormal[2], COLORS.borderNormal[3], 1) + end + end + + -- Update debuff icons + UpdateDebuffIcons(row, guid) + + row:Show() +end + +function UpdateFrame() + -- First, prune dead targets and those with no debuffs + CheckAndPruneTargets() + + local numTargets = table.getn(trackedOrder) + + if numTargets == 0 then + if isUnlocked then + -- Show placeholder for positioning + local row = targetRows[1] + if row then + row.nameText:SetText("(Unlocked)") + row.healthBar:SetValue(100) + row.healthBar:SetStatusBarColor(0.3, 0.3, 0.3) + row.hpText:SetText("Move") + row.targetIndicator:Hide() + row.raidIcon:Hide() + for i = 1, MAX_ICONS_PER_ROW do + if row.icons[i] then row.icons[i]:Hide() end + end + row.guid = nil + row:Show() + end + for i = 2, MAX_TRACKED_TARGETS do + targetRows[i]:Hide() + end + + local contentHeight = CONFIG.barHeight + CONFIG.barSpacing + local totalHeight = CONFIG.titleHeight + contentHeight + 8 + if mainFrame then + mainFrame:SetHeight(totalHeight) + contentFrame:SetHeight(contentHeight) + if not isFrameVisible then + mainFrame:Show() + isFrameVisible = true + end + end + return + end + + if mainFrame and isFrameVisible then + mainFrame:Hide() + isFrameVisible = false + end + return + end + + -- Update each row + for i = 1, MAX_TRACKED_TARGETS do + local row = targetRows[i] + if i <= numTargets then + local guid = trackedOrder[i] + local info = trackedTargets[guid] + UpdateRow(row, guid, info) + else + row:Hide() + end + end + + -- Adjust frame height + local contentHeight = numTargets * (CONFIG.barHeight + CONFIG.barSpacing) + local totalHeight = CONFIG.titleHeight + contentHeight + 8 + + if mainFrame then + mainFrame:SetHeight(totalHeight) + contentFrame:SetHeight(contentHeight) + end +end + +-- ============================================================================ +-- Frame Position Persistence +-- ============================================================================ + +function SaveFramePosition() + if not mainFrame then return end + + local point, relativeTo, relativePoint, xOfs, yOfs = mainFrame:GetPoint() + + CleveRoidMacros = CleveRoidMacros or {} + CleveRoidMacros.multiTargetPos = { + point = point, + relativePoint = relativePoint, + x = xOfs, + y = yOfs, + } +end + +local function RestoreFramePosition() + if not mainFrame then return end + + CleveRoidMacros = CleveRoidMacros or {} + local pos = CleveRoidMacros.multiTargetPos + + if pos and pos.point then + mainFrame:ClearAllPoints() + mainFrame:SetPoint(pos.point, UIParent, pos.relativePoint or pos.point, pos.x or 0, pos.y or 0) + end +end + +-- ============================================================================ +-- lib:AddEffect Hook +-- ============================================================================ + +local function IsPlayerAppliedDebuff(spellID, caster) + -- Explicitly marked as player + if IsCasterPlayer(caster) then + return true + end + + -- Proc debuffs (items/talents) are always from the player + if PROC_DEBUFFS[spellID] then + return true + end + + -- For shared debuffs, we rely on the caster field being set to "player" + -- The pending cast system handles this in DEBUFF_ADDED_OTHER + + return false +end + +local function HookAddEffect() + if not lib or not lib.AddEffect then + CleveRoids.Print("MultiTargetTracker: Could not hook libdebuff.AddEffect") + return + end + + local originalAddEffect = lib.AddEffect + + lib.AddEffect = function(self, guid, unitName, spellID, duration, stacks, caster) + -- Call original first to ensure the record exists + originalAddEffect(self, guid, unitName, spellID, duration, stacks, caster) + + -- Normalize GUID for our lookups + local normalizedGuid = CleveRoids.NormalizeGUID(guid) + + -- Check if we have a pending cast for this spell+target + local pending = GetPendingCast(spellID, guid) + if pending then + -- This is our debuff - force set caster and refresh timer + if normalizedGuid and lib.objects[normalizedGuid] and lib.objects[normalizedGuid][spellID] then + lib.objects[normalizedGuid][spellID].start = GetTime() + lib.objects[normalizedGuid][spellID].duration = duration or lib.objects[normalizedGuid][spellID].duration + lib.objects[normalizedGuid][spellID].caster = "player" + end + AddTrackedTarget(guid, unitName, false, guid) -- guid is raw here + elseif IsCasterPlayer(caster) then + -- Explicitly marked as player's debuff + AddTrackedTarget(guid, unitName, false, guid) -- guid is raw here + end + end +end + +-- ============================================================================ +-- SPELL_CAST_EVENT Handler (Nampower) - PRIMARY +-- From wiki: "This will only fire for spells you (and certain pets) initiated" +-- ============================================================================ + +local function SetupSpellCastEventTracking() + if not CleveRoids.hasNampower then + return + end + + local frame = CreateFrame("Frame", "CleveRoidsMTTSpellCastFrame", UIParent) + frame:RegisterEvent("SPELL_CAST_EVENT") + frame:SetScript("OnEvent", function() + -- SPELL_CAST_EVENT args (from Nampower wiki): + -- arg1 = success (1 if cast succeeded, 0 if failed) + -- arg2 = spellId + -- arg3 = castType (1=NORMAL, 2=NON_GCD, 3=ON_SWING, 4=CHANNEL, 5=TARGETING, 6=TARGETING_NON_GCD) + -- arg4 = targetGuid (string like "0xF5300000000000A5", or "0x000000000" if no explicit target) + -- arg5 = itemId (0 if not triggered by item) + + local success = arg1 + local spellId = arg2 + local targetGuid = arg4 + + -- Only track successful casts + if success ~= 1 or not spellId then return end + + -- Only track known debuff spells (filter out attacks like Bloodthirst, Heroic Strike) + if not IsKnownDebuffSpell(spellId) then + return + end + + -- For AoE spells, targetGuid might be "0x000000000" or the current target + -- We still want to record the cast for DEBUFF_ADDED_OTHER matching + local isAoE = AOE_DEBUFF_SPELLS[spellId] + + -- Skip non-AoE spells with no valid target + if not isAoE and (not targetGuid or targetGuid == "0x000000000") then + return + end + + -- Get target name using multiple resolution methods + local targetName = nil + if targetGuid and targetGuid ~= "0x000000000" then + targetName = ResolveTargetName(targetGuid) + end + + -- Record this cast for matching with DEBUFF_ADDED_OTHER + RecordPlayerCast(spellId, targetGuid, targetName) + + -- Refresh existing debuff timers + -- DEBUFF_ADDED_OTHER only fires on new applications, not refreshes + local libdebuff = CleveRoids.libdebuff + if libdebuff and libdebuff.objects then + local duration = GetSpellDuration(spellId) + + if isAoE then + -- AoE spell: refresh ALL tracked targets with this debuff + local refreshCount = 0 + + for guid, debuffs in pairs(libdebuff.objects) do + -- Try both number and string keys (Lua distinguishes them) + local rec = debuffs[spellId] or debuffs[tostring(spellId)] + if rec then + -- For AoE refreshes, we trust the pending cast system + -- If we just cast this AoE spell, refresh all instances of this debuff + -- The caster field may not be set yet for new targets hit by this AoE + local shouldRefresh = IsCasterPlayer(rec.caster) or pendingAoECasts[spellId] + + if shouldRefresh then + rec.start = GetTime() + rec.duration = duration + rec.caster = "player" -- Ensure caster is set for future refreshes + refreshCount = refreshCount + 1 + if MTT_DEBUG then + local spellName = SpellInfo and SpellInfo(spellId) or "?" + local name = UnitName(guid) or "?" + DEFAULT_CHAT_FRAME:AddMessage(string.format( + "|cff88ffff[MTT]|r AoE refresh: %s on %s (dur:%.1f)", + spellName, name, duration)) + end + end + end + end + else + -- Single-target spell: refresh only the targeted enemy + local normalizedGuid = CleveRoids.NormalizeGUID(targetGuid) + if normalizedGuid and libdebuff.objects[normalizedGuid] and libdebuff.objects[normalizedGuid][spellId] then + libdebuff.objects[normalizedGuid][spellId].start = GetTime() + libdebuff.objects[normalizedGuid][spellId].duration = duration + if MTT_DEBUG then + local spellName = SpellInfo and SpellInfo(spellId) or "?" + DEFAULT_CHAT_FRAME:AddMessage(string.format( + "|cff88ffff[MTT]|r Debuff refresh: %s on %s (dur:%.1f)", + spellName, targetName or "?", duration)) + end + end + end + end + end) + + -- Periodic cleanup of old pending casts + frame:SetScript("OnUpdate", function() + this.elapsed = (this.elapsed or 0) + arg1 + if this.elapsed > 1 then + this.elapsed = 0 + CleanupPendingCasts() + end + end) +end + +-- ============================================================================ +-- Direct Debuff Scanning (SuperWoW) +-- Scans target debuffs directly to catch debuffs missed by events +-- ============================================================================ + +local DEBUFF_SCAN_INTERVAL = 0.5 -- Scan every 0.5 seconds +local lastDebuffScan = 0 + +-- Scan a target's debuffs and update libdebuff.objects +-- Also removes debuffs that are no longer present (overwritten, dispelled, etc.) +-- @param normalizedGuid - Normalized GUID for lib.objects lookup +-- @param rawGuid - Raw GUID for API calls (UnitDebuff, etc.) +local function ScanTargetDebuffs(normalizedGuid, rawGuid) + if not CleveRoids.hasSuperwow then return end + + local lib = CleveRoids.libdebuff + if not lib then return end + + -- Use raw GUID for API calls, normalized for table lookups + local apiGuid = rawGuid or normalizedGuid + normalizedGuid = CleveRoids.NormalizeGUID(normalizedGuid) + if not normalizedGuid then return end + + local now = GetTime() + + -- Build a set of spell IDs currently on the target + local presentDebuffs = {} + + -- SuperWoW supports UnitDebuff with raw GUID directly + -- Scan debuff slots 1-16 (regular) and 17-48 (overflow in buff slots) + local scannedAny = false + + for slot = 1, 48 do + -- SuperWoW UnitDebuff(guid, slot) returns: texture, stacks, debuffType, spellID + -- NOTE: Does NOT return duration, timeLeft, or caster - only presence info + local texture, stacks, debuffType, spellId = UnitDebuff(apiGuid, slot) + + -- Stop at nil for slots 1-16 (dense), continue for 17-48 (sparse overflow) + if not texture then + if slot <= 16 then + break + end + elseif spellId then + -- Record that this debuff is present on target + presentDebuffs[spellId] = true + scannedAny = true + end + end + + -- Only do removal detection if the scan actually returned data + -- If UnitDebuff returned nothing, the GUID might be inaccessible (out of range, etc.) + if not scannedAny then + -- Check if there are ANY debuffs on this target in general + -- If the target should have debuffs (from our tracking) but scan found none, + -- it's likely a GUID access issue, not actual removal + if lib.objects[normalizedGuid] then + local hasTracked = false + for spellId, rec in pairs(lib.objects[normalizedGuid]) do + if rec.caster == "player" then + hasTracked = true + break + end + end + if hasTracked then + -- We're tracking debuffs but scan found nothing - skip removal detection + -- Don't spam debug - this is normal for GUIDs we can't access + return + end + end + end + + -- Check for debuffs we're tracking that are no longer on the target + -- (overwritten by stronger effects, dispelled, etc.) + if lib.objects[normalizedGuid] then + local toRemove = {} + + for spellId, rec in pairs(lib.objects[normalizedGuid]) do + -- Only check debuffs we applied (caster = player) + if rec.caster == "player" and IsKnownDebuffSpell(spellId) then + -- If debuff is not present on target anymore, mark for removal + if not presentDebuffs[spellId] then + -- Double-check it's not just expired naturally + local remaining = (rec.start + rec.duration) - now + if remaining > 0.5 then + -- Debuff should still be active but isn't on target - it was removed! + table.insert(toRemove, spellId) + + if MTT_DEBUG then + local spellName = SpellInfo and SpellInfo(spellId) or "?" + local targetName = UnitName(apiGuid) or "?" + DEFAULT_CHAT_FRAME:AddMessage(string.format( + "|cffff8800[MTT]|r Removed: %s on %s (overwritten/dispelled, %.1fs left)", + spellName, targetName, remaining)) + end + end + end + end + end + + -- Remove the debuffs that are no longer present + for _, spellId in ipairs(toRemove) do + lib.objects[normalizedGuid][spellId] = nil + end + end +end + +-- Scan tracked targets for debuffs +-- SuperWoW supports UnitDebuff with GUIDs directly +ScanAllTrackedDebuffs = function() + if not CleveRoids.hasSuperwow then return end + + local now = GetTime() + if now - lastDebuffScan < DEBUFF_SCAN_INTERVAL then + return + end + lastDebuffScan = now + + -- Scan current target first (most reliable) - use raw GUID + if UnitExists("target") then + local _, targetGuid = UnitExists("target") + if targetGuid then + ScanTargetDebuffs(targetGuid, targetGuid) -- raw GUID for API calls + end + end + + -- Also scan other tracked targets (SuperWoW supports GUID access) + for normalizedGuid, info in pairs(trackedTargets) do + -- Use stored raw GUID for API calls + local rawGuid = info.rawGuid or normalizedGuid + ScanTargetDebuffs(normalizedGuid, rawGuid) + end +end + +-- ============================================================================ +-- UNIT_CASTEVENT Handler (SuperWoW) - BACKUP +-- Used when Nampower is not available, or as additional tracking +-- ============================================================================ + +local function SetupUnitCastEventTracking() + if not CleveRoids.hasSuperwow then + return + end + + local frame = CreateFrame("Frame", "CleveRoidsMTTUnitCastFrame", UIParent) + frame:RegisterEvent("UNIT_CASTEVENT") + frame:SetScript("OnEvent", function() + -- UNIT_CASTEVENT args (from SuperWoW wiki): + -- arg1 = casterGUID + -- arg2 = targetGUID + -- arg3 = event type ("START", "CAST", "FAIL", "CHANNEL", "MAINHAND", "OFFHAND") + -- arg4 = spell id + -- arg5 = cast duration + + local casterGuid = arg1 + local targetGuid = arg2 + local eventType = arg3 + local spellId = arg4 + + -- Check if caster is player by comparing GUIDs (SuperWoW GUIDs are consistent) + local _, playerGuid = UnitExists("player") + if not playerGuid or casterGuid ~= playerGuid then + return + end + + -- Only track cast starts and instant casts + if eventType ~= "START" and eventType ~= "CAST" then + return + end + + if not spellId or not targetGuid then return end + + -- Skip empty GUIDs (can happen for some AoE spells) + if targetGuid == "" or targetGuid == "0x0" or targetGuid == "0x000000000" then + return + end + + -- Skip self-targeted spells (buffs like Flurry go on player, not enemies) + if targetGuid == playerGuid then + return + end + + -- Get target name using multiple resolution methods + local targetName = ResolveTargetName(targetGuid) + + -- Skip if we can't resolve the target name (avoid "Unknown" entries) + if not targetName then return end + + -- Record this cast (backup to SPELL_CAST_EVENT) + RecordPlayerCast(spellId, targetGuid, targetName) + end) +end + +-- ============================================================================ +-- AURA_CAST_ON_OTHER Handler (Nampower) - Duration data + caster verification +-- Requires CVar NP_EnableAuraCastEvents=1 +-- ============================================================================ + +local function SetupAuraCastTracking() + if not CleveRoids.hasNampower then + return + end + + -- Check if AURA_CAST events are enabled (they're off by default!) + local auraCastEnabled = GetCVar and GetCVar("NP_EnableAuraCastEvents") + if auraCastEnabled ~= "1" then + DEFAULT_CHAT_FRAME:AddMessage("|cffff8800[MTT]|r Warning: AURA_CAST events disabled. Run: /run SetCVar('NP_EnableAuraCastEvents','1')") + end + + local frame = CreateFrame("Frame", "CleveRoidsMTTAuraCastFrame", UIParent) + frame:RegisterEvent("AURA_CAST_ON_OTHER") + frame:SetScript("OnEvent", function() + -- AURA_CAST_ON_OTHER args (from Nampower wiki): + -- arg1 = spellId + -- arg2 = casterGuid + -- arg3 = targetGuid + -- arg4 = effect + -- arg5 = effectAuraName + -- arg6 = effectAmplitude + -- arg7 = effectMiscValue + -- arg8 = durationMs (duration in milliseconds!) + -- arg9 = auraCapStatus + + local spellId = arg1 + local casterGuid = arg2 + local targetGuid = arg3 + local durationMs = arg8 + + if not spellId or not targetGuid then return end + + -- Safety check: skip if target is the player (self-buff) + -- This prevents tracking self-buffs like Lightning Shield on enemies + local _, playerGuid = UnitExists("player") + if playerGuid and CleveRoids.GUIDsMatch(playerGuid, targetGuid) then + return -- Self-targeted aura, skip + end + + -- Cache duration from AURA_CAST_ON_OTHER (most accurate, includes client modifiers) + if durationMs and durationMs > 0 and IsCasterPlayer(casterGuid) then + auraCastDurations[spellId] = durationMs / 1000 + end + + -- Check pending casts for NEW debuff applications + local pending = GetPendingCast(spellId, targetGuid) + + -- Also check if this is a REFRESH of an existing debuff we're already tracking + local libdebuff = CleveRoids.libdebuff + local normalizedGuid = CleveRoids.NormalizeGUID(targetGuid) + local isRefresh = false + + if libdebuff and normalizedGuid then + local existingDebuff = libdebuff.objects[normalizedGuid] and libdebuff.objects[normalizedGuid][spellId] + if existingDebuff and IsCasterPlayer(existingDebuff.caster) then + -- This is a refresh of our existing debuff! + isRefresh = true + end + end + + -- For AoE spells, also treat as refresh if we have an AoE pending cast AND + -- the target already has this debuff from us (tracked in our system) + if not isRefresh and AOE_DEBUFF_SPELLS[spellId] and pending then + -- AoE spell with pending cast - check if target is already tracked with this debuff + if libdebuff and normalizedGuid and libdebuff.objects[normalizedGuid] and libdebuff.objects[normalizedGuid][spellId] then + isRefresh = true + end + end + + + if not pending and not isRefresh then + -- Not our debuff (no pending cast and not a refresh of existing tracked debuff) + return + end + + -- libdebuff and normalizedGuid already set above + if not libdebuff or not normalizedGuid then return end + + -- Get duration in seconds (from milliseconds) - key value from this event! + local duration = durationMs and (durationMs / 1000) or 0 + + -- Fallback to unified duration lookup + if duration <= 0 then + duration = GetSpellDuration(spellId) + end + + -- Get target name using multiple resolution methods + local targetName = ResolveTargetName(targetGuid) + + -- Skip if we can't resolve the target name (avoid "Unknown" entries) + if not targetName then return end + + -- Update debuff record with accurate duration from DBC + libdebuff.objects[normalizedGuid] = libdebuff.objects[normalizedGuid] or {} + local hasExisting = libdebuff.objects[normalizedGuid][spellId] + libdebuff.objects[normalizedGuid][spellId] = libdebuff.objects[normalizedGuid][spellId] or {} + libdebuff.objects[normalizedGuid][spellId].start = GetTime() + libdebuff.objects[normalizedGuid][spellId].duration = duration + libdebuff.objects[normalizedGuid][spellId].caster = "player" + -- Force stacks to 1 for non-stacking debuffs + if NON_STACKING_DEBUFFS[spellId] then + libdebuff.objects[normalizedGuid][spellId].stacks = 1 + else + libdebuff.objects[normalizedGuid][spellId].stacks = libdebuff.objects[normalizedGuid][spellId].stacks or 1 + end + + -- Add target to tracker (pass raw targetGuid for API calls) + AddTrackedTarget(normalizedGuid, targetName, false, targetGuid) + + if MTT_DEBUG then + local spellName = SpellInfo and SpellInfo(spellId) or "?" + DEFAULT_CHAT_FRAME:AddMessage(string.format( + "|cff00ff00[MTT]|r %s: %s on %s (%.0fs)", + hasExisting and "Refresh" or "Track", + spellName, targetName, duration)) + end + end) +end + +-- ============================================================================ +-- DEBUFF_ADDED_OTHER Handler (Nampower) - Debuff confirmation +-- ============================================================================ + +local function SetupDebuffAddedTracking() + if not CleveRoids.hasNampower then + return + end + + local frame = CreateFrame("Frame", "CleveRoidsMTTDebuffAddedFrame", UIParent) + frame:RegisterEvent("DEBUFF_ADDED_OTHER") + frame:SetScript("OnEvent", function() + -- DEBUFF_ADDED_OTHER args (from Nampower wiki): + -- arg1 = guid (target who received the debuff) + -- arg2 = slot (1-based debuff slot) + -- arg3 = spellId + -- arg4 = stackCount + -- arg5 = auraLevel (caster level) + + local targetGuid = arg1 + local spellId = arg3 + local stackCount = arg4 or 1 + + if not targetGuid or not spellId then return end + + -- Safety check: skip if target is the player (self-buff) + local _, playerGuid = UnitExists("player") + if playerGuid and CleveRoids.GUIDsMatch(playerGuid, targetGuid) then + return -- Self-targeted aura, skip + end + + -- Only track known debuff spells + local isKnown = IsKnownDebuffSpell(spellId) + + -- Debug: Show if we received DEBUFF_ADDED for known spells + if MTT_DEBUG and isKnown then + local spellName = SpellInfo and SpellInfo(spellId) or "?" + local targetName = UnitName(targetGuid) or "?" + DEFAULT_CHAT_FRAME:AddMessage(string.format( + "|cff00ff00[MTT]|r DEBUFF_ADDED: %s (id=%d) on %s", + spellName, spellId, targetName)) + end + + if not isKnown then + return + end + + -- Check if we have a pending cast for this spell+target + local pending = GetPendingCast(spellId, targetGuid) + + -- Also check if this is a REFRESH of an existing debuff we're already tracking + local libdebuff = CleveRoids.libdebuff + local normalizedGuid = CleveRoids.NormalizeGUID(targetGuid) + local isRefresh = false + + if libdebuff and normalizedGuid then + local existingDebuff = libdebuff.objects[normalizedGuid] and libdebuff.objects[normalizedGuid][spellId] + if existingDebuff and IsCasterPlayer(existingDebuff.caster) then + -- This is a refresh of our existing debuff! + isRefresh = true + end + end + + -- For AoE spells, also treat as refresh if we have an AoE pending cast AND + -- the target already has this debuff tracked + if not isRefresh and AOE_DEBUFF_SPELLS[spellId] and pending then + if libdebuff and normalizedGuid and libdebuff.objects[normalizedGuid] and libdebuff.objects[normalizedGuid][spellId] then + isRefresh = true + end + end + + if not pending and not isRefresh then + -- No pending cast and not a refresh - not our debuff + return + end + + -- We have a pending cast or refresh - this is our debuff! + if not libdebuff or not normalizedGuid then return end + + -- Get duration using unified lookup system + local duration = GetSpellDuration(spellId) + + -- Get target name using multiple resolution methods + local targetName = ResolveTargetName(targetGuid) + + -- Skip if we can't resolve the target name (avoid "Unknown" entries) + if not targetName then return end + + -- Add target to tracker (pass raw targetGuid for API calls) + AddTrackedTarget(normalizedGuid, targetName, false, targetGuid) + + -- Update debuff record + if libdebuff.objects then + libdebuff.objects[normalizedGuid] = libdebuff.objects[normalizedGuid] or {} + local hasExisting = libdebuff.objects[normalizedGuid][spellId] + libdebuff.objects[normalizedGuid][spellId] = libdebuff.objects[normalizedGuid][spellId] or {} + libdebuff.objects[normalizedGuid][spellId].start = GetTime() + libdebuff.objects[normalizedGuid][spellId].duration = duration + libdebuff.objects[normalizedGuid][spellId].caster = "player" + -- Force stacks to 1 for non-stacking debuffs + libdebuff.objects[normalizedGuid][spellId].stacks = NON_STACKING_DEBUFFS[spellId] and 1 or stackCount + + if MTT_DEBUG then + local spellName = SpellInfo and SpellInfo(spellId) or "?" + DEFAULT_CHAT_FRAME:AddMessage(string.format( + "|cff00ff00[MTT]|r %s: %s on %s (%.0fs)", + hasExisting and "Refresh" or "Track", + spellName, targetName, duration)) + end + end + end) +end + +-- ============================================================================ +-- BUFF_ADDED_OTHER Handler (Nampower) - Debuff overflow tracking +-- When debuff bar is full, debuffs appear in buff slots 17-48 +-- Also catches aura refreshes that might not trigger DEBUFF_ADDED_OTHER +-- ============================================================================ + +local function SetupBuffAddedTracking() + if not CleveRoids.hasNampower then + return + end + + local frame = CreateFrame("Frame", "CleveRoidsMTTBuffAddedFrame", UIParent) + frame:RegisterEvent("BUFF_ADDED_OTHER") + frame:SetScript("OnEvent", function() + -- BUFF_ADDED_OTHER args (same as DEBUFF_ADDED_OTHER): + -- arg1 = guid (target who received the buff) + -- arg2 = slot (1-based slot) + -- arg3 = spellId + -- arg4 = stackCount + -- arg5 = auraLevel (caster level) + + local targetGuid = arg1 + local spellId = arg3 + local stackCount = arg4 or 1 + + if not targetGuid or not spellId then return end + + -- Safety check: skip if target is the player (self-buff) + local _, playerGuid = UnitExists("player") + if playerGuid and CleveRoids.GUIDsMatch(playerGuid, targetGuid) then + return -- Self-targeted aura, skip + end + + -- Only process if this is a debuff we're already tracking (refresh in overflow slot) + local libdebuff = CleveRoids.libdebuff + local normalizedGuid = CleveRoids.NormalizeGUID(targetGuid) + + if not libdebuff or not normalizedGuid then return end + + local existingDebuff = libdebuff.objects[normalizedGuid] and libdebuff.objects[normalizedGuid][spellId] + if not existingDebuff or existingDebuff.caster ~= "player" then + -- Not a debuff we're tracking + return + end + + -- This is a refresh of our existing debuff (appearing in buff overflow)! + -- Get duration from our known durations + local duration = existingDebuff.duration + or (libdebuff.personalDebuffs and libdebuff.personalDebuffs[spellId]) + or (libdebuff.sharedDebuffs and libdebuff.sharedDebuffs[spellId]) + or (libdebuff.durations and libdebuff.durations[spellId]) + or 30 + + -- Update debuff record + libdebuff.objects[normalizedGuid][spellId].start = GetTime() + libdebuff.objects[normalizedGuid][spellId].stacks = stackCount + + if MTT_DEBUG then + local spellName = SpellInfo and SpellInfo(spellId) or "?" + local targetName = ResolveTargetName(targetGuid) or "?" + DEFAULT_CHAT_FRAME:AddMessage(string.format( + "|cff00ff00[MTT]|r Refresh: %s on %s (%.0fs)", + spellName, targetName, duration)) + end + end) +end + +-- ============================================================================ +-- Death Detection (immediate removal of dead units) +-- Uses UNIT_DIED from Nampower when available +-- ============================================================================ + +local function SetupDeathDetection() + local deathFrame = CreateFrame("Frame", "CleveRoidsMTTDeathFrame", UIParent) + + -- Register for target changes and health updates to catch deaths quickly + deathFrame:RegisterEvent("PLAYER_TARGET_CHANGED") + deathFrame:RegisterEvent("UNIT_HEALTH") + deathFrame:RegisterEvent("CHAT_MSG_COMBAT_HOSTILE_DEATH") + + -- UNIT_DIED from Nampower (most reliable) + if CleveRoids.hasNampower then + deathFrame:RegisterEvent("UNIT_DIED") + end + + -- Player leaving combat - cleanup targets with no active debuffs + deathFrame:RegisterEvent("PLAYER_REGEN_ENABLED") + + deathFrame:SetScript("OnEvent", function() + if event == "PLAYER_REGEN_ENABLED" then + -- Player left combat - immediately prune targets with no active debuffs + local toRemove = {} + for guid, info in pairs(trackedTargets) do + if IsGUIDDead(guid) then + table.insert(toRemove, guid) + else + local debuffs, count = GetActivePlayerDebuffs(guid) + if count == 0 then + table.insert(toRemove, guid) + end + end + end + for i = 1, table.getn(toRemove) do + RemoveTrackedTarget(toRemove[i]) + end + if MTT_DEBUG and table.getn(toRemove) > 0 then + DEFAULT_CHAT_FRAME:AddMessage(string.format( + "|cff00ff00[MTT]|r Left combat, removed %d targets with no debuffs", + table.getn(toRemove))) + end + + elseif event == "UNIT_DIED" then + -- UNIT_DIED args (from Nampower wiki): + -- arg1 = guid of the unit that died + local deadGuid = arg1 + if deadGuid then + local normalizedGUID = CleveRoids.NormalizeGUID(deadGuid) + if normalizedGUID and trackedTargets[normalizedGUID] then + if MTT_DEBUG then + local info = trackedTargets[normalizedGUID] + DEFAULT_CHAT_FRAME:AddMessage(string.format( + "|cffff0000[MTT]|r UNIT_DIED: %s", info.name or "?")) + end + RemoveTrackedTarget(normalizedGUID) + end + end + + elseif event == "CHAT_MSG_COMBAT_HOSTILE_DEATH" then + -- arg1 = death message like "Mob Name dies." + if arg1 then + -- Extract name from "X dies." message + local deadName = string.gsub(arg1, " dies%.$", "") + if deadName and deadName ~= "" then + -- Find and remove any tracked target with this name + for guid, info in pairs(trackedTargets) do + if info.name == deadName then + RemoveTrackedTarget(guid) + break + end + end + end + end + + elseif event == "PLAYER_TARGET_CHANGED" or event == "UNIT_HEALTH" then + -- Quick check: if current target is dead and tracked, remove it + if UnitExists("target") and UnitIsDead("target") then + local _, targetGUID = UnitExists("target") + if targetGUID then + local normalizedGUID = CleveRoids.NormalizeGUID(targetGUID) + if normalizedGUID and trackedTargets[normalizedGUID] then + RemoveTrackedTarget(normalizedGUID) + end + end + end + + -- Also do a quick scan of all tracked targets for deaths (SuperWoW) + if CleveRoids.hasSuperwow then + local toRemove = {} + for guid, info in pairs(trackedTargets) do + if UnitIsDead(guid) then + table.insert(toRemove, guid) + end + end + for i = 1, table.getn(toRemove) do + RemoveTrackedTarget(toRemove[i]) + end + end + end + end) +end + +-- ============================================================================ +-- Combat Target Scanning +-- ============================================================================ + +local combatScanInterval = 0.5 +local lastCombatScan = 0 + +ScanCombatTargets = function() + if not CleveRoids.hasUnitXP then + return + end + + if not UnitAffectingCombat("player") then + return + end + + local now = GetTime() + if now - lastCombatScan < combatScanInterval then + return + end + lastCombatScan = now + + local hadTarget = UnitExists("target") + local currentTargetGuid = nil + if hadTarget then + local _, guid = UnitExists("target") + currentTargetGuid = guid + end + + local firstGuid = nil + local maxIterations = 30 + + 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 + end + + local normalizedGuid = CleveRoids.NormalizeGUID(currentGuid) + + if normalizedGuid and not trackedTargets[normalizedGuid] then + local name = UnitName("target") + confirmedEnemies[normalizedGuid] = true + AddTrackedTarget(normalizedGuid, name, true, currentGuid) -- pass raw GUID + end + end + + -- Restore original target state + if currentTargetGuid then + TargetUnit(currentTargetGuid) + else + -- User had no target before scan - clear whatever UnitXP selected + ClearTarget() + end +end + +-- ============================================================================ +-- Multiscan Integration +-- ============================================================================ + +local function GetTrackedTargetGUIDs() + local guids = {} + for i = 1, table.getn(trackedOrder) do + local guid = trackedOrder[i] + if trackedTargets[guid] then + table.insert(guids, guid) + end + end + return guids +end + +local function GetTrackedTargetInfo(guid) + return trackedTargets[guid] +end + +local function IsTracked(guid) + if not guid then return false end + guid = CleveRoids.NormalizeGUID(guid) + return trackedTargets[guid] ~= nil +end + +local function GetTrackedCount() + return table.getn(trackedOrder) +end + +local function FindTrackedTarget(priority, validateFunc) + if table.getn(trackedOrder) == 0 then + return nil + end + + local bestGuid = nil + local bestScore = nil + + for i = 1, table.getn(trackedOrder) do + local guid = trackedOrder[i] + local info = trackedTargets[guid] + + if info then + if validateFunc and not validateFunc(guid) then + -- Skip + else + local score = nil + + if priority == "oldest" then + return guid + elseif priority == "newest" then + bestGuid = guid + elseif priority == "lowestdebuff" then + local debuffs, count = GetActivePlayerDebuffs(guid) + if debuffs and count > 0 then + for j = 1, count do + local remaining = debuffs[j].remaining + if not score or remaining < score then + score = remaining + end + end + end + elseif priority == "highestdebuff" then + local debuffs, count = GetActivePlayerDebuffs(guid) + if debuffs and count > 0 then + for j = 1, count do + local remaining = debuffs[j].remaining + if not score or remaining > score then + score = remaining + end + end + end + if score then score = -score end + elseif priority == "fewestdebuffs" then + local _, count = GetActivePlayerDebuffs(guid) + score = count + elseif priority == "mostdebuffs" then + local _, count = GetActivePlayerDebuffs(guid) + score = -count + end + + if score then + if not bestScore or score < bestScore then + bestScore = score + bestGuid = guid + end + end + end + end + end + + return bestGuid +end + +-- ============================================================================ +-- Extension Lifecycle +-- ============================================================================ + +-- Sync all our debuff durations to pfUI's libdebuff +local function SyncDurationsToPfUI() + if not pfUI or not pfUI.api or not pfUI.api.libdebuff then + return + end + + local pflib = pfUI.api.libdebuff + pflib.debuffs = pflib.debuffs or {} + + local count = 0 + + -- Sync personal debuffs + if lib.personalDebuffs then + for spellID, duration in pairs(lib.personalDebuffs) do + local spellName = SpellInfo(spellID) + if spellName then + -- Strip rank from name + local baseName = string.gsub(spellName, "%s*%(Rank %d+%)", "") + -- Only update if our duration is different (prefer longer/more accurate) + if not pflib.debuffs[baseName] or pflib.debuffs[baseName] < duration then + pflib.debuffs[baseName] = duration + count = count + 1 + end + end + end + end + + -- Sync shared debuffs + if lib.sharedDebuffs then + for spellID, duration in pairs(lib.sharedDebuffs) do + local spellName = SpellInfo(spellID) + if spellName then + local baseName = string.gsub(spellName, "%s*%(Rank %d+%)", "") + if not pflib.debuffs[baseName] or pflib.debuffs[baseName] < duration then + pflib.debuffs[baseName] = duration + count = count + 1 + end + end + end + end + + -- Sync proc debuffs + for spellID, _ in pairs(PROC_DEBUFFS) do + local duration = lib.durations and lib.durations[spellID] + if duration then + local spellName = SpellInfo(spellID) + if spellName then + local baseName = string.gsub(spellName, "%s*%(Rank %d+%)", "") + if not pflib.debuffs[baseName] or pflib.debuffs[baseName] ~= duration then + pflib.debuffs[baseName] = duration + count = count + 1 + end + end + end + end + +end + +function Extension.OnLoad() + lib = CleveRoids.libdebuff + + if not lib then + CleveRoids.Print("MultiTargetTracker: libdebuff not found, extension disabled") + return + end + + -- Build cache of player's known spells for attribution checks + BuildPlayerSpellCache() + + -- Add item proc debuffs to shared debuffs table (if not already present) + -- Thunderfury, Blessed Blade of the Windseeker + lib.sharedDebuffs = lib.sharedDebuffs or {} + lib.sharedDebuffs[21992] = lib.sharedDebuffs[21992] or 12 -- Thunderfury (12s duration) + lib.sharedDebuffs[27648] = lib.sharedDebuffs[27648] or 12 -- Thunderfury secondary effect + + -- Also add to durations table for GetDuration lookups + lib.durations = lib.durations or {} + lib.durations[21992] = lib.durations[21992] or 12 + lib.durations[27648] = lib.durations[27648] or 12 + + mainFrame = CreateMainFrame() + HookAddEffect() + + -- Enable AURA_CAST events in Nampower (required for AURA_CAST_ON_OTHER to fire) + if CleveRoids.hasNampower and SetCVar then + SetCVar("NP_EnableAuraCastEvents", "1") + end + + -- Setup tracking handlers based on wiki documentation: + -- SPELL_CAST_EVENT: "only fires for spells you (and certain pets) initiated" + -- DEBUFF_ADDED_OTHER: confirms debuff landed, provides stacks + -- AURA_CAST_ON_OTHER: provides durationMs from DBC + -- UNIT_CASTEVENT: SuperWoW backup with consistent GUIDs + -- UNIT_DIED: reliable death detection from Nampower + SetupSpellCastEventTracking() -- Primary: Nampower SPELL_CAST_EVENT (only YOUR casts) + SetupUnitCastEventTracking() -- Backup: SuperWoW UNIT_CASTEVENT + SetupDebuffAddedTracking() -- Confirmation: DEBUFF_ADDED_OTHER + SetupBuffAddedTracking() -- Overflow/refresh: BUFF_ADDED_OTHER (debuffs in buff slots 17-48) + SetupAuraCastTracking() -- Duration data + refresh: AURA_CAST_ON_OTHER + SetupDeathDetection() -- Cleanup: UNIT_DIED + fallbacks + RestoreFramePosition() + + -- Sync our durations to pfUI (delayed slightly to ensure pfUI is loaded) + local syncFrame = CreateFrame("Frame") + syncFrame:SetScript("OnUpdate", function() + this.elapsed = (this.elapsed or 0) + arg1 + if this.elapsed > 1 then + SyncDurationsToPfUI() + this:SetScript("OnUpdate", nil) + end + end) + + CleveRoids.Print("Multi-Target Tracker loaded. Use /cleveroid mtt for commands.") +end + +-- Export API +CleveRoids.MultiTargetTracker = { + -- Target management + AddTarget = AddTrackedTarget, + RemoveTarget = RemoveTrackedTarget, + ClearTargets = ClearAllTargets, + + -- Query functions + GetTrackedGUIDs = GetTrackedTargetGUIDs, + GetTargetInfo = GetTrackedTargetInfo, + IsTracked = IsTracked, + GetTrackedCount = GetTrackedCount, + FindTrackedTarget = FindTrackedTarget, + GetActiveDebuffs = GetActivePlayerDebuffs, + + -- Frame control + Show = function() + if mainFrame then + mainFrame:Show() + isFrameVisible = true + end + end, + Hide = function() + if mainFrame then + mainFrame:Hide() + isFrameVisible = false + end + end, + Toggle = function() + if mainFrame then + if isFrameVisible then + mainFrame:Hide() + isFrameVisible = false + else + mainFrame:Show() + isFrameVisible = true + end + end + end, + IsVisible = function() + return isFrameVisible + end, + + -- Frame positioning + Unlock = function() + isUnlocked = true + if mainFrame then + mainFrame:Show() + isFrameVisible = true + if mainFrame.titleText then + mainFrame.titleText:SetText("Debuff Tracker (UNLOCKED)") + mainFrame.titleText:SetTextColor(0, 1, 0, 1) + end + end + CleveRoids.Print("Multi-Target Tracker: Unlocked - drag to move, then /cleveroid mtt lock") + end, + Lock = function() + isUnlocked = false + if mainFrame and mainFrame.titleText then + mainFrame.titleText:SetText("Debuff Tracker") + mainFrame.titleText:SetTextColor(COLORS.titleText[1], COLORS.titleText[2], COLORS.titleText[3], 1) + end + if table.getn(trackedOrder) == 0 and mainFrame then + mainFrame:Hide() + isFrameVisible = false + end + CleveRoids.Print("Multi-Target Tracker: Locked") + end, + ResetPosition = function() + CleveRoidMacros = CleveRoidMacros or {} + CleveRoidMacros.multiTargetPos = nil + if mainFrame then + mainFrame:ClearAllPoints() + mainFrame:SetPoint("CENTER", UIParent, "CENTER", 200, 100) + end + CleveRoids.Print("Multi-Target Tracker: Position reset") + end, + + -- Debug mode + Debug = function(enable) + if enable == nil then + MTT_DEBUG = not MTT_DEBUG + else + MTT_DEBUG = enable + end + CleveRoids.Print("Multi-Target Tracker: Debug " .. (MTT_DEBUG and "ON" or "OFF")) + end, + + -- Dump current tracking state for debugging + DumpState = function() + CleveRoids.Print("=== MTT State Dump ===") + CleveRoids.Print("Tracked targets: " .. table.getn(trackedOrder)) + for i, guid in ipairs(trackedOrder) do + local info = trackedTargets[guid] + local debuffs, count = GetActivePlayerDebuffs(guid) + CleveRoids.Print(string.format(" %d. %s (GUID:%s) - %d debuffs", + i, info and info.name or "?", string.sub(tostring(guid), 1, 16), count)) + + -- Show lib.objects state + local libdebuff = CleveRoids.libdebuff + if libdebuff and libdebuff.objects then + local hasRecord = libdebuff.objects[guid] and true or false + CleveRoids.Print(string.format(" lib.objects[guid]: %s", hasRecord and "EXISTS" or "nil")) + if hasRecord then + for spellID, rec in pairs(libdebuff.objects[guid]) do + local name = SpellInfo and SpellInfo(spellID) or "?" + local remaining = rec.duration and rec.start and (rec.duration + rec.start - GetTime()) or 0 + local stacksInfo = rec.stacks and rec.stacks > 1 and (" x" .. rec.stacks) or "" + CleveRoids.Print(string.format(" - %s: %.1fs, caster=%s%s", + name, remaining, tostring(rec.caster), stacksInfo)) + end + end + end + end + end, +} + +_G["CleveRoids"] = CleveRoids diff --git a/README.md b/README.md index cabaf06..585f255 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,52 @@ Works with ALL casting methods: --- +## Multi-Target Tracker + +Visual UI extension that tracks your debuffs across multiple enemy targets. Displays health bars with debuff icons and timers, styled to match Cursive/ShaguScan. + +### Features +- Tracks up to 15 targets simultaneously +- Shows health bar, name, raid icon, and debuff timers per target +- Automatic cleanup when targets die or leave combat +- Only shows YOUR debuffs (class-aware filtering) +- Click target rows to target that enemy + +### Commands + +| Command | Description | +|---------|-------------| +| `/cleveroid mtt` | Show all MTT commands | +| `/cleveroid mtt unlock` | Unlock frame for repositioning | +| `/cleveroid mtt lock` | Lock and resume auto-hide | +| `/cleveroid mtt show` | Show the tracker frame | +| `/cleveroid mtt hide` | Hide the tracker frame | +| `/cleveroid mtt toggle` | Toggle frame visibility | +| `/cleveroid mtt clear` | Clear all tracked targets | +| `/cleveroid mtt reset` | Reset frame position | +| `/cleveroid mtt debug` | Toggle debug mode | +| `/cleveroid mtt dump` | Dump tracking state | + +### Tracked Debuff Categories + +**AoE Debuffs** (multi-target tracking): + +**Proc/Special Debuffs** (item procs, taunts): + +**Personal Debuffs**: +All class-specific DoTs, debuffs, and effects from the debuff timer system are automatically tracked. + +**Shared Debuffs**: +Debuffs like Sunder Armor, Faerie Fire, and Judgements are only shown if YOU can cast them (class-aware filtering prevents showing other players' debuffs). + +### Auto-Cleanup Behavior +- Dead targets are removed immediately +- Targets with no active debuffs are removed when: + - The target leaves combat, OR + - You leave combat + +--- + ## Talent Modifiers Automatically applies talent-based duration modifications to debuff tracking. diff --git a/SuperCleveRoidMacros.toc b/SuperCleveRoidMacros.toc index e6c297b..9c5fc2d 100644 --- a/SuperCleveRoidMacros.toc +++ b/SuperCleveRoidMacros.toc @@ -2,7 +2,7 @@ ## Author: DWG, _brain, Torio, yani9o, Avitasia ## Title: [Super]CleveRoidMacros ## Notes: /cleveroid for settings -## Version: 1.8 +## Version: 1.9 ## OptionalDeps: ClassicFocus, FocusFrame, pfUI, SuperMacro, Bongos_ActionBar ## SavedVariables: CleveRoidMacros, CleveRoids_LearnedDurations, CleveRoids_AuraTextures, CleveRoids_ImmunityData, CleveRoids_ComboDurations, CleveRoids_SpellSchools Localization.lua @@ -35,3 +35,4 @@ Extensions\Mouseover\ag_UnitFrames.lua Extensions\Tooltip\Generic.lua +Extensions\MultiTargetTracker.lua diff --git a/Utility.lua b/Utility.lua index 8c9767f..301d201 100644 --- a/Utility.lua +++ b/Utility.lua @@ -17,13 +17,30 @@ local tonumber = tonumber local tostring = tostring local GetTime = GetTime --- GUID normalization: ensure all GUIDs are strings for consistent table key lookups --- In Lua, table["123"] is different from table[123], so we must normalize +-- GUID normalization: ensures consistent string format for table keys +-- Preserves original format (including 0x prefix) for SuperWoW API compatibility function CleveRoids.NormalizeGUID(guid) if not guid then return nil end return tostring(guid) end +-- Compare two GUIDs for equality, handling format differences (0x prefix, case) +-- Use this when comparing GUIDs from different sources (e.g., UnitExists vs AURA_CAST_ON_OTHER) +function CleveRoids.GUIDsMatch(guid1, guid2) + if not guid1 or not guid2 then return false end + local s1 = tostring(guid1) + local s2 = tostring(guid2) + -- Strip 0x prefix if present for comparison + if string.sub(s1, 1, 2) == "0x" or string.sub(s1, 1, 2) == "0X" then + s1 = string.sub(s1, 3) + end + if string.sub(s2, 1, 2) == "0x" or string.sub(s2, 1, 2) == "0X" then + s2 = string.sub(s2, 3) + end + -- Case-insensitive comparison + return string.lower(s1) == string.lower(s2) +end + -- Hidden tooltip for scanning spell info local SpellScanTooltip = nil @@ -597,6 +614,18 @@ lib.personalDebuffs = lib.personalDebuffs or { [12323] = 6, -- Piercing Howl + [12721] = 12, -- Deep Wound (Rank 1) - actual debuff, not talent trigger + [12722] = 12, -- Deep Wound (Rank 2) + [12723] = 12, -- Deep Wound (Rank 3) + + [355] = 3, -- Taunt + [1161] = 6, -- Challenging Shout + [694] = 6, -- Mocking Blow (Rank 1) + [7400] = 6, -- Mocking Blow (Rank 2) + [7402] = 6, -- Mocking Blow (Rank 3) + [20559] = 6, -- Mocking Blow (Rank 4) + [20560] = 6, -- Mocking Blow (Rank 5) + -- ROGUE [2094] = 10, -- Blind [21060] = 10, -- Blind (alternate?) @@ -611,8 +640,17 @@ lib.personalDebuffs = lib.personalDebuffs or { [11285] = 4, -- Gouge (Rank 4) [11286] = 4, -- Gouge (Rank 5) - -- NOTE: Rupture removed - handled by ComboPointTracker (base 8s + 2s per CP) - -- NOTE: Kidney Shot removed - handled by ComboPointTracker (base 1s/2s + 1s per CP) + -- NOTE: Rupture/Kidney Shot durations calculated by ComboPointTracker + -- Base durations here for MultiTargetTracker recognition + [1943] = 16, -- Rupture (Rank 1) - base 8s + 2s per CP (max 5CP = 16s) + [8639] = 16, -- Rupture (Rank 2) + [8640] = 16, -- Rupture (Rank 3) + [11273] = 16, -- Rupture (Rank 4) + [11274] = 16, -- Rupture (Rank 5) + [11275] = 16, -- Rupture (Rank 6) + + [408] = 6, -- Kidney Shot (Rank 1) - base 1s + 1s per CP (max 5CP = 6s) + [8643] = 6, -- Kidney Shot (Rank 2) [703] = 18, -- Garrote (Rank 1) [8631] = 18, -- Garrote (Rank 2) @@ -754,6 +792,18 @@ lib.personalDebuffs = lib.personalDebuffs or { [9824] = 18, -- Pounce Bleed (Rank 2) - triggered by Pounce 9823 [9826] = 18, -- Pounce Bleed (Rank 3) - triggered by Pounce 9827 + [9005] = 2, -- Pounce Stun (Rank 1) + [9823] = 3, -- Pounce Stun (Rank 2) + [9827] = 3, -- Pounce Stun (Rank 3) + + [99] = 30, -- Demoralizing Roar (Rank 1) + [1735] = 30, -- Demoralizing Roar (Rank 2) + [9490] = 30, -- Demoralizing Roar (Rank 3) + [9747] = 30, -- Demoralizing Roar (Rank 4) + [9898] = 30, -- Demoralizing Roar (Rank 5) + + [5209] = 6, -- Challenging Roar + -- WARLOCK [172] = 12, -- Corruption (Rank 1) [6222] = 15, -- Corruption (Rank 2) @@ -950,10 +1000,10 @@ lib.sharedDebuffs = lib.sharedDebuffs or { [11597] = 30, -- Sunder Armor (Rank 5) [6343] = 10, -- Thunder Clap (Rank 1) - [8198] = 10, -- Thunder Clap (Rank 2) - [8205] = 10, -- Thunder Clap (Rank 3) - [11580] = 10, -- Thunder Clap (Rank 4) - [11581] = 10, -- Thunder Clap (Rank 5) + [8198] = 14, -- Thunder Clap (Rank 2) + [8205] = 18, -- Thunder Clap (Rank 3) + [11580] = 22, -- Thunder Clap (Rank 4) + [11581] = 30, -- Thunder Clap (Rank 5) [1160] = 30, -- Demoralizing Shout (Rank 1) [6190] = 30, -- Demoralizing Shout (Rank 2)