mirror of
https://github.com/akzkak/CombatLedger.git
synced 2026-11-10 22:57:33 +00:00
703 lines
27 KiB
Lua
703 lines
27 KiB
Lua
--[[
|
|
Threat - live per-target threat.
|
|
|
|
Primary path: the server answers an addon message with prefix
|
|
"TWT_UDTSv4" with a CHAT_MSG_ADDON reply (body marker "TWTv4=")
|
|
listing group members' threat on your target - TWThreat's protocol,
|
|
no handshake needed. The server only honors request limits up to 10.
|
|
|
|
Fallback path: EstimateThreat() below reads a local threat ledger fed
|
|
per event by Aggregator (NoteDamage/NoteHealing) and SPELL_GO - see
|
|
the "Local threat estimation" section. Runs whenever a poll happens
|
|
but no real reply has landed recently, so the real API always wins
|
|
when available.
|
|
|
|
Threat is a live snapshot for the current target only - no Overall
|
|
or History. UI_MainWindow.lua reads CL.Threat.GetSnapshot() directly.
|
|
]]
|
|
|
|
local CL = CombatLedger
|
|
|
|
local REQUEST_PREFIX = "TWT_UDTSv4"
|
|
local REPLY_PREFIX = "TWTv4="
|
|
local POLL_INTERVAL = 0.5
|
|
-- The server ignores requests above 10 rows, which would leave only
|
|
-- local estimates.
|
|
local REQUEST_LIMIT = 10
|
|
|
|
-- Seconds without a server reply before the local estimate takes over;
|
|
-- long enough that a slightly late reply still wins.
|
|
local ESTIMATE_GRACE = 2
|
|
|
|
-- The displayed snapshot: [guid] = { name, threat, perc, melee, tank },
|
|
-- from a server reply or the estimate. Declared before the estimator,
|
|
-- which writes it (locals must be declared above their first use).
|
|
local current = {}
|
|
local tankGuid = nil
|
|
local lastUpdate = 0
|
|
|
|
-- ============================================================
|
|
-- Local threat estimation (fallback when no real server reply arrives -
|
|
-- see this file's header comment for why that's needed at all). Only a
|
|
-- TWTv4 server reply is truth; this is a best-effort estimate.
|
|
--
|
|
-- Threat is accumulated per event into its own ledger, never read back
|
|
-- from Aggregator's merged totals:
|
|
-- ledger[enemyGuid][actorGuid] = threat, modifiers already applied
|
|
-- That keeps pets as their own actors (a pet's threat belongs to the
|
|
-- pet, not its owner - Aggregator rolls pet damage into the owner's
|
|
-- bar, which is right for Damage Done but wrong here) and applies each
|
|
-- actor's stance/form/buff modifiers as they were when the hit landed,
|
|
-- not whatever they happen to be when the estimate is displayed.
|
|
--
|
|
-- Modelled: damage (with per-ability threat multipliers), explicit DBC
|
|
-- threat effects (Sunder Armor/Taunt-style, via SPELL_GO), healing
|
|
-- (0.5 per effective point, split across engaged enemies), Rogue's
|
|
-- innate 0.71, Warrior stances, Druid Bear/Cat Form, Blessing of
|
|
-- Salvation and Righteous Fury. NOT modelled: talents (Defiance, Feral
|
|
-- Instinct, Silent Resolve, Improved Righteous Fury, ...), items and
|
|
-- threat-drop abilities (Feign Death, Vanish, Fade).
|
|
-- ============================================================
|
|
|
|
-- Damage abilities with higher-than-normal threat coefficients (1.12
|
|
-- approximations).
|
|
local SPELL_DAMAGE_THREAT_MULT = {
|
|
["Mind Blast"] = 2.00,
|
|
["Searing Pain"] = 2.00,
|
|
["Shield Slam"] = 1.50,
|
|
["Revenge"] = 2.00,
|
|
["Maul"] = 1.75,
|
|
["Heroic Strike"] = 1.25,
|
|
["Cleave"] = 1.15,
|
|
["Thunder Clap"] = 1.75,
|
|
["Mocking Blow"] = 2.50,
|
|
["Holy Shield"] = 1.30,
|
|
["Lacerate"] = 1.30,
|
|
["Devastate"] = 1.50,
|
|
}
|
|
|
|
-- Nampower mirrors the vanilla SpellEffect and SpellAttr enums. These
|
|
-- constants are intentionally local to Threat mode; the normal meter
|
|
-- pipeline has no reason to know about threat-only DBC details.
|
|
local SPELL_EFFECT_THREAT = 63
|
|
local SPELL_EFFECT_THREAT_ALL = 91
|
|
local SPELL_ATTR_EX_NO_THREAT = 1024 -- 0x00000400
|
|
|
|
local SCHOOL_HOLY = 1
|
|
local HEAL_THREAT_PER_POINT = 0.5
|
|
|
|
local STANCE_MOD = { defensive = 1.3, battle = 0.8, berserker = 0.8 }
|
|
local FORM_MOD = { bear = 1.3, cat = 0.71 }
|
|
local ROGUE_MOD = 0.71
|
|
local SALVATION_MOD = 0.7
|
|
local RIGHTEOUS_FURY_MOD = 1.6
|
|
|
|
-- Warrior stance, learned from SPELL_GO: the stance spells themselves,
|
|
-- plus abilities usable in only one stance (which also covers a warrior
|
|
-- who was already in stance before we started watching).
|
|
local STANCE_SPELL_IDS = { [2457] = "battle", [71] = "defensive", [2458] = "berserker" }
|
|
local STANCE_LOCKED_ABILITIES = {
|
|
["Revenge"] = "defensive", ["Shield Block"] = "defensive", ["Shield Wall"] = "defensive",
|
|
["Taunt"] = "defensive", ["Disarm"] = "defensive",
|
|
["Overpower"] = "battle", ["Mocking Blow"] = "battle", ["Retaliation"] = "battle",
|
|
["Charge"] = "battle", ["Thunder Clap"] = "battle",
|
|
["Whirlwind"] = "berserker", ["Intercept"] = "berserker", ["Berserker Rage"] = "berserker",
|
|
["Recklessness"] = "berserker", ["Pummel"] = "berserker",
|
|
}
|
|
-- Shapeshift-bar icons are locale-free; used for the player's own stance.
|
|
local STANCE_ICONS = {
|
|
["Ability_Warrior_OffensiveStance"] = "battle",
|
|
["Ability_Warrior_DefensiveStance"] = "defensive",
|
|
["Ability_Racial_Avatar"] = "berserker",
|
|
}
|
|
|
|
local ZERO_GUID = "0x0000000000000000"
|
|
|
|
local function ValidGuid(guid)
|
|
return guid and guid ~= "" and guid ~= ZERO_GUID and guid ~= "0x000000000"
|
|
end
|
|
|
|
local function SpellField(spellId, field)
|
|
if not spellId or not GetSpellRecField then return nil end
|
|
local ok, value = pcall(GetSpellRecField, spellId, field, 1)
|
|
if ok then return value end
|
|
return nil
|
|
end
|
|
|
|
local function IsTrackedGuid(guid)
|
|
return guid and CL.GuidCache and CL.GuidCache.IsTracked(guid)
|
|
end
|
|
|
|
-- Per-spellId DBC lookups, cached - they run on every tracked damage
|
|
-- event and cast, and a spell's DBC record never changes.
|
|
local hasThreatCache = {}
|
|
local function SpellHasInitialDamageThreat(spellId)
|
|
if not spellId or spellId < 0 then return true end
|
|
local cached = hasThreatCache[spellId]
|
|
if cached == nil then
|
|
local attributesEx = tonumber(SpellField(spellId, "attributesEx")) or 0
|
|
cached = not CL.HasBit(attributesEx, SPELL_ATTR_EX_NO_THREAT)
|
|
hasThreatCache[spellId] = cached
|
|
end
|
|
return cached
|
|
end
|
|
|
|
local function SpellDamageThreatMult(spellName, spellId)
|
|
if not SpellHasInitialDamageThreat(spellId) then return 0 end
|
|
return (spellName and SPELL_DAMAGE_THREAT_MULT[spellName]) or 1.0
|
|
end
|
|
|
|
-- Returns the target-specific and all-engaged-enemy threat encoded in a
|
|
-- spell's DBC effects. EffectBasePoints is stored as value-1 in vanilla.
|
|
local explicitThreatCache = {}
|
|
local function ExplicitSpellThreat(spellId)
|
|
local cached = explicitThreatCache[spellId]
|
|
if cached then return cached[1], cached[2] end
|
|
local targetThreat, allThreat = 0, 0
|
|
local effects = SpellField(spellId, "effect")
|
|
local basePoints = SpellField(spellId, "effectBasePoints")
|
|
if type(effects) == "table" and type(basePoints) == "table" then
|
|
local i
|
|
for i = 1, 3 do
|
|
local effect = tonumber(effects[i])
|
|
local amount = (tonumber(basePoints[i]) or -1) + 1
|
|
if effect == SPELL_EFFECT_THREAT then
|
|
targetThreat = targetThreat + amount
|
|
elseif effect == SPELL_EFFECT_THREAT_ALL then
|
|
allThreat = allThreat + amount
|
|
end
|
|
end
|
|
end
|
|
explicitThreatCache[spellId] = { targetThreat, allThreat }
|
|
return targetThreat, allThreat
|
|
end
|
|
|
|
--------------------------------------------------------------------------
|
|
-- Per-actor modifiers
|
|
--------------------------------------------------------------------------
|
|
|
|
local stanceByGuid = {} -- [warriorGuid] = "battle"/"defensive"/"berserker", from SPELL_GO
|
|
|
|
-- Buff-derived state per actor, refreshed at most every BUFF_SCAN_SECONDS
|
|
-- (a raid-wide scan per hit would be far too many UnitBuff calls).
|
|
-- SuperWoW lets UnitBuff take a raw GUID like every other unit API here.
|
|
local BUFF_SCAN_SECONDS = 2
|
|
local buffState = {} -- [guid] = { at, form, salvation, righteousFury }
|
|
|
|
local function ScanBuffs(guid)
|
|
local state = buffState[guid]
|
|
local now = GetTime()
|
|
if state and now - state.at < BUFF_SCAN_SECONDS then return state end
|
|
if not state then
|
|
state = {}
|
|
buffState[guid] = state
|
|
end
|
|
state.at = now
|
|
state.form, state.salvation, state.righteousFury = nil, false, false
|
|
if not UnitBuff then return state end
|
|
local i
|
|
for i = 1, 32 do
|
|
local ok, texture = pcall(UnitBuff, guid, i)
|
|
if not ok or not texture then break end
|
|
if string.find(texture, "BearForm", 1, true) then
|
|
state.form = "bear"
|
|
elseif string.find(texture, "CatForm", 1, true) then
|
|
state.form = "cat"
|
|
elseif string.find(texture, "Salvation", 1, true) then
|
|
state.salvation = true
|
|
elseif string.find(texture, "SealOfFury", 1, true) then
|
|
state.righteousFury = true
|
|
end
|
|
end
|
|
return state
|
|
end
|
|
|
|
local function PlayerStance()
|
|
if not GetShapeshiftFormInfo or not GetNumShapeshiftForms then return nil end
|
|
local i
|
|
for i = 1, GetNumShapeshiftForms() do
|
|
local icon, _, active = GetShapeshiftFormInfo(i)
|
|
if active and icon then
|
|
local key, stance
|
|
for key, stance in pairs(STANCE_ICONS) do
|
|
if string.find(icon, key, 1, true) then return stance end
|
|
end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Total threat multiplier for one actor's action right now. school is
|
|
-- only used for Righteous Fury (Holy). Class-based modifiers apply to
|
|
-- players only - pets can report an internal class (UnitClass on a
|
|
-- pet isn't meaningful here), so they get just the buff-based ones.
|
|
local function ActorMod(guid, school)
|
|
local info = CL.GuidCache.Resolve(guid)
|
|
local classToken = info and info.isPlayer and info.classToken
|
|
local mod = 1.0
|
|
if classToken == "ROGUE" then
|
|
mod = ROGUE_MOD
|
|
elseif classToken == "WARRIOR" then
|
|
local stance = stanceByGuid[guid]
|
|
local ok, _, playerGuid = pcall(UnitExists, "player")
|
|
if ok and guid == playerGuid then stance = PlayerStance() or stance end
|
|
-- Unknown stance stays neutral rather than guessing either way.
|
|
mod = (stance and STANCE_MOD[stance]) or 1.0
|
|
end
|
|
local buffs = ScanBuffs(guid)
|
|
if classToken == "DRUID" and buffs.form then mod = mod * FORM_MOD[buffs.form] end
|
|
if buffs.salvation then mod = mod * SALVATION_MOD end
|
|
if buffs.righteousFury and school == SCHOOL_HOLY then mod = mod * RIGHTEOUS_FURY_MOD end
|
|
return mod
|
|
end
|
|
|
|
--------------------------------------------------------------------------
|
|
-- Ledger
|
|
--------------------------------------------------------------------------
|
|
|
|
local ledger = {} -- [enemyGuid][actorGuid] = threat
|
|
local meleeActors = {} -- [enemyGuid][actorGuid] = true once they've auto-attacked it
|
|
local ledgerEncounter = nil -- the Aggregator encounter the ledger belongs to
|
|
|
|
-- A new Aggregator encounter means a new pull - start a fresh ledger.
|
|
local function SyncEncounter(enc)
|
|
if enc ~= ledgerEncounter then
|
|
ledger = {}
|
|
meleeActors = {}
|
|
ledgerEncounter = enc
|
|
end
|
|
end
|
|
|
|
local function AddThreat(enemyGuid, actorGuid, amount)
|
|
if amount == 0 or not ValidGuid(enemyGuid) or IsTrackedGuid(enemyGuid) then return end
|
|
local byActor = ledger[enemyGuid]
|
|
if not byActor then
|
|
byActor = {}
|
|
ledger[enemyGuid] = byActor
|
|
end
|
|
byActor[actorGuid] = math.max(0, (byActor[actorGuid] or 0) + amount)
|
|
end
|
|
|
|
-- Called by Aggregator.RecordDamage for every recorded hit (enc = the
|
|
-- live encounter). spellId nil = auto-attack.
|
|
local function NoteDamage(enc, casterGuid, targetGuid, spellId, spellName, school, amount)
|
|
if not IsTrackedGuid(casterGuid) or not ValidGuid(targetGuid) or IsTrackedGuid(targetGuid) then return end
|
|
SyncEncounter(enc)
|
|
AddThreat(targetGuid, casterGuid, amount * SpellDamageThreatMult(spellName, spellId) * ActorMod(casterGuid, school))
|
|
if not spellId then
|
|
local byActor = meleeActors[targetGuid]
|
|
if not byActor then
|
|
byActor = {}
|
|
meleeActors[targetGuid] = byActor
|
|
end
|
|
byActor[casterGuid] = true
|
|
end
|
|
end
|
|
|
|
-- Called by Aggregator.RecordHealing with effective (not overheal)
|
|
-- healing: 0.5 threat per point, split across every enemy this pull
|
|
-- has engaged that's still alive (dead ones are dropped on UNIT_DIED).
|
|
local function NoteHealing(enc, casterGuid, effective)
|
|
if not IsTrackedGuid(casterGuid) or not effective or effective <= 0 then return end
|
|
SyncEncounter(enc)
|
|
local count = 0
|
|
local enemyGuid
|
|
for enemyGuid in pairs(ledger) do count = count + 1 end
|
|
if count == 0 then return end
|
|
local each = effective * HEAL_THREAT_PER_POINT * ActorMod(casterGuid, SCHOOL_HOLY) / count
|
|
for enemyGuid in pairs(ledger) do AddThreat(enemyGuid, casterGuid, each) end
|
|
end
|
|
|
|
-- Builds the same snapshot shape HandleThreatPacket produces, so the UI
|
|
-- needs no changes to consume either source. The tank is whoever the
|
|
-- enemy is actually targeting (the aggro holder the 110%/130% rule is
|
|
-- measured against), falling back to the top of the ledger.
|
|
local function EstimateThreat(targetGuid)
|
|
if not ValidGuid(targetGuid) then return false end
|
|
local enc = CL.Aggregator.GetCurrent()
|
|
if not enc or enc ~= ledgerEncounter then return false end
|
|
local byActor = ledger[targetGuid]
|
|
if not byActor then return false end
|
|
|
|
local okTT, existsTT, aggroGuid = pcall(UnitExists, "targettarget")
|
|
if not (okTT and existsTT and aggroGuid and byActor[aggroGuid]) then aggroGuid = nil end
|
|
|
|
local newCurrent = {}
|
|
local maxThreat, topGuid = 0, nil
|
|
local actorGuid, threat
|
|
for actorGuid, threat in pairs(byActor) do
|
|
if threat > 0 then
|
|
local info = CL.GuidCache.Resolve(actorGuid)
|
|
newCurrent[actorGuid] = {
|
|
name = (info and info.name) or actorGuid,
|
|
threat = threat,
|
|
estimated = true,
|
|
melee = (meleeActors[targetGuid] and meleeActors[targetGuid][actorGuid]) or false,
|
|
tank = false,
|
|
}
|
|
if threat > maxThreat then maxThreat, topGuid = threat, actorGuid end
|
|
end
|
|
end
|
|
if maxThreat <= 0 then return false end
|
|
|
|
local newTank = (aggroGuid and newCurrent[aggroGuid]) and aggroGuid or topGuid
|
|
local tankThreat = newCurrent[newTank].threat
|
|
newCurrent[newTank].tank = true
|
|
local _, entry
|
|
for _, entry in pairs(newCurrent) do
|
|
entry.perc = math.floor((entry.threat / tankThreat) * 100 + 0.5)
|
|
end
|
|
|
|
current = newCurrent
|
|
tankGuid = newTank
|
|
|
|
if CL.debug then
|
|
CL.LogLine("[Threat] estimated target=" .. tostring(targetGuid) .. " " .. CL.TableCount(newCurrent) .. " actors (no real reply for " ..
|
|
string.format("%.1f", GetTime() - lastUpdate) .. "s)")
|
|
end
|
|
|
|
if CL.UI and CL.UI.RefreshMode then CL.UI.RefreshMode("threat") end
|
|
return true
|
|
end
|
|
|
|
-- [name] = guid for group members; server replies name players, and
|
|
-- this maps them back to GUIDs.
|
|
local nameToGuid = {}
|
|
|
|
-- Set when the target changes, cleared when data for it arrives. Until
|
|
-- then the previous target's bars stay up (a swap in place instead of a
|
|
-- flash to empty); after STALE_TARGET_TIMEOUT without data they clear.
|
|
local pendingTargetSince = nil
|
|
local STALE_TARGET_TIMEOUT = 2
|
|
|
|
local function AddRosterName(unit)
|
|
local ok, exists, guid = pcall(UnitExists, unit)
|
|
if ok and exists and guid then
|
|
local name = UnitName(unit)
|
|
if name then nameToGuid[name] = guid end
|
|
end
|
|
end
|
|
|
|
local function RefreshRosterNames()
|
|
nameToGuid = {}
|
|
AddRosterName("player")
|
|
if GetNumRaidMembers and GetNumRaidMembers() > 0 then
|
|
local i
|
|
for i = 1, 40 do
|
|
AddRosterName("raid" .. i)
|
|
end
|
|
elseif GetNumPartyMembers and GetNumPartyMembers() > 0 then
|
|
local i
|
|
for i = 1, 4 do
|
|
AddRosterName("party" .. i)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- RAID in a raid, otherwise PARTY - even solo, since the server reads
|
|
-- the message by its prefix, not by channel membership.
|
|
local function GroupChannel()
|
|
if GetNumRaidMembers and GetNumRaidMembers() > 0 then return "RAID" end
|
|
return "PARTY"
|
|
end
|
|
|
|
-- Plain-text split on a delimiter.
|
|
local function SplitString(str, delimiter)
|
|
local result = {}
|
|
local from = 1
|
|
local delimFrom, delimTo = string.find(str, delimiter, from, true)
|
|
while delimFrom do
|
|
table.insert(result, string.sub(str, from, delimFrom - 1))
|
|
from = delimTo + 1
|
|
delimFrom, delimTo = string.find(str, delimiter, from, true)
|
|
end
|
|
table.insert(result, string.sub(str, from))
|
|
return result
|
|
end
|
|
|
|
-- body: "name:isTank:threat:perc:isMelee;name2:...;..."
|
|
local function HandleThreatPacket(body)
|
|
if CL.debug then CL.LogLine("[Threat] packet body: " .. tostring(body)) end
|
|
|
|
local newCurrent = {}
|
|
local newTank = nil
|
|
|
|
local entries = SplitString(body, ";")
|
|
local i
|
|
for i = 1, table.getn(entries) do
|
|
if entries[i] ~= "" then
|
|
local parts = SplitString(entries[i], ":")
|
|
local name, tankFlag, threatStr, percStr, meleeFlag = parts[1], parts[2], parts[3], parts[4], parts[5]
|
|
if name and tankFlag and threatStr and percStr then
|
|
local guid = nameToGuid[name] or ("THREATNAME:" .. name)
|
|
local isTank = (tankFlag == "1")
|
|
-- Rounded: the server sends float noise (71.199997).
|
|
local percNum = tonumber(percStr) or 0
|
|
newCurrent[guid] = {
|
|
name = name,
|
|
threat = tonumber(threatStr) or 0,
|
|
perc = math.floor(percNum + 0.5),
|
|
melee = (meleeFlag == "1"),
|
|
tank = isTank,
|
|
}
|
|
if isTank then newTank = guid end
|
|
if CL.debug then
|
|
CL.LogLine("[Threat] parsed " .. name .. " guid=" .. tostring(guid) ..
|
|
" threat=" .. tostring(threatStr) .. " perc=" .. tostring(percStr) .. " tank=" .. tostring(isTank))
|
|
end
|
|
elseif CL.debug then
|
|
CL.LogLine("[Threat] unparseable entry: " .. tostring(entries[i]))
|
|
end
|
|
end
|
|
end
|
|
|
|
if CL.debug then CL.LogLine("[Threat] " .. table.getn(entries) .. " entries -> " .. CL.TableCount(newCurrent) .. " players resolved") end
|
|
|
|
current = newCurrent
|
|
tankGuid = newTank
|
|
lastUpdate = GetTime()
|
|
pendingTargetSince = nil
|
|
|
|
if CL.UI and CL.UI.RefreshMode then CL.UI.RefreshMode("threat") end
|
|
end
|
|
|
|
local function RequestThreat()
|
|
local channel = GroupChannel()
|
|
if not channel then return end
|
|
local requestBody = "limit=" .. REQUEST_LIMIT
|
|
local ok, err = pcall(SendAddonMessage, REQUEST_PREFIX, requestBody, channel)
|
|
if CL.debug then
|
|
CL.LogLine("[Threat] request sent prefix=" .. REQUEST_PREFIX ..
|
|
" body=" .. requestBody .. " channel=" .. channel .. " ok=" .. tostring(ok) ..
|
|
(ok and "" or (" err=" .. tostring(err))))
|
|
end
|
|
end
|
|
|
|
-- SPELL_GO: explicit DBC threat effects (Sunder Armor/Taunt-style
|
|
-- zero-damage threat, never the damage Aggregator already records) and
|
|
-- warrior stance tracking. Runs whether or not Threat mode is showing,
|
|
-- so an estimate opened mid-fight still covers the whole pull.
|
|
local function HandleThreatSpellGo(spellId, casterGuid, targetGuid, numTargetsHit)
|
|
spellId = tonumber(spellId)
|
|
if not spellId or not IsTrackedGuid(casterGuid) then return end
|
|
|
|
local info = CL.GuidCache.Resolve(casterGuid)
|
|
if info and info.classToken == "WARRIOR" then
|
|
local stance = STANCE_SPELL_IDS[spellId]
|
|
if not stance then
|
|
local name = SpellField(spellId, "name")
|
|
stance = name and STANCE_LOCKED_ABILITIES[name]
|
|
end
|
|
if stance then stanceByGuid[casterGuid] = stance end
|
|
end
|
|
|
|
local enc = CL.Aggregator.GetCurrent()
|
|
if not enc then return end
|
|
local targetThreat, allThreat = ExplicitSpellThreat(spellId)
|
|
if targetThreat == 0 and allThreat == 0 then return end
|
|
SyncEncounter(enc)
|
|
local mod = ActorMod(casterGuid)
|
|
|
|
-- The SPELL_GO primary target is reliable for single-target spells.
|
|
-- If it is absent, use the live locked target as a best-effort target
|
|
-- only for this explicit effect.
|
|
if not ValidGuid(targetGuid) then
|
|
local ok, exists, liveTargetGuid = pcall(UnitExists, "target")
|
|
if ok and exists then targetGuid = liveTargetGuid end
|
|
end
|
|
|
|
if targetThreat ~= 0 and ValidGuid(targetGuid) and (tonumber(numTargetsHit) or 1) > 0 then
|
|
AddThreat(targetGuid, casterGuid, targetThreat * mod)
|
|
end
|
|
if allThreat ~= 0 then
|
|
local enemyGuid
|
|
for enemyGuid in pairs(ledger) do
|
|
AddThreat(enemyGuid, casterGuid, allThreat * mod)
|
|
end
|
|
end
|
|
end
|
|
|
|
local f = CreateFrame("Frame")
|
|
f:RegisterEvent("CHAT_MSG_ADDON")
|
|
f:RegisterEvent("PARTY_MEMBERS_CHANGED")
|
|
f:RegisterEvent("RAID_ROSTER_UPDATE")
|
|
f:RegisterEvent("PLAYER_ENTERING_WORLD")
|
|
f:RegisterEvent("SPELL_GO_SELF")
|
|
f:RegisterEvent("SPELL_GO_OTHER")
|
|
f:RegisterEvent("UNIT_DIED")
|
|
local function OnThreatEvent()
|
|
if event == "PLAYER_ENTERING_WORLD" then
|
|
buffState = {}
|
|
stanceByGuid = {}
|
|
RefreshRosterNames()
|
|
return
|
|
end
|
|
if event == "CHAT_MSG_ADDON" then
|
|
-- arg1 = prefix, arg2 = message, arg3 = channel, arg4 = sender.
|
|
-- The "TWTv4=" marker is inside the message body, not the prefix.
|
|
if CL.debug and arg1 and (string.find(arg1, "TWT", 1, true) or (arg2 and string.find(arg2, "TWT", 1, true))) then
|
|
CL.LogLine("[Threat] CHAT_MSG_ADDON prefix=" .. tostring(arg1) .. " from=" .. tostring(arg4) .. " msg=" .. tostring(arg2))
|
|
end
|
|
if arg2 and string.find(arg2, REPLY_PREFIX, 1, true) then
|
|
local prefixPos = string.find(arg2, REPLY_PREFIX, 1, true)
|
|
local body = string.sub(arg2, prefixPos + string.len(REPLY_PREFIX))
|
|
HandleThreatPacket(body)
|
|
end
|
|
return
|
|
end
|
|
if event == "PARTY_MEMBERS_CHANGED" or event == "RAID_ROSTER_UPDATE" then
|
|
RefreshRosterNames()
|
|
return
|
|
end
|
|
-- The ledger resets itself per Aggregator encounter (SyncEncounter),
|
|
-- so regen changes need no handling here. A dead enemy stops taking
|
|
-- a share of healing threat.
|
|
if event == "UNIT_DIED" then
|
|
if arg1 then
|
|
ledger[arg1] = nil
|
|
meleeActors[arg1] = nil
|
|
end
|
|
return
|
|
end
|
|
if event == "SPELL_GO_SELF" or event == "SPELL_GO_OTHER" then
|
|
-- itemId, spellId, casterGuid, targetGuid, castFlags,
|
|
-- numTargetsHit, numTargetsMissed, corpseOwnerGuid
|
|
HandleThreatSpellGo(arg2, arg3, arg4, arg6)
|
|
return
|
|
end
|
|
end
|
|
f:SetScript("OnEvent", function()
|
|
local ok, err = pcall(OnThreatEvent)
|
|
if not ok then CL.RecordError("Threat:" .. tostring(event), err) end
|
|
end)
|
|
RefreshRosterNames()
|
|
|
|
local accum = 0
|
|
local wasPolling = false
|
|
local debugAccum = 0
|
|
local lastTargetGuid = nil
|
|
local function OnThreatUpdate()
|
|
accum = accum + arg1
|
|
if accum < POLL_INTERVAL then return end
|
|
accum = 0
|
|
|
|
-- Only poll while some window shows Threat mode.
|
|
local hasUI = CL.UI and CL.UI.IsModeVisible
|
|
local wantsThreat = hasUI and CL.UI.IsModeVisible("threat")
|
|
local channel = wantsThreat and GroupChannel()
|
|
local hasTargetOk, hasTarget, targetGuid, inCombat
|
|
local polling = false
|
|
if channel then
|
|
hasTargetOk, hasTarget, targetGuid = pcall(UnitExists, "target")
|
|
inCombat = hasTargetOk and hasTarget and UnitAffectingCombat("target")
|
|
polling = inCombat
|
|
end
|
|
|
|
-- Target changed: keep the old bars until the new target's data
|
|
-- arrives (see pendingTargetSince).
|
|
if targetGuid ~= lastTargetGuid then
|
|
if CL.debug then
|
|
CL.LogLine("[Threat] target changed: " .. tostring(lastTargetGuid) .. " -> " .. tostring(targetGuid))
|
|
end
|
|
lastTargetGuid = targetGuid
|
|
pendingTargetSince = GetTime()
|
|
end
|
|
|
|
-- No data for the new target in time: clear.
|
|
if pendingTargetSince and (GetTime() - pendingTargetSince) > STALE_TARGET_TIMEOUT then
|
|
pendingTargetSince = nil
|
|
if CL.debug then CL.LogLine("[Threat] pending target never replied - clearing") end
|
|
current = {}
|
|
tankGuid = nil
|
|
if CL.UI and CL.UI.RefreshMode then CL.UI.RefreshMode("threat") end
|
|
end
|
|
|
|
if CL.debug then
|
|
debugAccum = debugAccum + POLL_INTERVAL
|
|
if polling ~= wasPolling or debugAccum >= 3 then
|
|
debugAccum = 0
|
|
CL.LogLine("[Threat] state: polling=" .. tostring(polling) ..
|
|
" hasUI=" .. tostring(hasUI) .. " wantsThreat=" .. tostring(wantsThreat) ..
|
|
" channel=" .. tostring(channel) .. " hasTargetOk=" .. tostring(hasTargetOk) ..
|
|
" hasTarget=" .. tostring(hasTarget) .. " inCombat=" .. tostring(inCombat))
|
|
end
|
|
end
|
|
|
|
if polling then
|
|
RequestThreat()
|
|
-- The estimate only fills in while server replies are missing;
|
|
-- a reply always overwrites it.
|
|
if (GetTime() - lastUpdate) > ESTIMATE_GRACE then
|
|
if EstimateThreat(targetGuid) then
|
|
-- An estimate for the new target counts as its data.
|
|
pendingTargetSince = nil
|
|
end
|
|
end
|
|
elseif wasPolling then
|
|
-- Stopped polling (no target or out of combat): clear.
|
|
pendingTargetSince = nil
|
|
current = {}
|
|
tankGuid = nil
|
|
if CL.UI and CL.UI.RefreshMode then CL.UI.RefreshMode("threat") end
|
|
end
|
|
wasPolling = polling
|
|
end
|
|
f:SetScript("OnUpdate", function()
|
|
local ok, err = pcall(OnThreatUpdate)
|
|
if not ok then CL.RecordError("Threat:OnUpdate", err) end
|
|
end)
|
|
|
|
-- Sorted group member names, for the Threat filter dropdown (usable
|
|
-- before any threat data exists).
|
|
local function GetRosterNames()
|
|
local names = {}
|
|
local name, guid
|
|
for name, guid in pairs(nameToGuid) do
|
|
table.insert(names, name)
|
|
end
|
|
table.sort(names)
|
|
return names
|
|
end
|
|
|
|
-- Test mode roster, matching Aggregator.lua's TEST_ROSTER. Entries
|
|
-- carry classToken themselves since their fake GUIDs can't be resolved.
|
|
local TEST_THREAT_ROSTER = {
|
|
{ name = "Kobeni", classToken = "WARLOCK", threat = 12500, melee = false, tank = true },
|
|
{ name = "Kaladin", classToken = "WARRIOR", threat = 11800, melee = true, tank = false },
|
|
{ name = "Szeth", classToken = "ROGUE", threat = 8200, melee = true, tank = false },
|
|
{ name = "Dalinar", classToken = "PALADIN", threat = 6100, melee = true, tank = false },
|
|
{ name = "Shallan", classToken = "MAGE", threat = 4300, melee = false, tank = false },
|
|
{ name = "Jasnah", classToken = "PRIEST", threat = 3100, melee = false, tank = false },
|
|
{ name = "Adolin", classToken = "WARRIOR", threat = 2200, melee = true, tank = false },
|
|
}
|
|
|
|
-- Test mode's stand-in for GetSnapshot() (same shape, plus classToken).
|
|
local function GetTestSnapshot()
|
|
local snap = {}
|
|
local tankThreat = TEST_THREAT_ROSTER[1].threat
|
|
local i
|
|
for i = 1, table.getn(TEST_THREAT_ROSTER) do
|
|
local r = TEST_THREAT_ROSTER[i]
|
|
snap["TESTTHREAT" .. i] = {
|
|
name = r.name,
|
|
classToken = r.classToken,
|
|
threat = r.threat,
|
|
perc = math.floor((r.threat / tankThreat) * 100 + 0.5),
|
|
melee = r.melee,
|
|
tank = r.tank,
|
|
}
|
|
end
|
|
return snap
|
|
end
|
|
|
|
CL.Threat = {
|
|
GetSnapshot = function() return current end,
|
|
GetTestSnapshot = GetTestSnapshot,
|
|
GetTankGuid = function() return tankGuid end,
|
|
IsAvailable = function() return GroupChannel() ~= nil end,
|
|
GetLastUpdate = function() return lastUpdate end,
|
|
GetRosterNames = GetRosterNames,
|
|
NoteDamage = NoteDamage,
|
|
NoteHealing = NoteHealing,
|
|
}
|