2010 lines
72 KiB
Lua
2010 lines
72 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 threat percentage.
|
|
--
|
|
-- Two-mode display so the number matches user intuition:
|
|
-- * If the player IS the current top-threat holder, show how far
|
|
-- ahead they are of the runner-up: pct = playerThreat/second * 100.
|
|
-- A tank comfortably tanking a mob will read something like 120%
|
|
-- or 200% — anything > 100 means "you are above the next player".
|
|
-- * If the player is NOT top, show their share of the top holder's
|
|
-- threat: pct = playerThreat/top * 100. This is 0-100 and answers
|
|
-- "how close am I to pulling aggro off the current top holder".
|
|
--
|
|
-- This lets the plate show > 100% for the actual threat leader
|
|
-- instead of clamping everyone with aggro to a flat 100%.
|
|
local pct
|
|
if topPlayer == playerName then
|
|
if secondThreat > 0 then
|
|
pct = (playerThreat / secondThreat) * 100
|
|
else
|
|
pct = 100
|
|
end
|
|
if pct < 100 then pct = 100 end -- safety: top holder never below 100
|
|
else
|
|
pct = (playerThreat / topThreat) * 100
|
|
if pct > 100 then pct = 100 end -- non-top can't exceed the leader
|
|
end
|
|
|
|
-- 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
|