DopingControl v0.6.1

This commit is contained in:
2026-08-03 09:47:19 +02:00
commit 6859d73285
41 changed files with 15056 additions and 0 deletions
+210
View File
@@ -0,0 +1,210 @@
-- DopingControl core/config.lua
-- SavedVariables defaults + fill-in: single DEFAULTS table, per-field fill-in
-- with nil-checks (NEVER 'or' for booleans -- would flip saved false),
-- copy-on-assign for table values so editing a live value can never mutate
-- the defaults.
-- Pure Lua 5.0, no WoW API -- dofile-loadable offline.
DopingControl = DopingControl or {}
local DC = DopingControl
-- SavedVariables root (the client replaces this global at ADDON_LOADED;
-- core/init.lua then calls DC.EnsureDefaults(DopingControlDB)).
DopingControlDB = DopingControlDB or {}
-- ------------------------------------------------------------------
-- Defaults
--
-- Two keys intentionally have NO entry here (a nil value cannot live in a
-- Lua table):
-- framePos -- default nil = "no saved position, use the anchor
-- default"; written by the UI on drag as
-- { point=..., relPoint=..., x=..., y=... }.
-- expectations -- default nil = "not initialized yet"; EnsureDefaults
-- special-cases it below: the saved shape
-- is PER CLASS (db.expectations[role][class][slotId]),
-- materialized/migrated from the DC.DEFAULT_EXPECT seed
-- by DC.EnsureExpectations.
-- ------------------------------------------------------------------
DC.DEFAULTS = {
testMode = false, -- /dc test: simulated raid
showMinimapButton = true,
minimapAngle = 215, -- degrees around the minimap rim
readyCheckScan = true, -- auto-scan on READY_CHECK
readyCheckOpen = true, -- auto-open window on READY_CHECK
collapse = {}, -- per-role collapsed flags, e.g. collapse.RANGED = true
filterGapsOnly = false, -- toolbar filter All | Gaps only
roles = {}, -- confirmed roles keyed by player NAME
-- Weapon skill books: db.skillBooks[playerName][weaponSkillName] =
-- true|false. A quest weapon-skill book (+3 skill) is INVISIBLE on
-- other players -- foreign weapon skill cannot be read at all (see
-- data/hit.lua) -- so it is an opt-in the user ticks per player and
-- per weapon skill; it only shifts that player's melee/ranged hit CAP.
-- Unchecking stores an explicit false (never nil), exactly like the
-- expectation grid, so "deliberately off" survives any later fill-in.
-- The own character ignores these flags: its weapon skill is measured
-- and a measured value already contains the book.
skillBooks = {},
}
-- Generic deep copy (tables only; keys are copied by reference, values
-- recursively). No cycle handling -- config/expectation shapes are trees.
function DC.DeepCopy(src)
if type(src) ~= "table" then
return src
end
local out = {}
for k, v in pairs(src) do
if type(v) == "table" then
out[k] = DC.DeepCopy(v)
else
out[k] = v
end
end
return out
end
-- ------------------------------------------------------------------
-- Expectations: the saved shape is
-- PER CLASS --
-- db.expectations[role][class][slotId] = true|nil
-- with one class line for every class that can perform the role
-- (DC_Roles.ROLE_CLASSES, the ordered inversion of CLASS_ROLES).
-- DC.DEFAULT_EXPECT (data/expectations.lua) STAYS the role x slot SEED.
--
-- EnsureExpectations guarantees the full structure:
-- * first load (expectations == nil): every (role, performing class)
-- gets a deep copy of the seed's role line.
-- * legacy saved tables (one line per role) -- detected by BOOLEANS directly under
-- [role] (new-shape values are class tables) -- are MIGRATED: the
-- old role line is copied to every performing class, then the
-- structure is replaced. User edits survive on every class where
-- they are representable.
-- * new-shape tables: existing class lines are NEVER touched; missing
-- (role,class) combos are filled from the seed. The fill is
-- behavior-neutral -- the model resolves a missing combo to the
-- DC.DEFAULT_EXPECT role line anyway (runtime fallback) -- it only
-- hands the options grid a complete structure.
-- Known ambiguity (accepted): an old-shape role line with EVERY slot
-- unchecked is {} and indistinguishable from an untouched new-shape
-- role table (the boolean marker is gone); it is re-seeded per class.
--
-- Resolved at CALL time, not file-load time: data/expectations.lua and
-- core/roles.lua load after this file (TOC order const -> config ->
-- data/* -> model -> roles), but EnsureDefaults only runs at
-- ADDON_LOADED when everything is in. Offline callers without DC_Roles
-- loaded simply skip the expectations block.
-- ------------------------------------------------------------------
local function isOldShapeLine(line)
for _, v in pairs(line) do
if type(v) == "boolean" then
return true
end
end
return false
end
-- Top up ONE class line in place: slots the seed marks true and the line
-- has NEVER SEEN (nil) become true. Deliberate unchecks survive (the
-- options grid stores those as explicit false, never nil). Shared by both
-- EnsureExpectations branches below so a freshly migrated line is already
-- complete -- not just on the SECOND call (idempotency: a migration must
-- converge in one pass, not two).
local function topUpLine(cl, seedLine)
for slotId, v in pairs(seedLine) do
if v == true and cl[slotId] == nil then
cl[slotId] = true
end
end
end
function DC.EnsureExpectations(db)
if DC.DEFAULT_EXPECT == nil or DC_Roles == nil
or DC_Roles.ROLE_CLASSES == nil then
return
end
if db.expectations == nil then
db.expectations = {}
end
local exp = db.expectations
local nRoles = table.getn(DC.ROLES)
for r = 1, nRoles do
local role = DC.ROLES[r]
local classes = DC_Roles.ROLE_CLASSES[role] or {}
local nClasses = table.getn(classes)
local line = exp[role]
local seedLine = DC.DEFAULT_EXPECT[role] or {}
if line ~= nil and isOldShapeLine(line) then
-- migration: copy the old role line to every performing
-- class, then top up each new class line from the CURRENT
-- seed (a slot added since the old line was saved must reach
-- it immediately, not only on a later call -- see topUpLine),
-- then replace the structure (never lose user edits where
-- representable)
local byClass = {}
for c = 1, nClasses do
local cl = DC.DeepCopy(line)
topUpLine(cl, seedLine)
byClass[classes[c]] = cl
end
exp[role] = byClass
else
if line == nil then
line = {}
exp[role] = line
end
-- fill missing (role,class) combos from the seed; existing
-- class lines keep their values, but slots the seed knows
-- and the line has NEVER SEEN (nil) are topped up -- without
-- this, a slot added in a later version never reaches saved
-- lines (field report: neck/ring columns arrived as "not
-- expected" for everyone). Deliberate unchecks survive: the
-- options grid stores them as explicit false, never nil.
for c = 1, nClasses do
local cls = classes[c]
if line[cls] == nil then
line[cls] = DC.DeepCopy(seedLine)
else
topUpLine(line[cls], seedLine)
end
end
end
end
end
-- Fill missing fields in db from DC.DEFAULTS. Existing values -- including
-- saved booleans that are false -- are never touched. Table defaults are
-- deep-copied on assign.
function DC.EnsureDefaults(db)
for k, v in pairs(DC.DEFAULTS) do
if db[k] == nil then
if type(v) == "table" then
db[k] = DC.DeepCopy(v)
else
db[k] = v
end
end
end
-- expectations: per-(role,class) shape, materialized/migrated from
-- the DC.DEFAULT_EXPECT seed (see EnsureExpectations above)
DC.EnsureExpectations(db)
return db
end
-- Reset the whole config IN PLACE (keeps the table reference -- the client
-- saves the global DopingControlDB, so the reference must survive).
function DC.ResetConfig(db)
for k in pairs(db) do
db[k] = nil
end
return DC.EnsureDefaults(db)
end
-- Reset only the expectation matrix to the DC.DEFAULT_EXPECT seed,
-- materialized per (role, performing class) -- options button
-- "Reset expectations" (reseeds ALL classes).
function DC.ResetExpectations(db)
db.expectations = nil
DC.EnsureDefaults(db)
return db.expectations
end
+238
View File
@@ -0,0 +1,238 @@
-- DopingControl core/const.lua
-- Shared constants: roles, states, tabs, color palette, quality colors.
-- Pure Lua 5.0, no WoW API -- dofile-loadable offline.
DopingControl = DopingControl or {}
local DC = DopingControl
-- ------------------------------------------------------------------
-- Roles (array order = display order) and labels
-- ------------------------------------------------------------------
DC.ROLES = { "TANK", "MELEE", "RANGED", "CASTER", "HEALER" }
DC.ROLE_LABEL = {
TANK = "Tanks",
MELEE = "Melee",
RANGED = "Ranged",
CASTER = "Casters",
HEALER = "Healers",
}
-- ------------------------------------------------------------------
-- Cell/data states. Hard rule: UNKNOWN is NEVER condensed to MISSING --
-- an unreadable player is "no data", not "has nothing".
-- ------------------------------------------------------------------
DC.STATE = {
HAS = "HAS",
MISSING = "MISSING",
UNKNOWN = "UNKNOWN",
NOTEXP = "NOTEXP",
}
-- ------------------------------------------------------------------
-- Tabs (order = display order). DEBUFFS' columns are built DYNAMICALLY
-- from the store (no data/slot table); every other tab has a static slot
-- table. DEBUFFS, RESIST and HIT are pure DISPLAY tabs -- no expectations,
-- no gaps, no sums, no whisper (see the slot tables below and
-- core/model.lua's per-tab branches). HIT sits between RESIST and
-- EQUIPMENT because it answers the same "how is this player set up"
-- question as the resistances, only for the attack side.
-- ------------------------------------------------------------------
DC.TABS = { "CONSUMABLES", "CLASSBUFFS", "DEBUFFS", "RESIST", "HIT", "EQUIPMENT" }
DC.TAB_LABEL = {
CONSUMABLES = "Consumables",
CLASSBUFFS = "Class buffs",
DEBUFFS = "Debuffs",
RESIST = "Resistances",
HIT = "Hit",
EQUIPMENT = "Equipment",
}
-- ------------------------------------------------------------------
-- RESIST tab slot table (static, unlike DEBUFFS' dynamic columns --
-- resistances are always the same five schools). All columns are ALWAYS
-- active; expectations are never consulted on this tab (display only, see
-- core/model.lua).
--
-- `school` is the UnitResistance() school index (0 = physical, 1 = holy --
-- both never queried here; 2..6 below).
-- ------------------------------------------------------------------
DC.SLOTS_RESIST = {
{ id = "RFIRE", label = "FIR", sub = "fire", full = "Fire resistance", kind = "resist", school = 2 },
{ id = "RNAT", label = "NAT", sub = "nature", full = "Nature resistance", kind = "resist", school = 3 },
{ id = "RFROST", label = "FRO", sub = "frost", full = "Frost resistance", kind = "resist", school = 4 },
{ id = "RSHAD", label = "SHA", sub = "shadow", full = "Shadow resistance", kind = "resist", school = 5 },
{ id = "RARC", label = "ARC", sub = "arcane", full = "Arcane resistance", kind = "resist", school = 6 },
}
-- ------------------------------------------------------------------
-- HIT tab slot table (static). Same display-only rules as RESIST: every
-- column is always active, expectations are never consulted, nothing is
-- ever MISSING and nothing is whispered -- a hit number below its cap is
-- information, not a gap to nag about.
--
-- The numbers behind these columns are read from item TOOLTIPS (per-item
-- lines plus set bonuses, counted once per set), from hit-granting AURAS,
-- and from TALENTS -- the own character's via GetTalentInfo, everyone
-- else's via this server's inspect protocol (scan/talents.lua). There is
-- no hit API on this client at all -- see data/hit.lua and
-- data/talenthit.lua. `hitKind` selects which number a column shows:
-- "melee" -- generic physical hit vs the yellow (special attack) cap
-- "ranged" -- the same generic gear hit against the RANGED cap, and only
-- for a player who actually carries a ranged attack weapon
-- "spell" -- spell hit vs the spell cap
--
-- WHY SIX SPELL COLUMNS INSTEAD OF ONE: spell hit is not one number. A
-- column's value is the GENERIC spell hit plus everything that only helps
-- THAT school -- school-specific item affixes and school-specific talents
-- (a mage's Arcane Focus, a priest's Shadow Focus, ...). Two casters of
-- the same class routinely differ per school, which is exactly what the
-- tab exists to show; folding them into one "spell hit" column would hide
-- the difference that decides whether a caster is capped.
--
-- `school` on a spell column is the LOWERCASE school id, shared with the
-- talent tables (data/talenthit.lua). Gear-parsed school extras are keyed
-- by the CAPITALIZED tooltip spelling instead ("Fire"), because they come
-- straight out of the tooltip text -- DC.HIT_SCHOOL_KEY (data/hit.lua)
-- bridges the two and DC_Hit.SchoolHit does the lookup for callers.
-- ------------------------------------------------------------------
DC.SLOTS_HIT = {
{ id = "MHIT", label = "MHT", sub = "melee", full = "Melee hit", kind = "hit", hitKind = "melee" },
{ id = "RHIT", label = "RNG", sub = "ranged", full = "Ranged hit", kind = "hit", hitKind = "ranged" },
{ id = "HARC", label = "ARC", sub = "arcane", full = "Arcane hit", kind = "hit", hitKind = "spell", school = "arcane" },
{ id = "HFIR", label = "FIR", sub = "fire", full = "Fire hit", kind = "hit", hitKind = "spell", school = "fire" },
{ id = "HFRO", label = "FRO", sub = "frost", full = "Frost hit", kind = "hit", hitKind = "spell", school = "frost" },
{ id = "HHOL", label = "HOL", sub = "holy", full = "Holy hit", kind = "hit", hitKind = "spell", school = "holy" },
{ id = "HNAT", label = "NAT", sub = "nature", full = "Nature hit", kind = "hit", hitKind = "spell", school = "nature" },
{ id = "HSHA", label = "SHA", sub = "shadow", full = "Shadow hit", kind = "hit", hitKind = "spell", school = "shadow" },
}
-- ------------------------------------------------------------------
-- Out-of-range threshold in yards:
-- a READABLE player with distance >= FAR_LIMIT gets rowVM.far = true and
-- counts into vm.coverage.far. Purely diagnostic -- far never changes any
-- cell state, sum, gap or pill (distance is never a filter).
-- ------------------------------------------------------------------
DC.FAR_LIMIT = 40
-- ------------------------------------------------------------------
-- Alternating-row wash ("zebra"): the alpha of DC.COLORS.zebra on every
-- SECOND data row of a table, so the eye can hold a line across a wide
-- grid. It has to stay well under the washes that carry MEANING -- the
-- unreadable-row wash sits at 0.25 -- or a striped row would read as a
-- state. One number, used by every table (matrix rows, both options
-- grids), so the whole tool stripes at the same strength.
-- ------------------------------------------------------------------
DC.ZEBRA_ALPHA = 0.035
-- ------------------------------------------------------------------
-- Color palette
-- Every entry is { r=<0..1>, g=<0..1>, b=<0..1> } (WoW SetTextColor /
-- SetVertexColor convention). Hex source noted per line.
-- ------------------------------------------------------------------
local function hexrgb(hex)
return {
r = tonumber(string.sub(hex, 1, 2), 16) / 255,
g = tonumber(string.sub(hex, 3, 4), 16) / 255,
b = tonumber(string.sub(hex, 5, 6), 16) / 255,
}
end
DC.COLORS = {
-- state colors (cell glyph / cell background / cell border)
hat = hexrgb("3fae5c"), -- HAS green
hatBg = hexrgb("152b1c"),
hatBorder = hexrgb("2b5e3a"),
fehlt = hexrgb("d05252"), -- MISSING red
fehltBg = hexrgb("341a1a"),
fehltBorder = hexrgb("6e3030"),
unbek = hexrgb("8b93a0"), -- UNKNOWN gray
unbekBg = hexrgb("22262e"),
unbekBorder = hexrgb("3b424e"),
skip = hexrgb("14181f"), -- not-expected cell bg
skipBorder = hexrgb("1d222b"),
skipGlyph = hexrgb("3a404c"),
-- gold family
gold = hexrgb("8f7440"), -- hover border gold
goldHi = hexrgb("e6c87e"), -- title / highlight gold
goldSoft = hexrgb("54462a"), -- frame accent borders
goldDim = hexrgb("a98f57"), -- secondary gold, links
-- panels / lines
panel = hexrgb("11141a"), -- frame bg
panel2 = hexrgb("171b23"), -- popover bg
panel3 = hexrgb("1d222c"), -- reserved
line = hexrgb("262c37"), -- standard borders
theadBg = hexrgb("141821"), -- sticky head/foot bg
void = hexrgb("0a0c10"), -- page background
-- alternating-row wash ("zebra"): the COLOR is plain white, the
-- subtlety lives entirely in DC.ZEBRA_ALPHA below -- a tinted stripe
-- would shift the hue of every cell it sits under.
zebra = hexrgb("ffffff"),
-- text
ink = hexrgb("c9cdd4"), -- body text
dim = hexrgb("868d99"), -- secondary text
faint = hexrgb("5b6270"), -- tertiary / hints
parch = hexrgb("d6cdb8"), -- parchment text
parchDim = hexrgb("9d9482"), -- parchment dim (tooltip detail)
-- role group colors
roleTANK = hexrgb("8fb3dd"),
roleMELEE = hexrgb("e2a75e"),
roleRANGED = hexrgb("7fcabe"),
roleCASTER = hexrgb("bd9ce8"),
roleHEALER = hexrgb("8fd6a0"),
-- role badge border colors
roleBorderTANK = hexrgb("3c5877"),
roleBorderMELEE = hexrgb("7a5c33"),
roleBorderRANGED = hexrgb("3a6b63"),
roleBorderCASTER = hexrgb("5e4a80"),
roleBorderHEALER = hexrgb("3c6c4a"),
-- class colors: OWN table -- RAID_CLASS_COLORS does NOT exist on 1.12.
-- Standard vanilla class colors, all 9 classes (the sim roster covers
-- all of them).
classWARRIOR = hexrgb("c79c6e"),
classPALADIN = hexrgb("f58cba"),
classHUNTER = hexrgb("abd473"),
classROGUE = hexrgb("fff569"),
classPRIEST = hexrgb("ffffff"),
classSHAMAN = hexrgb("0070de"),
classMAGE = hexrgb("69ccf0"),
classWARLOCK = hexrgb("9482c9"),
classDRUID = hexrgb("ff7d0a"),
-- trinket tile quality accents (WoW rare/3 and epic/4)
rareText = hexrgb("4aa3ff"),
rareBorder = hexrgb("1d5a9e"),
epicText = hexrgb("b06cf0"),
epicBorder = hexrgb("6a3f8f"),
-- green button / pill family + banners
btnGreenText = hexrgb("cfe0a8"),
btnGreenBg = hexrgb("26311e"),
btnGreenBorder = hexrgb("4c6236"),
bannerYellowBg = hexrgb("2b2413"),
bannerYellowBorder = hexrgb("6e5a26"),
bannerYellowText = hexrgb("e0c98a"),
bannerGreenBg = hexrgb("16240f"),
}
-- ------------------------------------------------------------------
-- Item quality colors, indices 0..6 as returned by GetItemQualityColor.
-- Own table so pure-Lua tests can run offline (GetItemQualityColor exists
-- in-game but not under lua50). Values = 1.12 FrameXML ITEM_QUALITY_COLORS.
-- ------------------------------------------------------------------
DC.QUALITY = {
[0] = { r = 0.65, g = 0.65, b = 0.65 }, -- poor (gray)
[1] = { r = 1.00, g = 1.00, b = 1.00 }, -- common (white)
[2] = { r = 0.10, g = 1.00, b = 0.00 }, -- uncommon (green)
[3] = { r = 0.00, g = 0.44, b = 0.87 }, -- rare (blue)
[4] = { r = 0.64, g = 0.21, b = 0.93 }, -- epic (purple)
[5] = { r = 1.00, g = 0.50, b = 0.00 }, -- legendary (orange)
[6] = { r = 0.90, g = 0.80, b = 0.50 }, -- artifact
}
+240
View File
@@ -0,0 +1,240 @@
-- DopingControl core/init.lua
-- Bootstrap: ADDON_LOADED wiring (SavedVariables defaults), event
-- registration (roster changes, READY_CHECK), test-mode switch, slash
-- commands /dc + /doping. Loads LAST in the TOC -- every module global
-- (DC_Scan, DC_Matrix, DC_Options, DC_Report, DC_Sim, DC_Probe) exists by
-- the time any function here runs.
--
-- Pure-Lua 5.0 dofile-loadable: all WoW wiring behind "if CreateFrame then".
-- The pure parts (DC.HandleSlash, DC.SetTestMode, DC.DumpUnknown) are
-- offline-tested with stubbed modules.
DopingControl = DopingControl or {}
local DC = DopingControl
-- Store (the shared data shape every consumer reads). Empty until the
-- first scan finishes or test mode injects the simulated store.
DC.store = DC.store or { players = {}, source = "scan" }
-- Unknown-aura tally, filled by scan/aura.lua (DC.unknownSeen[name] = count);
-- read here by /dc unknown (unknown buff names are never silently discarded).
DC.unknownSeen = DC.unknownSeen or {}
local CHAT_PREFIX = "|cff3fae5cDopingControl|r: "
function DC.Print(msg)
if DEFAULT_CHAT_FRAME then
DEFAULT_CHAT_FRAME:AddMessage(CHAT_PREFIX .. tostring(msg))
else
print(CHAT_PREFIX .. tostring(msg)) -- offline Lua 5.0
end
end
-- Called by scan/engine.lua whenever the store changed (scan finished).
function DC.OnStoreUpdated()
DC_Matrix.Refresh()
end
-- Matrix visibility, used to decide whether a roster change should trigger
-- an immediate rescan. DC_Matrix.IsShown is not pinned in the plan -- if
-- ui/matrix.lua does not provide it, we conservatively treat the window as
-- hidden (next manual scan / ready check rescans anyway).
local function MatrixIsShown()
if DC_Matrix and DC_Matrix.IsShown then
return DC_Matrix.IsShown()
end
return nil
end
-- ------------------------------------------------------------------
-- Test mode: the simulator fully replaces the data source.
-- ------------------------------------------------------------------
function DC.SetTestMode(v)
local db = DC.db or DopingControlDB
db.testMode = v
if v then
-- buildStore() itself resets and refills DC.simRoles with the
-- built-in confirmations (DC_Sim.ROLES, core/sim.lua) -- do NOT wipe
-- DC.simRoles afterwards, that would erase them and turn every row
-- into a suggestion badge (the sim roster deliberately contains
-- exactly two suggestion-badge cases).
DC.store = DC_Sim.buildStore()
else
DC.store = { players = {}, source = "scan" }
DC_Scan.Start(true) -- leaving test mode: immediate real rescan
end
DC_Matrix.Refresh()
end
-- ------------------------------------------------------------------
-- /dc unknown -- dump DC.unknownSeen via SuperWoW ExportFile.
-- ------------------------------------------------------------------
function DC.DumpUnknown()
local names = {}
local distinct = 0
for name in pairs(DC.unknownSeen) do
table.insert(names, name)
distinct = distinct + 1
end
if distinct == 0 then
DC.Print("no unknown buffs recorded")
return
end
table.sort(names, function(a, b)
local ca, cb = DC.unknownSeen[a], DC.unknownSeen[b]
if ca ~= cb then return ca > cb end
return a < b
end)
local lines = { "DopingControl unknown buffs (" .. distinct .. " names)" }
for i = 1, table.getn(names) do
table.insert(lines, string.format("%dx %s", DC.unknownSeen[names[i]], names[i]))
end
local text = table.concat(lines, "\n")
if ExportFile then
-- SuperWoW appends ".txt" itself -- pass the name WITHOUT extension.
-- pcall: optional-DLL call -- an I/O error must not
-- escape into the slash handler.
local okE = pcall(ExportFile, "dc_unknown", text)
if okE then
DC.Print(distinct .. " unknown buff names dumped to dc_unknown.txt (WoW Imports folder)")
else
DC.Print("ExportFile failed - no dump written")
end
else
DC.Print("ExportFile unavailable (SuperWoW required) - no dump written")
end
end
-- ------------------------------------------------------------------
-- Slash commands: /dc, /doping
-- ------------------------------------------------------------------
local HELP_TEXT = "commands: /dc (window) | scan | options | report | test | probe [range <name>] | unknown | talents [on|off|clear]"
function DC.HandleSlash(msg)
msg = msg or ""
local _, _, word, rest = string.find(msg, "^%s*(%S*)%s*(.-)%s*$")
local cmd = string.lower(word or "")
local db = DC.db or DopingControlDB
if cmd == "" then
DC_Matrix.Toggle()
elseif cmd == "scan" then
if db and db.testMode then
-- real data never beats the simulation while test mode is on
DC.Print("test mode is on - /dc test to switch back before scanning")
else
DC_Scan.Start()
end
elseif cmd == "options" then
DC_Options.Toggle()
elseif cmd == "report" then
DC_Report.Open()
elseif cmd == "test" then
DC.SetTestMode(not (db and db.testMode))
if db and db.testMode then
DC.Print("test mode ON - simulated raid, chat output disabled")
else
DC.Print("test mode OFF - rescanning the real group")
end
elseif cmd == "probe" then
-- core/probe.lua owns the probe subcommand grammar:
-- DC_Probe.HandleSlash(rest) handles "" (battery),
-- "range <name>" (1 Hz run) and the range-run stop toggle.
DC_Probe.HandleSlash(rest)
elseif cmd == "unknown" then
DC.DumpUnknown()
elseif cmd == "talents" then
-- foreign talents come in over this server's inspect protocol
-- (scan/talents.lua). "on"/"off" is a real switch, not cosmetic:
-- asking makes OTHER players' clients send their tree back, so it
-- must be possible to stop doing that.
if not DC_Talents then
DC.Print("talent module not loaded")
elseif rest == "on" or rest == "off" then
DopingControlDB.readTalents = (rest == "on")
DC.Print("talent reading " .. rest)
if rest == "off" and DC_Talents.Clear then
DC_Talents.Clear()
end
elseif rest == "clear" then
DC_Talents.Clear()
DC.Print("talent cache cleared - they will be asked again")
else
local s = DC_Talents.Stats()
DC.Print("talents: " .. s.known .. " known, " .. s.pending
.. " mid-reply, " .. s.queued .. " queued, "
.. (s.enabled and "ON" or "OFF")
.. " (/dc talents on|off|clear)")
end
else
DC.Print(HELP_TEXT)
end
end
-- ------------------------------------------------------------------
-- WoW wiring
-- ------------------------------------------------------------------
if CreateFrame then
SLASH_DOPINGCONTROL1 = "/dc"
SLASH_DOPINGCONTROL2 = "/doping"
SlashCmdList["DOPINGCONTROL"] = DC.HandleSlash
-- Matrix toolbar hooks (ui/matrix.lua fires MX.hooks.*): the Options
-- and Report buttons fall back to DC_Options/DC_Report directly, but
-- the Scan button ONLY fires MX.hooks.scan -- wire it here through the
-- slash path so it gets the same test-mode gate as "/dc scan".
if DC_Matrix and DC_Matrix.hooks then
DC_Matrix.hooks.scan = function()
DC.HandleSlash("scan")
end
end
local ev = CreateFrame("Frame", "DopingControlInitEventFrame", UIParent)
ev:RegisterEvent("ADDON_LOADED")
ev:SetScript("OnEvent", function()
if event == "ADDON_LOADED" then
if arg1 ~= "DopingControl" then return end
ev:UnregisterEvent("ADDON_LOADED")
-- SavedVariables are in place now.
DopingControlDB = DopingControlDB or {}
DC.EnsureDefaults(DopingControlDB) -- also deep-copies
-- DC.DEFAULT_EXPECT into db.expectations on first load
-- (core/config.lua handles that inside EnsureDefaults).
DC.db = DopingControlDB
-- Roster changes invalidate the store; rescan only when the
-- window is up (otherwise the next scan re-reads the roster
-- anyway -- scan/engine.lua re-walks it on every Start()).
ev:RegisterEvent("RAID_ROSTER_UPDATE")
ev:RegisterEvent("PARTY_MEMBERS_CHANGED")
-- READY_CHECK: registered defensively -- it is unverified
-- whether this event fires natively on 1.12/TurtleWoW, so a
-- rejecting client must not break the bootstrap.
pcall(ev.RegisterEvent, ev, "READY_CHECK")
-- Persisted test mode survives reloads: restore the simulated
-- store so window content and db.testMode stay consistent.
if DC.db.testMode then
DC.store = DC_Sim.buildStore()
end
elseif event == "RAID_ROSTER_UPDATE" or event == "PARTY_MEMBERS_CHANGED" then
local db = DC.db
if db and not db.testMode and MatrixIsShown() then
DC_Scan.Start()
end
elseif event == "READY_CHECK" then
-- The auto-SCAN on ready check is owned by scan/engine.lua
-- (single owner) -- registering the Start
-- here too fired a duplicate full scan per ready check. This
-- handler only does the auto-OPEN half.
local db = DC.db
if db and db.readyCheckOpen then
DC_Matrix.Show()
end
end
end)
end
+1740
View File
File diff suppressed because it is too large Load Diff
+534
View File
@@ -0,0 +1,534 @@
-- DopingControl core/probe.lua
-- Feasibility probe: dumps RAW API returns to dc_probe.txt (in the WoW
-- client's Imports folder) via SuperWoW ExportFile so raw facts can be read
-- from disk instead of retyped from chat lines.
--
-- Entry points (core/init.lua wires the slash command):
-- DC_Probe.HandleSlash(rest) -- rest = text after "probe":
-- "" -> Battery() (part 2, near-range questions)
-- "range <name>" -> RangeStart(name) / RangeStop() toggle (part 1)
-- DC_Probe.Battery()
-- DC_Probe.RangeStart(playerName) / DC_Probe.RangeStop()
-- DC_Probe.IsRangeRunning()
--
-- Part 1 (range run): a raid member with known buffs walks away; a 1 Hz
-- poller writes one line per tick:
-- dist | UnitExists | UnitIsVisible | nA=count(UnitBuff) | 4th-return |
-- nB=count(GetUnitField) | UnitPosition
-- The unit token is RE-RESOLVED from the name every tick (raid indices are
-- volatile after roster changes).
--
-- Part 2 (battery) answers five open client-behavior questions with raw data:
-- Q1 does UnitBuff(unit, i) return a 4th value ~= nil (spell id)?
-- Q2 does GetUnitField(guid,"aura") match the tooltip names; is the
-- slot split really 1-32 buffs / 33-48 debuffs?
-- Q3 does GetWeaponEnchantInfo(unit) name a foreign temp enchant?
-- Q4 does GetInventoryItemLink(unit, slot) serve out-of-sight units?
-- Q5 is the ENCHANT field in foreign item links filled (~= 0)?
--
-- File mechanics: ExportFile appends ".txt" ITSELF -- the name is passed
-- WITHOUT extension ("dc_probe", never "dc_probe.txt"). ExportFile is a
-- whole-file overwrite, so the probe keeps an in-memory ring of lines and
-- rewrites the complete file on every flush.
--
-- Pure Lua 5.0, dofile-loadable offline (all WoW usage inside functions /
-- existence-checked; the poller frame is created lazily in-game).
DopingControl = DopingControl or {}
local DC = DopingControl
DC_Probe = DC_Probe or {}
local P = DC_Probe
P.RING_CAP = 2000 -- max lines kept; oldest dropped first
P.EXPORT_NAME = "dc_probe" -- NO extension (ExportFile appends .txt)
P.BATTERY_UNIT_CAP = 8 -- full detail for at most N foreign units
local ring = {}
-- ------------------------------------------------------------------
-- ring + flush
-- ------------------------------------------------------------------
function P.Clear()
ring = {}
end
function P.Push(line)
table.insert(ring, line)
while table.getn(ring) > P.RING_CAP do
table.remove(ring, 1)
end
end
function P.Text()
return table.concat(ring, "\n")
end
local function say(msg)
if DEFAULT_CHAT_FRAME then
DEFAULT_CHAT_FRAME:AddMessage("|cffe6c87eDopingControl|r probe: " .. msg)
end
end
function P.Flush()
if ExportFile then
-- name WITHOUT extension -- ExportFile appends .txt itself.
-- pcall: optional-DLL call -- a DLL-side I/O error
-- must not escape into the slash handler or the 1 Hz range ticker.
local ok = pcall(ExportFile, P.EXPORT_NAME, P.Text())
if not ok then
say("ExportFile failed - dump not written")
return false
end
return true
end
say("ExportFile missing (SuperWoW required) - dump not written")
return false
end
-- ------------------------------------------------------------------
-- formatting helpers
-- ------------------------------------------------------------------
local function fmt(v)
if v == nil then
return "nil"
end
if v == true then
return "true"
end
if v == false then
return "false"
end
if type(v) == "number" then
return tostring(v)
end
if type(v) == "string" then
return v
end
return type(v)
end
local function gtype(name)
local v = getglobal and getglobal(name)
if v == nil then
return "nil"
end
return type(v)
end
local function nowStamp()
if date then
return date("%Y-%m-%d %H:%M:%S")
end
return "?"
end
local function header(kind)
P.Push("=== DopingControl probe " .. kind .. " " .. nowStamp() .. " ===")
P.Push("client: TURTLE_WOW_VERSION=" .. fmt(TURTLE_WOW_VERSION)
.. " SUPERWOW_VERSION=" .. fmt(SUPERWOW_VERSION))
P.Push("dll-surface: UnitXP=" .. gtype("UnitXP")
.. " GetUnitField=" .. gtype("GetUnitField")
.. " ExportFile=" .. gtype("ExportFile")
.. " UnitPosition=" .. gtype("UnitPosition"))
end
-- resolve a roster unit token by player name; nil if not in group.
-- Re-run every use: raid indices are volatile.
local function resolveUnit(name)
if not UnitName then
return nil
end
local lower = string.lower(name)
local nraid = (GetNumRaidMembers and GetNumRaidMembers()) or 0
if nraid > 0 then
for i = 1, nraid do
local ok, nm = pcall(UnitName, "raid" .. i)
if ok and nm and string.lower(nm) == lower then
return "raid" .. i
end
end
return nil
end
for i = 1, 4 do
local ok, nm = pcall(UnitName, "party" .. i)
if ok and nm and string.lower(nm) == lower then
return "party" .. i
end
end
return nil
end
local function unitGuid(unit)
if not UnitExists then
return nil
end
local ok, ex, g = pcall(UnitExists, unit)
if ok and ex and g then
return g
end
return nil
end
-- count UnitBuff entries + first non-nil 4th return
local function pathACounts(unit)
local n = 0
local firstId = nil
local idCount = 0
if UnitBuff then
for i = 1, 32 do
local ok, tex, stacks, dtype, id4 = pcall(UnitBuff, unit, i)
if not ok or not tex then
break
end
n = n + 1
if type(id4) == "number" and id4 > 0 then
idCount = idCount + 1
if not firstId then
firstId = id4
end
end
end
end
return n, firstId, idCount
end
-- GetUnitField(guid,"aura") -> comma list for a slot range (sparse table)
local function pathBList(guid, from, to)
if not GetUnitField or not guid then
return nil, 0
end
local ok, t = pcall(GetUnitField, guid, "aura")
if not ok then
return "ERR", 0
end
if type(t) ~= "table" then
return "type=" .. type(t), 0
end
local parts = {}
local n = 0
for i = from, to do
local v = t[i]
if type(v) == "number" and v > 0 then
n = n + 1
table.insert(parts, i .. ":" .. v)
end
end
if n == 0 then
return "-", 0
end
return table.concat(parts, ","), n
end
-- ------------------------------------------------------------------
-- Part 2: the battery (/dc probe)
-- ------------------------------------------------------------------
function P.Battery()
if not UnitName then
say("no client APIs (offline?) - battery skipped")
return false
end
P.Clear()
header("battery")
local roster = (DC_Scan and DC_Scan.BuildRoster and DC_Scan.BuildRoster()) or {}
local total = table.getn(roster)
P.Push("roster: " .. total .. " units; full detail for the first "
.. P.BATTERY_UNIT_CAP .. " foreign units")
P.Push("")
-- self weapon enchant baseline (vanilla 6-return form)
if GetWeaponEnchantInfo then
local ok, r1, r2, r3, r4, r5, r6 = pcall(GetWeaponEnchantInfo)
P.Push("self GetWeaponEnchantInfo(): ok=" .. fmt(ok)
.. " hasMain=" .. fmt(r1) .. " mainExp=" .. fmt(r2)
.. " mainCharges=" .. fmt(r3) .. " hasOff=" .. fmt(r4)
.. " offExp=" .. fmt(r5) .. " offCharges=" .. fmt(r6))
else
P.Push("self GetWeaponEnchantInfo(): API missing")
end
P.Push("")
local detailed = 0
for r = 1, total do
local e = roster[r]
local isSelf = false
if UnitIsUnit then
local ok, v = pcall(UnitIsUnit, e.unit, "player")
isSelf = (ok and v) and true or false
end
if not isSelf and detailed < P.BATTERY_UNIT_CAP then
detailed = detailed + 1
local guid = unitGuid(e.unit)
local online, dist = DC_Reach.ReadUnit(e.unit)
P.Push("--- " .. e.unit .. " name=" .. fmt(e.name)
.. " class=" .. fmt(e.class) .. " online=" .. fmt(online)
.. " dist=" .. fmt(dist) .. " guid=" .. fmt(guid))
-- Q1: UnitBuff rows with 4th return + tooltip name
if UnitBuff then
local rows = 0
for i = 1, 32 do
local ok, tex, stacks, dtype, id4 = pcall(UnitBuff, e.unit, i)
if not ok or not tex then
break
end
rows = rows + 1
local tipName = nil
-- same hidden tooltip as scan/aura.lua (shared accessor,
-- never a second frame under the same global name)
local tip = DC_Aura and DC_Aura.GetScanTip
and DC_Aura.GetScanTip()
if tip then
local ok2 = pcall(function()
tip:ClearLines()
tip:SetUnitBuff(e.unit, i)
end)
if ok2 then
local obj = getglobal("DopingControlScanTipTextLeft1")
if obj then
tipName = obj:GetText()
end
end
end
P.Push("Q1 UnitBuff i=" .. i .. " tex=" .. fmt(tex)
.. " stacks=" .. fmt(stacks) .. " dtype=" .. fmt(dtype)
.. " id4=" .. fmt(id4) .. " tipName=" .. fmt(tipName))
end
local nA, firstId, idCount = pathACounts(e.unit)
P.Push("Q1 summary: buffs=" .. nA .. " id4nonNil=" .. idCount
.. " (id4 ~= nil answers Q1)")
else
P.Push("Q1 UnitBuff: API missing")
end
-- Q2: descriptor list, buff and debuff halves separately
local buffList = pathBList(guid, 1, 32)
local debuffList = pathBList(guid, 33, 48)
P.Push("Q2 GetUnitField(guid,\"aura\") slots1-32=" .. fmt(buffList))
P.Push("Q2 GetUnitField(guid,\"aura\") slots33-48=" .. fmt(debuffList)
.. " (compare vs UnitBuff names above -> slot-split check)")
-- Q3: foreign weapon enchant name
if GetWeaponEnchantInfo then
local ok, w1, w2 = pcall(GetWeaponEnchantInfo, e.unit)
P.Push("Q3 GetWeaponEnchantInfo(unit): ok=" .. fmt(ok)
.. " r1=" .. fmt(w1) .. " r2=" .. fmt(w2))
end
-- Q4/Q5: inventory links, raw + parsed enchant field.
-- subType (GetItemInfo return 6, de-shifted by DC_Gear.ItemInfo)
-- is dumped per slot to verify the DC.ENCH_EXEMPT [ASSUMPTION]
-- strings against reality (data/enchants.lua).
local slots = DC_Gear.InvSlots()
local linkCount = 0
for s = 1, table.getn(slots) do
local inv = slots[s]
local ok, link = pcall(GetInventoryItemLink, e.unit, inv)
if ok and link then
linkCount = linkCount + 1
local id, ench = DC_Gear.ParseLink(link)
local subType = nil
if id then
local _, _, st = DC_Gear.ItemInfo(id)
subType = st
end
P.Push("Q4/Q5 slot" .. inv .. " link=" .. fmt(link)
.. " -> id=" .. fmt(id) .. " ench=" .. fmt(ench)
.. " subType=" .. fmt(subType))
end
end
P.Push("Q4 summary: " .. linkCount .. "/" .. table.getn(slots)
.. " slots returned a link (0 for an out-of-sight unit"
.. " answers Q4 negatively)")
P.Push("Q5b: subType above checks the ENCH_EXEMPT assumption"
.. " strings - expected \"Wands\"/\"Thrown\" (slot 18) and"
.. " \"Miscellaneous\" (slot 17), data/enchants.lua")
-- Q6: resistance read paths (RESIST tab, scan/engine.lua
-- readResists) -- school 2 (Fire) as a representative probe,
-- all three paths dumped raw: does UnitResistance answer for a
-- foreign unit token, does it also accept the GUID directly,
-- and does GetUnitField(guid,"resistances") carry a usable
-- table at all -- or does a foreign read silently return 0?
if UnitResistance then
local ok1, base1, total1 = pcall(UnitResistance, e.unit, 2)
P.Push("Q6 UnitResistance(unit,2): ok=" .. fmt(ok1)
.. " base=" .. fmt(base1) .. " total=" .. fmt(total1))
if guid then
local ok2, base2, total2 = pcall(UnitResistance, guid, 2)
P.Push("Q6 UnitResistance(guid,2): ok=" .. fmt(ok2)
.. " base=" .. fmt(base2) .. " total=" .. fmt(total2))
end
else
P.Push("Q6 UnitResistance: API missing")
end
if guid and GetUnitField then
local ok3, t = pcall(GetUnitField, guid, "resistances")
if ok3 and type(t) == "table" then
P.Push("Q6 GetUnitField(guid,\"resistances\"): [1]="
.. fmt(t[1]) .. " [2]=" .. fmt(t[2]) .. " [3]="
.. fmt(t[3]) .. " [4]=" .. fmt(t[4]) .. " [5]="
.. fmt(t[5]) .. " [6]=" .. fmt(t[6]) .. " [7]="
.. fmt(t[7]))
else
P.Push("Q6 GetUnitField(guid,\"resistances\"): ok="
.. fmt(ok3) .. " type=" .. type(t))
end
end
P.Push("")
end
end
if detailed == 0 then
P.Push("no foreign units in group - battery needs a party/raid")
end
local written = P.Flush()
if written then
say("battery done (" .. detailed .. " foreign units) -> "
.. P.EXPORT_NAME .. ".txt")
end
return written
end
-- ------------------------------------------------------------------
-- Part 1: the 1 Hz range run (/dc probe range <name>)
-- ------------------------------------------------------------------
local rangeFrame = nil
local rangeName = nil
local rangeRunning = false
local rangeAcc = 0
local rangeT0 = 0
local function rangeTick()
local t = (GetTime and GetTime()) or 0
local rel = math.floor(t - rangeT0 + 0.5)
local unit = resolveUnit(rangeName)
if not unit then
P.Push("t=" .. rel .. "s target '" .. rangeName .. "' not in roster")
P.Flush()
return
end
local guid = unitGuid(unit)
local dist = nil
if UnitXP then
local ok, d = pcall(UnitXP, "distanceBetween", "player", unit)
if ok and type(d) == "number" then
dist = d
end
end
local exists = nil
if UnitExists then
local ok, ex = pcall(UnitExists, unit)
if ok then
exists = ex
end
end
local visible = nil
if UnitIsVisible then
local ok, v = pcall(UnitIsVisible, unit)
if ok then
visible = v
end
end
local nA, firstId = pathACounts(unit)
local _, nB = pathBList(guid, 1, 32)
local pos = "nil"
if UnitPosition and guid then
local ok, x, y = pcall(UnitPosition, guid)
if ok and type(x) == "number" then
pos = "ok(" .. string.format("%.1f", x) .. ","
.. string.format("%.1f", y or 0) .. ")"
end
end
-- part-1 column order (matches the header line written by RangeStart)
P.Push("t=" .. rel .. "s unit=" .. unit
.. " dist=" .. fmt(dist)
.. " exists=" .. fmt(exists)
.. " visible=" .. fmt(visible)
.. " nA=" .. nA
.. " id4first=" .. fmt(firstId)
.. " nB=" .. nB
.. " pos=" .. pos)
P.Flush()
end
function P.IsRangeRunning()
return rangeRunning
end
function P.RangeStart(name)
if not CreateFrame then
say("no client (offline) - range run unavailable")
return false
end
if rangeRunning then
say("range run already active for " .. fmt(rangeName)
.. " - stop it first (/dc probe range)")
return false
end
rangeName = name
rangeRunning = true
rangeAcc = 0
rangeT0 = (GetTime and GetTime()) or 0
P.Clear()
header("range-run target=" .. name)
P.Push("columns: t | unit | dist | exists | visible | nA=count(UnitBuff) |"
.. " id4first | nB=count(GetUnitField slots 1-32) | pos=UnitPosition")
if not rangeFrame then
rangeFrame = CreateFrame("Frame", "DopingControlProbeFrame")
end
rangeFrame:SetScript("OnUpdate", function()
rangeAcc = rangeAcc + (arg1 or 0)
if rangeAcc >= 1.0 then
rangeAcc = 0
rangeTick()
end
end)
say("range run started, target " .. name .. ", 1 Hz -> "
.. P.EXPORT_NAME .. ".txt (stop: /dc probe range " .. name .. ")")
return true
end
function P.RangeStop()
if rangeFrame then
rangeFrame:SetScript("OnUpdate", nil)
end
if rangeRunning then
rangeRunning = false
P.Push("=== range run stopped " .. nowStamp() .. " ===")
P.Flush()
say("range run stopped, " .. table.getn(ring) .. " lines in "
.. P.EXPORT_NAME .. ".txt")
end
rangeName = nil
end
-- ------------------------------------------------------------------
-- slash glue: rest of the line after "probe"
-- ------------------------------------------------------------------
function P.HandleSlash(rest)
rest = rest or ""
rest = string.gsub(rest, "^%s+", "")
rest = string.gsub(rest, "%s+$", "")
if rest == "" then
return P.Battery()
end
local _, _, word = string.find(rest, "^(%S+)")
if word and string.lower(word) == "range" then
if rangeRunning then
P.RangeStop()
return true
end
local _, _, name = string.find(rest, "^%S+%s+(.+)$")
if name then
return P.RangeStart(name)
end
say("usage: /dc probe range <playername>")
return false
end
say("usage: /dc probe | /dc probe range <playername>")
return false
end
+247
View File
@@ -0,0 +1,247 @@
-- DopingControl core/roles.lua
-- Role suggestion heuristic + confirmed-role resolution: five roles, the
-- role hangs on the player NAME, and no heuristic ever decides silently --
-- a suggestion is always flagged as such and carries a reason string.
-- Badge UX: CLASS_ROLES = ordered plausible roles per class, cycle() =
-- badge right-click (step + immediate confirm).
-- Per-class expectations:
-- ROLE_CLASSES = generated inversion of CLASS_ROLES (ordered classes per
-- role) -- the domain of db.expectations[role][class].
-- Pure Lua 5.0, no WoW API -- dofile-loadable offline.
DopingControl = DopingControl or {}
local DC = DopingControl
DC_Roles = DC_Roles or {}
-- Transient confirmations for the simulated raid (test mode). NEVER written
-- to SavedVariables: synthetic names must not pollute db.roles.
-- Reset by DC_Sim.buildStore() on every (re-)entry into test mode.
DC.simRoles = DC.simRoles or {}
-- ------------------------------------------------------------------
-- Plausible roles per class token, ORDERED (the order is
-- the badge right-click cycle order). suggest() below must only ever
-- return a role CONTAINED in the class's list -- property-tested over
-- every class x fact combination in the offline test suite.
-- ------------------------------------------------------------------
DC_Roles.CLASS_ROLES = {
WARRIOR = { "MELEE", "TANK" },
ROGUE = { "MELEE" },
HUNTER = { "RANGED", "MELEE" }, -- melee hunter is a real spec on TWoW
MAGE = { "CASTER" },
WARLOCK = { "CASTER" },
PRIEST = { "HEALER", "CASTER" },
DRUID = { "HEALER", "TANK", "MELEE", "CASTER" },
SHAMAN = { "HEALER", "TANK", "MELEE", "CASTER" },
PALADIN = { "HEALER", "TANK", "MELEE" },
}
-- ------------------------------------------------------------------
-- Per-class expectations:
-- ROLE_CLASSES = the INVERSION of CLASS_ROLES -- for each role the
-- ordered list of class tokens that can perform it, in the fixed
-- class order below, filtered per role. GENERATED from CLASS_ROLES so
-- the two tables can never drift apart.
-- Consumers: core/config.lua materializes db.expectations[role][class]
-- over exactly these lists, core/model.lua iterates them for the
-- dynamic-column check, and the options expectation grid renders one
-- indented class row per entry.
-- ------------------------------------------------------------------
DC_Roles.CLASS_ORDER = {
"WARRIOR", "PALADIN", "HUNTER", "ROGUE", "PRIEST",
"SHAMAN", "MAGE", "WARLOCK", "DRUID",
}
DC_Roles.ROLE_CLASSES = {}
do
-- fresh tables on every (re-)load -- regeneration, not accretion
local inv = DC_Roles.ROLE_CLASSES
for i = 1, table.getn(DC_Roles.CLASS_ORDER) do
local cls = DC_Roles.CLASS_ORDER[i]
local list = DC_Roles.CLASS_ROLES[cls] or {}
for j = 1, table.getn(list) do
local role = list[j]
if inv[role] == nil then
inv[role] = {}
end
table.insert(inv[role], cls)
end
end
end
-- ------------------------------------------------------------------
-- Facts extraction: derive the suggestion facts from a store player entry.
-- Works on both aura paths: form NAMES from
-- the tooltip path, form SPELL IDS from the GUID path (which delivers ids
-- only).
-- [ASSUMPTION] Form aura spell IDs (vanilla 1.12): Cat Form 768, Bear Form
-- 5487, Dire Bear Form 9634, Moonkin Form 24858, Shadowform 15473.
-- ------------------------------------------------------------------
DC_Roles.FORM_NAMES = {
["Cat Form"] = "catForm",
["Bear Form"] = "bearForm",
["Dire Bear Form"] = "bearForm",
["Moonkin Form"] = "moonkin",
["Shadowform"] = "shadowform",
}
DC_Roles.FORM_IDS = {
[768] = "catForm",
[5487] = "bearForm",
[9634] = "bearForm",
[24858] = "moonkin",
[15473] = "shadowform",
}
function DC_Roles.factsFor(player)
local facts = {
hasShield = false, catForm = false, bearForm = false,
shadowform = false, moonkin = false,
}
if not player then
return facts
end
if player.aurasRead and player.auras then
if player.auras.names then
for name, key in pairs(DC_Roles.FORM_NAMES) do
if player.auras.names[name] then
facts[key] = true
end
end
end
if player.auras.ids then
for id, key in pairs(DC_Roles.FORM_IDS) do
if player.auras.ids[id] then
facts[key] = true
end
end
end
end
if player.gearRead and player.gear then
local off = player.gear[17] -- off hand inventory slot
if type(off) == "table" and off.subType == "Shields" then
facts.hasShield = true
end
end
return facts
end
-- ------------------------------------------------------------------
-- Suggestion heuristic. ALWAYS returns a
-- (role, reason) pair -- no suggestion without a stated reason (the badge
-- tooltip shows it).
-- ------------------------------------------------------------------
function DC_Roles.suggest(class, facts)
facts = facts or {}
if class == "WARRIOR" then
if facts.hasShield then
return "TANK", "shield equipped"
end
return "MELEE", "class default"
elseif class == "ROGUE" then
return "MELEE", "class default"
elseif class == "HUNTER" then
return "RANGED", "class default"
elseif class == "MAGE" then
return "CASTER", "class default"
elseif class == "WARLOCK" then
return "CASTER", "class default"
elseif class == "PRIEST" then
if facts.shadowform then
return "CASTER", "Shadowform at scan time"
end
return "HEALER", "class default"
elseif class == "DRUID" then
-- form precedence: bear > cat > moonkin > none (a druid can only be
-- in one form at a time; the order only matters for garbage input)
if facts.bearForm then
return "TANK", "Bear Form at scan time"
end
if facts.catForm then
return "MELEE", "Cat Form at scan time"
end
if facts.moonkin then
return "CASTER", "Moonkin Form at scan time"
end
return "HEALER", "class default"
elseif class == "SHAMAN" then
-- deliberately NOT shield->MELEE: resto shamans
-- carry shields too, so a shield is no enhancement signal here.
-- Plain class default; enhancers get confirmed by hand once.
return "HEALER", "class default"
elseif class == "PALADIN" then
if facts.hasShield then
return "TANK", "shield equipped"
end
return "HEALER", "class default"
end
-- unknown class token: still return a pair, never nil
return "MELEE", "unknown class"
end
-- ------------------------------------------------------------------
-- Resolution: confirmed role wins over suggestion.
-- source "sim" -> confirmations live ONLY in DC.simRoles (transient);
-- db.roles is never consulted and never written for
-- simulated players (synthetic names must
-- not end up in SavedVariables).
-- source "scan" -> confirmations live in db.roles (persistent, keyed by
-- player name).
-- Returns role, suggested(bool), reason(string|nil):
-- confirmed -> (role, false, nil)
-- suggested -> (role, true, reason)
-- ------------------------------------------------------------------
function DC_Roles.resolve(db, storeSource, player)
local name = player and player.name
local confirmed
if storeSource == "sim" then
confirmed = name and DC.simRoles[name]
else
confirmed = name and db and db.roles and db.roles[name]
end
if confirmed then
return confirmed, false, nil
end
local role, reason = DC_Roles.suggest(player and player.class,
DC_Roles.factsFor(player))
return role, true, reason
end
-- Confirm a role (badge LEFT-click). Same routing rule as resolve().
function DC_Roles.confirm(db, storeSource, name, role)
if storeSource == "sim" then
DC.simRoles[name] = role
else
db.roles[name] = role
end
end
-- ------------------------------------------------------------------
-- cycle(db, storeSource, name, class, currentRole) -> nextRole
-- Badge RIGHT-click: step to the entry AFTER currentRole
-- in CLASS_ROLES[class] (wrapping; currentRole not in the list -> the
-- FIRST entry) and CONFIRM it immediately -- same persistence routing as
-- confirm() (sim source -> DC.simRoles, else db.roles).
-- Single-entry classes wrap onto themselves (the UI treats right-click
-- as a no-op there and never calls this; calling it anyway is harmless:
-- the same role comes back and is re-confirmed).
-- Unknown class token: no list -> currentRole returned unchanged and
-- NOTHING confirmed (defensive; scan classes are always known tokens).
-- ------------------------------------------------------------------
function DC_Roles.cycle(db, storeSource, name, class, currentRole)
local list = DC_Roles.CLASS_ROLES[class]
if not list or table.getn(list) == 0 then
return currentRole
end
local n = table.getn(list)
local nextRole = list[1]
for i = 1, n do
if list[i] == currentRole then
nextRole = list[math.mod(i, n) + 1]
break
end
end
DC_Roles.confirm(db, storeSource, name, nextRole)
return nextRole
end
+1135
View File
File diff suppressed because it is too large Load Diff