DopingControl v0.6.1
This commit is contained in:
+496
@@ -0,0 +1,496 @@
|
||||
-- DopingControl scan/aura.lua
|
||||
-- Buff reading for one unit, two independent paths, merged:
|
||||
-- Path A (unit token): UnitBuff(unit, i), i = 1..32 -- buff NAME via a
|
||||
-- hidden tooltip (DopingControlScanTip, the standard 1.12
|
||||
-- hidden-tooltip scanner pattern), spell ID via the
|
||||
-- 4th return value (a SuperWoW extension; unverified on other client
|
||||
-- builds -- covered by the probe battery).
|
||||
-- UnitBuff compacts its list: stop at the first nil texture.
|
||||
-- Path B (GUID): GetUnitField(guid, "aura") -- nampower descriptor table,
|
||||
-- 48 ONE-BASED slots of spell ids (1-32 buffs, 33-48 debuffs). The
|
||||
-- table is SPARSE (in-game verified: e.g. slots
|
||||
-- 1,2,5,6 filled with 3,4 empty), so iterate all 32 buff slots and
|
||||
-- skip empties instead of breaking at the first gap.
|
||||
-- Both paths are pcall-wrapped per call (hard rule).
|
||||
--
|
||||
-- Merge (pure, offline-tested): union of the ID sets, names from path A.
|
||||
-- Buff icons: auras.textures[name] = icon path (UnitBuff return 1) for
|
||||
-- every path-A buff that yielded a name; ids without a name are OPTIONALLY
|
||||
-- resolved via SuperWoW SpellInfo(id) (return 3 = icon) when the function
|
||||
-- exists. textures is OPTIONAL/partial by design -- every consumer
|
||||
-- nil-guards, nothing downstream may require it.
|
||||
-- Disagreements between the two ID sets are counted (symmetric difference,
|
||||
-- only when BOTH paths yielded at least one ID -- an absent path is missing
|
||||
-- data, not a disagreement) and accumulated by the engine into
|
||||
-- DC.probeStats. Zero USABLE auras (name- or id-carrying) from both paths
|
||||
-- => aurasRead = false (a failed read, never "has nothing";
|
||||
-- texture-only path-A rows carry no evidence and do not count).
|
||||
--
|
||||
-- Unknown auras are collected, never silently dropped:
|
||||
-- an aura is unknown when neither its name is in DC.CONSUMABLES /
|
||||
-- DC.CLASSBUFFS nor its spell ID in DC.SPELL_TO_SLOT. Path-B-only IDs
|
||||
-- normally carry no name and are reported as "spell:<id>", UNLESS the
|
||||
-- caller supplies idNames (see A.Assemble below): with a resolved name,
|
||||
-- a path-B-only aura is judged the same way a path-A one is (known
|
||||
-- consumable/class-buff name => not unknown at all) and, if still
|
||||
-- unknown, is reported as that name instead of "spell:<id>" -- and only
|
||||
-- once, even when the same aura also showed up unresolved via path A
|
||||
-- (this is what keeps the same aura from being counted twice: once as a
|
||||
-- name from path A, once as "spell:<id>" from path B, which used to
|
||||
-- happen whenever path A never delivered a spell ID at all, making the
|
||||
-- id-based dedup a no-op).
|
||||
--
|
||||
-- DEBUFFS:
|
||||
-- ReadUnit additionally reads UnitDebuff(unit, i) 1..16 (same hidden
|
||||
-- tooltip via SetUnitDebuff, texture = return 1, spell id = 4th return if
|
||||
-- present) and the debuff half of the SAME GetUnitField "aura" fetch
|
||||
-- (one-based slots 33-48). AssembleDebuffs merges them into the store
|
||||
-- shape players[i].debuffs = { names, ids, textures } (sibling of auras).
|
||||
-- Debuff READABILITY rides on the existing aurasRead flag -- zero debuffs
|
||||
-- is a LEGITIMATE clean state (empty sets), NEVER a read failure, and it
|
||||
-- never influences aurasRead. Debuff names are NOT collected into
|
||||
-- DC.unknownSeen (that channel is consumable tracking);
|
||||
-- nothing is silently dropped either way, because the DEBUFFS tab builds
|
||||
-- its columns dynamically from exactly this data.
|
||||
--
|
||||
-- Pure Lua 5.0, dofile-loadable offline (WoW wiring only inside functions
|
||||
-- that run in-game; tooltip frame is created lazily).
|
||||
|
||||
DopingControl = DopingControl or {}
|
||||
local DC = DopingControl
|
||||
|
||||
DC_Aura = DC_Aura or {}
|
||||
local A = DC_Aura
|
||||
|
||||
-- ==================================================================
|
||||
-- Pure part
|
||||
-- ==================================================================
|
||||
|
||||
-- Assemble(rawA, rawB, idNames) -> auras, aurasRead, disagreements, unknown
|
||||
-- rawA : array of { name = string|nil, id = number|nil,
|
||||
-- texture = string|nil } (path A; texture is the 1st
|
||||
-- UnitBuff return, optional)
|
||||
-- rawB : array of spell ids (numbers, path B)
|
||||
-- idNames : OPTIONAL { [spellId] = "Buff Name" } for path-B ids,
|
||||
-- filled by the WoW-side caller (SuperWoW SpellInfo lookup --
|
||||
-- see A.ReadUnit). Omitted in tests/offline callers, in which
|
||||
-- case unknown path-B ids are reported as "spell:<id>" exactly
|
||||
-- as before (no behavior change without it).
|
||||
-- Returns:
|
||||
-- auras : { names = { [name]=true }, ids = { [id]=true },
|
||||
-- textures = { [name]=iconPath } }
|
||||
-- (store shape; textures may be empty/partial --
|
||||
-- consumers nil-guard)
|
||||
-- aurasRead : bool -- true iff at least one USABLE aura arrived: a
|
||||
-- path-A entry carrying a name or an id, or a path-B id.
|
||||
-- Entries with NEITHER (UnitBuff texture present but the
|
||||
-- tooltip read failed and no 4th return) are information-
|
||||
-- free: counting them would flip a partially failed read
|
||||
-- from UNKNOWN to all-slots-MISSING -- exactly what the
|
||||
-- "zero auras => read failed" rule
|
||||
-- exists to prevent.
|
||||
-- disagreements : number of ids present in exactly one of the two ID
|
||||
-- sets; 0 unless both sets are non-empty
|
||||
-- unknown : array of unknown-aura keys (name, or "spell:<id>")
|
||||
-- trim(s) -> s with leading/trailing whitespace stripped. Lua-5.0-safe:
|
||||
-- string.find with a captured "middle" group, no string-method syntax.
|
||||
local function trim(s)
|
||||
local _, _, middle = string.find(s, "^%s*(.-)%s*$")
|
||||
return middle
|
||||
end
|
||||
|
||||
-- isKnownAuraName(name, cons, cbuf) -> true if `name` is a recognized
|
||||
-- consumable/class-buff key -- tried EXACT first, then (only if that
|
||||
-- misses) its trimmed form. SpellInfo() aura names can carry stray
|
||||
-- leading/trailing whitespace on this client (measured live:
|
||||
-- SpellInfo(7254) = "Nature Protection " with a trailing space) that a
|
||||
-- hand-authored table key never has; without this fallback such a name
|
||||
-- never matches its own table entry. Exact-first matters because a
|
||||
-- table key legitimately containing whitespace (should one ever exist)
|
||||
-- must still win over a coincidental trimmed collision.
|
||||
local function isKnownAuraName(name, cons, cbuf)
|
||||
if not name then
|
||||
return false
|
||||
end
|
||||
if cons[name] or cbuf[name] then
|
||||
return true
|
||||
end
|
||||
local trimmed = trim(name)
|
||||
if trimmed ~= name and (cons[trimmed] or cbuf[trimmed]) then
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function A.Assemble(rawA, rawB, idNames)
|
||||
rawA = rawA or {}
|
||||
rawB = rawB or {}
|
||||
idNames = idNames or {}
|
||||
local names = {}
|
||||
local ids = {}
|
||||
local idsA = {}
|
||||
local idsB = {}
|
||||
local textures = {} -- [buffName] = icon path
|
||||
local namedIds = {} -- ids that arrived WITH a name (path A rows)
|
||||
local usable = 0 -- entries that carry actual evidence (see header)
|
||||
|
||||
local nA = table.getn(rawA)
|
||||
for i = 1, nA do
|
||||
local e = rawA[i]
|
||||
local hasInfo = false
|
||||
if e.name then
|
||||
names[e.name] = true
|
||||
hasInfo = true
|
||||
if e.texture then
|
||||
textures[e.name] = e.texture
|
||||
end
|
||||
end
|
||||
if type(e.id) == "number" and e.id > 0 then
|
||||
idsA[e.id] = true
|
||||
ids[e.id] = true
|
||||
hasInfo = true
|
||||
if e.name then
|
||||
namedIds[e.id] = true
|
||||
end
|
||||
end
|
||||
if hasInfo then
|
||||
usable = usable + 1
|
||||
end
|
||||
end
|
||||
|
||||
local nB = table.getn(rawB)
|
||||
for i = 1, nB do
|
||||
local id = rawB[i]
|
||||
if type(id) == "number" and id > 0 then
|
||||
idsB[id] = true
|
||||
ids[id] = true
|
||||
usable = usable + 1
|
||||
end
|
||||
end
|
||||
|
||||
local aurasRead = usable > 0
|
||||
|
||||
-- Optional icon resolution for ids that carried no name (path-B-only
|
||||
-- ids, or nameless path-A rows): SuperWoW SpellInfo(id) -> name, rank,
|
||||
-- icon (3rd return). Runtime-only enhancement -- SpellInfo is nil in
|
||||
-- offline tests and may be absent in-game (non-SuperWoW client);
|
||||
-- textures simply stays partial then. Path-A textures win (never
|
||||
-- overwritten): UnitBuff saw the actual aura, SpellInfo is a lookup.
|
||||
if SpellInfo then
|
||||
for id in pairs(ids) do
|
||||
if not namedIds[id] then
|
||||
local okSI, nm, _, icon = pcall(SpellInfo, id)
|
||||
if okSI and nm and icon and textures[nm] == nil then
|
||||
textures[nm] = icon
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- disagreement count: symmetric difference of the ID sets, but only
|
||||
-- when both paths actually delivered IDs (else it is path absence)
|
||||
local disagreements = 0
|
||||
if next(idsA) ~= nil and next(idsB) ~= nil then
|
||||
for id in pairs(idsA) do
|
||||
if not idsB[id] then
|
||||
disagreements = disagreements + 1
|
||||
end
|
||||
end
|
||||
for id in pairs(idsB) do
|
||||
if not idsA[id] then
|
||||
disagreements = disagreements + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- unknown-aura collection (no silent discard)
|
||||
local unknown = {}
|
||||
local unknownNamesA = {} -- names already added to `unknown` from path A
|
||||
local cons = DC.CONSUMABLES or {}
|
||||
local cbuf = DC.CLASSBUFFS or {}
|
||||
local s2s = DC.SPELL_TO_SLOT or {}
|
||||
for i = 1, nA do
|
||||
local e = rawA[i]
|
||||
-- name-known OR id-known -> known. Deliberately NOT an
|
||||
-- if/elseif chain gated on "no name": a row can carry a name
|
||||
-- that fails to match a table key exactly (SpellInfo() aura-
|
||||
-- name spelling vs. the hand-authored table key) while still
|
||||
-- carrying a correct spell id -- the id must be checked
|
||||
-- regardless of whether a name is present (belied a 26-man
|
||||
-- raid: a correctly ID-tracked consumable was discarded 38
|
||||
-- times because its live aura-name spelling did not match the
|
||||
-- table key it was filed under).
|
||||
local known = isKnownAuraName(e.name, cons, cbuf)
|
||||
if not known and type(e.id) == "number" and s2s[e.id] then
|
||||
known = true
|
||||
end
|
||||
if not known then
|
||||
if e.name then
|
||||
table.insert(unknown, e.name)
|
||||
unknownNamesA[e.name] = true
|
||||
elseif e.id then
|
||||
table.insert(unknown, "spell:" .. e.id)
|
||||
end
|
||||
-- entry with neither name nor id carries no information: skip
|
||||
end
|
||||
end
|
||||
-- path-B-only ids: with a resolved name (idNames, WoW-side SpellInfo
|
||||
-- lookup) an id is judged by name exactly like a path-A entry --
|
||||
-- known consumable/class-buff name is not unknown, and a name already
|
||||
-- reported from path A is not reported again (this is the dedup: on
|
||||
-- clients where UnitBuff never yields a spell ID, idsA stays empty
|
||||
-- and the id-based check below is a no-op, so the same aura would
|
||||
-- otherwise be counted once as a name and once as "spell:<id>").
|
||||
-- Without a resolved name, behavior is unchanged: "spell:<id>".
|
||||
for i = 1, nB do
|
||||
local id = rawB[i]
|
||||
if type(id) == "number" and id > 0 and not idsA[id] and not s2s[id] then
|
||||
local nm = idNames[id]
|
||||
if not nm then
|
||||
table.insert(unknown, "spell:" .. id)
|
||||
elseif isKnownAuraName(nm, cons, cbuf) then
|
||||
-- known by resolved name (exact or trimmed -- same rule
|
||||
-- as the path-A loop above): not unknown, nothing to add
|
||||
elseif unknownNamesA[nm] then
|
||||
-- already reported via path A under this name: skip
|
||||
else
|
||||
table.insert(unknown, nm)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return { names = names, ids = ids, textures = textures },
|
||||
aurasRead, disagreements, unknown
|
||||
end
|
||||
|
||||
-- SplitAuraField(t) -> buffIds, debuffIds (two arrays of spell ids)
|
||||
-- Splits ONE GetUnitField(guid, "aura") descriptor table into its buff
|
||||
-- half (one-based slots 1-32) and debuff half (slots 33-48). The table is
|
||||
-- SPARSE (in-game verified, see header) -- every slot is visited, empties
|
||||
-- and non-positive/non-number values are skipped, no break at gaps.
|
||||
-- Pure -- offline-tested in test_scan_parse.lua.
|
||||
function A.SplitAuraField(t)
|
||||
local buffs = {}
|
||||
local debuffs = {}
|
||||
if type(t) == "table" then
|
||||
for i = 1, 32 do
|
||||
local v = t[i]
|
||||
if type(v) == "number" and v > 0 then
|
||||
table.insert(buffs, v)
|
||||
end
|
||||
end
|
||||
for i = 33, 48 do
|
||||
local v = t[i]
|
||||
if type(v) == "number" and v > 0 then
|
||||
table.insert(debuffs, v)
|
||||
end
|
||||
end
|
||||
end
|
||||
return buffs, debuffs
|
||||
end
|
||||
|
||||
-- AssembleDebuffs(rawDA, rawDB) -> debuffs
|
||||
-- rawDA : array of { name = string|nil, id = number|nil,
|
||||
-- texture = string|nil } (UnitDebuff path, same row
|
||||
-- shape as the buff path A)
|
||||
-- rawDB : array of spell ids (GetUnitField debuff half, slots 33-48)
|
||||
-- Returns the store shape:
|
||||
-- { names = { [name]=true }, ids = { [id]=true },
|
||||
-- textures = { [name]=iconPath } }
|
||||
-- Merge mirrors A.Assemble: union of the ID sets, names + textures from
|
||||
-- the tooltip path, optional SpellInfo icon resolution for nameless ids.
|
||||
-- NO read flag is derived here (readability = the existing aurasRead
|
||||
-- flag; empty sets are a legitimate clean state) and NO unknown list is
|
||||
-- returned (debuffs never feed DC.unknownSeen -- see header).
|
||||
function A.AssembleDebuffs(rawDA, rawDB)
|
||||
rawDA = rawDA or {}
|
||||
rawDB = rawDB or {}
|
||||
local names = {}
|
||||
local ids = {}
|
||||
local textures = {}
|
||||
local namedIds = {}
|
||||
|
||||
local nA = table.getn(rawDA)
|
||||
for i = 1, nA do
|
||||
local e = rawDA[i]
|
||||
if e.name then
|
||||
names[e.name] = true
|
||||
if e.texture then
|
||||
textures[e.name] = e.texture
|
||||
end
|
||||
end
|
||||
if type(e.id) == "number" and e.id > 0 then
|
||||
ids[e.id] = true
|
||||
if e.name then
|
||||
namedIds[e.id] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local nB = table.getn(rawDB)
|
||||
for i = 1, nB do
|
||||
local id = rawDB[i]
|
||||
if type(id) == "number" and id > 0 then
|
||||
ids[id] = true
|
||||
end
|
||||
end
|
||||
|
||||
-- optional icon resolution for nameless ids (mirrors A.Assemble:
|
||||
-- SpellInfo is a SuperWoW runtime lookup, nil offline; tooltip-path
|
||||
-- textures are never overwritten)
|
||||
if SpellInfo then
|
||||
for id in pairs(ids) do
|
||||
if not namedIds[id] then
|
||||
local okSI, nm, _, icon = pcall(SpellInfo, id)
|
||||
if okSI and nm and icon and textures[nm] == nil then
|
||||
textures[nm] = icon
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return { names = names, ids = ids, textures = textures }
|
||||
end
|
||||
|
||||
-- ==================================================================
|
||||
-- WoW part (runtime only; every call pcall-wrapped)
|
||||
-- ==================================================================
|
||||
|
||||
-- Hidden tooltip scanner (standard 1.12 pattern).
|
||||
-- The frame is NAMED, and the name is REQUIRED rather than cosmetic: the
|
||||
-- line FontStrings are read back via getglobal("<name>TextLeft1").
|
||||
local scanTip = nil
|
||||
|
||||
local function ensureTip()
|
||||
if not scanTip and CreateFrame then
|
||||
scanTip = CreateFrame("GameTooltip", "DopingControlScanTip",
|
||||
nil, "GameTooltipTemplate")
|
||||
scanTip:SetOwner(WorldFrame, "ANCHOR_NONE")
|
||||
end
|
||||
return scanTip
|
||||
end
|
||||
|
||||
-- shared accessor (core/probe.lua reuses the same hidden tooltip instead
|
||||
-- of creating a second frame under the same global name)
|
||||
function A.GetScanTip()
|
||||
return ensureTip()
|
||||
end
|
||||
|
||||
-- raises an error on bad units -> always called through pcall
|
||||
local function tipBuffName(unit, buffIndex)
|
||||
scanTip:ClearLines()
|
||||
scanTip:SetUnitBuff(unit, buffIndex)
|
||||
local textObj = getglobal("DopingControlScanTipTextLeft1")
|
||||
if textObj then
|
||||
return textObj:GetText()
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- debuff twin of tipBuffName (same hidden tooltip, SetUnitDebuff);
|
||||
-- raises an error on bad units -> always called through pcall
|
||||
local function tipDebuffName(unit, debuffIndex)
|
||||
scanTip:ClearLines()
|
||||
scanTip:SetUnitDebuff(unit, debuffIndex)
|
||||
local textObj = getglobal("DopingControlScanTipTextLeft1")
|
||||
if textObj then
|
||||
return textObj:GetText()
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ReadUnit(unit, guid) -> rawA, rawB, rawDA, rawDB, idNames
|
||||
-- rawA/rawB : buff shapes as taken by A.Assemble
|
||||
-- rawDA/rawDB: debuff shapes as taken by A.AssembleDebuffs (rawDA from
|
||||
-- the UnitDebuff 1..16 loop, rawDB = debuff half slots
|
||||
-- 33-48 of the SAME GetUnitField fetch as rawB)
|
||||
-- idNames : { [spellId] = "Buff Name" } for the ids in rawB, resolved
|
||||
-- via SuperWoW SpellInfo (same lookup A.Assemble already
|
||||
-- uses for icons); taken by A.Assemble's optional 3rd
|
||||
-- parameter. Extra return value -- existing callers that
|
||||
-- only capture rawA..rawDB are unaffected.
|
||||
function A.ReadUnit(unit, guid)
|
||||
local rawA = {}
|
||||
local rawDA = {}
|
||||
if UnitBuff then
|
||||
local tip = ensureTip()
|
||||
for i = 1, 32 do
|
||||
-- 4th return = SuperWoW spell id (unverified on other builds; probe Q1)
|
||||
local ok, texture, stacks, dtype, auraID = pcall(UnitBuff, unit, i)
|
||||
if not ok or not texture then
|
||||
break
|
||||
end
|
||||
local name = nil
|
||||
if tip then
|
||||
local ok2, nm = pcall(tipBuffName, unit, i)
|
||||
if ok2 then
|
||||
name = nm
|
||||
end
|
||||
end
|
||||
local id = nil
|
||||
if type(auraID) == "number" and auraID > 0 then
|
||||
id = auraID
|
||||
end
|
||||
-- texture (UnitBuff return 1) rides along so Assemble can key
|
||||
-- it under the tooltip name (auras.textures)
|
||||
table.insert(rawA, { name = name, id = id, texture = texture })
|
||||
end
|
||||
end
|
||||
if UnitDebuff then
|
||||
-- mirror of the buff loop: 16 debuff slots on 1.12, list is
|
||||
-- compacted (stop at the first nil texture), name via the same
|
||||
-- hidden tooltip, spell id via 4th return if present
|
||||
local tip = ensureTip()
|
||||
for i = 1, 16 do
|
||||
local ok, texture, stacks, dtype, auraID = pcall(UnitDebuff, unit, i)
|
||||
if not ok or not texture then
|
||||
break
|
||||
end
|
||||
local name = nil
|
||||
if tip then
|
||||
local ok2, nm = pcall(tipDebuffName, unit, i)
|
||||
if ok2 then
|
||||
name = nm
|
||||
end
|
||||
end
|
||||
local id = nil
|
||||
if type(auraID) == "number" and auraID > 0 then
|
||||
id = auraID
|
||||
end
|
||||
table.insert(rawDA, { name = name, id = id, texture = texture })
|
||||
end
|
||||
end
|
||||
|
||||
local rawB = {}
|
||||
local rawDB = {}
|
||||
if GetUnitField and guid then
|
||||
local ok, t = pcall(GetUnitField, guid, "aura")
|
||||
if ok and type(t) == "table" then
|
||||
-- ONE fetch carries both halves (sparse 48-slot table):
|
||||
-- buffs 1-32, debuffs 33-48 -- split without extra API calls
|
||||
rawB, rawDB = A.SplitAuraField(t)
|
||||
end
|
||||
end
|
||||
|
||||
-- name resolution for path-B ids (SuperWoW SpellInfo lookup, same
|
||||
-- pattern/guarding as the icon resolution in A.Assemble): path-B ids
|
||||
-- never carry a name of their own, so every id here is a candidate.
|
||||
-- Runtime-only, pcall-wrapped, nil-safe; stays empty offline/without
|
||||
-- SuperWoW, which leaves A.Assemble's unknown-aura reporting exactly
|
||||
-- as before ("spell:<id>").
|
||||
local idNames = {}
|
||||
if SpellInfo then
|
||||
local nB = table.getn(rawB)
|
||||
for i = 1, nB do
|
||||
local id = rawB[i]
|
||||
if type(id) == "number" and id > 0 then
|
||||
local okSI, nm = pcall(SpellInfo, id)
|
||||
if okSI and nm then
|
||||
idNames[id] = nm
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return rawA, rawB, rawDA, rawDB, idNames
|
||||
end
|
||||
+1103
File diff suppressed because it is too large
Load Diff
+222
@@ -0,0 +1,222 @@
|
||||
-- DopingControl scan/gear.lua
|
||||
-- Equipment + enchant reading for one unit: GetInventoryItemLink for the 16
|
||||
-- equipment-tab slots (DC.SLOTS_EQUIPMENT), enchant id parsed from the item
|
||||
-- link ("item:ID:ENCHANT:..."), item details via GetItemInfo with the
|
||||
-- MEASURED Turtle 1.18.1 return order
|
||||
-- : 1 name, 2 link, 3 QUALITY, 4 reqLevel, 5 itemType,
|
||||
-- 6 ITEMSUBTYPE, 7 maxStack, 8 equipSlot, 9 ICONPATH, 10 vendorPrice.
|
||||
-- Reading position 4 as itemLevel or 10 as texture breaks SILENTLY with
|
||||
-- plausible-looking values -- do not "fix" this back to the vanilla order.
|
||||
--
|
||||
-- Readability rule (binding):
|
||||
-- ANY slot returned a link => gearRead = true; slots with nil => "EMPTY"
|
||||
-- ALL 13 slots returned nil => gearRead = false (failed read, NOT naked)
|
||||
-- A link that came back but does not parse leaves its slot nil while
|
||||
-- gearRead stays true -- core/model.lua shows that cell as UNKNOWN
|
||||
-- ("no data"), never as MISSING (resolution: a parse anomaly must not be
|
||||
-- claimed as "no item").
|
||||
--
|
||||
-- Pure Lua 5.0, dofile-loadable offline: ParseLink / ParseLinkName /
|
||||
-- ParseWeaponEnchantLine / Assemble are pure (offline-tested);
|
||||
-- ReadUnit, ItemInfo and ReadSelfWeaponEnchantName
|
||||
-- touch the WoW API and run in-game only, pcall-wrapped.
|
||||
|
||||
DopingControl = DopingControl or {}
|
||||
local DC = DopingControl
|
||||
|
||||
DC_Gear = DC_Gear or {}
|
||||
local G = DC_Gear
|
||||
|
||||
-- Fallback inventory-slot list (= invSlot column of DC.SLOTS_EQUIPMENT,
|
||||
-- data/enchants.lua) so this file works standalone in tests.
|
||||
local FALLBACK_INV_SLOTS = { 1, 2, 3, 15, 5, 9, 10, 7, 8, 16, 17, 18, 11, 12, 13, 14 }
|
||||
|
||||
-- InvSlots() -> array of the 16 inventory slot numbers, in column order.
|
||||
-- Prefers DC.SLOTS_EQUIPMENT (single source of truth once data/ is loaded).
|
||||
function G.InvSlots()
|
||||
if DC.SLOTS_EQUIPMENT then
|
||||
local out = {}
|
||||
local n = table.getn(DC.SLOTS_EQUIPMENT)
|
||||
for i = 1, n do
|
||||
table.insert(out, DC.SLOTS_EQUIPMENT[i].invSlot)
|
||||
end
|
||||
return out
|
||||
end
|
||||
return FALLBACK_INV_SLOTS
|
||||
end
|
||||
|
||||
-- ==================================================================
|
||||
-- Pure part
|
||||
-- ==================================================================
|
||||
|
||||
-- ParseLink(link) -> itemId, enchantId (numbers) | nil, nil on malformed.
|
||||
-- Enchant id is the 2nd field of the link payload (standard 1.12 link
|
||||
-- pattern, widened to also capture the item id).
|
||||
function G.ParseLink(link)
|
||||
if type(link) ~= "string" then
|
||||
return nil, nil
|
||||
end
|
||||
local _, _, idStr, enchStr = string.find(link, "item:(%d+):(%d+)")
|
||||
if not idStr then
|
||||
return nil, nil
|
||||
end
|
||||
return tonumber(idStr), tonumber(enchStr)
|
||||
end
|
||||
|
||||
-- ParseLinkName(link) -> "[bracket] name" | nil (fallback when GetItemInfo
|
||||
-- has no cached data yet; standard 1.12 pattern).
|
||||
function G.ParseLinkName(link)
|
||||
if type(link) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local _, _, name = string.find(link, "%[(.+)%]")
|
||||
return name
|
||||
end
|
||||
|
||||
-- ParseWeaponEnchantLine(text) -> name|nil (pure).
|
||||
-- The main-hand tooltip's temporary-enchant line reads "<Name> (N min)" on
|
||||
-- the English client (e.g. "Dense Sharpening Stone (28 min)") -- strip the
|
||||
-- "(N min)" part, keep the name. Any other line (item name, "Speed 2.70",
|
||||
-- "+9 Damage", a bare "(28 min)") and any non-string input -> nil.
|
||||
function G.ParseWeaponEnchantLine(text)
|
||||
if type(text) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local _, _, name = string.find(text, "^(.-)%s*%((%d+) min%)%s*$")
|
||||
if name and name ~= "" then
|
||||
return name
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Assemble(rawLinks, slots, itemInfo) -> gear, gearRead
|
||||
-- rawLinks : { [invSlot] = link string | nil }
|
||||
-- slots : array of invSlot numbers (default G.InvSlots())
|
||||
-- itemInfo : nil, or function(itemId) -> name, quality, subType, texture
|
||||
-- (already de-shifted; in-game wrapper is G.ItemInfo, tests
|
||||
-- pass a stub)
|
||||
-- Returns the store shape:
|
||||
-- gearRead = false => gear = nil
|
||||
-- gearRead = true => gear[invSlot] = { id, enchant, quality, subType,
|
||||
-- texture, name } | "EMPTY" (no link) | nil
|
||||
-- (link present but unparseable -> UNKNOWN cell)
|
||||
-- The subType field feeds DC.ENCH_EXEMPT in core/model.lua (wand/holdable
|
||||
-- exemptions); the model explicitly expects it.
|
||||
function G.Assemble(rawLinks, slots, itemInfo)
|
||||
rawLinks = rawLinks or {}
|
||||
slots = slots or G.InvSlots()
|
||||
local n = table.getn(slots)
|
||||
|
||||
local gear = {}
|
||||
local any = false
|
||||
for i = 1, n do
|
||||
local inv = slots[i]
|
||||
local link = rawLinks[inv]
|
||||
if link ~= nil then
|
||||
any = true
|
||||
local id, ench = G.ParseLink(link)
|
||||
if id then
|
||||
local entry = { id = id, enchant = ench }
|
||||
if itemInfo then
|
||||
local name, quality, subType, texture = itemInfo(id)
|
||||
entry.name = name
|
||||
entry.quality = quality
|
||||
entry.subType = subType
|
||||
entry.texture = texture
|
||||
end
|
||||
if not entry.name then
|
||||
entry.name = G.ParseLinkName(link)
|
||||
end
|
||||
gear[inv] = entry
|
||||
end
|
||||
-- unparseable link: slot stays nil (UNKNOWN), see header
|
||||
end
|
||||
end
|
||||
|
||||
if not any then
|
||||
return nil, false
|
||||
end
|
||||
for i = 1, n do
|
||||
local inv = slots[i]
|
||||
if rawLinks[inv] == nil then
|
||||
gear[inv] = "EMPTY"
|
||||
end
|
||||
end
|
||||
return gear, true
|
||||
end
|
||||
|
||||
-- ==================================================================
|
||||
-- WoW part (runtime only; every foreign-unit call pcall-wrapped)
|
||||
-- ==================================================================
|
||||
|
||||
-- ItemInfo(itemId) -> name, quality, subType, texture | nil (uncached/err).
|
||||
-- De-shifts the Turtle 1.18.1 GetItemInfo order (see header).
|
||||
function G.ItemInfo(itemId)
|
||||
if not GetItemInfo then
|
||||
return nil
|
||||
end
|
||||
local ok, name, link, quality, reqLevel, itemType, subType,
|
||||
maxStack, equipSlot, iconPath = pcall(GetItemInfo, itemId)
|
||||
if not ok or not name then
|
||||
return nil
|
||||
end
|
||||
return name, quality, subType, iconPath
|
||||
end
|
||||
|
||||
-- ReadSelfWeaponEnchantName() -> name|nil (WoW runtime only).
|
||||
-- Self path for store.weaponMainName: GetWeaponEnchantInfo() carries
|
||||
-- no NAME for the own unit, but the equipped main hand's tooltip lists the
|
||||
-- temp enchant as its own "<Name> (N min)" line (English client). Scans
|
||||
-- the shared hidden tooltip (scan/aura.lua accessor -- never a second
|
||||
-- frame under the same global name) over SetInventoryItem("player", 16)
|
||||
-- and returns the first line that parses; nil-safe when no such line.
|
||||
function G.ReadSelfWeaponEnchantName()
|
||||
local tip = DC_Aura and DC_Aura.GetScanTip and DC_Aura.GetScanTip()
|
||||
if not tip then
|
||||
return nil
|
||||
end
|
||||
local ok = pcall(function()
|
||||
tip:ClearLines()
|
||||
tip:SetInventoryItem("player", 16)
|
||||
end)
|
||||
if not ok then
|
||||
return nil
|
||||
end
|
||||
local n = 30
|
||||
if tip.NumLines then
|
||||
local ok2, v = pcall(tip.NumLines, tip)
|
||||
if ok2 and type(v) == "number" then
|
||||
n = v
|
||||
end
|
||||
end
|
||||
for i = 1, n do
|
||||
local obj = getglobal("DopingControlScanTipTextLeft" .. i)
|
||||
if obj then
|
||||
local name = G.ParseWeaponEnchantLine(obj:GetText())
|
||||
if name then
|
||||
return name
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ReadUnit(unit) -> rawLinks table for G.Assemble.
|
||||
-- Vanilla serves GetInventoryItemLink only for the own/inspected unit;
|
||||
-- SuperWoW serves it for all friendly units (unverified beyond sight,
|
||||
-- probe questions 4/5) -- either way nil slots are handled by the
|
||||
-- EMPTY-vs-gearRead rule above, so no capability branching here.
|
||||
function G.ReadUnit(unit)
|
||||
local raw = {}
|
||||
if GetInventoryItemLink then
|
||||
local slots = G.InvSlots()
|
||||
local n = table.getn(slots)
|
||||
for i = 1, n do
|
||||
local ok, link = pcall(GetInventoryItemLink, unit, slots[i])
|
||||
if ok and link then
|
||||
raw[slots[i]] = link
|
||||
end
|
||||
end
|
||||
end
|
||||
return raw
|
||||
end
|
||||
+1052
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
-- DopingControl scan/reach.lua
|
||||
-- Unit readability probe: online FALLBACK via UnitIsConnected, distance via
|
||||
-- UnitXP_SP3 "distanceBetween". Distance is DIAGNOSTIC ONLY -- it is written
|
||||
-- into the store but NEVER used as a filter (readability is
|
||||
-- decided from the read RESULT, not from a range assumption).
|
||||
--
|
||||
-- ONLINE IS A FALLBACK ONLY (measured
|
||||
-- in-game): the raid roster online flag (GetRaidRosterInfo return 8) is
|
||||
-- SERVER TRUTH and wins in scan/engine.lua scanOne -- UnitIsConnected can
|
||||
-- read nil mid-zoning while the player is actually online (false-offline
|
||||
-- bug). The reach online result is only consulted for roster entries
|
||||
-- WITHOUT a roster flag (the party path).
|
||||
--
|
||||
-- Pure Lua 5.0, dofile-loadable offline: the WoW calls live in ReadUnit,
|
||||
-- which is only invoked in-game; Normalize is pure and offline-tested.
|
||||
|
||||
DopingControl = DopingControl or {}
|
||||
local DC = DopingControl
|
||||
|
||||
DC_Reach = DC_Reach or {}
|
||||
local R = DC_Reach
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Normalize(haveOnline, onlineRaw, distOk, distVal) -> online, distance
|
||||
-- haveOnline : bool -- UnitIsConnected existed AND its pcall succeeded.
|
||||
-- UnitIsConnected returns 1/nil, where nil is read
|
||||
-- as offline -- BUT it can also read nil mid-zoning
|
||||
-- while the player is online (measured in-game),
|
||||
-- which is why the engine only uses this value as a
|
||||
-- fallback when there is no roster online flag.
|
||||
-- onlineRaw : raw UnitIsConnected return (1/nil/false)
|
||||
-- distOk : pcall success of UnitXP("distanceBetween", ...)
|
||||
-- distVal : its first return
|
||||
-- Returns:
|
||||
-- online : true|false|nil -- nil = could not determine; the caller
|
||||
-- (engine scanOne) consults this at all
|
||||
-- only for entries without a roster flag
|
||||
-- distance : number|nil -- nil = not measurable (no UnitXP_SP3, or
|
||||
-- the call failed / returned a non-number)
|
||||
-- ------------------------------------------------------------------
|
||||
function R.Normalize(haveOnline, onlineRaw, distOk, distVal)
|
||||
local online = nil
|
||||
if haveOnline then
|
||||
if onlineRaw then
|
||||
online = true
|
||||
else
|
||||
online = false
|
||||
end
|
||||
end
|
||||
local distance = nil
|
||||
if distOk and type(distVal) == "number" then
|
||||
distance = distVal
|
||||
end
|
||||
return online, distance
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- ReadUnit(unit) -> online, distance (WoW runtime only)
|
||||
-- Every foreign-unit call is pcall-wrapped (hard rule).
|
||||
-- Missing UnitXP_SP3 only drops the diagnostic distance
|
||||
-- -- the online read runs regardless.
|
||||
-- ------------------------------------------------------------------
|
||||
function R.ReadUnit(unit)
|
||||
local haveOnline = false
|
||||
local onlineRaw = nil
|
||||
if UnitIsConnected then
|
||||
local ok, v = pcall(UnitIsConnected, unit)
|
||||
if ok then
|
||||
haveOnline = true
|
||||
onlineRaw = v
|
||||
end
|
||||
end
|
||||
local distOk = false
|
||||
local distVal = nil
|
||||
if UnitXP then
|
||||
distOk, distVal = pcall(UnitXP, "distanceBetween", "player", unit)
|
||||
end
|
||||
return R.Normalize(haveOnline, onlineRaw, distOk, distVal)
|
||||
end
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
-- DopingControl scan/resist.lua
|
||||
-- Pure resistance-reconstruction math: turn item TOOLTIP lines into a
|
||||
-- per-school sum, and look up a race's innate bonus. Mirrors scan/hit.lua's
|
||||
-- pure half (ParseHitLine/SumGear) one level down -- the constants live in
|
||||
-- data/resist.lua, this file only turns them into behavior.
|
||||
--
|
||||
-- NO WoW-glue function lives here on purpose: unlike scan/hit.lua/scan/
|
||||
-- gear.lua/scan/aura.lua, this module never calls SetInventoryItem itself.
|
||||
-- The RESIST tab's numbers are reconstructed from tooltip lines
|
||||
-- scan/hit.lua's item scan ALREADY fetched (see that file's itemHit()) --
|
||||
-- one more table walk over data already in hand, not a second tooltip
|
||||
-- operation per item. That is the entire point of hanging resistance
|
||||
-- reconstruction off the HIT tab's existing per-item cache instead of
|
||||
-- building an independent RESIST scan pass: 26 players x 19 slots would be
|
||||
-- ~500 extra SetInventoryItem calls per scan, and scan/hit.lua already pays
|
||||
-- that cost once per unique item.
|
||||
--
|
||||
-- Pure Lua 5.0, no WoW API, dofile-loadable offline: everything here is
|
||||
-- offline-tested (test_resist.lua).
|
||||
|
||||
DopingControl = DopingControl or {}
|
||||
local DC = DopingControl
|
||||
|
||||
DC_Resist = DC_Resist or {}
|
||||
local R = DC_Resist
|
||||
|
||||
-- ParseResistLine(text) -> kind, value | nil
|
||||
-- kind : a DC.RESIST_SCHOOLS name ("Fire", "Nature", ...) or "all"
|
||||
-- value : the flat resistance amount as a number
|
||||
-- Returns nil (a single value) when no pattern matches -- an unrecognized
|
||||
-- phrasing is ignored, never guessed at (same contract as
|
||||
-- DC_Hit.ParseHitLine).
|
||||
function R.ParseResistLine(text)
|
||||
if type(text) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local pats = DC.RESIST_PATTERNS
|
||||
if type(pats) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
local n = table.getn(pats)
|
||||
for i = 1, n do
|
||||
local e = pats[i]
|
||||
local _, _, cap = string.find(text, e.pattern)
|
||||
if cap then
|
||||
local v = tonumber(cap)
|
||||
if v then
|
||||
return e.kind, v
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- SumLines(lines) -> sum
|
||||
-- lines : the FULL tooltip line array of ONE item, element 1 = item name
|
||||
-- (same convention as scan/hit.lua's itemHit -- line 1 is never
|
||||
-- a stat line, so the scan starts at 2; a caller that already
|
||||
-- stripped the name may safely pass an array starting at the
|
||||
-- real first stat line too, since no genuine item name matches
|
||||
-- "^%+%d+ <School> Resistance" or "^Equip: %+%d+ All
|
||||
-- Resistances" in the first place).
|
||||
-- sum : { [UnitResistance school index] = total } -- ONLY schools
|
||||
-- that actually appeared; a plain item with no resistance stat
|
||||
-- returns {} (empty, not nil) -- an empty sum is a real "this
|
||||
-- item carries no resistance", exactly like SumGear's zero.
|
||||
-- The "All Resistances" trap this table exists to close: a naive parser
|
||||
-- keyed only on the five single-school patterns would silently skip
|
||||
-- "Equip: +8 All Resistances." and undercount every one of the five
|
||||
-- columns by that amount. Handled here by fanning ONE matched line out
|
||||
-- over every entry of DC.RESIST_SCHOOLS.
|
||||
function R.SumLines(lines)
|
||||
local sum = {}
|
||||
lines = lines or {}
|
||||
local n = table.getn(lines)
|
||||
for i = 2, n do
|
||||
local kind, v = R.ParseResistLine(lines[i])
|
||||
if kind then
|
||||
if kind == "all" then
|
||||
local schools = DC.RESIST_SCHOOLS or {}
|
||||
for s = 1, table.getn(schools) do
|
||||
local school = schools[s].school
|
||||
sum[school] = (sum[school] or 0) + v
|
||||
end
|
||||
else
|
||||
local num = DC.RESIST_SCHOOL_NUM and DC.RESIST_SCHOOL_NUM[kind]
|
||||
if num then
|
||||
sum[num] = (sum[num] or 0) + v
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return sum
|
||||
end
|
||||
|
||||
-- RaceBonus(race) -> { [UnitResistance school index] = bonus }
|
||||
-- ALWAYS returns a table (empty for "no bonus", an unrecognized race
|
||||
-- token/display name, or nil input) so callers can fold it onto a gear sum
|
||||
-- without a nil check. The returned table is a FRESH COPY, never the
|
||||
-- shared DC.RACE_RESIST entry itself -- DC.RACE_RESIST aliases two
|
||||
-- spellings of the same race to ONE table object, so handing that
|
||||
-- reference out would let a caller's in-place addition (E.AssembleResists
|
||||
-- folds this straight onto the gear sum) corrupt the shared constant for
|
||||
-- every future lookup of that race.
|
||||
function R.RaceBonus(race)
|
||||
local out = {}
|
||||
local tbl = race and DC.RACE_RESIST and DC.RACE_RESIST[race]
|
||||
if tbl then
|
||||
for school, v in pairs(tbl) do
|
||||
out[school] = v
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
@@ -0,0 +1,375 @@
|
||||
-- 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
|
||||
Reference in New Issue
Block a user