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