Files
2026-09-07 20:07:42 -07:00

3554 lines
124 KiB
Lua
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
--[[
GreedMeter - Threat module
Optional Threat, Tank, and Overall threat views.
Uses the server threat addon API when available; otherwise estimates from
meter data (party/raid only).
]]
local OM = GreedMeter
local UI = GreedMeter.UI
local Threat = {}
OM.modules = OM.modules or {}
-- ============================================================
-- Extra-settings registry (shared by any future modules)
-- Supports optional children (indented, parent-gated).
-- ============================================================
OM.extraSettingsCheckboxes = OM.extraSettingsCheckboxes or {}
--[[
entry = {
label = "Add threat mode",
key = "enableThreatMode",
tooltip = "...",
default = false,
order = 10, -- lower = closer to bottom
onToggle = function(checked) end,
children = { -- optional nested checkboxes
{
label = "Tanking mode",
key = "enableTankingMode",
tooltip = "...",
default = false,
onToggle = function(checked) end,
},
},
}
]]
function OM:RegisterExtraCheckbox(entry)
if not entry or not entry.key or not entry.label then return end
local i
for i = 1, table.getn(self.extraSettingsCheckboxes) do
if self.extraSettingsCheckboxes[i].key == entry.key then
self.extraSettingsCheckboxes[i] = entry
return
end
end
table.insert(self.extraSettingsCheckboxes, entry)
end
-- ============================================================
-- Defaults
-- ============================================================
OM.defaults = OM.defaults or {}
if OM.defaults.enableThreatMode == nil then
OM.defaults.enableThreatMode = false
end
if OM.defaults.showPetThreat == nil then
OM.defaults.showPetThreat = false
end
if OM.defaults.threatWarnSound == nil then
OM.defaults.threatWarnSound = true
end
if OM.defaults.threatWarnGlow == nil then
OM.defaults.threatWarnGlow = true
end
if OM.defaults.threatWarnPercent == nil then
OM.defaults.threatWarnPercent = 90
end
if OM.defaults.threatWarnSoundFile == nil then
OM.defaults.threatWarnSoundFile = "raidwarning"
end
if OM.defaults.petAsTank == nil then
OM.defaults.petAsTank = false
end
-- threatView: "single" | "tank" | "overall" | "all"
if OM.defaults.threatView == nil then
OM.defaults.threatView = "single"
end
-- Migrate older tank-mode checkbox
if OM.defaults.enableTankingMode ~= nil then
if OM.defaults.enableTankingMode and OM.defaults.threatView == "single" then
OM.defaults.threatView = "tank"
end
end
-- ============================================================
-- Constants / state
-- ============================================================
-- Server threat addon API
-- Query: SendAddonMessage("TWT_UDTSv4" or "TWT_UDTSv4_TM", "limit=N", RAID|PARTY)
-- Reply: arg2 contains "TWTv4=player:tank:threat:perc:melee;..." optional "#TMTv1=..."
local THREAT_API_PREFIX = "TWTv4="
local TANK_MODE_API_PREFIX = "TMTv1="
local THREAT_QUERY_BASE = "TWT_UDTSv4"
local PULL_AGGRO_NAME = "-Pull Aggro at-"
Threat.usingApi = false
Threat.lastApiTime = 0
Threat.apiTimeout = 6.0
Threat.threats = {} -- [name] = { threat, perc, tank, tps, class, melee, estimated }
Threat.tankModeThreats = {} -- [guid] = { creature, name, perc } (API tank-mode multi-mob)
Threat.overallThreat = {} -- [name] = { threat, class, estimated } fight-total threat generated
Threat.threatByTarget = {} -- [targetKey][player] = last API threat on that mob
Threat.currentTargetKey = nil -- GUID/name key for the mob the last API packet was about
-- Shared work across multiple threat windows (single / tank / overall):
-- dataGen bumps whenever underlying tables change; list caches rebuild at most once per gen.
Threat.dataGen = 0
Threat.listCache = {} -- [view] = { gen=n, list={...} }
Threat.targetingCache = {} -- [playerName] = { enemyName, ... }
Threat.targetingCacheGen = -1
Threat.targetName = nil
Threat.tankName = nil
Threat.queryInterval = 0.50
Threat.lastQuery = 0
Threat.history = {} -- for TPS
Threat.groupCombat = false -- party/raid combat session (not just local regen)
Threat.fleeingEnemies = {} -- [name or guid] = expireTime
-- Improved class baseline modifiers (relative threat generation)
-- Tanks higher, pure DPS lower, healers lowest base on damage contribution
local CLASS_THREAT_MOD = {
WARRIOR = 1.15, -- often tanking / high threat kit
PALADIN = 1.10,
DRUID = 1.05, -- bear form is higher; handled separately for self
ROGUE = 0.71,
HUNTER = 0.65,
MAGE = 0.70,
WARLOCK = 0.72,
PRIEST = 0.55, -- mostly healing threat
SHAMAN = 0.75,
}
-- Damage abilities with higher-than-normal threat coefficients (1.12 approximations).
-- Applied to that spell's damage already stored in the meter (damageSpells).
local SPELL_DAMAGE_THREAT_MULT = {
["Mind Blast"] = 2.00,
["Searing Pain"] = 2.00,
["Shield Slam"] = 1.50,
["Revenge"] = 2.00,
["Maul"] = 1.75,
["Heroic Strike"] = 1.25,
["Cleave"] = 1.15,
["Thunder Clap"] = 1.75,
["Mocking Blow"] = 2.50,
["Holy Shield"] = 1.30,
["Lacerate"] = 1.30,
["Devastate"] = 1.50,
}
-- Non-damaging (or mostly non-damaging) threat applications.
-- Values are flat threat per successful application/cast (classic-era approximations).
local SPELL_FLAT_THREAT = {
["Sunder Armor"] = 261,
["Demoralizing Shout"] = 43,
["Demoralizing Roar"] = 39,
["Faerie Fire"] = 108,
["Faerie Fire (Feral)"] = 108,
["Hamstring"] = 104,
-- ============================================================
-- Pet / minion high-threat abilities (KLH Threat Meter 1.12 data)
-- Rankless keys use the highest rank typically available at 60.
-- ============================================================
-- Hunter pet Growl (ranks 1–7 in vanilla; values max→min)
["Growl"] = 415,
["Growl (Rank 1)"] = 50,
["Growl (Rank 2)"] = 65,
["Growl (Rank 3)"] = 110,
["Growl (Rank 4)"] = 170,
["Growl (Rank 5)"] = 240,
["Growl (Rank 6)"] = 320,
["Growl (Rank 7)"] = 415,
-- Hunter BM Intimidation (fixed)
["Intimidation"] = 580,
-- Voidwalker Torment (ranks 1–6)
["Torment"] = 395,
["Torment (Rank 1)"] = 45,
["Torment (Rank 2)"] = 75,
["Torment (Rank 3)"] = 125,
["Torment (Rank 4)"] = 215,
["Torment (Rank 5)"] = 300,
["Torment (Rank 6)"] = 395,
-- Voidwalker Suffering (ranks 1–4, AoE threat)
["Suffering"] = 600,
["Suffering (Rank 1)"] = 150,
["Suffering (Rank 2)"] = 300,
["Suffering (Rank 3)"] = 450,
["Suffering (Rank 4)"] = 600,
}
local function SpellDamageThreatMult(spell)
if not spell or spell == "" then return 1.0 end
local m = SPELL_DAMAGE_THREAT_MULT[spell]
if m then return m end
-- Partial match for ranks / variants ("Mind Blast(Rank 5)", "Sunder Armor Rank 5")
local key, mult
for key, mult in pairs(SPELL_DAMAGE_THREAT_MULT) do
if string.find(spell, key, 1, true) then
return mult
end
end
return 1.0
end
local function SpellFlatThreat(spell)
if not spell or spell == "" then return 0 end
local v = SPELL_FLAT_THREAT[spell]
if v and v > 0 then return v end
local key, flat
for key, flat in pairs(SPELL_FLAT_THREAT) do
if flat > 0 and string.find(spell, key, 1, true) then
return flat
end
end
return 0
end
-- Threat-reducing buffs detectable on the local player (name patterns)
local THREAT_REDUCE_BUFFS = {
["Blessing of Salvation"] = 0.70,
["Greater Blessing of Salvation"] = 0.70,
["Tranquil Air"] = 0.80,
["Fade"] = 0.50, -- temporary; still useful if active
["Shadowform"] = 1.00, -- no change (placeholder)
["Innervate"] = 1.00,
}
local function IsInGroup()
return (GetNumRaidMembers() and GetNumRaidMembers() > 0)
or (GetNumPartyMembers() and GetNumPartyMembers() > 0)
end
-- True if the player OR any party/raid member is in combat.
-- Healers often leave themselves out of combat; overall threat still needs to run.
local function IsGroupInCombat()
if UnitAffectingCombat("player") then
return true
end
local i
local nParty = GetNumPartyMembers() or 0
for i = 1, nParty do
if UnitExists("party" .. i) and UnitAffectingCombat("party" .. i) then
return true
end
end
local nRaid = GetNumRaidMembers() or 0
for i = 1, nRaid do
if UnitExists("raid" .. i) and UnitAffectingCombat("raid" .. i) then
return true
end
end
return false
end
local function HasHostileTarget()
if not UnitExists("target") then return false end
if UnitIsPlayer("target") or UnitIsFriend("player", "target") then return false end
return true
end
local function ShowPetThreatEnabled()
return OM:GetSetting("showPetThreat") == true
end
local function PetAsTankEnabled()
return OM:GetSetting("petAsTank") == true
end
-- Local pet unit token / name (hunter pet or warlock minion)
local function GetLocalPetName()
if UnitExists("pet") then
return UnitName("pet")
end
return nil
end
-- Unit treated as the tank actor for tank-mode status checks
local function GetTankActorUnit()
if PetAsTankEnabled() and UnitExists("pet") then
return "pet"
end
return "player"
end
local function GetTankActorName()
local u = GetTankActorUnit()
return UnitName(u)
end
-- Sum damage attributed to the owner's pet from meter spell totals.
-- Parser stores pet hits as "Pet: <spell>" (or "Pet: Damage" when merged).
local function PetDamageFromMeterData(data)
if not data or not data.damageSpells then return 0 end
local total = 0
local spell, amt
for spell, amt in pairs(data.damageSpells) do
if type(spell) == "string" and string.sub(spell, 1, 5) == "Pet: " then
total = total + (amt or 0)
end
end
return total
end
-- Pet/minion ability names that generate flat threat (matched as substring)
local PET_FLAT_THREAT_NAMES = {
"Growl",
"Intimidation",
"Torment",
"Suffering",
}
local function IsPetFlatThreatSpell(spell)
if not spell or type(spell) ~= "string" then return false end
local i
for i = 1, table.getn(PET_FLAT_THREAT_NAMES) do
if string.find(spell, PET_FLAT_THREAT_NAMES[i], 1, true) then
return true
end
end
return false
end
-- Flat threat from pet/minion threat abilities credited on the owner row
local function PetAbilityThreatFromMeterData(data)
if not data or not data.threatCasts then return 0 end
local total = 0
local spell, count
for spell, count in pairs(data.threatCasts) do
if IsPetFlatThreatSpell(spell) then
count = count or 0
if count > 0 then
total = total + count * SpellFlatThreat(spell)
end
end
end
return total
end
-- Use RAID channel in a raid, otherwise PARTY
local function GetThreatChannel()
if GetNumRaidMembers and GetNumRaidMembers() > 0 then
return "RAID"
end
return "PARTY"
end
-- API replies are only expected for elite/worldboss targets that are in combat.
local function IsThreatApiTarget()
if not IsInGroup() then return false end
if not UnitExists("target") then return false end
if UnitIsPlayer("target") or UnitIsFriend("player", "target") then return false end
local classification = UnitClassification and UnitClassification("target") or nil
if classification ~= "worldboss"
and classification ~= "elite"
and classification ~= "rareelite" then
return false
end
-- No threat table until the mob is actually in combat
if UnitAffectingCombat and not UnitAffectingCombat("target") then
return false
end
return true
end
local function GetPlayerClass(name)
if OM.players and OM.players[name] and OM.players[name].class then
return OM.players[name].class
end
if name == UnitName("player") then
local _, c = UnitClass("player")
return c
end
return nil
end
local function ClearThreatTable()
local k
for k in pairs(Threat.threats) do
Threat.threats[k] = nil
end
Threat.tankName = nil
end
local function ClearTankModeTable()
local k
for k in pairs(Threat.tankModeThreats) do
Threat.tankModeThreats[k] = nil
end
end
-- threatView setting: single | tank | overall | all
local function GetThreatView()
local v = OM:GetSetting("threatView")
if v == "tank" or v == "overall" or v == "all" or v == "single" then
return v
end
-- Migration from enableTankingMode checkbox
if OM:GetSetting("enableTankingMode") == true then
return "tank"
end
return "single"
end
local function IsThreatModeKey(mode)
return mode == "threat" or mode == "tank" or mode == "overall"
end
-- Effective view for a given frame mode
local function EffectiveView(mode)
if mode == "tank" then return "tank" end
if mode == "overall" then return "overall" end
if mode == "threat" then
local v = GetThreatView()
if v == "all" or v == "single" then return "single" end
return v -- tank or overall when only that mode is registered as "threat"
end
return "single"
end
local function TankViewActive(mode)
return EffectiveView(mode or "threat") == "tank"
end
local function TankingModeEnabled()
if not OM:GetSetting("enableThreatMode") then return false end
local v = GetThreatView()
return v == "tank" or v == "all"
end
local function InvalidateThreatCaches()
Threat.dataGen = (Threat.dataGen or 0) + 1
-- listCache entries become stale via gen mismatch; drop tables to free memory
Threat.listCache = {}
-- targeting cache is tied to targetingCacheGen
end
-- Request TMTv1 multi-mob payload only when a tank window (or tank setting) needs it
local function NeedTankModeApi()
if not OM:GetSetting("enableThreatMode") then return false end
if Threat.AnyFrameInTankView and Threat:AnyFrameInTankView() then
return true
end
local v = GetThreatView()
return v == "tank" or v == "all"
end
-- ============================================================
-- GUID helpers (optional enhanced client APIs when present)
-- ============================================================
local function HasSuperWoW()
if OM and OM.HasSuperWoW then
return OM:HasSuperWoW()
end
return (SUPERWOW_VERSION or SUPERWOW_STRING or SuperWoW) and true or false
end
-- Returns a stable GUID string for a unit token, or nil
local function GetUnitGUID(unit)
if not unit then return nil end
-- Enhanced clients: second return of UnitExists may be a GUID
local exists, guid = UnitExists(unit)
if exists and type(guid) == "string" and guid ~= "" and guid ~= unit then
-- Prefer values that look like GUIDs (0x...) when present
if string.find(guid, "0x") or string.len(guid) >= 8 then
return guid
end
end
-- Some clients also expose UnitGUID(unit)
if UnitGUID then
local g = UnitGUID(unit)
if type(g) == "string" and g ~= "" then
return g
end
-- Some clients return UnitGUID as hi, lo numbers
if type(g) == "number" then
local hi, lo = UnitGUID(unit)
if hi and lo then
return string.format("0x%08X%08X", hi, lo)
end
end
end
return nil
end
-- Short suffix for UI when multiple mobs share a name
local function ShortGuidSuffix(guid)
if not guid or guid == "" then return nil end
local s = tostring(guid)
-- strip 0x prefix and take last 4 hex chars
s = string.gsub(s, "^0[xX]", "")
if string.len(s) > 4 then
s = string.sub(s, -4)
end
return s
end
-- Target a specific enemy by GUID when available, else by name
local function TargetEnemy(entry)
if not entry then return end
local guid = entry.data and entry.data.guid
local name = entry.name
if guid and HasSuperWoW() and TargetUnit then
-- Stale GUIDs can crash some clients; only target when UnitExists.
local exists = UnitExists(guid)
if exists then
TargetUnit(guid)
return
end
-- fall through to name targeting
end
if name and name ~= "" and TargetByName then
TargetByName(name, 1)
end
end
-- Remember the current hostile target as its own GUID-keyed enemy row
-- (lets Tank mode accumulate distinct same-name mobs as you tab through them)
local function RememberHostileUnit(unit)
if not unit or not UnitExists(unit) then return end
if UnitIsPlayer(unit) or UnitIsFriend("player", unit) then return end
local name = UnitName(unit) or "Enemy"
local guid = GetUnitGUID(unit)
if not guid or guid == "" then
guid = "name:" .. name
end
local existing = Threat.tankModeThreats[guid]
if not existing then
Threat.tankModeThreats[guid] = {
creature = name,
name = name,
guid = guid,
perc = 0,
threat = 0,
status = "red",
estimated = true,
}
else
existing.creature = existing.creature or name
existing.name = name
existing.guid = guid
end
end
local function RememberHostileTarget()
if not TankingModeEnabled() and not (Threat.AnyFrameInTankView and Threat:AnyFrameInTankView()) then
-- Still allow remember when a tank window is open even if threatView setting is single
end
if UnitExists("target")
and not UnitIsPlayer("target") and not UnitIsFriend("player", "target") then
RememberHostileUnit("target")
end
-- Pet-as-tank: track what the pet is hitting
if PetAsTankEnabled() and UnitExists("pettarget")
and not UnitIsPlayer("pettarget") and not UnitIsFriend("player", "pettarget") then
RememberHostileUnit("pettarget")
end
end
local function GetLocalThreatModifier()
local mod = 1.0
local _, class = UnitClass("player")
-- Warrior stance (GetShapeshiftFormInfo is available in 1.12)
if class == "WARRIOR" then
local i
for i = 1, 3 do
local _, name, isActive = GetShapeshiftFormInfo(i)
if isActive then
if name and string.find(string.lower(name), "defensive") then
mod = mod * 1.30
elseif name and string.find(string.lower(name), "berserker") then
mod = mod * 0.80
elseif name and string.find(string.lower(name), "battle") then
mod = mod * 0.80
end
break
end
end
elseif class == "DRUID" then
-- Bear / Dire Bear roughly 1.3 threat; cat lower
local i
for i = 1, 5 do
local _, name, isActive = GetShapeshiftFormInfo(i)
if isActive and name then
local lower = string.lower(name)
if string.find(lower, "bear") then
mod = mod * 1.30
elseif string.find(lower, "cat") then
mod = mod * 0.71
end
break
end
end
end
-- Scan buffs for major threat reducers (local player only)
local bi
for bi = 1, 32 do
local buffTexture = UnitBuff("player", bi)
if not buffTexture then break end
-- 1.12 without SuperWoW tooltip scanning only exposes texture;
-- use a conservative approach via tooltip if available.
-- Many private servers still expose the name via GameTooltip scan.
end
-- Tooltip-based buff name scan (works on most 1.12 clients)
if not Threat._buffTip then
Threat._buffTip = CreateFrame("GameTooltip", "GreedMeterThreatBuffTip", nil, "GameTooltipTemplate")
Threat._buffTip:SetOwner(WorldFrame, "ANCHOR_NONE")
end
local tip = Threat._buffTip
local bi2
for bi2 = 1, 32 do
tip:ClearLines()
tip:SetUnitBuff("player", bi2)
local text = getglobal("GreedMeterThreatBuffTipTextLeft1")
local buffName = text and text:GetText()
if not buffName then break end
local reduce = THREAT_REDUCE_BUFFS[buffName]
if reduce then
mod = mod * reduce
end
end
return mod
end
-- ============================================================
-- Server threat API path
-- ============================================================
local function SendThreatQuery()
-- Only query when the server is expected to answer
if not IsThreatApiTarget() then return end
local query = THREAT_QUERY_BASE
-- Tank-mode multi-mob payload only when a tank view actually needs it
if NeedTankModeApi() then
query = query .. "_TM"
end
-- limit = how many players the server should include in the reply
local msg = "limit=19"
local channel = GetThreatChannel()
SendAddonMessage(query, msg, channel)
end
local function ParseThreatPacket(packet)
local start = string.find(packet, THREAT_API_PREFIX, 1, true)
if not start then return false end
-- Packet may contain "TWTv4=...#TMTv1=..." when tank mode is active
local threatPart = packet
local tankPart = nil
local hash = string.find(packet, "#", 1, true)
if hash then
threatPart = string.sub(packet, 1, hash - 1)
tankPart = string.sub(packet, hash + 1)
end
local dataStart = string.find(threatPart, THREAT_API_PREFIX, 1, true)
local data = string.sub(threatPart, dataStart + string.len(THREAT_API_PREFIX))
ClearThreatTable()
Threat.usingApi = true
Threat.lastApiTime = GetTime()
-- Snapshots may be broadcast on PARTY/RAID when anyone queries.
-- Apply them to Overall even when this client has no hostile target.
if UnitExists("target")
and not UnitIsPlayer("target")
and not UnitIsFriend("player", "target") then
Threat.targetName = UnitName("target")
Threat.currentTargetKey = GetUnitGUID("target")
or ("name:" .. (Threat.targetName or "?"))
else
-- No local hostile target: keep the last mob key, or a shared fight key.
if not Threat.currentTargetKey then
Threat.currentTargetKey = "raid:active"
end
-- targetName left as last known for UI title
end
local pos = 1
local len = string.len(data)
while pos <= len do
local semi = string.find(data, ";", pos, true) or (len + 1)
local chunk = string.sub(data, pos, semi - 1)
pos = semi + 1
if chunk and chunk ~= "" then
local parts = {}
local p = 1
local clen = string.len(chunk)
while p <= clen do
local colon = string.find(chunk, ":", p, true) or (clen + 1)
table.insert(parts, string.sub(chunk, p, colon - 1))
p = colon + 1
end
if table.getn(parts) >= 4 then
local player = parts[1]
local tank = parts[2] == "1"
local threat = tonumber(parts[3]) or 0
local perc = tonumber(parts[4]) or 0
local melee = parts[5] == "1"
if player and player ~= "" then
local tps = 0
local hist = Threat.history[player]
local now = GetTime()
if hist and hist.time and (now - hist.time) > 0.05 then
tps = (threat - (hist.threat or 0)) / (now - hist.time)
if tps < 0 then tps = 0 end
end
Threat.history[player] = { threat = threat, time = now }
Threat.threats[player] = {
threat = threat,
perc = perc,
tank = tank,
tps = tps,
melee = melee,
class = GetPlayerClass(player),
estimated = false,
}
if tank then
Threat.tankName = player
end
end
end
end
end
-- Optional tank-mode multi-mob block
if tankPart and string.find(tankPart, TANK_MODE_API_PREFIX, 1, true) then
ClearTankModeTable()
local tStart = string.find(tankPart, TANK_MODE_API_PREFIX, 1, true)
local tData = string.sub(tankPart, tStart + string.len(TANK_MODE_API_PREFIX))
local tp = 1
local tlen = string.len(tData)
while tp <= tlen do
local semi = string.find(tData, ";", tp, true) or (tlen + 1)
local chunk = string.sub(tData, tp, semi - 1)
tp = semi + 1
if chunk and chunk ~= "" then
local parts = {}
local p = 1
local clen = string.len(chunk)
while p <= clen do
local colon = string.find(chunk, ":", p, true) or (clen + 1)
table.insert(parts, string.sub(chunk, p, colon - 1))
p = colon + 1
end
-- TMTv1 fields: creature:guid:secondPlayer:secondPerc
-- creature = mob name, name = runner-up player, perc = their threat %
-- Presence of a row means YOU are tanking that mob.
if table.getn(parts) >= 4 then
local creature = parts[1]
local guid = parts[2]
local secondPlayer = parts[3]
local secondPerc = tonumber(parts[4]) or 0
if not guid or guid == "" or guid == secondPlayer or guid == creature then
guid = "row:" .. tostring(creature) .. ":" .. tostring(secondPlayer) .. ":" .. tostring(secondPerc) .. ":" .. tostring(tp)
end
local status = (secondPerc >= 90) and "yellow" or "green"
-- Sort key: more contested (higher second %) → lower value → nearer top
local threat = math.max(1, 100 - secondPerc)
Threat.tankModeThreats[guid] = {
creature = creature,
name = secondPlayer, -- runner-up (kept for tooltip)
guid = guid,
perc = 100, -- you are the tank on this row
secondPerc = secondPerc,
threat = threat,
status = status,
estimated = false,
}
end
end
end
end
-- Pull-aggro threshold is part of single-target Threat view
Threat:AddPullAggroRow()
Threat:UpdateOverallFromPlayerThreats()
InvalidateThreatCaches()
return true
end
function Threat:AddPullAggroRow()
local tankThreat = 0
local myMelee = false
local me = UnitName("player")
local name, data
for name, data in pairs(self.threats) do
if data.tank then
tankThreat = data.threat or 0
break
end
end
if self.threats[me] then
myMelee = self.threats[me].melee and true or false
end
-- Classic rule: melee pulls at 110% of tank, ranged at 130%
local mult = myMelee and 1.1 or 1.3
local pullThreat = tankThreat * mult
if pullThreat <= 0 then pullThreat = 1 end
self.threats[PULL_AGGRO_NAME] = {
threat = pullThreat,
perc = myMelee and 110 or 130,
tank = false,
tps = 0,
melee = false,
class = nil,
isPull = true,
estimated = not self.usingApi,
}
end
-- ============================================================
-- 1.12 estimation fallback (party/raid only)
-- Uses the damage meter segment only — no second pass over combat log
-- and no parallel damage/heal sample buffers.
-- ============================================================
local function SegmentDuration(segment)
if not segment or not segment.startTime or segment.startTime <= 0 then
return 0
end
local endT = segment.endTime or 0
local now = GetTime()
local dur
if endT and endT > segment.startTime then
dur = endT - segment.startTime
else
dur = now - segment.startTime
end
-- Prefer trimmed activity window when the meter recorded it
local lastAct = segment.lastActivityTime
if lastAct and lastAct >= segment.startTime and lastAct <= now then
local trimmed = lastAct - segment.startTime
if trimmed > 0 and (dur <= 0 or trimmed < dur) then
dur = trimmed
end
end
if dur < 1 then dur = 1 end
return dur
end
-- Classic-style relative threat from meter totals already parsed by GreedMeter.
local function ThreatFromMeterPlayer(name, data, me, localMod)
if not data then return 0, nil end
local class = data.class or GetPlayerClass(name)
local mod = 1.0
if class and CLASS_THREAT_MOD[class] then
mod = CLASS_THREAT_MOD[class]
end
if me and name == me then
mod = mod * (localMod or 1.0)
end
-- Damage threat: prefer per-spell totals so high-threat abilities can be weighted.
-- Meter already parsed these once into data.damage / data.damageSpells.
local dmgThreat = 0
local accounted = 0
if data.damageSpells then
local spell, amt
for spell, amt in pairs(data.damageSpells) do
amt = amt or 0
dmgThreat = dmgThreat + amt * SpellDamageThreatMult(spell)
accounted = accounted + amt
end
end
local totalDmg = data.damage or 0
local rest = totalDmg - accounted
if rest > 0 then
dmgThreat = dmgThreat + rest
elseif accounted <= 0 then
dmgThreat = totalDmg
end
-- Healing threat (~0.5x effective heal — parser stores effective in data.healing)
local healThreat = (data.healing or 0) * 0.5
-- Flat threat from non-damaging applications (Sunder, Demo Shout, etc.)
local flatThreat = 0
if data.threatCasts then
local spell, count
for spell, count in pairs(data.threatCasts) do
count = count or 0
if count > 0 then
flatThreat = flatThreat + count * SpellFlatThreat(spell)
end
end
end
local threat = (dmgThreat + healThreat + flatThreat) * mod
return threat, class
end
local function BuildEstimatedThreat()
ClearThreatTable()
Threat.usingApi = false
Threat.targetName = UnitName("target")
-- Solo allowed when "Show pets" is on so hunters/locks can quest with threat bars
if not IsInGroup() and not ShowPetThreatEnabled() then
return
end
if not HasHostileTarget() then
return
end
if not UI or not UI.GetSegmentData then return end
local segment = UI.GetSegmentData("current")
if not segment or not segment.players then return end
local me = UnitName("player")
local localMod = 1.0
if me then
localMod = GetLocalThreatModifier()
end
local tankUnit = GetTankActorUnit()
local tankName = GetTankActorName() or me
local iAmTanking = UnitExists("targettarget") and UnitIsUnit(tankUnit, "targettarget")
if PetAsTankEnabled() and not iAmTanking and UnitExists("pettargettarget") then
iAmTanking = UnitIsUnit("pet", "pettargettarget")
end
local maxThreat = 0
local list = {}
local name, data
for name, data in pairs(segment.players) do
local threat, class = ThreatFromMeterPlayer(name, data, me, localMod)
local petThreat = 0
if ShowPetThreatEnabled() and me and name == me then
local petDmg = PetDamageFromMeterData(data)
local growlThreat = PetAbilityThreatFromMeterData(data)
local petPortion = petDmg + growlThreat
if petPortion > 0 then
-- Remove pet portion from the owner row (class/stance mod was applied to the sum)
local stripped = threat - (petPortion * localMod)
if stripped < 0 then stripped = 0 end
threat = stripped
-- Pet threat is not scaled by the hunter's stance/class mod
petThreat = petPortion
end
end
if name == tankName and iAmTanking then
threat = threat * 1.15
end
if threat > 0 then
table.insert(list, {
name = name,
threat = threat,
class = class,
isLikelyTank = (name == tankName and iAmTanking) or false,
isPet = false,
})
if threat > maxThreat then maxThreat = threat end
end
if petThreat > 0 then
local petName = GetLocalPetName() or "Pet"
local petIsTank = PetAsTankEnabled() and iAmTanking
if petIsTank then
petThreat = petThreat * 1.15
end
table.insert(list, {
name = petName,
threat = petThreat,
class = class,
isLikelyTank = petIsTank,
isPet = true,
})
if petThreat > maxThreat then maxThreat = petThreat end
end
end
-- Solo with show pets: still show the pet if a name is known
if ShowPetThreatEnabled() and not IsInGroup() and table.getn(list) == 0 and me then
local pdata = segment.players[me]
if pdata then
local petDmg = PetDamageFromMeterData(pdata)
local growlThreat = PetAbilityThreatFromMeterData(pdata)
local petPortion = petDmg + growlThreat
if petPortion > 0 then
local petName = GetLocalPetName() or "Pet"
table.insert(list, {
name = petName,
threat = petPortion,
class = pdata.class,
isLikelyTank = iAmTanking and PetAsTankEnabled(),
isPet = true,
})
maxThreat = petPortion
end
end
end
local ttName = UnitExists("targettarget") and UnitName("targettarget") or nil
local duration = SegmentDuration(segment)
local i
for i = 1, table.getn(list) do
local e = list[i]
local perc = 0
if maxThreat > 0 then
perc = math.floor((e.threat / maxThreat) * 100 + 0.5)
end
local isTank = e.isLikelyTank
or (ttName and e.name == ttName)
or (perc >= 100 and maxThreat > 0 and e.threat >= maxThreat)
local tps = 0
if duration > 0 then
tps = e.threat / duration
end
Threat.threats[e.name] = {
threat = e.threat,
perc = perc,
tank = isTank,
tps = tps,
melee = false,
class = e.class,
estimated = true,
isPet = e.isPet and true or false,
}
if isTank then
Threat.tankName = e.name
end
end
Threat:AddPullAggroRow()
Threat:UpdateOverallFromPlayerThreats()
InvalidateThreatCaches()
end
-- Overall rankings with no personal target — still only meter totals.
local function BuildEstimatedOverallOnly()
if not IsInGroup() then return end
if not UI or not UI.GetSegmentData then return end
local segment = UI.GetSegmentData("current")
if not segment or not segment.players then return end
local me = UnitName("player")
local localMod = 1.0
if me then
localMod = GetLocalThreatModifier()
end
local duration = SegmentDuration(segment)
local name, data
for name, data in pairs(segment.players) do
local threat, class = ThreatFromMeterPlayer(name, data, me, localMod)
if threat > 0 then
local tps = 0
if duration > 0 then
tps = threat / duration
end
Threat.overallThreat[name] = {
threat = threat,
class = class,
estimated = true,
tank = false,
tps = tps,
}
end
end
InvalidateThreatCaches()
end
-- ============================================================
-- Enemy status (Tank mode): red / yellow / green
-- ============================================================
-- perc = your threat as % of the highest threat on that mob (API convention)
local function EnemyStatusFromPerc(perc)
perc = tonumber(perc) or 0
if perc < 100 then
return "red" -- you do not have aggro
end
-- At max threat. Without second-place data treat
-- a thin margin above 100 as contested when API later supplies it;
-- for a plain 100% reading mark green (secure lead on the meter).
return "green"
end
-- When runner-up ratio (0-1 of your threat) is known, refine yellow
local function EnemyStatusFromLead(myThreat, secondThreat)
myThreat = tonumber(myThreat) or 0
secondThreat = tonumber(secondThreat) or 0
if myThreat <= 0 then
return "red"
end
if secondThreat > myThreat then
return "red" -- someone else is ahead
end
local ratio = secondThreat / myThreat
if ratio >= 0.90 then
return "yellow" -- someone is close to pulling
end
return "green"
end
-- Live snapshot of the tank actor's threat on the current target
-- (player by default; pet when "Use pet as Tank" is on)
local function CurrentTargetThreatSnapshot()
local actor = GetTankActorName() or UnitName("player")
local actorUnit = GetTankActorUnit()
local myData = actor and Threat.threats[actor]
local myThreat = myData and (myData.threat or 0) or 0
local myPerc = myData and (myData.perc or 0) or 0
-- Pet-as-tank: also try player row if pet has no estimate yet
if PetAsTankEnabled() and myThreat <= 0 then
local petName = GetLocalPetName()
if petName and Threat.threats[petName] then
myData = Threat.threats[petName]
myThreat = myData.threat or 0
myPerc = myData.perc or 0
actor = petName
end
end
local iAmTanking = UnitExists("targettarget") and UnitIsUnit(actorUnit, "targettarget")
-- If looking at pettarget, check that unit's targettarget
if not iAmTanking and PetAsTankEnabled() and UnitExists("pettarget") then
if UnitExists("pettargettarget") and UnitIsUnit("pet", "pettargettarget") then
iAmTanking = true
end
end
local second = 0
local n, d
for n, d in pairs(Threat.threats) do
if n ~= actor and not d.isPull and (d.threat or 0) > second then
second = d.threat or 0
end
end
local status
if iAmTanking then
status = EnemyStatusFromLead(myThreat, second)
if myThreat <= 0 then
myThreat = 1
myPerc = 100
end
else
status = "red"
if myThreat <= 0 and myPerc <= 0 then
myThreat = 0
end
end
return myThreat, myPerc, status, iAmTanking
end
-- Resolve max HP / boss classification for a tank-mode enemy row.
-- Prefer GUID unit tokens when available, otherwise the current target
-- or party/raid members' targets.
local function ResolveEnemyMaxHP(guid, name)
local function inspect(unit)
if not unit or not UnitExists(unit) then return 0, false end
if UnitIsPlayer(unit) or UnitIsFriend("player", unit) then return 0, false end
local maxHP = UnitHealthMax(unit) or 0
local isBoss = false
if UnitClassification then
local c = UnitClassification(unit)
if c == "worldboss" then
isBoss = true
end
end
-- Level -1 is a classic boss marker on many 1.12 clients
if UnitLevel then
local lvl = UnitLevel(unit)
if lvl and lvl < 0 then
isBoss = true
end
end
return maxHP, isBoss
end
if guid and type(guid) == "string"
and not string.find(guid, "^name:")
and not string.find(guid, "^row:")
and not string.find(guid, "^test") then
local hp, boss = inspect(guid)
if hp > 0 or boss then return hp, boss end
end
if UnitExists("target") and not UnitIsPlayer("target") and not UnitIsFriend("player", "target") then
local tGuid = GetUnitGUID("target")
local tName = UnitName("target")
if (guid and tGuid and tGuid == guid) or (name and tName and tName == name) then
return inspect("target")
end
end
-- Scan group targets for a matching GUID/name
local function scan(unit)
if not UnitExists(unit) then return nil end
local uGuid = GetUnitGUID(unit)
local uName = UnitName(unit)
if (guid and uGuid and uGuid == guid) or (name and uName and uName == name) then
local hp, boss = inspect(unit)
return hp, boss
end
return nil
end
local i
for i = 1, 4 do
local hp, boss = scan("party" .. i .. "target")
if hp then return hp, boss end
end
for i = 1, 40 do
local hp, boss = scan("raid" .. i .. "target")
if hp then return hp, boss end
end
return 0, false
end
-- Flag list entries whose max HP is much higher than the rest (boss among adds).
local function MarkBossesByRelativeHP(list)
if not list or table.getn(list) < 2 then return end
local hps = {}
local i
for i = 1, table.getn(list) do
local hp = list[i].data and list[i].data.maxHP or 0
if hp > 0 then
table.insert(hps, hp)
end
end
if table.getn(hps) < 2 then return end
table.sort(hps, function(a, b) return a < b end)
-- Median of the lower half (exclude the top outlier when computing "typical" add HP)
local n = table.getn(hps)
local typical
if n >= 3 then
-- median of all but the largest value
local trimmed = n - 1
local mid = math.floor((trimmed + 1) / 2)
if math.mod(trimmed, 2) == 0 then
typical = (hps[mid] + hps[mid + 1]) / 2
else
typical = hps[mid]
end
else
typical = hps[1] -- smaller of the two
end
if not typical or typical <= 0 then return end
-- Boss threshold: at least 2.5x typical add HP (and preferably the clear top)
local threshold = typical * 2.5
for i = 1, table.getn(list) do
local d = list[i].data
if d and not d.isBoss then
local hp = d.maxHP or 0
if hp >= threshold then
d.isBoss = true
end
end
end
end
-- ============================================================
-- Flee detection (tank mode LOST → FLEEING)
-- ============================================================
local FLEE_FLAG_DURATION = 8.0
local FLEE_DEBUFFS = {
["Fear"] = true,
["Psychic Scream"] = true,
["Intimidating Shout"] = true,
["Howl of Terror"] = true,
["Scare Beast"] = true,
["Seduction"] = true,
["Blind"] = true,
}
local function MarkEnemyFleeing(name, guid)
local untilT = GetTime() + FLEE_FLAG_DURATION
if guid and guid ~= "" then
Threat.fleeingEnemies[guid] = untilT
end
if name and name ~= "" then
Threat.fleeingEnemies[name] = untilT
end
end
local function ClearEnemyFleeing(name, guid)
if guid then Threat.fleeingEnemies[guid] = nil end
if name then Threat.fleeingEnemies[name] = nil end
end
local function IsEnemyMarkedFleeing(name, guid)
local now = GetTime()
local t
if guid then
t = Threat.fleeingEnemies[guid]
if t and t > now then return true end
if t then Threat.fleeingEnemies[guid] = nil end
end
if name then
t = Threat.fleeingEnemies[name]
if t and t > now then return true end
if t then Threat.fleeingEnemies[name] = nil end
end
return false
end
-- Live inspect: fear-like debuffs on a unit token (target / SuperWoW GUID)
local function UnitHasFleeDebuff(unit)
if not unit or not UnitExists(unit) then return false end
if not UnitDebuff then return false end
local i
for i = 1, 16 do
-- 1.12 UnitDebuff returns texture, stacks, debuffType (no name on many clients)
local texture, applications, debuffType = UnitDebuff(unit, i)
if not texture then break end
-- Prefer tooltip scan for the name when available
end
-- GameTooltip-based name scan (works on 1.12)
if not GameTooltip or not GameTooltip.SetUnitDebuff then
return false
end
for i = 1, 16 do
local texture = UnitDebuff(unit, i)
if not texture then break end
GameTooltip:SetOwner(UIParent, "ANCHOR_NONE")
GameTooltip:SetUnitDebuff(unit, i)
local tipName = GameTooltipTextLeft1 and GameTooltipTextLeft1:GetText()
GameTooltip:Hide()
if tipName and FLEE_DEBUFFS[tipName] then
return true
end
-- Partial match for ranked names
if tipName then
local k
for k in pairs(FLEE_DEBUFFS) do
if string.find(tipName, k, 1, true) then
return true
end
end
end
end
return false
end
local function DetectFleeOnUnit(unit, name, guid)
if UnitHasFleeDebuff(unit) then
MarkEnemyFleeing(name, guid)
return true
end
return IsEnemyMarkedFleeing(name, guid)
end
-- Combat log / emote lines that indicate a mob is fleeing
local function ParseFleeMessage(message)
if not message or message == "" then return end
local lower = string.lower(message)
if not (string.find(lower, "flee", 1, true)
or string.find(lower, "run away", 1, true)
or string.find(lower, "runs away", 1, true)) then
return
end
-- "%s attempts to run away in fear!" / "%s flees in fear!" / "X flees."
local name
local _
_, _, name = string.find(message, "^(.+) attempts to run away")
if not name then
_, _, name = string.find(message, "^(.+) flees")
end
if not name then
_, _, name = string.find(message, "^(.+) runs away")
end
if not name then return end
name = string.gsub(name, "%s+$", "")
if name == "" or name == "You" or name == "you" then return end
MarkEnemyFleeing(name, nil)
end
function Threat:BuildEnemyList(hiddenNames)
local list = {}
-- Track player target and (when enabled) pet target
RememberHostileTarget()
local function RefreshActiveUnit(unit)
if not unit or not UnitExists(unit) then return end
if UnitIsPlayer(unit) or UnitIsFriend("player", unit) then return end
local tName = UnitName(unit) or "Target"
local tGuid = GetUnitGUID(unit) or ("name:" .. tName)
local myThreat, myPerc, status = CurrentTargetThreatSnapshot()
-- When using pet as tank and this is the player's target (not pettarget),
-- still report the pet actor's status on that mob.
local row = self.tankModeThreats[tGuid]
if row then
row.name = tName
row.creature = row.creature or tName
row.threat = myThreat
row.perc = myPerc
row.status = status
if myThreat > 0 or status ~= "red" then
row.estimated = not self.usingApi
end
end
end
RefreshActiveUnit("target")
if PetAsTankEnabled() then
RefreshActiveUnit("pettarget")
end
-- Multi-mob table is GUID-keyed so same-name mobs stay distinct
local guid, info
for guid, info in pairs(self.tankModeThreats) do
local mobName = info.creature or info.name or "?"
-- TMTv1: creature=mob, name=second-highest player, perc=their share
-- Estimation rows use name=mob. Prefer creature when it looks like a mob label.
if info.creature and info.creature ~= "" then
mobName = info.creature
end
local hideKey = guid or mobName
if not hiddenNames or (not hiddenNames[mobName] and not hiddenNames[hideKey]) then
local perc = tonumber(info.perc) or 0
local threat = tonumber(info.threat) or 0
local status = info.status
local estimated = info.estimated
-- API tank-mode rows: you are tanking this mob; perc is the runner-up's %
if not info.isTest and not estimated and (info.guid or guid)
and not string.find(tostring(guid), "^name:")
and not string.find(tostring(guid), "^row:")
and status == nil then
-- Tanking: contested if second place is high
if perc >= 90 then
status = "yellow"
else
status = "green"
end
-- Sort key: lower margin → lower value → sorts nearer the top (danger)
if threat <= 0 then
threat = math.max(1, 100 - perc)
end
-- Display perc as YOUR share (you're the tank on this row)
perc = 100
end
if not status then
status = EnemyStatusFromPerc(perc)
end
if threat <= 0 and status ~= "red" then
threat = 1
end
if threat <= 0 and perc > 0 then
threat = perc
end
local rowGuid = info.guid or guid
local maxHP, isBossUnit = ResolveEnemyMaxHP(rowGuid, mobName)
table.insert(list, {
name = mobName,
data = {
threat = threat,
perc = perc,
status = status,
isEnemy = true,
guid = rowGuid,
creature = info.creature,
estimated = estimated,
isTest = info.isTest,
secondPlayer = (info.name and info.creature and info.name ~= info.creature) and info.name or nil,
maxHP = maxHP,
isBoss = isBossUnit,
},
value = threat,
})
end
end
-- Mark bosses by relative max HP: significantly tougher than peers in this list
MarkBossesByRelativeHP(list)
-- Annotate fleeing (combat-log/emote flag; live fear scan only on current target)
local li
for li = 1, table.getn(list) do
local e = list[li]
local d = e.data
if d then
local guid = d.guid
local fleeing = IsEnemyMarkedFleeing(e.name, guid)
if not fleeing and UnitExists("target") and UnitName("target") == e.name then
fleeing = DetectFleeOnUnit("target", e.name, guid)
end
-- Solid aggro again: clear a stale flee flag
if d.status == "green" or d.status == "yellow" then
ClearEnemyFleeing(e.name, guid)
fleeing = false
end
d.fleeing = fleeing and true or false
end
end
-- Sort priority (top → bottom):
-- 1) Bosses still pinned first
-- 2) LOST (no aggro, not fleeing)
-- 3) FLEEING
-- 4) Contested / secure aggro (yellow / green)
-- Within a group: lowest threat first (weakest hold nearer the top)
local function AggroRank(d)
if not d then return 1 end
if d.fleeing then return 2 end
local s = d.status
if s == "green" or s == "yellow" then
return 1 -- have / contested aggro — bottom group
end
return 3 -- LOST
end
table.sort(list, function(a, b)
local aBoss = (a.data and a.data.isBoss) and 1 or 0
local bBoss = (b.data and b.data.isBoss) and 1 or 0
if aBoss ~= bBoss then
return aBoss > bBoss
end
local ar = AggroRank(a.data)
local br = AggroRank(b.data)
if ar ~= br then
return ar > br
end
if a.value == b.value then
local ga = (a.data and a.data.guid) or a.name
local gb = (b.data and b.data.guid) or b.name
return tostring(ga) < tostring(gb)
end
return a.value < b.value
end)
-- Disambiguate identical names
local nameCounts = {}
local i
for i = 1, table.getn(list) do
local n = list[i].name
nameCounts[n] = (nameCounts[n] or 0) + 1
end
local nameIndex = {}
for i = 1, table.getn(list) do
local e = list[i]
e.rank = i
if nameCounts[e.name] and nameCounts[e.name] > 1 then
nameIndex[e.name] = (nameIndex[e.name] or 0) + 1
local suffix = ShortGuidSuffix(e.data and e.data.guid)
if suffix and not string.find(tostring(e.data.guid), "^name:")
and not string.find(tostring(e.data.guid), "^row:")
and not string.find(tostring(e.data.guid), "^test") then
e.displayName = e.name .. " [" .. suffix .. "]"
else
e.displayName = e.name .. " #" .. tostring(nameIndex[e.name])
end
else
e.displayName = e.name
end
end
return list
end
-- ============================================================
-- Sorted list for the meter
-- ============================================================
-- Accumulate fight-total threat generated per player (not target-specific)
-- Rebuild overallThreat from per-target API snapshots when possible.
-- Server API reports party threat on one mob (current target), not a full matrix.
-- Store each packet under that mob key and sum each player across observed mobs.
function Threat:UpdateOverallFromPlayerThreats()
local targetKey = self.currentTargetKey
if not targetKey then
-- Fall back to name of current target if known
if UnitExists("target") and not UnitIsPlayer("target") then
targetKey = GetUnitGUID("target") or ("name:" .. (UnitName("target") or "?"))
self.currentTargetKey = targetKey
end
end
local anyApi = false
local name, data
-- Live target key: snapshot current threats table into that bucket
if targetKey then
if not self.threatByTarget[targetKey] then
self.threatByTarget[targetKey] = {}
end
local bucket = self.threatByTarget[targetKey]
for name, data in pairs(self.threats) do
if name and not data.isPull then
bucket[name] = data.threat or 0
if data.estimated == false then
anyApi = true
end
end
end
end
-- Sum across all observed targets for each player
local totals = {} -- [player] = { threat, class, tank, tps, estimated }
local tKey, bucket
for tKey, bucket in pairs(self.threatByTarget) do
for name, threat in pairs(bucket) do
local row = totals[name]
if not row then
row = { threat = 0, class = GetPlayerClass(name), tank = false, tps = 0, estimated = false }
totals[name] = row
end
row.threat = row.threat + (threat or 0)
end
end
-- Overlay class/tank/tps/estimated from the live single-target table
for name, data in pairs(self.threats) do
if name and not data.isPull then
local row = totals[name]
if not row then
-- No per-target buckets yet (estimate-only path): keep previous behavior
local prev = self.overallThreat[name]
local prevT = prev and prev.threat or 0
local t = data.threat or 0
local best = t
if prevT > best then best = prevT end
if data.estimated == false and t > prevT then
best = t
end
self.overallThreat[name] = {
threat = best,
class = data.class or GetPlayerClass(name),
estimated = data.estimated and true or false,
tank = data.tank,
tps = data.tps or 0,
}
else
row.class = data.class or row.class or GetPlayerClass(name)
row.tank = data.tank or row.tank
row.tps = data.tps or row.tps
if data.estimated == false then
row.estimated = false
end
end
end
end
for name, row in pairs(totals) do
self.overallThreat[name] = {
threat = row.threat,
class = row.class,
estimated = row.estimated,
tank = row.tank,
tps = row.tps,
}
end
end
-- Enemies currently targeting a given player (group-visible hostile units)
-- Returns ordered list of unique enemy names.
function Threat:GetEnemiesTargetingPlayer(playerName)
local names = {}
if not playerName or playerName == "" then return names end
-- One unit-token scan per player per data generation (shared across windows)
if self.targetingCacheGen == self.dataGen and self.targetingCache[playerName] then
return self.targetingCache[playerName]
end
if self.targetingCacheGen ~= self.dataGen then
self.targetingCache = {}
self.targetingCacheGen = self.dataGen or 0
end
local seen = {}
local function considerEnemyUnit(unit)
if not unit or not UnitExists(unit) then return end
if UnitIsPlayer(unit) or UnitIsFriend("player", unit) then return end
if UnitIsDead(unit) or UnitIsCorpse(unit) then return end
local tt = unit .. "target"
if not UnitExists(tt) then return end
if UnitName(tt) ~= playerName then return end
local enemyName = UnitName(unit)
if not enemyName or enemyName == "" then return end
local key = GetUnitGUID(unit) or (enemyName .. ":" .. tostring(UnitHealth(unit)))
if seen[key] then return end
seen[key] = true
table.insert(names, enemyName)
end
-- Direct unit tokens
considerEnemyUnit("target")
considerEnemyUnit("mouseover")
considerEnemyUnit("pettarget")
-- Party / raid members' targets (common way to see adds in 1.12)
local i
for i = 1, 4 do
considerEnemyUnit("party" .. i .. "target")
considerEnemyUnit("partypet" .. i .. "target")
end
for i = 1, 40 do
considerEnemyUnit("raid" .. i .. "target")
considerEnemyUnit("raidpet" .. i .. "target")
end
-- Known GUID-keyed enemies can be addressed as unit tokens on some clients
if HasSuperWoW() then
local guid, info
for guid, info in pairs(self.tankModeThreats) do
if type(guid) == "string" and not string.find(guid, "^name:")
and not string.find(guid, "^row:")
and not string.find(guid, "^test") then
considerEnemyUnit(guid)
end
end
end
self.targetingCache[playerName] = names
return names
end
-- Stable fake "targeted by" lists for Test mode previews
local function FakeTargetingForTest(playerName, isTank)
if isTank then
return { "Onyxia", "Onyxian Warder", "Onyxian Warder", "Onyxian Guard" }
end
-- Lightweight pseudo-random from name length so rows look different
local n = string.len(playerName or "")
if math.mod(n, 5) == 0 then
return { "Onyxian Whelp" }
elseif math.mod(n, 5) == 1 then
return { "Onyxian Whelp", "Onyxian Whelp" }
elseif math.mod(n, 5) == 2 then
return { "Lava Spawn" }
end
return {}
end
function Threat:BuildOverallList(hiddenNames)
local list = {}
local name, data
for name, data in pairs(self.overallThreat) do
if not hiddenNames or not hiddenNames[name] then
local value = data.threat or 0
if value > 0 then
local targeting = self:GetEnemiesTargetingPlayer(name)
if self.testDataActive and table.getn(targeting) == 0 then
targeting = FakeTargetingForTest(name, data.tank)
end
table.insert(list, {
name = name,
data = {
threat = value,
perc = 0,
tank = data.tank,
tps = data.tps or 0,
class = data.class,
estimated = data.estimated,
isOverall = true,
isTest = self.testDataActive,
targetedBy = targeting,
targetedByCount = table.getn(targeting),
},
value = value,
})
end
end
end
-- Also include anyone present only in current target threats
for name, data in pairs(self.threats) do
if not data.isPull and (not self.overallThreat[name] or (self.overallThreat[name].threat or 0) <= 0) then
if not hiddenNames or not hiddenNames[name] then
local value = data.threat or 0
if value > 0 then
local targeting = self:GetEnemiesTargetingPlayer(name)
if self.testDataActive and table.getn(targeting) == 0 then
targeting = FakeTargetingForTest(name, data.tank)
end
table.insert(list, {
name = name,
data = {
threat = value,
perc = data.perc or 0,
tank = data.tank,
tps = data.tps or 0,
class = data.class,
estimated = data.estimated,
isOverall = true,
isTest = self.testDataActive,
targetedBy = targeting,
targetedByCount = table.getn(targeting),
},
value = value,
})
end
end
end
end
table.sort(list, function(a, b)
if a.value == b.value then return a.name < b.name end
return a.value > b.value
end)
local maxVal = 0
if list[1] then maxVal = list[1].value or 0 end
local i
for i = 1, table.getn(list) do
list[i].rank = i
if maxVal > 0 then
list[i].data.perc = math.floor((list[i].value / maxVal) * 100 + 0.5)
end
end
return list
end
function Threat:GetSortedList(hiddenNames, mode)
mode = mode or "threat"
local view = EffectiveView(mode)
local gen = self.dataGen or 0
-- Shared list cache: multiple windows on the same view reuse one build
local function filterHidden(src)
if not src then return {} end
if not hiddenNames then
-- Shallow copy so RefreshFrame can append a Total row safely
local copy = {}
local i
for i = 1, table.getn(src) do
copy[i] = src[i]
end
return copy
end
local out = {}
local i
for i = 1, table.getn(src) do
local e = src[i]
local hideKey = (e.data and e.data.guid) or e.name
if not hiddenNames[e.name] and not hiddenNames[hideKey] then
table.insert(out, e)
end
end
for i = 1, table.getn(out) do
out[i].rank = i
end
return out
end
local cached = self.listCache and self.listCache[view]
-- Tank list includes live flee/HP probes — reuse briefly, then rebuild
-- Single-target must refresh often so targettarget (aggro) switches show up
local maxAge = 0.5
if cached and cached.gen == gen and cached.list then
local age = GetTime() - (cached.time or 0)
if age <= maxAge then
return filterHidden(cached.list)
end
end
local list
if view == "tank" then
list = self:BuildEnemyList(nil) -- unfiltered; hide applied below
elseif view == "overall" then
self:UpdateOverallFromPlayerThreats()
list = self:BuildOverallList(nil)
else
-- Single-target Threat mode → players + Pull Aggro row
-- Live aggro always comes from who the enemy is attacking right now.
-- Estimates/API numbers can lag; targettarget is ground truth for "has threat".
local aggroName = nil
if UnitExists("target") and UnitExists("targettarget")
and not UnitIsDead("targettarget") then
aggroName = UnitName("targettarget")
end
-- When using pet as tank and looking at pet's mob, prefer pettargettarget
if PetAsTankEnabled() and UnitExists("pettarget") and UnitExists("pettargettarget")
and not UnitIsDead("pettargettarget") then
-- If player has no target or target is the pet's mob, use pet's aggro target
if not aggroName or (UnitExists("target") and UnitIsUnit("target", "pettarget")) then
aggroName = UnitName("pettargettarget")
end
end
-- If the live aggro holder changed, force a fresh list even if dataGen is stale
if aggroName ~= self._lastAggroName then
self._lastAggroName = aggroName
-- Skip cached result; list was just rebuilt
end
list = {}
local name, data
for name, data in pairs(self.threats) do
local value = data.threat or 0
if not data.isPull then
-- Override tank flag from live unit targeting
if aggroName and name == aggroName then
data.tank = true
self.tankName = name
else
data.tank = false
end
end
if value > 0 or data.tank or data.isPull then
table.insert(list, {
name = name,
data = data,
value = value,
})
end
end
-- If the mob is on someone not yet in the threat table (common mid-estimate),
-- inject a row so they still appear at the top.
if aggroName and not self.threats[aggroName] then
local injected = {
threat = 1,
perc = 100,
tank = true,
tps = 0,
melee = false,
class = GetPlayerClass(aggroName),
estimated = true,
}
self.threats[aggroName] = injected
self.tankName = aggroName
table.insert(list, {
name = aggroName,
data = injected,
value = 1,
})
end
table.sort(list, function(a, b)
local aPull = a.data and a.data.isPull
local bPull = b.data and b.data.isPull
local aTank = a.data and a.data.tank and not aPull
local bTank = b.data and b.data.tank and not bPull
-- Whoever currently has aggro is always first (above Pull Aggro too)
if aTank and not bTank then return true end
if bTank and not aTank then return false end
if aPull and not bPull then
return a.value > (b.value or 0)
end
if bPull and not aPull then
return (a.value or 0) > b.value
end
if a.value == b.value then
return (a.name or "") < (b.name or "")
end
return a.value > b.value
end)
local i
for i = 1, table.getn(list) do
list[i].rank = i
end
end
if not self.listCache then self.listCache = {} end
self.listCache[view] = { gen = gen, list = list, time = GetTime() }
return filterHidden(list)
end
function Threat:AnyFrameInThreatMode()
if not UI or not UI.frames then return false end
local _, f
for _gi = 1, table.getn(UI.frames) do local f = UI.frames[_gi]
if f:IsShown() and IsThreatModeKey(f.mode) then
return true
end
end
return false
end
function Threat:AnyFrameInTankView()
if not UI or not UI.frames then return false end
local _, f
for _gi = 1, table.getn(UI.frames) do local f = UI.frames[_gi]
if f:IsShown() and TankViewActive(f.mode) then
return true
end
end
return false
end
function Threat:RefreshOpenFrames()
if not UI or not UI.frames then return end
local i, f
for i = 1, table.getn(UI.frames) do
f = UI.frames[i]
if f and f:IsShown() and IsThreatModeKey(f.mode) then
Threat:RefreshFrame(f)
end
end
end
local function EstimateSignature()
local act, tgt = 0, ""
if UI and UI.GetSegmentData then
local seg = UI.GetSegmentData("current")
if seg then
act = seg.lastActivityTime or seg.damage or 0
end
end
if UnitExists("target") then
tgt = UnitName("target") or ""
end
local pet = (OM.GetSetting and OM:GetSetting("showPetThreat") == true) and 1 or 0
local tank = (OM.GetSetting and OM:GetSetting("petAsTank") == true) and 1 or 0
return tostring(act) .. "|" .. tgt .. "|" .. tostring(pet) .. tostring(tank)
end
-- ============================================================
-- UI hooks
-- ============================================================
-- Format secondary text for a threat entry (RefreshFrame path)
local function FormatThreatSecondary(data)
if not data then return "0" end
-- Enemy row (Tank view)
if data.isEnemy then
local perc = data.perc or 0
local threat = data.threat or 0
local text
if UI.FormatNumber then
text = UI.FormatNumber(threat)
else
text = tostring(math.floor(threat + 0.5))
end
text = text .. " (" .. tostring(math.floor(perc + 0.5)) .. "%)"
if data.status == "green" then
text = text .. " OK"
elseif data.status == "yellow" then
text = text .. " !!"
elseif data.fleeing then
text = text .. " FLEEING"
else
text = text .. " LOST"
end
if data.estimated then text = text .. "*" end
return text
end
-- Overall fight threat (respects Customization column toggles)
if data.isOverall then
local function show(key)
if UI.GetColumnSetting then
return UI.GetColumnSetting("overall", key)
end
-- Fallbacks if helpers not loaded yet
if key == "share" or key == "rate" then return false end
return true
end
local threat = data.threat or 0
local perc = data.perc or 0
local tps = data.tps or 0
local count = data.targetedByCount or 0
local text = ""
if show("amount") then
if UI.FormatNumber then
text = UI.FormatNumber(threat)
else
text = tostring(math.floor(threat + 0.5))
end
end
if show("share") then
text = text .. " (" .. tostring(math.floor(perc + 0.5)) .. "%)"
end
if show("rate") and tps and tps > 0 then
if UI.FormatNumber then
text = text .. " " .. UI.FormatNumber(tps)
else
text = text .. " " .. string.format("%.0f", tps)
end
end
-- "Targeted by" — enemy count in parentheses (was always on before)
if show("targeted") then
text = text .. " (" .. tostring(count) .. ")"
end
if data.estimated then
text = text .. "*"
end
if text == "" or text == "*" then
return "0"
end
return text
end
if data.isPull then
local t
if UI.FormatNumber then
t = UI.FormatNumber(data.threat or 0)
else
t = tostring(math.floor((data.threat or 0) + 0.5))
end
return t .. " (" .. tostring(data.perc or 0) .. "%)"
end
local threat = data.threat or 0
local perc = data.perc or 0
local tps = data.tps or 0
local function show(key)
if UI.GetColumnSetting then
return UI.GetColumnSetting("threat", key)
end
return true
end
local text = ""
if show("amount") then
if UI.FormatNumber then
text = UI.FormatNumber(threat)
else
text = tostring(math.floor(threat + 0.5))
end
end
-- % of main tank / raw percent
if show("share") then
local shareStr
if Threat.tankName and not data.tank then
local tankData = Threat.threats[Threat.tankName]
local tankThreat = tankData and tankData.threat or 0
if tankThreat > 0 then
local ofTank = math.floor((threat / tankThreat) * 100 + 0.5)
shareStr = " (" .. tostring(ofTank) .. "% MT)"
else
shareStr = " (" .. tostring(perc) .. "%)"
end
else
shareStr = " (" .. tostring(perc) .. "%)"
end
text = text .. shareStr
end
if show("rate") and tps and tps > 0 then
if UI.FormatNumber then
text = text .. "(" .. UI.FormatNumber(tps) .. ")"
else
text = text .. "(" .. string.format("%.0f", tps) .. ")"
end
end
if data.estimated then
text = text .. "*"
end
if text == "" or text == "*" then
return "0"
end
return text
end
local function GetThreatBarColor(name, data)
-- Tank mode enemy colors
if data and data.isEnemy then
if data.status == "green" then
return 0.2, 0.85, 0.2
elseif data.status == "yellow" then
return 1.0, 0.85, 0.15
else
return 0.95, 0.2, 0.2
end
end
if OM.GetSetting and OM:GetSetting("classColors") == false then
return 0.55, 0.55, 0.55
end
-- Pull Aggro threshold row stays distinct (not a player)
if data and data.isPull then
return 1.0, 0.35, 0.35
end
-- Always use class color for players (including the current tank)
local class = data and data.class
if not class and OM.players and OM.players[name] then
class = OM.players[name].class
end
if not class and name == UnitName("player") then
local _, c = UnitClass("player")
class = c
end
local colors = UI.CLASS_COLORS
if class and colors and colors[class] then
return colors[class][1], colors[class][2], colors[class][3]
end
return 0.6, 0.6, 0.6
end
-- TargetEnemy is defined above with SuperWoW GUID support
-- ============================================================
-- Threat warnings (single-target view only — not tank / overall)
-- ============================================================
local warnGlowFrame = nil
local warnSoundArmed = true
-- Built-in client sounds for threat warning (key -> PlaySoundFile path)
local WARN_SOUND_FILES = {
raidwarning = "Sound\\Interface\\RaidWarning.wav",
questfail = "Sound\\Interface\\igQuestFailed.wav",
mapping = "Sound\\Interface\\MapPing.wav",
bellally = "Sound\\Doodad\\BellTollAlliance.wav",
bellhorde = "Sound\\Doodad\\BellTollHorde.wav",
bellne = "Sound\\Doodad\\BellTollNightElf.wav",
auction = "Sound\\Interface\\AuctionWindowOpen.wav",
}
local function GetWarnSoundPath()
local key = OM.GetSetting and OM:GetSetting("threatWarnSoundFile")
if key and WARN_SOUND_FILES[key] then
return WARN_SOUND_FILES[key]
end
return WARN_SOUND_FILES.raidwarning
end
local function EnsureWarnGlow()
if warnGlowFrame then return warnGlowFrame end
local f = CreateFrame("Frame", "GreedMeterThreatWarnGlow", UIParent)
f:SetFrameStrata("BACKGROUND")
f:SetFrameLevel(0)
f:SetAllPoints(UIParent)
f:EnableMouse(false)
f:Hide()
local screenW = UIParent:GetWidth() or 1024
local screenH = UIParent:GetHeight() or 768
local topH = screenH * 0.06
local sideW = screenW * 0.04
-- Outer → inner: stronger at the monitor edge, fade toward center
local bands = {
{ 0.30, 0.22 },
{ 0.18, 0.26 },
{ 0.09, 0.26 },
{ 0.03, 0.26 },
}
local function Band(alpha)
local tex = f:CreateTexture(nil, "BACKGROUND")
tex:SetTexture(1, 0.06, 0.06, alpha)
return tex
end
local yOff = 0
local i
for i = 1, table.getn(bands) do
local h = topH * bands[i][2]
local top = Band(bands[i][1])
top:SetPoint("TOPLEFT", f, "TOPLEFT", 0, -yOff)
top:SetPoint("TOPRIGHT", f, "TOPRIGHT", 0, -yOff)
top:SetHeight(h)
local bot = Band(bands[i][1])
bot:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 0, yOff)
bot:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", 0, yOff)
bot:SetHeight(h)
yOff = yOff + h
end
-- Sides sit between the top and bottom stacks so corners are not doubled
local xOff = 0
for i = 1, table.getn(bands) do
local w = sideW * bands[i][2]
local left = Band(bands[i][1])
left:SetPoint("TOPLEFT", f, "TOPLEFT", xOff, -topH)
left:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", xOff, topH)
left:SetWidth(w)
local right = Band(bands[i][1])
right:SetPoint("TOPRIGHT", f, "TOPRIGHT", -xOff, -topH)
right:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -xOff, topH)
right:SetWidth(w)
xOff = xOff + w
end
warnGlowFrame = f
return f
end
local function HideThreatWarnGlow()
if warnGlowFrame then warnGlowFrame:Hide() end
end
local function ShowThreatWarnGlow()
local f = EnsureWarnGlow()
f:Show()
end
local function InPartyOrRaid()
local nRaid = GetNumRaidMembers and GetNumRaidMembers() or 0
if nRaid and nRaid > 0 then return true end
local nParty = GetNumPartyMembers and GetNumPartyMembers() or 0
if nParty and nParty > 0 then return true end
return false
end
-- Live enemy you can pull off a teammate. Not players, corpses, or friendlies.
local function WarnTargetIsLiveEnemy()
if not UnitExists("target") then return false end
if UnitIsDead("target") or (UnitIsCorpse and UnitIsCorpse("target")) then return false end
if UnitIsPlayer("target") then return false end
if UnitIsFriend("player", "target") then return false end
if UnitCanAttack and not UnitCanAttack("player", "target") then return false end
return true
end
-- Someone else currently has this mob. First-pull / you-tanking is not a warning.
local function SomeoneElseHasAggro()
if not UnitExists("targettarget") then return false end
if UnitIsUnit("targettarget", "player") then return false end
if UnitExists("pet") and UnitIsUnit("targettarget", "pet") then return false end
if UnitIsUnit("targettarget", "target") then return false end
return true
end
-- Your threat as a percent of the current tank's threat (not of the list max).
local function PlayerThreatVsTankPercent()
local me = UnitName("player")
if not me then return 0 end
local myRow = Threat.threats and Threat.threats[me]
local myT = myRow and tonumber(myRow.threat) or 0
local tankName = UnitExists("targettarget") and UnitName("targettarget") or nil
if not tankName or tankName == me then return 0 end
local tankRow = Threat.threats and Threat.threats[tankName]
local tankT = tankRow and tonumber(tankRow.threat) or 0
if tankT > 0 and myT > 0 then
return (myT / tankT) * 100
end
if myRow and myRow.perc then
return tonumber(myRow.perc) or 0
end
return 0
end
local function ThreatWarningsAllowed()
if not OM:GetSetting("enableThreatMode") then return false end
if OM:GetSetting("testMode") == true then return false end
local v = GetThreatView()
if v == "tank" or v == "overall" then return false end
if not InPartyOrRaid() then return false end
if not WarnTargetIsLiveEnemy() then return false end
if not SomeoneElseHasAggro() then return false end
return true
end
local function UpdateThreatWarnings()
if not ThreatWarningsAllowed() then
HideThreatWarnGlow()
warnSoundArmed = true
return
end
local threshold = tonumber(OM:GetSetting("threatWarnPercent")) or 90
if threshold < 50 then threshold = 50 end
if threshold > 100 then threshold = 100 end
local perc = PlayerThreatVsTankPercent()
local over = perc >= threshold
if OM:GetSetting("threatWarnGlow") == true and over then
ShowThreatWarnGlow()
else
HideThreatWarnGlow()
end
if OM:GetSetting("threatWarnSound") == true then
if over and warnSoundArmed then
warnSoundArmed = false
local path = GetWarnSoundPath()
local ok = pcall(PlaySoundFile, path)
if not ok then
pcall(PlaySound, "RaidWarning")
end
elseif not over then
warnSoundArmed = true
end
else
warnSoundArmed = true
end
end
-- Drive meter bars for threat mode. Frames.lua captures list builders at load
-- time, so this owns the full refresh path when f.mode == "threat".
function Threat:RefreshFrame(f)
if not f then return end
if OM then OM._readIndex = f.layoutIndex end
-- Ensure test data is present when Test mode is on (no party required)
if OM:GetSetting("testMode") == true then
if not self.testDataActive then
self:LoadTestData()
end
end
if not f.visibleBars and UI.LayoutBars then
UI:LayoutBars(f)
end
local mode = f.mode or "threat"
local view = EffectiveView(mode)
local tankMode = (view == "tank")
local overallMode = (view == "overall")
if f.title then
if tankMode then
f.title:SetText((UI.MODE_LABELS and UI.MODE_LABELS.tank) or "Tank")
elseif overallMode then
f.title:SetText((UI.MODE_LABELS and UI.MODE_LABELS.overall) or "Overall Threat")
else
f.title:SetText((UI.MODE_LABELS and UI.MODE_LABELS.threat) or "Threat")
end
end
local list = self:GetSortedList(f.hiddenNames, mode)
-- Optional total bar (player lists only — not for enemy tank mode)
if not tankMode and OM.GetSetting and OM:GetSetting("showTotal") and table.getn(list) > 0 then
local totalVal = 0
local _, entry
for _gi = 1, table.getn(list) do local entry = list[_gi]
if not entry.data or not entry.data.isPull then
totalVal = totalVal + (entry.value or 0)
end
end
table.insert(list, {
name = "Total",
data = { threat = totalVal, perc = 100, tank = false, tps = 0 },
value = totalVal,
isTotal = true,
})
end
local maxVal = 0
local _, entry
for _gi = 1, table.getn(list) do local entry = list[_gi]
if not entry.isTotal and entry.value and entry.value > maxVal then
maxVal = entry.value
end
end
if maxVal <= 0 and list[1] then
maxVal = list[1].value or 1
end
if maxVal <= 0 then maxVal = 1 end
local hideTitle = OM.GetSetting and OM:GetSetting("hideTitle") == true
if UI.ApplyHeaderLayout then
UI:ApplyHeaderLayout(f, hideTitle, 0)
end
local MAX_BARS = UI.MAX_BARS or 40
local fit = f.visibleBars or MAX_BARS
local hasTotal = false
local totalEntry = nil
local playerList = {}
for _gi = 1, table.getn(list) do local entry = list[_gi]
if entry.isTotal then
hasTotal = true
totalEntry = entry
else
table.insert(playerList, entry)
end
end
local playerSlots = fit
if hasTotal and fit > 1 then
playerSlots = fit - 1
elseif hasTotal and fit == 1 then
playerSlots = 0
end
local playerCount = table.getn(playerList)
local maxScroll = playerCount - playerSlots
if maxScroll < 0 then maxScroll = 0 end
f.maxScroll = maxScroll
if not f.scrollOffset then f.scrollOffset = 0 end
if f.scrollOffset > maxScroll then f.scrollOffset = maxScroll end
if f.scrollOffset < 0 then f.scrollOffset = 0 end
local scroll = f.scrollOffset
local barH = 16
if OM.GetSetting then
barH = tonumber(OM:GetSetting("barHeight")) or 16
end
local shown = 0
local i
for i = 1, MAX_BARS do
local bar = f.bars and f.bars[i]
if not bar then break end
local entry = nil
if i <= playerSlots then
entry = playerList[i + scroll]
elseif hasTotal and i == playerSlots + 1 then
entry = totalEntry
end
if entry and i <= fit then
shown = shown + 1
local pct = (entry.value or 0) / maxVal
if pct > 1 then pct = 1 end
if pct < 0 then pct = 0 end
bar:Show()
bar:SetValue(pct)
local r, g, b
if entry.isTotal then
r, g, b = 0.7, 0.7, 0.7
else
r, g, b = GetThreatBarColor(entry.name, entry.data)
end
bar:SetStatusBarColor(r, g, b, 0.9)
local rank = entry.rank or (i + scroll)
local isEnemy = entry.data and entry.data.isEnemy
local showIcon = OM.GetSetting and OM:GetSetting("showClassIcons")
and not (entry.data and entry.data.isPull)
and not isEnemy
and not entry.isTotal
if showIcon and bar.classIcon then
local class = entry.data and entry.data.class
local coords = UI.CLASS_ICON_TCOORDS and class and UI.CLASS_ICON_TCOORDS[class]
if coords then
bar.classIcon:SetWidth(barH)
bar.classIcon:SetHeight(barH)
bar.classIcon:SetTexture("Interface\\Glues\\CharacterCreate\\UI-CharacterCreate-Classes")
bar.classIcon:SetTexCoord(coords[1], coords[2], coords[3], coords[4])
bar.classIcon:Show()
bar.nameText:ClearAllPoints()
bar.nameText:SetPoint("LEFT", bar.classIcon, "RIGHT", 2, 0)
else
bar.classIcon:Hide()
bar.nameText:ClearAllPoints()
bar.nameText:SetPoint("LEFT", bar, "LEFT", 4, 0)
end
else
if bar.classIcon then bar.classIcon:Hide() end
if bar.nameText then
bar.nameText:ClearAllPoints()
bar.nameText:SetPoint("LEFT", bar, "LEFT", 4, 0)
end
end
if bar.nameText then
if entry.data and entry.data.isPull then
bar.nameText:SetText(entry.name)
else
local label = entry.displayName or entry.name
if UI.FormatBarName then
bar.nameText:SetText(UI.FormatBarName(rank, label, mode))
else
bar.nameText:SetText(rank .. ". " .. label)
end
end
end
if bar.valueText then
bar.valueText:SetText(FormatThreatSecondary(entry.data))
end
bar.entry = entry
bar.mode = mode
bar.duration = 0
-- Tank view: click a bar to target that enemy (taunt / switch assist).
-- StatusBar is not a Button in 1.12, so OnClick is unavailable — use OnMouseUp.
-- With SuperWoW, TargetUnit(guid) selects the exact mob even when names match.
if isEnemy then
bar:SetScript("OnMouseUp", function()
if arg1 ~= "LeftButton" then return end
local e = this.entry
if e then
TargetEnemy(e)
end
end)
else
bar:SetScript("OnMouseUp", nil)
end
else
bar:Hide()
bar.entry = nil
bar:SetScript("OnMouseUp", nil)
end
end
if f.emptyLabel then
f.emptyLabel:SetText("")
f.emptyLabel:Hide()
end
end
local function InstallUIHooks()
if Threat._hooksInstalled then return end
Threat._hooksInstalled = true
-- Own the refresh path for threat modes (see note above about Frames.lua locals)
if UI.RefreshFrame then
local oldRefreshFrame = UI.RefreshFrame
UI.RefreshFrame = function(self, f)
if f and IsThreatModeKey(f.mode) then
return Threat:RefreshFrame(f)
end
return oldRefreshFrame(self, f)
end
end
-- Tooltip: bar OnEnter often calls UI.ShowBarTooltip if present
local oldTooltip = UI.ShowBarTooltip
if oldTooltip then
UI.ShowBarTooltip = function(bar)
if bar and IsThreatModeKey(bar.mode) and bar.entry and bar.entry.data then
local d = bar.entry.data
GameTooltip:SetOwner(bar, "ANCHOR_RIGHT")
if d.isEnemy then
GameTooltip:AddLine(bar.entry.name or "?", 1, 1, 1)
GameTooltip:AddDoubleLine("Your threat", tostring(math.floor((d.threat or 0) + 0.5)), 0.8, 0.8, 0.8, 1, 1, 1)
GameTooltip:AddDoubleLine("% of max", tostring(math.floor((d.perc or 0) + 0.5)) .. "%", 0.8, 0.8, 0.8, 1, 1, 1)
if d.status == "green" then
GameTooltip:AddLine("Solid threat lead", 0.2, 1, 0.2)
elseif d.status == "yellow" then
GameTooltip:AddLine("Contested — someone is close", 1, 0.85, 0.2)
else
GameTooltip:AddLine("You do not have aggro", 1, 0.3, 0.3)
end
GameTooltip:AddLine("Click to target", 0.6, 0.6, 0.6)
elseif d.isOverall then
GameTooltip:AddLine(bar.entry.name or "?", 1, 1, 1)
GameTooltip:AddDoubleLine("Total threat", tostring(math.floor((d.threat or 0) + 0.5)), 0.8, 0.8, 0.8, 1, 1, 1)
if d.perc and d.perc > 0 then
GameTooltip:AddDoubleLine("% of max", tostring(d.perc) .. "%", 0.8, 0.8, 0.8, 1, 1, 1)
end
local count = d.targetedByCount or 0
GameTooltip:AddDoubleLine("Enemies targeting", tostring(count), 0.8, 0.8, 0.8, 1, 0.85, 0.3)
if d.targetedBy and table.getn(d.targetedBy) > 0 then
GameTooltip:AddLine("Targeted by:", 0.7, 0.7, 0.7)
local ti
for ti = 1, table.getn(d.targetedBy) do
GameTooltip:AddLine(" " .. d.targetedBy[ti], 1, 0.85, 0.4)
end
elseif count == 0 then
GameTooltip:AddLine("No visible enemies targeting this player.", 0.5, 0.5, 0.5)
end
elseif d.isPull then
GameTooltip:AddLine("Pull Aggro Threshold", 1, 0.4, 0.4)
GameTooltip:AddLine("Threat needed to pull from the current tank.", 0.7, 0.7, 0.7, 1)
GameTooltip:AddDoubleLine("Threshold", tostring(math.floor((d.threat or 0) + 0.5)), 0.8, 0.8, 0.8, 1, 1, 1)
GameTooltip:AddDoubleLine("% of tank", tostring(d.perc or 0) .. "%", 0.8, 0.8, 0.8, 1, 1, 1)
else
GameTooltip:AddLine(bar.entry.name or "?", 1, 1, 1)
GameTooltip:AddDoubleLine("Threat", tostring(math.floor((d.threat or 0) + 0.5)), 0.8, 0.8, 0.8, 1, 1, 1)
GameTooltip:AddDoubleLine("% of max", tostring(d.perc or 0) .. "%", 0.8, 0.8, 0.8, 1, 1, 1)
if Threat.tankName and Threat.threats[Threat.tankName] then
local tt = Threat.threats[Threat.tankName].threat or 0
if tt > 0 then
local ofTank = math.floor(((d.threat or 0) / tt) * 100 + 0.5)
GameTooltip:AddDoubleLine("% of tank", ofTank .. "%", 0.8, 0.8, 0.8, 1, 1, 1)
local toPull = (tt * ((d.melee and 1.1) or 1.3)) - (d.threat or 0)
if toPull < 0 then toPull = 0 end
GameTooltip:AddDoubleLine("Threat to pull", tostring(math.floor(toPull + 0.5)), 0.8, 0.8, 0.8, 1, 0.5, 0.5)
end
end
if d.tps and d.tps > 0 then
GameTooltip:AddDoubleLine("TPS", string.format("%.1f", d.tps), 0.8, 0.8, 0.8, 1, 1, 1)
end
if d.tank then
GameTooltip:AddLine("Tanking", 0.2, 1, 0.2)
end
end
if d.isTest then
GameTooltip:AddLine("Test data", 0.5, 0.8, 1)
elseif d.estimated then
GameTooltip:AddLine("Estimated (no Turtle API)", 1, 0.7, 0.2)
else
GameTooltip:AddLine("Turtle Threat API", 0.4, 1, 0.4)
end
if Threat.tankName and not d.isEnemy then
GameTooltip:AddLine("Tank: " .. Threat.tankName, 0.6, 0.8, 1)
end
GameTooltip:Show()
return
end
return oldTooltip(bar)
end
end
end
-- ============================================================
-- Mode list management
-- ============================================================
local THREAT_MODE_KEYS = { "threat", "tank", "overall" }
local function EnsureModeInList(enabled)
if not UI.MODE_ORDER or not UI.MODE_LABELS then return end
-- Mutate MODE_ORDER in place. Frames.lua captures the table at load time.
local i, k
for i = table.getn(UI.MODE_ORDER), 1, -1 do
local key = UI.MODE_ORDER[i]
if key == "threat" or key == "tank" or key == "overall" then
table.remove(UI.MODE_ORDER, i)
end
end
UI.MODE_LABELS.threat = nil
UI.MODE_LABELS.tank = nil
UI.MODE_LABELS.overall = nil
if not enabled then
if UI.frames then
local _, f
for _gi = 1, table.getn(UI.frames) do local f = UI.frames[_gi]
if IsThreatModeKey(f.mode) then
f.mode = "damage"
if f.title then f.title:SetText(UI.MODE_LABELS.damage or "Damage") end
if UI.RefreshFrame then UI:RefreshFrame(f) end
end
end
end
return
end
local view = GetThreatView()
if view == "all" then
table.insert(UI.MODE_ORDER, "threat")
table.insert(UI.MODE_ORDER, "tank")
table.insert(UI.MODE_ORDER, "overall")
UI.MODE_LABELS.threat = "Threat"
UI.MODE_LABELS.tank = "Tank"
UI.MODE_LABELS.overall = "Overall Threat"
elseif view == "tank" then
table.insert(UI.MODE_ORDER, "tank")
UI.MODE_LABELS.tank = "Tank"
elseif view == "overall" then
table.insert(UI.MODE_ORDER, "overall")
UI.MODE_LABELS.overall = "Overall Threat"
else
table.insert(UI.MODE_ORDER, "threat")
UI.MODE_LABELS.threat = "Threat"
end
-- If a frame is stuck on a mode that was removed, snap to a valid one
if UI.frames then
local valid = {}
if view == "all" then
valid.threat = true
valid.tank = true
valid.overall = true
elseif view == "tank" then
valid.tank = true
elseif view == "overall" then
valid.overall = true
else
valid.threat = true
end
local _, f
for _gi = 1, table.getn(UI.frames) do local f = UI.frames[_gi]
if IsThreatModeKey(f.mode) and not valid[f.mode] then
if valid.threat then
f.mode = "threat"
elseif valid.tank then
f.mode = "tank"
elseif valid.overall then
f.mode = "overall"
else
f.mode = "damage"
end
if UI.RefreshFrame then UI:RefreshFrame(f) end
end
end
end
end
-- ============================================================
-- Settings checkboxes (parent + child, non-colliding)
-- ============================================================
local function SyncChildEnabled(parentCb, childCb, parentKey)
local parentOn = OM:GetSetting(parentKey) == true
if parentOn then
childCb:Enable()
if childCb.label then childCb.label:SetTextColor(1, 1, 1) end
else
childCb:Disable()
if childCb.label then childCb.label:SetTextColor(0.5, 0.5, 0.5) end
-- Force child off when parent is off
if OM:GetSetting(childCb.settingKey) then
OM:SetSetting(childCb.settingKey, false)
childCb:SetChecked(nil)
if childCb.onToggle then childCb.onToggle(false) end
end
end
end
local THREAT_VIEW_OPTIONS = {
{ key = "single", label = "Single target" },
{ key = "tank", label = "Tank" },
{ key = "overall", label = "Overall" },
{ key = "all", label = "All" },
}
local function ThreatViewLabel(key)
local i
for i = 1, table.getn(THREAT_VIEW_OPTIONS) do
if THREAT_VIEW_OPTIONS[i].key == key then
return THREAT_VIEW_OPTIONS[i].label
end
end
return "Single target"
end
local function AddThreatCheckboxToSettings(f)
if not f then return end
-- Settings.lua now builds Add threat mode on General natively
if f.threatModeCb or f._greedThreatCheckbox then
f._greedThreatCheckbox = true
return
end
-- Only on General tab page (so it never overlaps Display/Appearance/Modes)
local parent = f.pageGeneral
if not parent then return end
local extras = OM.extraSettingsCheckboxes or {}
if table.getn(extras) == 0 then return end
table.sort(extras, function(a, b)
return (a.order or 100) < (b.order or 100)
end)
local rowH = 22
local dropH = 32
local y = f._threatExtrasY
if not y then y = -220 end
if y > -40 then y = -40 end
for i = 1, table.getn(extras) do
local entry = extras[i]
local cb = CreateFrame("CheckButton", nil, parent, "UICheckButtonTemplate")
cb:SetWidth(24)
cb:SetHeight(24)
cb:SetPoint("TOPLEFT", parent, "TOPLEFT", 16, y)
local fs = parent:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
fs:SetPoint("LEFT", cb, "RIGHT", 2, 0)
fs:SetText(entry.label)
cb.label = fs
cb.settingKey = entry.key
local cur = OM:GetSetting(entry.key)
if cur == nil then cur = entry.default end
cb:SetChecked(cur and 1 or nil)
local childCbs = {}
local childDrop = nil
local childDropBtn = nil
local function SyncDropdownEnabled()
if not childDrop then return end
local parentOn = OM:GetSetting(entry.key) == true
if parentOn then
if UIDropDownMenu_EnableDropDown then
UIDropDownMenu_EnableDropDown(childDrop)
end
if childDropBtn then
childDropBtn:Enable()
childDropBtn:EnableMouse(true)
end
if childDrop.label then childDrop.label:SetTextColor(1, 1, 1) end
else
if UIDropDownMenu_DisableDropDown then
UIDropDownMenu_DisableDropDown(childDrop)
end
if childDropBtn then
childDropBtn:Disable()
childDropBtn:EnableMouse(false)
end
if CloseDropDownMenus then CloseDropDownMenus() end
if childDrop.label then childDrop.label:SetTextColor(0.5, 0.5, 0.5) end
end
end
cb:SetScript("OnClick", function()
local checked = this:GetChecked() and true or false
OM:SetSetting(entry.key, checked)
if entry.onToggle then entry.onToggle(checked) end
local c
for _gi = 1, table.getn(childCbs) do local c = childCbs[_gi]
SyncChildEnabled(this, c, entry.key)
end
SyncDropdownEnabled()
if UI.RefreshThreatVisibility then
UI.RefreshThreatVisibility(f)
end
if UI.ApplySettingsToFrames then UI:ApplySettingsToFrames() end
end)
if entry.tooltip then
cb:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetText(entry.tooltip, nil, nil, nil, nil, 1)
GameTooltip:Show()
end)
cb:SetScript("OnLeave", function() GameTooltip:Hide() end)
end
y = y - rowH
if entry.childDropdown then
local ddInfo = entry.childDropdown
local opts = ddInfo.options or THREAT_VIEW_OPTIONS
local lbl = parent:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
lbl:SetPoint("TOPLEFT", parent, "TOPLEFT", 40, y - 2)
lbl:SetText(ddInfo.label or "View:")
local ddName = "GreedMeterThreatViewDropDown"
local dd = CreateFrame("Frame", ddName, parent, "UIDropDownMenuTemplate")
dd:SetPoint("LEFT", lbl, "RIGHT", -8, -2)
dd.label = lbl
childDrop = dd
childDropBtn = getglobal(ddName .. "Button")
local function OnSelect()
if OM:GetSetting(entry.key) ~= true then return end
local id = this:GetID()
local opt = opts[id]
if not opt then return end
OM:SetSetting(ddInfo.key, opt.key)
UIDropDownMenu_SetSelectedID(dd, id)
UIDropDownMenu_SetText(opt.label, dd)
if ddInfo.onChange then ddInfo.onChange(opt.key) end
end
-- Always list threat-view options. Returning early when threat is off
-- left the shared UIDropDownMenu list filled with whatever was last
-- opened (e.g. bar fonts like Morpheus).
local function Init()
local curKey = OM:GetSetting(ddInfo.key) or ddInfo.default or "single"
local oi
for oi = 1, table.getn(opts) do
local info = {}
info.text = opts[oi].label
info.func = OnSelect
info.checked = (opts[oi].key == curKey)
UIDropDownMenu_AddButton(info)
end
end
UIDropDownMenu_Initialize(dd, Init)
UIDropDownMenu_SetWidth(110, dd)
UIDropDownMenu_SetButtonWidth(110, dd)
local curKey = OM:GetSetting(ddInfo.key) or ddInfo.default or "single"
UIDropDownMenu_SetText(ThreatViewLabel(curKey), dd)
local sel = 1
local oi
for oi = 1, table.getn(opts) do
if opts[oi].key == curKey then sel = oi break end
end
UIDropDownMenu_SetSelectedID(dd, sel)
if ddInfo.tooltip then
dd:SetScript("OnEnter", function()
if OM:GetSetting(entry.key) ~= true then return end
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetText(ddInfo.tooltip, nil, nil, nil, nil, 1)
GameTooltip:Show()
end)
dd:SetScript("OnLeave", function() GameTooltip:Hide() end)
end
SyncDropdownEnabled()
y = y - dropH
end
if entry.children then
local ci
for ci = 1, table.getn(entry.children) do
local child = entry.children[ci]
local ccb = CreateFrame("CheckButton", nil, parent, "UICheckButtonTemplate")
ccb:SetWidth(20)
ccb:SetHeight(20)
ccb:SetPoint("TOPLEFT", parent, "TOPLEFT", 36, y)
local cfs = parent:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
cfs:SetPoint("LEFT", ccb, "RIGHT", 2, 0)
cfs:SetText(child.label)
ccb.label = cfs
ccb.settingKey = child.key
ccb.onToggle = child.onToggle
local ccur = OM:GetSetting(child.key)
if ccur == nil then ccur = child.default end
ccb:SetChecked(ccur and 1 or nil)
ccb:SetScript("OnClick", function()
local checked = this:GetChecked() and true or false
OM:SetSetting(child.key, checked)
if child.onToggle then child.onToggle(checked) end
if UI.ApplySettingsToFrames then UI:ApplySettingsToFrames() end
end)
if child.tooltip then
ccb:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetText(child.tooltip, nil, nil, nil, nil, 1)
GameTooltip:Show()
end)
ccb:SetScript("OnLeave", function() GameTooltip:Hide() end)
end
table.insert(childCbs, ccb)
SyncChildEnabled(cb, ccb, entry.key)
y = y - rowH
end
end
end
f._greedThreatCheckbox = true
-- Expand General tab height to include threat block
if f.tabHeights then
local need = (-y) + 36
if need < 180 then need = 180 end
f.tabHeights.general = math.max(f.tabHeights.general or 140, need)
end
if f.SelectTab then
f.SelectTab(f.activeTab or "general")
end
end
local function HookSettingsFrame()
if not UI or not UI.CreateSettingsFrame then return end
if Threat._settingsHooked then return end
Threat._settingsHooked = true
local oldCreate = UI.CreateSettingsFrame
UI.CreateSettingsFrame = function(self)
local f = oldCreate(self)
AddThreatCheckboxToSettings(f)
return f
end
if UI.settingsFrame then
AddThreatCheckboxToSettings(UI.settingsFrame)
end
end
-- ============================================================
-- Events / update loop
-- ============================================================
local eventFrame = CreateFrame("Frame")
eventFrame:RegisterEvent("CHAT_MSG_ADDON")
eventFrame:RegisterEvent("PLAYER_TARGET_CHANGED")
eventFrame:RegisterEvent("PLAYER_REGEN_ENABLED")
eventFrame:RegisterEvent("PLAYER_REGEN_DISABLED")
eventFrame:RegisterEvent("PARTY_MEMBERS_CHANGED")
eventFrame:RegisterEvent("RAID_ROSTER_UPDATE")
-- Flee / fear combat-log lines
eventFrame:RegisterEvent("CHAT_MSG_MONSTER_EMOTE")
eventFrame:RegisterEvent("CHAT_MSG_MONSTER_SAY")
eventFrame:RegisterEvent("CHAT_MSG_COMBAT_HOSTILE_DEATH")
eventFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE")
eventFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE")
eventFrame:RegisterEvent("CHAT_MSG_SPELL_AURA_GONE_OTHER")
eventFrame:RegisterEvent("CHAT_MSG_SPELL_AURA_GONE_SELF")
eventFrame:RegisterEvent("CHAT_MSG_COMBAT_MISC_INFO")
eventFrame:RegisterEvent("CHAT_MSG_SYSTEM")
eventFrame:SetScript("OnEvent", function()
if not OM:GetSetting("enableThreatMode") then return end
if event == "CHAT_MSG_ADDON" then
if arg2 and string.find(arg2, THREAT_API_PREFIX, 1, true) then
if ParseThreatPacket(arg2) then
if Threat:AnyFrameInThreatMode() then
Threat:RefreshOpenFrames()
end
end
end
elseif event == "PLAYER_TARGET_CHANGED" then
-- Do NOT wipe tankModeThreats here — Tank mode needs to keep every
-- distinct GUID (including multiple same-name mobs) across target swaps.
ClearThreatTable()
Threat.usingApi = false
Threat.targetName = UnitName("target")
RememberHostileTarget()
InvalidateThreatCaches()
if Threat:AnyFrameInThreatMode() then
Threat:RefreshOpenFrames()
end
elseif event == "PLAYER_REGEN_ENABLED" then
-- Leaving combat: drop the accumulated enemy list and flee flags
if not OM:GetSetting("testMode") then
ClearTankModeTable()
end
local k
for k in pairs(Threat.fleeingEnemies) do
Threat.fleeingEnemies[k] = nil
end
elseif event == "PARTY_MEMBERS_CHANGED" or event == "RAID_ROSTER_UPDATE" then
if not IsInGroup() then
ClearThreatTable()
if not OM:GetSetting("testMode") then
ClearTankModeTable()
end
Threat.usingApi = false
if Threat:AnyFrameInThreatMode() then
Threat:RefreshOpenFrames()
end
end
elseif event == "CHAT_MSG_MONSTER_EMOTE"
or event == "CHAT_MSG_MONSTER_SAY"
or event == "CHAT_MSG_COMBAT_MISC_INFO"
or event == "CHAT_MSG_SYSTEM"
or event == "CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE"
or event == "CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE"
or event == "CHAT_MSG_SPELL_AURA_GONE_OTHER"
or event == "CHAT_MSG_SPELL_AURA_GONE_SELF" then
if arg1 and type(arg1) == "string" then
ParseFleeMessage(arg1)
end
end
end)
-- ============================================================
-- Test mode: fake threat list (same roster as /gdm test)
-- ============================================================
Threat.testDataActive = false
function Threat:LoadTestData()
-- Same 40-player roster as Core/Commands.lua LoadTestData for consistency
local classes = {
"WARRIOR", "MAGE", "ROGUE", "DRUID", "HUNTER",
"SHAMAN", "PRIEST", "WARLOCK", "PALADIN",
}
local names = {
"Grommash", "Jaina", "Valeera", "Malfurion", "Rexxar",
"Thrall", "Anduin", "Guldan", "Uther", "Garrosh",
"Khadgar", "Garona", "Cenarius", "Alleria", "Voljin",
"Velen", "Medivh", "Liadrin", "Saurfang", "Aegwynn",
"Sylvanas", "Illidan", "Tyrande", "Cairne", "Varok",
"Kaelthas", "Maiev", "Arthas", "Kelthuzad", "Anubarak",
"Muradin", "Bolvar", "Alexstrasza", "Ysera", "Nozdormu",
"Malygos", "Neltharion", "Ragnaros", "Nefarian", "Onyxia",
}
ClearThreatTable()
ClearTankModeTable()
Threat.usingApi = false
Threat.testDataActive = true
Threat.targetName = "Onyxia"
Threat.tankName = "Saurfang"
-- Tank sits at the top with a solid lead; everyone else cascades down.
-- Values are absolute threat numbers (similar scale to live API output).
local tankThreat = 125000
local entries = {
-- name, threat, melee?
{ "Saurfang", tankThreat, true },
{ "Grommash", 98000, true },
{ "Varok", 91000, true },
{ "Garrosh", 87000, true },
{ "Uther", 82000, true },
{ "Bolvar", 78000, true },
{ "Malfurion", 74000, true },
{ "Cairne", 70000, true },
{ "Thrall", 65000, false },
{ "Rexxar", 61000, false },
{ "Illidan", 58000, true },
{ "Valeera", 54000, true },
{ "Garona", 51000, true },
{ "Maiev", 48000, true },
{ "Jaina", 45000, false },
{ "Khadgar", 42000, false },
{ "Kaelthas", 40000, false },
{ "Guldan", 38000, false },
{ "Medivh", 36000, false },
{ "Kelthuzad", 34000, false },
{ "Anduin", 32000, false },
{ "Velen", 30000, false },
{ "Liadrin", 28000, true },
{ "Tyrande", 26000, false },
{ "Alleria", 24000, false },
{ "Sylvanas", 22000, false },
{ "Voljin", 20000, false },
{ "Cenarius", 18000, false },
{ "Aegwynn", 16000, false },
{ "Arthas", 14000, true },
{ "Anubarak", 12000, true },
{ "Muradin", 11000, true },
{ "Alexstrasza",10000, false },
{ "Ysera", 9000, false },
{ "Nozdormu", 8000, false },
{ "Malygos", 7000, false },
{ "Neltharion", 6000, false },
{ "Ragnaros", 5000, false },
{ "Nefarian", 4000, false },
{ "Onyxia", 3000, false },
}
local i
for i = 1, table.getn(entries) do
local e = entries[i]
local name = e[1]
local threat = e[2]
local melee = e[3]
local perc = 0
if tankThreat > 0 then
perc = math.floor((threat / tankThreat) * 100 + 0.5)
end
-- Mild TPS so the secondary text looks alive
local tps = math.floor(threat / 180 + math.random(0, 40))
local class = nil
local ni
for ni = 1, table.getn(names) do
if names[ni] == name then
class = classes[((ni - 1) - math.floor((ni - 1) / 9) * 9) + 1]
break
end
end
Threat.threats[name] = {
threat = threat,
perc = perc,
tank = (name == Threat.tankName),
tps = tps,
melee = melee,
class = class,
estimated = false, -- present as clean "API-style" numbers in test
isTest = true,
}
end
-- Normal Threat mode always includes Pull Aggro threshold
Threat:AddPullAggroRow()
if Threat.threats[PULL_AGGRO_NAME] then
Threat.threats[PULL_AGGRO_NAME].isTest = true
Threat.threats[PULL_AGGRO_NAME].estimated = false
end
-- Fake multi-enemy set for Tank mode preview (lowest threat first when sorted)
-- status: red = no aggro, yellow = contested, green = solid lead
local fakeEnemies = {
{ guid = "test1", creature = "Onyxian Whelp", name = "Onyxian Whelp", threat = 0, perc = 0, status = "red" },
{ guid = "test2", creature = "Onyxian Whelp", name = "Onyxian Whelp", threat = 1200, perc = 35, status = "red" },
{ guid = "test3", creature = "Onyxian Warder", name = "Onyxian Warder", threat = 45000, perc = 92, status = "yellow" },
{ guid = "test4", creature = "Onyxia", name = "Onyxia", threat = 125000,perc = 100, status = "green" },
{ guid = "test5", creature = "Onyxian Warder", name = "Onyxian Warder", threat = 80000, perc = 100, status = "green" },
{ guid = "test6", creature = "Onyxian Whelp", name = "Onyxian Whelp", threat = 500, perc = 12, status = "red" },
{ guid = "test7", creature = "Onyxian Guard", name = "Onyxian Guard", threat = 62000, perc = 96, status = "yellow" },
{ guid = "test8", creature = "Lava Spawn", name = "Lava Spawn", threat = 0, perc = 0, status = "red" },
}
local fi
for fi = 1, table.getn(fakeEnemies) do
local e = fakeEnemies[fi]
Threat.tankModeThreats[e.guid] = {
creature = e.creature,
name = e.name,
threat = e.threat,
perc = e.perc,
status = e.status,
isTest = true,
estimated = false,
}
end
-- Overall fight totals for Overall view / test
local oi
for oi = 1, table.getn(entries) do
local e = entries[oi]
local name = e[1]
local threat = math.floor((e[2] or 0) * 1.35) -- slightly higher than single-target snapshot
local class = nil
local ni
for ni = 1, table.getn(names) do
if names[ni] == name then
class = classes[((ni - 1) - math.floor((ni - 1) / 9) * 9) + 1]
break
end
end
Threat.overallThreat[name] = {
threat = threat,
class = class,
estimated = false,
tank = (name == Threat.tankName),
}
end
InvalidateThreatCaches()
end
function Threat:ClearTestData()
self.testDataActive = false
ClearThreatTable()
ClearTankModeTable()
local k
for k in pairs(self.overallThreat) do
self.overallThreat[k] = nil
end
for k in pairs(self.threatByTarget) do
self.threatByTarget[k] = nil
end
self.currentTargetKey = nil
self.history = {}
self.usingApi = false
self.tankName = nil
self.targetName = nil
InvalidateThreatCaches()
end
local elapsed = 0
eventFrame:SetScript("OnUpdate", function()
if not OM or not OM.GetSetting then return end
if not OM:GetSetting("enableThreatMode") then return end
if not Threat:AnyFrameInThreatMode() then return end
-- Aggro swap is cheap and should redraw threat windows immediately
local liveAggro = nil
if UnitExists("target") and UnitExists("targettarget") then
if not (UnitIsDead and UnitIsDead("targettarget")) then
liveAggro = UnitName("targettarget")
end
end
if liveAggro ~= Threat._lastAggroName then
Threat._lastAggroName = liveAggro
local n, d
for n, d in pairs(Threat.threats) do
if d and not d.isPull then
d.tank = (liveAggro and n == liveAggro) or false
end
end
if liveAggro then
Threat.tankName = liveAggro
end
InvalidateThreatCaches()
UpdateThreatWarnings()
Threat:RefreshOpenFrames()
end
elapsed = elapsed + arg1
if elapsed < 0.5 then return end
elapsed = 0
if OM:GetSetting("testMode") == true then
if not Threat.testDataActive then
Threat:LoadTestData()
end
Threat:RefreshOpenFrames()
return
elseif Threat.testDataActive then
Threat:ClearTestData()
end
local now = GetTime()
local playerCombat = UnitAffectingCombat and UnitAffectingCombat("player")
local targetCombat = UnitExists("target") and UnitAffectingCombat and UnitAffectingCombat("target")
local groupCombat
if playerCombat or targetCombat then
groupCombat = true
else
groupCombat = IsGroupInCombat()
end
local hostileTarget = HasHostileTarget()
local apiTarget = IsThreatApiTarget()
if groupCombat and not Threat.groupCombat then
Threat.groupCombat = true
local k
for k in pairs(Threat.overallThreat) do
Threat.overallThreat[k] = nil
end
for k in pairs(Threat.threatByTarget) do
Threat.threatByTarget[k] = nil
end
Threat.currentTargetKey = nil
Threat.usingApi = false
Threat._lastEstSig = nil
InvalidateThreatCaches()
elseif not groupCombat and Threat.groupCombat then
Threat.groupCombat = false
end
if apiTarget then
if (now - Threat.lastQuery) >= Threat.queryInterval then
Threat.lastQuery = now
SendThreatQuery()
end
end
if Threat.usingApi and (now - Threat.lastApiTime) > Threat.apiTimeout then
Threat.usingApi = false
Threat._lastEstSig = nil
end
if not Threat.usingApi then
local sig = EstimateSignature()
if sig ~= Threat._lastEstSig then
Threat._lastEstSig = sig
if hostileTarget then
BuildEstimatedThreat()
elseif IsInGroup() and (groupCombat or Threat.groupCombat) then
BuildEstimatedOverallOnly()
end
end
end
UpdateThreatWarnings()
Threat:RefreshOpenFrames()
end)
-- ============================================================
-- Module lifecycle
-- ============================================================
function Threat:OnLoad()
if OM.InitDB and not OM.db then
OM:InitDB()
end
if OM.db then
if OM.db.enableThreatMode == nil then OM.db.enableThreatMode = false end
if OM.db.showPetThreat == nil then OM.db.showPetThreat = false end
if OM.db.threatWarnSound == nil then OM.db.threatWarnSound = true end
if OM.db.threatWarnGlow == nil then OM.db.threatWarnGlow = true end
if OM.db.threatWarnPercent == nil then OM.db.threatWarnPercent = 90 end
if OM.db.threatWarnSoundFile == nil then OM.db.threatWarnSoundFile = "raidwarning" end
if OM.db.petAsTank == nil then OM.db.petAsTank = false end
if OM.db.threatView == nil then
-- migrate older tank checkbox
if OM.db.enableTankingMode then
OM.db.threatView = "tank"
else
OM.db.threatView = "single"
end
end
end
InstallUIHooks()
HookSettingsFrame()
-- Extend core test data so Threat mode is populated too
if OM.LoadTestData and not Threat._loadTestHooked then
Threat._loadTestHooked = true
local oldLoad = OM.LoadTestData
OM.LoadTestData = function(self)
oldLoad(self)
if OM:GetSetting("enableThreatMode") then
Threat:LoadTestData()
if UI and UI.Refresh then UI:Refresh() end
end
end
end
OM:RegisterExtraCheckbox({
label = "Add threat mode",
key = "enableThreatMode",
default = false,
order = 10,
tooltip = "Enables threat metering on meter windows.\nUse the Threat view dropdown to choose Single target, Tank, Overall, or All (adds every view as its own Mode option).",
onToggle = function(checked)
EnsureModeInList(checked)
if not checked then
Threat:ClearTestData()
elseif OM:GetSetting("testMode") then
Threat:LoadTestData()
if UI and UI.Refresh then UI:Refresh() end
end
if checked then
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00GreedMeter:|r Threat mode enabled. Pick a view and select it from Mode on a meter window.")
else
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00GreedMeter:|r Threat mode disabled.")
end
end,
childDropdown = {
label = "Threat view:",
key = "threatView",
default = "single",
options = THREAT_VIEW_OPTIONS,
tooltip = "Single target — player threat on your current target (with Pull Aggro).\nTank — enemies you are fighting, lowest of your threat first; click to target.\nOverall — each player's total threat generated this fight (not target-specific).\nAll — adds Threat, Tank, and Overall Threat as separate Mode options.",
onChange = function(key)
EnsureModeInList(OM:GetSetting("enableThreatMode") == true)
if OM:GetSetting("testMode") and OM:GetSetting("enableThreatMode") then
Threat:LoadTestData()
end
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00GreedMeter:|r Threat view: " .. ThreatViewLabel(key))
if UI and UI.Refresh then UI:Refresh() end
end,
},
})
EnsureModeInList(OM:GetSetting("enableThreatMode") == true)
-- Settings may have been built before extras were registered; attach now
if UI and UI.settingsFrame then
UI.settingsFrame._greedThreatCheckbox = nil
AddThreatCheckboxToSettings(UI.settingsFrame)
end
-- If both threat + test were already on from a previous session, fill test threat
if OM:GetSetting("enableThreatMode") and OM:GetSetting("testMode") then
Threat:LoadTestData()
end
end
function Threat:OnCombatStart()
if not OM:GetSetting("enableThreatMode") then return end
-- Don't wipe test data when "combat" starts while testing
if OM:GetSetting("testMode") then return end
-- Single-target snapshot resets on local combat entry.
-- overallThreat is owned by the group-combat session in OnUpdate so a
-- healer entering combat mid-fight does not wipe the whole meter.
ClearThreatTable()
ClearTankModeTable()
Threat.usingApi = false
Threat.testDataActive = false
end
function Threat:OnCombatEnd()
-- keep last values visible for review
end
function Threat:OnReset()
-- Always wipe threat tables first. Reload fake data only if test mode is still on.
Threat:ClearTestData()
if OM:GetSetting("testMode") and OM:GetSetting("enableThreatMode") then
Threat:LoadTestData()
end
end
OM:RegisterModule("Threat", Threat)