376 lines
14 KiB
Lua
376 lines
14 KiB
Lua
-- DopingControl scan/talents.lua
|
|
-- Reading OTHER players' talents.
|
|
--
|
|
-- Why this file exists: talents raise hit (Precision, Elemental Precision,
|
|
-- Shadow Focus, Suppression, ...), so a hit number that omits them is
|
|
-- systematically LOW. GetTalentInfo has no unit parameter and the server
|
|
-- never transmits foreign talents (no SMSG_INSPECT_TALENT in this fork,
|
|
-- no talent field in InitVisibleBits or SMSG_PARTY_MEMBER_STATS).
|
|
--
|
|
-- This server nonetheless ships its OWN inspect protocol, and it is a
|
|
-- plain addon-message conversation any addon may hold.
|
|
--
|
|
-- ask SendAddonMessage("TW_CHAT_MSG_WHISPER<Name>", "INSTalentShow",
|
|
-- "GUILD")
|
|
-- reply the TARGET's client runs GetTalentInfo on ITSELF and sends one
|
|
-- message per line:
|
|
-- INSTalentTabInfo;<tree>;<treeName>;<pointsSpent>;<numTalents>
|
|
-- INSTalentInfo;<tree>;<index>;<name>;<tier>;<column>;
|
|
-- <currRank>;<maxRank>;<meetsPrereq>;
|
|
-- <ptier>;<pcolumn>;<isLearnable>
|
|
-- INSTalentEND;
|
|
--
|
|
-- Cost, as observed: ONE outgoing message per player; the reply is a few
|
|
-- dozen messages, ending in INSTalentEND.
|
|
--
|
|
-- Three properties of the reply path decide the design here:
|
|
--
|
|
-- * The answering client checks NOTHING -- no CanInspect, no group,
|
|
-- guild, range or visibility test. Anyone whose NAME we know answers.
|
|
-- So the limit has to be ours: we only ask for players the scan
|
|
-- actually shows (ASK_GAP apart, each name at most REASK_AFTER), and
|
|
-- the whole sender can be switched off.
|
|
-- * We cannot ask for single talents. What gets sent is decided by the
|
|
-- target's client (Turtle's own code in patch-9.mpq), not by us -- it
|
|
-- dumps the whole tree. Our outgoing traffic is one message per
|
|
-- player; everything else is inbound and we drop the lines we do not
|
|
-- need.
|
|
-- * 1.12's wire format is PREFIX<TAB>MESSAGE, and the server rewrites
|
|
-- the "<Name>" tag out of the prefix on the way -- which leaves the
|
|
-- TAB at the HEAD OF THE PAYLOAD (measured: first bytes 9 73 78 83).
|
|
-- An anchored match on "INS..." therefore fails on every single line.
|
|
-- T.Normalize is not cosmetic; without it the whole reply is dropped.
|
|
--
|
|
-- The pure half (Normalize/Parse/Feed/Plausible/ShouldAsk) is offline
|
|
-- tested under real Lua 5.0; only the frame, the event and SendAddonMessage
|
|
-- are in-game.
|
|
|
|
DopingControl = DopingControl or {}
|
|
local DC = DopingControl
|
|
|
|
DC_Talents = DC_Talents or {}
|
|
local T = DC_Talents
|
|
|
|
-- addon-message prefix of the protocol (ours to send, theirs to answer on)
|
|
T.PREFIX = "TW_CHAT_MSG_WHISPER"
|
|
-- the channel argument is INERT once a "<Name>" tag is in the prefix (the
|
|
-- tag is the whisper substitute) -- kept exactly as Turtle's own UI sends
|
|
-- it, because a value the server does not expect is a needless risk
|
|
T.CHANNEL = "GUILD"
|
|
T.ASK_MSG = "INSTalentShow"
|
|
|
|
-- seconds between two outgoing requests (any target). Nothing documents a
|
|
-- rate limit for THIS protocol, so the number is deliberately timid: one
|
|
-- request per this many seconds is far below what Turtle's own UI produces
|
|
-- when a player clicks through an inspect window.
|
|
T.ASK_GAP = 5
|
|
-- do not ask the same player again for this long. Talents change on
|
|
-- respec, which is rare and never mid-raid.
|
|
T.REASK_AFTER = 1800
|
|
|
|
-- ==================================================================
|
|
-- PURE SECTION (offline-testable)
|
|
-- ==================================================================
|
|
|
|
-- Strip the leading wire separator. See the header: the payload arrives as
|
|
-- <TAB>INSTalent... because the server rewrote the name tag out of the
|
|
-- prefix. Leading spaces/CR/LF are stripped too -- cheap, and it keeps the
|
|
-- parser from depending on which whitespace the transport happens to leave.
|
|
function T.Normalize(msg)
|
|
if type(msg) ~= "string" then
|
|
return ""
|
|
end
|
|
return (string.gsub(msg, "^[ \t\r\n]+", ""))
|
|
end
|
|
|
|
-- Split on ";" -- Lua 5.0 has no string.split and no gmatch. Empty fields
|
|
-- are preserved (the protocol uses fixed positions, so a dropped empty
|
|
-- field would shift every later one).
|
|
local function split(s)
|
|
local out, from = {}, 1
|
|
while true do
|
|
local i = string.find(s, ";", from, true)
|
|
if not i then
|
|
table.insert(out, string.sub(s, from))
|
|
break
|
|
end
|
|
table.insert(out, string.sub(s, from, i - 1))
|
|
from = i + 1
|
|
end
|
|
return out
|
|
end
|
|
|
|
-- Classify + decode one payload.
|
|
-- Returns "tab", { tree, treeName, spent, numTalents }
|
|
-- or "talent", { tree, index, name, tier, column, rank, maxRank }
|
|
-- or "end", nil
|
|
-- or nil (not ours -- every other addon's traffic lands here too)
|
|
--
|
|
-- A talent line with a MISSING or non-numeric rank is rejected rather than
|
|
-- read as 0: a silently zeroed rank looks exactly like an untrained talent
|
|
-- and would understate hit without any sign that something went wrong.
|
|
function T.Parse(msg)
|
|
msg = T.Normalize(msg)
|
|
if msg == "" then
|
|
return nil
|
|
end
|
|
if string.find(msg, "INSTalentEND", 1, true) == 1 then
|
|
return "end", nil
|
|
end
|
|
if string.find(msg, "INSTalentTabInfo;", 1, true) == 1 then
|
|
local f = split(msg)
|
|
local tree = tonumber(f[2])
|
|
local spent = tonumber(f[4])
|
|
if not tree or not spent then
|
|
return nil
|
|
end
|
|
return "tab", { tree = tree, treeName = f[3], spent = spent,
|
|
numTalents = tonumber(f[5]) }
|
|
end
|
|
if string.find(msg, "INSTalentInfo;", 1, true) == 1 then
|
|
local f = split(msg)
|
|
local tree = tonumber(f[2])
|
|
local name = f[4]
|
|
local rank = tonumber(f[7])
|
|
if not tree or type(name) ~= "string" or name == "" or not rank then
|
|
return nil
|
|
end
|
|
return "talent", { tree = tree, index = tonumber(f[3]), name = name,
|
|
tier = tonumber(f[5]), column = tonumber(f[6]),
|
|
rank = rank, maxRank = tonumber(f[8]) }
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- A per-sender accumulator. Shape:
|
|
-- { ranks = { [talentName] = rank }, -- rank > 0 only, like the own read
|
|
-- spent = <sum of pointsSpent over the trees that reported>,
|
|
-- sumRank = <sum of every rank we stored>,
|
|
-- trees = <how many tab lines arrived>,
|
|
-- complete = <INSTalentEND seen> }
|
|
function T.NewAcc()
|
|
return { ranks = {}, spent = 0, sumRank = 0, trees = 0, complete = false }
|
|
end
|
|
|
|
-- Fold one parsed line into the accumulator. Returns the accumulator so
|
|
-- callers can chain; `complete` flips exactly once, on the END marker.
|
|
function T.Feed(acc, kind, data)
|
|
if not acc or not kind then
|
|
return acc
|
|
end
|
|
if kind == "end" then
|
|
acc.complete = true
|
|
return acc
|
|
end
|
|
if kind == "tab" and data then
|
|
acc.trees = acc.trees + 1
|
|
acc.spent = acc.spent + (data.spent or 0)
|
|
return acc
|
|
end
|
|
if kind == "talent" and data and data.rank and data.rank > 0 then
|
|
-- keyed BY NAME, because that is what DC.TALENT_HIT matches on
|
|
-- (data/talenthit.lua) and what ReadSelfTalents produces -- the two
|
|
-- paths must hand DC_Hit.TalentHit the same shape or they would
|
|
-- drift apart silently
|
|
if not acc.ranks[data.name] then
|
|
acc.sumRank = acc.sumRank + data.rank
|
|
acc.ranks[data.name] = data.rank
|
|
end
|
|
end
|
|
return acc
|
|
end
|
|
|
|
-- Consistency gate before a reply is believed.
|
|
--
|
|
-- The client that answered told us two independent things: how many points
|
|
-- it spent per tree, and every individual rank. They must agree. They are
|
|
-- computed from the same source on the far side, so agreement does not
|
|
-- prove the data is FRESH -- but disagreement proves we mis-parsed or lost
|
|
-- messages, and that is exactly the failure this protects against (a
|
|
-- half-received tree would understate hit while looking perfectly normal).
|
|
--
|
|
-- All three trees must have reported; a talentless character legitimately
|
|
-- sends three empty tab lines and zero talent lines, which passes.
|
|
function T.Plausible(acc)
|
|
if not acc or not acc.complete then
|
|
return false
|
|
end
|
|
if acc.trees < 3 then
|
|
return false
|
|
end
|
|
return acc.sumRank == acc.spent
|
|
end
|
|
|
|
-- Throttle decision, pure so the whole policy is testable without a client.
|
|
-- asked = { [name] = <time we last asked> }
|
|
-- lastAny = <time of the last request to ANYBODY>
|
|
function T.ShouldAsk(name, now, asked, lastAny)
|
|
if type(name) ~= "string" or name == "" then
|
|
return false
|
|
end
|
|
now = now or 0
|
|
if lastAny and (now - lastAny) < T.ASK_GAP then
|
|
return false
|
|
end
|
|
local last = asked and asked[name]
|
|
if last and (now - last) < T.REASK_AFTER then
|
|
return false
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- ==================================================================
|
|
-- WOW SECTION -- frame, event, sending
|
|
-- ==================================================================
|
|
if CreateFrame then
|
|
|
|
local acc = {} -- [sender] = accumulator, dropped once complete
|
|
local ranks = {} -- [sender] = { [talent] = rank }, the result
|
|
local readAt = {} -- [sender] = when that result landed
|
|
local asked = {} -- [name] = when we last asked
|
|
local lastAny = nil -- when we last asked anybody
|
|
local queue = {} -- names waiting to be asked
|
|
local queued = {} -- [name] = true, so the queue cannot hold dupes
|
|
|
|
local function enabled()
|
|
local d = DopingControlDB
|
|
-- default ON: an addon that computes hit and silently leaves out a
|
|
-- readable component is the bug this file fixes
|
|
return not (d and d.readTalents == false)
|
|
end
|
|
|
|
-- ranks for a player, or nil if we have not got a plausible reply yet.
|
|
-- nil (not {}) is load-bearing, exactly as for the own character:
|
|
-- "not read" and "read, no talents" are different states and only the
|
|
-- second one may count as a measured zero.
|
|
function T.RanksFor(name)
|
|
return ranks[name]
|
|
end
|
|
|
|
function T.ReadAt(name)
|
|
return readAt[name]
|
|
end
|
|
|
|
-- diagnostics for /dc talents
|
|
function T.Stats()
|
|
local nRanks, nPending = 0, 0
|
|
for _ in pairs(ranks) do
|
|
nRanks = nRanks + 1
|
|
end
|
|
for _ in pairs(acc) do
|
|
nPending = nPending + 1
|
|
end
|
|
return { known = nRanks, pending = nPending,
|
|
queued = table.getn(queue), enabled = enabled() }
|
|
end
|
|
|
|
function T.Ask(name)
|
|
if not enabled() or type(name) ~= "string" or name == "" then
|
|
return false
|
|
end
|
|
if not SendAddonMessage then
|
|
return false
|
|
end
|
|
-- pcall: a malformed name must never take the scan down with it
|
|
local ok = pcall(SendAddonMessage, T.PREFIX .. "<" .. name .. ">",
|
|
T.ASK_MSG, T.CHANNEL)
|
|
if ok then
|
|
asked[name] = GetTime()
|
|
lastAny = asked[name]
|
|
end
|
|
return ok
|
|
end
|
|
|
|
-- Called by the scan with the players it can see. Nothing is sent here
|
|
-- -- names are only lined up; the timer below paces them out.
|
|
function T.Request(names)
|
|
if not enabled() or type(names) ~= "table" then
|
|
return
|
|
end
|
|
local now = GetTime()
|
|
for i = 1, table.getn(names) do
|
|
local n = names[i]
|
|
if type(n) == "string" and n ~= "" and not queued[n]
|
|
and T.ShouldAsk(n, now, asked, nil) then
|
|
queued[n] = true
|
|
table.insert(queue, n)
|
|
end
|
|
end
|
|
end
|
|
|
|
local f = CreateFrame("Frame", "DopingControlTalentFrame")
|
|
f:RegisterEvent("CHAT_MSG_ADDON")
|
|
f:SetScript("OnEvent", function()
|
|
-- 1.12: arg1 prefix, arg2 message, arg3 channel, arg4 sender
|
|
if arg1 ~= T.PREFIX then
|
|
return
|
|
end
|
|
local sender = arg4
|
|
if type(sender) ~= "string" or sender == "" then
|
|
return
|
|
end
|
|
local kind, data = T.Parse(arg2)
|
|
if not kind then
|
|
return
|
|
end
|
|
local a = acc[sender]
|
|
if not a then
|
|
a = T.NewAcc()
|
|
acc[sender] = a
|
|
end
|
|
T.Feed(a, kind, data)
|
|
if a.complete then
|
|
acc[sender] = nil
|
|
if T.Plausible(a) then
|
|
ranks[sender] = a.ranks
|
|
readAt[sender] = GetTime()
|
|
-- a fresh talent set changes computed hit, so the view has
|
|
-- to be rebuilt -- without this the number only corrects
|
|
-- itself on the next scan
|
|
if DC_Matrix and DC_Matrix.Refresh then
|
|
DC_Matrix.Refresh()
|
|
end
|
|
end
|
|
end
|
|
end)
|
|
|
|
-- Paced sender: one request per T.ASK_GAP, and only ever from the
|
|
-- queue the scan filled. Deliberately a plain timer rather than a burst
|
|
-- after each scan -- 40 requests in one frame is what a rate limit
|
|
-- would punish, and nothing documents where that limit sits.
|
|
local elapsed = 0
|
|
f:SetScript("OnUpdate", function()
|
|
elapsed = elapsed + (arg1 or 0)
|
|
if elapsed < 1 then
|
|
return
|
|
end
|
|
elapsed = 0
|
|
if not enabled() or table.getn(queue) == 0 then
|
|
return
|
|
end
|
|
local now = GetTime()
|
|
if lastAny and (now - lastAny) < T.ASK_GAP then
|
|
return
|
|
end
|
|
local name = table.remove(queue, 1)
|
|
if name then
|
|
queued[name] = nil
|
|
if T.ShouldAsk(name, now, asked, lastAny) then
|
|
T.Ask(name)
|
|
end
|
|
end
|
|
end)
|
|
|
|
-- forget everything (respec, or a deliberate re-read)
|
|
function T.Clear()
|
|
acc = {}
|
|
ranks = {}
|
|
readAt = {}
|
|
asked = {}
|
|
queue = {}
|
|
queued = {}
|
|
lastAny = nil
|
|
end
|
|
|
|
end
|