---------------------------------------------------------------------- -- RelationshipsThreatPlates_Threat.lua -- Threat calculation bridge for WoW 1.12 (Vanilla API) -- Works with Turtle WoW / Octo WoW threat hooks -- Standalone - does not require Omen, KLHThreatMeter, or any lib -- -- THREAT DATA SOURCES (in priority order): -- 1. UnitDetailedThreatSituation (Turtle WoW server extension) -- 2. GetThreatPercent API (custom server extension) -- 3. UnitThreatSituation (partial API on some servers) -- 4. ThreatEngine combat log parsing (precise per-1% values) -- 5. Target-based heuristic (crude fallback for edge cases only) -- -- The ThreatEngine (RelationshipsThreatPlates_ThreatEngine.lua) parses combat log -- events using GlobalStrings pattern matching to calculate accurate -- absolute threat values per mob per player, then derives precise -- percentages as (playerThreat / topThreat) * 100. ---------------------------------------------------------------------- -- CHANGES (multi-mob threat display fix): -- * GetThreatPercent now checks ALL party/raid members' threat -- on each mob, not just the player's own threat -- * Added groupThreatCache: per-mob cache of best threat from -- any group member, so ALL attacking mobs show accurate % -- * Added ScanGroupThreat: iterates party/raid members against -- each visible hostile mob using native threat APIs -- * Added ThreatEngine group parsing: CHAT_MSG_SPELL_PARTY_* -- and CHAT_MSG_SPELL_FRIENDLYPLAYER_* events now tracked -- * Mobs attacking any group member now show threat bars, -- not just mobs attacking the player -- * Previous fixes preserved: UnitAffectingCombat checks, -- idle mob filtering, Vanilla 1.12 compatibility ---------------------------------------------------------------------- local addon = RelationshipsThreatPlates addon.Threat = {} -- Cache for threat values per unit identifier (name-based in 1.12) local threatCache = {} -- Group-wide threat cache: groupThreatCache[mobUID] = { -- bestPct, bestSit, bestSource, mobTargetName, lastUpdate, -- playerPct, playerSit, -- sources = { [sourceName] = { pct, sit } } -- } -- This stores the BEST threat from ANY group member for each mob, -- so all attacking mobs show accurate percentages. local groupThreatCache = {} -- Player name cache local playerName = nil -- Party/raid member names cache: name -> unitToken local partyNames = {} -- Party/raid unit tokens cache (ordered list) local partyUnits = {} -- Update throttle (seconds) local THROTTLE = 0.2 -- ================================================================== -- Safe API call helpers -- ================================================================== local function SafeCall2(func, a, b) if not func then return nil end local ok, r1 = pcall(func, a, b) if ok then return r1 end return nil end local function SafeCall5(func, a, b) if not func then return nil, nil, nil, nil, nil end local ok, r1, r2, r3, r4, r5 = pcall(func, a, b) if ok then return r1, r2, r3, r4, r5 end return nil, nil, nil, nil, nil end -- ================================================================== -- Detect which threat APIs are available at runtime -- ================================================================== local hasUnitDetailedThreatSituation = false local hasGetThreatPercent = false local hasUnitThreatSituation = false local function DetectThreatAPIs() hasUnitDetailedThreatSituation = (UnitDetailedThreatSituation ~= nil) hasGetThreatPercent = (GetThreatPercent ~= nil) hasUnitThreatSituation = (UnitThreatSituation ~= nil) if hasUnitDetailedThreatSituation then addon:ui_print("UnitDetailedThreatSituation detected") end if hasGetThreatPercent then addon:ui_print("GetThreatPercent detected") end if hasUnitThreatSituation then addon:ui_print("UnitThreatSituation detected") end if not hasUnitDetailedThreatSituation and not hasGetThreatPercent and not hasUnitThreatSituation then addon:ui_print("No native threat API - using combat log parsing + heuristic") end end -- ================================================================== -- Cache party/raid member names for threat heuristic -- ================================================================== local function UpdatePartyNames() partyNames = {} partyUnits = {} -- Player if playerName then partyNames[playerName] = "player" partyUnits[1] = "player" end -- Party for i = 1, 4 do local unit = "party" .. i if UnitExists(unit) then local ok, name = pcall(UnitName, unit) if ok and name then partyNames[name] = unit partyUnits[table.getn(partyUnits) + 1] = unit end end end -- Raid for i = 1, 40 do local unit = "raid" .. i if UnitExists(unit) then local ok, name = pcall(UnitName, unit) if ok and name then partyNames[name] = unit partyUnits[table.getn(partyUnits) + 1] = unit end end end end -- ================================================================== -- Scan group threat: check all party/raid members' threat on -- a specific mob unit using native threat APIs. -- -- Returns: bestPct, bestSit, bestSourceName, playerPct, playerSit -- bestPct = highest threat % from any group member -- bestSit = threat situation of the member with highest threat -- bestSourceName = name of the group member with highest threat -- playerPct = the player's own threat % on this mob -- playerSit = the player's own threat situation -- -- If no native APIs are available, returns nil (caller should -- fall through to ThreatEngine / heuristic). -- ================================================================== local function ScanGroupThreatOnUnit(mobUnit) if not mobUnit or not UnitExists(mobUnit) then return nil end -- Only scan if at least one native threat API is available if not hasUnitDetailedThreatSituation and not hasGetThreatPercent and not hasUnitThreatSituation then return nil end local bestPct = 0 local bestSit = 0 local bestSource = nil local playerPct = 0 local playerSit = 0 for i = 1, table.getn(partyUnits) do local groupUnit = partyUnits[i] if UnitExists(groupUnit) then local memberPct = nil local memberSit = nil -- Method 1: UnitDetailedThreatSituation if hasUnitDetailedThreatSituation then local isTanking, status, scaledPct, rawPct, threatVal = SafeCall5(UnitDetailedThreatSituation, groupUnit, mobUnit) if rawPct then memberPct = math.min(100, math.max(0, rawPct)) memberSit = status or 0 end end -- Method 2: GetThreatPercent if not memberPct and hasGetThreatPercent then local pct = SafeCall2(GetThreatPercent, groupUnit, mobUnit) if pct then memberPct = math.min(100, math.max(0, pct)) if hasUnitThreatSituation then memberSit = SafeCall2(UnitThreatSituation, groupUnit, mobUnit) or 0 else if memberPct >= 100 then memberSit = 3 elseif memberPct >= 60 then memberSit = 2 elseif memberPct >= 30 then memberSit = 1 else memberSit = 0 end end end end -- Method 3: UnitThreatSituation if not memberPct and hasUnitThreatSituation then memberSit = SafeCall2(UnitThreatSituation, groupUnit, mobUnit) or 0 if memberSit == 0 then memberPct = 0 elseif memberSit == 1 then memberPct = 25 elseif memberSit == 2 then memberPct = 60 elseif memberSit == 3 then memberPct = 100 end end if memberPct then -- Track player's own threat specifically if groupUnit == "player" then playerPct = memberPct playerSit = memberSit or 0 end -- Track the best threat from any group member if memberPct > bestPct then bestPct = memberPct bestSit = memberSit or 0 local ok, name = pcall(UnitName, groupUnit) bestSource = (ok and name) or groupUnit end end end end return bestPct, bestSit, bestSource, playerPct, playerSit end -- ================================================================== -- Check if a name belongs to a party/raid member -- Returns: true if the name is a group member, false otherwise -- ================================================================== function addon.Threat:IsPartyMember(name) if not name then return false end if name == playerName then return true end if partyNames[name] then return true end return false end -- ================================================================== -- Initialize threat module -- ================================================================== function addon.Threat:Initialize() playerName = UnitName("player") -- Detect available APIs DetectThreatAPIs() -- Cache party names UpdatePartyNames() -- Polling frame for periodic threat updates self.pollFrame = CreateFrame("Frame", "RelationshipsThreatPlatesPollFrame") self.pollFrame.elapsed = 0 self.pollFrame:SetScript("OnUpdate", function() this.elapsed = this.elapsed + arg1 if this.elapsed >= THROTTLE then this.elapsed = 0 addon.Threat:PollThreat() end end) -- Event frame for threat-related events local eventFrame = CreateFrame("Frame", "RelationshipsThreatPlatesEventFrame") eventFrame:RegisterEvent("PLAYER_TARGET_CHANGED") eventFrame:RegisterEvent("PARTY_MEMBERS_CHANGED") eventFrame:RegisterEvent("RAID_ROSTER_UPDATE") -- Safely register threat event only if API exists if hasUnitThreatSituation or hasUnitDetailedThreatSituation then eventFrame:RegisterEvent("UNIT_THREAT_SITUATION_UPDATE") end eventFrame:SetScript("OnEvent", function() addon.Threat:OnEvent(event) end) self.eventFrame = eventFrame end -- ================================================================== -- Event handler -- ================================================================== function addon.Threat:OnEvent(ev) if ev == "PLAYER_TARGET_CHANGED" then self:PollThreat() elseif ev == "UNIT_THREAT_SITUATION_UPDATE" then local unit = arg1 if unit and UnitExists(unit) then self:UpdateUnitThreat(unit) end elseif ev == "PARTY_MEMBERS_CHANGED" or ev == "RAID_ROSTER_UPDATE" then UpdatePartyNames() self:PollThreat() end end -- ================================================================== -- Poll threat for all visible nameplates -- ================================================================== function addon.Threat:PollThreat() if not RelationshipsThreatPlatesDB or not RelationshipsThreatPlatesDB.enabled then return end for nameplate, data in pairs(addon.Nameplates.plates) do if nameplate:IsVisible() and data.unit then self:UpdateUnitThreat(data.unit) end end end -- ================================================================== -- Get threat percentage for a unit -- Returns: threatPct (0-100), threatSit (0-3) -- -- PRIORITY: -- 1. Group-wide scan via UnitDetailedThreatSituation (Turtle WoW) -- 2. Group-wide scan via GetThreatPercent API (custom extension) -- 3. Group-wide scan via UnitThreatSituation (partial API) -- 4. ThreatEngine combat log data (precise per-1% values) -- 5. Target-based heuristic (crude fallback only) -- -- KEY CHANGE: Methods 1-3 now check ALL party/raid members' threat -- on the mob, not just the player. This ensures that: -- - Mobs attacking the player show the player's threat % -- - Mobs attacking party members show those members' threat % -- - ALL mobs in combat with the group show accurate threat bars ---------------------------------------------------------------------- function addon.Threat:GetThreatPercent(unit) if not unit or not UnitExists(unit) then return 0, 0 end -- ============================================================== -- Pre-filter: Only check threat for hostile, in-combat mobs -- These checks apply regardless of which method we use below. -- ============================================================== -- Only check threat for hostile units local reaction = UnitReaction(unit, "player") if reaction and reaction > 4 then -- Friendly unit - no threat display return 0, 0 end -- Check if player OR any group member is in combat. -- CRITICAL FIX: Previously only checked UnitAffectingCombat("player"), -- which meant mobs attacking party members showed no threat when the -- player wasn't in combat. Now we check the entire group. local playerInCombat = UnitAffectingCombat("player") local groupInCombat = addon.ThreatEngine:IsGroupInCombat() -- The MOB itself must be in combat. local ok_mc, mobCombat = pcall(UnitAffectingCombat, unit) if ok_mc and not mobCombat then return 0, 0 end -- Verify this is a hostile unit the player can attack local ok_ca, canAttack = pcall(UnitCanAttack, "player", unit) if ok_ca and not canAttack then -- Also check if any group member can attack it local groupCanAttack = false for i = 1, table.getn(partyUnits) do local ok_gca, gca = pcall(UnitCanAttack, partyUnits[i], unit) if ok_gca and gca then groupCanAttack = true break end end if not groupCanAttack then return 0, 0 end end -- Get mob name and who the mob is targeting (used by multiple methods) local ok_name, mobName = pcall(UnitName, unit) if not ok_name or not mobName then return 0, 0 end local mobTargetName = nil local ok_mt, mt = pcall(UnitName, unit .. "target") if ok_mt and mt then mobTargetName = mt end -- ============================================================== -- Methods 1-3: Group-wide native threat API scan -- Check ALL party/raid members' threat on this mob. -- This is the KEY fix: instead of only checking the player's -- threat, we check every group member and return the best. -- ============================================================== if hasUnitDetailedThreatSituation or hasGetThreatPercent or hasUnitThreatSituation then local bestPct, bestSit, bestSource, playerPct, playerSit = ScanGroupThreatOnUnit(unit) if bestPct and bestPct > 0 then -- We have threat data from at least one group member. -- Determine whose threat to display: -- If the mob is targeting the player -> show player's threat -- If the mob is targeting a group member -> show that member's threat -- If the mob is targeting an outsider -> show player's threat -- Otherwise -> show the best (highest) threat from any member if mobTargetName == playerName then -- Mob is attacking the player - show player's own threat return playerPct, playerSit elseif mobTargetName and partyNames[mobTargetName] then -- Mob is attacking a group member - show that member's threat -- Look up that member's threat from the group scan local memberUnit = partyNames[mobTargetName] local memberPct = nil local memberSit = nil if hasUnitDetailedThreatSituation then local _, status, _, rawPct = SafeCall5(UnitDetailedThreatSituation, memberUnit, unit) if rawPct then memberPct = math.min(100, math.max(0, rawPct)) memberSit = status or 0 end end if not memberPct and hasGetThreatPercent then local pct = SafeCall2(GetThreatPercent, memberUnit, unit) if pct then memberPct = math.min(100, math.max(0, pct)) if hasUnitThreatSituation then memberSit = SafeCall2(UnitThreatSituation, memberUnit, unit) or 0 else if memberPct >= 100 then memberSit = 3 elseif memberPct >= 60 then memberSit = 2 elseif memberPct >= 30 then memberSit = 1 else memberSit = 0 end end end end if not memberPct and hasUnitThreatSituation then memberSit = SafeCall2(UnitThreatSituation, memberUnit, unit) or 0 if memberSit == 0 then memberPct = 0 elseif memberSit == 1 then memberPct = 25 elseif memberSit == 2 then memberPct = 60 elseif memberSit == 3 then memberPct = 100 end end if memberPct then return memberPct, memberSit or 0 end -- Fallback: can't get specific member data, use best return bestPct, bestSit else -- Mob targets an outsider, or we can't see its target. -- Show player's threat if they have any, otherwise the -- best group threat. if playerPct > 0 then return playerPct, playerSit end return bestPct, bestSit end end -- All group members have 0 threat on this mob via native APIs, -- but the mob IS in combat. This can happen if: -- - Only ThreatEngine has data (combat log parsing) -- - The mob is fighting an outsider -- - The mob was just pulled and the API hasn't updated yet -- Fall through to ThreatEngine / heuristic below. end -- ============================================================== -- Method 4: ThreatEngine combat log parsing -- This provides precise per-1% threat values based on actual -- damage/healing/ability threat tracked from the combat log. -- This is the PRIMARY method on servers without native APIs. -- -- KEY FIX: Now uses group combat state instead of only player combat. -- If any group member is in combat, ThreatEngine data is relevant. -- ============================================================== -- Someone in the group must be in combat for ThreatEngine data -- to be relevant. This prevents threat bars on idle mobs when -- no one in the group is fighting. if not groupInCombat then return 0, 0 end -- If the mob targets an outsider (someone not in the -- player's party/raid), verify the player has actually engaged -- this mob personally before showing any threat bar. if mobTargetName and mobTargetName ~= playerName and not partyNames[mobTargetName] then -- Mob targets someone outside our group. -- Check if the player has personally hit this mob. -- If player has 0 threat on this mob, don't show a bar. local playerThreat = addon.ThreatEngine:GetPlayerThreat(mobName) if playerThreat <= 0 then return 0, 0 end -- Player has some threat but mob targets an outsider. -- The player's threat is secondary - show it but reduced. -- We can't know the outsider's threat, so estimate conservatively. -- Use ThreatEngine data if available, otherwise heuristic. local enginePct, engineSit, _, _, hasGroupData = addon.ThreatEngine:GetThreatOnTarget(mobName) if enginePct > 0 then if hasGroupData then return enginePct, engineSit end -- Solo data only - the outsider probably has more threat. -- Cap our displayed percentage conservatively. if enginePct >= 100 then return 80, 2 end return enginePct, engineSit end -- No ThreatEngine data but player is hitting it - rough estimate if UnitIsUnit("target", unit) then return 40, 1 end return 20, 0 end -- Query ThreatEngine with the mob's name local enginePct, engineSit, enginePlayerThreat, engineTopThreat, hasGroupData = addon.ThreatEngine:GetThreatOnTarget(mobName) if enginePct > 0 then if hasGroupData then -- We have threat data from multiple sources (group broadcasts). -- The percentage is reliable - use it directly. return enginePct, engineSit end -- ThreatEngine only has the player's own threat data (no group -- broadcasts). Cross-reference with mob's target to calibrate. if mobTargetName then if mobTargetName == playerName then -- Mob targets the player AND player has threat data. -- Player really does have aggro. Since we only know our -- own threat and we're the top, show as 100%. return 100, 3 elseif partyNames[mobTargetName] then -- Mob targets a PARTY/RAID member. The player doesn't -- have aggro but does have some threat. Show the -- ThreatEngine percentage as the player's relative threat. -- This is much better than the old fixed 60%/30% estimate. return enginePct, engineSit else -- Mob targets an outsider - show conservative estimate if enginePct >= 100 then return 90, 2 end return enginePct, engineSit end else -- Can't see mob's target. Show raw ThreatEngine data -- but cap at 90% since we can't confirm we have aggro. if enginePct >= 100 then return 90, 2 end return enginePct, engineSit end end -- ============================================================== -- Method 5: Target-based heuristic (last resort fallback) -- Only used when ThreatEngine has NO data for a mob. -- This can happen for: newly engaged mobs, mobs in combat with -- other players outside our group, or very brief encounters. -- -- This is the MOST RELIABLE heuristic in Vanilla WoW: -- Who is the mob targeting? That person has aggro. -- -- KEY CHANGE: Now properly handles party/raid members. -- If the mob is targeting a group member, we show a high -- threat bar (that member has aggro), not a low estimate. -- ============================================================== -- Check if the mob is targeting someone if mobTargetName then if mobTargetName == playerName then -- Mob is targeting the PLAYER - player has AGGRO (100%) threatPct = 100 situation = 3 elseif partyNames[mobTargetName] then -- Mob is targeting a PARTY/RAID member. -- That member has aggro - show high threat. -- This is the KEY fix: previously showed only 60%/30% -- for mobs targeting group members. Now shows 100% -- because that group member IS the one being attacked. threatPct = 100 situation = 3 else -- Mob is targeting someone outside the party -- (another player, a pet, etc.) if UnitIsUnit("target", unit) then threatPct = 40 situation = 1 else threatPct = 15 situation = 0 end end else -- Mob's target is nil. We already know it's in combat. -- This happens when someone outside our group has aggro -- or the mob is between targets. if UnitIsUnit("target", unit) then threatPct = 40 situation = 1 else threatPct = 10 situation = 0 end end return threatPct, situation end -- ================================================================== -- Update threat for a specific unit and notify nameplates -- ================================================================== function addon.Threat:UpdateUnitThreat(unit) if not unit or not UnitExists(unit) then return end local pct, situation = self:GetThreatPercent(unit) local uid = addon:GetUnitID(unit) if uid then -- Get mob target info for group context local mobTargetName = nil local ok_mt, mt = pcall(UnitName, unit .. "target") if ok_mt and mt then mobTargetName = mt end threatCache[uid] = { pct = pct, situation = situation, unit = unit, lastUpdate = GetTime(), mobTargetName = mobTargetName, } -- Also store in group threat cache for cross-reference groupThreatCache[uid] = { bestPct = pct, bestSit = situation, mobTargetName = mobTargetName, lastUpdate = GetTime(), } addon.Nameplates:UpdateThreatForUnit(unit, pct, situation) end end -- ================================================================== -- Get cached threat for a unit identifier -- ================================================================== function addon.Threat:GetCachedThreat(uid) if not uid then return 0, 0 end local cache = threatCache[uid] if cache then if GetTime() - cache.lastUpdate < 5 then return cache.pct, cache.situation end end return 0, 0 end -- ================================================================== -- Get threat percentage by mob NAME (no unit token required) -- This is the fallback for when FindUnitByName couldn't resolve -- a unit token for a nameplate. -- -- CRITICAL FIX for same-name mob confusion: -- In Vanilla 1.12 there is no UnitGUID. Multiple mobs with the -- same name (e.g. "Defias Bandit") all share one ThreatEngine -- entry. If we return threat data by name alone, idle same-name -- mobs will incorrectly show threat bars. -- -- Solution: GetThreatByName now REQUIRES finding a unit token -- that is verified to be in combat. If no combat-verified unit -- token can be found for the given name, it returns 0, 0. -- This ensures idle mobs never get threat data. -- -- Returns: threatPct (0-100), threatSit (0-3) -- ================================================================== function addon.Threat:GetThreatByName(mobName) if not mobName or strlen(mobName) == 0 then return 0, 0 end -- KEY FIX: Only require that SOMEONE in the group is in combat, -- not necessarily the player. This allows threat bars to show -- on mobs attacking party/raid members even when the player -- isn't in combat themselves. if not addon.ThreatEngine:IsGroupInCombat() then return 0, 0 end -- ============================================================== -- Step 1: Try to find a combat-verified unit token. -- If we can find one and verify it's in combat, use the full -- GetThreatPercent which has all the proper checks. -- ============================================================== local unitTokens = { "target", "mouseover", "targettarget", "pet", } for i = 1, 4 do unitTokens[table.getn(unitTokens) + 1] = "party" .. i .. "target" unitTokens[table.getn(unitTokens) + 1] = "party" .. i .. "targettarget" end for i = 1, 40 do unitTokens[table.getn(unitTokens) + 1] = "raid" .. i .. "target" unitTokens[table.getn(unitTokens) + 1] = "raid" .. i .. "targettarget" end -- Search for a unit token that matches the name AND is in combat for i = 1, table.getn(unitTokens) do local token = unitTokens[i] local ok, exists = pcall(UnitExists, token) if ok and exists then local ok2, name = pcall(UnitName, token) if ok2 and name and name == mobName then local ok3, inCombat = pcall(UnitAffectingCombat, token) if ok3 and inCombat then local pct, sit = self:GetThreatPercent(token) return pct, sit end end end end -- ============================================================== -- Step 2: No combat-verified unit token found. -- -- For UNIQUE-name mobs (only 1 plate with this name visible), -- the ThreatEngine data IS unambiguous — there's only one mob -- with this name, so the threat data is definitely about it. -- We can use ThreatEngine data directly. -- -- For SAME-name mobs (multiple plates share this name), we -- CANNOT use ThreatEngine data without a unit token because -- it would apply the same threat to all same-name plates, -- including idle ones. -- -- The caller (UpdateAllPlates) decides whether a name is -- unique. GetThreatByName is ONLY called for unique names -- from UpdateAllPlates, so it's safe to use ThreatEngine here. -- ============================================================== if addon.ThreatEngine:HasDataForTarget(mobName) then local enginePct, engineSit, _, _, hasGroupData = addon.ThreatEngine:GetThreatOnTarget(mobName) if enginePct > 0 then -- We have ThreatEngine data for this unique-name mob. -- Cap at 90% since we can't verify who has aggro -- without seeing the mob's target. if enginePct >= 100 then return 90, 2 end return enginePct, engineSit end end -- No ThreatEngine data either. This mob likely hasn't been -- engaged by the player. Return 0. return 0, 0 end -- ================================================================== -- Check if a mob is attacking any group member -- Returns: the name of the group member being attacked, or nil -- ================================================================== function addon.Threat:GetMobTarget(mobUnit) if not mobUnit or not UnitExists(mobUnit) then return nil end local ok, targetName = pcall(UnitName, mobUnit .. "target") if ok and targetName then if targetName == playerName or partyNames[targetName] then return targetName end end return nil end -- ================================================================== -- Get cached group threat for a unit identifier -- Returns: bestPct, bestSit, mobTargetName -- ================================================================== function addon.Threat:GetGroupCachedThreat(uid) if not uid then return 0, 0, nil end local cache = groupThreatCache[uid] if cache then if GetTime() - cache.lastUpdate < 5 then return cache.bestPct, cache.bestSit, cache.mobTargetName end end return 0, 0, nil end -- ================================================================== -- Clear all threat data -- ================================================================== function addon.Threat:ClearAll() threatCache = {} groupThreatCache = {} end