Files
2026-08-03 09:47:19 +02:00

248 lines
9.4 KiB
Lua

-- 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