调整dps插件对仇恨的估算方式

优化dps插件
This commit is contained in:
rucky
2026-03-25 00:57:35 +08:00
parent 5c3f2243c4
commit 12c8c55159
16 changed files with 2454 additions and 165 deletions

863
ThreatEngine.lua Normal file
View File

@@ -0,0 +1,863 @@
local NanamiDPS = NanamiDPS
local TC = NanamiDPS.ThreatCoefficients
-------------------------------------------------------------------------------
-- ThreatEngine: Adaptive dual-track hybrid threat monitoring
--
-- Track 1 (Primary): TWThreat server API — direct authoritative data for
-- Elite/Boss targets when in party/raid. Completely bypasses combat
-- log parsing for the queried target.
--
-- Track 2 (Fallback): Local heuristic combat log estimation — used for
-- normal mobs, solo play, or environments without SuperWoW.
--
-- Data model: 3D table [targetKey][playerName] = { threat, tps, ... }
-- targetKey = GUID (TWThreat) or RaidIcon/virtualID (fallback)
-------------------------------------------------------------------------------
local TE = CreateFrame("Frame", "NanamiDPSThreatEngine", UIParent)
NanamiDPS.ThreatEngine = TE
local _find = string.find
local _sub = string.sub
local _len = string.len
local _lower = string.lower
local _floor = math.floor
local _max = math.max
local _pairs = pairs
local _tonumber = tonumber
-------------------------------------------------------------------------------
-- Constants
-------------------------------------------------------------------------------
local QUERY_INTERVAL_DEFAULT = 0.5
local QUERY_PREFIX = "TWT_UDTSv4"
local QUERY_PREFIX_TM = "TWT_UDTSv4_TM"
local API_MARKER = "TWTv4="
local TM_MARKER = "TMTv1="
local ADDON_PREFIX = "TWT"
local HISTORY_WINDOW = 10
local TPS_WINDOW = 5
-------------------------------------------------------------------------------
-- State
-------------------------------------------------------------------------------
TE.targets = {}
TE.apiActive = false
TE.apiTargetKey = nil
TE.inCombat = false
TE.tankMode = false
TE.playerName = UnitName("player")
local _, playerClassToken = UnitClass("player")
TE.playerClass = playerClassToken
TE.talentCache = {}
TE.stanceModCache = {}
TE.queryTimer = 0
TE.queryInterval = QUERY_INTERVAL_DEFAULT
TE.lastUpdateTime = 0
-------------------------------------------------------------------------------
-- String split utility (1.12 compatible)
-------------------------------------------------------------------------------
local function strsplit(sep, str)
local t = {}
local pos = 1
while true do
local s, e = _find(str, sep, pos, true)
if not s then
local piece = _sub(str, pos)
if piece ~= "" then t[table.getn(t) + 1] = piece end
break
end
local piece = _sub(str, pos, s - 1)
t[table.getn(t) + 1] = piece
pos = e + 1
end
return t
end
-------------------------------------------------------------------------------
-- Target key helpers
-------------------------------------------------------------------------------
local function GetTargetKey(unit)
if not unit or not UnitExists(unit) then return nil end
if UnitGUID then
local guid = UnitGUID(unit)
if guid then return "G:" .. guid end
end
local icon = GetRaidTargetIndex and GetRaidTargetIndex(unit)
if icon then return "I:" .. icon end
local name = UnitName(unit)
local hp = UnitHealth(unit)
local hpMax = UnitHealthMax(unit)
if name then return "V:" .. name .. ":" .. (hpMax or 0) end
return nil
end
local function IsEliteOrBoss(unit)
if not unit or not UnitExists(unit) then return false end
local classification = UnitClassification and UnitClassification(unit)
if classification == "worldboss" or classification == "rareelite" or classification == "elite" then
return true
end
local level = UnitLevel(unit)
if level == -1 then return true end
return false
end
local function IsInGroup()
return (GetNumRaidMembers() > 0) or (GetNumPartyMembers() > 0)
end
local function GetChannel()
if GetNumRaidMembers() > 0 then return "RAID" end
if GetNumPartyMembers() > 0 then return "PARTY" end
return nil
end
-------------------------------------------------------------------------------
-- Talent scanning: cached at combat start
-------------------------------------------------------------------------------
function TE:ScanTalents()
self.talentCache = {}
local class = self.playerClass
local talentDefs = TC.talentMultipliers[class]
if not talentDefs then return end
for _, def in _pairs(talentDefs) do
local _, _, _, _, rank = GetTalentInfo(def.tab, def.index)
rank = rank or 0
self.talentCache[def.label] = {
rank = rank,
mod = def.base + rank * def.perPoint,
}
end
end
function TE:GetTalentMod()
local total = 0
for _, data in _pairs(self.talentCache) do
total = total + data.mod
end
return 1.0 + total
end
-------------------------------------------------------------------------------
-- Stance / buff scanning for threat modifier
-------------------------------------------------------------------------------
local STANCE_CACHE_TTL = 2
function TE:ScanUnitThreatMod(unit)
if not unit or not UnitExists(unit) then return 1.0, 1.0, false, false end
local stanceMod = 1.0
local buffMod = 1.0
local hasRF = false
local hasRockbiter = false
for i = 1, 32 do
local texture = UnitBuff(unit, i)
if not texture then break end
for _, s in _pairs(TC.stancePatterns) do
if _find(texture, s.pat) then
stanceMod = s.mod
end
end
for _, b in _pairs(TC.buffPatterns) do
if _find(texture, b.pat) then
buffMod = buffMod * b.mod
end
end
if TC.righteousFury and _find(texture, TC.righteousFury.texture) then
hasRF = true
end
if TC.rockbiter and _find(texture, TC.rockbiter.texture) then
hasRockbiter = true
end
end
return stanceMod, buffMod, hasRF, hasRockbiter
end
function TE:GetUnitThreatMod(name, unit)
local now = GetTime()
local cached = self.stanceModCache[name]
if cached and (now - cached.time) < STANCE_CACHE_TTL then
return cached.stanceMod, cached.buffMod, cached.hasRF, cached.hasRockbiter
end
if not unit then
unit = NanamiDPS.Parser and NanamiDPS.Parser:UnitByName(name) or nil
end
local sm, bm, rf, rb = self:ScanUnitThreatMod(unit)
self.stanceModCache[name] = {
stanceMod = sm, buffMod = bm, hasRF = rf, hasRockbiter = rb,
time = now,
}
return sm, bm, rf, rb
end
-------------------------------------------------------------------------------
-- Track 2: Local heuristic threat calculation
-------------------------------------------------------------------------------
function TE:CalculateLocalThreat(source, spell, damage, isHeal, sourceClass)
if not damage or damage <= 0 then return 0 end
local threat = damage
local coeff = spell and TC.spells[spell]
if isHeal then
threat = damage * TC.HEAL_THREAT_COEFF
elseif coeff then
if coeff.zero then return 0 end
threat = damage * (coeff.mult or 1.0) + (coeff.bonus or 0)
end
local class = sourceClass
if class and TC.classPassiveMod[class] then
threat = threat * TC.classPassiveMod[class]
end
local stanceMod, buffMod, hasRF, hasRockbiter = self:GetUnitThreatMod(source)
threat = threat * stanceMod * buffMod
if hasRF and class == "PALADIN" then
local rfMod = TC.righteousFury.baseMod
local talentDef = TC.righteousFury
if talentDef.talentTab then
local _, _, _, _, rank = GetTalentInfo(talentDef.talentTab, talentDef.talentIndex)
rank = rank or 0
rfMod = rfMod + rank * talentDef.perPoint
end
threat = threat * rfMod
end
if hasRockbiter and class == "SHAMAN" then
threat = threat * TC.rockbiter.globalMod
end
if class == "WARRIOR" or class == "DRUID" or class == "SHAMAN" then
local talentMod = self:GetTalentMod()
if talentMod > 1.0 then
threat = threat * talentMod
end
end
return _max(threat, 0)
end
-------------------------------------------------------------------------------
-- 3D data model operations
-------------------------------------------------------------------------------
local function EnsureTarget(targetKey)
if not TE.targets[targetKey] then
TE.targets[targetKey] = {
players = {},
tankName = nil,
tankThreat = 0,
lastUpdate = GetTime(),
source = "local",
}
end
return TE.targets[targetKey]
end
local function EnsurePlayer(targetData, playerName)
if not targetData.players[playerName] then
targetData.players[playerName] = {
threat = 0,
tps = 0,
isTanking = false,
isMelee = false,
perc = 0,
history = {},
lastThreatTime = 0,
}
end
return targetData.players[playerName]
end
function TE:AddLocalThreat(targetKey, source, amount)
if not targetKey or not source or not amount then return end
local td = EnsureTarget(targetKey)
if td.source == "api" then return end
local pd = EnsurePlayer(td, source)
pd.threat = pd.threat + amount
pd.lastThreatTime = GetTime()
local now = GetTime()
table.insert(pd.history, { time = now, threat = pd.threat })
local cutoff = now - TPS_WINDOW
while table.getn(pd.history) > 0 and pd.history[1].time < cutoff do
table.remove(pd.history, 1)
end
if table.getn(pd.history) >= 2 then
local first = pd.history[1]
local last = pd.history[table.getn(pd.history)]
local dt = last.time - first.time
if dt > 0 then
pd.tps = (last.threat - first.threat) / dt
end
end
td.lastUpdate = now
self:RecalcTankStatus(targetKey)
end
-------------------------------------------------------------------------------
-- Taunt handling: copy highest threat to the taunter (Growl, Taunt, etc.)
-------------------------------------------------------------------------------
function TE:HandleTaunt(taunterName, targetKey)
if not targetKey then return end
local td = self.targets[targetKey]
if not td then return end
local maxThreat = 0
for name, pd in _pairs(td.players) do
if name ~= taunterName and pd.threat > maxThreat then
maxThreat = pd.threat
end
end
if maxThreat > 0 then
local pd = EnsurePlayer(td, taunterName)
if pd.threat < maxThreat then
pd.threat = maxThreat
pd.isTanking = true
pd.lastThreatTime = GetTime()
end
end
self:RecalcTankStatus(targetKey)
end
function TE:WipePlayerThreat(source, wipeType)
for targetKey, td in _pairs(self.targets) do
local pd = td.players[source]
if pd then
if wipeType == "FULL_WIPE" then
pd.threat = 0
pd.tps = 0
pd.history = {}
elseif wipeType == "HALF_WIPE" then
pd.threat = pd.threat * 0.5
elseif wipeType == "TEMP_REDUCE" then
pd.threat = pd.threat * 0.5
end
end
end
end
function TE:RecalcTankStatus(targetKey)
local td = self.targets[targetKey]
if not td then return end
local maxThreat = 0
local tankName = nil
for name, pd in _pairs(td.players) do
if pd.threat > maxThreat then
maxThreat = pd.threat
tankName = name
end
end
td.tankName = tankName
td.tankThreat = maxThreat
for name, pd in _pairs(td.players) do
pd.isTanking = (name == tankName)
pd.perc = maxThreat > 0 and (pd.threat / maxThreat * 100) or 0
end
end
-------------------------------------------------------------------------------
-- Track 1: TWThreat API — packet handling
-------------------------------------------------------------------------------
function TE:HandleAPIPacket(rawData)
local threatData = rawData
local tmData = nil
local hashPos = _find(rawData, "#", 1, true)
if hashPos then
threatData = _sub(rawData, 1, hashPos - 1)
tmData = _sub(rawData, hashPos + 1)
end
local apiStart = _find(threatData, API_MARKER, 1, true)
if not apiStart then return end
local playersStr = _sub(threatData, apiStart + _len(API_MARKER))
local entries = strsplit(";", playersStr)
local targetKey = self.apiTargetKey or "api_target"
local td = EnsureTarget(targetKey)
td.source = "api"
td.lastUpdate = GetTime()
td.tankName = nil
td.tankThreat = 0
local oldPlayers = td.players
td.players = {}
for _, entry in _pairs(entries) do
local fields = strsplit(":", entry)
if fields[1] and fields[2] and fields[3] and fields[4] and fields[5] then
local name = fields[1]
local isTank = fields[2] == "1"
local threat = _tonumber(fields[3]) or 0
local perc = _tonumber(fields[4]) or 0
local isMelee = fields[5] == "1"
local prevHistory = oldPlayers[name] and oldPlayers[name].history or {}
local pd = EnsurePlayer(td, name)
pd.threat = threat
pd.isTanking = isTank
pd.perc = perc
pd.isMelee = isMelee
pd.lastThreatTime = GetTime()
table.insert(prevHistory, { time = GetTime(), threat = threat })
local cutoff = GetTime() - TPS_WINDOW
while table.getn(prevHistory) > 0 and prevHistory[1].time < cutoff do
table.remove(prevHistory, 1)
end
pd.history = prevHistory
if table.getn(pd.history) >= 2 then
local first = pd.history[1]
local last = pd.history[table.getn(pd.history)]
local dt = last.time - first.time
if dt > 0 then
pd.tps = (last.threat - first.threat) / dt
end
end
if isTank then
td.tankName = name
td.tankThreat = threat
end
end
end
if tmData then
self:HandleTankModePacket(tmData)
end
self:FireUpdate()
end
function TE:HandleTankModePacket(raw)
local tmStart = _find(raw, TM_MARKER, 1, true)
if not tmStart then return end
local dataStr = _sub(raw, tmStart + _len(TM_MARKER))
local entries = strsplit(";", dataStr)
for _, entry in _pairs(entries) do
local fields = strsplit(":", entry)
if fields[1] and fields[2] and fields[3] and fields[4] then
local creature = fields[1]
local guid = fields[2]
local name = fields[3]
local perc = _tonumber(fields[4]) or 0
local targetKey = "G:" .. guid
local td = EnsureTarget(targetKey)
td.source = "api_tm"
td.lastUpdate = GetTime()
local pd = EnsurePlayer(td, name)
pd.perc = perc
pd.isTanking = true
td.tankName = name
end
end
end
-------------------------------------------------------------------------------
-- API query ticker
-------------------------------------------------------------------------------
function TE:SendQuery()
if not self.inCombat then return end
if not IsInGroup() then return end
local unit = "target"
if not UnitExists(unit) or UnitIsPlayer(unit) or not UnitAffectingCombat(unit) then
return
end
self.apiTargetKey = GetTargetKey(unit)
local channel = GetChannel()
if not channel then return end
local prefix = self.tankMode and QUERY_PREFIX_TM or QUERY_PREFIX
local limit = 10
SendAddonMessage(prefix, "limit=" .. limit, channel)
end
-------------------------------------------------------------------------------
-- Query result: OT analysis for current player
-------------------------------------------------------------------------------
function TE:GetOTStatus(targetKey)
local td = self.targets[targetKey]
if not td then
return { safe = true, pct = 0, threshold = 0, buffer = 0, tankName = nil }
end
local myData = td.players[self.playerName]
if not myData then
return { safe = true, pct = 0, threshold = 0, buffer = 0, tankName = td.tankName }
end
local tankThreat = td.tankThreat
if tankThreat <= 0 then
return { safe = true, pct = 0, threshold = 0, buffer = 0, tankName = td.tankName }
end
local threshold = myData.isMelee and TC.OT_MELEE_THRESHOLD or TC.OT_RANGED_THRESHOLD
local otPoint = tankThreat * threshold
local myThreat = myData.threat
local pct = myThreat / otPoint * 100
local buffer = otPoint - myThreat
return {
safe = myThreat < otPoint,
pct = pct,
threshold = threshold,
otPoint = otPoint,
buffer = buffer,
myThreat = myThreat,
tankThreat = tankThreat,
tankName = td.tankName,
isMelee = myData.isMelee,
}
end
-------------------------------------------------------------------------------
-- Get sorted threat list for display
-------------------------------------------------------------------------------
function TE:GetThreatList(targetKey)
local td = self.targets[targetKey]
if not td then return {} end
local list = {}
for name, pd in _pairs(td.players) do
table.insert(list, {
name = name,
threat = pd.threat,
tps = pd.tps,
perc = pd.perc,
isTanking = pd.isTanking,
isMelee = pd.isMelee,
})
end
table.sort(list, function(a, b) return a.threat > b.threat end)
local top = list[1] and list[1].threat or 0
for _, entry in _pairs(list) do
entry.relativePercent = top > 0 and (entry.threat / top * 100) or 0
end
return list
end
function TE:GetActiveTargetKey()
if self.apiActive and self.apiTargetKey then
return self.apiTargetKey
end
return GetTargetKey("target")
end
-------------------------------------------------------------------------------
-- Cleanup stale data
-------------------------------------------------------------------------------
function TE:PurgeStaleTargets()
local now = GetTime()
local staleThreshold = 30
local keysToRemove = {}
for key, td in _pairs(self.targets) do
if (now - td.lastUpdate) > staleThreshold then
keysToRemove[table.getn(keysToRemove) + 1] = key
end
end
for _, key in _pairs(keysToRemove) do
self.targets[key] = nil
end
end
-------------------------------------------------------------------------------
-- Refresh callback
-------------------------------------------------------------------------------
function TE:FireUpdate()
NanamiDPS:FireCallback("threat_update")
self.lastUpdateTime = GetTime()
end
-------------------------------------------------------------------------------
-- Event registration & main loop
-------------------------------------------------------------------------------
TE:RegisterEvent("CHAT_MSG_ADDON")
TE:RegisterEvent("PLAYER_REGEN_DISABLED")
TE:RegisterEvent("PLAYER_REGEN_ENABLED")
TE:RegisterEvent("PLAYER_TARGET_CHANGED")
TE:SetScript("OnEvent", function()
if event == "CHAT_MSG_ADDON" then
if arg2 and _find(arg2, API_MARKER, 1, true) then
TE:HandleAPIPacket(arg2)
TE.apiActive = true
end
return
end
if event == "PLAYER_REGEN_DISABLED" then
TE.inCombat = true
TE:ScanTalents()
TE.stanceModCache = {}
return
end
if event == "PLAYER_REGEN_ENABLED" then
TE.inCombat = false
TE.apiActive = false
TE.apiTargetKey = nil
return
end
if event == "PLAYER_TARGET_CHANGED" then
TE.apiTargetKey = GetTargetKey("target")
if TE.inCombat and UnitExists("target") and not UnitIsPlayer("target") then
local td = TE.targets[TE.apiTargetKey]
if not td or td.source ~= "api" then
TE.apiActive = false
end
end
return
end
end)
-------------------------------------------------------------------------------
-- Threat-clearing buff detection via direct buff scanning
-- More reliable than combat log text matching for abilities like
-- Feign Death, Vanish, etc.
-------------------------------------------------------------------------------
TE.threatWipeBuffs = {
{ pat = "Ability_Rogue_FeignDeath", wipe = "FULL_WIPE" },
{ pat = "FeignDeath", wipe = "FULL_WIPE" },
{ pat = "Ability_Vanish", wipe = "FULL_WIPE" },
{ pat = "Spell_Shadow_Possession", wipe = "TEMP_REDUCE" },
{ pat = "Spell_Holy_FadeAway", wipe = "TEMP_REDUCE" },
}
TE.lastWipeCheck = 0
TE.wipeActive = {}
function TE:CheckThreatWipeBuffs()
local now = GetTime()
if (now - self.lastWipeCheck) < 0.3 then return end
self.lastWipeCheck = now
local unit = "player"
if not UnitExists(unit) then return end
for _, def in _pairs(self.threatWipeBuffs) do
local found = false
for i = 1, 32 do
local tex = UnitBuff(unit, i)
if not tex then break end
if _find(tex, def.pat) then
found = true
break
end
end
if found and not self.wipeActive[def.pat] then
self.wipeActive[def.pat] = true
self:WipePlayerThreat(self.playerName, def.wipe)
local DS = NanamiDPS and NanamiDPS.DataStore
if DS then
local current = DS:GetPlayerThreat(self.playerName)
if current > 0 then
DS:AddThreat(self.playerName, -current)
end
end
elseif not found and self.wipeActive[def.pat] then
self.wipeActive[def.pat] = nil
end
end
end
-------------------------------------------------------------------------------
-- targettarget heuristic: if the mob is attacking entity X, then X must
-- have the highest threat. This corrects for pet Growl and other taunt
-- effects that don't produce combat log damage.
-------------------------------------------------------------------------------
TE.lastToTCheck = 0
local TOT_CHECK_INTERVAL = 0.5
function TE:CheckTargetOfTarget()
local now = GetTime()
if (now - self.lastToTCheck) < TOT_CHECK_INTERVAL then return end
self.lastToTCheck = now
if not UnitExists("target") or UnitIsPlayer("target") then return end
if not UnitExists("targettarget") then return end
local targetKey = self:GetCurrentTargetKey()
if not targetKey then return end
if self:IsAPIActiveForTarget(targetKey) then return end
local td = self.targets[targetKey]
if not td then return end
local totName = UnitName("targettarget")
if not totName then return end
local totData = td.players[totName]
if not totData then
for pName, _ in _pairs(td.players) do
if _find(pName, totName, 1, true) then
totData = td.players[pName]
totName = pName
break
end
end
end
if not totData then
totData = EnsurePlayer(td, totName)
end
local maxOther = 0
for name, pd in _pairs(td.players) do
if name ~= totName and pd.threat > maxOther then
maxOther = pd.threat
end
end
if totData.threat < maxOther then
totData.threat = maxOther
totData.isTanking = true
totData.lastThreatTime = now
self:RecalcTankStatus(targetKey)
elseif not totData.isTanking then
totData.isTanking = true
self:RecalcTankStatus(targetKey)
end
end
TE:SetScript("OnUpdate", function()
if not TE.inCombat then return end
local now = GetTime()
TE:CheckThreatWipeBuffs()
TE:CheckTargetOfTarget()
if (now - TE.queryTimer) >= TE.queryInterval then
TE.queryTimer = now
TE:SendQuery()
end
if (now - TE.lastUpdateTime) >= 0.25 then
TE:FireUpdate()
end
if mod(_floor(now), 10) == 0 and _floor(now) ~= _floor(TE.queryTimer - TE.queryInterval) then
TE:PurgeStaleTargets()
end
end)
-------------------------------------------------------------------------------
-- Public helpers for Parser integration
-------------------------------------------------------------------------------
function TE:IsAPIActiveForTarget(targetKey)
if not targetKey then return false end
local td = self.targets[targetKey]
return td and td.source == "api"
end
function TE:GetCurrentTargetKey()
return GetTargetKey("target")
end
TE.GetTargetKey = GetTargetKey
TE.IsEliteOrBoss = IsEliteOrBoss
TE.IsInGroup = IsInGroup
-------------------------------------------------------------------------------
-- Public API: NanamiDPS.ThreatEngine:QueryUnitThreat(unitID)
--
-- Returns a table with the caller's threat data for the given unit, or nil.
-- Intended for external addons (e.g., nameplate addons) to query threat info.
--
-- @param unitID string WoW unit ID (e.g., "target", "mouseover", etc.)
-- @return table or nil { pct, threat, tankName, tankThreat, isTanking,
-- isMelee, source, secondName, secondPct }
-------------------------------------------------------------------------------
function TE:QueryUnitThreat(unitID)
if not unitID or not UnitExists(unitID) then return nil end
if not self.inCombat then return nil end
local targetKey = GetTargetKey(unitID)
if not targetKey then return nil end
local td = self.targets[targetKey]
if not td then return nil end
local myData = td.players[self.playerName]
local myThreat = myData and myData.threat or 0
local myPct = myData and myData.perc or 0
local secondName, secondThreat, secondPct = nil, 0, 0
local sorted = {}
for name, pd in _pairs(td.players) do
table.insert(sorted, { name = name, threat = pd.threat })
end
table.sort(sorted, function(a, b) return a.threat > b.threat end)
if sorted[2] then
secondName = sorted[2].name
secondThreat = sorted[2].threat
secondPct = td.tankThreat > 0 and (secondThreat / td.tankThreat * 100) or 0
end
return {
pct = myPct,
threat = myThreat,
tankName = td.tankName,
tankThreat = td.tankThreat,
isTanking = myData and myData.isTanking or false,
isMelee = myData and myData.isMelee or false,
source = td.source,
secondName = secondName,
secondPct = secondPct,
secondThreat = secondThreat,
}
end
-------------------------------------------------------------------------------
-- Public API: NanamiDPS.ThreatEngine:QueryNameThreat(targetKey, playerName)
--
-- Query a specific player's threat on a specific target.
-- @param targetKey string Target key from GetTargetKey()
-- @param playerName string Player name
-- @return number, boolean, number threat, isTanking, pct
-------------------------------------------------------------------------------
function TE:QueryNameThreat(targetKey, playerName)
if not targetKey or not playerName then return 0, false, 0 end
local td = self.targets[targetKey]
if not td then return 0, false, 0 end
local pd = td.players[playerName]
if not pd then return 0, false, 0 end
return pd.threat, pd.isTanking, pd.perc
end