396 lines
14 KiB
Lua
396 lines
14 KiB
Lua
-- Threat view: the server's threat list for the current target.
|
|
--
|
|
-- 1.18.1 servers answer a threat query sent as an addon message with the
|
|
-- prefix "TWT_UDTSv4" and the text "limit=<n>" to the group channel. The
|
|
-- answer arrives as CHAT_MSG_ADDON whose text holds
|
|
-- "TWTv4=name:tank:threat:percent:melee;name:tank:threat:percent:melee;..."
|
|
-- for the target of the player who asked: tank is 1 for the unit's current
|
|
-- victim, percent is relative to the pull threshold (the victim shows 100),
|
|
-- melee is 1 for players in melee
|
|
-- range. Melee players pull aggro at 110% of the tank's threat, ranged ones
|
|
-- at 130%. Only works in a party or raid, on elite and boss targets.
|
|
--
|
|
-- This is the same server API TWThreat uses. Unmatched replies can supply
|
|
-- display data and temporarily suppress polling outside the reply guard.
|
|
|
|
local parser = ShaguDPS.parser
|
|
local config = ShaguDPS.config
|
|
local data = ShaguDPS.data
|
|
local window = ShaguDPS.window
|
|
|
|
local QUERY_PREFIX = "TWT_UDTSv4"
|
|
local ANSWER_TAG = "TWTv4="
|
|
local INTERVAL = 0.5
|
|
local STALE_TIMEOUT = 3
|
|
local TPS_WINDOW = 5
|
|
local FOREIGN_TIMEOUT = 1 -- allow slower polling addons to keep supplying data
|
|
local REPLY_TIMEOUT = 1.25
|
|
local REPLY_GUARD = 0.75
|
|
local pending_key, pending_at
|
|
local guard_until, retry_at, failures = 0, 0, 0
|
|
local warned
|
|
|
|
-- Opt-in, short measurement; no per-packet chat output or retained log.
|
|
local measurement
|
|
local function DiagnosticMessage(text)
|
|
DEFAULT_CHAT_FRAME:AddMessage("ShaguDPS threat: " .. text)
|
|
end
|
|
|
|
local function DiagnosticTick(now)
|
|
if not measurement or now - measurement.start < 10 then return end
|
|
local elapsed = now - measurement.start
|
|
DiagnosticMessage(string.format("%.1fs: %d requests (%.2f/s), %d replies (%.2f/s), %d changed replies, %d target switches. %d timeouts, %d ignored, %d malformed, %d empty, %d unmatched accepted. Healthy interval: %.2fs.",
|
|
elapsed, measurement.sent, measurement.sent / elapsed, measurement.received,
|
|
measurement.received / elapsed, measurement.changed, measurement.switches,
|
|
measurement.timeouts, measurement.ignored, measurement.malformed, measurement.empty,
|
|
measurement.unmatched, INTERVAL))
|
|
measurement = nil
|
|
end
|
|
|
|
local PULL = "Pull Aggro"
|
|
local function TargetKey()
|
|
local guid = UnitGUID and UnitGUID("target")
|
|
if not guid and GetUnitGUID then guid = GetUnitGUID("target") end
|
|
if not guid then local _, value = UnitExists("target"); guid = value end
|
|
if type(guid) == "string" and guid ~= "" then return guid end
|
|
return UnitName("target")
|
|
end
|
|
-- view 5 shows data.threat: one entry per player, rebuilt on every answer
|
|
data.threat = {}
|
|
|
|
local threat = CreateFrame("Frame")
|
|
ShaguDPS.threat = threat
|
|
|
|
threat.Measure = function()
|
|
measurement = { start = GetTime(), sent = 0, received = 0, changed = 0, switches = 0,
|
|
timeouts = 0, ignored = 0, malformed = 0, empty = 0, unmatched = 0 }
|
|
DiagnosticMessage("Measuring for 10 seconds. Keep one engaged elite/boss targeted, stay grouped, and keep the threat window visible. Disable other threat addons for an isolated test.")
|
|
end
|
|
|
|
threat.target = nil -- name of the unit the list belongs to
|
|
threat.pending = nil -- we asked and wait for our answer
|
|
threat.last_foreign = nil -- last answer to a question another addon asked
|
|
|
|
local function RetireRequest(now)
|
|
if pending_at then guard_until = now + REPLY_GUARD end
|
|
pending_at, pending_key, threat.pending = nil, nil, nil
|
|
end
|
|
|
|
local function ExpireRequest(now)
|
|
if not pending_at or now - pending_at < REPLY_TIMEOUT then return end
|
|
RetireRequest(now)
|
|
failures = math.min(3, failures + 1)
|
|
retry_at = now + 2 ^ (failures - 1)
|
|
if measurement then measurement.timeouts = measurement.timeouts + 1 end
|
|
end
|
|
|
|
-- Recent timestamped samples per player, bounded to the rolling TPS window.
|
|
local history = {}
|
|
|
|
local function Wipe(t)
|
|
for k in pairs(t) do t[k] = nil end
|
|
end
|
|
|
|
local function Clear()
|
|
Wipe(data.threat)
|
|
Wipe(history)
|
|
warned = nil
|
|
end
|
|
|
|
local function TPS(name, value, now)
|
|
local h = history[name]
|
|
local last = h and h[table.getn(h)]
|
|
if not last or value < last.value or now - last.time >= STALE_TIMEOUT then
|
|
history[name] = { { time = now, value = value } }
|
|
return 0
|
|
end
|
|
if now == last.time then
|
|
last.value = value
|
|
else
|
|
table.insert(h, { time = now, value = value })
|
|
end
|
|
local cutoff = now - TPS_WINDOW
|
|
-- Retain one sample before the boundary so it can be interpolated.
|
|
while table.getn(h) > 1 and h[2].time <= cutoff do table.remove(h, 1) end
|
|
local first = h[1]
|
|
local start, baseline = first.time, first.value
|
|
if start < cutoff and h[2] then
|
|
local nextSample = h[2]
|
|
baseline = baseline + (nextSample.value - baseline) * (cutoff - start) / (nextSample.time - start)
|
|
start = cutoff
|
|
end
|
|
return now > start and math.max(0, (value - baseline) / (now - start)) or 0
|
|
end
|
|
|
|
local function Entry(name)
|
|
local entry = data.threat[name]
|
|
if not entry then
|
|
entry = { _ctime = 1 }
|
|
data.threat[name] = entry
|
|
end
|
|
return entry
|
|
end
|
|
|
|
-- "name:tank:threat:percent:melee;..."
|
|
local row_pattern = "([^:;]+):([01]):([^:;]+):([^:;]+):([01])"
|
|
local function ValidPayload(list)
|
|
local offset, length = 1, string.len(list)
|
|
while offset <= length do
|
|
local first, last, _, _, value, perc = string.find(list, row_pattern, offset)
|
|
value, perc = tonumber(value), tonumber(perc)
|
|
if first ~= offset or not value or not perc or value < 0 or perc < 0
|
|
or value - value ~= 0 or perc - perc ~= 0 then return end
|
|
if last < length and string.sub(list, last + 1, last + 1) ~= ";" then return end
|
|
offset = last + 2
|
|
end
|
|
return true
|
|
end
|
|
|
|
local function ParseAnswer(list, now)
|
|
local seen = {}
|
|
local tank_threat, my_melee = 0, nil
|
|
local me = UnitName("player")
|
|
|
|
-- Accept decimal numeric fields, just like the reference addon. Match fields
|
|
-- first, then validate them as TWThreat does instead of requiring integers.
|
|
for name, tank, value, perc, melee in string.gfind(list, row_pattern) do
|
|
value, perc = tonumber(value), tonumber(perc)
|
|
local entry = Entry(name)
|
|
entry._sum = value
|
|
entry._perc = perc
|
|
entry._tank = tank == "1" or nil
|
|
entry._melee = melee == "1" or nil
|
|
entry._tps = TPS(name, value, now)
|
|
seen[name] = true
|
|
|
|
if entry._tank then tank_threat = value end
|
|
if name == me then my_melee = entry._melee end
|
|
|
|
-- class colors for group members
|
|
if not data.classes[name] then parser:ScanName(name) end
|
|
end
|
|
|
|
-- drop players that left the list
|
|
for name in pairs(data.threat) do
|
|
if not seen[name] and name ~= PULL then
|
|
data.threat[name] = nil
|
|
history[name] = nil
|
|
end
|
|
end
|
|
|
|
-- where you'd pull aggro: 110% of the tank in melee range, 130% at range
|
|
if tank_threat > 0 then
|
|
local pull = Entry(PULL)
|
|
pull._sum = math.floor(tank_threat * (my_melee and 1.1 or 1.3))
|
|
pull._perc = 100
|
|
pull._pull = true
|
|
pull._minimum = nil
|
|
if not seen[me] then
|
|
pull._minimum = math.floor(tank_threat * 1.1)
|
|
end
|
|
else
|
|
data.threat[PULL] = nil
|
|
end
|
|
end
|
|
|
|
local function WarnNearPull()
|
|
local setting = config.threat_warning or 0
|
|
local entry = data.threat[UnitName("player")]
|
|
if setting < 1 or setting > 5 or not entry or entry._tank then
|
|
warned = nil
|
|
return
|
|
end
|
|
local threshold = 75 + setting * 5
|
|
if entry._perc < threshold - 5 then
|
|
warned = nil
|
|
elseif not warned and entry._perc >= threshold then
|
|
warned = true
|
|
local message = string.format("ShaguDPS: %.0f%% to pull aggro on %s", entry._perc, threat.target)
|
|
if UIErrorsFrame then
|
|
UIErrorsFrame:AddMessage(message, 1, .25, .1, 1)
|
|
else
|
|
DEFAULT_CHAT_FRAME:AddMessage(message)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- does any visible window show threat? returns the most bars they show
|
|
local function ThreatBars()
|
|
local bars
|
|
for i = 1, 10 do
|
|
local w = window[i]
|
|
if w and config[i] and config[i].view == 5 and w:IsVisible() then
|
|
bars = math.max(bars or 0, config[i].bars or 8)
|
|
end
|
|
end
|
|
return bars
|
|
end
|
|
|
|
local function TargetUsable()
|
|
if not UnitExists("target") or UnitIsPlayer("target") or UnitIsDead("target") then return nil end
|
|
local class = UnitClassification("target")
|
|
return class == "elite" or class == "worldboss" or class == "rareelite"
|
|
end
|
|
|
|
-- Explain why an empty view is empty, rather than implying every target works.
|
|
threat.GetStatus = function(self)
|
|
if GetNumRaidMembers() == 0 and GetNumPartyMembers() == 0 then
|
|
return "Join a party or raid to receive threat data."
|
|
elseif not TargetUsable() then
|
|
return "Select a living elite or boss target to view threat."
|
|
elseif not UnitAffectingCombat("target") then
|
|
return "Waiting for the target to enter combat."
|
|
elseif self.stale then
|
|
return "Threat data stopped arriving. Waiting for a fresh reply."
|
|
elseif self.last_answer then
|
|
return "The server returned no threat entries for this target."
|
|
elseif self.requested_at and GetTime() - self.requested_at >= 3 then
|
|
return "No threat reply received. This view requires the server threat API."
|
|
end
|
|
return "Waiting for threat data from the server."
|
|
end
|
|
|
|
local function Refresh()
|
|
-- Threat packets already arrive at a bounded rate. Display them immediately
|
|
-- instead of waiting for the regular 0.2-second window refresh timer.
|
|
for i = 1, 10 do
|
|
local w = window[i]
|
|
if w and config[i] and config[i].view == 5 then
|
|
w.needs_refresh = true
|
|
if w:IsVisible() then
|
|
w.needs_refresh = nil
|
|
w:Refresh()
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function Query(now)
|
|
if pending_at or now < guard_until or now < retry_at then return end
|
|
if GetNumRaidMembers() == 0 and GetNumPartyMembers() == 0 then return end
|
|
if not TargetUsable() or not UnitAffectingCombat("target") then return end
|
|
|
|
local bars = ThreatBars()
|
|
if not bars then return end
|
|
|
|
-- another addon (TWThreat) is already asking: just listen to its answers
|
|
if threat.last_foreign and now - threat.last_foreign < FOREIGN_TIMEOUT then return end
|
|
|
|
threat.tick = now + INTERVAL
|
|
threat.pending = true
|
|
threat.requested_at = threat.requested_at or now
|
|
local channel = GetNumRaidMembers() > 0 and "RAID" or "PARTY"
|
|
pending_key, pending_at = TargetKey(), now
|
|
if measurement then measurement.sent = measurement.sent + 1 end
|
|
SendAddonMessage(QUERY_PREFIX, "limit=" .. math.min(40, bars), channel)
|
|
end
|
|
|
|
threat:RegisterEvent("CHAT_MSG_ADDON")
|
|
threat:RegisterEvent("PLAYER_TARGET_CHANGED")
|
|
threat:RegisterEvent("PLAYER_REGEN_ENABLED")
|
|
|
|
threat:SetScript("OnEvent", function()
|
|
if event == "CHAT_MSG_ADDON" then
|
|
if not arg2 then return end
|
|
local _, start = string.find(arg2, ANSWER_TAG, 1, true)
|
|
if not start then return end
|
|
local now = GetTime()
|
|
ExpireRequest(now)
|
|
local payload = string.sub(arg2, start + 1)
|
|
local cut = string.find(payload, "#", 1, true)
|
|
if cut then payload = string.sub(payload, 1, cut - 1) end
|
|
if measurement then
|
|
measurement.received = measurement.received + 1
|
|
end
|
|
if now < guard_until or not TargetUsable() or not UnitAffectingCombat("target")
|
|
or (GetNumRaidMembers() == 0 and GetNumPartyMembers() == 0)
|
|
or (pending_at and pending_key ~= TargetKey()) then
|
|
if measurement then measurement.ignored = measurement.ignored + 1 end
|
|
return
|
|
end
|
|
if not ValidPayload(payload) then
|
|
if measurement then measurement.malformed = measurement.malformed + 1 end
|
|
return
|
|
end
|
|
local matched = pending_at ~= nil
|
|
if matched then
|
|
pending_at, pending_key, threat.pending = nil, nil, nil
|
|
failures, retry_at = 0, 0
|
|
elseif now < retry_at then
|
|
if measurement then measurement.ignored = measurement.ignored + 1 end
|
|
return
|
|
else
|
|
-- Keep listening alongside another meter, but unmatched replies do not warn.
|
|
threat.last_foreign = now
|
|
end
|
|
if measurement then
|
|
if not matched then measurement.unmatched = measurement.unmatched + 1 end
|
|
if payload == "" then measurement.empty = measurement.empty + 1 end
|
|
if measurement.previous and payload ~= measurement.previous then
|
|
measurement.changed = measurement.changed + 1
|
|
end
|
|
measurement.previous = payload
|
|
end
|
|
|
|
threat.stale = nil
|
|
threat.target = UnitName("target")
|
|
threat.last_answer = now
|
|
ParseAnswer(payload, now)
|
|
if matched then WarnNearPull() end
|
|
Refresh()
|
|
elseif event == "PLAYER_TARGET_CHANGED" then
|
|
local now = GetTime()
|
|
RetireRequest(now)
|
|
retry_at, failures = 0, 0
|
|
if measurement then
|
|
measurement.switches = measurement.switches + 1
|
|
measurement.previous = nil
|
|
end
|
|
-- the list belongs to the old target
|
|
Clear()
|
|
threat.pending, threat.requested_at, threat.last_answer = nil, nil, nil
|
|
threat.target = TargetUsable() and UnitName("target") or nil
|
|
threat.stale = nil
|
|
threat.tick = 0
|
|
-- Replies for the previous target must not delay the new target query.
|
|
threat.last_foreign = nil
|
|
Refresh()
|
|
Query(now)
|
|
elseif event == "PLAYER_REGEN_ENABLED" then
|
|
Wipe(history)
|
|
end
|
|
end)
|
|
|
|
-- ask the server twice a second while a threat window is shown
|
|
threat:SetScript("OnUpdate", function()
|
|
local now = GetTime()
|
|
DiagnosticTick(now)
|
|
if (this.tick or 0) > now then return end
|
|
this.tick = now + INTERVAL
|
|
ExpireRequest(now)
|
|
|
|
local usable = TargetUsable()
|
|
local active = usable and UnitAffectingCombat("target") and
|
|
(GetNumRaidMembers() > 0 or GetNumPartyMembers() > 0)
|
|
local expired = threat.last_answer and now - threat.last_answer >= STALE_TIMEOUT
|
|
local cleared
|
|
if not active or expired then
|
|
cleared = next(data.threat) ~= nil
|
|
Clear()
|
|
threat.stale = active and expired or nil
|
|
if not active then
|
|
RetireRequest(now)
|
|
retry_at, failures = 0, 0
|
|
threat.last_answer, threat.requested_at, threat.pending, threat.last_foreign = nil, nil, nil, nil
|
|
end
|
|
end
|
|
threat.target = usable and UnitName("target") or nil
|
|
local status = threat:GetStatus()
|
|
if cleared or status ~= threat.status then
|
|
threat.status = status
|
|
Refresh()
|
|
end
|
|
|
|
Query(now)
|
|
end)
|