DopingControl 0.8.1

This commit is contained in:
2026-10-18 13:41:37 +02:00
parent e6561d3bce
commit 755194d1cb
8 changed files with 444 additions and 23 deletions
+310 -11
View File
@@ -17,6 +17,25 @@
-- proves shootyepgp itself is installed.
-- Rinse prefix "Rinse": query "REPORT_ADDON_VERSION", reply
-- "V_<ver>" (only the UpsiRinse fork answers).
-- RABuffs passive only. Every client in a raid broadcasts on prefix
-- "RAB/BT" (RAID) every 30 s: "<key> <number>" pairs ("h2oc 20
-- fort 1500 ..."), but only when it has something to report
-- (mana users, tracked timed buffs). The version messages
-- "RAB/V" ("<ver> <deci>") and "RAB/VC" are disabled upstream;
-- parsed if they ever arrive, never sent. Its own version check
-- runs over visible chat and is never used. Silence is weak
-- evidence: "not seen" only after RAB_SILENT seconds in the raid.
-- BCS BetterCharacterStats, passive only. Its sole traffic is prefix
-- "bcs" on PARTY: "TREE,<name>[,<bonus>]", sent by druids in
-- Tree of Life form. Too rare to prove anything: without a
-- responder answer (below) a member is unknown, never missing.
-- ADDONCHECK the shootyepgp responder. One query "Q1:<id>:<folders>" on
-- the group channel, every guild member's shootyepgp answers
-- "A1:<id>:<Folder>=L/<ver>;<Folder>=D;<Folder>=M" (loaded /
-- installed but disabled / not installed), split over several
-- messages when long. That is certain evidence per folder and
-- beats every passive rule. Older shootyepgp versions and
-- non-guildmates stay silent: silence is never red for BCS/RAB.
-- Honest-unknown rule: silence is only "no reply" after a query window has
-- passed for a member who was in the group when the query went out;
-- before that the cell is unknown, and members who cannot answer (not in
@@ -33,9 +52,11 @@ local A = DC_Addons
A.WINDOW = 6 -- seconds a query waits for replies
A.AUTO_THROTTLE = 30 -- seconds between automatic queries
A.MAX_NAMES = 400 -- passive-store cap (guild chatter from non-raiders)
A.RAB_SILENT = 70 -- seconds in the raid before RABuffs silence counts
-- (two 30 s broadcast cycles plus margin)
-- slot ids (match DC.SLOTS_ADDONS)
A.SLOT_IDS = { "BIGWIGS", "TWT", "EPGP", "SUPERWOW", "RINSE" }
A.SLOT_IDS = { "BIGWIGS", "TWT", "EPGP", "SUPERWOW", "RINSE", "RABUFFS", "BCS" }
A.DISPELLERS = {
DRUID = true, PRIEST = true, PALADIN = true, SHAMAN = true, MAGE = true,
@@ -48,13 +69,41 @@ local PREFIX_SLOT = {
["Rinse"] = "RINSE",
["SEPGP_PREFIX"] = "EPGP",
["SWOW_R"] = "SUPERWOW",
["RAB/BT"] = "RABUFFS",
["RAB/V"] = "RABUFFS",
["RAB/VC"] = "RABUFFS",
["bcs"] = "BCS",
["ADDONCHECK"] = "CHECK", -- responder answers, own parser
}
A.PREFIXES = PREFIX_SLOT
-- ADDONCHECK protocol: folder name (as on the client) -> column
A.CHECK_PREFIX = "ADDONCHECK"
A.CHECK_NAMES = { "BigWigs", "TWThreat", "shootyepgp", "UpsiRinse", "Rinse",
"RABuffs", "BetterCharacterStats" }
local CHECK_SLOT = {
BigWigs = "BIGWIGS", TWThreat = "TWT", shootyepgp = "EPGP",
UpsiRinse = "RINSE", Rinse = "RINSE", RABuffs = "RABUFFS",
BetterCharacterStats = "BCS",
}
local CHECK_CHANNEL = { RAID = true, PARTY = true, GUILD = true, BATTLEGROUND = true }
-- passive store: A.data[name][slot] = entry
A.data = {}
A.dataCount = 0
-- roster time: A.since[name] = when the name was first seen in the group
-- (feeds the RABuffs "not seen" rule)
A.since = {}
-- responder store: A.resp[name][folder] = { s = "L"|"D"|"M", ver = text|nil }
-- (evicted with the roster in Prune, capped like the passive store)
A.resp = {}
A.respCount = 0
A.checkId = nil -- id of the ADDONCHECK query in flight (answers to any
-- other id are ignored)
A.checkSeq = 0 -- survives Reset so ids never repeat inside a session
-- query state
A.query = { at = nil, sent = {}, roster = {} }
A.lastStatus = nil -- last StartQuery verdict text (shown next to the button)
@@ -252,12 +301,49 @@ function A.ParseSwow(msg)
return nil
end
-- RABuffs. "RAB/V" payload "<ver> <deci>" (pattern RABuffs itself uses)
-- gives the version; a "RAB/BT" payload is "<key> <number>" pairs and only
-- proves presence. Anything else is ignored.
function A.ParseRABuffs(msg)
if type(msg) ~= "string" then
return nil
end
local _, _, v = string.find(msg, "^([%w.]+) %d+[.,]%d+%s*$")
v = A.Clean(v)
if v then
return entry(v, nil, msg)
end
if not string.find(msg, "^%a%w+ %d+") then
return nil
end
-- every byte must belong to a pair, else it is not a broadcast
local rest = string.gsub(msg, "%a%w+ %d+", "")
if string.find(rest, "%S") then
return nil
end
local e = entry("yes", nil, msg)
e.presenceOnly = true
return e
end
-- BetterCharacterStats: only "TREE,<name>[,<bonus>]" exists
function A.ParseBCS(msg)
if type(msg) ~= "string" or not string.find(msg, "^TREE,") then
return nil
end
local e = entry("yes", nil, msg)
e.presenceOnly = true
return e
end
local PARSER = {
BIGWIGS = A.ParseBigWigs,
TWT = A.ParseTWT,
RINSE = A.ParseRinse,
EPGP = A.ParseEPGP,
SUPERWOW = A.ParseSwow,
RABUFFS = A.ParseRABuffs,
BCS = A.ParseBCS,
}
-- ------------------------------------------------------------------
@@ -291,6 +377,9 @@ function A.OnAddonMessage(prefix, msg, channel, sender, now)
if not slot or type(sender) ~= "string" or sender == A.own then
return false
end
if slot == "CHECK" then
return A.OnCheckAnswer(msg, channel, sender, now)
end
-- BigWigs itself only honours its syncs on RAID
if slot == "BIGWIGS" and channel ~= "RAID" then
return false
@@ -313,6 +402,115 @@ function A.OnAddonMessage(prefix, msg, channel, sender, now)
return changed
end
-- Build the query text for a fresh id (alphanumeric only) and remember it.
function A.NewCheckQuery(now)
A.checkSeq = A.checkSeq + 1
local id = "d" .. string.format("%x", math.mod(math.floor((now or 0) * 1000), 1048576))
.. "x" .. string.format("%x", A.checkSeq)
A.checkId = id
return "Q1:" .. id .. ":" .. table.concat(A.CHECK_NAMES, ",")
end
-- Parse "A1:<id>:<Folder>=L/<ver>;<Folder>=D;..." -> id, list of
-- { folder, s, ver } or nil. Strict: one malformed part drops the message.
-- Folders we never asked about are skipped.
function A.ParseCheckAnswer(msg)
if type(msg) ~= "string" or string.len(msg) > 255 then
return nil
end
local _, _, id, list = string.find(msg, "^A1:(%w+):(.+)$")
if not id then
return nil
end
local out = {}
for part in string.gfind(list, "([^;]+)") do
local _, _, folder, st, rest = string.find(part, "^([%w_%-%.]+)=([LDM])(.*)$")
if not folder then
return nil
end
local ver
if st == "L" then
local _, _, v = string.find(rest, "^/(.*)$")
if not v then
return nil
end
v = A.Clean(v)
if v and v ~= "?" then
ver = v
end
elseif rest ~= "" then
return nil
end
if CHECK_SLOT[folder] then
table.insert(out, { folder = folder, s = st, ver = ver })
end
end
return id, out
end
-- responder answer -> store. Returns changed (bool).
function A.OnCheckAnswer(msg, channel, sender, now)
if not CHECK_CHANNEL[channel] or not A.checkId then
return false
end
local id, list = A.ParseCheckAnswer(msg)
if not id or id ~= A.checkId or table.getn(list) == 0 then
return false
end
local byName = A.resp[sender]
if not byName then
if A.respCount >= A.MAX_NAMES then
A.resp = {}
A.respCount = 0
end
byName = {}
A.resp[sender] = byName
A.respCount = A.respCount + 1
end
for i = 1, table.getn(list) do
local r = list[i]
byName[r.folder] = { s = r.s, ver = r.ver, at = now }
end
return true
end
-- Responder verdict for one cell: nil (no answer) | { present = entry } |
-- { missing = why }. Rinse counts present when either fork is loaded and
-- missing only when both forks answered D/M.
function A.ResponderState(name, slot)
local byName = A.resp[name]
if not byName then
return nil
end
local want, got, disabled = 0, 0, false
for i = 1, table.getn(A.CHECK_NAMES) do
local folder = A.CHECK_NAMES[i]
if CHECK_SLOT[folder] == slot then
want = want + 1
local r = byName[folder]
if r then
got = got + 1
if r.s == "L" then
local e = entry(r.ver or "yes", nil, "addoncheck " .. folder .. "=L")
if not r.ver then
e.presenceOnly = true
end
e.at = r.at
e.responder = true
return { present = e }
elseif r.s == "D" then
disabled = true
end
end
end
end
if want == 0 or got < want then
return nil
end
return { missing = disabled and "installed but disabled (shootyepgp addon check)"
or "not installed (shootyepgp addon check)" }
end
-- drop everybody not in the given name set (members who left)
function A.Prune(nameSet)
for name in pairs(A.data) do
@@ -321,11 +519,39 @@ function A.Prune(nameSet)
A.dataCount = A.dataCount - 1
end
end
for name in pairs(A.resp) do
if not nameSet[name] then
A.resp[name] = nil
A.respCount = A.respCount - 1
end
end
end
-- Remember when each roster name first appeared; forget names that left.
-- names = array of member names.
function A.NoteRoster(names, now)
local set = {}
for i = 1, table.getn(names) do
local nm = names[i]
set[nm] = true
if not A.since[nm] then
A.since[nm] = now
end
end
for nm in pairs(A.since) do
if not set[nm] then
A.since[nm] = nil
end
end
end
function A.Reset()
A.data = {}
A.dataCount = 0
A.since = {}
A.resp = {}
A.respCount = 0
A.checkId = nil
A.query = { at = nil, sent = {}, roster = {} }
A.lastStatus = nil
end
@@ -363,6 +589,8 @@ function A.StartQuery(env, auto)
sent.TWT = true
pcall(send, "Rinse", "REPORT_ADDON_VERSION", chan)
sent.RINSE = true
-- one responder query on the same group channel (shootyepgp answers)
pcall(send, A.CHECK_PREFIX, A.NewCheckQuery(env.now), chan)
if env.guild then
pcall(send, "SWOW_Q", "1", "GUILD")
sent.SUPERWOW = true
@@ -450,6 +678,20 @@ function A.LocalEntries(g)
else
out.RINSE = false
end
-- RABuffs / BetterCharacterStats: only the addon list is known locally
local function localPassive(addon)
if not g.loaded(addon) then
return false
end
local ver = g.meta(addon, "Version")
local e = mk(ver or "yes")
if not ver then
e.presenceOnly = true
end
return e
end
out.RABUFFS = localPassive("RABuffs")
out.BCS = localPassive("BetterCharacterStats")
return out
end
@@ -527,6 +769,15 @@ end
local NA_CLASS = "this class cannot dispel, so Rinse is not expected"
local NA_GUILD = "not in your guild - shootyepgp only talks on guild chat"
local NA_NOGLD = "you are not in a guild - this check needs guild chat"
local CONFIRM = " Only confirmed when the player runs shootyepgp with the"
.. " addon check."
local WHY_RAB = "No RABuffs message seen. Weak evidence only: RABuffs"
.. " broadcasts only when the member has mana or a tracked timed buff,"
.. " so silence does not prove it is missing." .. CONFIRM
local WHY_RAB_WAIT = "no RABuffs message yet - RABuffs broadcasts every 30 s,"
.. " so this needs about a minute in the raid." .. CONFIRM
local WHY_BCS = "No BetterCharacterStats message seen. It rarely sends, so"
.. " silence proves nothing." .. CONFIRM
-- Annotate(players, ctx): sets p.addons[slot] and p.addonsAnswered on every
-- player table. ctx:
@@ -555,21 +806,32 @@ function A.Annotate(players, ctx)
for s = 1, table.getn(A.SLOT_IDS) do
local slot = A.SLOT_IDS[s]
local r
local ownE = isOwn and ctx.ownEntries[slot] or nil
local resp = (not isOwn and ctx.responder) and ctx.responder(p, slot) or nil
if p.online == false then
r = { kind = "unknown", why = "offline" }
elseif ownE then
-- own character: the local addon list is certain, so an
-- installed addon shows even where the class rule below
-- would call it not expected
r = { kind = "present", e = ownE }
if not ownE.presenceOnly then
table.insert(lists[slot], ownE)
end
elseif resp and resp.present then
-- shootyepgp answered: certain, beats every passive rule
r = { kind = "present", e = resp.present }
if not resp.present.presenceOnly then
table.insert(lists[slot], resp.present)
end
elseif resp and resp.missing
and not (slot == "RINSE" and not A.DISPELLERS[p.class or ""]) then
r = { kind = "missing", why = resp.missing }
elseif slot == "RINSE" and not A.DISPELLERS[p.class or ""] then
r = { kind = "na", why = NA_CLASS }
elseif isOwn then
local e = ctx.ownEntries[slot]
if e then
r = { kind = "present", e = e }
if not e.presenceOnly then
table.insert(lists[slot], e)
end
else
r = { kind = "missing",
why = "not installed on your own character" }
end
r = { kind = "missing",
why = "not installed on your own character" }
elseif (slot == "EPGP" or slot == "SUPERWOW")
and not ctx.inGuild(p) then
r = { kind = "na", why = ctx.myGuild and NA_GUILD or NA_NOGLD }
@@ -580,6 +842,17 @@ function A.Annotate(players, ctx)
if not e.presenceOnly then
table.insert(lists[slot], e)
end
elseif slot == "RABUFFS" then
-- passive only: silence is weak evidence, shown as
-- "not seen" and only after two broadcast cycles
local secs = ctx.inGroupFor and ctx.inGroupFor(p)
if secs and secs >= A.RAB_SILENT then
r = { kind = "notseen", why = WHY_RAB }
else
r = { kind = "unknown", why = WHY_RAB_WAIT }
end
elseif slot == "BCS" then
r = { kind = "unknown", why = WHY_BCS }
elseif ctx.silent(p, slot) then
r = { kind = "missing" }
else
@@ -654,6 +927,8 @@ function A.Annotate(players, ctx)
.. " that does not answer" }
elseif r.kind == "na" then
res = { status = "na", why = r.why }
elseif r.kind == "notseen" then
res = { status = "notseen", why = r.why }
else
res = { status = "unknown", why = r.why }
end
@@ -685,6 +960,13 @@ function A.RealCtx(env)
silent = function(p, slot)
return A.IsSilent(p.name, slot, now)
end,
responder = function(p, slot)
return A.ResponderState(p.name, slot)
end,
inGroupFor = function(p)
local since = A.since[p.name]
return since and (now - since) or nil
end,
}
end
@@ -712,6 +994,13 @@ function A.SimCtx(now)
silent = function(p, slot)
return p.addonSim ~= nil and p.addonSim[slot] == false
end,
inGroupFor = function(p)
-- sim: a RABUFFS = false member has been around long enough
if p.addonSim ~= nil and p.addonSim.RABUFFS == false then
return A.RAB_SILENT
end
return nil
end,
}
end
@@ -724,6 +1013,9 @@ function A.AnnotateStore(store, env)
A.Annotate(store.players, A.SimCtx(env and env.now or 0))
return
end
if env and env.roster and env.now then
A.NoteRoster(env.roster, env.now)
end
A.Annotate(store.players, A.RealCtx(env))
-- evict against the live roster (not the store: before the first scan
-- finishes the store is empty and would wipe everything)
@@ -867,7 +1159,14 @@ end
-- when replies came in and again when the query window ends
local listener = CreateFrame("Frame", "DopingControlAddonsEventFrame", UIParent)
listener:RegisterEvent("CHAT_MSG_ADDON")
listener:RegisterEvent("RAID_ROSTER_UPDATE")
listener:RegisterEvent("PARTY_MEMBERS_CHANGED")
listener:RegisterEvent("PLAYER_ENTERING_WORLD")
listener:SetScript("OnEvent", function()
if event ~= "CHAT_MSG_ADDON" then
A.NoteRoster(names(), GetTime())
return
end
if A.OnAddonMessage(arg1, arg2, arg3, arg4, GetTime()) then
A.dirty = true
A.Wake()