2052 lines
82 KiB
Lua
2052 lines
82 KiB
Lua
-- SoulstoneTracker
|
|
-- Tracks soulstones cast by warlocks on raid members
|
|
-- Author: Waylock
|
|
|
|
SoulstoneTracker = {}
|
|
local m = SoulstoneTracker
|
|
|
|
local SOULSTONE_DURATION = 30 * 60
|
|
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
|
|
}
|
|
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.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,
|
|
-- 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
|
|
|
|
-------------------------------------------------------------------------------
|
|
-- 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
|
|
|
|
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
|
|
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")
|
|
if cfg("announceWarnings") 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") 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") 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
|
|
|
|
-------------------------------------------------------------------------------
|
|
-- Minimap Button
|
|
-------------------------------------------------------------------------------
|
|
|
|
function m.createMinimapButton()
|
|
local radius = 80
|
|
local btn = CreateFrame("Button", "SoulstoneTrackerMinimap", Minimap)
|
|
btn:SetWidth(20)
|
|
btn:SetHeight(20)
|
|
btn:SetFrameStrata("MEDIUM")
|
|
btn:SetFrameLevel(8)
|
|
btn:SetClampedToScreen(true)
|
|
|
|
-- Background circle
|
|
local bg = btn:CreateTexture(nil, "BACKGROUND")
|
|
bg:SetTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Background")
|
|
bg:SetAllPoints(btn)
|
|
|
|
-- Use a soulstone-themed icon that's guaranteed to exist
|
|
local icon = btn:CreateTexture(nil, "ARTWORK")
|
|
icon:SetTexture("Interface\\Icons\\Spell_Shadow_SoulGem")
|
|
icon:SetWidth(18)
|
|
icon:SetHeight(18)
|
|
icon:SetPoint("Center", btn, "Center", 0, 0)
|
|
|
|
-- Highlight on hover
|
|
local highlight = btn:CreateTexture(nil, "HIGHLIGHT")
|
|
highlight:SetTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight")
|
|
highlight:SetAllPoints(btn)
|
|
highlight:SetBlendMode("ADD")
|
|
|
|
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) 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
|
|
-------------------------------------------------------------------------------
|
|
|
|
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, -(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 f = CreateFrame("Frame", "SoulstoneTrackerFrame", UIParent)
|
|
f:SetWidth(savedW)
|
|
f:SetHeight(savedH)
|
|
f:SetFrameStrata("MEDIUM")
|
|
f:SetBackdrop({
|
|
bgFile = "Interface\\Buttons\\WHITE8X8",
|
|
edgeFile = "Interface\\Buttons\\WHITE8X8",
|
|
edgeSize = 1,
|
|
insets = { left=1, right=1, top=1, bottom=1 }
|
|
})
|
|
f:SetBackdropColor(0.05, 0.05, 0.08, 0.95)
|
|
f:SetBackdropBorderColor(0.25, 0.25, 0.35, 1)
|
|
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)
|
|
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)
|
|
f.titleBar = titleBar
|
|
|
|
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(FONT, 11, "OUTLINE")
|
|
title:SetPoint("Left", titleBar, "Left", 6, 0)
|
|
title:SetText("|cffffd700Soulstone Tracker v1.2|r")
|
|
f.title = title
|
|
|
|
-- Settings button (defined in XML with BLP texture)
|
|
local settingsBtn = _G["SSTSettingsButton"]
|
|
settingsBtn:SetParent(titleBar)
|
|
settingsBtn:SetPoint("Right", titleBar, "Right", -42, 0)
|
|
settingsBtn:Show()
|
|
|
|
-- 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)
|
|
local scanTex = scanBtn:CreateTexture(nil, "ARTWORK")
|
|
scanTex:SetAllPoints(scanBtn)
|
|
scanTex:SetTexture("Interface\\Icons\\INV_Misc_Spyglass_03")
|
|
scanTex:SetTexCoord(0.08, 0.92, 0.08, 0.92)
|
|
scanBtn:SetScript("OnEnter", function()
|
|
GameTooltip:SetOwner(scanBtn, "ANCHOR_LEFT")
|
|
GameTooltip:SetText("Scan for existing soulstones")
|
|
GameTooltip:Show()
|
|
scanTex:SetVertexColor(1, 1, 0.6)
|
|
end)
|
|
scanBtn:SetScript("OnLeave", function()
|
|
GameTooltip:Hide()
|
|
scanTex:SetVertexColor(1, 1, 1)
|
|
end)
|
|
scanBtn:SetScript("OnClick", function()
|
|
m.scanForSoulstones()
|
|
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
|
|
|
|
local function updateLock()
|
|
if m.locked then
|
|
lockTex:SetTexture("Interface\\addons\\SoulstoneTracker\\images\\icon_locked")
|
|
else
|
|
lockTex:SetTexture("Interface\\addons\\SoulstoneTracker\\images\\icon_unlocked")
|
|
end
|
|
end
|
|
updateLock()
|
|
f.updateLock = updateLock
|
|
|
|
-- X close button (defined in XML with BLP texture)
|
|
local closeBtn = _G["SSTCloseButton"]
|
|
closeBtn:SetParent(titleBar)
|
|
closeBtn:SetPoint("Right", titleBar, "Right", -4, 0)
|
|
closeBtn:Show()
|
|
|
|
-- Column headers
|
|
local hY = -(TITLE_H + HEADER_H/2)
|
|
|
|
local hCaster = f:CreateFontString(nil, "OVERLAY")
|
|
hCaster:SetFont(FONT, 10, "OUTLINE")
|
|
hCaster:SetTextColor(0.85, 0.85, 0.85, 1)
|
|
hCaster:SetPoint("TopLeft", f, "TopLeft", 8, hY)
|
|
hCaster:SetText("Caster")
|
|
|
|
local hTarget = f:CreateFontString(nil, "OVERLAY")
|
|
hTarget:SetFont(FONT, 10, "OUTLINE")
|
|
hTarget:SetTextColor(0.85, 0.85, 0.85, 1)
|
|
hTarget:SetPoint("TopLeft", f, "TopLeft", math.floor(savedW * COL_ARROW) + 14, hY)
|
|
hTarget:SetText("Target")
|
|
|
|
local hExpires = f:CreateFontString(nil, "OVERLAY")
|
|
hExpires:SetFont(FONT, 10, "OUTLINE")
|
|
hExpires:SetTextColor(0.85, 0.85, 0.85, 1)
|
|
hExpires:SetPoint("TopRight", f, "TopRight", -8, hY)
|
|
hExpires:SetJustifyH("Right")
|
|
hExpires:SetText("Expires")
|
|
|
|
local div = f:CreateTexture(nil, "ARTWORK")
|
|
div:SetHeight(1)
|
|
div:SetPoint("TopLeft", f, "TopLeft", 1, -(TITLE_H + HEADER_H))
|
|
div:SetPoint("TopRight", f, "TopRight", -1, -(TITLE_H + HEADER_H))
|
|
div:SetTexture(0.3, 0.15, 0.5, 0.8)
|
|
|
|
local empty = f:CreateFontString(nil, "OVERLAY")
|
|
empty:SetFont(FONT, 10, "OUTLINE")
|
|
empty:SetTextColor(0.4, 0.4, 0.4, 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", 1, rowY)
|
|
bg:SetPoint("TopRight", f, "TopRight", -1, rowY)
|
|
bg:SetHeight(ROW_H)
|
|
if math.mod(i, 2) == 0 then bg:SetTexture(1,1,1,0.04) else bg:SetTexture(0,0,0,0) end
|
|
bg:Hide()
|
|
|
|
local caster = f:CreateFontString(nil, "OVERLAY")
|
|
caster:SetFont(FONT, 11, "OUTLINE")
|
|
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")
|
|
arrow:SetFont(FONT, 11, "OUTLINE")
|
|
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")
|
|
target:SetFont(FONT, 11, "OUTLINE")
|
|
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")
|
|
timer:SetFont(FONT, 11, "OUTLINE")
|
|
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", 1, rowY)
|
|
hitbox:SetPoint("TopRight", f, "TopRight", -1, rowY)
|
|
hitbox:EnableMouse(true)
|
|
hitbox.rowIndex = i
|
|
hitbox:SetScript("OnEnter", function()
|
|
local row = f.rows[this.rowIndex]
|
|
if not row or not row.data then return 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() 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)
|
|
local warlockDivTex = f:CreateTexture(nil, "ARTWORK")
|
|
warlockDivTex:SetHeight(1)
|
|
warlockDivTex:SetTexture(0.3, 0.15, 0.5, 0.8)
|
|
f.curseDivider = warlockDivTex
|
|
|
|
local warlockHdr = f:CreateFontString(nil, "OVERLAY")
|
|
warlockHdr:SetFont(FONT, 8, "OUTLINE")
|
|
warlockHdr:SetTextColor(0.6, 0.6, 0.6, 1)
|
|
warlockHdr:SetText("CURSE AND BANISH ASSIGNMENT")
|
|
f.curseHeader = warlockHdr
|
|
|
|
-- 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,0.04) else bg:SetTexture(0,0,0,0) end
|
|
bg:Hide()
|
|
|
|
-- Warlock name
|
|
local nameTxt = f:CreateFontString(nil, "OVERLAY")
|
|
nameTxt:SetFont(FONT, 11, "OUTLINE")
|
|
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")
|
|
curseLbl:SetFont(FONT, 10, "OUTLINE")
|
|
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()
|
|
for sn, data in pairs(m.curses) do
|
|
if data.caster == wName then m.curses[sn] = nil 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)
|
|
-- Remove wName from any other curse slot first
|
|
for sn2, data in pairs(m.curses) do
|
|
if data.caster == wName and sn2 ~= sn then m.curses[sn2] = nil end
|
|
end
|
|
m.curses[sn] = { caster = wName }
|
|
m.refresh()
|
|
local msg = "[Curse] "..wName.." -> "..sn.." (manual override)"
|
|
say(msg)
|
|
local ch = GetNumRaidMembers()>0 and "RAID" or (GetNumPartyMembers()>0 and "PARTY" or nil)
|
|
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")
|
|
banishLbl:SetFont(FONT, 10, "OUTLINE")
|
|
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)
|
|
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)
|
|
grip:SetPoint("BottomRight", f, "BottomRight", 0, 0)
|
|
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
|
|
local tick = 0
|
|
local flashTime = 0
|
|
local flashDir = 1
|
|
f:SetScript("OnUpdate", function()
|
|
tick = tick + arg1
|
|
if tick >= 1 then
|
|
tick = 0
|
|
m.clearExpired()
|
|
m.refresh()
|
|
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
|
|
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)
|
|
else
|
|
flashTime = 0
|
|
flashDir = 1
|
|
f.titleBar:SetBackdropColor(0.22, 0.08, 0.38, 1)
|
|
end
|
|
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
|
|
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("|cffffd700Soulstone Tracker|r |cffff7c0a["..table.getn(missing).." available]|r")
|
|
else
|
|
f.title:SetText("|cffffd700Soulstone Tracker v1.2|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("|cffa050ff"..data.caster.."|r")
|
|
row.arrow:SetText("->")
|
|
row.target:SetText("|cffffffff"..data.target.."|r")
|
|
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()
|
|
row.data = nil
|
|
end
|
|
end
|
|
|
|
local rows = math.max(1, count)
|
|
-- Height is managed by refreshBanish which accounts for all sections
|
|
m.refreshBanish()
|
|
end
|
|
|
|
function m.refreshBanish()
|
|
if not m.frame then return end
|
|
local f = m.frame
|
|
local SEC_ROW_H = 18
|
|
local HEADER_H2 = 10
|
|
|
|
-- 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 FALLBACK_ICON = "Interface\\Icons\\Spell_Shadow_UnholyStrength"
|
|
|
|
-- Build warlock list
|
|
local warlocks = {}
|
|
local playerName = UnitName("player")
|
|
if playerName 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)
|
|
|
|
if not f.warlockRows then
|
|
-- Frame height only
|
|
f:SetHeight(TITLE_H + HEADER_H + stoneRows * ROW_H + 6)
|
|
return
|
|
end
|
|
|
|
if warlockCount > 0 and cfg("showCurseSection") then
|
|
-- Divider
|
|
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
|
|
|
|
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", 1, curY)
|
|
row.bg:SetPoint("TopRight", f, "TopRight", -1, curY)
|
|
row.bg:SetHeight(SEC_ROW_H)
|
|
row.bg:Show()
|
|
|
|
-- Name
|
|
row.nameTxt:ClearAllPoints()
|
|
row.nameTxt:SetPoint("TopLeft", f, "TopLeft", 8, curY - 2)
|
|
row.nameTxt:SetWidth(f:GetWidth() * 0.5)
|
|
row.nameTxt:SetText("|cffa050ff"..wName.."|r")
|
|
row.nameTxt:Show()
|
|
|
|
-- Curse info (centered in row)
|
|
local shortName = warlockCurse[wName]
|
|
if shortName then
|
|
row.curseIcon:ClearAllPoints()
|
|
row.curseIcon:SetPoint("TopLeft", f, "TopLeft", math.floor(f:GetWidth() * 0.45), 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)
|
|
row.curseIcon:Show()
|
|
row.curseLbl:ClearAllPoints()
|
|
row.curseLbl:SetPoint("TopLeft", f, "TopLeft", math.floor(f:GetWidth() * 0.45) + 18, curY - 2)
|
|
row.curseLbl:SetText((CURSE_COLORS[shortName] or "|cffffffff")..shortName.."|r")
|
|
row.curseLbl:Show()
|
|
else
|
|
row.curseIcon:Hide()
|
|
row.curseLbl:ClearAllPoints()
|
|
row.curseLbl:SetPoint("TopLeft", f, "TopLeft", math.floor(f:GetWidth() * 0.45), curY - 2)
|
|
row.curseLbl:SetText("|cff666666-|r")
|
|
row.curseLbl:Show()
|
|
end
|
|
|
|
-- Curse override hitbox (covers icon+label area, click to manually set)
|
|
row.curseBtn:ClearAllPoints()
|
|
row.curseBtn:SetPoint("TopLeft", f, "TopLeft", math.floor(f:GetWidth() * 0.45) - 2, curY - 2)
|
|
row.curseBtn:SetWidth(48)
|
|
row.curseBtn:Show()
|
|
|
|
-- Banish icon button (right side, moved in from edge)
|
|
row.banishBtn:ClearAllPoints()
|
|
row.banishBtn:SetPoint("TopRight", f, "TopRight", -24, 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:SetTexture("Interface\\TargetingFrame\\UI-RaidTargetingIcons")
|
|
row.banishTex:SetTexCoord(c[1], c[2], c[3], c[4])
|
|
row.banishLbl:SetText("")
|
|
else
|
|
row.banishTex:SetTexCoord(0,0,0,0)
|
|
row.banishLbl:SetText("|cffaaaaaa-|r")
|
|
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
|
|
|
|
-- Resize
|
|
local totalH = TITLE_H + HEADER_H + (stoneRows * ROW_H) + 6 + math.abs(curY - baseY)
|
|
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")
|
|
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 SW, SH = 320, 420
|
|
local s = CreateFrame("Frame", "SoulstoneTrackerSettings", UIParent)
|
|
s:SetWidth(SW)
|
|
s:SetHeight(SH)
|
|
s:SetFrameStrata("HIGH")
|
|
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)
|
|
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
|
|
|
|
-- 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)
|
|
|
|
-- Helper to create a toggle checkbox row
|
|
local function makeToggle(parent, label, key, yOff)
|
|
local cb = CreateFrame("Button", nil, parent)
|
|
cb:SetWidth(14) cb:SetHeight(14)
|
|
cb:SetPoint("TopLeft", parent, "TopLeft", 10, 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)
|
|
setCfg(this.key, not cur)
|
|
if not cur then this.check:Show() else this.check:Hide() end
|
|
if m.frame then m.refreshBanish() end
|
|
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)
|
|
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(text)
|
|
return hdr
|
|
end
|
|
|
|
-- Helper for number input
|
|
local function makeSlider(parent, label, key, minV, maxV, step, yOff)
|
|
local lbl = parent:CreateFontString(nil, "OVERLAY")
|
|
lbl:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE")
|
|
lbl:SetTextColor(0.85, 0.85, 0.85, 1)
|
|
lbl:SetPoint("TopLeft", parent, "TopLeft", 10, yOff)
|
|
lbl:SetText(label)
|
|
|
|
local valLbl = parent:CreateFontString(nil, "OVERLAY")
|
|
valLbl:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE")
|
|
valLbl:SetTextColor(0.6, 0.3, 1, 1)
|
|
valLbl:SetPoint("TopRight", parent, "TopRight", -10, yOff)
|
|
valLbl:SetJustifyH("Right")
|
|
|
|
local sliderName = "SSTracker_Slider_"..key
|
|
local slider = CreateFrame("Slider", sliderName, parent, "OptionsSliderTemplate")
|
|
slider:SetWidth(SW - 30)
|
|
slider:SetHeight(16)
|
|
slider:SetPoint("TopLeft", parent, "TopLeft", 10, 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)))
|
|
sliderText:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE")
|
|
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
|
|
|
|
-- Helper for action button
|
|
local function makeButton(parent, label, onClick, yOff)
|
|
local btn = CreateFrame("Button", nil, parent)
|
|
btn:SetWidth(SW - 20) btn:SetHeight(20)
|
|
btn:SetPoint("TopLeft", parent, "TopLeft", 10, 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
|
|
local y = -28
|
|
|
|
-- NOTIFICATIONS section
|
|
makeHeader(s, "NOTIFICATIONS", y) y = y - 14
|
|
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
|
|
makeHeader(s, "SOUNDS", y) y = y - 14
|
|
s.checks["muteSounds"] = makeToggle(s, "Mute all sounds", "muteSounds", y) y = y - 24
|
|
|
|
-- RAID CHAT section
|
|
makeHeader(s, "RAID CHAT ANNOUNCEMENTS", y) y = y - 14
|
|
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
|
|
makeHeader(s, "DISPLAY", y) y = y - 14
|
|
s.checks["showCurseSection"] = makeToggle(s, "Show curse & banish section", "showCurseSection", y) y = y - 24
|
|
|
|
-- TIMING section
|
|
makeHeader(s, "WARNING THRESHOLDS", y) y = y - 14
|
|
s.warnMinSlider = makeSlider(s, "First warning (minutes):", "warnMinutes", 1, 15, 1, y) y = y - 36
|
|
s.warnSecSlider = makeSlider(s, "Final warning (seconds):", "warnSeconds", 15, 120, 5, y) y = y - 40
|
|
|
|
-- ACTION BUTTONS
|
|
makeHeader(s, "ACTIONS", y) y = y - 14
|
|
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, "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)
|
|
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
|
|
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
|
|
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 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("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")
|
|
end
|
|
|
|
eFrame:SetScript("OnEvent", function()
|
|
if event == "PLAYER_LOGIN" then
|
|
SoulstoneTrackerDB = SoulstoneTrackerDB or {}
|
|
-- Load config
|
|
if SoulstoneTrackerDB.cfg then
|
|
m.cfg = SoulstoneTrackerDB.cfg
|
|
end
|
|
loadAll()
|
|
m.createFrame()
|
|
m.createMinimapButton()
|
|
if SoulstoneTrackerDB.hidden then m.frame:Hide() else m.frame:Show() end
|
|
if SoulstoneTrackerDB.locked then m.locked = true m.frame.updateLock() end
|
|
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()
|
|
-- 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()
|
|
-- 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_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")
|
|
-- 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)
|
|
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)."
|
|
if arg1 then
|
|
local target = string.match(arg1, "(.+) gains Soulstone Resurrection")
|
|
if target and lastCaster then
|
|
m.addStone(target, lastCaster)
|
|
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.match(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
|
|
|
|
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.match(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
|
|
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 arg2 == "SYNCREQ" and arg4 ~= UnitName("player") then
|
|
-- Someone is requesting our stone data - send all our stones
|
|
-- (except our own uncertain scanned entries -- those
|
|
-- shouldn't propagate and risk overwriting someone else's
|
|
-- accurate value with a guess)
|
|
local channel = GetNumRaidMembers() > 0 and "RAID" or "PARTY"
|
|
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
|
|
-- Also sync banish assignments
|
|
for wName, iconIdx in pairs(m.banish) do
|
|
SendAddonMessage(ADDON_MSG_PREFIX, "BANISH:"..wName..":"..iconIdx, channel)
|
|
end
|
|
-- Also sync curses
|
|
for shortName, data in pairs(m.curses) do
|
|
SendAddonMessage(ADDON_MSG_PREFIX, "CURSE:"..shortName..":"..data.caster, channel)
|
|
end
|
|
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"))
|
|
|
|
if cmd == "clear" then
|
|
m.stones = {} m.refresh() say("Cleared all soulstones.")
|
|
|
|
elseif string.find(cmd, "^testcurse") then
|
|
local sn, caster = string.match(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.match(args, "%a+%s+(%a+)%s*(%a*)%s*(%d*)")
|
|
if not target then
|
|
say("Usage: |cffffd700/ss testcast <target> [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.match(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 == "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 cmd == "diag" then
|
|
-- 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
|
|
|
|
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.match(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 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)")
|
|
say("|cffffd700/ss scan|r — scan group for existing stones")
|
|
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
|