From 35b9baa95a9ed7b46e18c95460f5455970ff204e Mon Sep 17 00:00:00 2001 From: Jrc13245 Date: Fri, 2 Jan 2026 22:31:02 -0500 Subject: [PATCH] fix multitarget tracker --- Core.lua | 15 +- Extensions/MultiTargetTracker.lua | 3373 ++++++++++------------------- 2 files changed, 1123 insertions(+), 2265 deletions(-) diff --git a/Core.lua b/Core.lua index 9af6311..ed27efa 100644 --- a/Core.lua +++ b/Core.lua @@ -6439,12 +6439,17 @@ SlashCmdList["CLEVEROID"] = function(msg) tracker.Lock() elseif val == "show" then tracker.Show() - CleveRoids.Print("Multi-Target Tracker: Shown") + CleveRoids.Print("Multi-Target Tracker: Enabled (will show when tracking)") elseif val == "hide" then tracker.Hide() - CleveRoids.Print("Multi-Target Tracker: Hidden") + CleveRoids.Print("Multi-Target Tracker: Disabled (persists across reloads)") elseif val == "toggle" then tracker.Toggle() + if tracker.IsDisabled and tracker.IsDisabled() then + CleveRoids.Print("Multi-Target Tracker: Disabled (persists across reloads)") + else + CleveRoids.Print("Multi-Target Tracker: Enabled (will show when tracking)") + end elseif val == "clear" then tracker.ClearTargets() elseif val == "reset" then @@ -6457,9 +6462,9 @@ SlashCmdList["CLEVEROID"] = function(msg) 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 show - Enable tracker (persists)") + CleveRoids.Print(" /cleveroid mtt hide - Disable tracker (persists)") + CleveRoids.Print(" /cleveroid mtt toggle - Toggle enabled state (persists)") 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") diff --git a/Extensions/MultiTargetTracker.lua b/Extensions/MultiTargetTracker.lua index 76bf6ca..68580a6 100644 --- a/Extensions/MultiTargetTracker.lua +++ b/Extensions/MultiTargetTracker.lua @@ -1,9 +1,13 @@ --[[ - Multi-Target Debuff Tracker Extension + Multi-Target Debuff Tracker Extension (Cursive-style rewrite) Tracks player's debuffs across multiple enemy targets - Displays: Target indicator, raid icon, health bar with name, debuff icons with timers + Based on Cursive addon architecture by Pepopo - Styled to match Cursive/ShaguScan UI + Features: + - Per-class spell tables (only tracks spells you can cast) + - UNIT_CASTEVENT based tracking with debuff scan verification + - Resist/immune detection via chat parsing + - Display priority: raid marks -> highest HP -> rest ]] local _G = _G or getfenv(0) @@ -16,33 +20,22 @@ local Extension = CleveRoids.RegisterExtension("MultiTargetTracker") -- ============================================================================ local MAX_TRACKED_TARGETS = 15 -local MAX_ICONS_PER_ROW = 8 -local UPDATE_THROTTLE = 0.1 -- 10 Hz update rate +local MAX_DEBUFFS_PER_ROW = 8 +local UPDATE_INTERVAL = 0.1 -- 10 Hz --- 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, @@ -55,898 +48,838 @@ local BACKDROP_BACKGROUND = { 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 + healthDefault = { 1, 0.8, 0.2, 1 }, 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 + timerExpiring = { 1, 0.2, 0.2 }, + titleText = { 1, 0.82, 0, 1 }, } -- ============================================================================ --- Local State +-- Per-Class Spell Tables (Cursive format) -- ============================================================================ -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 playerClassName = nil -- Set in LoadClassSpells() + +-- Combo point tracking for finisher duration calculation +local previousComboPoints = 0 +local currentComboPoints = 0 + +local function GetComboPointsUsed() + if currentComboPoints == 0 then + return previousComboPoints + end + return currentComboPoints +end + +-- Duration calculators for combo point scaling +local function getRipDuration() + return 8 + GetComboPointsUsed() * 2 +end + +local function getRuptureDuration() + local duration = 6 + GetComboPointsUsed() * 2 + -- Check for Improved Rupture talent (Assassination tree, tier 10) + local _, _, _, _, count = GetTalentInfo(1, 10) + if count and count > 0 then + duration = duration + (count * 2) + end + return duration +end + +local function getKidneyShotDuration() + return 1 + GetComboPointsUsed() +end + +local function getGougeDuration() + -- Improved Gouge talent (Combat tree, tier 1) + local _, _, _, _, count = GetTalentInfo(2, 1) + if count and count > 0 then + return 4 + (count * 0.5) + end + return 4 +end + +-- Class spell tables +local function getDruidSpells() + return { + -- Entangling Roots + [339] = { name = "entangling roots", rank = 1, duration = 12 }, + [1062] = { name = "entangling roots", rank = 2, duration = 15 }, + [5195] = { name = "entangling roots", rank = 3, duration = 18 }, + [5196] = { name = "entangling roots", rank = 4, duration = 21 }, + [9852] = { name = "entangling roots", rank = 5, duration = 24 }, + [9853] = { name = "entangling roots", rank = 6, duration = 27 }, + -- Hibernate + [2637] = { name = "hibernate", rank = 1, duration = 20 }, + [18657] = { name = "hibernate", rank = 2, duration = 30 }, + [18658] = { name = "hibernate", rank = 3, duration = 40 }, + -- Faerie Fire (all versions use same name for blocking) + [770] = { name = "faerie fire", rank = 1, duration = 40 }, + [778] = { name = "faerie fire", rank = 2, duration = 40 }, + [9749] = { name = "faerie fire", rank = 3, duration = 40 }, + [9907] = { name = "faerie fire", rank = 4, duration = 40 }, + [16855] = { name = "faerie fire", rank = 1, duration = 40 }, + [17387] = { name = "faerie fire", rank = 2, duration = 40 }, + [17388] = { name = "faerie fire", rank = 3, duration = 40 }, + [17389] = { name = "faerie fire", rank = 4, duration = 40 }, + [16857] = { name = "faerie fire", rank = 1, duration = 40 }, + [17390] = { name = "faerie fire", rank = 2, duration = 40 }, + [17391] = { name = "faerie fire", rank = 3, duration = 40 }, + [17392] = { name = "faerie fire", rank = 4, duration = 40 }, + -- Insect Swarm + [5570] = { name = "insect swarm", rank = 1, duration = 18 }, + [24974] = { name = "insect swarm", rank = 2, duration = 18 }, + [24975] = { name = "insect swarm", rank = 3, duration = 18 }, + [24976] = { name = "insect swarm", rank = 4, duration = 18 }, + [24977] = { name = "insect swarm", rank = 5, duration = 18 }, + -- Moonfire + [8921] = { name = "moonfire", rank = 1, duration = 9 }, + [8924] = { name = "moonfire", rank = 2, duration = 12 }, + [8925] = { name = "moonfire", rank = 3, duration = 12 }, + [8926] = { name = "moonfire", rank = 4, duration = 12 }, + [8927] = { name = "moonfire", rank = 5, duration = 12 }, + [8928] = { name = "moonfire", rank = 6, duration = 12 }, + [8929] = { name = "moonfire", rank = 7, duration = 12 }, + [9833] = { name = "moonfire", rank = 8, duration = 12 }, + [9834] = { name = "moonfire", rank = 9, duration = 12 }, + [9835] = { name = "moonfire", rank = 10, duration = 12 }, + -- Rake + [1822] = { name = "rake", rank = 1, duration = 9 }, + [1823] = { name = "rake", rank = 2, duration = 9 }, + [1824] = { name = "rake", rank = 3, duration = 9 }, + [9904] = { name = "rake", rank = 4, duration = 9 }, + -- Rip (combo point scaling) + [1079] = { name = "rip", rank = 1, duration = 8, calculateDuration = getRipDuration }, + [9492] = { name = "rip", rank = 2, duration = 8, calculateDuration = getRipDuration }, + [9493] = { name = "rip", rank = 3, duration = 8, calculateDuration = getRipDuration }, + [9752] = { name = "rip", rank = 4, duration = 8, calculateDuration = getRipDuration }, + [9894] = { name = "rip", rank = 5, duration = 8, calculateDuration = getRipDuration }, + [9896] = { name = "rip", rank = 6, duration = 8, calculateDuration = getRipDuration }, + -- Bash + [5211] = { name = "bash", rank = 1, duration = 2 }, + [6798] = { name = "bash", rank = 2, duration = 3 }, + [8983] = { name = "bash", rank = 3, duration = 4 }, + -- Demoralizing Roar + [99] = { name = "demoralizing roar", rank = 1, duration = 30 }, + [1735] = { name = "demoralizing roar", rank = 2, duration = 30 }, + [9490] = { name = "demoralizing roar", rank = 3, duration = 30 }, + [9747] = { name = "demoralizing roar", rank = 4, duration = 30 }, + [9898] = { name = "demoralizing roar", rank = 5, duration = 30 }, + -- Pounce Bleed + [9005] = { name = "pounce bleed", rank = 1, duration = 18 }, + [9823] = { name = "pounce bleed", rank = 2, duration = 18 }, + [9827] = { name = "pounce bleed", rank = 3, duration = 18 }, + } +end + +local function getRogueSpells() + return { + -- Blind + [2094] = { name = "blind", rank = 1, duration = 10 }, + [21060] = { name = "blind", rank = 1, duration = 10 }, + -- Sap + [6770] = { name = "sap", rank = 1, duration = 25 }, + [2070] = { name = "sap", rank = 2, duration = 35 }, + [11297] = { name = "sap", rank = 3, duration = 45 }, + -- Gouge (talent scaling) + [1776] = { name = "gouge", rank = 1, duration = 4, calculateDuration = getGougeDuration }, + [1777] = { name = "gouge", rank = 2, duration = 4, calculateDuration = getGougeDuration }, + [8629] = { name = "gouge", rank = 3, duration = 4, calculateDuration = getGougeDuration }, + [11285] = { name = "gouge", rank = 4, duration = 4, calculateDuration = getGougeDuration }, + [11286] = { name = "gouge", rank = 5, duration = 4, calculateDuration = getGougeDuration }, + -- Rupture (combo point + talent scaling) + [1943] = { name = "rupture", rank = 1, duration = 6, calculateDuration = getRuptureDuration }, + [8639] = { name = "rupture", rank = 2, duration = 6, calculateDuration = getRuptureDuration }, + [8640] = { name = "rupture", rank = 3, duration = 6, calculateDuration = getRuptureDuration }, + [11273] = { name = "rupture", rank = 4, duration = 6, calculateDuration = getRuptureDuration }, + [11274] = { name = "rupture", rank = 5, duration = 6, calculateDuration = getRuptureDuration }, + [11275] = { name = "rupture", rank = 6, duration = 6, calculateDuration = getRuptureDuration }, + -- Kidney Shot (combo point scaling) + [408] = { name = "kidney shot", rank = 1, duration = 1, calculateDuration = getKidneyShotDuration }, + [8643] = { name = "kidney shot", rank = 2, duration = 1, calculateDuration = getKidneyShotDuration }, + -- Expose Armor + [8647] = { name = "expose armor", rank = 1, duration = 30 }, + [8649] = { name = "expose armor", rank = 2, duration = 30 }, + [8650] = { name = "expose armor", rank = 3, duration = 30 }, + [11197] = { name = "expose armor", rank = 4, duration = 30 }, + [11198] = { name = "expose armor", rank = 5, duration = 30 }, + -- Garrote + [703] = { name = "garrote", rank = 1, duration = 18 }, + [8631] = { name = "garrote", rank = 2, duration = 18 }, + [8632] = { name = "garrote", rank = 3, duration = 18 }, + [8633] = { name = "garrote", rank = 4, duration = 18 }, + [11289] = { name = "garrote", rank = 5, duration = 18 }, + [11290] = { name = "garrote", rank = 6, duration = 18 }, + -- Deadly Poison + [2818] = { name = "deadly poison", rank = 1, duration = 12 }, + [2819] = { name = "deadly poison ii", rank = 2, duration = 12 }, + [11353] = { name = "deadly poison iii", rank = 3, duration = 12 }, + [11354] = { name = "deadly poison iv", rank = 4, duration = 12 }, + [25349] = { name = "deadly poison v", rank = 5, duration = 12 }, + -- Hemorrhage + [16511] = { name = "hemorrhage", rank = 1, duration = 15 }, + -- Cheap Shot + [1833] = { name = "cheap shot", rank = 1, duration = 4 }, + [14902] = { name = "cheap shot", rank = 1, duration = 4 }, + } +end + +local function getWarriorSpells() + return { + -- Rend + [772] = { name = "rend", rank = 1, duration = 9 }, + [6546] = { name = "rend", rank = 2, duration = 12 }, + [6547] = { name = "rend", rank = 3, duration = 15 }, + [6548] = { name = "rend", rank = 4, duration = 18 }, + [11572] = { name = "rend", rank = 5, duration = 21 }, + [11573] = { name = "rend", rank = 6, duration = 21 }, + [11574] = { name = "rend", rank = 7, duration = 21 }, + -- Hamstring + [1715] = { name = "hamstring", rank = 1, duration = 15 }, + [7372] = { name = "hamstring", rank = 2, duration = 15 }, + [7373] = { name = "hamstring", rank = 3, duration = 15 }, + -- Thunder Clap + [6343] = { name = "thunder clap", rank = 1, duration = 10 }, + [8198] = { name = "thunder clap", rank = 2, duration = 14 }, + [8205] = { name = "thunder clap", rank = 3, duration = 18 }, + [11580] = { name = "thunder clap", rank = 4, duration = 22 }, + [11581] = { name = "thunder clap", rank = 5, duration = 26 }, + -- Demoralizing Shout + [1160] = { name = "demoralizing shout", rank = 1, duration = 30 }, + [6190] = { name = "demoralizing shout", rank = 2, duration = 30 }, + [11554] = { name = "demoralizing shout", rank = 3, duration = 30 }, + [11555] = { name = "demoralizing shout", rank = 4, duration = 30 }, + [11556] = { name = "demoralizing shout", rank = 5, duration = 30 }, + -- Sunder Armor + [7386] = { name = "sunder armor", rank = 1, duration = 30 }, + [7405] = { name = "sunder armor", rank = 2, duration = 30 }, + [8380] = { name = "sunder armor", rank = 3, duration = 30 }, + [11596] = { name = "sunder armor", rank = 4, duration = 30 }, + [11597] = { name = "sunder armor", rank = 5, duration = 30 }, + } +end + +local function getWarlockSpells() + return { + -- Corruption + [172] = { name = "corruption", rank = 1, duration = 12 }, + [6222] = { name = "corruption", rank = 2, duration = 15 }, + [6223] = { name = "corruption", rank = 3, duration = 18 }, + [7648] = { name = "corruption", rank = 4, duration = 18 }, + [11671] = { name = "corruption", rank = 5, duration = 18 }, + [11672] = { name = "corruption", rank = 6, duration = 18 }, + [25311] = { name = "corruption", rank = 7, duration = 18 }, + -- Curse of Agony + [980] = { name = "curse of agony", rank = 1, duration = 24 }, + [1014] = { name = "curse of agony", rank = 2, duration = 24 }, + [6217] = { name = "curse of agony", rank = 3, duration = 24 }, + [11711] = { name = "curse of agony", rank = 4, duration = 24 }, + [11712] = { name = "curse of agony", rank = 5, duration = 24 }, + [11713] = { name = "curse of agony", rank = 6, duration = 24 }, + -- Siphon Life + [18265] = { name = "siphon life", rank = 1, duration = 30 }, + [18879] = { name = "siphon life", rank = 2, duration = 30 }, + [18880] = { name = "siphon life", rank = 3, duration = 30 }, + [18881] = { name = "siphon life", rank = 4, duration = 30 }, + -- Curse of Doom + [603] = { name = "curse of doom", rank = 1, duration = 60 }, + -- Curse of Recklessness + [704] = { name = "curse of recklessness", rank = 1, duration = 120 }, + [7658] = { name = "curse of recklessness", rank = 2, duration = 120 }, + [7659] = { name = "curse of recklessness", rank = 3, duration = 120 }, + [11717] = { name = "curse of recklessness", rank = 4, duration = 120 }, + -- Curse of Shadow + [17862] = { name = "curse of shadow", rank = 1, duration = 300 }, + [17937] = { name = "curse of shadow", rank = 2, duration = 300 }, + -- Curse of the Elements + [1490] = { name = "curse of the elements", rank = 1, duration = 300 }, + [11721] = { name = "curse of the elements", rank = 2, duration = 300 }, + [11722] = { name = "curse of the elements", rank = 3, duration = 300 }, + -- Curse of Tongues + [1714] = { name = "curse of tongues", rank = 1, duration = 30 }, + [11719] = { name = "curse of tongues", rank = 2, duration = 30 }, + -- Curse of Weakness + [702] = { name = "curse of weakness", rank = 1, duration = 120 }, + [1108] = { name = "curse of weakness", rank = 2, duration = 120 }, + [6205] = { name = "curse of weakness", rank = 3, duration = 120 }, + [7646] = { name = "curse of weakness", rank = 4, duration = 120 }, + [11707] = { name = "curse of weakness", rank = 5, duration = 120 }, + [11708] = { name = "curse of weakness", rank = 6, duration = 120 }, + -- Curse of Exhaustion + [18223] = { name = "curse of exhaustion", rank = 1, duration = 12 }, + -- Immolate + [348] = { name = "immolate", rank = 1, duration = 15 }, + [707] = { name = "immolate", rank = 2, duration = 15 }, + [1094] = { name = "immolate", rank = 3, duration = 15 }, + [2941] = { name = "immolate", rank = 4, duration = 15 }, + [11665] = { name = "immolate", rank = 5, duration = 15 }, + [11667] = { name = "immolate", rank = 6, duration = 15 }, + [11668] = { name = "immolate", rank = 7, duration = 15 }, + [25309] = { name = "immolate", rank = 8, duration = 15 }, + -- Death Coil + [6789] = { name = "death coil", rank = 1, duration = 3 }, + [17925] = { name = "death coil", rank = 2, duration = 3 }, + [17926] = { name = "death coil", rank = 3, duration = 3 }, + -- Banish + [710] = { name = "banish", rank = 1, duration = 20 }, + [18647] = { name = "banish", rank = 2, duration = 30 }, + -- Fear + [5782] = { name = "fear", rank = 1, duration = 10 }, + [6213] = { name = "fear", rank = 2, duration = 15 }, + [6215] = { name = "fear", rank = 3, duration = 20 }, + } +end + +local function getHunterSpells() + return { + -- Scorpid Sting + [3043] = { name = "scorpid sting", rank = 1, duration = 20 }, + [14275] = { name = "scorpid sting", rank = 2, duration = 20 }, + [14276] = { name = "scorpid sting", rank = 3, duration = 20 }, + [14277] = { name = "scorpid sting", rank = 4, duration = 20 }, + -- Serpent Sting + [1978] = { name = "serpent sting", rank = 1, duration = 15 }, + [13549] = { name = "serpent sting", rank = 2, duration = 15 }, + [13550] = { name = "serpent sting", rank = 3, duration = 15 }, + [13551] = { name = "serpent sting", rank = 4, duration = 15 }, + [13552] = { name = "serpent sting", rank = 5, duration = 15 }, + [13553] = { name = "serpent sting", rank = 6, duration = 15 }, + [13554] = { name = "serpent sting", rank = 7, duration = 15 }, + [13555] = { name = "serpent sting", rank = 8, duration = 15 }, + [25295] = { name = "serpent sting", rank = 9, duration = 15 }, + -- Viper Sting + [3034] = { name = "viper sting", rank = 1, duration = 8 }, + [14279] = { name = "viper sting", rank = 2, duration = 8 }, + [14280] = { name = "viper sting", rank = 3, duration = 8 }, + -- Wing Clip + [2974] = { name = "wing clip", rank = 1, duration = 10 }, + [14267] = { name = "wing clip", rank = 2, duration = 10 }, + [14268] = { name = "wing clip", rank = 3, duration = 10 }, + -- Concussive Shot + [5116] = { name = "concussive shot", rank = 1, duration = 4 }, + -- Wyvern Sting + [19386] = { name = "wyvern sting", rank = 1, duration = 12 }, + [24132] = { name = "wyvern sting", rank = 2, duration = 12 }, + [24133] = { name = "wyvern sting", rank = 3, duration = 12 }, + -- Hunter's Mark + [1130] = { name = "hunter's mark", rank = 1, duration = 120 }, + [14323] = { name = "hunter's mark", rank = 2, duration = 120 }, + [14324] = { name = "hunter's mark", rank = 3, duration = 120 }, + [14325] = { name = "hunter's mark", rank = 4, duration = 120 }, + } +end + +local function getMageSpells() + return { + -- Polymorph + [118] = { name = "polymorph", rank = 1, duration = 20 }, + [12824] = { name = "polymorph", rank = 2, duration = 30 }, + [12825] = { name = "polymorph", rank = 3, duration = 40 }, + [12826] = { name = "polymorph", rank = 4, duration = 50 }, + [28270] = { name = "polymorph: cow", rank = 1, duration = 50 }, + [28271] = { name = "polymorph: turtle", rank = 1, duration = 50 }, + [28272] = { name = "polymorph: pig", rank = 1, duration = 50 }, + -- Frostbolt (snare component) + [116] = { name = "frostbolt", rank = 1, duration = 5 }, + [205] = { name = "frostbolt", rank = 2, duration = 6 }, + [837] = { name = "frostbolt", rank = 3, duration = 6 }, + [7322] = { name = "frostbolt", rank = 4, duration = 7 }, + [8406] = { name = "frostbolt", rank = 5, duration = 7 }, + [8407] = { name = "frostbolt", rank = 6, duration = 8 }, + [8408] = { name = "frostbolt", rank = 7, duration = 8 }, + [10179] = { name = "frostbolt", rank = 8, duration = 9 }, + [10180] = { name = "frostbolt", rank = 9, duration = 9 }, + [10181] = { name = "frostbolt", rank = 10, duration = 9 }, + [25304] = { name = "frostbolt", rank = 11, duration = 9 }, + } +end + +local function getPriestSpells() + return { + -- Shackle Undead + [9485] = { name = "shackle undead", rank = 1, duration = 30 }, + [9486] = { name = "shackle undead", rank = 2, duration = 40 }, + [10955] = { name = "shackle undead", rank = 3, duration = 50 }, + -- Mind Control + [605] = { name = "mind control", rank = 1, duration = 60 }, + [10911] = { name = "mind control", rank = 2, duration = 30 }, + [10912] = { name = "mind control", rank = 3, duration = 30 }, + -- Devouring Plague + [2944] = { name = "devouring plague", rank = 1, duration = 24 }, + [19276] = { name = "devouring plague", rank = 2, duration = 24 }, + [19277] = { name = "devouring plague", rank = 3, duration = 24 }, + [19278] = { name = "devouring plague", rank = 4, duration = 24 }, + [19279] = { name = "devouring plague", rank = 5, duration = 24 }, + [19280] = { name = "devouring plague", rank = 6, duration = 24 }, + -- Shadow Word: Pain + [589] = { name = "shadow word: pain", rank = 1, duration = 18 }, + [594] = { name = "shadow word: pain", rank = 2, duration = 18 }, + [970] = { name = "shadow word: pain", rank = 3, duration = 18 }, + [992] = { name = "shadow word: pain", rank = 4, duration = 18 }, + [2767] = { name = "shadow word: pain", rank = 5, duration = 18 }, + [10892] = { name = "shadow word: pain", rank = 6, duration = 18 }, + [10893] = { name = "shadow word: pain", rank = 7, duration = 18 }, + [10894] = { name = "shadow word: pain", rank = 8, duration = 18 }, + -- Vampiric Embrace + [15286] = { name = "vampiric embrace", rank = 1, duration = 60 }, + -- Holy Fire (DoT component) + [14914] = { name = "holy fire", rank = 1, duration = 10 }, + [15262] = { name = "holy fire", rank = 2, duration = 10 }, + [15263] = { name = "holy fire", rank = 3, duration = 10 }, + [15264] = { name = "holy fire", rank = 4, duration = 10 }, + [15265] = { name = "holy fire", rank = 5, duration = 10 }, + [15266] = { name = "holy fire", rank = 6, duration = 10 }, + [15267] = { name = "holy fire", rank = 7, duration = 10 }, + [15261] = { name = "holy fire", rank = 8, duration = 10 }, + } +end + +local function getShamanSpells() + return { + -- Flame Shock + [8050] = { name = "flame shock", rank = 1, duration = 12 }, + [8052] = { name = "flame shock", rank = 2, duration = 12 }, + [8053] = { name = "flame shock", rank = 3, duration = 12 }, + [10447] = { name = "flame shock", rank = 4, duration = 12 }, + [10448] = { name = "flame shock", rank = 5, duration = 12 }, + [29228] = { name = "flame shock", rank = 6, duration = 12 }, + -- Frost Shock (snare) + [8056] = { name = "frost shock", rank = 1, duration = 8 }, + [8058] = { name = "frost shock", rank = 2, duration = 8 }, + [10472] = { name = "frost shock", rank = 3, duration = 8 }, + [10473] = { name = "frost shock", rank = 4, duration = 8 }, + } +end + +local function getPaladinSpells() + return { + -- Judgement of the Crusader + [21183] = { name = "judgement of the crusader", rank = 1, duration = 10 }, + [20188] = { name = "judgement of the crusader", rank = 2, duration = 10 }, + [20300] = { name = "judgement of the crusader", rank = 3, duration = 10 }, + [20301] = { name = "judgement of the crusader", rank = 4, duration = 10 }, + [20302] = { name = "judgement of the crusader", rank = 5, duration = 10 }, + [20303] = { name = "judgement of the crusader", rank = 6, duration = 10 }, + -- Judgement of Light + [20185] = { name = "judgement of light", rank = 1, duration = 10 }, + [20267] = { name = "judgement of light", rank = 2, duration = 10 }, + [20268] = { name = "judgement of light", rank = 3, duration = 10 }, + [20271] = { name = "judgement of light", rank = 4, duration = 10 }, + -- Judgement of Wisdom + [20186] = { name = "judgement of wisdom", rank = 1, duration = 10 }, + [20354] = { name = "judgement of wisdom", rank = 2, duration = 10 }, + [20355] = { name = "judgement of wisdom", rank = 3, duration = 10 }, + -- Judgement of Justice + [20184] = { name = "judgement of justice", rank = 1, duration = 10 }, + -- Hammer of Justice + [853] = { name = "hammer of justice", rank = 1, duration = 3 }, + [5588] = { name = "hammer of justice", rank = 2, duration = 4 }, + [5589] = { name = "hammer of justice", rank = 3, duration = 5 }, + [10308] = { name = "hammer of justice", rank = 4, duration = 6 }, + } +end + +-- ============================================================================ +-- State Variables +-- ============================================================================ + +local trackedSpellIds = {} -- spellID -> { name, rank, duration, texture, ... } +local trackedSpellNamesToTextures = {} -- "rake" -> texture path +local discoveredGuids = {} -- guid -> timestamp (when discovered) +local trackedDebuffs = {} -- guid -> { [spellName] = { spellID, rank, duration, start, texture } } +local pendingCast = {} -- Current pending cast { spellID, targetGuid, time } +local lastTargetGuid = nil -- Last target GUID for resist detection + local mainFrame = nil -local contentFrame = nil -local lastUpdate = 0 +local unitFrames = {} -- [row] -> frame local isFrameVisible = false +local isDisabled = 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 --- +-- Spell Loading -- ============================================================================ --- 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 +local function LoadClassSpells() + trackedSpellIds = {} + trackedSpellNamesToTextures = {} --- 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) -} + -- Get player class at load time (not file parse time) + local _, className = UnitClass("player") + playerClassName = className --- 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 + if not playerClassName then + CleveRoids.Print("MultiTargetTracker: Could not determine player class") return false end - local lib = CleveRoids.libdebuff + local spells = nil + if playerClassName == "DRUID" then + spells = getDruidSpells() + elseif playerClassName == "ROGUE" then + spells = getRogueSpells() + elseif playerClassName == "WARRIOR" then + spells = getWarriorSpells() + elseif playerClassName == "WARLOCK" then + spells = getWarlockSpells() + elseif playerClassName == "HUNTER" then + spells = getHunterSpells() + elseif playerClassName == "MAGE" then + spells = getMageSpells() + elseif playerClassName == "PRIEST" then + spells = getPriestSpells() + elseif playerClassName == "SHAMAN" then + spells = getShamanSpells() + elseif playerClassName == "PALADIN" then + spells = getPaladinSpells() + end - -- Check our tracking tables - if PROC_DEBUFFS[spellId] then return true end - if AOE_DEBUFF_SPELLS[spellId] then return true end + if not spells then + CleveRoids.Print("MultiTargetTracker: No spells defined for class " .. tostring(playerClassName)) + return false + 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 + local spellCount = 0 + for spellID, data in pairs(spells) do + -- Get texture via SpellInfo + local name, rank, texture = SpellInfo(spellID) + data.texture = texture + trackedSpellIds[spellID] = data + trackedSpellNamesToTextures[data.name] = texture + spellCount = spellCount + 1 + end + + return true, spellCount +end + +-- ============================================================================ +-- GUID Discovery (Cursive core.lua style) +-- ============================================================================ + +local function AddGuid(guid) + if not guid or guid == "" or guid == "0x0" or guid == "0x000000000" then + return + end + if string.sub(guid, 1, 2) ~= "0x" then + return + end + if UnitExists(guid) and not UnitIsDead(guid) then + discoveredGuids[guid] = GetTime() + end +end + +local function AddUnit(unit) + local _, guid = UnitExists(unit) + if guid and not UnitIsDead(unit) then + discoveredGuids[guid] = GetTime() + end +end + +local function RemoveGuid(guid) + discoveredGuids[guid] = nil + trackedDebuffs[guid] = nil +end + +-- ============================================================================ +-- Debuff Scanning +-- ============================================================================ + +local function ScanGuidForDebuff(guid, spellID) + if not CleveRoids.hasSuperwow then return false end + + -- Scan debuff slots 1-16 + for slot = 1, 16 do + local _, _, _, id = UnitDebuff(guid, slot) + if not id then break end + if id == spellID then return true end + end + + -- Scan buff slots (for overflow debuffs) 1-32 + for slot = 1, 32 do + local _, _, id = UnitBuff(guid, slot) + if not id then break end + if id == spellID then return true end end return false end --- Forward declaration for ResolveTargetName (defined after GetUnitForGUID) -local ResolveTargetName +local function ScanGuidForAllDebuffs(guid) + if not CleveRoids.hasSuperwow then return end + if not trackedDebuffs[guid] then return end --- 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 + for spellName, debuffData in pairs(trackedDebuffs[guid]) do + if not ScanGuidForDebuff(guid, debuffData.spellID) then + -- Debuff no longer on target, remove it + trackedDebuffs[guid][spellName] = 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 +-- ============================================================================ +-- Debuff Application +-- ============================================================================ - -- Compare against actual player GUID - local _, playerGuid = UnitExists("player") - if playerGuid then - return CleveRoids.GUIDsMatch(casterGuid, playerGuid) +local function GetDebuffDuration(spellID) + local spellData = trackedSpellIds[spellID] + if not spellData then return 0 end + + if spellData.calculateDuration then + return spellData.calculateDuration() + end + + return spellData.duration or 0 +end + +local function ApplyDebuff(spellID, targetGuid, startTime, duration) + local spellData = trackedSpellIds[spellID] + if not spellData then return end + + -- Verify debuff is actually on target + if not ScanGuidForDebuff(targetGuid, spellID) then + return + end + + if not trackedDebuffs[targetGuid] then + trackedDebuffs[targetGuid] = {} + end + + trackedDebuffs[targetGuid][spellData.name] = { + spellID = spellID, + rank = spellData.rank, + duration = duration, + start = startTime, + texture = spellData.texture, + } + + -- Ensure GUID is in discovery list + discoveredGuids[targetGuid] = GetTime() +end + +local function RemoveDebuff(guid, spellName) + if trackedDebuffs[guid] and trackedDebuffs[guid][spellName] then + trackedDebuffs[guid][spellName] = nil + end +end + +local function TimeRemaining(debuffData) + local remaining = debuffData.duration - (GetTime() - debuffData.start) + return remaining > 0 and remaining or 0 +end + +local function HasAnyDebuff(guid) + if trackedDebuffs[guid] and next(trackedDebuffs[guid]) then + return true end return false end -- ============================================================================ --- Utility Functions +-- Event Handlers -- ============================================================================ -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) +local eventFrame = CreateFrame("Frame", "CleveRoidsMTTEventFrame", UIParent) + +-- GUID Discovery events +eventFrame:RegisterEvent("PLAYER_TARGET_CHANGED") +eventFrame:RegisterEvent("UNIT_COMBAT") +eventFrame:RegisterEvent("UNIT_MODEL_CHANGED") + +-- Cast tracking +eventFrame:RegisterEvent("UNIT_CASTEVENT") + +-- Resist/immune detection +eventFrame:RegisterEvent("CHAT_MSG_SPELL_SELF_DAMAGE") + +-- Fade detection +eventFrame:RegisterEvent("CHAT_MSG_SPELL_AURA_GONE_OTHER") + +-- Combo points for finisher tracking +eventFrame:RegisterEvent("PLAYER_COMBO_POINTS") + +-- Resist patterns +local resist_pattern = "Your (.+) was resisted by (.+)" +local immune_pattern = "Your (.+) fail.+%. (.+) is immune" +local missed_pattern = "Your (.+) missed (.+)" +local parried_pattern = "Your (.+) is parried by (.+)" +local blocked_pattern = "Your (.+) was blocked by (.+)" +local dodged_pattern = "Your (.+) was dodged by (.+)" + +-- Fade pattern +local fades_pattern = "(.+) fades from (.+)" + +eventFrame:SetScript("OnEvent", function() + if event == "PLAYER_TARGET_CHANGED" then + AddUnit("target") + + elseif event == "UNIT_COMBAT" or event == "UNIT_MODEL_CHANGED" then + -- arg1 is GUID in SuperWoW + if arg1 then + AddGuid(arg1) + end + + elseif event == "UNIT_CASTEVENT" then + -- UNIT_CASTEVENT args: + -- arg1 = casterGUID + -- arg2 = targetGUID + -- arg3 = event type ("START", "CAST", "FAIL", "CHANNEL") + -- arg4 = spell ID + -- arg5 = cast duration + + local casterGuid = arg1 + local targetGuid = arg2 + local eventType = arg3 + local spellID = arg4 + + -- Check if caster is player + local _, playerGuid = UnitExists("player") + if not playerGuid or casterGuid ~= playerGuid then + return + end + + if eventType == "CAST" then + if trackedSpellIds[spellID] and targetGuid then + lastTargetGuid = targetGuid + + -- Calculate duration + local duration = GetDebuffDuration(spellID) + + -- Apply debuff after a short delay to account for travel time + local delay = 0.2 + local _, _, ping = GetNetStats() + if ping and ping > 0 and ping < 500 then + delay = 0.05 + (ping / 1000) + end + + -- Store pending cast + pendingCast = { + spellID = spellID, + targetGuid = targetGuid, + time = GetTime(), + duration = duration - delay, + } + + -- Schedule application + local applyFrame = CreateFrame("Frame") + applyFrame.elapsed = 0 + applyFrame:SetScript("OnUpdate", function() + this.elapsed = this.elapsed + arg1 + if this.elapsed >= delay then + if pendingCast.spellID == spellID and pendingCast.targetGuid == targetGuid then + ApplyDebuff(spellID, targetGuid, GetTime(), pendingCast.duration) + pendingCast = {} + end + this:SetScript("OnUpdate", nil) + end + end) + end + + elseif eventType == "START" then + if trackedSpellIds[spellID] and targetGuid then + pendingCast = { + spellID = spellID, + targetGuid = targetGuid, + time = GetTime(), + } + end + + elseif eventType == "FAIL" then + pendingCast = {} + end + + elseif event == "CHAT_MSG_SPELL_SELF_DAMAGE" then + local message = arg1 + + local patterns = { resist_pattern, immune_pattern } + -- Add melee patterns for melee classes + if playerClassName == "DRUID" or playerClassName == "ROGUE" or playerClassName == "WARRIOR" then + table.insert(patterns, missed_pattern) + table.insert(patterns, parried_pattern) + table.insert(patterns, blocked_pattern) + table.insert(patterns, dodged_pattern) + end + + for _, pattern in pairs(patterns) do + local _, _, spellName, target = string.find(message, pattern) + if spellName and target then + -- Cancel pending cast + pendingCast = {} + return + end + end + + elseif event == "CHAT_MSG_SPELL_AURA_GONE_OTHER" then + local message = arg1 + local _, _, spellName, target = string.find(message, fades_pattern) + + if spellName and target then + local lowerSpellName = string.lower(spellName) + + -- Check if this is a spell we track + if trackedSpellNamesToTextures[lowerSpellName] then + -- Find the GUID for this target and verify + for guid, debuffs in pairs(trackedDebuffs) do + if debuffs[lowerSpellName] then + if not ScanGuidForDebuff(guid, debuffs[lowerSpellName].spellID) then + RemoveDebuff(guid, lowerSpellName) + end + end + end + end + end + + elseif event == "PLAYER_COMBO_POINTS" then + previousComboPoints = currentComboPoints + currentComboPoints = GetComboPoints() end +end) + +-- ============================================================================ +-- UI Creation +-- ============================================================================ + +local function GetBarWidth() + return CONFIG.targetIndicatorSize + CONFIG.raidIconSize + CONFIG.padding + + CONFIG.healthBarWidth + CONFIG.padding + + (MAX_DEBUFFS_PER_ROW * (CONFIG.debuffIconSize + CONFIG.debuffIconSpacing)) end local function FormatHealth(hp) - if not hp then return "?" end + 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 + return tostring(hp) 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()) +local function CreateUnitFrame(parent, index) + local frame = CreateFrame("Frame", "CleveRoidsMTTRow" .. index, parent) + frame:SetWidth(GetBarWidth()) + frame:SetHeight(CONFIG.barHeight) + frame.guid = nil + frame.hover = false + + -- First section: target indicator + raid icon + local firstSection = CreateFrame("Frame", nil, frame) + firstSection:SetPoint("LEFT", frame, "LEFT", 0, 0) + firstSection:SetWidth(CONFIG.targetIndicatorSize + CONFIG.raidIconSize + CONFIG.padding) firstSection:SetHeight(CONFIG.barHeight) - row.firstSection = firstSection + frame.firstSection = firstSection - -- Target indicator arrow (shows when this unit is targeted) + -- Target indicator local targetIndicator = firstSection:CreateTexture(nil, "OVERLAY") targetIndicator:SetWidth(CONFIG.targetIndicatorSize) - targetIndicator:SetHeight(CONFIG.targetIndicatorSize) + targetIndicator:SetHeight(8) 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:SetTexture("Interface\\AddOns\\Cursive\\img\\target-left") targetIndicator:Hide() - row.targetIndicator = targetIndicator + frame.targetIndicator = targetIndicator -- Raid icon local raidIcon = firstSection:CreateTexture(nil, "OVERLAY") @@ -955,79 +888,36 @@ local function CreateTargetRow(parent, index) raidIcon:SetPoint("RIGHT", firstSection, "RIGHT", 0, 0) raidIcon:SetTexture("Interface\\TargetingFrame\\UI-RaidTargetingIcons") raidIcon:Hide() - row.raidIcon = raidIcon + frame.raidIcon = raidIcon - -- ======================================== - -- Second Section: Health Bar (clickable) - -- ======================================== - local secondSection = CreateFrame("Button", nil, row) + -- Second section: health bar (clickable) + local secondSection = CreateFrame("Button", nil, frame) secondSection:SetPoint("LEFT", firstSection, "RIGHT", 0, 0) - secondSection:SetWidth(GetSecondSectionWidth()) + secondSection:SetWidth(CONFIG.healthBarWidth + CONFIG.padding) secondSection:SetHeight(CONFIG.barHeight) - secondSection.parent = row - row.secondSection = secondSection + secondSection.parent = frame + frame.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 + -- Click to target 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) + TargetUnit(this.parent.guid) + if not PlayerFrame.inCombat then + AttackTarget() + end 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) + if this.parent.healthBar and this.parent.healthBar.border then + this.parent.healthBar.border:SetBackdropBorderColor(1, 1, 1, 1) end - -- Show tooltip if this.parent.guid then GameTooltip_SetDefaultAnchor(GameTooltip, this) GameTooltip:SetUnit(this.parent.guid) @@ -1040,345 +930,120 @@ local function CreateTargetRow(parent, index) 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 + -- Health bar + local healthBar = CreateFrame("StatusBar", nil, secondSection) + 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) + healthBar:SetPoint("LEFT", secondSection, "LEFT", CONFIG.padding, 0) + healthBar:SetWidth(CONFIG.healthBarWidth) + healthBar:SetHeight(CONFIG.barHeight) + frame.healthBar = healthBar - -- 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) + -- Health bar backdrop + healthBar:SetBackdrop(BACKDROP_BACKGROUND) + healthBar:SetBackdropColor(0, 0, 0, 1) + + -- Health bar border + local border = CreateFrame("Frame", nil, healthBar) + border:SetBackdrop(BACKDROP_BORDER) + border:SetBackdropBorderColor(COLORS.borderNormal[1], COLORS.borderNormal[2], COLORS.borderNormal[3], 1) + border:SetPoint("TOPLEFT", healthBar, "TOPLEFT", -2, 2) + border:SetPoint("BOTTOMRIGHT", healthBar, "BOTTOMRIGHT", 2, -2) + healthBar.border = border + + -- HP text + local hpText = healthBar:CreateFontString(nil, "OVERLAY", "GameFontWhite") + hpText:SetPoint("TOPRIGHT", healthBar, "TOPRIGHT", -2, -2) + hpText:SetFont(STANDARD_TEXT_FONT, CONFIG.textSize, "OUTLINE") + hpText:SetJustifyH("RIGHT") + frame.hpText = hpText + + -- Name text + local nameText = healthBar:CreateFontString(nil, "OVERLAY", "GameFontWhite") + nameText:SetPoint("TOPLEFT", healthBar, "TOPLEFT", 2, -2) + nameText:SetPoint("BOTTOMRIGHT", hpText, "BOTTOMLEFT", -2, 0) + nameText:SetFont(STANDARD_TEXT_FONT, CONFIG.textSize, "OUTLINE") + nameText:SetJustifyH("LEFT") + frame.nameText = nameText + + -- Third section: debuff icons + local thirdSection = CreateFrame("Frame", nil, frame) + thirdSection:SetPoint("LEFT", secondSection, "RIGHT", 0, 0) + thirdSection:SetWidth(MAX_DEBUFFS_PER_ROW * (CONFIG.debuffIconSize + CONFIG.debuffIconSpacing)) + thirdSection:SetHeight(CONFIG.barHeight) + frame.thirdSection = thirdSection + + -- Debuff icons + frame.debuffIcons = {} + for i = 1, MAX_DEBUFFS_PER_ROW do + local icon = thirdSection:CreateTexture(nil, "OVERLAY") + icon:SetWidth(CONFIG.debuffIconSize) + icon:SetHeight(CONFIG.debuffIconSize) + icon:SetPoint("LEFT", thirdSection, "LEFT", (i - 1) * (CONFIG.debuffIconSize + CONFIG.debuffIconSpacing) + CONFIG.padding, 0) + icon:Hide() + + local timer = thirdSection:CreateFontString(nil, "OVERLAY", "GameFontNormal") + timer:SetFont(STANDARD_TEXT_FONT, 10, "OUTLINE") + timer:SetTextColor(1, 1, 1) + timer:SetAllPoints(icon) + timer:Hide() + icon.timer = timer + + frame.debuffIcons[i] = icon end - row:Hide() - return row + frame:Hide() + return frame end local function CreateMainFrame() - local barWidth = GetBarWidth() - local frame = CreateFrame("Frame", "CleveRoidsMultiTargetFrame", UIParent) - frame:SetWidth(barWidth + 8) - frame:SetHeight(CONFIG.titleHeight + 50) + frame:SetWidth(GetBarWidth() + 10) + frame:SetHeight(CONFIG.titleHeight + (MAX_TRACKED_TARGETS * (CONFIG.barHeight + CONFIG.barSpacing)) + 10) frame:SetPoint("CENTER", UIParent, "CENTER", 200, 100) - frame:SetFrameStrata("MEDIUM") - frame:SetMovable(true) + frame:SetBackdrop(BACKDROP_BACKGROUND) + frame:SetBackdropColor(0, 0, 0, 0.8) frame:EnableMouse(true) - frame:SetClampedToScreen(true) + frame:SetMovable(true) + frame:RegisterForDrag("LeftButton") - -- 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 + frame:SetScript("OnDragStart", function() + if isUnlocked then + this:StartMoving() end end) - -- Dragging - titleBar:SetScript("OnMouseDown", function() - if arg1 == "LeftButton" then - frame:StartMoving() - end - end) - titleBar:SetScript("OnMouseUp", function() - frame:StopMovingOrSizing() + frame:SetScript("OnDragStop", function() + this: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) + -- Title + local title = frame:CreateFontString(nil, "OVERLAY", "GameFontWhite") + title:SetPoint("TOPLEFT", frame, "TOPLEFT", 5, -2) + title:SetFont(STANDARD_TEXT_FONT, CONFIG.textSize, "OUTLINE") + title:SetText("Debuff Tracker") + title:SetTextColor(COLORS.titleText[1], COLORS.titleText[2], COLORS.titleText[3], 1) + frame.titleText = title - -- Create row pool + -- Create unit frames 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))) + local unitFrame = CreateUnitFrame(frame, i) + unitFrame:SetPoint("TOPLEFT", frame, "TOPLEFT", 5, -(CONFIG.titleHeight + (i - 1) * (CONFIG.barHeight + CONFIG.barSpacing))) + unitFrames[i] = unitFrame 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 +-- Frame Position Saving -- ============================================================================ -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() +local function SaveFramePosition() if not mainFrame then return end local point, relativeTo, relativePoint, xOfs, yOfs = mainFrame:GetPoint() @@ -1405,1090 +1070,311 @@ local function RestoreFramePosition() end -- ============================================================================ --- lib:AddEffect Hook +-- Display Logic (Cursive-style priority) -- ============================================================================ -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 +local function ShouldDisplayGuid(guid) + if not UnitExists(guid) then return false end + if UnitIsDead(guid) then return false end + if not HasAnyDebuff(guid) then return false end + return true end -local function HookAddEffect() - if not lib or not lib.AddEffect then - CleveRoids.Print("MultiTargetTracker: Could not hook libdebuff.AddEffect") - return +local function GetSortedDebuffs(guidDebuffs) + local debuffList = {} + for name, data in pairs(guidDebuffs) do + table.insert(debuffList, { name = name, data = data }) 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 + -- Sort by time remaining (soonest first) + table.sort(debuffList, function(a, b) + return TimeRemaining(a.data) < TimeRemaining(b.data) 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) + return debuffList end --- ============================================================================ --- Direct Debuff Scanning (SuperWoW) --- Scans target debuffs directly to catch debuffs missed by events --- ============================================================================ +local function UpdateUnitFrame(frame, guid) + frame.guid = guid -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 + -- Update health bar + if frame.healthBar then + frame.healthBar:SetMinMaxValues(0, UnitHealthMax(guid) or 100) + frame.healthBar:SetValue(UnitHealth(guid) or 0) 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 + -- Update name + local name = UnitName(guid) + if name and frame.nameText then + frame.nameText:SetText(name) 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 + -- Update HP text + if frame.hpText then + local hp = UnitHealth(guid) + frame.hpText:SetText(FormatHealth(hp)) 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 + -- Update border color + if frame.healthBar and frame.healthBar.border then + if frame.hover then + frame.healthBar.border:SetBackdropBorderColor(1, 1, 1, 1) + elseif UnitAffectingCombat(guid) then + frame.healthBar.border:SetBackdropBorderColor(0.8, 0.2, 0.2, 1) else - libdebuff.objects[normalizedGuid][spellId].stacks = libdebuff.objects[normalizedGuid][spellId].stacks or 1 + frame.healthBar.border:SetBackdropBorderColor(0.2, 0.2, 0.2, 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 + -- Update target indicator + if frame.targetIndicator then + if UnitIsUnit("target", guid) then + frame.targetIndicator:Show() + else + frame.targetIndicator:Hide() 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 + -- Update raid icon + if frame.raidIcon then + local raidIndex = GetRaidTargetIndex(guid) + if raidIndex then + SetRaidTargetIconTexture(frame.raidIcon, raidIndex) + frame.raidIcon:Show() + else + frame.raidIcon:Hide() 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") + -- Update debuff icons + for i = 1, MAX_DEBUFFS_PER_ROW do + frame.debuffIcons[i]:Hide() + frame.debuffIcons[i].timer:Hide() + end - 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) + local guidDebuffs = trackedDebuffs[guid] + if guidDebuffs then + local sortedDebuffs = GetSortedDebuffs(guidDebuffs) + for i, debuff in ipairs(sortedDebuffs) do + if i > MAX_DEBUFFS_PER_ROW then break end + + local icon = frame.debuffIcons[i] + local remaining = TimeRemaining(debuff.data) + + if remaining > 0 then + icon:SetTexture(debuff.data.texture) + icon:Show() + + -- Timer text + local timerText + if remaining < 10 then + timerText = string.format("%.1f", remaining) else - local debuffs, count = GetActivePlayerDebuffs(guid) - if count == 0 then - table.insert(toRemove, guid) - end + timerText = string.format("%d", math.ceil(remaining)) 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 + icon.timer:SetText(timerText) - 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]) + -- Timer color + if remaining < 3 then + icon.timer:SetTextColor(1, 0.2, 0.2) + else + icon.timer:SetTextColor(1, 1, 1) end + icon.timer:Show() end end - end) + end + + frame:Show() +end + +local function UpdateDisplay() + if isDisabled then return end + + -- Cleanup old/dead GUIDs + for guid, time in pairs(discoveredGuids) do + if not UnitExists(guid) or UnitIsDead(guid) then + RemoveGuid(guid) + elseif GetTime() - time > 900 and not UnitIsVisible(guid) then + -- 15 minutes old and not visible + RemoveGuid(guid) + end + end + + -- Collect displayable GUIDs + local displayable = {} + + -- Priority 1: Raid marked targets + for i = 8, 1, -1 do + local _, guid = UnitExists("mark" .. i) + if guid and ShouldDisplayGuid(guid) then + table.insert(displayable, guid) + end + end + + -- Priority 2: Highest HP targets (top 3) + local hpTargets = {} + for guid, _ in pairs(discoveredGuids) do + if ShouldDisplayGuid(guid) then + local found = false + for _, dg in ipairs(displayable) do + if dg == guid then found = true break end + end + if not found then + table.insert(hpTargets, { guid = guid, hp = UnitHealthMax(guid) or 0 }) + end + end + end + + table.sort(hpTargets, function(a, b) return a.hp > b.hp end) + + for i = 1, 3 do + if hpTargets[i] then + table.insert(displayable, hpTargets[i].guid) + end + end + + -- Priority 3: Remaining targets + for guid, _ in pairs(discoveredGuids) do + if table.getn(displayable) >= MAX_TRACKED_TARGETS then break end + + if ShouldDisplayGuid(guid) then + local found = false + for _, dg in ipairs(displayable) do + if dg == guid then found = true break end + end + if not found then + table.insert(displayable, guid) + end + end + end + + -- Update unit frames + local showFrame = false + for i = 1, MAX_TRACKED_TARGETS do + local frame = unitFrames[i] + local guid = displayable[i] + + if guid then + UpdateUnitFrame(frame, guid) + showFrame = true + else + frame:Hide() + end + end + + -- Show/hide main frame + if mainFrame then + if showFrame and not isDisabled then + if not isFrameVisible then + mainFrame:Show() + isFrameVisible = true + end + elseif not isUnlocked then + if isFrameVisible then + mainFrame:Hide() + isFrameVisible = false + end + end + end end -- ============================================================================ --- Combat Target Scanning +-- Update Loop -- ============================================================================ -local combatScanInterval = 0.5 -local lastCombatScan = 0 - -ScanCombatTargets = function() - if not CleveRoids.hasUnitXP then - return - end - - if not UnitAffectingCombat("player") then - return - end +local lastUpdate = 0 +local function OnUpdate() local now = GetTime() - if now - lastCombatScan < combatScanInterval then - return - end - lastCombatScan = now + if now - lastUpdate < UPDATE_INTERVAL then return end + lastUpdate = 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 + UpdateDisplay() end -- ============================================================================ --- Multiscan Integration +-- Extension Load -- ============================================================================ -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") + if not CleveRoids.hasSuperwow then + CleveRoids.Print("MultiTargetTracker: SuperWoW required, 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") + -- Load class spells + local success, spellCount = LoadClassSpells() + if not success then + CleveRoids.Print("MultiTargetTracker: Failed to load spells, extension disabled") + return 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 + -- Create main frame + mainFrame = CreateMainFrame() + + -- Restore position 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 + -- Restore hidden state + CleveRoidMacros = CleveRoidMacros or {} + if CleveRoidMacros.multiTargetHidden then + isDisabled = true + mainFrame:Hide() + isFrameVisible = false + end + + -- Start update loop + mainFrame:SetScript("OnUpdate", function() + OnUpdate() end) - CleveRoids.Print("Multi-Target Tracker loaded. Use /cleveroid mtt for commands.") + CleveRoids.Print("Multi-Target Tracker loaded for " .. playerClassName .. " (" .. spellCount .. " spells). Use /cleveroid mtt for commands.") end --- Export API +-- ============================================================================ +-- API Export +-- ============================================================================ + 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 + isDisabled = false + CleveRoidMacros = CleveRoidMacros or {} + CleveRoidMacros.multiTargetHidden = nil end end, + Hide = function() if mainFrame then mainFrame:Hide() isFrameVisible = false + isDisabled = true + CleveRoidMacros = CleveRoidMacros or {} + CleveRoidMacros.multiTargetHidden = true end end, + Toggle = function() if mainFrame then - if isFrameVisible then - mainFrame:Hide() - isFrameVisible = false + if isDisabled then + CleveRoids.MultiTargetTracker.Show() else - mainFrame:Show() - isFrameVisible = true + CleveRoids.MultiTargetTracker.Hide() end end end, + IsVisible = function() return isFrameVisible end, - -- Frame positioning + IsDisabled = function() + return isDisabled + end, + Unlock = function() isUnlocked = true if mainFrame then @@ -2499,20 +1385,19 @@ CleveRoids.MultiTargetTracker = { mainFrame.titleText:SetTextColor(0, 1, 0, 1) end end - CleveRoids.Print("Multi-Target Tracker: Unlocked - drag to move, then /cleveroid mtt lock") + CleveRoids.Print("Multi-Target Tracker: Unlocked - drag to move") 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 + SaveFramePosition() CleveRoids.Print("Multi-Target Tracker: Locked") end, + ResetPosition = function() CleveRoidMacros = CleveRoidMacros or {} CleveRoidMacros.multiTargetPos = nil @@ -2523,42 +1408,10 @@ CleveRoids.MultiTargetTracker = { 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 + ClearTargets = function() + discoveredGuids = {} + trackedDebuffs = {} + CleveRoids.Print("Multi-Target Tracker: Cleared all targets") end, }