From 2ccead28bc2bd0f0811c13673358722bf96bc93f Mon Sep 17 00:00:00 2001 From: Relationship <139162359+Relationship-OctoWoW@users.noreply.github.com> Date: Fri, 3 Jul 2026 07:40:23 +0100 Subject: [PATCH] Delete ThreatPlates_ThreatEngine.lua --- ThreatPlates_ThreatEngine.lua | 1982 --------------------------------- 1 file changed, 1982 deletions(-) delete mode 100644 ThreatPlates_ThreatEngine.lua diff --git a/ThreatPlates_ThreatEngine.lua b/ThreatPlates_ThreatEngine.lua deleted file mode 100644 index ea3dd08..0000000 --- a/ThreatPlates_ThreatEngine.lua +++ /dev/null @@ -1,1982 +0,0 @@ ----------------------------------------------------------------------- --- ThreatPlates_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 = ThreatPlates - -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 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 - end - - -- Find player's threat and top threat - local playerThreat = mobThreat[playerName] or 0 - local topThreat = 0 - local topPlayer = nil - local sourceCount = 0 - - for source, value in pairs(mobThreat) do - sourceCount = sourceCount + 1 - if value > topThreat then - topThreat = value - topPlayer = source - end - end - - if topThreat <= 0 then - return 0, 0, 0, 0, false, nil - 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 -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", "ThreatPlatesCombatFrame") - - -- 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", "ThreatPlatesEngineUpdateFrame") - 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