Files
RelationshipsThreatPlates/RelationshipsThreatPlates_ThreatEngine.lua
T
2026-07-08 18:24:21 +01:00

1987 lines
71 KiB
Lua

----------------------------------------------------------------------
-- RelationshipsThreatPlates_ThreatEngine.lua
-- Accurate threat calculation engine for WoW 1.12 (Vanilla API)
-- Parses combat log events to calculate precise per-1% threat values
-- instead of crude target-based heuristics (20%, 50%, 75%, 100%)
--
-- APPROACH:
-- 1. Parse Vanilla 1.12 combat log events using GlobalStrings pattern
-- matching (same technique as KTM/ShaguDPS)
-- 2. Track absolute threat values per mob name per player
-- 3. Apply class-specific threat modifiers (stance, buffs, talents)
-- 4. Calculate precise percentage: playerThreat / topThreat * 100
-- 5. Broadcast threat data via CHAT_MSG_ADDON for group sync
--
-- VANILLA 1.12 COMPATIBILITY:
-- - No string.match() -> use string.find()
-- - No select() -> use arg1..arg9
-- - No varargs (...) -> use arg table
-- - No CombatLogGetCurrentEventInfo() -> use GlobalStrings parsing
-- - No UnitGUID -> track by mob name
-- - string.gfind exists in 1.12 (alias for string.gmatch)
----------------------------------------------------------------------
-- CHANGES (multi-mob threat display fix):
-- * Added PARTY damage/heal parsers using OTHEROTHER GlobalStrings
-- (Vanilla 1.12 has no PARTY-specific GlobalStrings; the same
-- OTHEROTHER patterns like SPELLLOGSCHOOLOTHEROTHER appear on
-- party/friendly combat log events per ShaguDPS/KTM conventions)
-- * Events: CHAT_MSG_SPELL_PARTY_DAMAGE, CHAT_MSG_COMBAT_PARTY_HITS,
-- CHAT_MSG_SPELL_FRIENDLYPLAYER_DAMAGE, CHAT_MSG_COMBAT_FRIENDLYPLAYER_HITS,
-- CHAT_MSG_SPELL_PERIODIC_PARTY_DAMAGE, CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_DAMAGE,
-- CHAT_MSG_SPELL_PARTY_BUFF, CHAT_MSG_SPELL_FRIENDLYPLAYER_BUFF,
-- CHAT_MSG_SPELL_PERIODIC_PARTY_BUFFS, CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_BUFFS
-- * Now tracks threat per mob per ALL group members, not just player
-- * Added partyThreatModifier estimation for group members
-- * AddThreat() now accepts optional sourceName parameter
-- * GetThreatOnTarget() returns threat for any group member
-- * GetEngagedMobs() includes mobs attacking any group member
----------------------------------------------------------------------
local addon = RelationshipsThreatPlates
addon.ThreatEngine = {}
-- ============================================================
-- Constants
-- ============================================================
-- Threat constants (from KTM/ThreatClassic-1.0)
local THREAT_CONST = {
healing = 0.5, -- healing threat coefficient
meleeAggro = 1.1, -- melee pull aggro at 110%
rangeAggro = 1.3, -- ranged pull aggro at 130%
rageGain = 5.0, -- threat per rage gained
energyGain = 5.0, -- threat per energy gained
manaGain = 0.5, -- threat per mana gained
}
-- Addon communication prefix
local COMM_PREFIX = "TPThreat"
local COMM_VERSION = 1
-- ============================================================
-- State variables
-- ============================================================
-- Player info (cached on load)
local playerName = nil
local playerClass = nil -- lowercase english class name
-- Threat table: threatData[targetName][sourceName] = absoluteThreatValue
-- This tracks how much threat each player has on each mob
-- KEY CHANGE: sourceName can now be any group member, not just playerName
local threatData = {}
-- Target tracking: what the player's current target is
local currentTargetName = nil
-- Player's threat modifiers (recalculated periodically)
local threatModifier = 1.0
-- Party member class cache: partyMemberName -> classLowercase
-- Used for estimating threat modifiers for group members
local partyClassCache = {}
-- Sunder Armor transaction (KTM-style: assume success, retract on failure)
local pendingSunder = nil
local pendingSunderTime = 0
-- Player combat state
local playerInCombat = false
-- Group combat state: true if any party/raid member is in combat.
-- This is CRITICAL for showing threat bars on mobs attacking group
-- members when the player is NOT in combat themselves.
local groupInCombat = false
-- ============================================================
-- Safe API helpers
-- ============================================================
local function SafeCall1(func, a)
if not func then return nil end
local ok, r1 = pcall(func, a)
if ok then return r1 end
return nil
end
local function SafeCall2(func, a, b)
if not func then return nil, nil end
local ok, r1, r2 = pcall(func, a, b)
if ok then return r1, r2 end
return nil, nil
end
-- ============================================================
-- GlobalStrings -> Regex conversion
-- Same technique used by KTM and ShaguDPS:
-- Convert format strings like "Your %s hits %s for %d."
-- into regex patterns like "^Your (.+) hits (.+) for (%d+)%."
-- This works on ALL locales automatically because the client
-- defines these GlobalStrings variables at runtime.
-- ============================================================
local regexCache = {}
-- Convert a printf-style format string into a Lua regex pattern
-- Handles both simple (%s, %d) and positional (%1$s, %2$d) formats
local function FormatToRegex(formatStr)
if not formatStr then return nil end
if regexCache[formatStr] then return regexCache[formatStr] end
local regex = formatStr
-- Escape regex magic characters in the format string
-- We need to escape: . ( ) % + - * ? [ ^ $
-- But NOT the %s %d format specifiers
regex = string.gsub(regex, "([%+%-%*%?%[%]%^%$])", "%%%1")
regex = string.gsub(regex, "%.", "%%.")
-- Now replace format specifiers with capture groups
-- Handle positional format: %1$s, %2$d, etc.
regex = string.gsub(regex, "%%(%d+)%$([sd])", function(idx, typ)
if typ == "d" then
return "(%d+)"
else
return "(.+)"
end
end)
-- Handle simple format: %s, %d
regex = string.gsub(regex, "%%([sd])", function(typ)
if typ == "d" then
return "(%d+)"
else
return "(.+)"
end
end)
-- Anchor to start of string for faster matching
regex = "^" .. regex
regexCache[formatStr] = regex
return regex
end
-- ============================================================
-- Parser definitions
-- Maps GlobalStrings variable names to parse handlers
-- These are the combat log patterns we need to track for threat
-- ============================================================
-- Each parser entry: { globalVarName, handler }
-- The handler receives the captures from the regex match
-- and returns: sourceName, targetName, value, spellName, isCrit, spellSchool
local SELF_DAMAGE_PARSERS = {}
local SELF_HEAL_PARSERS = {}
local SELF_BUFF_PARSERS = {}
local PERIODIC_PARSERS = {}
-- Party/raid member parsers - same patterns but with player name captures
local PARTY_DAMAGE_PARSERS = {}
local PARTY_HEAL_PARSERS = {}
local PARTY_PERIODIC_PARSERS = {}
-- Build parsers from GlobalStrings at load time
local parsersBuilt = false
-- ============================================================
-- Build parsers from GlobalStrings at runtime
-- This converts GlobalStrings like SPELLLOGSELFOTHER into
-- regex patterns and stores them for fast matching
-- ============================================================
local function BuildParser(t, globalVarName, handler)
local formatStr = getglobal(globalVarName)
if not formatStr then return end
local regex = FormatToRegex(formatStr)
if not regex then return end
t[table.getn(t) + 1] = { regex = regex, handler = handler, globalVar = globalVarName }
end
local function BuildAllParsers()
if parsersBuilt then return end
parsersBuilt = true
-- ========================================
-- CHAT_MSG_COMBAT_SELF_HITS parsers
-- Auto-attack (white damage) from player
-- ========================================
BuildParser(SELF_DAMAGE_PARSERS, "COMBATHITSELFOTHER",
function(target, damage)
return playerName, target, tonumber(damage) or 0, nil, false, nil
end)
BuildParser(SELF_DAMAGE_PARSERS, "COMBATHITCRITSELFOTHER",
function(target, damage)
return playerName, target, tonumber(damage) or 0, nil, true, nil
end)
BuildParser(SELF_DAMAGE_PARSERS, "COMBATHITSCHOOLSELFOTHER",
function(target, damage, school)
return playerName, target, tonumber(damage) or 0, nil, false, school
end)
BuildParser(SELF_DAMAGE_PARSERS, "COMBATHITCRITSCHOOLSELFOTHER",
function(target, damage, school)
return playerName, target, tonumber(damage) or 0, nil, true, school
end)
-- ========================================
-- CHAT_MSG_SPELL_SELF_DAMAGE parsers
-- Spell/ability damage from player
-- ========================================
BuildParser(SELF_DAMAGE_PARSERS, "SPELLLOGSCHOOLSELFOTHER",
function(spell, target, damage, school)
return playerName, target, tonumber(damage) or 0, spell, false, school
end)
BuildParser(SELF_DAMAGE_PARSERS, "SPELLLOGCRITSCHOOLSELFOTHER",
function(spell, target, damage, school)
return playerName, target, tonumber(damage) or 0, spell, true, school
end)
BuildParser(SELF_DAMAGE_PARSERS, "SPELLLOGSELFOTHER",
function(spell, target, damage)
return playerName, target, tonumber(damage) or 0, spell, false, nil
end)
BuildParser(SELF_DAMAGE_PARSERS, "SPELLLOGCRITSELFOTHER",
function(spell, target, damage)
return playerName, target, tonumber(damage) or 0, spell, true, nil
end)
-- Non-damage spell performs (Sunder Armor, etc.)
BuildParser(SELF_DAMAGE_PARSERS, "SPELLPERFORMGOSELFTARGETTED",
function(spell, target)
return playerName, target, 0, spell, false, nil
end)
BuildParser(SELF_DAMAGE_PARSERS, "SPELLCASTGOSELFTARGETTED",
function(spell, target)
return playerName, target, 0, spell, false, nil
end)
-- Spell hitting self (e.g. own spell reflected)
BuildParser(SELF_DAMAGE_PARSERS, "SPELLLOGSCHOOLSELFSELF",
function(spell, damage, school)
return playerName, playerName, tonumber(damage) or 0, spell, false, school
end)
-- ========================================
-- CHAT_MSG_SPELL_SELF_BUFF parsers
-- Healing from player
-- ========================================
BuildParser(SELF_HEAL_PARSERS, "HEALEDSELFOTHER",
function(spell, target, amount)
return playerName, target, tonumber(amount) or 0, spell, false, nil
end)
BuildParser(SELF_HEAL_PARSERS, "HEALEDCRITSELFOTHER",
function(spell, target, amount)
return playerName, target, tonumber(amount) or 0, spell, true, nil
end)
BuildParser(SELF_HEAL_PARSERS, "HEALEDSELFSELF",
function(spell, amount)
return playerName, playerName, tonumber(amount) or 0, spell, false, nil
end)
BuildParser(SELF_HEAL_PARSERS, "HEALEDCRITSELFSELF",
function(spell, amount)
return playerName, playerName, tonumber(amount) or 0, spell, true, nil
end)
-- ========================================
-- CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS parsers
-- HoT ticks and power gains
-- ========================================
BuildParser(PERIODIC_PARSERS, "PERIODICAURAHEALSELFSELF",
function(amount, spell)
return playerName, playerName, tonumber(amount) or 0, spell, false, nil
end)
BuildParser(PERIODIC_PARSERS, "PERIODICAURAHEALSELFOTHER",
function(target, amount, spell)
return playerName, target, tonumber(amount) or 0, spell, false, nil
end)
-- Power gains (rage, energy, mana)
BuildParser(PERIODIC_PARSERS, "POWERGAINSELFSELF",
function(amount, powerType, spell)
return playerName, nil, tonumber(amount) or 0, spell, false, powerType
end)
-- ========================================
-- CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE parsers
-- DoT ticks from player on mobs
-- ========================================
BuildParser(PERIODIC_PARSERS, "PERIODICAURADAMAGESELFOTHER",
function(target, damage, school, spell)
return playerName, target, tonumber(damage) or 0, spell, false, school
end)
addon:ui_print("Threat engine: " .. table.getn(SELF_DAMAGE_PARSERS) ..
" damage, " .. table.getn(SELF_HEAL_PARSERS) ..
" heal, " .. table.getn(PERIODIC_PARSERS) .. " periodic parsers built")
-- ========================================
-- CHAT_MSG_SPELL_PARTY_DAMAGE parsers
-- CHAT_MSG_SPELL_FRIENDLYPLAYER_DAMAGE parsers
-- Spell/ability damage from party/raid members
-- Uses OTHEROTHER GlobalStrings: "%s's %s hits %s for %d."
-- Vanilla 1.12 has no PARTY-specific GlobalStrings;
-- the same OTHEROTHER patterns appear on party/friendly events.
-- ========================================
BuildParser(PARTY_DAMAGE_PARSERS, "SPELLLOGSCHOOLOTHEROTHER",
function(source, spell, target, damage, school)
return source, target, tonumber(damage) or 0, spell, false, school
end)
BuildParser(PARTY_DAMAGE_PARSERS, "SPELLLOGCRITSCHOOLOTHEROTHER",
function(source, spell, target, damage, school)
return source, target, tonumber(damage) or 0, spell, true, school
end)
BuildParser(PARTY_DAMAGE_PARSERS, "SPELLLOGOTHEROTHER",
function(source, spell, target, damage)
return source, target, tonumber(damage) or 0, spell, false, nil
end)
BuildParser(PARTY_DAMAGE_PARSERS, "SPELLLOGCRITOTHEROTHER",
function(source, spell, target, damage)
return source, target, tonumber(damage) or 0, spell, true, nil
end)
-- ========================================
-- CHAT_MSG_COMBAT_PARTY_HITS parsers
-- Auto-attack (white damage) from party/raid members
-- Uses OTHEROTHER GlobalStrings: "%s hits %s for %d."
-- ========================================
BuildParser(PARTY_DAMAGE_PARSERS, "COMBATHITOTHEROTHER",
function(source, target, damage)
return source, target, tonumber(damage) or 0, nil, false, nil
end)
BuildParser(PARTY_DAMAGE_PARSERS, "COMBATHITCRITOTHEROTHER",
function(source, target, damage)
return source, target, tonumber(damage) or 0, nil, true, nil
end)
BuildParser(PARTY_DAMAGE_PARSERS, "COMBATHITSCHOOLOTHEROTHER",
function(source, target, damage, school)
return source, target, tonumber(damage) or 0, nil, false, school
end)
BuildParser(PARTY_DAMAGE_PARSERS, "COMBATHITCRITSCHOOLOTHEROTHER",
function(source, target, damage, school)
return source, target, tonumber(damage) or 0, nil, true, school
end)
-- ========================================
-- CHAT_MSG_SPELL_PERIODIC_PARTY_DAMAGE parsers
-- CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_DAMAGE parsers
-- DoT ticks from party/raid members
-- Uses OTHEROTHER: "%s suffers %d %s damage from %s's %s."
-- ========================================
BuildParser(PARTY_DAMAGE_PARSERS, "PERIODICAURADAMAGEOTHEROTHER",
function(target, damage, school, source, spell)
return source, target, tonumber(damage) or 0, spell, false, school
end)
-- ========================================
-- CHAT_MSG_SPELL_FRIENDLYPLAYER_BUFF parsers
-- CHAT_MSG_SPELL_PARTY_BUFF parsers
-- Healing from party/raid members
-- Uses OTHEROTHER GlobalStrings: "%s's %s heals %s for %d."
-- ========================================
BuildParser(PARTY_HEAL_PARSERS, "HEALEDOTHEROTHER",
function(source, spell, target, amount)
return source, target, tonumber(amount) or 0, spell, false, nil
end)
BuildParser(PARTY_HEAL_PARSERS, "HEALEDCRITOTHEROTHER",
function(source, spell, target, amount)
return source, target, tonumber(amount) or 0, spell, true, nil
end)
-- OTHERSELF: "%s's %s heals you for %d." - party member healing the player
BuildParser(PARTY_HEAL_PARSERS, "HEALEDOTHERSELF",
function(source, spell, amount)
return source, playerName, tonumber(amount) or 0, spell, false, nil
end)
BuildParser(PARTY_HEAL_PARSERS, "HEALEDCRITOTHERSELF",
function(source, spell, amount)
return source, playerName, tonumber(amount) or 0, spell, true, nil
end)
-- ========================================
-- CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_BUFFS parsers
-- CHAT_MSG_SPELL_PERIODIC_PARTY_BUFFS parsers
-- HoT ticks from party/raid members
-- Uses OTHEROTHER: "%s gains %d health from %s's %s."
-- Note: SELFOTHER is NOT included here to avoid double-counting
-- with CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS which already tracks
-- the player's own HoT ticks via the self-event handlers.
-- ========================================
BuildParser(PARTY_PERIODIC_PARSERS, "PERIODICAURAHEALOTHEROTHER",
function(target, amount, source, spell)
return source, target, tonumber(amount) or 0, spell, false, nil
end)
addon:ui_print("Threat engine: " .. table.getn(PARTY_DAMAGE_PARSERS) ..
" party damage, " .. table.getn(PARTY_HEAL_PARSERS) ..
" party heal, " .. table.getn(PARTY_PERIODIC_PARSERS) ..
" party periodic parsers built")
end
-- ============================================================
-- Parse a combat log message against a set of parsers
-- Returns: sourceName, targetName, value, spellName, isCrit, spellSchool
-- or nil if no match
-- ============================================================
local function ParseMessage(parserSet, msg)
if not msg then return nil end
for i = 1, table.getn(parserSet) do
local entry = parserSet[i]
local _, _, c1, c2, c3, c4, c5 = string.find(msg, entry.regex)
if c1 then
-- Call the handler with whatever captures we got
local s, t, v, sp, cr, sc = entry.handler(c1, c2, c3, c4, c5)
if s then
return s, t, v, sp, cr, sc
end
end
end
return nil
end
-- ============================================================
-- Special spell threat table
-- Flat threat values for abilities that generate threat without
-- (or in addition to) damage
-- Values from KTM/ThreatClassic-1.0 class modules
-- ============================================================
local SPELL_THREAT = {}
-- Warrior abilities
SPELL_THREAT["sunder"] = 261 -- Sunder Armor rank 8
SPELL_THREAT["revenge"] = 355 -- Revenge rank 6
SPELL_THREAT["shieldslam"] = 250 -- Shield Slam bonus threat
SPELL_THREAT["heroicstrike"] = 145 -- Heroic Strike rank 8 bonus threat
SPELL_THREAT["shieldbash"] = 110 -- Shield Bash bonus threat
SPELL_THREAT["hamstring"] = 145 -- Hamstring bonus threat
SPELL_THREAT["thunderclap"] = 130 -- Thunder Clap bonus threat
SPELL_THREAT["demoralizingshout"] = 43 -- Demo Shout threat
SPELL_THREAT["battle shout"] = 69 -- Battle Shout threat per target hit
SPELL_THREAT["disarm"] = 104 -- Disarm bonus threat
-- Druid abilities
SPELL_THREAT["maul"] = nil -- Maul uses multiplier instead
SPELL_THREAT["swipe"] = nil -- Swipe uses multiplier
SPELL_THREAT["demoralizingroar"] = 30 -- Demo Roar threat
-- Warlock abilities
SPELL_THREAT["searingpain"] = nil -- Searing Pain uses multiplier
SPELL_THREAT["lifetap"] = 0 -- Life Tap generates no threat
SPELL_THREAT["drainlife"] = 0 -- Drain Life generates no threat
SPELL_THREAT["siphonlife"] = 0 -- Siphon Life generates no threat
-- Priest abilities
SPELL_THREAT["holynova"] = 0 -- Holy Nova generates no threat
-- Rogue abilities
SPELL_THREAT["feint"] = -150 -- Feint reduces threat (rank 5)
SPELL_THREAT["vanish"] = -1000000 -- Vanish wipes threat
-- Spell multiplier table (some spells multiply damage for threat)
local SPELL_MULTIPLIER = {}
SPELL_MULTIPLIER["searingpain"] = 2.0 -- Searing Pain double threat
SPELL_MULTIPLIER["heroicstrike"] = 1.0 -- HS adds flat + bonus on top
SPELL_MULTIPLIER["maul"] = 1.75 -- Maul multiplier
SPELL_MULTIPLIER["swipe"] = 1.0 -- Swipe multiplier
-- Unlocalise spell name: map localised name to internal ID
-- We build this at runtime from known localised names
local spellNameToId = {}
local function BuildSpellNameMap()
-- Warrior spells
local warriorSpells = {
"sunder", "revenge", "shieldslam", "heroicstrike",
"shieldbash", "hamstring", "thunderclap", "demoralizingshout",
"disarm"
}
-- Check if player is the right class and cache localised names
if playerClass == "warrior" then
for i = 1, table.getn(warriorSpells) do
local id = warriorSpells[i]
-- Try to get localised name from spellbook
-- This is a best-effort approach; if we can't find it,
-- we'll fall back to string matching
end
end
end
-- Match a localised spell name against known threat spells
-- Uses simple substring matching for common spell patterns
local function MatchSpell(spellName)
if not spellName then return nil end
-- Try exact match first (case-insensitive)
local lowerName = string.lower(spellName)
-- Common substring patterns for spell identification
-- This works across locales where the spell name contains
-- recognizable fragments
-- Sunder Armor
if SPELL_THREAT["sunder"] then
-- Check if this could be sunder by checking known localisations
-- English: "Sunder Armor", "Sunder"
if string.find(lowerName, "sunder") then
return "sunder"
end
end
-- Revenge
if string.find(lowerName, "revenge") then
return "revenge"
end
-- Shield Slam
if string.find(lowerName, "shield slam") or string.find(lowerName, "shieldslam") then
return "shieldslam"
end
-- Shield Bash
if string.find(lowerName, "shield bash") or string.find(lowerName, "shieldbash") then
return "shieldbash"
end
-- Heroic Strike
if string.find(lowerName, "heroic strike") or string.find(lowerName, "heroicstrike") then
return "heroicstrike"
end
-- Hamstring
if string.find(lowerName, "hamstring") then
return "hamstring"
end
-- Thunder Clap
if string.find(lowerName, "thunder clap") or string.find(lowerName, "thunderclap") then
return "thunderclap"
end
-- Demoralizing Shout
if string.find(lowerName, "demoralizing") or string.find(lowerName, "demoralising") then
return "demoralizingshout"
end
-- Battle Shout
if string.find(lowerName, "battle shout") then
return "battle shout"
end
-- Disarm
if string.find(lowerName, "disarm") then
return "disarm"
end
-- Feint
if string.find(lowerName, "feint") then
return "feint"
end
-- Vanish
if string.find(lowerName, "vanish") then
return "vanish"
end
-- Searing Pain
if string.find(lowerName, "searing pain") or string.find(lowerName, "searingpain") then
return "searingpain"
end
-- Maul
if string.find(lowerName, "maul") then
return "maul"
end
-- Swipe
if string.find(lowerName, "swipe") then
return "swipe"
end
-- Life Tap (no threat)
if string.find(lowerName, "life tap") or string.find(lowerName, "lifetap") then
return "lifetap"
end
-- Holy Nova (no threat)
if string.find(lowerName, "holy nova") or string.find(lowerName, "holynova") then
return "holynova"
end
return nil
end
-- ============================================================
-- Threat modifier calculation
-- Recalculated periodically based on player buffs, stance, etc.
-- ============================================================
local function CalculateThreatModifier()
threatModifier = 1.0
-- Blessing of Salvation (-30%)
for i = 1, 16 do
local ok, texture = pcall(UnitBuff, "player", i)
if ok and texture then
if texture == "Interface\\Icons\\Spell_Holy_SealOfSalvation" or
texture == "Interface\\Icons\\Spell_Holy_GreaterBlessingofSalvation" then
threatModifier = threatModifier * 0.7
end
end
end
-- Tranquil Air Totem (-20%)
for i = 1, 16 do
local ok, texture = pcall(UnitBuff, "player", i)
if ok and texture then
if texture == "Interface\\Icons\\Spell_Nature_Brilliance" then
threatModifier = threatModifier * 0.8
end
end
end
-- Class-specific modifiers
if playerClass == "warrior" then
-- Check stance via buff texture detection
-- In Vanilla 1.12, stances appear as buffs with specific icons
local stanceIndex = 0
for i = 1, 16 do
local ok, texture = pcall(UnitBuff, "player", i)
if ok and texture then
-- Defensive Stance icon
if texture == "Interface\\Icons\\Ability_Warrior_DefensiveStance" then
stanceIndex = 2
-- Battle Stance icon
elseif texture == "Interface\\Icons\\Ability_Warrior_BattleStance" then
stanceIndex = 1
-- Berserker Stance icon
elseif texture == "Interface\\Icons\\Ability_Warrior_BerserkerStance" then
stanceIndex = 3
end
end
end
if stanceIndex == 2 then
-- Defensive Stance: +30% threat modifier (1.3x)
threatModifier = threatModifier * 1.3
-- Defiance talent: +5% per rank (5 ranks max = +25%)
-- We approximate based on talent tree position
-- For now, use a conservative estimate
elseif stanceIndex == 1 then
-- Battle Stance: -20% threat (0.8x)
threatModifier = threatModifier * 0.8
elseif stanceIndex == 3 then
-- Berserker Stance: -20% threat (0.8x)
threatModifier = threatModifier * 0.8
end
elseif playerClass == "druid" then
-- Check form
for i = 1, 16 do
local ok, texture = pcall(UnitBuff, "player", i)
if ok and texture then
-- Bear Form / Dire Bear Form
if texture == "Interface\\Icons\\Ability_Racial_BearForm" or
texture == "Interface\\Icons\\Ability_Racial_DireBearForm" then
threatModifier = threatModifier * 1.3
-- Cat Form
elseif texture == "Interface\\Icons\\Ability_Druid_CatForm" then
threatModifier = threatModifier * 0.71
end
end
end
elseif playerClass == "rogue" then
-- Rogues have -29% passive threat modifier
threatModifier = threatModifier * 0.71
elseif playerClass == "mage" then
-- Arcanist 8/8 set bonus: -15%
-- For now, just base modifier
end
-- +2% threat enchant on gloves (enchant ID 2613)
local ok, link = pcall(GetInventoryItemLink, "player", 10)
if ok and link then
local _, _, enchant = string.find(link, ".-item:%d+:(%d+).*")
if enchant == "2613" then
threatModifier = threatModifier * 1.02
end
end
-- -2% threat enchant on cloak (enchant ID 2621)
ok, link = pcall(GetInventoryItemLink, "player", 15)
if ok and link then
local _, _, enchant = string.find(link, ".-item:%d+:(%d+).*")
if enchant == "2621" then
threatModifier = threatModifier * 0.98
end
end
end
-- ============================================================
-- Add threat for a player to a specific target
-- sourceName: the player generating threat (defaults to playerName)
-- ============================================================
local function AddThreat(targetName, amount, sourceName)
if not targetName then return end
if not amount then return end
if amount == 0 then return end
-- Default source to the player
if not sourceName then sourceName = playerName end
-- Don't track threat on friendly targets
-- We'll let the nameplate system handle the filtering
-- Initialize target table if needed
if not threatData[targetName] then
threatData[targetName] = {}
end
-- Add to the source player's threat on this target
local current = threatData[targetName][sourceName] or 0
threatData[targetName][sourceName] = current + amount
-- Clamp to 0 minimum
if threatData[targetName][sourceName] < 0 then
threatData[targetName][sourceName] = 0
end
end
-- ============================================================
-- Get estimated threat modifier for a group member
-- We don't have full buff/stance info for other players, so
-- we estimate based on their class. This is better than using
-- the player's own modifier (which was the old behavior).
-- ============================================================
local function EstimateGroupMemberModifier(memberName)
-- Default modifier: 1.0 (no adjustment)
local mod = 1.0
-- Check cached class for this party member
local class = partyClassCache[memberName]
if class then
if class == "rogue" then
mod = 0.71
elseif class == "mage" then
mod = 1.0
elseif class == "warrior" then
-- Could be any stance; default to battle stance (0.8)
-- Defensive would be 1.3, but we can't know remotely
mod = 0.8
elseif class == "druid" then
-- Could be any form; default to 0.71 (cat/humanoid)
mod = 0.71
elseif class == "priest" then
mod = 0.8 -- shadowform not considered
elseif class == "warlock" then
mod = 1.0
elseif class == "hunter" then
mod = 1.0
elseif class == "paladin" then
mod = 1.0 -- Righteous Fury not considered
elseif class == "shaman" then
mod = 1.0
end
end
-- Blessing of Salvation / Tranquil Air Totem can't be detected
-- remotely, so we don't adjust for those.
return mod
end
-- ============================================================
-- Cache party member classes for modifier estimation
-- ============================================================
local function UpdatePartyClassCache()
partyClassCache = {}
-- Player
if playerClass then
partyClassCache[playerName] = playerClass
end
-- Party members
for i = 1, 4 do
local unit = "party" .. i
if UnitExists(unit) then
local ok, name = pcall(UnitName, unit)
if ok and name then
local ok2, class = pcall(UnitClass, unit)
if ok2 and class then
partyClassCache[name] = string.lower(class)
end
end
end
end
-- Raid members
for i = 1, 40 do
local unit = "raid" .. i
if UnitExists(unit) then
local ok, name = pcall(UnitName, unit)
if ok and name then
local ok2, class = pcall(UnitClass, unit)
if ok2 and class then
partyClassCache[name] = string.lower(class)
end
end
end
end
end
-- ============================================================
-- Process damage event (from combat log parsing)
-- sourceName: who dealt the damage (player or group member)
-- ============================================================
local function ProcessDamage(sourceName, targetName, damage, spellName, isCrit, spellSchool)
if not sourceName then return end
if not targetName then return end
-- Don't track self-damage
if targetName == sourceName then return end
-- Determine threat modifier for the source
local sourceModifier = threatModifier
if sourceName ~= playerName then
sourceModifier = EstimateGroupMemberModifier(sourceName)
end
local threatAmount = 0
-- Check if this is a special spell with known threat values
local spellId = MatchSpell(spellName)
if spellId and SPELL_THREAT[spellId] ~= nil then
-- Special spell with flat threat value
local flatThreat = SPELL_THREAT[spellId]
if flatThreat == 0 then
-- No threat from this spell (Life Tap, Holy Nova, etc.)
return
elseif flatThreat < 0 then
-- Negative threat (Feint, Vanish)
AddThreat(targetName, flatThreat * sourceModifier, sourceName)
return
else
-- Flat threat + damage threat
threatAmount = damage + flatThreat
end
elseif spellId and SPELL_MULTIPLIER[spellId] then
-- Spell with threat multiplier
threatAmount = damage * SPELL_MULTIPLIER[spellId]
else
-- Normal damage: 1 threat per damage
threatAmount = damage
end
-- Apply source's threat modifier
threatAmount = threatAmount * sourceModifier
AddThreat(targetName, threatAmount, sourceName)
end
-- ============================================================
-- Process heal event
-- Healing generates 0.5x threat, split across all mobs in combat
-- ============================================================
local function ProcessHeal(sourceName, targetName, amount, spellName)
if not sourceName then return end
if not amount or amount <= 0 then return end
-- Skip healing on hostile targets
if targetName then
local unit = addon.Nameplates and addon.Nameplates:FindUnitByName(targetName)
if unit then
local ok, canAttack = pcall(UnitCanAttack, "player", unit)
if ok and canAttack then return end
end
end
-- Calculate effective heal (minus overheal)
-- We can check the target's health deficit to estimate overheal
local effectiveAmount = amount
if targetName then
local unit = addon.Nameplates and addon.Nameplates:FindUnitByName(targetName)
if unit then
local ok, maxHp = pcall(UnitHealthMax, unit)
if ok and maxHp then
local ok2, hp = pcall(UnitHealth, unit)
if ok2 and hp then
local deficit = maxHp - hp
if deficit > 0 then
effectiveAmount = math.min(amount, deficit)
else
effectiveAmount = 0
end
end
end
end
end
if effectiveAmount <= 0 then return end
-- Determine threat modifier for the source
local sourceModifier = threatModifier
if sourceName ~= playerName then
sourceModifier = EstimateGroupMemberModifier(sourceName)
end
-- Healing threat = amount * 0.5 * sourceModifier
local healThreat = effectiveAmount * THREAT_CONST.healing * sourceModifier
-- Split healing threat across mobs the player is ACTIVELY in combat with.
-- We use GetEngagedMobs() which verifies each mob is actually in combat
-- via UnitAffectingCombat, rather than just having a threatData entry.
-- This prevents healing threat from being added to idle nearby mobs that
-- accumulated threat from prior healing splits.
local engagedMobs, mobCount = addon.ThreatEngine:GetEngagedMobs()
if mobCount > 0 then
-- Add healing threat to all engaged mobs (split evenly)
local splitThreat = healThreat / mobCount
for i = 1, mobCount do
AddThreat(engagedMobs[i], splitThreat, sourceName)
end
else
-- Source has no actively engaged mobs yet - add healing threat to current target
-- For group members, use the player's current target as approximation
if sourceName == playerName then
if currentTargetName then
AddThreat(currentTargetName, healThreat, sourceName)
end
end
end
end
-- ============================================================
-- Process power gain event (rage, energy, mana)
-- ============================================================
local function ProcessPowerGain(sourceName, amount, powerType, spellName)
if sourceName ~= playerName then return end
if not amount or amount <= 0 then return end
-- Prevent "overheal" for power gain
local ok, maxPower = pcall(UnitManaMax, "player")
if ok and maxPower then
local ok2, power = pcall(UnitMana, "player")
if ok2 and power then
amount = math.min(amount, maxPower - power)
end
end
local playerPowerType = UnitPowerType("player")
local gainThreat = 0
-- Check power type matches player's power type
-- powerType is localised (e.g. "Mana", "Rage", "Energy")
if powerType then
local pt = string.lower(powerType)
if string.find(pt, "rage") and playerPowerType == 1 then
gainThreat = amount * THREAT_CONST.rageGain
elseif string.find(pt, "energy") and playerPowerType == 3 then
gainThreat = amount * THREAT_CONST.energyGain
elseif string.find(pt, "mana") and playerPowerType == 0 then
gainThreat = amount * THREAT_CONST.manaGain
end
end
-- Skip Life Tap (warlock mana gain = no threat)
if spellName then
local spellId = MatchSpell(spellName)
if spellId == "lifetap" then return end
end
if gainThreat > 0 then
-- Add to current target
if currentTargetName then
AddThreat(currentTargetName, gainThreat * threatModifier)
end
end
end
-- ============================================================
-- Sunder Armor handling (KTM-style transaction)
-- We assume sunder hits when cast, and retract on failure
-- ============================================================
local function SubmitSunderCast()
if playerClass ~= "warrior" then return end
if not currentTargetName then return end
-- Add sunder threat immediately
local sunderThreat = SPELL_THREAT["sunder"] or 261
AddThreat(currentTargetName, sunderThreat * threatModifier)
pendingSunder = currentTargetName
pendingSunderTime = GetTime()
end
local function RetractSunderCast()
if not pendingSunder then return end
-- Remove the sunder threat we added
local sunderThreat = SPELL_THREAT["sunder"] or 261
AddThreat(pendingSunder, -(sunderThreat * threatModifier))
pendingSunder = nil
end
-- ============================================================
-- Addon Communication
-- Broadcast threat data to group members
-- ============================================================
local function BroadcastThreat()
-- Only broadcast if in a group
local inParty = GetNumPartyMembers() > 0
local inRaid = GetNumRaidMembers() > 0
if not inParty and not inRaid then return end
-- Build threat data string
-- Format: "TPv1~target1:player=val~target2:player=val~..."
-- NOTE: Uses ~ as delimiter instead of | because | is the WoW
-- chat escape prefix (|c, |H, |T, etc.) and causes "invalid
-- escape code in chat message" errors if a name starts with
-- one of those letters after a | delimiter.
local parts = {}
local count = 0
for target, sources in pairs(threatData) do
for source, value in pairs(sources) do
if value > 0 then
count = count + 1
-- Sanitize: replace any ~ in names with - to avoid
-- delimiter collision (extremely rare but safe)
local safeTarget = string.gsub(target, "~", "-")
local safeSource = string.gsub(source, "~", "-")
parts[count] = safeTarget .. ":" .. safeSource .. "=" .. string.format("%.0f", value)
-- Limit broadcast size to avoid chat message length limits
if count >= 20 then
-- Send what we have so far
local msg = "TPv1~" .. table.concat(parts, "~")
SendAddonMessage(COMM_PREFIX, msg, inRaid and "RAID" or "PARTY")
parts = {}
count = 0
end
end
end
end
if count > 0 then
local msg = "TPv1~" .. table.concat(parts, "~")
SendAddonMessage(COMM_PREFIX, msg, inRaid and "RAID" or "PARTY")
end
end
-- Parse incoming threat data from other players
local function ReceiveThreat(msg)
if not msg then return end
-- Check version prefix (now uses ~ as separator)
local _, _, version = string.find(msg, "^(TPv%d+)~")
if not version then return end
-- Remove version prefix
local data = string.gsub(msg, "^TPv%d+~", "")
-- Parse entries: target:source=value (delimited by ~)
for entry in string.gfind(data, "[^~]+") do
local _, _, target, source, value = string.find(entry, "(.+):(.+)=(%d+)")
if target and source and value then
if not threatData[target] then
threatData[target] = {}
end
local numValue = tonumber(value) or 0
-- Only update if the incoming value is higher (avoid stale data overwriting fresh)
if not threatData[target][source] or numValue > threatData[target][source] then
threatData[target][source] = numValue
end
end
end
end
-- ============================================================
-- Taunt handling
-- When you taunt, you gain threat equal to the current top threat
-- ============================================================
local function ProcessTaunt(targetName)
if not targetName then return end
if not threatData[targetName] then return end
-- Find the maximum threat on this target from any player
local maxThreat = 0
for source, value in pairs(threatData[targetName]) do
if value > maxThreat then
maxThreat = value
end
end
-- Set player's threat to match the top
local playerThreat = threatData[targetName][playerName] or 0
if maxThreat > playerThreat then
AddThreat(targetName, maxThreat - playerThreat)
end
end
-- ============================================================
-- Event handler for combat log events
-- ============================================================
function addon.ThreatEngine:OnCombatEvent(ev, msg)
if not msg then return end
if not playerName then return end
-- Strip absorb/resist suffixes (like KTM does)
-- These can modify the damage string format
if ABSORB_TRAILER then
msg = string.gsub(msg, FormatToRegex(ABSORB_TRAILER), "")
end
-- Process based on event type
if ev == "CHAT_MSG_COMBAT_SELF_HITS" then
local s, t, v, sp, cr, sc = ParseMessage(SELF_DAMAGE_PARSERS, msg)
if s then
ProcessDamage(s, t, v, sp, cr, sc)
end
elseif ev == "CHAT_MSG_SPELL_SELF_DAMAGE" then
local s, t, v, sp, cr, sc = ParseMessage(SELF_DAMAGE_PARSERS, msg)
if s then
-- Check for Sunder Armor failure (retract pending sunder)
if sp and string.find(string.lower(sp), "sunder") then
-- If we had a pending sunder, check if this is a failure message
if string.find(msg, "fail") or string.find(msg, "fail:") then
RetractSunderCast()
end
end
-- Check for taunt
if sp then
local lowerSpell = string.lower(sp)
if string.find(lowerSpell, "taunt") then
ProcessTaunt(t)
end
end
ProcessDamage(s, t, v, sp, cr, sc)
end
elseif ev == "CHAT_MSG_SPELL_SELF_BUFF" then
local s, t, v, sp, cr, sc = ParseMessage(SELF_HEAL_PARSERS, msg)
if s then
ProcessHeal(s, t, v, sp)
end
-- Also check for power gains in this event
local ps, pt, pv, psp, pcr, psc = ParseMessage(PERIODIC_PARSERS, msg)
if ps and psc then
-- psc contains the power type for POWERGAINSELFSELF
ProcessPowerGain(ps, pv, psc, psp)
end
elseif ev == "CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS" then
local s, t, v, sp, cr, sc = ParseMessage(PERIODIC_PARSERS, msg)
if s then
if sc and (string.lower(sc) == "mana" or string.lower(sc) == "rage" or string.lower(sc) == "energy") then
-- This is a power gain, not a heal
ProcessPowerGain(s, v, sc, sp)
else
-- This is a HoT tick
ProcessHeal(s, t, v, sp)
end
end
elseif ev == "CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE" then
local s, t, v, sp, cr, sc = ParseMessage(PERIODIC_PARSERS, msg)
if s then
ProcessDamage(s, t, v, sp, cr, sc)
end
elseif ev == "CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE" then
-- Self-inflicted periodic damage (e.g. standing in fire)
-- Usually no threat implications, skip
elseif ev == "CHAT_MSG_COMBAT_HOSTILE_DEATH" then
-- A hostile unit died - clear threat on it
-- Try multiple patterns for robustness across locales
local deadName = nil
-- Pattern 1: "X dies." (English)
_, _, deadName = string.find(msg, "^(.+) dies%.$")
if not deadName then
-- Pattern 2: "X dies." without anchor
_, _, deadName = string.find(msg, "(.+) dies%.")
end
if not deadName then
-- Pattern 3: Try the GlobalStrings pattern for UNITDIESOTHER
local unitDiesOther = getglobal("UNITDIESOTHER")
if unitDiesOther then
local diesRegex = FormatToRegex(unitDiesOther)
if diesRegex then
_, _, deadName = string.find(msg, diesRegex)
end
end
end
if deadName and threatData[deadName] then
threatData[deadName] = nil
end
elseif ev == "CHAT_MSG_COMBAT_FRIENDLY_DEATH" then
-- Player died - wipe threat
if msg == UNITDIESSELF then
-- "You die."
threatData = {}
end
elseif ev == "CHAT_MSG_SPELL_FAILED_LOCALPLAYER" then
-- Spell failed - check for sunder failure
if string.find(string.lower(msg), "sunder") then
RetractSunderCast()
end
elseif ev == "CHAT_MSG_ADDON" then
-- Addon communication - receive threat data from others
-- arg1 = prefix, arg2 = message, arg3 = channel, arg4 = sender
if arg1 == COMM_PREFIX then
ReceiveThreat(arg2)
end
end
end
-- ============================================================
-- Get threat percentage for a specific mob
-- Returns: threatPct (0-100), threatSit (0-3), rawThreat, topThreat,
-- hasGroupData, bestSourceName
--
-- KEY CHANGE: Now returns threat info for ALL group members, not
-- just the player. The bestPct reflects the highest threat from
-- any group member. The caller can use this to show accurate
-- threat bars on mobs attacking any group member.
-- ============================================================
function addon.ThreatEngine:GetThreatOnTarget(mobName)
if not mobName then return 0, 0, 0, 0, false, nil, 0 end
-- Check if we have threat data for this mob
local mobThreat = threatData[mobName]
if not mobThreat then
return 0, 0, 0, 0, false, nil, 0
end
-- Find player's threat, top threat and second-highest threat
local playerThreat = mobThreat[playerName] or 0
local topThreat = 0
local topPlayer = nil
local secondThreat = 0
local sourceCount = 0
for source, value in pairs(mobThreat) do
sourceCount = sourceCount + 1
if value > topThreat then
secondThreat = topThreat
topThreat = value
topPlayer = source
elseif value > secondThreat then
secondThreat = value
end
end
if topThreat <= 0 then
return 0, 0, 0, 0, false, nil, 0
end
-- Calculate the player's percentage relative to top threat
local pct = (playerThreat / topThreat) * 100
-- Determine threat situation based on the player's threat
local sit = 0
if topPlayer == playerName then
-- Player has aggro (or is tied for top)
sit = 3
elseif pct >= 60 then
sit = 2
elseif pct >= 30 then
sit = 1
else
sit = 0
end
-- hasGroupData: true if we have threat data from OTHER players
-- (via addon communication or party combat log parsing).
local hasGroupData = (sourceCount > 1)
return pct, sit, playerThreat, topThreat, hasGroupData, topPlayer, secondThreat
end
-- ============================================================
-- Get threat info for a specific group member on a mob
-- Returns: memberPct (0-100), memberSit (0-3), memberThreat
-- ============================================================
function addon.ThreatEngine:GetMemberThreatOnTarget(mobName, memberName)
if not mobName or not memberName then return 0, 0, 0 end
local mobThreat = threatData[mobName]
if not mobThreat then
return 0, 0, 0
end
local memberThreat = mobThreat[memberName] or 0
if memberThreat <= 0 then
return 0, 0, 0
end
-- Find top threat on this mob
local topThreat = 0
for source, value in pairs(mobThreat) do
if value > topThreat then
topThreat = value
end
end
if topThreat <= 0 then
return 0, 0, 0
end
local pct = (memberThreat / topThreat) * 100
local sit = 0
if pct >= 100 then
sit = 3
elseif pct >= 60 then
sit = 2
elseif pct >= 30 then
sit = 1
end
return pct, sit, memberThreat
end
-- ============================================================
-- Get threat data for the player on a specific mob
-- ============================================================
function addon.ThreatEngine:GetPlayerThreat(mobName)
if not mobName then return 0 end
if not threatData[mobName] then return 0 end
return threatData[mobName][playerName] or 0
end
-- ============================================================
-- Get the mob that the player has the most threat on
-- ============================================================
function addon.ThreatEngine:GetTopThreatTarget()
local maxThreat = 0
local maxTarget = nil
for target, sources in pairs(threatData) do
local playerThreat = sources[playerName] or 0
if playerThreat > maxThreat then
maxThreat = playerThreat
maxTarget = target
end
end
return maxTarget, maxThreat
end
-- ============================================================
-- Get list of mob names the player or group is actively engaged with.
-- A mob is "engaged" if:
-- 1. Any group member has threat on it (threatData entry exists)
-- 2. The mob can be found via a unit token
-- 3. The mob is actually in combat (UnitAffectingCombat)
--
-- KEY CHANGE: Now includes mobs that ANY group member has threat on,
-- not just the player. This ensures healing threat splits across
-- all mobs the group is fighting, and threat bars appear on all
-- engaged mobs.
--
-- Returns: table of mob names (indexed), count
-- ============================================================
function addon.ThreatEngine:GetEngagedMobs()
local engaged = {}
local count = 0
for targetName, sources in pairs(threatData) do
-- Check if any group member has threat on this mob
local hasActiveThreat = false
for source, value in pairs(sources) do
if value > 0 then
-- Verify the source is in our group (or is the player)
if source == playerName or partyClassCache[source] then
hasActiveThreat = true
break
end
end
end
if hasActiveThreat then
-- Verify the mob is actually in combat.
-- CRITICAL FIX for same-name mobs: Check ALL unit tokens
-- matching this name. If ANY is in combat, the mob is engaged.
local anyInCombat = false
if addon.Nameplates and addon.Nameplates.GetCandidateUnitsByName then
local candidates = addon.Nameplates:GetCandidateUnitsByName(targetName)
for i = 1, table.getn(candidates) do
if candidates[i].inCombat then
anyInCombat = true
break
end
end
else
-- Fallback: single-token check
local unit = addon.Nameplates and addon.Nameplates:FindUnitByName(targetName)
if unit then
local ok, inCombat = pcall(UnitAffectingCombat, unit)
if ok and inCombat then
anyInCombat = true
end
end
end
if anyInCombat then
count = count + 1
engaged[count] = targetName
elseif addon.ThreatEngine:IsGroupInCombat() then
-- Can't find any in-combat unit token for this name.
-- If anyone in the group is in combat, assume it's still
-- engaged (mob might be out of unit-token range).
count = count + 1
engaged[count] = targetName
end
end
end
return engaged, count
end
-- ============================================================
-- Update group combat state.
-- Checks all party/raid members to see if any are in combat.
-- This is essential for showing threat on mobs attacking group
-- members when the player is NOT in combat.
-- ============================================================
local function UpdateGroupCombatState()
groupInCombat = false
-- Check player
if UnitAffectingCombat("player") then
groupInCombat = true
return
end
-- Check party members
for i = 1, 4 do
local unit = "party" .. i
if UnitExists(unit) then
local ok, inCombat = pcall(UnitAffectingCombat, unit)
if ok and inCombat then
groupInCombat = true
return
end
end
end
-- Check raid members
for i = 1, 40 do
local unit = "raid" .. i
if UnitExists(unit) then
local ok, inCombat = pcall(UnitAffectingCombat, unit)
if ok and inCombat then
groupInCombat = true
return
end
end
end
end
-- ============================================================
-- Check if any group member (player or party/raid) is in combat.
-- Returns: true if any group member is in combat, false otherwise.
-- ============================================================
function addon.ThreatEngine:IsGroupInCombat()
return groupInCombat or playerInCombat
end
-- ============================================================
-- Clear all threat data
-- ============================================================
function addon.ThreatEngine:ClearAll()
threatData = {}
end
-- ============================================================
-- Clear only the player's own threat data, preserving group data.
-- Called when the player leaves combat but group members may
-- still be fighting.
-- ============================================================
function addon.ThreatEngine:ClearPlayerThreat()
for target, sources in pairs(threatData) do
sources[playerName] = nil
-- If no sources remain for this target, remove the entry
local hasAny = false
for s, v in pairs(sources) do
if v and v > 0 then
hasAny = true
break
end
end
if not hasAny then
threatData[target] = nil
end
end
end
-- ============================================================
-- Clear threat on a specific target
-- ============================================================
function addon.ThreatEngine:ClearTarget(mobName)
if mobName then
threatData[mobName] = nil
end
end
-- ============================================================
-- Decay old threat entries (prevents stale data buildup)
-- ============================================================
function addon.ThreatEngine:DecayOld()
-- Remove mobs that are no longer in combat or have zero threat.
-- This prevents stale threat data from causing threat bars to
-- appear on idle nearby mobs.
--
-- CRITICAL FIX for same-name mobs: FindUnitByName only returns the
-- FIRST unit token matching a name. With multiple same-name mobs
-- (e.g. 3 "Defias Bandit"), it might find an idle one while a
-- different one with the same name is still in combat.
--
-- Solution: Check ALL unit tokens matching the mob name. Only
-- remove threat data if NO same-name unit is in combat.
--
-- Collect keys first to avoid modifying table during pairs() iteration.
local toRemove = {}
local removeCount = 0
for target, sources in pairs(threatData) do
local shouldRemove = false
-- Condition 1: All sources have zero threat
local hasActiveThreat = false
for source, value in pairs(sources) do
if value > 0 then
hasActiveThreat = true
break
end
end
if not hasActiveThreat then
shouldRemove = true
end
-- Condition 2: No one in the group is in combat — clear all mob threat.
if not shouldRemove and not addon.ThreatEngine:IsGroupInCombat() then
shouldRemove = true
end
-- Condition 3: Are ALL same-name mobs out of combat?
-- This is the critical same-name fix. We must check ALL unit
-- tokens matching this mob name, not just the first one.
if not shouldRemove and addon.ThreatEngine:IsGroupInCombat() then
local anyInCombat = false
-- Use GetCandidateUnitsByName to find ALL matching units
if addon.Nameplates and addon.Nameplates.GetCandidateUnitsByName then
local candidates = addon.Nameplates:GetCandidateUnitsByName(target)
for i = 1, table.getn(candidates) do
if candidates[i].inCombat then
anyInCombat = true
break
end
end
else
-- Fallback: single-token check (less accurate but safe)
local unit = addon.Nameplates and addon.Nameplates:FindUnitByName(target)
if unit then
local ok, inCombat = pcall(UnitAffectingCombat, unit)
if ok and inCombat then
anyInCombat = true
end
end
end
if not anyInCombat then
shouldRemove = true
end
end
if shouldRemove then
removeCount = removeCount + 1
toRemove[removeCount] = target
end
end
for i = 1, removeCount do
threatData[toRemove[i]] = nil
end
end
-- ============================================================
-- Party/raid member damage event handler
-- Parses CHAT_MSG_SPELL_PARTY_DAMAGE, CHAT_MSG_COMBAT_PARTY_HITS,
-- CHAT_MSG_SPELL_FRIENDLYPLAYER_DAMAGE, CHAT_MSG_COMBAT_FRIENDLYPLAYER_HITS,
-- CHAT_MSG_SPELL_PERIODIC_PARTY_DAMAGE, and
-- CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_DAMAGE events
-- ============================================================
function addon.ThreatEngine:OnPartyDamageEvent(ev, msg)
if not msg then return end
if not playerName then return end
-- Only process if in a group
local inParty = GetNumPartyMembers() > 0
local inRaid = GetNumRaidMembers() > 0
if not inParty and not inRaid then return end
-- Strip absorb/resist suffixes
if ABSORB_TRAILER then
msg = string.gsub(msg, FormatToRegex(ABSORB_TRAILER), "")
end
-- Try to parse against party damage parsers
local s, t, v, sp, cr, sc = ParseMessage(PARTY_DAMAGE_PARSERS, msg)
if s then
-- Validate: source must be a known party/raid member
if s ~= playerName and not partyClassCache[s] then
-- Source is not in our group - might be a naming mismatch
-- or the parser captured the wrong field. Skip.
return
end
ProcessDamage(s, t, v, sp, cr, sc)
end
end
-- ============================================================
-- Party/raid member heal event handler
-- Parses CHAT_MSG_SPELL_PARTY_BUFF and
-- CHAT_MSG_SPELL_FRIENDLYPLAYER_BUFF events
-- ============================================================
function addon.ThreatEngine:OnPartyHealEvent(ev, msg)
if not msg then return end
if not playerName then return end
local inParty = GetNumPartyMembers() > 0
local inRaid = GetNumRaidMembers() > 0
if not inParty and not inRaid then return end
local s, t, v, sp, cr, sc = ParseMessage(PARTY_HEAL_PARSERS, msg)
if s then
if s ~= playerName and not partyClassCache[s] then
return
end
ProcessHeal(s, t, v, sp)
end
end
-- ============================================================
-- Party/raid member periodic event handler
-- Parses CHAT_MSG_SPELL_PERIODIC_PARTY_BUFFS and
-- CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_BUFFS events
-- ============================================================
function addon.ThreatEngine:OnPartyPeriodicEvent(ev, msg)
if not msg then return end
if not playerName then return end
local inParty = GetNumPartyMembers() > 0
local inRaid = GetNumRaidMembers() > 0
if not inParty and not inRaid then return end
local s, t, v, sp, cr, sc = ParseMessage(PARTY_PERIODIC_PARSERS, msg)
if s then
if s ~= playerName and not partyClassCache[s] then
return
end
-- HoT ticks from group members generate healing threat
ProcessHeal(s, t, v, sp)
end
end
-- ============================================================
-- Initialize the threat engine
-- ============================================================
function addon.ThreatEngine:Initialize()
playerName = UnitName("player")
local _, class = UnitClass("player")
playerClass = string.lower(class or "")
-- Build parsers from GlobalStrings
BuildAllParsers()
-- Calculate initial threat modifier
CalculateThreatModifier()
-- Register for all combat log events we need
local combatFrame = CreateFrame("Frame", "RelationshipsThreatPlatesCombatFrame")
-- Damage events
combatFrame:RegisterEvent("CHAT_MSG_COMBAT_SELF_HITS")
combatFrame:RegisterEvent("CHAT_MSG_SPELL_SELF_DAMAGE")
-- Heal events
combatFrame:RegisterEvent("CHAT_MSG_SPELL_SELF_BUFF")
-- Periodic events (HoTs, DoTs, power gains)
combatFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS")
combatFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE")
combatFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE")
-- Death events (threat wipes)
combatFrame:RegisterEvent("CHAT_MSG_COMBAT_HOSTILE_DEATH")
combatFrame:RegisterEvent("CHAT_MSG_COMBAT_FRIENDLY_DEATH")
-- Spell failure (sunder retraction)
combatFrame:RegisterEvent("CHAT_MSG_SPELL_FAILED_LOCALPLAYER")
-- Addon communication
combatFrame:RegisterEvent("CHAT_MSG_ADDON")
-- Target tracking
combatFrame:RegisterEvent("PLAYER_TARGET_CHANGED")
-- Group changes
combatFrame:RegisterEvent("PARTY_MEMBERS_CHANGED")
combatFrame:RegisterEvent("RAID_ROSTER_UPDATE")
-- Combat state
combatFrame:RegisterEvent("PLAYER_REGEN_DISABLED")
combatFrame:RegisterEvent("PLAYER_REGEN_ENABLED")
-- Zone change (threat wipes on zone)
combatFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
-- Party/raid member combat log events
combatFrame:RegisterEvent("CHAT_MSG_SPELL_PARTY_DAMAGE")
combatFrame:RegisterEvent("CHAT_MSG_COMBAT_PARTY_HITS")
combatFrame:RegisterEvent("CHAT_MSG_SPELL_FRIENDLYPLAYER_DAMAGE")
combatFrame:RegisterEvent("CHAT_MSG_COMBAT_FRIENDLYPLAYER_HITS")
combatFrame:RegisterEvent("CHAT_MSG_SPELL_PARTY_BUFF")
combatFrame:RegisterEvent("CHAT_MSG_SPELL_FRIENDLYPLAYER_BUFF")
combatFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_PARTY_BUFFS")
combatFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_BUFFS")
combatFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_PARTY_DAMAGE")
combatFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_DAMAGE")
combatFrame:SetScript("OnEvent", function()
-- Vanilla 1.12: event name is in global 'event', args in global arg1..arg9
-- IMPORTANT: we must NOT shadow 'event' with a local or parameter
if event == "CHAT_MSG_ADDON" then
addon.ThreatEngine:OnCombatEvent(event, arg2)
elseif event == "CHAT_MSG_COMBAT_SELF_HITS"
or event == "CHAT_MSG_SPELL_SELF_DAMAGE"
or event == "CHAT_MSG_SPELL_SELF_BUFF"
or event == "CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS"
or event == "CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE"
or event == "CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE"
or event == "CHAT_MSG_COMBAT_HOSTILE_DEATH"
or event == "CHAT_MSG_COMBAT_FRIENDLY_DEATH"
or event == "CHAT_MSG_SPELL_FAILED_LOCALPLAYER" then
addon.ThreatEngine:OnCombatEvent(event, arg1)
-- Party/raid member combat log events
elseif event == "CHAT_MSG_SPELL_PARTY_DAMAGE"
or event == "CHAT_MSG_COMBAT_PARTY_HITS"
or event == "CHAT_MSG_SPELL_FRIENDLYPLAYER_DAMAGE"
or event == "CHAT_MSG_COMBAT_FRIENDLYPLAYER_HITS"
or event == "CHAT_MSG_SPELL_PERIODIC_PARTY_DAMAGE"
or event == "CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_DAMAGE" then
addon.ThreatEngine:OnPartyDamageEvent(event, arg1)
elseif event == "CHAT_MSG_SPELL_PARTY_BUFF"
or event == "CHAT_MSG_SPELL_FRIENDLYPLAYER_BUFF" then
addon.ThreatEngine:OnPartyHealEvent(event, arg1)
elseif event == "CHAT_MSG_SPELL_PERIODIC_PARTY_BUFFS"
or event == "CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_BUFFS" then
addon.ThreatEngine:OnPartyPeriodicEvent(event, arg1)
end
-- Handle non-combat events
if event == "PLAYER_TARGET_CHANGED" then
local ok, name = pcall(UnitName, "target")
if ok and name then
currentTargetName = name
else
currentTargetName = nil
end
elseif event == "PARTY_MEMBERS_CHANGED" or event == "RAID_ROSTER_UPDATE" then
UpdatePartyClassCache()
elseif event == "PLAYER_ENTERING_WORLD" then
-- Clear threat on zone change
addon.ThreatEngine:ClearAll()
elseif event == "PLAYER_REGEN_DISABLED" then
-- Entered combat
playerInCombat = true
groupInCombat = true
elseif event == "PLAYER_REGEN_ENABLED" then
-- Left combat - clear the player's own threat data.
-- IMPORTANT: Do NOT clear all threat data if group members
-- are still in combat. Their threat data must be preserved
-- so that threat bars continue to show on mobs attacking them.
playerInCombat = false
UpdateGroupCombatState()
if groupInCombat then
-- Group members still in combat: only clear player's threat
addon.ThreatEngine:ClearPlayerThreat()
else
-- No one in group is in combat: safe to clear all
addon.ThreatEngine:ClearAll()
end
end
end)
self.combatFrame = combatFrame
-- Register addon communication prefix
local ok, _ = pcall(RegisterAddonMessagePrefix, COMM_PREFIX)
-- Set up periodic updates
self.updateFrame = CreateFrame("Frame", "RelationshipsThreatPlatesEngineUpdateFrame")
self.updateFrame.elapsed = 0
self.updateFrame:SetScript("OnUpdate", function()
-- Vanilla 1.12: OnUpdate receives elapsed time in global arg1
this.elapsed = this.elapsed + arg1
if this.elapsed >= 2.0 then
this.elapsed = 0
addon.ThreatEngine:OnPeriodicUpdate()
end
end)
-- Hook CastSpellByName for Sunder Armor detection
if playerClass == "warrior" then
self:HookSunder()
end
addon:ui_print("Threat engine initialized for " .. playerClass)
end
-- ============================================================
-- Periodic update (every 2 seconds)
-- ============================================================
function addon.ThreatEngine:OnPeriodicUpdate()
-- Recalculate threat modifier
CalculateThreatModifier()
-- Update party member class cache
UpdatePartyClassCache()
-- Update group combat state
UpdateGroupCombatState()
-- Broadcast threat to group
BroadcastThreat()
-- Decay old entries
self:DecayOld()
end
-- ============================================================
-- Hook CastSpellByName and UseAction for Sunder Armor
-- (KTM-style: detect when player casts Sunder and add threat immediately)
-- ============================================================
function addon.ThreatEngine:HookSunder()
-- Hook CastSpellByName
if not self.originalCastSpellByName then
self.originalCastSpellByName = CastSpellByName
local origCast = self.originalCastSpellByName
CastSpellByName = function(name, onSelf)
if name and string.find(string.lower(name), "sunder") then
SubmitSunderCast()
end
origCast(name, onSelf)
end
end
-- Hook UseAction (for action bar buttons)
if not self.originalUseAction then
self.originalUseAction = UseAction
local origUse = self.originalUseAction
UseAction = function(slot, checkCursor, onSelf)
-- Check if this is a Sunder Armor action
local ok, texture = pcall(GetActionTexture, slot)
if ok and texture == "Interface\\Icons\\Ability_Warrior_Sunder" then
SubmitSunderCast()
end
origUse(slot, checkCursor, onSelf)
end
end
end
-- ============================================================
-- Get all threat data (for debugging/external use)
-- ============================================================
function addon.ThreatEngine:GetAllThreatData()
return threatData
end
-- ============================================================
-- Check if ThreatEngine has any data for a mob
-- ============================================================
function addon.ThreatEngine:HasDataForTarget(mobName)
if not mobName then return false end
return threatData[mobName] ~= nil
end
-- ============================================================
-- Check if player is in combat (from ThreatEngine's perspective)
-- ============================================================
function addon.ThreatEngine:IsInCombat()
return playerInCombat
end
-- ============================================================
-- Slash command: /tp threat - show threat table
-- ============================================================
function addon.ThreatEngine:DumpThreat()
addon:ui_print("=== Threat Table ===")
local count = 0
for target, sources in pairs(threatData) do
for source, value in pairs(sources) do
if value > 0 then
local pct, sit, pt, tt, hasGroup = self:GetThreatOnTarget(target)
local groupTag = hasGroup and " [group]" or " [solo]"
addon:ui_print(string.format(" %s -> %s: %.0f threat (%.0f%% of %.0f)%s",
source, target, value, pct, tt, groupTag))
count = count + 1
end
end
end
if count == 0 then
addon:ui_print(" (no threat data)")
end
addon:ui_print(string.format("Threat modifier: %.2f", threatModifier))
end