Files
ShaguDPS/threat.lua
T

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)