864 lines
27 KiB
Lua
864 lines
27 KiB
Lua
local NanamiDPS = NanamiDPS
|
|
local TC = NanamiDPS.ThreatCoefficients
|
|
|
|
-------------------------------------------------------------------------------
|
|
-- ThreatEngine: Adaptive dual-track hybrid threat monitoring
|
|
--
|
|
-- Track 1 (Primary): TWThreat server API — direct authoritative data for
|
|
-- Elite/Boss targets when in party/raid. Completely bypasses combat
|
|
-- log parsing for the queried target.
|
|
--
|
|
-- Track 2 (Fallback): Local heuristic combat log estimation — used for
|
|
-- normal mobs, solo play, or environments without SuperWoW.
|
|
--
|
|
-- Data model: 3D table [targetKey][playerName] = { threat, tps, ... }
|
|
-- targetKey = GUID (TWThreat) or RaidIcon/virtualID (fallback)
|
|
-------------------------------------------------------------------------------
|
|
|
|
local TE = CreateFrame("Frame", "NanamiDPSThreatEngine", UIParent)
|
|
NanamiDPS.ThreatEngine = TE
|
|
|
|
local _find = string.find
|
|
local _sub = string.sub
|
|
local _len = string.len
|
|
local _lower = string.lower
|
|
local _floor = math.floor
|
|
local _max = math.max
|
|
local _pairs = pairs
|
|
local _tonumber = tonumber
|
|
|
|
-------------------------------------------------------------------------------
|
|
-- Constants
|
|
-------------------------------------------------------------------------------
|
|
local QUERY_INTERVAL_DEFAULT = 0.5
|
|
local QUERY_PREFIX = "TWT_UDTSv4"
|
|
local QUERY_PREFIX_TM = "TWT_UDTSv4_TM"
|
|
local API_MARKER = "TWTv4="
|
|
local TM_MARKER = "TMTv1="
|
|
local ADDON_PREFIX = "TWT"
|
|
local HISTORY_WINDOW = 10
|
|
local TPS_WINDOW = 5
|
|
|
|
-------------------------------------------------------------------------------
|
|
-- State
|
|
-------------------------------------------------------------------------------
|
|
TE.targets = {}
|
|
TE.apiActive = false
|
|
TE.apiTargetKey = nil
|
|
TE.inCombat = false
|
|
TE.tankMode = false
|
|
|
|
TE.playerName = UnitName("player")
|
|
local _, playerClassToken = UnitClass("player")
|
|
TE.playerClass = playerClassToken
|
|
|
|
TE.talentCache = {}
|
|
TE.stanceModCache = {}
|
|
|
|
TE.queryTimer = 0
|
|
TE.queryInterval = QUERY_INTERVAL_DEFAULT
|
|
|
|
TE.lastUpdateTime = 0
|
|
|
|
-------------------------------------------------------------------------------
|
|
-- String split utility (1.12 compatible)
|
|
-------------------------------------------------------------------------------
|
|
local function strsplit(sep, str)
|
|
local t = {}
|
|
local pos = 1
|
|
while true do
|
|
local s, e = _find(str, sep, pos, true)
|
|
if not s then
|
|
local piece = _sub(str, pos)
|
|
if piece ~= "" then t[table.getn(t) + 1] = piece end
|
|
break
|
|
end
|
|
local piece = _sub(str, pos, s - 1)
|
|
t[table.getn(t) + 1] = piece
|
|
pos = e + 1
|
|
end
|
|
return t
|
|
end
|
|
|
|
-------------------------------------------------------------------------------
|
|
-- Target key helpers
|
|
-------------------------------------------------------------------------------
|
|
local function GetTargetKey(unit)
|
|
if not unit or not UnitExists(unit) then return nil end
|
|
if UnitGUID then
|
|
local guid = UnitGUID(unit)
|
|
if guid then return "G:" .. guid end
|
|
end
|
|
local icon = GetRaidTargetIndex and GetRaidTargetIndex(unit)
|
|
if icon then return "I:" .. icon end
|
|
local name = UnitName(unit)
|
|
local hp = UnitHealth(unit)
|
|
local hpMax = UnitHealthMax(unit)
|
|
if name then return "V:" .. name .. ":" .. (hpMax or 0) end
|
|
return nil
|
|
end
|
|
|
|
local function IsEliteOrBoss(unit)
|
|
if not unit or not UnitExists(unit) then return false end
|
|
local classification = UnitClassification and UnitClassification(unit)
|
|
if classification == "worldboss" or classification == "rareelite" or classification == "elite" then
|
|
return true
|
|
end
|
|
local level = UnitLevel(unit)
|
|
if level == -1 then return true end
|
|
return false
|
|
end
|
|
|
|
local function IsInGroup()
|
|
return (GetNumRaidMembers() > 0) or (GetNumPartyMembers() > 0)
|
|
end
|
|
|
|
local function GetChannel()
|
|
if GetNumRaidMembers() > 0 then return "RAID" end
|
|
if GetNumPartyMembers() > 0 then return "PARTY" end
|
|
return nil
|
|
end
|
|
|
|
-------------------------------------------------------------------------------
|
|
-- Talent scanning: cached at combat start
|
|
-------------------------------------------------------------------------------
|
|
function TE:ScanTalents()
|
|
self.talentCache = {}
|
|
local class = self.playerClass
|
|
local talentDefs = TC.talentMultipliers[class]
|
|
if not talentDefs then return end
|
|
|
|
for _, def in _pairs(talentDefs) do
|
|
local _, _, _, _, rank = GetTalentInfo(def.tab, def.index)
|
|
rank = rank or 0
|
|
self.talentCache[def.label] = {
|
|
rank = rank,
|
|
mod = def.base + rank * def.perPoint,
|
|
}
|
|
end
|
|
end
|
|
|
|
function TE:GetTalentMod()
|
|
local total = 0
|
|
for _, data in _pairs(self.talentCache) do
|
|
total = total + data.mod
|
|
end
|
|
return 1.0 + total
|
|
end
|
|
|
|
-------------------------------------------------------------------------------
|
|
-- Stance / buff scanning for threat modifier
|
|
-------------------------------------------------------------------------------
|
|
local STANCE_CACHE_TTL = 2
|
|
|
|
function TE:ScanUnitThreatMod(unit)
|
|
if not unit or not UnitExists(unit) then return 1.0, 1.0, false, false end
|
|
local stanceMod = 1.0
|
|
local buffMod = 1.0
|
|
local hasRF = false
|
|
local hasRockbiter = false
|
|
|
|
for i = 1, 32 do
|
|
local texture = UnitBuff(unit, i)
|
|
if not texture then break end
|
|
for _, s in _pairs(TC.stancePatterns) do
|
|
if _find(texture, s.pat) then
|
|
stanceMod = s.mod
|
|
end
|
|
end
|
|
for _, b in _pairs(TC.buffPatterns) do
|
|
if _find(texture, b.pat) then
|
|
buffMod = buffMod * b.mod
|
|
end
|
|
end
|
|
if TC.righteousFury and _find(texture, TC.righteousFury.texture) then
|
|
hasRF = true
|
|
end
|
|
if TC.rockbiter and _find(texture, TC.rockbiter.texture) then
|
|
hasRockbiter = true
|
|
end
|
|
end
|
|
|
|
return stanceMod, buffMod, hasRF, hasRockbiter
|
|
end
|
|
|
|
function TE:GetUnitThreatMod(name, unit)
|
|
local now = GetTime()
|
|
local cached = self.stanceModCache[name]
|
|
if cached and (now - cached.time) < STANCE_CACHE_TTL then
|
|
return cached.stanceMod, cached.buffMod, cached.hasRF, cached.hasRockbiter
|
|
end
|
|
|
|
if not unit then
|
|
unit = NanamiDPS.Parser and NanamiDPS.Parser:UnitByName(name) or nil
|
|
end
|
|
|
|
local sm, bm, rf, rb = self:ScanUnitThreatMod(unit)
|
|
self.stanceModCache[name] = {
|
|
stanceMod = sm, buffMod = bm, hasRF = rf, hasRockbiter = rb,
|
|
time = now,
|
|
}
|
|
return sm, bm, rf, rb
|
|
end
|
|
|
|
-------------------------------------------------------------------------------
|
|
-- Track 2: Local heuristic threat calculation
|
|
-------------------------------------------------------------------------------
|
|
function TE:CalculateLocalThreat(source, spell, damage, isHeal, sourceClass)
|
|
if not damage or damage <= 0 then return 0 end
|
|
|
|
local threat = damage
|
|
local coeff = spell and TC.spells[spell]
|
|
|
|
if isHeal then
|
|
threat = damage * TC.HEAL_THREAT_COEFF
|
|
elseif coeff then
|
|
if coeff.zero then return 0 end
|
|
threat = damage * (coeff.mult or 1.0) + (coeff.bonus or 0)
|
|
end
|
|
|
|
local class = sourceClass
|
|
if class and TC.classPassiveMod[class] then
|
|
threat = threat * TC.classPassiveMod[class]
|
|
end
|
|
|
|
local stanceMod, buffMod, hasRF, hasRockbiter = self:GetUnitThreatMod(source)
|
|
threat = threat * stanceMod * buffMod
|
|
|
|
if hasRF and class == "PALADIN" then
|
|
local rfMod = TC.righteousFury.baseMod
|
|
local talentDef = TC.righteousFury
|
|
if talentDef.talentTab then
|
|
local _, _, _, _, rank = GetTalentInfo(talentDef.talentTab, talentDef.talentIndex)
|
|
rank = rank or 0
|
|
rfMod = rfMod + rank * talentDef.perPoint
|
|
end
|
|
threat = threat * rfMod
|
|
end
|
|
|
|
if hasRockbiter and class == "SHAMAN" then
|
|
threat = threat * TC.rockbiter.globalMod
|
|
end
|
|
|
|
if class == "WARRIOR" or class == "DRUID" or class == "SHAMAN" then
|
|
local talentMod = self:GetTalentMod()
|
|
if talentMod > 1.0 then
|
|
threat = threat * talentMod
|
|
end
|
|
end
|
|
|
|
return _max(threat, 0)
|
|
end
|
|
|
|
-------------------------------------------------------------------------------
|
|
-- 3D data model operations
|
|
-------------------------------------------------------------------------------
|
|
local function EnsureTarget(targetKey)
|
|
if not TE.targets[targetKey] then
|
|
TE.targets[targetKey] = {
|
|
players = {},
|
|
tankName = nil,
|
|
tankThreat = 0,
|
|
lastUpdate = GetTime(),
|
|
source = "local",
|
|
}
|
|
end
|
|
return TE.targets[targetKey]
|
|
end
|
|
|
|
local function EnsurePlayer(targetData, playerName)
|
|
if not targetData.players[playerName] then
|
|
targetData.players[playerName] = {
|
|
threat = 0,
|
|
tps = 0,
|
|
isTanking = false,
|
|
isMelee = false,
|
|
perc = 0,
|
|
history = {},
|
|
lastThreatTime = 0,
|
|
}
|
|
end
|
|
return targetData.players[playerName]
|
|
end
|
|
|
|
function TE:AddLocalThreat(targetKey, source, amount)
|
|
if not targetKey or not source or not amount then return end
|
|
|
|
local td = EnsureTarget(targetKey)
|
|
if td.source == "api" then return end
|
|
|
|
local pd = EnsurePlayer(td, source)
|
|
pd.threat = pd.threat + amount
|
|
pd.lastThreatTime = GetTime()
|
|
|
|
local now = GetTime()
|
|
table.insert(pd.history, { time = now, threat = pd.threat })
|
|
local cutoff = now - TPS_WINDOW
|
|
while table.getn(pd.history) > 0 and pd.history[1].time < cutoff do
|
|
table.remove(pd.history, 1)
|
|
end
|
|
|
|
if table.getn(pd.history) >= 2 then
|
|
local first = pd.history[1]
|
|
local last = pd.history[table.getn(pd.history)]
|
|
local dt = last.time - first.time
|
|
if dt > 0 then
|
|
pd.tps = (last.threat - first.threat) / dt
|
|
end
|
|
end
|
|
|
|
td.lastUpdate = now
|
|
self:RecalcTankStatus(targetKey)
|
|
end
|
|
|
|
-------------------------------------------------------------------------------
|
|
-- Taunt handling: copy highest threat to the taunter (Growl, Taunt, etc.)
|
|
-------------------------------------------------------------------------------
|
|
function TE:HandleTaunt(taunterName, targetKey)
|
|
if not targetKey then return end
|
|
local td = self.targets[targetKey]
|
|
if not td then return end
|
|
|
|
local maxThreat = 0
|
|
for name, pd in _pairs(td.players) do
|
|
if name ~= taunterName and pd.threat > maxThreat then
|
|
maxThreat = pd.threat
|
|
end
|
|
end
|
|
|
|
if maxThreat > 0 then
|
|
local pd = EnsurePlayer(td, taunterName)
|
|
if pd.threat < maxThreat then
|
|
pd.threat = maxThreat
|
|
pd.isTanking = true
|
|
pd.lastThreatTime = GetTime()
|
|
end
|
|
end
|
|
|
|
self:RecalcTankStatus(targetKey)
|
|
end
|
|
|
|
function TE:WipePlayerThreat(source, wipeType)
|
|
for targetKey, td in _pairs(self.targets) do
|
|
local pd = td.players[source]
|
|
if pd then
|
|
if wipeType == "FULL_WIPE" then
|
|
pd.threat = 0
|
|
pd.tps = 0
|
|
pd.history = {}
|
|
elseif wipeType == "HALF_WIPE" then
|
|
pd.threat = pd.threat * 0.5
|
|
elseif wipeType == "TEMP_REDUCE" then
|
|
pd.threat = pd.threat * 0.5
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
function TE:RecalcTankStatus(targetKey)
|
|
local td = self.targets[targetKey]
|
|
if not td then return end
|
|
|
|
local maxThreat = 0
|
|
local tankName = nil
|
|
for name, pd in _pairs(td.players) do
|
|
if pd.threat > maxThreat then
|
|
maxThreat = pd.threat
|
|
tankName = name
|
|
end
|
|
end
|
|
|
|
td.tankName = tankName
|
|
td.tankThreat = maxThreat
|
|
|
|
for name, pd in _pairs(td.players) do
|
|
pd.isTanking = (name == tankName)
|
|
pd.perc = maxThreat > 0 and (pd.threat / maxThreat * 100) or 0
|
|
end
|
|
end
|
|
|
|
-------------------------------------------------------------------------------
|
|
-- Track 1: TWThreat API — packet handling
|
|
-------------------------------------------------------------------------------
|
|
function TE:HandleAPIPacket(rawData)
|
|
local threatData = rawData
|
|
local tmData = nil
|
|
|
|
local hashPos = _find(rawData, "#", 1, true)
|
|
if hashPos then
|
|
threatData = _sub(rawData, 1, hashPos - 1)
|
|
tmData = _sub(rawData, hashPos + 1)
|
|
end
|
|
|
|
local apiStart = _find(threatData, API_MARKER, 1, true)
|
|
if not apiStart then return end
|
|
|
|
local playersStr = _sub(threatData, apiStart + _len(API_MARKER))
|
|
local entries = strsplit(";", playersStr)
|
|
|
|
local targetKey = self.apiTargetKey or "api_target"
|
|
local td = EnsureTarget(targetKey)
|
|
td.source = "api"
|
|
td.lastUpdate = GetTime()
|
|
td.tankName = nil
|
|
td.tankThreat = 0
|
|
|
|
local oldPlayers = td.players
|
|
td.players = {}
|
|
|
|
for _, entry in _pairs(entries) do
|
|
local fields = strsplit(":", entry)
|
|
if fields[1] and fields[2] and fields[3] and fields[4] and fields[5] then
|
|
local name = fields[1]
|
|
local isTank = fields[2] == "1"
|
|
local threat = _tonumber(fields[3]) or 0
|
|
local perc = _tonumber(fields[4]) or 0
|
|
local isMelee = fields[5] == "1"
|
|
|
|
local prevHistory = oldPlayers[name] and oldPlayers[name].history or {}
|
|
|
|
local pd = EnsurePlayer(td, name)
|
|
pd.threat = threat
|
|
pd.isTanking = isTank
|
|
pd.perc = perc
|
|
pd.isMelee = isMelee
|
|
pd.lastThreatTime = GetTime()
|
|
|
|
table.insert(prevHistory, { time = GetTime(), threat = threat })
|
|
local cutoff = GetTime() - TPS_WINDOW
|
|
while table.getn(prevHistory) > 0 and prevHistory[1].time < cutoff do
|
|
table.remove(prevHistory, 1)
|
|
end
|
|
pd.history = prevHistory
|
|
|
|
if table.getn(pd.history) >= 2 then
|
|
local first = pd.history[1]
|
|
local last = pd.history[table.getn(pd.history)]
|
|
local dt = last.time - first.time
|
|
if dt > 0 then
|
|
pd.tps = (last.threat - first.threat) / dt
|
|
end
|
|
end
|
|
|
|
if isTank then
|
|
td.tankName = name
|
|
td.tankThreat = threat
|
|
end
|
|
end
|
|
end
|
|
|
|
if tmData then
|
|
self:HandleTankModePacket(tmData)
|
|
end
|
|
|
|
self:FireUpdate()
|
|
end
|
|
|
|
function TE:HandleTankModePacket(raw)
|
|
local tmStart = _find(raw, TM_MARKER, 1, true)
|
|
if not tmStart then return end
|
|
|
|
local dataStr = _sub(raw, tmStart + _len(TM_MARKER))
|
|
local entries = strsplit(";", dataStr)
|
|
|
|
for _, entry in _pairs(entries) do
|
|
local fields = strsplit(":", entry)
|
|
if fields[1] and fields[2] and fields[3] and fields[4] then
|
|
local creature = fields[1]
|
|
local guid = fields[2]
|
|
local name = fields[3]
|
|
local perc = _tonumber(fields[4]) or 0
|
|
|
|
local targetKey = "G:" .. guid
|
|
local td = EnsureTarget(targetKey)
|
|
td.source = "api_tm"
|
|
td.lastUpdate = GetTime()
|
|
|
|
local pd = EnsurePlayer(td, name)
|
|
pd.perc = perc
|
|
pd.isTanking = true
|
|
td.tankName = name
|
|
end
|
|
end
|
|
end
|
|
|
|
-------------------------------------------------------------------------------
|
|
-- API query ticker
|
|
-------------------------------------------------------------------------------
|
|
function TE:SendQuery()
|
|
if not self.inCombat then return end
|
|
if not IsInGroup() then return end
|
|
|
|
local unit = "target"
|
|
if not UnitExists(unit) or UnitIsPlayer(unit) or not UnitAffectingCombat(unit) then
|
|
return
|
|
end
|
|
|
|
self.apiTargetKey = GetTargetKey(unit)
|
|
|
|
local channel = GetChannel()
|
|
if not channel then return end
|
|
|
|
local prefix = self.tankMode and QUERY_PREFIX_TM or QUERY_PREFIX
|
|
local limit = 10
|
|
SendAddonMessage(prefix, "limit=" .. limit, channel)
|
|
end
|
|
|
|
-------------------------------------------------------------------------------
|
|
-- Query result: OT analysis for current player
|
|
-------------------------------------------------------------------------------
|
|
function TE:GetOTStatus(targetKey)
|
|
local td = self.targets[targetKey]
|
|
if not td then
|
|
return { safe = true, pct = 0, threshold = 0, buffer = 0, tankName = nil }
|
|
end
|
|
|
|
local myData = td.players[self.playerName]
|
|
if not myData then
|
|
return { safe = true, pct = 0, threshold = 0, buffer = 0, tankName = td.tankName }
|
|
end
|
|
|
|
local tankThreat = td.tankThreat
|
|
if tankThreat <= 0 then
|
|
return { safe = true, pct = 0, threshold = 0, buffer = 0, tankName = td.tankName }
|
|
end
|
|
|
|
local threshold = myData.isMelee and TC.OT_MELEE_THRESHOLD or TC.OT_RANGED_THRESHOLD
|
|
local otPoint = tankThreat * threshold
|
|
local myThreat = myData.threat
|
|
local pct = myThreat / otPoint * 100
|
|
local buffer = otPoint - myThreat
|
|
|
|
return {
|
|
safe = myThreat < otPoint,
|
|
pct = pct,
|
|
threshold = threshold,
|
|
otPoint = otPoint,
|
|
buffer = buffer,
|
|
myThreat = myThreat,
|
|
tankThreat = tankThreat,
|
|
tankName = td.tankName,
|
|
isMelee = myData.isMelee,
|
|
}
|
|
end
|
|
|
|
-------------------------------------------------------------------------------
|
|
-- Get sorted threat list for display
|
|
-------------------------------------------------------------------------------
|
|
function TE:GetThreatList(targetKey)
|
|
local td = self.targets[targetKey]
|
|
if not td then return {} end
|
|
|
|
local list = {}
|
|
for name, pd in _pairs(td.players) do
|
|
table.insert(list, {
|
|
name = name,
|
|
threat = pd.threat,
|
|
tps = pd.tps,
|
|
perc = pd.perc,
|
|
isTanking = pd.isTanking,
|
|
isMelee = pd.isMelee,
|
|
})
|
|
end
|
|
|
|
table.sort(list, function(a, b) return a.threat > b.threat end)
|
|
|
|
local top = list[1] and list[1].threat or 0
|
|
for _, entry in _pairs(list) do
|
|
entry.relativePercent = top > 0 and (entry.threat / top * 100) or 0
|
|
end
|
|
|
|
return list
|
|
end
|
|
|
|
function TE:GetActiveTargetKey()
|
|
if self.apiActive and self.apiTargetKey then
|
|
return self.apiTargetKey
|
|
end
|
|
return GetTargetKey("target")
|
|
end
|
|
|
|
-------------------------------------------------------------------------------
|
|
-- Cleanup stale data
|
|
-------------------------------------------------------------------------------
|
|
function TE:PurgeStaleTargets()
|
|
local now = GetTime()
|
|
local staleThreshold = 30
|
|
local keysToRemove = {}
|
|
for key, td in _pairs(self.targets) do
|
|
if (now - td.lastUpdate) > staleThreshold then
|
|
keysToRemove[table.getn(keysToRemove) + 1] = key
|
|
end
|
|
end
|
|
for _, key in _pairs(keysToRemove) do
|
|
self.targets[key] = nil
|
|
end
|
|
end
|
|
|
|
-------------------------------------------------------------------------------
|
|
-- Refresh callback
|
|
-------------------------------------------------------------------------------
|
|
function TE:FireUpdate()
|
|
NanamiDPS:FireCallback("threat_update")
|
|
self.lastUpdateTime = GetTime()
|
|
end
|
|
|
|
-------------------------------------------------------------------------------
|
|
-- Event registration & main loop
|
|
-------------------------------------------------------------------------------
|
|
TE:RegisterEvent("CHAT_MSG_ADDON")
|
|
TE:RegisterEvent("PLAYER_REGEN_DISABLED")
|
|
TE:RegisterEvent("PLAYER_REGEN_ENABLED")
|
|
TE:RegisterEvent("PLAYER_TARGET_CHANGED")
|
|
|
|
TE:SetScript("OnEvent", function()
|
|
if event == "CHAT_MSG_ADDON" then
|
|
if arg2 and _find(arg2, API_MARKER, 1, true) then
|
|
TE:HandleAPIPacket(arg2)
|
|
TE.apiActive = true
|
|
end
|
|
return
|
|
end
|
|
|
|
if event == "PLAYER_REGEN_DISABLED" then
|
|
TE.inCombat = true
|
|
TE:ScanTalents()
|
|
TE.stanceModCache = {}
|
|
return
|
|
end
|
|
|
|
if event == "PLAYER_REGEN_ENABLED" then
|
|
TE.inCombat = false
|
|
TE.apiActive = false
|
|
TE.apiTargetKey = nil
|
|
return
|
|
end
|
|
|
|
if event == "PLAYER_TARGET_CHANGED" then
|
|
TE.apiTargetKey = GetTargetKey("target")
|
|
if TE.inCombat and UnitExists("target") and not UnitIsPlayer("target") then
|
|
local td = TE.targets[TE.apiTargetKey]
|
|
if not td or td.source ~= "api" then
|
|
TE.apiActive = false
|
|
end
|
|
end
|
|
return
|
|
end
|
|
end)
|
|
|
|
-------------------------------------------------------------------------------
|
|
-- Threat-clearing buff detection via direct buff scanning
|
|
-- More reliable than combat log text matching for abilities like
|
|
-- Feign Death, Vanish, etc.
|
|
-------------------------------------------------------------------------------
|
|
TE.threatWipeBuffs = {
|
|
{ pat = "Ability_Rogue_FeignDeath", wipe = "FULL_WIPE" },
|
|
{ pat = "FeignDeath", wipe = "FULL_WIPE" },
|
|
{ pat = "Ability_Vanish", wipe = "FULL_WIPE" },
|
|
{ pat = "Spell_Shadow_Possession", wipe = "TEMP_REDUCE" },
|
|
{ pat = "Spell_Holy_FadeAway", wipe = "TEMP_REDUCE" },
|
|
}
|
|
TE.lastWipeCheck = 0
|
|
TE.wipeActive = {}
|
|
|
|
function TE:CheckThreatWipeBuffs()
|
|
local now = GetTime()
|
|
if (now - self.lastWipeCheck) < 0.3 then return end
|
|
self.lastWipeCheck = now
|
|
|
|
local unit = "player"
|
|
if not UnitExists(unit) then return end
|
|
|
|
for _, def in _pairs(self.threatWipeBuffs) do
|
|
local found = false
|
|
for i = 1, 32 do
|
|
local tex = UnitBuff(unit, i)
|
|
if not tex then break end
|
|
if _find(tex, def.pat) then
|
|
found = true
|
|
break
|
|
end
|
|
end
|
|
|
|
if found and not self.wipeActive[def.pat] then
|
|
self.wipeActive[def.pat] = true
|
|
self:WipePlayerThreat(self.playerName, def.wipe)
|
|
|
|
local DS = NanamiDPS and NanamiDPS.DataStore
|
|
if DS then
|
|
local current = DS:GetPlayerThreat(self.playerName)
|
|
if current > 0 then
|
|
DS:AddThreat(self.playerName, -current)
|
|
end
|
|
end
|
|
elseif not found and self.wipeActive[def.pat] then
|
|
self.wipeActive[def.pat] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
-------------------------------------------------------------------------------
|
|
-- targettarget heuristic: if the mob is attacking entity X, then X must
|
|
-- have the highest threat. This corrects for pet Growl and other taunt
|
|
-- effects that don't produce combat log damage.
|
|
-------------------------------------------------------------------------------
|
|
TE.lastToTCheck = 0
|
|
local TOT_CHECK_INTERVAL = 0.5
|
|
|
|
function TE:CheckTargetOfTarget()
|
|
local now = GetTime()
|
|
if (now - self.lastToTCheck) < TOT_CHECK_INTERVAL then return end
|
|
self.lastToTCheck = now
|
|
|
|
if not UnitExists("target") or UnitIsPlayer("target") then return end
|
|
if not UnitExists("targettarget") then return end
|
|
|
|
local targetKey = self:GetCurrentTargetKey()
|
|
if not targetKey then return end
|
|
if self:IsAPIActiveForTarget(targetKey) then return end
|
|
|
|
local td = self.targets[targetKey]
|
|
if not td then return end
|
|
|
|
local totName = UnitName("targettarget")
|
|
if not totName then return end
|
|
|
|
local totData = td.players[totName]
|
|
if not totData then
|
|
for pName, _ in _pairs(td.players) do
|
|
if _find(pName, totName, 1, true) then
|
|
totData = td.players[pName]
|
|
totName = pName
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
if not totData then
|
|
totData = EnsurePlayer(td, totName)
|
|
end
|
|
|
|
local maxOther = 0
|
|
for name, pd in _pairs(td.players) do
|
|
if name ~= totName and pd.threat > maxOther then
|
|
maxOther = pd.threat
|
|
end
|
|
end
|
|
|
|
if totData.threat < maxOther then
|
|
totData.threat = maxOther
|
|
totData.isTanking = true
|
|
totData.lastThreatTime = now
|
|
self:RecalcTankStatus(targetKey)
|
|
elseif not totData.isTanking then
|
|
totData.isTanking = true
|
|
self:RecalcTankStatus(targetKey)
|
|
end
|
|
end
|
|
|
|
TE:SetScript("OnUpdate", function()
|
|
if not TE.inCombat then return end
|
|
|
|
local now = GetTime()
|
|
|
|
TE:CheckThreatWipeBuffs()
|
|
TE:CheckTargetOfTarget()
|
|
|
|
if (now - TE.queryTimer) >= TE.queryInterval then
|
|
TE.queryTimer = now
|
|
TE:SendQuery()
|
|
end
|
|
|
|
if (now - TE.lastUpdateTime) >= 0.25 then
|
|
TE:FireUpdate()
|
|
end
|
|
|
|
if mod(_floor(now), 10) == 0 and _floor(now) ~= _floor(TE.queryTimer - TE.queryInterval) then
|
|
TE:PurgeStaleTargets()
|
|
end
|
|
end)
|
|
|
|
-------------------------------------------------------------------------------
|
|
-- Public helpers for Parser integration
|
|
-------------------------------------------------------------------------------
|
|
function TE:IsAPIActiveForTarget(targetKey)
|
|
if not targetKey then return false end
|
|
local td = self.targets[targetKey]
|
|
return td and td.source == "api"
|
|
end
|
|
|
|
function TE:GetCurrentTargetKey()
|
|
return GetTargetKey("target")
|
|
end
|
|
|
|
TE.GetTargetKey = GetTargetKey
|
|
TE.IsEliteOrBoss = IsEliteOrBoss
|
|
TE.IsInGroup = IsInGroup
|
|
|
|
-------------------------------------------------------------------------------
|
|
-- Public API: NanamiDPS.ThreatEngine:QueryUnitThreat(unitID)
|
|
--
|
|
-- Returns a table with the caller's threat data for the given unit, or nil.
|
|
-- Intended for external addons (e.g., nameplate addons) to query threat info.
|
|
--
|
|
-- @param unitID string WoW unit ID (e.g., "target", "mouseover", etc.)
|
|
-- @return table or nil { pct, threat, tankName, tankThreat, isTanking,
|
|
-- isMelee, source, secondName, secondPct }
|
|
-------------------------------------------------------------------------------
|
|
function TE:QueryUnitThreat(unitID)
|
|
if not unitID or not UnitExists(unitID) then return nil end
|
|
if not self.inCombat then return nil end
|
|
|
|
local targetKey = GetTargetKey(unitID)
|
|
if not targetKey then return nil end
|
|
|
|
local td = self.targets[targetKey]
|
|
if not td then return nil end
|
|
|
|
local myData = td.players[self.playerName]
|
|
local myThreat = myData and myData.threat or 0
|
|
local myPct = myData and myData.perc or 0
|
|
|
|
local secondName, secondThreat, secondPct = nil, 0, 0
|
|
local sorted = {}
|
|
for name, pd in _pairs(td.players) do
|
|
table.insert(sorted, { name = name, threat = pd.threat })
|
|
end
|
|
table.sort(sorted, function(a, b) return a.threat > b.threat end)
|
|
if sorted[2] then
|
|
secondName = sorted[2].name
|
|
secondThreat = sorted[2].threat
|
|
secondPct = td.tankThreat > 0 and (secondThreat / td.tankThreat * 100) or 0
|
|
end
|
|
|
|
return {
|
|
pct = myPct,
|
|
threat = myThreat,
|
|
tankName = td.tankName,
|
|
tankThreat = td.tankThreat,
|
|
isTanking = myData and myData.isTanking or false,
|
|
isMelee = myData and myData.isMelee or false,
|
|
source = td.source,
|
|
secondName = secondName,
|
|
secondPct = secondPct,
|
|
secondThreat = secondThreat,
|
|
}
|
|
end
|
|
|
|
-------------------------------------------------------------------------------
|
|
-- Public API: NanamiDPS.ThreatEngine:QueryNameThreat(targetKey, playerName)
|
|
--
|
|
-- Query a specific player's threat on a specific target.
|
|
-- @param targetKey string Target key from GetTargetKey()
|
|
-- @param playerName string Player name
|
|
-- @return number, boolean, number threat, isTanking, pct
|
|
-------------------------------------------------------------------------------
|
|
function TE:QueryNameThreat(targetKey, playerName)
|
|
if not targetKey or not playerName then return 0, false, 0 end
|
|
local td = self.targets[targetKey]
|
|
if not td then return 0, false, 0 end
|
|
local pd = td.players[playerName]
|
|
if not pd then return 0, false, 0 end
|
|
return pd.threat, pd.isTanking, pd.perc
|
|
end
|