-- SoulstoneTracker -- Tracks soulstones cast by warlocks on raid members -- Author: Waylock SoulstoneTracker = {} local m = SoulstoneTracker local SOULSTONE_DURATION = 30 * 60 -- Soulstone Resurrection spellID, confirmed live via BUFF_ADDED_OTHER -- (nampower) during actual testing. local SOULSTONE_SPELL_ID = 20765 local FONT = "Fonts\\FRIZQT__.TTF" local BASE_W = 380 local ROW_H = 18 local TITLE_H = 22 local HEADER_H = 16 local MAX_ROWS = 10 local MIN_W = 250 local ADDON_MSG_PREFIX = "SSTracker" local COL_CASTER_END = 0.38 local COL_ARROW = 0.40 local COL_TARGET_END = 0.72 -- Curse tracking local CURSES = { ["Curse of the Elements"] = "CoE", ["Curse of Shadow"] = "CoS", ["Curse of Recklessness"] = "CoR", ["Curse of Weakness"] = "CoW", } local CURSE_COLORS = { ["CoE"] = "|cffaa00ff", -- Purple ["CoS"] = "|cff0099ff", -- Blue ["CoR"] = "|cffff3333", -- Red ["CoW"] = "|cff00cc44", -- Green } -- Curse icons - exact paths verified from GetSpellTexture in-game local CURSE_ICONS = { ["CoE"] = "Interface\\Icons\\Spell_Shadow_ChillTouch", -- Curse of the Elements ["CoS"] = "Interface\\Icons\\Spell_Shadow_CurseOfAchimonde", -- Curse of Shadow ["CoR"] = "Interface\\Icons\\Spell_Shadow_UnholyStrength", -- Curse of Recklessness ["CoW"] = "Interface\\Icons\\Spell_Shadow_CurseOfMannoroth", -- Curse of Weakness } local CURSE_DIVIDER_H = 14 local CURSE_ROW_H = 16 -- Raid target icons (texture coords in UI-RaidTargetingIcons) local RAID_ICONS = { { name = "Star", coords = {0, 0.25, 0, 0.25} }, { name = "Circle", coords = {0.25, 0.5, 0, 0.25} }, { name = "Diamond", coords = {0.5, 0.75, 0, 0.25} }, { name = "Triangle", coords = {0.75, 1, 0, 0.25} }, { name = "Moon", coords = {0, 0.25, 0.25, 0.5 } }, { name = "Square", coords = {0.25, 0.5, 0.25, 0.5 } }, { name = "Cross", coords = {0.5, 0.75, 0.25, 0.5 } }, { name = "Skull", coords = {0.75, 1, 0.25, 0.5 } }, } m.stones = {} m.curses = {} -- { shortName = { caster } } m.banish = {} -- { warlockName = raidIconIndex } m.shardCounts = {} -- { warlockName = count } m.ssCooldowns = {} -- { warlockName = readyAtEpochSeconds (0/nil = ready now) } m.history = {} -- { {text=..., time=...}, ... } session-only log m.frame = nil m.settings = nil -- settings panel frame m.locked = false m.muted = false -- Default settings local DEFAULTS = { -- Notifications muteChat = false, muteSounds = false, warnOnCast = true, warnFiveMin = true, warnOneMin = true, warnExpired = true, warnNoStones = true, warnWarlocks = true, -- Display showCurseSection = true, showBanishSection = true, frameAlpha = 0.95, hideWhenSolo = false, classicTheme = true, -- Blizzard look; false = the original purple "Shadow" look -- Timing warnMinutes = 5, -- first warning threshold in minutes warnSeconds = 60, -- second warning threshold in seconds -- Announce announceOnCast = true, announceExpired = true, announceWarnings = true, announceCurse = true, announceBanish = true, } m.cfg = {} ------------------------------------------------------------------------------- -- Config helpers ------------------------------------------------------------------------------- local function cfg(key) if m.cfg[key] == nil then return DEFAULTS[key] end return m.cfg[key] end local function setCfg(key, val) m.cfg[key] = val if SoulstoneTrackerDB then SoulstoneTrackerDB.cfg = m.cfg end end -- English class token ("WARLOCK"), so it works on any client language local function playerIsWarlock() local _, class = UnitClass("player") return class == "WARLOCK" end -- Key for per-character settings stored in the account-wide saved variables local function charKey() return (UnitName("player") or "?").."-"..(GetRealmName() or "?") end ------------------------------------------------------------------------------- -- Themes ------------------------------------------------------------------------------- -- The theme is read once when the windows are built (PLAYER_LOGIN), so -- switching it asks for a /reload, same as the other reload-only settings. -- -- classic: the stock Blizzard look -- tooltip-style panel (Bagshui's neutral -- dark fill and light border) for the tracker, the marbled dialog box with the gold -- header plate for settings, gold titles, drop-shadow text instead -- of outlines, silver rules that fade out at the ends, quest-log -- hover glow on rows, and stock checkboxes / buttons / close button. -- shadow: the original flat dark window with the purple title bar. local THEMES = { shadow = { classic = false, inset = 1, -- gap between edge and content padBottom = 0, bg = { 0.05, 0.05, 0.08 }, border = { 0.25, 0.25, 0.35 }, titleHex = "|cffffd700", headerColor = { 0.85, 0.85, 0.85 }, sectionColor= { 0.6, 0.6, 0.6 }, emptyColor = { 0.4, 0.4, 0.4 }, rule = { 0.3, 0.15, 0.5, 0.8 }, stripe = 0.04, }, classic = { classic = true, inset = 4, padBottom = 4, -- same neutral window colors as Bagshui's defaults (windowBackground / -- windowBorder), so the tracker matches it instead of the navy tooltip tint bg = { 0.090, 0.085, 0.090 }, border = { 0.900, 0.850, 0.900 }, titleHex = "|cffffd100", -- NORMAL_FONT_COLOR headerColor = { 1, 0.82, 0 }, sectionColor= { 1, 0.82, 0 }, emptyColor = { 0.5, 0.5, 0.5 }, rule = { 0.8, 0.8, 0.8, 0.6 }, stripe = 0.03, }, } local function theme() if cfg("classicTheme") then return THEMES.classic end return THEMES.shadow end -- Set a font in the active theme's style: classic text has the stock 1px -- drop shadow and no outline, the shadow theme keeps its thin outline. local function styleFont(fs, size, T) T = T or theme() if T.classic then fs:SetFont(FONT, size, "") fs:SetShadowColor(0, 0, 0, 1) fs:SetShadowOffset(1, -1) else fs:SetFont(FONT, size, "OUTLINE") end end -- A 1px horizontal rule as a frame (so callers can ClearAllPoints / SetPoint -- / Show / Hide it like the plain texture it replaces). Classic: a silver -- line that fades out toward both ends. Shadow: a solid purple line. local function makeRule(parent, T) T = T or theme() local r = CreateFrame("Frame", nil, parent) r:SetHeight(1) local c = T.rule if T.classic then local L = r:CreateTexture(nil, "ARTWORK") local R = r:CreateTexture(nil, "ARTWORK") L:SetTexture("Interface\\Buttons\\WHITE8X8") R:SetTexture("Interface\\Buttons\\WHITE8X8") L:SetPoint("TopLeft", r, "TopLeft", 0, 0) L:SetPoint("BottomRight", r, "Bottom", 0, 0) R:SetPoint("TopLeft", r, "Top", 0, 0) R:SetPoint("BottomRight", r, "BottomRight", 0, 0) L:SetGradientAlpha("HORIZONTAL", c[1], c[2], c[3], 0, c[1], c[2], c[3], c[4]) R:SetGradientAlpha("HORIZONTAL", c[1], c[2], c[3], c[4], c[1], c[2], c[3], 0) else local t = r:CreateTexture(nil, "ARTWORK") t:SetAllPoints(r) t:SetTexture(c[1], c[2], c[3], c[4]) end return r end -- Same as makeRule but the line only fades out to the right: used beside a -- section label ("Label ———") so it never runs through the text. local function makeTrailingRule(parent, T) T = T or theme() local r = CreateFrame("Frame", nil, parent) r:SetHeight(1) local c = T.rule local t = r:CreateTexture(nil, "ARTWORK") t:SetAllPoints(r) t:SetTexture("Interface\\Buttons\\WHITE8X8") t:SetGradientAlpha("HORIZONTAL", c[1], c[2], c[3], c[4], c[1], c[2], c[3], 0) return r end ------------------------------------------------------------------------------- -- Utility ------------------------------------------------------------------------------- local function timeLeft(expires) return math.max(0, expires - time()) end local function formatTime(secs) if secs <= 0 then return "|cffff3333EXPIRED|r" elseif secs <= 300 then return "|cffff7c0a"..string.format("%dm %02ds", math.floor(secs/60), math.mod(secs,60)).."|r" else return "|cff00ff98"..string.format("%dm %02ds", math.floor(secs/60), math.mod(secs,60)).."|r" end end local function say(msg) DEFAULT_CHAT_FRAME:AddMessage("|cffa050ff[SS]|r " .. msg) end -- Look up a name's class from raid/party/player roster and return the name -- wrapped in that class's color code. Falls back to defaultColorCode (a -- hex string like "|cffa050ff") if the class can't be determined (e.g. an -- "Unknown" caster from a scan, or someone who's left the group). local function classColorName(name, defaultColorCode) local class = nil if name == UnitName("player") then local _, c = UnitClass("player") class = c else local numRaid = GetNumRaidMembers() if numRaid > 0 then for i = 1, numRaid do local n, _, _, _, c = GetRaidRosterInfo(i) if n == name then class = c break end end else for i = 1, GetNumPartyMembers() do if UnitName("party"..i) == name then local _, c = UnitClass("party"..i) class = c break end end end end if class and RAID_CLASS_COLORS[string.upper(class)] then local c = RAID_CLASS_COLORS[string.upper(class)] return string.format("|cff%02x%02x%02x", c.r*255, c.g*255, c.b*255)..name.."|r" end return (defaultColorCode or "|cffffffff")..name.."|r" end local function sendGroup(msg) if cfg("muteChat") then return end if GetNumRaidMembers() > 0 then SendChatMessage(msg, "RAID") elseif GetNumPartyMembers() > 0 then SendChatMessage(msg, "PARTY") end end local function playSound(sound) if not cfg("muteSounds") then PlaySound(sound) end end local function hasActiveStones() for _ in pairs(m.stones) do return true end return false end local function getWarlocksMissingStones() local missing = {} -- Build set of targets who have a stone local coveredTargets = {} for target in pairs(m.stones) do coveredTargets[target] = true end local numRaid = GetNumRaidMembers() if numRaid > 0 then for i = 1, numRaid do local name, _, _, _, class = GetRaidRosterInfo(i) if name and class == "Warlock" and not coveredTargets[name] then -- Check if they've cast on anyone local hasCast = false for _, data in pairs(m.stones) do if data.caster == name then hasCast = true break end end if not hasCast then table.insert(missing, name) end end end else for i = 1, GetNumPartyMembers() do local name = UnitName("party"..i) local class = UnitClass("party"..i) if name and class == "Warlock" and not coveredTargets[name] then local hasCast = false for _, data in pairs(m.stones) do if data.caster == name then hasCast = true break end end if not hasCast then table.insert(missing, name) end end end -- Check self local myClass = UnitClass("player") local myName = UnitName("player") if myClass == "Warlock" and not coveredTargets[myName] then local hasCast = false for _, data in pairs(m.stones) do if data.caster == myName then hasCast = true break end end if not hasCast then table.insert(missing, myName) end end end return missing end ------------------------------------------------------------------------------- -- Persistence ------------------------------------------------------------------------------- local function saveAll() if not SoulstoneTrackerDB then return end local now = time() -- Save stones local saved = {} for target, data in pairs(m.stones) do if data.expires > now then saved[target] = { caster=data.caster, expires=data.expires, castTime=data.castTime or now, warnedFiveMin=data.warnedFiveMin, warnedOneMin=data.warnedOneMin } end end SoulstoneTrackerDB.stones = saved -- Save curses SoulstoneTrackerDB.curses = m.curses -- Save banish SoulstoneTrackerDB.banish = m.banish end local function loadAll() if not SoulstoneTrackerDB then return end -- Load stones if SoulstoneTrackerDB.stones then local now = time() for target, data in pairs(SoulstoneTrackerDB.stones) do if data.expires > now then m.stones[target] = { caster=data.caster, expires=data.expires, castTime=data.castTime or now, warnedFiveMin=data.warnedFiveMin or false, warnedOneMin=data.warnedOneMin or false } end end SoulstoneTrackerDB.stones = nil end -- Load curses if SoulstoneTrackerDB.curses then m.curses = SoulstoneTrackerDB.curses SoulstoneTrackerDB.curses = nil end -- Load banish if SoulstoneTrackerDB.banish then m.banish = SoulstoneTrackerDB.banish SoulstoneTrackerDB.banish = nil end end ------------------------------------------------------------------------------- -- Raid Sync ------------------------------------------------------------------------------- local function broadcastStone(target, caster, expires) local msg = "ADD:"..target..":"..caster..":"..expires if GetNumRaidMembers() > 0 then SendAddonMessage(ADDON_MSG_PREFIX, msg, "RAID") elseif GetNumPartyMembers() > 0 then SendAddonMessage(ADDON_MSG_PREFIX, msg, "PARTY") end end local function broadcastRemove(target) local msg = "REM:"..target if GetNumRaidMembers() > 0 then SendAddonMessage(ADDON_MSG_PREFIX, msg, "RAID") elseif GetNumPartyMembers() > 0 then SendAddonMessage(ADDON_MSG_PREFIX, msg, "PARTY") end end ------------------------------------------------------------------------------- -- Tracking ------------------------------------------------------------------------------- function m.addStone(target, caster, expires, silent, unknownExpiry) local exp = expires or (time() + SOULSTONE_DURATION) m.stones[target] = { caster=caster, expires=exp, castTime=time(), warnedFiveMin=false, warnedOneMin=false, unknownExpiry=unknownExpiry or false } if not silent then say("|cffa050ff"..caster.."|r soulstoned |cffffffff"..target.."|r") if cfg("warnOnCast") then playSound("SPELLBOOKCLOSE") end broadcastStone(target, caster, exp) if cfg("announceOnCast") then sendGroup("[Soulstone] "..caster.." -> "..target.." (30 min)") end m.logHistory(caster.." soulstoned "..target) end m.refresh() end function m.removeStone(target, silent) if m.stones[target] then m.stones[target] = nil if not silent then broadcastRemove(target) end m.refresh() end end function m.clearExpired() local now = time() local changed = false for target, data in pairs(m.stones) do local secs = data.expires - now -- First warning (configurable, default 5 min) local firstWarnSecs = cfg("warnMinutes") * 60 if not data.warnedFiveMin and secs <= firstWarnSecs and secs > 0 then data.warnedFiveMin = true if cfg("warnFiveMin") then say("|cffff7c0a[WARNING]|r Soulstone on |cffffffff"..target.."|r expires in "..cfg("warnMinutes").." minutes!") playSound("RAID_WARNING") -- Only the actual caster's client broadcasts to raid chat -- -- every client independently evaluates the same synced -- data on the same timer, so without this every warlock -- running the addon would announce identically at once. if cfg("announceWarnings") and data.caster == UnitName("player") then sendGroup("[Soulstone] WARNING: "..target.."'s soulstone expires in "..cfg("warnMinutes").." minutes!") end end m.refresh() end -- Second warning (configurable, default 60 sec) local secondWarnSecs = cfg("warnSeconds") if not data.warnedOneMin and secs <= secondWarnSecs and secs > 0 then data.warnedOneMin = true if cfg("warnOneMin") then say("|cffff3333[WARNING]|r Soulstone on |cffffffff"..target.."|r expires in "..cfg("warnSeconds").." seconds!") playSound("RAID_WARNING") if cfg("announceWarnings") and data.caster == UnitName("player") then sendGroup("[Soulstone] WARNING: "..target.."'s soulstone expires in "..cfg("warnSeconds").." seconds!") end end m.refresh() end -- Expired if now >= data.expires then if cfg("warnExpired") then say("|cffff3333[WARNING]|r Soulstone expired on |cffffffff"..target.."|r") playSound("LOOTCLOSE") if cfg("announceExpired") and data.caster == UnitName("player") then sendGroup("[Soulstone] "..target.."'s soulstone has expired!") end end m.stones[target] = nil changed = true end end if changed then m.refresh() if not hasActiveStones() and cfg("warnNoStones") then say("|cffff3333[WARNING]|r No active soulstones in the raid!") playSound("RAID_WARNING") if cfg("warnWarlocks") then local missing = getWarlocksMissingStones() if table.getn(missing) > 0 then say("|cffff7c0aWarlocks with available SS: |cffffffff"..table.concat(missing, ", ").."|r") end end end end end ------------------------------------------------------------------------------- -- Buff Scan ------------------------------------------------------------------------------- function m.scanForSoulstones(silent) local units = {} local numRaid = GetNumRaidMembers() if numRaid > 0 then for i = 1, numRaid do table.insert(units, "raid"..i) end else table.insert(units, "player") for i = 1, GetNumPartyMembers() do table.insert(units, "party"..i) end end local found = 0 for _, unit in ipairs(units) do local name = UnitName(unit) if name then local i = 1 while true do local tex = UnitBuff(unit, i) if not tex then break end if string.find(tex, "Spell_Shadow_SoulGem") then if not m.stones[name] then m.addStone(name, "Unknown", nil, true, true) found = found + 1 end end i = i + 1 end end end if found > 0 then say("Found "..found.." existing soulstone(s) on group members.") m.refresh() elseif not silent then say("No soulstones found on group members.") end end -- Rebroadcasts everything we confidently know (soulstones, banish -- assignments, curses) to the group. Used both when responding to someone -- else's SYNCREQ, and as the basis for a manual force-resync. -- Counts Soul Shards in the local player's bags by matching the item's -- icon texture (same robust substring approach used for soulstone/curse -- detection, rather than relying on item-ID APIs that may not exist here). function m.countShards() local count = 0 for bag = 0, 4 do local slots = GetContainerNumSlots(bag) if slots and slots > 0 then for slot = 1, slots do local texture, itemCount = GetContainerItemInfo(bag, slot) if texture and string.find(texture, "INV_Misc_Gem_Amethyst_02", 1, true) then count = count + (itemCount or 1) end end end end return count end -- Rescans and broadcasts our own shard count if it changed since last -- broadcast, so we don't spam the addon channel on every bag update. local lastBroadcastShardCount = nil function m.updateAndBroadcastShards() local count = m.countShards() m.shardCounts[UnitName("player")] = count if count ~= lastBroadcastShardCount then lastBroadcastShardCount = count local channel = GetNumRaidMembers() > 0 and "RAID" or (GetNumPartyMembers() > 0 and "PARTY" or nil) if channel then SendAddonMessage(ADDON_MSG_PREFIX, "SHARDS:"..count, channel) end end m.refreshBanish() end -- Tracks the soulstone-USE cooldown (separate from a stone's 30-min -- duration on the target -- this is whether the warlock can use/make a NEW -- one right now). Confirmed via in-game tooltip: using a soulstone puts a -- fixed 30-min cooldown on it. Five different Blizzard/nampower cooldown -- APIs were all tried and all failed against a real, tooltip-confirmed -- active cooldown (GetSpellIdCooldown, GetItemCooldown by name, by numeric -- ID, GetSpellCooldown via spellbook slot, GetContainerItemCooldown) -- -- rather than keep guessing at a sixth API, this just starts our OWN -- 30-minute timer at the exact moment we detect a real soulstone -- application, reusing the detection logic that's already proven solid -- all session. No API dependency, no bag-scan requirement. local lastBroadcastReadyAt = nil function m.startOwnSoulstoneCooldown() local readyAt = time() + SOULSTONE_DURATION m.ssCooldowns[UnitName("player")] = readyAt if not lastBroadcastReadyAt or math.abs((lastBroadcastReadyAt or 0) - readyAt) > 5 then lastBroadcastReadyAt = readyAt local channel = GetNumRaidMembers() > 0 and "RAID" or (GetNumPartyMembers() > 0 and "PARTY" or nil) if channel then SendAddonMessage(ADDON_MSG_PREFIX, "SSCD:"..readyAt, channel) end end m.refreshBanish() end -- Kept as a no-op stub so the periodic ticker's existing call site doesn't -- need touching -- the timer above is event-driven now, not polled. function m.updateSoulstoneCooldown() end -- Session-only log of "who stoned who, when" and curse/banish assignments, -- for answering "did I get stoned tonight" type questions after a wipe. function m.logHistory(text) table.insert(m.history, { text = text, time = time() }) -- Cap growth over a long session if table.getn(m.history) > 100 then table.remove(m.history, 1) end end function m.rebroadcastKnownData() local channel = GetNumRaidMembers() > 0 and "RAID" or (GetNumPartyMembers() > 0 and "PARTY" or nil) if not channel then return end -- Skip our own uncertain scanned entries -- those shouldn't propagate -- and risk overwriting someone else's accurate value with a guess. for target, data in pairs(m.stones) do if data.expires > time() and not data.unknownExpiry then SendAddonMessage(ADDON_MSG_PREFIX, "ADD:"..target..":"..data.caster..":"..data.expires, channel) end end for wName, iconIdx in pairs(m.banish) do SendAddonMessage(ADDON_MSG_PREFIX, "BANISH:"..wName..":"..iconIdx, channel) end for shortName, data in pairs(m.curses) do if data.caster ~= "Unknown" then SendAddonMessage(ADDON_MSG_PREFIX, "CURSE:"..shortName..":"..data.caster, channel) end end local myShards = m.shardCounts[UnitName("player")] if myShards then SendAddonMessage(ADDON_MSG_PREFIX, "SHARDS:"..myShards, channel) end local myCd = m.ssCooldowns[UnitName("player")] if myCd then SendAddonMessage(ADDON_MSG_PREFIX, "SSCD:"..myCd, channel) end end function m.scanForCurses(silent) if not UnitExists("target") then if not silent then say("No target selected -- target the mob you want to check.") end return end -- Match on just the icon filename portion (e.g. "ChillTouch"), same -- robust substring approach already used for soulstone texture -- detection, rather than requiring an exact full-path match. local CURSE_ICON_NAMES = { ["CoE"] = "ChillTouch", ["CoS"] = "CurseOfAchimonde", ["CoR"] = "UnholyStrength", ["CoW"] = "CurseOfMannoroth", } local found = 0 local i = 1 while true do local tex = UnitDebuff("target", i) if not tex then break end for shortName, iconName in pairs(CURSE_ICON_NAMES) do if string.find(tex, iconName, 1, true) then -- Only a genuinely new discovery counts as newsworthy -- -- don't re-announce something we already know about every -- time the debounced auto-scan re-runs on retarget. if not m.curses[shortName] then m.curses[shortName] = { caster = "Unknown" } found = found + 1 end end end i = i + 1 end if found > 0 then if not silent then say("Found "..found.." new curse(s) on your target (caster unknown -- see curse section).") end m.refreshBanish() elseif not silent then say("No curses found on your target.") end end ------------------------------------------------------------------------------- -- Minimap Button ------------------------------------------------------------------------------- function m.createMinimapButton() local radius = 80 local btn = CreateFrame("Button", "SoulstoneTrackerMinimap", Minimap) -- Standard minimap button layout (same as the stock tracking button and -- other addons' buttons): 31x31 button, round gold tracking border over -- it, round dark backing, and the icon trimmed to sit inside the ring. btn:SetWidth(31) btn:SetHeight(31) btn:SetFrameStrata("MEDIUM") btn:SetFrameLevel(8) btn:SetClampedToScreen(true) -- Round dark backing behind the icon local bg = btn:CreateTexture(nil, "BACKGROUND") bg:SetTexture("Interface\\Minimap\\UI-Minimap-Background") bg:SetWidth(20) bg:SetHeight(20) bg:SetPoint("TopLeft", btn, "TopLeft", 7, -5) -- Soulstone icon, edges trimmed so its square corners hide under the ring local icon = btn:CreateTexture(nil, "ARTWORK") icon:SetTexture("Interface\\Icons\\Spell_Shadow_SoulGem") icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) icon:SetWidth(18) icon:SetHeight(18) icon:SetPoint("TopLeft", btn, "TopLeft", 7, -6) btn.icon = icon -- Round gold border local border = btn:CreateTexture(nil, "OVERLAY") border:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder") border:SetWidth(53) border:SetHeight(53) border:SetPoint("TopLeft", btn, "TopLeft", 0, 0) -- Highlight on hover btn:SetHighlightTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight") local highlight = btn:GetHighlightTexture() if highlight then highlight:SetBlendMode("ADD") end -- Icon nudges down-right while pressed, like the stock buttons btn:SetScript("OnMouseDown", function() icon:SetPoint("TopLeft", btn, "TopLeft", 8, -7) end) btn:SetScript("OnMouseUp", function() icon:SetPoint("TopLeft", btn, "TopLeft", 7, -6) end) local angle = SoulstoneTrackerDB.minimapAngle or 195 local function updatePos() btn:SetPoint("Center", Minimap, "Center", math.cos(math.rad(angle))*radius, math.sin(math.rad(angle))*radius) end updatePos() btn:RegisterForDrag("LeftButton") btn:SetScript("OnDragStart", function() btn:SetScript("OnUpdate", function() local mx, my = Minimap:GetCenter() local px, py = GetCursorPosition() local s = Minimap:GetEffectiveScale() angle = math.deg(math.atan2(py/s - my, px/s - mx)) SoulstoneTrackerDB.minimapAngle = angle updatePos() end) end) btn:SetScript("OnDragStop", function() btn:SetScript("OnUpdate", nil) icon:SetPoint("TopLeft", btn, "TopLeft", 7, -6) end) btn:SetScript("OnClick", function() if m.frame:IsVisible() then m.frame:Hide() SoulstoneTrackerDB.hidden = true else m.frame:Show() SoulstoneTrackerDB.hidden = false end end) btn:SetScript("OnEnter", function() GameTooltip:SetOwner(btn, "ANCHOR_LEFT") GameTooltip:SetText("|cffa050ffSoulstone Tracker|r") local n = 0 for _ in pairs(m.stones) do n = n + 1 end if n > 0 then GameTooltip:AddLine(n.." active soulstone(s)", 0, 1, 0) else GameTooltip:AddLine("No active soulstones", 0.5, 0.5, 0.5) end GameTooltip:AddLine("Click to toggle", 1, 1, 1) GameTooltip:Show() end) btn:SetScript("OnLeave", function() GameTooltip:Hide() end) m.minimap = btn end ------------------------------------------------------------------------------- -- Column repositioning on resize ------------------------------------------------------------------------------- -- Background fill follows the transparency slider; in the classic theme the -- border fades with it a little (never fully), like OctoBus does. function m.applyFrameAlpha(f) f = f or m.frame if not f then return end local T = f.theme or theme() local a = cfg("frameAlpha") f:SetBackdropColor(T.bg[1], T.bg[2], T.bg[3], a) if T.classic then f:SetBackdropBorderColor(T.border[1], T.border[2], T.border[3], math.min(0.98, a + 0.25)) else f:SetBackdropBorderColor(T.border[1], T.border[2], T.border[3], 1) end end function m.repositionColumns() local f = m.frame if not f or not f.cols then return end local w = f:GetWidth() local tx = math.floor(w * COL_ARROW) + 14 f.cols.hTarget:ClearAllPoints() f.cols.hTarget:SetPoint("TopLeft", f, "TopLeft", tx, f.headerY or -(TITLE_H + HEADER_H/2)) for i = 1, MAX_ROWS do local row = f.rows[i] if row then local rowY = -(TITLE_H + HEADER_H + 2 + (i-1)*ROW_H) row.caster:SetWidth(math.floor(w * COL_CASTER_END) - 8) row.arrow:ClearAllPoints() row.arrow:SetPoint("TopLeft", f, "TopLeft", math.floor(w * COL_ARROW), rowY) row.target:ClearAllPoints() row.target:SetPoint("TopLeft", f, "TopLeft", tx, rowY) row.target:SetWidth(math.floor(w * COL_TARGET_END) - tx - 4) row.timer:SetWidth(w - math.floor(w * COL_TARGET_END) - 8) end end end ------------------------------------------------------------------------------- -- UI ------------------------------------------------------------------------------- StaticPopupDialogs["SOULSTONETRACKER_RELOAD"] = { text = "SoulstoneTracker: some changes need a UI reload to fully apply. Reload now?", button1 = "Reload Now", button2 = "Later", OnAccept = function() ReloadUI() end, timeout = 0, whileDead = true, hideOnEscape = true, preferredIndex = 3, } -- Call this instead of m.settings:Hide() directly whenever the settings -- panel is closed, so a pending reload-required change gets flagged. function m.closeSettings() if not m.settings then return end m.settings:Hide() if m.settings.needsReload then m.settings.needsReload = false StaticPopup_Show("SOULSTONETRACKER_RELOAD") end end function m.onClose() if m.frame then m.frame:Hide() SoulstoneTrackerDB.hidden = true end end function m.onLockClick() m.locked = not m.locked SoulstoneTrackerDB.locked = m.locked m.frame.updateLock() say(m.locked and "Frame locked." or "Frame unlocked.") end function m.createFrame() local savedW = SoulstoneTrackerDB.size and SoulstoneTrackerDB.size.w or BASE_W local savedH = SoulstoneTrackerDB.size and SoulstoneTrackerDB.size.h or TITLE_H + HEADER_H + ROW_H + 6 local T = theme() local IN = T.inset local f = CreateFrame("Frame", "SoulstoneTrackerFrame", UIParent) f:SetWidth(savedW) f:SetHeight(savedH) f:SetFrameStrata("MEDIUM") if T.classic then -- classic Blizzard tooltip panel: dark blue fill, silver rounded border f:SetBackdrop({ bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", tile = true, tileSize = 16, edgeSize = 16, insets = { left=4, right=4, top=4, bottom=4 } }) else f:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1, insets = { left=1, right=1, top=1, bottom=1 } }) end f.theme = T m.applyFrameAlpha(f) f:EnableMouse(true) f:SetMovable(true) f:SetResizable(true) f:SetClampedToScreen(true) f:RegisterForDrag("LeftButton") f:SetScript("OnDragStart", function() if not m.locked then this:StartMoving() end end) f:SetScript("OnDragStop", function() this:StopMovingOrSizing() local point, _, rpoint, x, y = this:GetPoint() SoulstoneTrackerDB.position = { point=point, rpoint=rpoint, x=x, y=y } end) f:SetScript("OnSizeChanged", function() local w = math.max(MIN_W, this:GetWidth()) this:SetWidth(w) SoulstoneTrackerDB.size = { w=w, h=this:GetHeight() } m.repositionColumns() end) -- Title bar local titleBar = CreateFrame("Frame", nil, f) f.titleBar = titleBar if T.classic then -- no filled bar: gold title straight on the panel, silver rule below titleBar:SetPoint("TopLeft", f, "TopLeft", IN, -IN) titleBar:SetPoint("TopRight", f, "TopRight", -IN, -IN) titleBar:SetHeight(TITLE_H - IN) -- red glow behind the title that pulses while a stone is in its -- final minute (the shadow theme flashes its title bar instead) local glow = titleBar:CreateTexture(nil, "BACKGROUND") glow:SetAllPoints(titleBar) glow:SetTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight") glow:SetBlendMode("ADD") glow:SetVertexColor(1, 0.15, 0.1, 0) f.titleGlow = glow local titleRule = makeRule(f, T) titleRule:SetPoint("TopLeft", f, "TopLeft", IN + 4, -TITLE_H) titleRule:SetPoint("TopRight", f, "TopRight", -(IN + 4), -TITLE_H) else titleBar:SetPoint("TopLeft", f, "TopLeft", 1, -1) titleBar:SetPoint("TopRight", f, "TopRight", -1, -1) titleBar:SetHeight(TITLE_H) titleBar:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8" }) titleBar:SetBackdropColor(0.22, 0.08, 0.38, 1) local grad = titleBar:CreateTexture(nil, "OVERLAY") grad:SetAllPoints(titleBar) grad:SetTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight") grad:SetBlendMode("ADD") grad:SetVertexColor(0.2, 0.05, 0.4, 0.4) end local title = titleBar:CreateFontString(nil, "OVERLAY") styleFont(title, T.classic and 12 or 11, T) title:SetPoint("Left", titleBar, "Left", T.classic and 4 or 6, 0) title:SetText(T.titleHex.."Soulstone Tracker v1.5|r") f.title = title -- Title bar icons. The .blp copies of these icons have no colour data -- (they render as solid black), and WoW picks the .blp over the .tga when -- the path has no extension -- so the .tga files are named explicitly. -- Classic: white versions tinted gold like the "S", white on hover. -- Shadow: the original coloured icons (grey cog, green lock, red X). local IMG = "Interface\\AddOns\\SoulstoneTracker\\images\\" local function iconFile(name) return IMG..name..(T.classic and "_white.tga" or ".tga") end local GOLD_R, GOLD_G, GOLD_B = 1, 0.82, 0 -- same gold as the "S" local function goldOnHover(btn, tex) if not T.classic or not tex then return end tex:SetVertexColor(GOLD_R, GOLD_G, GOLD_B) local onEnter = btn:GetScript("OnEnter") local onLeave = btn:GetScript("OnLeave") btn:SetScript("OnEnter", function() tex:SetVertexColor(1, 1, 1) if onEnter then onEnter() end end) btn:SetScript("OnLeave", function() tex:SetVertexColor(GOLD_R, GOLD_G, GOLD_B) if onLeave then onLeave() end end) end -- Settings button (defined in XML) local settingsBtn = _G["SSTSettingsButton"] settingsBtn:SetParent(titleBar) settingsBtn:SetPoint("Right", titleBar, "Right", -42, 0) settingsBtn:Show() local cog = settingsBtn:GetNormalTexture() if cog then cog:SetTexture(iconFile("icon_cog")) end goldOnHover(settingsBtn, cog) -- Scan button (pure Lua, not XML -- avoids the reload/staleness issues -- we hit with the XML-defined buttons). local scanBtn = CreateFrame("Button", nil, titleBar) scanBtn:SetWidth(16) scanBtn:SetHeight(16) scanBtn:SetPoint("Right", titleBar, "Right", -60, 0) -- Text glyph instead of a mismatched item icon -- guaranteed to fit the -- theme since it's just colored text, same approach (and now the exact -- same colors) as the settings panel's own text-based close button. local scanTxt = scanBtn:CreateFontString(nil, "OVERLAY") styleFont(scanTxt, 13, T) scanTxt:SetAllPoints(scanBtn) scanTxt:SetJustifyH("Center") scanTxt:SetJustifyV("Middle") local scanIdle = T.classic and "|cffffd100S|r" or "|cffaaaaaaS|r" scanTxt:SetText(scanIdle) scanBtn:SetScript("OnEnter", function() GameTooltip:SetOwner(scanBtn, "ANCHOR_LEFT") GameTooltip:SetText("Force resync with group") GameTooltip:Show() scanTxt:SetText("|cffffffffS|r") end) scanBtn:SetScript("OnLeave", function() GameTooltip:Hide() scanTxt:SetText(scanIdle) end) scanBtn:SetScript("OnClick", function() local channel = GetNumRaidMembers() > 0 and "RAID" or (GetNumPartyMembers() > 0 and "PARTY" or nil) if not channel then say("Not in a group -- nothing to sync.") else m.rebroadcastKnownData() SendAddonMessage(ADDON_MSG_PREFIX, "SYNCREQ", channel) say("Resync requested -- rebroadcast your data and asked others to do the same.") end end) -- Lock button (defined in XML with BLP texture) local lockBtn = _G["SSTLockButton"] lockBtn:SetParent(titleBar) lockBtn:SetPoint("Right", titleBar, "Right", -24, 0) lockBtn:Show() local lockTex = _G["SSTLockButtonNT"] f.lockTex = lockTex goldOnHover(lockBtn, lockTex) local function updateLock() if m.locked then lockTex:SetTexture(iconFile("icon_locked")) else lockTex:SetTexture(iconFile("icon_unlocked")) end end updateLock() f.updateLock = updateLock -- X close button (defined in XML with BLP texture) local closeBtn = _G["SSTCloseButton"] if T.classic then -- the stock red Blizzard close button instead of the flat X closeBtn:Hide() closeBtn = CreateFrame("Button", "SSTClassicCloseButton", titleBar, "UIPanelCloseButton") closeBtn:SetWidth(24) closeBtn:SetHeight(24) closeBtn:SetPoint("Right", titleBar, "Right", 4, 0) closeBtn:SetFrameLevel(titleBar:GetFrameLevel() + 3) closeBtn:SetScript("OnClick", function() m.onClose() end) closeBtn:Show() else closeBtn:SetParent(titleBar) closeBtn:SetPoint("Right", titleBar, "Right", -4, 0) local xTex = closeBtn:GetNormalTexture() if xTex then xTex:SetTexture(IMG.."icon_x.tga") end closeBtn:Show() end -- Column headers -- classic: headers sit a bit higher so they clear the rule under them local hY = T.classic and -(TITLE_H + 3) or -(TITLE_H + HEADER_H/2) f.headerY = hY local hc = T.headerColor local hCaster = f:CreateFontString(nil, "OVERLAY") styleFont(hCaster, 10, T) hCaster:SetTextColor(hc[1], hc[2], hc[3], 1) hCaster:SetPoint("TopLeft", f, "TopLeft", 8, hY) hCaster:SetText("Caster") local hTarget = f:CreateFontString(nil, "OVERLAY") styleFont(hTarget, 10, T) hTarget:SetTextColor(hc[1], hc[2], hc[3], 1) hTarget:SetPoint("TopLeft", f, "TopLeft", math.floor(savedW * COL_ARROW) + 14, hY) hTarget:SetText("Target") local hExpires = f:CreateFontString(nil, "OVERLAY") styleFont(hExpires, 10, T) hExpires:SetTextColor(hc[1], hc[2], hc[3], 1) hExpires:SetPoint("TopRight", f, "TopRight", -8, hY) hExpires:SetJustifyH("Right") hExpires:SetText("Expires") local div = makeRule(f, T) local divIn = T.classic and IN + 4 or 1 div:SetPoint("TopLeft", f, "TopLeft", divIn, -(TITLE_H + HEADER_H)) div:SetPoint("TopRight", f, "TopRight", -divIn, -(TITLE_H + HEADER_H)) local empty = f:CreateFontString(nil, "OVERLAY") styleFont(empty, 10, T) empty:SetTextColor(T.emptyColor[1], T.emptyColor[2], T.emptyColor[3], 1) empty:SetPoint("TopLeft", f, "TopLeft", 8, -(TITLE_H + HEADER_H + 3)) empty:SetText("No active soulstones") f.empty = empty f.cols = { hTarget = hTarget } -- Rows f.rows = {} for i = 1, MAX_ROWS do local rowY = -(TITLE_H + HEADER_H + 2 + (i-1)*ROW_H) local w = savedW local bg = f:CreateTexture(nil, "BACKGROUND") bg:SetPoint("TopLeft", f, "TopLeft", IN, rowY) bg:SetPoint("TopRight", f, "TopRight", -IN, rowY) bg:SetHeight(ROW_H) if math.mod(i, 2) == 0 then bg:SetTexture(1,1,1,T.stripe) else bg:SetTexture(0,0,0,0) end bg:Hide() local caster = f:CreateFontString(nil, "OVERLAY") styleFont(caster, 11, T) caster:SetPoint("TopLeft", f, "TopLeft", 8, rowY) caster:SetWidth(math.floor(w * COL_CASTER_END) - 8) caster:SetJustifyH("Left") local arrow = f:CreateFontString(nil, "OVERLAY") styleFont(arrow, 11, T) arrow:SetTextColor(0.4, 0.4, 0.4, 1) arrow:SetPoint("TopLeft", f, "TopLeft", math.floor(w * COL_ARROW), rowY) arrow:SetText("->") local tx = math.floor(w * COL_ARROW) + 14 local target = f:CreateFontString(nil, "OVERLAY") styleFont(target, 11, T) target:SetPoint("TopLeft", f, "TopLeft", tx, rowY) target:SetWidth(math.floor(w * COL_TARGET_END) - tx - 4) target:SetJustifyH("Left") local timer = f:CreateFontString(nil, "OVERLAY") styleFont(timer, 11, T) timer:SetPoint("TopRight", f, "TopRight", -8, rowY) timer:SetWidth(w - math.floor(w * COL_TARGET_END) - 8) timer:SetJustifyH("Right") local hitbox = CreateFrame("Frame", nil, f) hitbox:SetHeight(ROW_H) hitbox:SetPoint("TopLeft", f, "TopLeft", IN, rowY) hitbox:SetPoint("TopRight", f, "TopRight", -IN, rowY) hitbox:EnableMouse(true) hitbox.rowIndex = i -- classic quest-log style hover glow on rows that have a stone -- (drawn on the window itself, under the row text, not on the hitbox -- which sits above the text) if T.classic then local hl = f:CreateTexture(nil, "BORDER") hl:SetAllPoints(hitbox) hl:SetTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight") hl:SetBlendMode("ADD") hl:SetAlpha(0.5) hl:Hide() hitbox.hl = hl end hitbox:SetScript("OnEnter", function() local row = f.rows[this.rowIndex] if not row or not row.data then return end if this.hl then this.hl:Show() end local d = row.data GameTooltip:SetOwner(this, "ANCHOR_CURSOR") GameTooltip:SetText("|cffa050ff"..d.caster.."|r -> |cffffffff"..d.target.."|r") GameTooltip:AddLine("Cast: "..date("%H:%M:%S", d.castTime), 0.8, 0.8, 0.8) GameTooltip:AddLine("Expires: "..date("%H:%M:%S", d.expires), 0.8, 0.8, 0.8) local s = timeLeft(d.expires) GameTooltip:AddLine(string.format("Time left: %dm %02ds", math.floor(s/60), math.mod(s,60)), 1,1,0) GameTooltip:Show() end) hitbox:SetScript("OnLeave", function() if this.hl then this.hl:Hide() end GameTooltip:Hide() end) f.rows[i] = { caster=caster, arrow=arrow, target=target, timer=timer, bg=bg, hitbox=hitbox, data=nil } end local SEC_ROW_H = 18 -- Combined Warlock section (curse + banish on same row) -- classic: "Curse and Banish Assignment ———" with the rule trailing the -- label; shadow: the original small caps label on a full-width line local warlockDivTex = T.classic and makeTrailingRule(f, T) or makeRule(f, T) f.curseDivider = warlockDivTex local warlockHdr = f:CreateFontString(nil, "OVERLAY") styleFont(warlockHdr, T.classic and 10 or 8, T) warlockHdr:SetTextColor(T.sectionColor[1], T.sectionColor[2], T.sectionColor[3], 1) warlockHdr:SetText(T.classic and "Curse and Banish Assignment" or "CURSE AND BANISH ASSIGNMENT") f.curseHeader = warlockHdr -- Footer line for curses found via target-scan whose caster is unknown -- (e.g. cast by a warlock not running the addon) -- these don't fit the -- per-warlock row display since there's no known warlock to attach them -- to, so they get a small summary line instead. local unknownCurseLine = f:CreateFontString(nil, "OVERLAY") styleFont(unknownCurseLine, 9, T) unknownCurseLine:SetTextColor(0.7, 0.7, 0.7, 1) unknownCurseLine:Hide() f.unknownCurseLine = unknownCurseLine -- One row per warlock (up to 5), shows: Name | CurseIcon | BanishIcon f.warlockRows = {} for i = 1, 5 do local bg = f:CreateTexture(nil, "BACKGROUND") bg:SetHeight(SEC_ROW_H) if math.mod(i,2)==0 then bg:SetTexture(1,1,1,T.stripe) else bg:SetTexture(0,0,0,0) end bg:Hide() -- Warlock name local nameTxt = f:CreateFontString(nil, "OVERLAY") styleFont(nameTxt, 11, T) nameTxt:SetJustifyH("Left") nameTxt:Hide() -- Curse icon (auto-detected) local curseIcon = f:CreateTexture(nil, "ARTWORK") curseIcon:SetWidth(14) curseIcon:SetHeight(14) curseIcon:Hide() -- Curse label local curseLbl = f:CreateFontString(nil, "OVERLAY") styleFont(curseLbl, 10, T) curseLbl:SetJustifyH("Left") curseLbl:Hide() -- Curse override button (manual assign/correct, mirrors banish button below) local curseBtn = CreateFrame("Button", "SSWarlockCurse_"..i, f) curseBtn:SetHeight(SEC_ROW_H) curseBtn:SetFrameLevel(f:GetFrameLevel() + 5) curseBtn.rowIndex = i curseBtn:SetScript("OnEnter", function() local row = f.warlockRows[this.rowIndex] if not row or not row.warlockName then return end GameTooltip:SetOwner(curseBtn, "ANCHOR_LEFT") GameTooltip:SetText(row.warlockName, 0.6, 0.3, 1) local shortName = nil for sn, data in pairs(m.curses) do if data.caster == row.warlockName then shortName = sn break end end GameTooltip:AddLine(shortName and ("Curse: "..shortName) or "No curse detected", 1,1,0) GameTooltip:AddLine("Click to manually set/correct", 0.7,0.7,0.7) GameTooltip:Show() end) curseBtn:SetScript("OnLeave", function() GameTooltip:Hide() end) curseBtn:SetScript("OnClick", function() local row = f.warlockRows[this.rowIndex] if not row or not row.warlockName then return end local wName = row.warlockName local menu = CreateFrame("Frame", "SSWarlockCurseMenu_"..this.rowIndex, UIParent, "UIDropDownMenuTemplate") UIDropDownMenu_Initialize(menu, function() local none = { text="-- None --", notCheckable=true } none.func = function() local ch = GetNumRaidMembers()>0 and "RAID" or (GetNumPartyMembers()>0 and "PARTY" or nil) for sn, data in pairs(m.curses) do if data.caster == wName then m.curses[sn] = nil if ch then SendAddonMessage(ADDON_MSG_PREFIX, "CURSEREM:"..sn, ch) end end end m.refresh() say("Cleared curse assignment for "..wName..".") end UIDropDownMenu_AddButton(none) for fullName, shortName in pairs(CURSES) do local ci = { text=fullName.." ("..shortName..")", notCheckable=true, arg1=shortName } ci.func = function(sn) local ch = GetNumRaidMembers()>0 and "RAID" or (GetNumPartyMembers()>0 and "PARTY" or nil) -- Remove wName from any other curse slot first, and -- broadcast that removal so other clients don't keep -- a stale entry for the superseded curse. for sn2, data in pairs(m.curses) do if data.caster == wName and sn2 ~= sn then m.curses[sn2] = nil if ch then SendAddonMessage(ADDON_MSG_PREFIX, "CURSEREM:"..sn2, ch) end end end m.curses[sn] = { caster = wName } m.refresh() local msg = "[Curse] "..wName.." -> "..sn.." (manual override)" say(msg) if ch then SendAddonMessage(ADDON_MSG_PREFIX, "CURSE:"..sn..":"..wName, ch) end end UIDropDownMenu_AddButton(ci) end end, "MENU") ToggleDropDownMenu(1, nil, menu, "cursor", 0, 0) end) curseBtn:Hide() -- Banish icon button local banishBtn = CreateFrame("Button", "SSWarlockBanish_"..i, f) banishBtn:SetWidth(16) banishBtn:SetHeight(16) banishBtn:SetFrameLevel(f:GetFrameLevel() + 5) banishBtn.rowIndex = i local banishTex = banishBtn:CreateTexture(nil, "ARTWORK") banishTex:SetAllPoints(banishBtn) banishTex:SetTexture("Interface\\TargetingFrame\\UI-RaidTargetingIcons") banishTex:SetTexCoord(0,0,0,0) banishBtn.iconTex = banishTex local banishLbl = banishBtn:CreateFontString(nil, "OVERLAY") styleFont(banishLbl, 10, T) banishLbl:SetAllPoints(banishBtn) banishLbl:SetJustifyH("Center") banishLbl:SetJustifyV("Middle") banishLbl:SetTextColor(0.4, 0.4, 0.4, 1) banishLbl:SetText("-") banishBtn.lbl = banishLbl banishBtn:SetScript("OnEnter", function() local row = f.warlockRows[this.rowIndex] if not row or not row.warlockName then return end GameTooltip:SetOwner(banishBtn, "ANCHOR_LEFT") GameTooltip:SetText(row.warlockName, 0.6, 0.3, 1) local idx = m.banish[row.warlockName] GameTooltip:AddLine(idx and ("Banish: "..RAID_ICONS[idx].name) or "Click to assign banish target", 1,1,0) GameTooltip:Show() end) banishBtn:SetScript("OnLeave", function() GameTooltip:Hide() end) banishBtn:SetScript("OnClick", function() local row = f.warlockRows[this.rowIndex] if not row or not row.warlockName then return end local wName = row.warlockName local menu = CreateFrame("Frame", "SSWarlockMenu_"..this.rowIndex, UIParent, "UIDropDownMenuTemplate") UIDropDownMenu_Initialize(menu, function() local none = { text="-- None --", notCheckable=true } none.func = function() m.banish[wName] = nil m.refreshBanish() local ch = GetNumRaidMembers()>0 and "RAID" or (GetNumPartyMembers()>0 and "PARTY" or nil) if ch then SendAddonMessage(ADDON_MSG_PREFIX, "BANISH:"..wName..":0", ch) end end UIDropDownMenu_AddButton(none) for idx, iconData in ipairs(RAID_ICONS) do local ii = { text=iconData.name, notCheckable=true, arg1=idx } ii.func = function(iconIdx) m.banish[wName] = iconIdx m.refreshBanish() local msg = "[Banish] "..wName.." -> "..RAID_ICONS[iconIdx].name say(msg) m.logHistory(wName.." assigned banish: "..RAID_ICONS[iconIdx].name) local ch = GetNumRaidMembers()>0 and "RAID" or (GetNumPartyMembers()>0 and "PARTY" or nil) if ch then sendGroup(msg) SendAddonMessage(ADDON_MSG_PREFIX, "BANISH:"..wName..":"..iconIdx, ch) end end UIDropDownMenu_AddButton(ii) end end, "MENU") ToggleDropDownMenu(1, nil, menu, "cursor", 0, 0) end) banishBtn:Hide() f.warlockRows[i] = { bg=bg, nameTxt=nameTxt, curseIcon=curseIcon, curseLbl=curseLbl, curseBtn=curseBtn, banishBtn=banishBtn, banishTex=banishTex, banishLbl=banishLbl, warlockName=nil } end -- Keep these for compatibility f.curseRows = {} f.banishRows = {} f.banishDivider = f:CreateTexture(nil, "ARTWORK") -- unused but referenced f.banishHeader = f:CreateFontString(nil, "OVERLAY") -- unused but referenced -- Resize grip local grip = CreateFrame("Button", nil, f) grip:SetWidth(16) grip:SetHeight(16) if T.classic then grip:SetPoint("BottomRight", f, "BottomRight", -2, 2) else grip:SetPoint("BottomRight", f, "BottomRight", 0, 0) end grip:SetFrameLevel(f:GetFrameLevel() + 10) grip:SetNormalTexture("Interface\\AddOns\\SoulstoneTracker\\ResizeGrip") grip:SetScript("OnEnter", function() GameTooltip:SetOwner(grip, "ANCHOR_LEFT") GameTooltip:SetText("Drag to resize", 1,1,1) GameTooltip:Show() end) grip:SetScript("OnLeave", function() GameTooltip:Hide() end) grip:SetScript("OnMouseDown", function() f:StartSizing("BOTTOMRIGHT") end) grip:SetScript("OnMouseUp", function() f:StopMovingOrSizing() end) -- Ticker -- Runs on its own always-shown, parentless frame rather than on the -- tracker window: WoW doesn't run OnUpdate on hidden frames, so with the -- ticker on the window, closing it (X, minimap button, /ss, or "hide when -- not in party/raid") stopped expiry warnings, expired alerts and -- announcements, clearing expired stones, the soulstone cooldown check and -- the periodic resync. No parent also keeps it running when the UI is -- hidden with Alt+Z. local tick = 0 local ssCdTick = 0 local autoSyncTick = 0 local flashTime = 0 local flashDir = 1 local ticker = m.ticker or CreateFrame("Frame", "SoulstoneTrackerTicker") m.ticker = ticker ticker:Show() ticker:SetScript("OnUpdate", function() tick = tick + arg1 if tick >= 1 then tick = 0 m.clearExpired() m.refresh() end ssCdTick = ssCdTick + arg1 if ssCdTick >= 15 then ssCdTick = 0 m.updateSoulstoneCooldown() end autoSyncTick = autoSyncTick + arg1 if autoSyncTick >= 300 then autoSyncTick = 0 -- Periodic background resync (every 5 min) to catch any drift -- over a long raid session -- same logic as /ss resync, but -- silent (no chat message) since this runs automatically. local channel = GetNumRaidMembers() > 0 and "RAID" or (GetNumPartyMembers() > 0 and "PARTY" or nil) if channel then m.rebroadcastKnownData() SendAddonMessage(ADDON_MSG_PREFIX, "SYNCREQ", channel) end end -- the last-minute title pulse is only worth animating when visible if not f:IsVisible() then return end local shouldFlash = false local now = time() for _, data in pairs(m.stones) do if data.expires - now <= 60 and data.expires - now > 0 then shouldFlash = true break end end if shouldFlash then flashTime = flashTime + arg1 * flashDir if flashTime >= 1 then flashDir = -1 elseif flashTime <= 0 then flashDir = 1 end if f.titleGlow then f.titleGlow:SetVertexColor(1, 0.15, 0.1, 0.8 * flashTime) else local r = 0.22 + (0.5 * flashTime) local g = 0.08 * (1 - flashTime) local b = 0.38 * (1 - flashTime) f.titleBar:SetBackdropColor(r, g, b, 1) end else flashTime = 0 flashDir = 1 if f.titleGlow then f.titleGlow:SetVertexColor(1, 0.15, 0.1, 0) else f.titleBar:SetBackdropColor(0.22, 0.08, 0.38, 1) end end end) -- Redraw as soon as the window becomes visible again, so it never shows -- stale times after being closed (m.refresh skips work while hidden). f:SetScript("OnShow", function() m.refresh() end) -- Restore position if SoulstoneTrackerDB.position then local p = SoulstoneTrackerDB.position f:ClearAllPoints() f:SetPoint(p.point, UIParent, p.rpoint, p.x, p.y) else f:SetPoint("Center", UIParent, "Center", 300, 0) end -- Restore scale if SoulstoneTrackerDB.scale then f:SetScale(SoulstoneTrackerDB.scale) end -- Restore lock state if SoulstoneTrackerDB.locked then m.locked = true end m.frame = f m.repositionColumns() -- Apply lock state after m.frame is set f.updateLock() m.refresh() end function m.refresh() if not m.frame then return end -- Purely visual: skip while the window is closed (or the UI is hidden -- with Alt+Z). The window's OnShow redraws it the moment it reappears, -- and warnings / expiry keep running from the ticker regardless. if not m.frame:IsVisible() then return end local f = m.frame local list = {} for target, data in pairs(m.stones) do table.insert(list, { target=target, caster=data.caster, expires=data.expires, castTime=data.castTime or 0, unknownExpiry=data.unknownExpiry }) end table.sort(list, function(a,b) return a.expires < b.expires end) local count = table.getn(list) local missing = getWarlocksMissingStones() if table.getn(missing) > 0 and (GetNumRaidMembers() > 0 or GetNumPartyMembers() > 0) then f.title:SetText((f.theme or theme()).titleHex.."Soulstone Tracker|r |cffff7c0a["..table.getn(missing).." available]|r") else f.title:SetText((f.theme or theme()).titleHex.."Soulstone Tracker v1.5|r") end if count == 0 then f.empty:Show() else f.empty:Hide() end for i = 1, MAX_ROWS do local row = f.rows[i] local data = list[i] if data then row.caster:SetText(classColorName(data.caster, "|cffa050ff")) row.arrow:SetText("->") row.target:SetText(classColorName(data.target, "|cffffffff")) if data.unknownExpiry then row.timer:SetText("|cff888888~"..formatTime(timeLeft(data.expires)).." ?|r") else row.timer:SetText(formatTime(timeLeft(data.expires))) end row.bg:Show() row.data = data else row.caster:SetText("") row.arrow:SetText("") row.target:SetText("") row.timer:SetText("") row.bg:Hide() if row.hitbox.hl then row.hitbox.hl:Hide() end row.data = nil end end local rows = math.max(1, count) -- Height is managed by refreshBanish which accounts for all sections m.refreshBanish() end -- Auto-shows/hides the frame based on party/raid membership, if the -- "hideWhenSolo" setting is enabled. Never fights a manual close -- if the -- user explicitly hid the frame (X button), this won't force it back open. function m.updateGroupVisibility() if not m.frame then return end if not cfg("hideWhenSolo") then return end if SoulstoneTrackerDB.hidden then return end local inGroup = GetNumRaidMembers() > 0 or GetNumPartyMembers() > 0 if inGroup then if not m.frame:IsShown() then m.frame:Show() end else if m.frame:IsShown() then m.frame:Hide() end end end -- Placeholders shown when a warlock has no curse / no banish mark local EMPTY_CURSE_ICON = "Interface\\Icons\\Spell_Shadow_DeathCoil" local EMPTY_BANISH_ICON = "Interface\\Icons\\Spell_Shadow_Cripple" -- Grey an icon out (desaturated where the client supports it, dimmed either -- way), or restore it to full color. local function setGreyed(tex, grey) if grey then if tex.SetDesaturated then tex:SetDesaturated(1) end tex:SetVertexColor(0.6, 0.6, 0.6) tex:SetAlpha(0.55) else if tex.SetDesaturated then tex:SetDesaturated(nil) end tex:SetVertexColor(1, 1, 1) tex:SetAlpha(1) end end function m.refreshBanish() if not m.frame then return end -- same as m.refresh: nothing to draw while hidden. (Joining or leaving a -- group still shows/hides the window: the roster events call -- updateGroupVisibility directly.) if not m.frame:IsVisible() then return end local f = m.frame m.applyFrameAlpha(f) m.updateGroupVisibility() local SEC_ROW_H = 18 local HEADER_H2 = 10 local FALLBACK_ICON = "Interface\\Icons\\Spell_Shadow_UnholyStrength" -- Build warlock list local warlocks = {} local playerName = UnitName("player") if playerName and playerIsWarlock() then table.insert(warlocks, playerName) end local numRaid = GetNumRaidMembers() if numRaid > 0 then for i = 1, numRaid do local name, _, _, _, class = GetRaidRosterInfo(i) if name and name ~= playerName and class == "Warlock" then table.insert(warlocks, name) end end else for i = 1, GetNumPartyMembers() do local name = UnitName("party"..i) if name and UnitClass("party"..i) == "Warlock" then table.insert(warlocks, name) end end end -- Build reverse curse lookup: warlock name -> shortName local warlockCurse = {} for shortName, data in pairs(m.curses) do if data.caster then warlockCurse[data.caster] = shortName end end -- Count stones for base Y local stoneCount = 0 for _ in pairs(m.stones) do stoneCount = stoneCount + 1 end local stoneRows = math.max(1, stoneCount) local baseY = -(TITLE_H + HEADER_H + stoneRows * ROW_H + 6) local curY = baseY local warlockCount = table.getn(warlocks) local T = f.theme or theme() local IN = T.inset if not f.warlockRows then -- Frame height only f:SetHeight(TITLE_H + HEADER_H + stoneRows * ROW_H + 6 + T.padBottom) return end if warlockCount > 0 and cfg("showCurseSection") then -- Divider if T.classic then -- gold label, then a silver rule trailing off to the right of it curY = curY - 4 f.curseHeader:ClearAllPoints() f.curseHeader:SetPoint("TopLeft", f, "TopLeft", 8, curY) f.curseHeader:Show() local ruleX = 8 + math.ceil(f.curseHeader:GetStringWidth() or 150) + 6 f.curseDivider:ClearAllPoints() f.curseDivider:SetPoint("TopLeft", f, "TopLeft", ruleX, curY - 6) f.curseDivider:SetPoint("TopRight", f, "TopRight", -(IN + 4), curY - 6) f.curseDivider:Show() curY = curY - 14 else f.curseDivider:ClearAllPoints() f.curseDivider:SetPoint("TopLeft", f, "TopLeft", 1, curY) f.curseDivider:SetPoint("TopRight", f, "TopRight", -1, curY) f.curseDivider:Show() -- Header inline on divider f.curseHeader:ClearAllPoints() f.curseHeader:SetPoint("TopLeft", f, "TopLeft", 8, curY + 5) f.curseHeader:Show() curY = curY - 2 end for i = 1, 5 do local row = f.warlockRows[i] local wName = warlocks[i] if row then row.warlockName = wName if wName then -- Bg row.bg:ClearAllPoints() row.bg:SetPoint("TopLeft", f, "TopLeft", IN, curY) row.bg:SetPoint("TopRight", f, "TopRight", -IN, curY) row.bg:SetHeight(SEC_ROW_H) row.bg:Show() -- Fixed-width columns anchored from the right edge, so -- name/curse/banish can never overlap regardless of -- frame width (the old percentage-based layout had a -- built-in overlap zone between the name and curse -- columns). Banish only needs its 16px icon, curse -- needs icon+3-letter label, name gets whatever's left. local BANISH_COL_W = 44 local CURSE_COL_W = 52 -- Prefer a compact, close-to-name position; only fall -- back to the right-edge-anchored math (which guarantees -- no overlap) when the frame is narrow enough to need it. local compactCurseColX = 175 local curseColX = math.min(compactCurseColX, f:GetWidth() - BANISH_COL_W - CURSE_COL_W) -- Name row.nameTxt:ClearAllPoints() row.nameTxt:SetPoint("TopLeft", f, "TopLeft", 8, curY - 2) row.nameTxt:SetWidth(curseColX - 12) local nameStr = classColorName(wName, "|cffa050ff") if m.shardCounts[wName] then nameStr = nameStr.." |cffcc66ff("..m.shardCounts[wName]..")|r" end local readyAt = m.ssCooldowns[wName] if readyAt and readyAt > time() then local mins = math.ceil((readyAt - time()) / 60) nameStr = nameStr.." |cffff6666[SS "..mins.."m]|r" elseif readyAt then nameStr = nameStr.." |cff00ff00[SS rdy]|r" end row.nameTxt:SetText(nameStr) row.nameTxt:Show() -- Curse info (fixed-width column) local shortName = warlockCurse[wName] if shortName then row.curseIcon:ClearAllPoints() row.curseIcon:SetPoint("TopLeft", f, "TopLeft", curseColX, curY - 2) row.curseIcon:SetTexture(CURSE_ICONS[shortName] or "Interface\\Icons\\Spell_Shadow_Curse") row.curseIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92) setGreyed(row.curseIcon, false) row.curseIcon:Show() row.curseLbl:ClearAllPoints() row.curseLbl:SetPoint("TopLeft", f, "TopLeft", curseColX + 18, curY - 2) row.curseLbl:SetWidth(CURSE_COL_W - 18) row.curseLbl:SetText((CURSE_COLORS[shortName] or "|cffffffff")..shortName.."|r") row.curseLbl:Show() else -- no curse: greyed-out Death Coil placeholder row.curseIcon:ClearAllPoints() row.curseIcon:SetPoint("TopLeft", f, "TopLeft", curseColX, curY - 2) row.curseIcon:SetTexture(EMPTY_CURSE_ICON) row.curseIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92) setGreyed(row.curseIcon, true) row.curseIcon:Show() row.curseLbl:SetText("") row.curseLbl:Hide() end -- Curse override hitbox (covers icon+label area, click to manually set) row.curseBtn:ClearAllPoints() row.curseBtn:SetPoint("TopLeft", f, "TopLeft", curseColX - 2, curY - 2) row.curseBtn:SetWidth(CURSE_COL_W) row.curseBtn:Show() -- Banish icon button (fixed-width column, right edge) row.banishBtn:ClearAllPoints() row.banishBtn:SetPoint("TopRight", f, "TopRight", -28, curY - 1) row.banishBtn:Show() local iconIdx = m.banish[wName] if iconIdx and RAID_ICONS[iconIdx] then local c = RAID_ICONS[iconIdx].coords row.banishTex:ClearAllPoints() row.banishTex:SetAllPoints(row.banishBtn) row.banishTex:SetTexture("Interface\\TargetingFrame\\UI-RaidTargetingIcons") row.banishTex:SetTexCoord(c[1], c[2], c[3], c[4]) setGreyed(row.banishTex, false) row.banishLbl:SetText("") else -- no mark: small greyed-out Banish placeholder row.banishTex:ClearAllPoints() row.banishTex:SetWidth(14) row.banishTex:SetHeight(14) row.banishTex:SetPoint("Center", row.banishBtn, "Center", 0, 0) row.banishTex:SetTexture(EMPTY_BANISH_ICON) row.banishTex:SetTexCoord(0.08, 0.92, 0.08, 0.92) setGreyed(row.banishTex, true) row.banishLbl:SetText("") end curY = curY - SEC_ROW_H else row.warlockName = nil row.bg:Hide() row.nameTxt:Hide() row.curseIcon:Hide() row.curseLbl:Hide() row.curseBtn:Hide() row.banishBtn:Hide() end end end else f.curseDivider:Hide() f.curseHeader:Hide() for i = 1, 5 do local row = f.warlockRows[i] if row then row.bg:Hide() row.nameTxt:Hide() row.curseIcon:Hide() row.curseLbl:Hide() row.curseBtn:Hide() row.banishBtn:Hide() end end end -- Unattributed curses (found via target-scan, no known caster) local unknownList = {} for shortName, data in pairs(m.curses) do if data.caster == "Unknown" then table.insert(unknownList, (CURSE_COLORS[shortName] or "|cffffffff")..shortName.."|r") end end if table.getn(unknownList) > 0 and cfg("showCurseSection") then f.unknownCurseLine:ClearAllPoints() f.unknownCurseLine:SetPoint("TopLeft", f, "TopLeft", 6, curY - 2) f.unknownCurseLine:SetText("|cff888888On target, unattributed:|r "..table.concat(unknownList, ", ")) f.unknownCurseLine:Show() curY = curY - 14 else f.unknownCurseLine:Hide() end -- Resize local totalH = TITLE_H + HEADER_H + (stoneRows * ROW_H) + 6 + math.abs(curY - baseY) + T.padBottom f:SetHeight(totalH) end ------------------------------------------------------------------------------- -- Curse tracking ------------------------------------------------------------------------------- local lastCurseCaster = nil local SHORT_NAMES = { ["Curse of the Elements"] = "CoE", ["Curse of Shadow"] = "CoS", ["Curse of Recklessness"] = "CoR", ["Curse of Weakness"] = "CoW", } -- Reverse lookup: shortName -> fullName, so SPELL_GO_SELF (which gives us a -- spellID, not text) can still feed detectCurse()'s existing message-based -- logic unchanged. local FULL_NAMES = {} for full, short in pairs(SHORT_NAMES) do FULL_NAMES[short] = full end -- Curse spellIDs confirmed live via SPELL_GO_SELF on this server (Turtle/ -- OctoWow's client lacks GetSpellInfo, so these can't be looked up -- generically -- only whichever ranks are actually reported get added). -- If a warlock with an untracked rank casts a curse, add their reported ID -- here rather than falling back to guessing. local CURSE_SPELL_IDS = { [702] = "CoW", -- Curse of Weakness [17937] = "CoS", -- Curse of Shadow [11722] = "CoE", -- Curse of the Elements [11717] = "CoR", -- Curse of Recklessness } function m.detectCurse(msg, caster) for fullName, shortName in pairs(SHORT_NAMES) do if string.find(msg, fullName) then local casterName = caster or UnitName("player") -- Only update if: -- 1. This curse has no caster yet, OR -- 2. The same warlock is casting it (already assigned to them), OR -- 3. The caster previously had a different curse (they switched) local existing = m.curses[shortName] local casterPrevCurse = nil for sn, data in pairs(m.curses) do if data.caster == casterName then casterPrevCurse = sn break end end local shouldUpdate = false if not existing then -- New curse not yet tracked shouldUpdate = true elseif existing.caster == casterName then -- Same warlock recasting the same curse -- nothing actually -- changed (no timestamp is tracked per-curse), so skip the -- announce/broadcast entirely to avoid repeat noise. shouldUpdate = false end -- If another warlock already has this curse slot, don't overwrite -- Clear the caster's previous curse whenever they're switching to -- a different one, regardless of whether the new curse is -- already tracked -- this used to only run in one of the three -- branches above, so switching to an untracked curse left the -- old assignment stuck forever and never told other clients. if casterPrevCurse and casterPrevCurse ~= shortName then m.curses[casterPrevCurse] = nil shouldUpdate = true local channel = GetNumRaidMembers() > 0 and "RAID" or (GetNumPartyMembers() > 0 and "PARTY" or nil) if channel then SendAddonMessage(ADDON_MSG_PREFIX, "CURSEREM:"..casterPrevCurse, channel) end end if shouldUpdate then m.curses[shortName] = { caster = casterName } say((CURSE_COLORS[shortName] or "|cffffffff")..shortName.."|r detected — cast by |cffa050ff"..casterName.."|r") m.logHistory(casterName.." cast "..shortName) if cfg("announceCurse") then sendGroup("[Curse] "..casterName.." is on "..shortName.." ("..fullName..")") end local channel = GetNumRaidMembers() > 0 and "RAID" or (GetNumPartyMembers() > 0 and "PARTY" or nil) if channel then SendAddonMessage(ADDON_MSG_PREFIX, "CURSE:"..shortName..":"..casterName, channel) -- Addon messages over RAID scope can occasionally get -- dropped (a known vanilla-era limitation, not something -- fixable in code) -- send a redundant follow-up after a -- short delay so a single lost packet doesn't leave -- other clients out of sync. Idempotent to repeat. local resendTimer = CreateFrame("Frame") local elapsed = 0 resendTimer:SetScript("OnUpdate", function() elapsed = elapsed + arg1 if elapsed >= 1.5 then resendTimer:SetScript("OnUpdate", nil) local ch = GetNumRaidMembers() > 0 and "RAID" or (GetNumPartyMembers() > 0 and "PARTY" or nil) if ch and m.curses[shortName] and m.curses[shortName].caster == casterName then SendAddonMessage(ADDON_MSG_PREFIX, "CURSE:"..shortName..":"..casterName, ch) end end end) end m.refreshBanish() end return true end end return false end function m.removeCurse(msg) for fullName, shortName in pairs(SHORT_NAMES) do if string.find(msg, fullName) then -- Only remove locally if THIS client's own player is the one -- who cast it. AURA_GONE events aren't reliably self-scoped -- -- they can fire from fade/replace text visible near a shared -- target regardless of who's tracking it, which was wiping out -- another warlock's correctly-synced curse entry. Removal for -- another warlock's curse should come from their own client's -- sync instead, not from ambient combat text here. local existing = m.curses[shortName] if existing and existing.caster == UnitName("player") then m.curses[shortName] = nil m.refresh() end return true end end return false end ------------------------------------------------------------------------------- -- Settings Panel ------------------------------------------------------------------------------- function m.toggleSettings() if m.settings and m.settings:IsVisible() then m.closeSettings() return end if not m.settings then m.createSettings() end m.settings:Show() m.refreshSettings() end function m.createSettings() local T = theme() -- SW is the content width; the classic dialog border adds PADX each side local PADX = T.classic and 12 or 0 local SW, SH = 320, 420 local s = CreateFrame("Frame", "SoulstoneTrackerSettings", UIParent) s:SetWidth(SW + 2 * PADX) s:SetHeight(SH) s:SetFrameStrata("HIGH") if T.classic then -- the stock Blizzard dialog box: marbled dark background, gold-trimmed border s:SetBackdrop({ bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", tile = true, tileSize = 32, edgeSize = 32, insets = { left=11, right=12, top=12, bottom=11 } }) else s:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1, insets = { left=1, right=1, top=1, bottom=1 } }) s:SetBackdropColor(0.05, 0.05, 0.08, 0.98) s:SetBackdropBorderColor(0.4, 0.2, 0.6, 1) end s:SetMovable(true) s:EnableMouse(true) s:RegisterForDrag("LeftButton") s:SetScript("OnDragStart", function() this:StartMoving() end) s:SetScript("OnDragStop", function() this:StopMovingOrSizing() end) s:SetClampedToScreen(true) -- Position next to main frame if m.frame then s:SetPoint("TopLeft", m.frame, "TopRight", 4, 0) else s:SetPoint("Center", UIParent, "Center", 0, 0) end if T.classic then -- gold title plate across the top edge, like the stock option windows. -- Built from three slices of the stock header art (left cap, middle, -- right cap) so the middle stretches to fit the title without -- squashing the end caps. local HEADER_TEX = "Interface\\DialogFrame\\UI-DialogBox-Header" local title = s:CreateFontString(nil, "OVERLAY", "GameFontNormal") title:SetText("Soulstone Tracker Settings") local mid = s:CreateTexture(nil, "ARTWORK") mid:SetTexture(HEADER_TEX) mid:SetTexCoord(0.31, 0.67, 0, 0.63) mid:SetHeight(40) mid:SetWidth(math.ceil(title:GetStringWidth() or 170) + 16) mid:SetPoint("Top", s, "Top", 0, 12) local capL = s:CreateTexture(nil, "ARTWORK") capL:SetTexture(HEADER_TEX) capL:SetTexCoord(0.21, 0.31, 0, 0.63) capL:SetWidth(30) capL:SetHeight(40) capL:SetPoint("Right", mid, "Left", 0, 0) local capR = s:CreateTexture(nil, "ARTWORK") capR:SetTexture(HEADER_TEX) capR:SetTexCoord(0.67, 0.77, 0, 0.63) capR:SetWidth(30) capR:SetHeight(40) capR:SetPoint("Left", mid, "Right", 0, 0) title:SetPoint("Top", mid, "Top", 0, -14) local closeBtn = CreateFrame("Button", nil, s, "UIPanelCloseButton") closeBtn:SetPoint("TopRight", s, "TopRight", -5, -5) closeBtn:SetFrameLevel(s:GetFrameLevel() + 5) closeBtn:SetScript("OnClick", function() m.closeSettings() end) else -- Title bar local titleBar = CreateFrame("Frame", nil, s) titleBar:SetPoint("TopLeft", s, "TopLeft", 1, -1) titleBar:SetPoint("TopRight", s, "TopRight", -1, -1) titleBar:SetHeight(22) titleBar:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8" }) titleBar:SetBackdropColor(0.22, 0.08, 0.38, 1) local grad = titleBar:CreateTexture(nil, "OVERLAY") grad:SetAllPoints(titleBar) grad:SetTexture("Interface\\QuestFrame\\UI-QuestTitleHighlight") grad:SetBlendMode("ADD") grad:SetVertexColor(0.2, 0.05, 0.4, 0.4) local title = titleBar:CreateFontString(nil, "OVERLAY") title:SetFont("Fonts\\FRIZQT__.TTF", 11, "OUTLINE") title:SetPoint("Left", titleBar, "Left", 6, 0) title:SetText("|cffffd700Soulstone Tracker — Settings|r") local closeBtn = CreateFrame("Button", nil, titleBar) closeBtn:SetWidth(18) closeBtn:SetHeight(18) closeBtn:SetPoint("Right", titleBar, "Right", -4, 0) local closeTxt = closeBtn:CreateFontString(nil, "OVERLAY") closeTxt:SetFont("Fonts\\FRIZQT__.TTF", 13, "OUTLINE") closeTxt:SetAllPoints(closeBtn) closeTxt:SetJustifyH("Center") closeTxt:SetJustifyV("Middle") closeTxt:SetText("|cffaaaaaaX|r") closeBtn:SetScript("OnEnter", function() closeTxt:SetText("|cffffffffX|r") end) closeBtn:SetScript("OnLeave", function() closeTxt:SetText("|cffaaaaaaX|r") end) closeBtn:SetScript("OnClick", function() m.closeSettings() end) end -- left / right content edges inside the border local LX = 10 + PADX local RX = -(10 + PADX) -- shared click handling for both checkbox styles local function onToggle(key, newVal) setCfg(key, newVal) -- the theme is applied when the windows are built if key == "classicTheme" then s.needsReload = true say("Theme changes apply after a UI reload.") end if m.frame then m.refreshBanish() end end -- Helper to create a toggle checkbox row local function makeToggle(parent, label, key, yOff) if T.classic then -- stock gold-trimmed options checkbox with a white label local name = "SSTrackerCheck_"..key local cb = CreateFrame("CheckButton", name, parent, "OptionsCheckButtonTemplate") cb:SetWidth(22) cb:SetHeight(22) cb:SetPoint("TopLeft", parent, "TopLeft", LX - 4, yOff + 4) cb:SetHitRectInsets(0, -180, 0, 0) local text = getglobal(name.."Text") text:SetText(label) text:SetTextColor(1, 1, 1) cb.key = key cb:SetScript("OnClick", function() local on = this:GetChecked() and true or false if on then PlaySound("igMainMenuOptionCheckBoxOn") else PlaySound("igMainMenuOptionCheckBoxOff") end onToggle(this.key, on) end) cb:SetChecked(cfg(key)) return cb end local cb = CreateFrame("Button", nil, parent) cb:SetWidth(14) cb:SetHeight(14) cb:SetPoint("TopLeft", parent, "TopLeft", LX, yOff) local box = cb:CreateTexture(nil, "ARTWORK") box:SetAllPoints(cb) box:SetTexture("Interface\\Buttons\\WHITE8X8") box:SetVertexColor(0.15, 0.15, 0.2, 1) local check = cb:CreateTexture(nil, "OVERLAY") check:SetAllPoints(cb) check:SetTexture("Interface\\Buttons\\UI-CheckBox-Check") check:SetVertexColor(0.6, 0.3, 1, 1) local lbl = parent:CreateFontString(nil, "OVERLAY") lbl:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE") lbl:SetPoint("Left", cb, "Right", 6, 0) lbl:SetText(label) lbl:SetTextColor(0.85, 0.85, 0.85, 1) cb.check = check cb.key = key cb:SetScript("OnClick", function() local cur = cfg(this.key) if not cur then this.check:Show() else this.check:Hide() end onToggle(this.key, not cur) end) cb:SetScript("OnEnter", function() box:SetVertexColor(0.25, 0.15, 0.35, 1) end) cb:SetScript("OnLeave", function() box:SetVertexColor(0.15, 0.15, 0.2, 1) end) return cb end -- Helper for section header local function makeHeader(parent, text, yOff) if T.classic then -- gold label with a silver rule trailing off to its right local hdr = parent:CreateFontString(nil, "OVERLAY", "GameFontNormal") hdr:SetPoint("TopLeft", parent, "TopLeft", LX - 2, yOff + 6) hdr:SetText(text) local rule = makeTrailingRule(parent, T) local rx = LX - 2 + math.ceil(hdr:GetStringWidth() or 100) + 6 rule:SetPoint("TopLeft", parent, "TopLeft", rx, yOff) rule:SetPoint("TopRight", parent, "TopRight", RX, yOff) return hdr end local div = parent:CreateTexture(nil, "ARTWORK") div:SetHeight(1) div:SetPoint("TopLeft", parent, "TopLeft", 6, yOff) div:SetPoint("TopRight", parent, "TopRight", -6, yOff) div:SetTexture(0.3, 0.15, 0.5, 0.8) local hdr = parent:CreateFontString(nil, "OVERLAY") hdr:SetFont("Fonts\\FRIZQT__.TTF", 9, "OUTLINE") hdr:SetTextColor(0.7, 0.5, 1, 1) hdr:SetPoint("TopLeft", parent, "TopLeft", 10, yOff + 5) hdr:SetText(string.upper(text)) return hdr end -- label + value line above a slider, styled per theme local function sliderLabels(parent, label, yOff) local lbl = parent:CreateFontString(nil, "OVERLAY") local valLbl = parent:CreateFontString(nil, "OVERLAY") if T.classic then lbl:SetFontObject(GameFontHighlightSmall) valLbl:SetFontObject(GameFontNormalSmall) else lbl:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE") lbl:SetTextColor(0.85, 0.85, 0.85, 1) valLbl:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE") valLbl:SetTextColor(0.6, 0.3, 1, 1) end lbl:SetPoint("TopLeft", parent, "TopLeft", LX, yOff) lbl:SetText(label) valLbl:SetPoint("TopRight", parent, "TopRight", RX, yOff) valLbl:SetJustifyH("Right") return lbl, valLbl end -- Helper for number input local function makeSlider(parent, label, key, minV, maxV, step, yOff) local lbl, valLbl = sliderLabels(parent, label, yOff) local sliderName = "SSTracker_Slider_"..key local slider = CreateFrame("Slider", sliderName, parent, "OptionsSliderTemplate") slider:SetWidth(SW - 30) slider:SetHeight(16) slider:SetPoint("TopLeft", parent, "TopLeft", LX, yOff - 14) slider:SetMinMaxValues(minV, maxV) slider:SetValueStep(step) slider:SetValue(cfg(key)) getglobal(sliderName.."Low"):SetText(tostring(minV)) getglobal(sliderName.."High"):SetText(tostring(maxV)) local sliderText = getglobal(sliderName.."Text") sliderText:SetText(tostring(cfg(key))) if T.classic then sliderText:Hide() else sliderText:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE") end slider.key = key slider.valLbl = valLbl slider:SetScript("OnValueChanged", function() local v = math.floor(this:GetValue() / step + 0.5) * step setCfg(this.key, v) getglobal(this:GetName().."Text"):SetText(tostring(v)) this.valLbl:SetText(tostring(v)) end) valLbl:SetText(tostring(cfg(key))) return slider end -- Dedicated slider for frameAlpha specifically: displays as a 0-100% -- percentage (more intuitive) while storing/reading the underlying -- 0-1 float config value, and applies live rather than requiring reopen. local function makeAlphaSlider(parent, label, yOff) local lbl, valLbl = sliderLabels(parent, label, yOff) local sliderName = "SSTracker_Slider_frameAlpha" local slider = CreateFrame("Slider", sliderName, parent, "OptionsSliderTemplate") slider:SetWidth(SW - 30) slider:SetHeight(16) slider:SetPoint("TopLeft", parent, "TopLeft", LX, yOff - 14) slider:SetMinMaxValues(10, 100) slider:SetValueStep(5) local startPct = math.floor(cfg("frameAlpha") * 100 + 0.5) slider:SetValue(startPct) getglobal(sliderName.."Low"):SetText("10%") getglobal(sliderName.."High"):SetText("100%") local sliderText = getglobal(sliderName.."Text") sliderText:SetText(startPct.."%") if T.classic then sliderText:Hide() else sliderText:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE") end slider.valLbl = valLbl slider:SetScript("OnValueChanged", function() local pct = math.floor(this:GetValue() / 5 + 0.5) * 5 setCfg("frameAlpha", pct / 100) getglobal(this:GetName().."Text"):SetText(pct.."%") this.valLbl:SetText(pct.."%") m.refreshBanish() end) valLbl:SetText(startPct.."%") return slider end -- Helper for action button local btnCount = 0 local function makeButton(parent, label, onClick, yOff) if T.classic then -- stock red-and-gold panel button btnCount = btnCount + 1 local btn = CreateFrame("Button", "SSTrackerSettingsButton"..btnCount, parent, "UIPanelButtonTemplate") btn:SetWidth(SW - 20) btn:SetHeight(22) btn:SetPoint("TopLeft", parent, "TopLeft", LX, yOff) btn:SetText(label) btn:SetScript("OnClick", onClick) return btn end local btn = CreateFrame("Button", nil, parent) btn:SetWidth(SW - 20) btn:SetHeight(20) btn:SetPoint("TopLeft", parent, "TopLeft", LX, yOff) btn:SetBackdrop({ bgFile="Interface\\Buttons\\WHITE8X8", edgeFile="Interface\\Buttons\\WHITE8X8", edgeSize=1, insets={left=1,right=1,top=1,bottom=1} }) btn:SetBackdropColor(0.15, 0.08, 0.25, 1) btn:SetBackdropBorderColor(0.4, 0.2, 0.6, 0.8) local txt = btn:CreateFontString(nil, "OVERLAY") txt:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE") txt:SetAllPoints(btn) txt:SetJustifyH("Center") txt:SetJustifyV("Middle") txt:SetText(label) txt:SetTextColor(0.85, 0.85, 0.85, 1) btn:SetScript("OnEnter", function() btn:SetBackdropColor(0.25, 0.12, 0.4, 1) end) btn:SetScript("OnLeave", function() btn:SetBackdropColor(0.15, 0.08, 0.25, 1) end) btn:SetScript("OnClick", onClick) return btn end -- Store checkboxes for refresh s.checks = {} s.needsReload = false -- classic: start below the gold header plate, and give each section -- header a little air above and below local y = T.classic and -34 or -28 local HGAP = T.classic and 6 or 0 -- NOTIFICATIONS section y = y - HGAP makeHeader(s, "Notifications", y) y = y - 14 - HGAP s.checks["warnOnCast"] = makeToggle(s, "Play sound on soulstone cast", "warnOnCast", y) y = y - 18 s.checks["warnFiveMin"] = makeToggle(s, "Warn when soulstone nears expiry", "warnFiveMin", y) y = y - 18 s.checks["warnOneMin"] = makeToggle(s, "Warn at final countdown", "warnOneMin", y) y = y - 18 s.checks["warnExpired"] = makeToggle(s, "Alert when soulstone expires", "warnExpired", y) y = y - 18 s.checks["warnNoStones"] = makeToggle(s, "Alert when no stones remain", "warnNoStones", y) y = y - 18 s.checks["warnWarlocks"] = makeToggle(s, "List warlocks with available SS", "warnWarlocks", y) y = y - 24 -- SOUNDS section y = y - HGAP makeHeader(s, "Sounds", y) y = y - 14 - HGAP s.checks["muteSounds"] = makeToggle(s, "Mute all sounds", "muteSounds", y) y = y - 24 -- RAID CHAT section y = y - HGAP makeHeader(s, "Raid Chat Announcements", y) y = y - 14 - HGAP s.checks["muteChat"] = makeToggle(s, "Mute all raid chat", "muteChat", y) y = y - 18 s.checks["announceOnCast"] = makeToggle(s, "Announce soulstone casts", "announceOnCast", y) y = y - 18 s.checks["announceWarnings"] = makeToggle(s, "Announce expiry warnings", "announceWarnings", y) y = y - 18 s.checks["announceExpired"] = makeToggle(s, "Announce when stone expires", "announceExpired", y) y = y - 18 s.checks["announceCurse"] = makeToggle(s, "Announce curse assignments", "announceCurse", y) y = y - 18 s.checks["announceBanish"] = makeToggle(s, "Announce banish assignments", "announceBanish", y) y = y - 24 -- DISPLAY section y = y - HGAP makeHeader(s, "Display", y) y = y - 14 - HGAP s.checks["showCurseSection"] = makeToggle(s, "Show curse & banish section", "showCurseSection", y) y = y - 24 s.checks["hideWhenSolo"] = makeToggle(s, "Hide when not in party/raid", "hideWhenSolo", y) y = y - 24 s.checks["classicTheme"] = makeToggle(s, "Classic WoW look (needs reload)", "classicTheme", y) y = y - 24 s.alphaSlider = makeAlphaSlider(s, "Background transparency:", y) y = y - 46 -- TIMING section y = y - HGAP makeHeader(s, "Warning Thresholds", y) y = y - 14 - HGAP s.warnMinSlider = makeSlider(s, "First warning (minutes):", "warnMinutes", 1, 15, 1, y) y = y - (T.classic and 44 or 36) s.warnSecSlider = makeSlider(s, "Final warning (seconds):", "warnSeconds", 15, 120, 5, y) y = y - 40 -- ACTION BUTTONS y = y - HGAP makeHeader(s, "Actions", y) y = y - 14 - HGAP makeButton(s, "Clear all soulstones", function() m.stones = {} m.refresh() say("Cleared all soulstones.") end, y) y = y - 26 makeButton(s, "Clear curse assignments", function() m.curses = {} m.refreshBanish() say("Cleared curse assignments.") end, y) y = y - 26 makeButton(s, "Clear banish assignments", function() m.banish = {} m.refreshBanish() say("Cleared banish assignments.") end, y) y = y - 26 makeButton(s, "Scan group for soulstones", function() m.scanForSoulstones() end, y) y = y - 26 makeButton(s, "Scan target for curses", function() m.scanForCurses() end, y) y = y - 26 makeButton(s, "Force resync with group", function() local channel = GetNumRaidMembers() > 0 and "RAID" or (GetNumPartyMembers() > 0 and "PARTY" or nil) if not channel then say("Not in a group -- nothing to sync.") else m.rebroadcastKnownData() SendAddonMessage(ADDON_MSG_PREFIX, "SYNCREQ", channel) say("Resync requested -- rebroadcast your data and asked others to do the same.") end end, y) y = y - 26 makeButton(s, "Reset position & size", function() SoulstoneTrackerDB.position = nil SoulstoneTrackerDB.size = nil s.needsReload = true say("Position reset. Reload to apply.") end, y) s:SetHeight(math.abs(y) + 30 + (T.classic and 6 or 0)) s:Hide() m.settings = s end function m.refreshSettings() if not m.settings then return end local s = m.settings for key, cb in pairs(s.checks) do if cb.check then if cfg(key) then cb.check:Show() else cb.check:Hide() end elseif cb.SetChecked then cb:SetChecked(cfg(key)) end end if s.warnMinSlider then s.warnMinSlider:SetValue(cfg("warnMinutes")) getglobal(s.warnMinSlider:GetName().."Text"):SetText(tostring(cfg("warnMinutes"))) end if s.warnSecSlider then s.warnSecSlider:SetValue(cfg("warnSeconds")) getglobal(s.warnSecSlider:GetName().."Text"):SetText(tostring(cfg("warnSeconds"))) end if s.alphaSlider then local pct = math.floor(cfg("frameAlpha") * 100 + 0.5) s.alphaSlider:SetValue(pct) getglobal(s.alphaSlider:GetName().."Text"):SetText(pct.."%") end end ------------------------------------------------------------------------------- -- Events ------------------------------------------------------------------------------- -- Track last caster per target using addon messages -- When YOU cast soulstone, SPELLCAST_START fires, then CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS -- When OTHERS cast, we only get the buff gain message with no caster info -- Solution: broadcast via addon message with caster info, and use target unit scanning local lastCaster = nil local isSelfCast = false local eFrame = CreateFrame("Frame") eFrame:RegisterEvent("PLAYER_LOGIN") eFrame:RegisterEvent("PLAYER_LEAVING_WORLD") eFrame:RegisterEvent("PLAYER_ENTERING_WORLD") eFrame:RegisterEvent("PARTY_MEMBERS_CHANGED") eFrame:RegisterEvent("RAID_ROSTER_UPDATE") eFrame:RegisterEvent("PLAYER_TARGET_CHANGED") eFrame:RegisterEvent("BAG_UPDATE") eFrame:RegisterEvent("SPELLCAST_START") eFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS") eFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_BUFFS") eFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_PARTY_BUFFS") eFrame:RegisterEvent("CHAT_MSG_SPELL_AURA_GONE_SELF") eFrame:RegisterEvent("CHAT_MSG_SPELL_AURA_GONE_OTHER") eFrame:RegisterEvent("CHAT_MSG_COMBAT_FRIENDLY_DEATH") eFrame:RegisterEvent("CHAT_MSG_SPELL_HOSTILEPLAYER_BUFF") eFrame:RegisterEvent("CHAT_MSG_SPELL_SELF_DAMAGE") eFrame:RegisterEvent("CHAT_MSG_SPELL_CREATURE_VS_SELF_DAMAGE") eFrame:RegisterEvent("CHAT_MSG_SPELL_DAMAGESHIELDS_SELF") eFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE") eFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE") eFrame:RegisterEvent("CHAT_MSG_ADDON") -- Reliable curse self-detection via nampower, if available. SPELL_GO_SELF -- is guaranteed to only ever fire for spells the local player casts -- no -- ambiguous chat-text parsing, unlike the CHAT_MSG_SPELL_* fallback below. local hasNampower = (GetNampowerVersion ~= nil) if hasNampower then eFrame:RegisterEvent("SPELL_GO_SELF") eFrame:RegisterEvent("AURA_CAST_ON_OTHER") eFrame:RegisterEvent("AURA_CAST_ON_SELF") -- Unlike SPELL_GO_SELF, aura cast events default to OFF and must be -- explicitly enabled via this CVar. SetCVar("NP_EnableAuraCastEvents", 1) -- Fallback for auras without a spell effect component (which -- AURA_CAST_ON_OTHER/SELF won't catch) -- Soulstone Resurrection may be -- one of these, per nampower's own docs. eFrame:RegisterEvent("BUFF_ADDED_SELF") eFrame:RegisterEvent("BUFF_ADDED_OTHER") end eFrame:SetScript("OnEvent", function() if event == "PLAYER_LOGIN" then SoulstoneTrackerDB = SoulstoneTrackerDB or {} -- Load config if SoulstoneTrackerDB.cfg then m.cfg = SoulstoneTrackerDB.cfg end -- Off on non-warlock characters unless turned on for that character -- with /ss enable. Nothing is built and every event is dropped, so a -- disabled character costs nothing (and sends nothing to the group). SoulstoneTrackerDB.enabledChars = SoulstoneTrackerDB.enabledChars or {} if not playerIsWarlock() and not SoulstoneTrackerDB.enabledChars[charKey()] then m.disabled = true this:UnregisterAllEvents() -- tell each character once, so it isn't a mystery later SoulstoneTrackerDB.toldDisabled = SoulstoneTrackerDB.toldDisabled or {} if not SoulstoneTrackerDB.toldDisabled[charKey()] then SoulstoneTrackerDB.toldDisabled[charKey()] = true say("Off on this character (not a warlock). Type |cffffd700/ss enable|r to turn it on here.") end return end loadAll() m.createFrame() m.createMinimapButton() -- Baseline "ready" state -- purely event-driven now (no cooldown -- API works on this client), so without this the display would stay -- blank until the first soulstone cast is actually detected this -- session. If you're actually still on cooldown from before this -- reload, it'll incorrectly show ready until corrected by your next -- real cast -- an accepted limitation given no API can tell us. if playerIsWarlock() then m.ssCooldowns[UnitName("player")] = 0 end if SoulstoneTrackerDB.hidden then m.frame:Hide() else m.frame:Show() end if SoulstoneTrackerDB.locked then m.locked = true m.frame.updateLock() end m.updateGroupVisibility() m.refreshBanish() say("Loaded. Type |cffffd700/ss help|r for commands.") local t = CreateFrame("Frame") local e = 0 t:SetScript("OnUpdate", function() e = e + arg1 if e >= 2 then t:SetScript("OnUpdate", nil) m.scanForSoulstones() m.updateAndBroadcastShards() m.updateSoulstoneCooldown() -- Request sync from others with the addon local channel = GetNumRaidMembers() > 0 and "RAID" or (GetNumPartyMembers() > 0 and "PARTY" or nil) if channel then SendAddonMessage(ADDON_MSG_PREFIX, "SYNCREQ", channel) end end end) elseif event == "PLAYER_LEAVING_WORLD" then saveAll() elseif event == "PARTY_MEMBERS_CHANGED" or event == "RAID_ROSTER_UPDATE" then m.refreshBanish() m.updateGroupVisibility() -- Debounced scan for existing soulstones on newly-joined members -- -- without this, a stone already active on someone who just joined -- your group wouldn't be picked up until next login/zone change. -- Silent unless it actually finds something, and coalesces rapid -- roster churn (e.g. several people joining a raid in quick -- succession) into a single scan instead of one per event. if m.__rosterScanTimer then m.__rosterScanTimer:SetScript("OnUpdate", nil) end local t = CreateFrame("Frame") m.__rosterScanTimer = t local e = 0 t:SetScript("OnUpdate", function() e = e + arg1 if e >= 2 then t:SetScript("OnUpdate", nil) m.scanForSoulstones(true) -- Ask others with the addon to resend their accurate data -- -- this is what lets a scanned "Unknown"/estimated stone get -- corrected once the real caster's client responds. Without -- this, joining a raid mid-session (unlike login, which -- already does this) would leave scanned entries permanently -- estimated. local channel = GetNumRaidMembers() > 0 and "RAID" or (GetNumPartyMembers() > 0 and "PARTY" or nil) if channel then SendAddonMessage(ADDON_MSG_PREFIX, "SYNCREQ", channel) end end end) elseif event == "PLAYER_TARGET_CHANGED" then -- Debounced silent curse scan on the new target -- catches curses -- from warlocks who don't have the addon (best-effort, no caster -- attribution possible). Debounced since target changes can fire -- rapidly while tab-targeting. if m.__targetScanTimer then m.__targetScanTimer:SetScript("OnUpdate", nil) end local t2 = CreateFrame("Frame") m.__targetScanTimer = t2 local e2 = 0 t2:SetScript("OnUpdate", function() e2 = e2 + arg1 if e2 >= 0.5 then t2:SetScript("OnUpdate", nil) m.scanForCurses(true) end end) elseif event == "BAG_UPDATE" then -- Debounced since bag updates fire once per slot touched, and a -- single bag operation (looting, splitting stacks) can touch many. if m.__shardScanTimer then m.__shardScanTimer:SetScript("OnUpdate", nil) end local t3 = CreateFrame("Frame") m.__shardScanTimer = t3 local e3 = 0 t3:SetScript("OnUpdate", function() e3 = e3 + arg1 if e3 >= 1 then t3:SetScript("OnUpdate", nil) m.updateAndBroadcastShards() end end) elseif event == "PLAYER_ENTERING_WORLD" then if m.frame then local t = CreateFrame("Frame") local e = 0 t:SetScript("OnUpdate", function() e = e + arg1 if e >= 3 then t:SetScript("OnUpdate", nil) m.scanForSoulstones() end end) end elseif event == "SPELLCAST_START" then -- Only fires for player's own casts if arg1 and string.find(arg1, "Soulstone Resurrection") then lastCaster = UnitName("player") isSelfCast = true -- Broadcast that we're casting so others know the caster if GetNumRaidMembers() > 0 then SendAddonMessage(ADDON_MSG_PREFIX, "CASTING:"..UnitName("player")..">"..UnitName("target"), "RAID") elseif GetNumPartyMembers() > 0 then SendAddonMessage(ADDON_MSG_PREFIX, "CASTING:"..UnitName("player")..">"..UnitName("target"), "PARTY") end end -- Track curse caster (you casting) for fullName in pairs(SHORT_NAMES) do if arg1 and string.find(arg1, fullName) then lastCurseCaster = UnitName("player") break end end elseif event == "CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS" then -- "You gain Soulstone Resurrection (1)." - you received a soulstone -- Only apply locally if we're confident who cast it (lastCaster set -- via our own SPELLCAST_START, or received via the CASTING: sync). -- Otherwise skip rather than guessing "Unknown" and broadcasting a -- wrong/premature attribution that could race the real caster's own. if arg1 and string.find(arg1, "Soulstone Resurrection") and lastCaster then m.addStone(UnitName("player"), lastCaster, nil, not isSelfCast) if isSelfCast then m.startOwnSoulstoneCooldown() end lastCaster = nil end elseif event == "CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_BUFFS" or event == "CHAT_MSG_SPELL_PERIODIC_PARTY_BUFFS" then -- "Playername gains Soulstone Resurrection (1)." -- fallback for -- when BUFF_ADDED_OTHER doesn't fire/isn't available; usually -- already a no-op since lastCaster gets consumed by BUFF_ADDED_OTHER -- first when it fires. if arg1 then local suffix = " gains Soulstone Resurrection" local pos = string.find(arg1, suffix, 1, true) local target = pos and string.sub(arg1, 1, pos - 1) if target and target ~= "" and lastCaster then m.addStone(target, lastCaster, nil, not isSelfCast) if isSelfCast then m.startOwnSoulstoneCooldown() end lastCaster = nil end end elseif event == "CHAT_MSG_SPELL_AURA_GONE_SELF" then if arg1 and string.find(arg1, "Soulstone Resurrection") then m.removeStone(UnitName("player")) end m.removeCurse(arg1 or "") elseif event == "CHAT_MSG_SPELL_AURA_GONE_OTHER" then if arg1 then local _, _, target = string.find(arg1, "Soulstone Resurrection fades from (.+)%.") if target then m.removeStone(target) end m.removeCurse(arg1) end elseif event == "SPELL_GO_SELF" then -- itemId, spellID, casterGuid, targetGuid, castFlags, numTargetsHit, numTargetsMissed local spellID = arg2 local shortName = spellID and CURSE_SPELL_IDS[spellID] if shortName then -- Guaranteed self-scoped by nampower, so always safe to -- attribute directly to the local player. m.detectCurse(FULL_NAMES[shortName], UnitName("player")) end m.updateSoulstoneCooldown() elseif event == "AURA_CAST_ON_OTHER" or event == "AURA_CAST_ON_SELF" then -- spellId, casterGuid, targetGuid, effect, effectAuraName, effectAmplitude, effectMiscValue, durationMs, auraCapStatus if m.diagAura then local targetName = UnitName(arg3) or tostring(arg3) say("|cff00ff00[DIAG]|r "..event..": spellId="..tostring(arg1).." target="..tostring(targetName).." durationMs="..tostring(arg8)) end elseif event == "BUFF_ADDED_SELF" then -- guid, slot, spellId, stackCount, casterLevel (confirmed via live -- testing: casterLevel matched Waylock's actual level of 60). -- Precise soulstone detection: only trusted when we're already -- confident WE are the one mid-cast (lastCaster set via our own -- SPELLCAST_START) -- this event alone doesn't tell us who cast it, -- so blindly trusting it would reintroduce the same cross-warlock -- misattribution risk we already fixed for curses. if arg3 == SOULSTONE_SPELL_ID and lastCaster then m.addStone(UnitName("player"), lastCaster, time() + SOULSTONE_DURATION, not isSelfCast) if isSelfCast then m.startOwnSoulstoneCooldown() end lastCaster = nil end if m.diagAura then say("|cff00ff00[DIAG]|r BUFF_ADDED_SELF: a1="..tostring(arg1).." a2="..tostring(arg2).." a3="..tostring(arg3).." a4="..tostring(arg4).." a5="..tostring(arg5)) end elseif event == "BUFF_ADDED_OTHER" then -- guid, slot, spellId, stackCount, casterLevel if arg3 == SOULSTONE_SPELL_ID and lastCaster then local targetName = UnitName(arg1) if targetName then m.addStone(targetName, lastCaster, time() + SOULSTONE_DURATION, not isSelfCast) if isSelfCast then m.startOwnSoulstoneCooldown() end lastCaster = nil end end if m.diagAura then say("|cff00ff00[DIAG]|r BUFF_ADDED_OTHER: a1="..tostring(arg1).." a2="..tostring(arg2).." a3="..tostring(arg3).." a4="..tostring(arg4).." a5="..tostring(arg5)) end elseif event == "CHAT_MSG_SPELL_HOSTILEPLAYER_BUFF" or event == "CHAT_MSG_SPELL_SELF_DAMAGE" or event == "CHAT_MSG_SPELL_CREATURE_VS_SELF_DAMAGE" or event == "CHAT_MSG_SPELL_DAMAGESHIELDS_SELF" or event == "CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE" or event == "CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE" then -- When nampower is available, SPELL_GO_SELF above is the reliable -- source of truth for local curse casts, so this ambiguous -- chat-text fallback is skipped entirely to avoid the cross-warlock -- misattribution these shared-visibility channels can cause. This -- does mean a curse rank not yet in CURSE_SPELL_IDS above simply -- won't register -- add its spellID there rather than re-enabling -- this fallback. Without nampower at all, use best-effort behavior. if arg1 and not hasNampower then m.detectCurse(arg1, lastCurseCaster or UnitName("player")) lastCurseCaster = nil end elseif event == "CHAT_MSG_COMBAT_FRIENDLY_DEATH" then if arg1 then local _, _, dead = string.find(arg1, "(.+) dies%.") if dead and m.stones[dead] then m.removeStone(dead) end end elseif event == "CHAT_MSG_ADDON" then -- arg1=prefix arg2=message arg3=channel arg4=sender if arg1 == ADDON_MSG_PREFIX then if string.find(arg2, "^CASTING:") then local rest = string.sub(arg2, 9) -- strip leading "CASTING:" (8 chars) local gtPos = string.find(rest, ">", 1, true) local caster = gtPos and string.sub(rest, 1, gtPos - 1) local target = gtPos and string.sub(rest, gtPos + 1) if caster and target and caster ~= "" and target ~= "" and arg4 ~= UnitName("player") then lastCaster = caster isSelfCast = false end elseif string.find(arg2, "^ADD:") then local rest = string.sub(arg2, 5) -- strip leading "ADD:" (4 chars) local p1 = string.find(rest, ":", 1, true) local target = p1 and string.sub(rest, 1, p1 - 1) local rest2 = p1 and string.sub(rest, p1 + 1) local p2 = rest2 and string.find(rest2, ":", 1, true) local caster = p2 and string.sub(rest2, 1, p2 - 1) local expires = p2 and string.sub(rest2, p2 + 1) if target and caster and expires and target ~= "" and caster ~= "" and expires ~= "" and arg4 ~= UnitName("player") then m.addStone(target, caster, tonumber(expires), true) end elseif string.find(arg2, "^REM:") then local target = string.sub(arg2, 5) -- strip leading "REM:" (4 chars) if target and target ~= "" then m.removeStone(target, true) end elseif string.find(arg2, "^BANISH:") then local rest = string.sub(arg2, 8) -- strip leading "BANISH:" (7 chars) local colonPos = string.find(rest, ":", 1, true) local wName = colonPos and string.sub(rest, 1, colonPos - 1) local iconIdx = colonPos and string.sub(rest, colonPos + 1) if wName and iconIdx and wName ~= "" and iconIdx ~= "" and arg4 ~= UnitName("player") then local idx = tonumber(iconIdx) if idx == 0 then m.banish[wName] = nil else m.banish[wName] = idx end m.refreshBanish() end elseif string.find(arg2, "^CURSE:") then local rest = string.sub(arg2, 7) -- strip leading "CURSE:" (6 chars) local colonPos = string.find(rest, ":", 1, true) local shortName = colonPos and string.sub(rest, 1, colonPos - 1) local caster = colonPos and string.sub(rest, colonPos + 1) if shortName and caster and shortName ~= "" and caster ~= "" and arg4 ~= UnitName("player") then m.curses[shortName] = { caster=caster } m.refresh() end elseif string.find(arg2, "^CURSEREM:") then local shortName = string.sub(arg2, 10) -- strip leading "CURSEREM:" (9 chars) if shortName and shortName ~= "" and arg4 ~= UnitName("player") then m.curses[shortName] = nil m.refresh() end elseif string.find(arg2, "^SHARDS:") then local countStr = string.sub(arg2, 8) -- strip leading "SHARDS:" (7 chars) local count = tonumber(countStr) if count and arg4 ~= UnitName("player") then m.shardCounts[arg4] = count m.refreshBanish() end elseif string.find(arg2, "^SSCD:") then local readyAtStr = string.sub(arg2, 6) -- strip leading "SSCD:" (5 chars) local readyAt = tonumber(readyAtStr) if readyAt and arg4 ~= UnitName("player") then m.ssCooldowns[arg4] = readyAt m.refreshBanish() end elseif arg2 == "SYNCREQ" and arg4 ~= UnitName("player") then m.rebroadcastKnownData() end end end end) ------------------------------------------------------------------------------- -- Slash ------------------------------------------------------------------------------- SLASH_SOULSTONETRACKER1 = "/ss" SLASH_SOULSTONETRACKER2 = "/soulstone" SlashCmdList["SOULSTONETRACKER"] = function(args) args = args or "" local cmd = string.lower(string.gsub(args, "^%s*(.-)%s*$", "%1")) -- per-character on/off for non-warlocks (warlocks always have it on) if cmd == "enable" or cmd == "disable" then if playerIsWarlock() then say("Always on for warlocks.") return end local on = (cmd == "enable") local was = SoulstoneTrackerDB.enabledChars[charKey()] and true or false SoulstoneTrackerDB.enabledChars[charKey()] = on or nil if on == was then say(on and "Already on for this character." or "Already off for this character.") elseif on == not m.disabled then say(on and "Turned on for this character." or "Turned off for this character.") else say((on and "Turned on" or "Turned off").." for this character. Reload the UI to apply.") StaticPopup_Show("SOULSTONETRACKER_RELOAD") end return end if m.disabled then say("Off on this character (not a warlock). Type |cffffd700/ss enable|r to turn it on here.") return end if cmd == "clear" then m.stones = {} m.refresh() say("Cleared all soulstones.") elseif string.find(cmd, "^testcurse") then local _, _, sn, caster = string.find(args, "%a+%s+(%a+)%s*(%a*)") sn = sn or "CoE" local CURSE_LOOKUP = { coe="CoE", cos="CoS", cor="CoR", cow="CoW" } sn = CURSE_LOOKUP[string.lower(sn)] or sn if not CURSE_COLORS[sn] then say("Unknown curse short name |cffffffff"..sn.."|r. Use one of: |cffffd700CoE, CoS, CoR, CoW|r") else caster = (caster ~= "" and caster) or UnitName("player") -- Injects directly into m.curses exactly like the CHAT_MSG_ADDON -- "CURSE:" receive-branch does, to simulate a sync arriving from -- another player without needing a second client. m.curses[sn] = { caster = caster } m.refresh() say("Simulated sync: |cffa050ff"..caster.."|r -> "..(CURSE_COLORS[sn] or "|cffffffff")..sn.."|r (as if received from another player)") -- The curse/banish section only draws a row for your own name or -- an actual Warlock currently in your raid/party, so a made-up -- name won't visibly appear even though it's tracked internally. if caster ~= UnitName("player") then local isRealWarlock = false local numRaid = GetNumRaidMembers() if numRaid > 0 then for i = 1, numRaid do local n, _, _, _, class = GetRaidRosterInfo(i) if n == caster and class == "Warlock" then isRealWarlock = true break end end else for i = 1, GetNumPartyMembers() do if UnitName("party"..i) == caster and UnitClass("party"..i) == "Warlock" then isRealWarlock = true break end end end if not isRealWarlock then say("|cffff7c0aNote:|r "..caster.." isn't a Warlock in your current group, so no row will show for them. Use your own name or an actual grouped warlock to see it rendered.") end end end elseif string.find(cmd, "^testcast") then local _, _, target, caster, secs = string.find(args, "%a+%s+(%a+)%s*(%a*)%s*(%d*)") if not target then say("Usage: |cffffd700/ss testcast [caster] [seconds]|r") else caster = (caster ~= "" and caster) or UnitName("player") local duration = (secs ~= "" and tonumber(secs)) or (30 * 60) m.addStone(target, caster, time() + duration) say("Test stone: |cffa050ff"..caster.."|r -> |cffffffff"..target.."|r ("..duration.."s)") end elseif string.find(cmd, "^test") then local _, _, secs = string.find(cmd, "test%s+(%d+)") local duration = tonumber(secs) or (30 * 60) m.addStone(UnitName("player"), UnitName("player"), time() + duration) if secs then say("Test stone added with "..secs.."s duration.") else say("Test stone added. Use |cffffd700/ss test 90|r to test with 90 seconds.") end elseif cmd == "scan" then m.scanForSoulstones() elseif cmd == "scancurse" then m.scanForCurses() elseif cmd == "shards" then m.updateAndBroadcastShards() local any = false for name, count in pairs(m.shardCounts) do any = true say(classColorName(name, "|cffa050ff").." — "..count.." shards") end if not any then say("No shard data yet.") end elseif cmd == "sscd" then m.updateSoulstoneCooldown() local any = false for name, readyAt in pairs(m.ssCooldowns) do any = true if readyAt > time() then say(classColorName(name, "|cffa050ff").." — |cffff6666"..formatTime(readyAt - time()).." until ready|r") else say(classColorName(name, "|cffa050ff").." — |cff00ff00ready now|r") end end if not any then say("No soulstone cooldown data yet. (Requires nampower; won't work for non-warlocks.)") end elseif string.find(cmd, "^history") then local numStr = string.find(cmd, " ") and string.sub(cmd, string.find(cmd, " ") + 1) local n = (numStr and tonumber(numStr)) or 15 local total = table.getn(m.history) if total == 0 then say("No history logged this session yet.") else say("Last "..math.min(n, total).." event(s) this session:") local start = math.max(1, total - n + 1) for i = start, total do local entry = m.history[i] local elapsed = time() - entry.time local ago if elapsed < 60 then ago = elapsed.."s ago" else ago = math.floor(elapsed/60).."m ago" end say("|cff888888["..ago.."]|r "..entry.text) end end elseif cmd == "resync" then local channel = GetNumRaidMembers() > 0 and "RAID" or (GetNumPartyMembers() > 0 and "PARTY" or nil) if not channel then say("Not in a group -- nothing to sync.") else m.rebroadcastKnownData() SendAddonMessage(ADDON_MSG_PREFIX, "SYNCREQ", channel) say("Resync requested -- rebroadcast your data and asked others to do the same.") end elseif cmd == "list" then local n = 0 for target, data in pairs(m.stones) do local s = timeLeft(data.expires) say(data.caster.." -> "..target.." ("..math.floor(s/60).."m "..math.mod(s,60).."s)") n = n + 1 end if n == 0 then say("No active soulstones.") end elseif cmd == "warlocks" then local missing = getWarlocksMissingStones() if table.getn(missing) == 0 then say("All warlocks have active soulstones!") else say("|cffff7c0aMissing: |cffffffff"..table.concat(missing, ", ").."|r") end elseif string.find(cmd, "^diag") then local spacePos = string.find(cmd, " ", 1, true) local sub = spacePos and string.sub(cmd, spacePos + 1) if sub == "aura" then m.diagAura = not m.diagAura say("Aura-cast diagnostic "..(m.diagAura and "|cff00ff00ENABLED|r" or "|cffff0000DISABLED|r")..". Cast/receive anything to see spellId/target/durationMs in chat.") else -- On-demand diagnostic: reveal exact string contents (including -- hidden whitespace or realm suffixes) of both the roster-derived -- warlock names and the synced curse caster names, since they can -- look identical on screen but fail an exact string match in code. local playerName = UnitName("player") say("|cff00ff00[DIAG]|r player = "..string.format("%q", playerName)) local numRaid = GetNumRaidMembers() if numRaid > 0 then for i = 1, numRaid do local name, _, _, _, class = GetRaidRosterInfo(i) if name and class == "Warlock" then say("|cff00ff00[DIAG]|r roster warlock = "..string.format("%q", name)) end end else for i = 1, GetNumPartyMembers() do local name = UnitName("party"..i) if name and UnitClass("party"..i) == "Warlock" then say("|cff00ff00[DIAG]|r roster warlock = "..string.format("%q", name)) end end end local any = false for sn, data in pairs(m.curses) do any = true say("|cff00ff00[DIAG]|r m.curses["..sn.."].caster = "..string.format("%q", data.caster or "nil")) end if not any then say("|cff00ff00[DIAG]|r m.curses is empty") end end elseif cmd == "lock" then m.locked = not m.locked SoulstoneTrackerDB.locked = m.locked m.frame.updateLock() say(m.locked and "Frame locked." or "Frame unlocked.") elseif cmd == "curses" then local n = 0 for shortName, data in pairs(m.curses) do say((CURSE_COLORS[shortName] or "|cffffffff")..shortName.."|r — "..data.caster.." on "..data.target) n = n + 1 end if n == 0 then say("No active curses tracked.") end elseif cmd == "clearcurses" then m.curses = {} m.refresh() say("Cleared all curse assignments.") elseif cmd == "clearbanish" then m.banish = {} m.refreshBanish() say("Cleared all banish assignments.") local channel = GetNumRaidMembers() > 0 and "RAID" or (GetNumPartyMembers() > 0 and "PARTY" or nil) if channel then SendAddonMessage(ADDON_MSG_PREFIX, "SYNCREQ", channel) say("Requesting sync from others with SoulstoneTracker...") end elseif cmd == "reset" then SoulstoneTrackerDB.position = nil SoulstoneTrackerDB.size = nil SoulstoneTrackerDB.scale = nil say("Position/size reset. /reload to apply.") elseif string.find(cmd, "^scale") then local _, _, val = string.find(cmd, "scale%s+([%d%.]+)") local scale = tonumber(val) if scale and scale >= 0.5 and scale <= 2.0 then m.frame:SetScale(scale) SoulstoneTrackerDB.scale = scale say("Scale set to "..scale) else say("Usage: /ss scale 0.5-2.0 (e.g. /ss scale 0.8)") end elseif string.find(cmd, "^theme") then local _, _, which = string.find(cmd, "^theme%s*(%a*)") if which == "classic" or which == "shadow" then setCfg("classicTheme", which == "classic") if m.settings and m.settings.checks and m.settings.checks["classicTheme"] then m.refreshSettings() end say("Theme set to "..which..". /reload to apply.") else say("Theme: "..(cfg("classicTheme") and "classic" or "shadow").. ". Usage: /ss theme classic, or /ss theme shadow") end elseif cmd == "help" then say("|cffffd700Commands:|r") say("|cffffd700/ss|r — toggle window") say("|cffffd700/ss test|r — add test stone (30 min)") say("|cffffd700/ss test 90|r — add test stone with 90 second duration") say("|cffffd700/ss clear|r — clear all stones") say("|cffffd700/ss list|r — list active stones in chat") say("|cffffd700/ss curses|r — list active curse assignments") say("|cffffd700/ss testcurse CoE Bob|r — simulate a curse sync from 'Bob' (CoE/CoS/CoR/CoW)") say("|cffffd700/ss testcast Bob Waylock 90|r — simulate Waylock soulstoning Bob, 90s") say("|cffffd700/ss clearcurses|r — clear curse assignments") say("|cffffd700/ss lock|r — toggle frame lock") say("|cffffd700/ss scale 0.8|r — resize (0.5-2.0)") if not playerIsWarlock() then say("|cffffd700/ss disable|r — turn the addon off on this (non-warlock) character") end say("|cffffd700/ss theme classic|r or |cffffd700shadow|r — Blizzard look or the original purple look (needs /reload)") say("|cffffd700/ss scan|r — scan group for existing stones") say("|cffffd700/ss scancurse|r — scan your target for curses (unknown caster)") say("|cffffd700/ss resync|r — force resync of known data with group") say("|cffffd700/ss shards|r — list known soul shard counts") say("|cffffd700/ss sscd|r — list soulstone-creation cooldowns") say("|cffffd700/ss history [n]|r — show last n session events (default 15)") say("|cffffd700/ss diag aura|r — toggle live aura-cast spellId/duration diagnostic") say("|cffffd700/ss reset|r — reset position and size") else if m.frame and m.frame:IsVisible() then m.frame:Hide() SoulstoneTrackerDB.hidden = true say("Hidden.") else if not m.frame then m.createFrame() end m.frame:Show() SoulstoneTrackerDB.hidden = false say("Shown.") end end end