DopingControl 0.8.1
This commit is contained in:
+310
-11
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user