-- 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=" 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)