-- DopingControl core/model.lua -- Pure view-model layer: classify cells, build the matrix view-model, -- format whisper/report strings. NO WoW API, NO frames -- offline-testable -- under real Lua 5.0. -- -- Covers: cell states, ready pill, chat formats (whisper/report separators -- are pure ASCII: "-" and "|", no middle dots). -- -- Hard rules implemented here: -- * UNKNOWN is NEVER condensed into MISSING -- not in cells, not in sums, -- not in pills, not in footer, not in the report. -- Concretely: UNKNOWN cells count neither as ready nor as gap; they are -- excluded from BOTH sum numbers (sumHas/sumExpected count only -- determinate = HAS/MISSING cells). -- * Sums only for readable players (sumHas/sumExpected nil otherwise). -- * Footer and report count only readable players. -- * Report lines <= M.MAX_LINE (240) chars, overflow continues on -- " ... continued:" lines (chat hard limit is 255). -- * Whisper/report strings are pure ASCII. -- * Aura identity: spell-ID before name (different buffs can collide on -- one display name, e.g. "Shadow Protection" the buff vs. the potion -- effect). When the scan delivered ANY aura IDs -- for a player, a bare name match no longer counts for table entries -- that have known IDs (the aura carrying that name would have matched -- by ID if it really were that buff). Names stay authoritative for -- entries without known IDs (e.g. "Well Fed") and for ID-less scans. -- -- Deliberate resolutions (edge cases decided here, documented so they -- stay deliberate): -- * ench + EMPTY slot: an item property, not an expectation -- -- independent of the expectation checkbox. Slots that HAVE an -- exemption table (OFFH/RNGD) treat an EMPTY slot as NOTEXP "no item" -- (nothing to enchant; 2H users have no off hand, casters may have no -- ranged weapon). EVERY OTHER empty ench slot -- armor AND the -- enchantable neck/ring slots -- is ALWAYS a gap: MISSING with detail -- " empty", regardless of whether the slot is expected. Empty -- gear is a problem the expectation grid cannot suppress; the grid -- only governs the ENCHANT question for a slot that has an item (see -- the ench branch below). Trinket-kind tiles (T1/T2) mirror this: -- EMPTY is always MISSING " empty"; the expectation parameter -- is not even read for them. -- * Gear entries may carry an optional subType field (itemSubType, -- GetItemInfo return 6) -- required by the ENCH_EXEMPT check. Absent -- subType = no exemption. -- * Row "ready" = readable AND gaps==0 AND unknowns==0 ("verified -- complete" -- an unverified (UNKNOWN) cell can never be -- part of a green pill). -- * Trinket HAS detail is the gear entry TABLE (quality/name/texture for -- the tile + tooltip) and hit HAS detail is the breakdown table; all -- other details are strings or nil. -- * Footer name lists and the "Not readable" list are sorted -- alphabetically (deterministic across scans). -- -- Cell shape: -- aura/weapon cells: { state, detail, icon = texturePath|nil } -- icon ONLY when state==HAS; aura kind = texture of the matched buff -- name from player.auras.textures (optional store field -- nil-guarded, -- absent/partial textures => icon nil); weapon kind = always nil for -- now. For ID matches the matched name is idToName[id]; when that is -- nil the icon stays nil. -- ench/trinket cells: { state, detail, item = gearEntry|nil } -- item = the gear entry TABLE BY REFERENCE (model is read-only over -- the store, no copy) whenever the entry is a real item table -- HAS, -- enchant missing, subtype-exempt NOTEXP, and (ench kind only) the -- not-expected NOTEXP case (the item still rides along so the tile -- keeps rendering -- see the ench branch below); nil for "EMPTY" and -- unknown (gear is not consulted, or the slot never held an item). -- resist cells: { state, detail, value = number|nil } -- RESIST tab -- only (see below); value is the resistance total, set on HAS, nil on -- UNKNOWN. -- hit cells: { state, detail, value, cap, capMet } -- HIT tab only; -- value/cap are percentages with one decimal (set on HAS, nil -- otherwise), detail is the breakdown TABLE on HAS and a plain reason -- string on NOTEXP/UNKNOWN. -- classify() accordingly returns (state, detail, icon, item, value) -- -- the trailing returns are nil for callers/kinds that do not use them. -- `value` is used ONLY by kind=="resist" (build() copies it onto -- cell.value); every other kind returns it as nil. -- -- DEBUFFS tab, class exemptions, out-of-range flag: -- * DEBUFFS tab: vm.slots is built DYNAMICALLY from the store -- -- one column per distinct debuff name across READABLE players, -- sorted by afflicted count desc then alpha, capped at M.DEBUFF_CAP -- (10) columns; slot entries { id=name, label=nil, full=name, -- kind="debuff", icon=texture|nil }. Cell semantics: afflicted => -- MISSING (detail "Afflicted: ", icon = debuff texture -- the -- ONE kind whose icon rides on a non-HAS state); clean => HAS (icon -- nil); unreadable (aurasRead false) => UNKNOWN. Zero debuffs is a -- LEGITIMATE clean state -- an absent/empty player.debuffs on a -- readable player is HAS, never a read failure. Expectations are NOT -- consulted (every column applies to every role); whisperEligible is -- ALWAYS false on DEBUFFS; ready pill formula unchanged (readable, 0 -- gaps, 0 unknowns = "clean players"); footer/report machinery -- unchanged (afflicted counts per debuff). More than DEBUFF_CAP -- distinct debuffs: columns stay capped, vm.debuffMore carries the -- overflow and reportLines appends "+N more debuff types". -- * Class-based enchant exemption: DC.ENCH_CLASS_EXEMPT -- [slotId][class] => NOTEXP (detail "no ranged enchant for this -- class"), checked FIRST in the ench branch -- before gearRead/ -- UNKNOWN, before EMPTY, before the expectation -- so it wins over -- every other equipment outcome, readable or not. -- * Out-of-range flag: rowVM.far = true iff readable and -- player.distance and player.distance >= DC.FAR_LIMIT; -- vm.coverage.far = count. Diagnostic ONLY -- far changes no state, -- no sum, no pill. -- -- RESIST and HIT tabs (display only, no gaps): -- * Two static-column tabs share ONE set of rules. vm.slots is -- DC.SLOTS_RESIST (the five resistance schools) resp. DC.SLOTS_HIT -- (the eight hit columns), always ALL of them, unconditionally -- -- unlike the equipment/consumable tabs, nothing here is gated by -- expectations, there are none to consult. -- * classify() kind=="resist": not player.resistsRead => UNKNOWN (same -- unreadableReason convention as every other kind); otherwise ALWAYS -- HAS, value = player.resists[slotdef.school] or 0 (0 is a legitimate -- resistance, not a read failure), detail = ": ". -- NEVER MISSING, NEVER NOTEXP -- a display tab has no gaps. -- * Row/group aggregates on BOTH tabs: sumHas/sumExpected stay nil -- (mirrors DEBUFFS); whisperEligible is ALWAYS false; gaps stay 0 -- (nothing can produce MISSING here), so the ready pill is vacuously -- "all clean" and not rendered as a readiness claim by the UI. -- * vm.colAvg = { [slotId] = the column's average over the players who -- DELIVER that column } -- resistance columns average over -- player.resistsRead == true, hit columns over the players whose hit -- cell came out HAS. Computed straight off store.players (independent -- of row.readable, which tracks aura/online status, not resistance or -- hit readability). -- * vm.colCount = { [slotId] = the DENOMINATOR that column's average -- was divided by }. Anything that PRINTS a population (the report) -- must take it from here: the ranged hit column drops every player -- without a ranged weapon, so its denominator differs from any -- channel-wide reader count. Those channel counts are -- vm.resistReadable (resistsRead) and vm.hitReadable (hit.hitRead). -- * vm.resistAvg / vm.resistCount are kept as ALIASES of colAvg/ -- colCount (the same tables, not copies) so readers written when the -- resistances were the only display tab keep working. -- -- Hit columns (HIT tab, kind=="hit"): -- * DC.SLOTS_HIT carries EIGHT columns: melee and ranged hit -- (slotdef.hitKind "melee"/"ranged"), then one column per magic -- school (hitKind "spell" plus slotdef.school = "arcane" / "fire" / -- "frost" / "holy" / "nature" / "shadow"). A school column shows the -- generic spell hit PLUS that school's own bonuses -- which is the -- entire point of the tab: two casters of the same class can sit at -- different numbers per school. -- * They ride on the SAME display-only rules as the resistances: -- expectations are never consulted, row sums stay nil, no cell can -- become a gap, no whisper. -- * Readability is its own flag again: player.hit.hitRead. No hit table -- or hitRead == false => UNKNOWN. There is no hit API on this client -- (no GetHitModifier / GetSpellHitModifier / GetCombatRating), so the -- numbers are summed from item tooltips by the scan -- an unreadable -- unit yields no hit at all, never a 0. -- * Otherwise HAS with cell.value = the hit percentage (one decimal), -- cell.cap = the cap that applies to THIS player, cell.capMet = -- value >= cap, and cell.detail = the breakdown TABLE below (the -- second kind after trinket HAS whose detail is a table, not a -- string). -- * ONE exception to "readable => HAS": the ranged column is NOTEXP for -- a player without a physical ranged weapon (bow/gun/crossbow/thrown) -- -- a caster holding a wand has no ranged-hit question. NOTEXP needs -- readable data, so an unreadable player stays UNKNOWN (unlike the -- expectation kinds, where NOTEXP wins over UNKNOWN). -- * Melee and ranged caps shrink with weapon skill (the linear scaling -- of the current patch); the spell cap does not. The cap arithmetic -- itself is NOT reimplemented here -- DC_Hit.MeleeCap/RangedCap -- (scan/hit.lua, pure) own it, DC.HIT_CAPS (data/hit.lua) owns the -- constants, and every call is existence-guarded: without that module -- the cells show their numbers and make no cap claim. -- * The weapon skill behind a melee/ranged cap is resolved HERE, per -- build: the own character's MEASURED skill (hit.skillExact) wins, -- everyone else gets DC_Hit.SkillFor's estimate = base + racial -- specialization (player.race) + weapon-skill book. The book flags -- are the build() `books` param (db.skillBooks), so ticking one in -- the options changes the cap on the next REFRESH -- the scan -- deliberately does not bake it in, and re-deriving the estimate here -- is idempotent (a book is never added twice). Each column resolves -- its OWN skill line: a bow has nothing to do with the measured -- main-hand skill. -- * There is no separate ranged-hit item stat in 1.12: the generic -- "+N% chance to hit" line covers melee AND ranged, so both columns -- read the same summed gear number and differ only in cap and weapon -- skill. -- * THREE contributions per column, kept apart end to end so the cell -- tooltip can name each one instead of showing one unlabeled number: -- gear hit.gearMelee / hit.gearSpell, summed from item tooltips -- by scan/hit.lua. Falls back to the pre-split totals -- hit.melee / hit.spell when the split fields are absent, -- so a hand-built or older store still classifies right. -- auras hit.auraMelee / auraRanged / auraSpell, folded on top by -- scan/engine.lua's BuildPlayer from DC.HIT_AURAS. -- hit.aurasRead == false means the aura contribution is -- UNKNOWN, not a verified zero (the tooltip says so). -- talents hit.talentMelee / talentRanged / talentSpell, plus -- hit.talentSchools per school and hit.talentOffhand (the -- warrior off-hand talent -- deliberately NOT part of the -- melee number, it only rides in the tooltip). -- hit.melee / hit.spell stay the TOTAL for backward compatibility. -- * TALENTS ARE GATED BY hit.talentsRead, read STRICTLY (== true): -- GetTalentInfo has no foreign-unit channel, so other players' ranks -- arrive over this server's inspect protocol (scan/talents.lua) and -- land in the same fields via the same DC_Hit.TalentHit. Anything but -- true means no plausible reply has arrived YET (a "not yet", not an -- "impossible"), so the number EXCLUDES talents and every tooltip -- says exactly that. Reading the -- flag strictly is what keeps the number and that sentence from ever -- contradicting each other -- an absent flag never invents a bonus. -- * Per-school value = generic spell hit (gear + aura + talent) + that -- school's GEAR bonus (hit.schools, the school-only item lines) + -- that school's TALENT bonus (hit.talentSchools). School keys arrive -- in TWO spellings -- the gear table uses the DC.HIT_SCHOOLS display -- names ("Frost"), slots and talents the lower-case token ("frost") -- -- and are resolved case-insensitively with FIRST MATCH ONLY, never a -- sum, so a table carrying both spellings cannot double-count. -- * cell.detail (hit breakdown table): { kind, value, cap, capBase, -- capMet, gear, aura, aurasRead, talent, talentsRead, talentOffhand, -- generic, school, gearSchool, talentSchool, skill, skillBase, -- skillExact, skillName, book, dualWield, capWhite, mainSub, offSub, -- rangedSub, schools }. Everything the cell tooltip needs -- capBase -- is the unadjusted cap and skillBase the unmodified weapon skill, so -- the tooltip can show the derivation; the UI never recomputes hit -- math. `generic` is the school-independent part of a spell column, -- `schools` rides along on a school-LESS spell column only (on the -- HIT tab every school has its own column, so listing the others in a -- column's tooltip would be noise). The spell cap carries no -- capUnverified flag any more: it is derived from the server's own -- 99%-hit clamp (data/hit.lua DC.HIT_CAPS header), not a -- quoted-but-unmeasured number. -- * vm.colAvg holds one entry per hit column (average over the players -- whose hit cell is HAS, one decimal -- a NOTEXP ranged cell is left -- out instead of counted as 0) and vm.hitReadable is the channel's -- reader count. -- -- Weapon temp-enchant name: -- * weaponMainName: weapon-kind HAS detail = player.weaponMainName (the -- temp-enchant NAME the scan delivered) when set, else the generic -- "main hand temp enchant". States are untouched -- the name never -- influences HAS/MISSING/UNKNOWN (that stays weaponMain's job). -- -- Last-known display layer: -- THE IRON RULE: last-known data is DISPLAY-ONLY. It never counts as -- HAS/MISSING in gaps, ready pills, sums, footer or report -- those keep -- treating the cell as UNKNOWN. A flask seen 20 minutes ago is not -- verified preparation. -- * build() gains an OPTIONAL 5th param `now` (number|nil; nil => no -- ages computed). Matrix passes GetTime(); report/offline callers may -- omit it. -- * For every cell whose state is UNKNOWN, when the player carries -- matching last* data (p.lastAuras/p.lastGear/p.lastWeaponMain -- -- attached by scan/engine.lua from the runtime cache, or by core/sim), -- THAT data is classified with the SAME classify() rules via a light -- synthetic player view (no duplicated state machine) and the result -- rides as cell.last = { state, detail, icon, item, age } (age = -- now - when both known). last.state can be HAS or MISSING -- -- purely informational; a cached UNKNOWN/NOTEXP outcome attaches -- nothing (the plain gray "?" stays). Debuff cells have no last-known -- channel. -- * rowVM.gearUnreadable = (tab=="EQUIPMENT" and readable and not -- player.gearRead) -- the cross-continent inspect-limit case. -- rowVM.unreadNote = "offline" for offline rows, an "items not -- readable" observation for -- gearUnreadable rows, nil otherwise. -- -- Per-class expectations + DEBUFFS warning semantics: -- * Expectations are PER (role, class): expect[role][class][slotId]. -- build() resolves each row's line via (row role, player.class); an -- UNKNOWN combination -- role or class line missing -- falls back to -- the DC.DEFAULT_EXPECT role line. classify()'s -- signature is unchanged: the CALLER resolves `expected`. -- Dynamic columns: a slot column is active when ANY (role, -- performing class) resolves to expected -- the domain is -- DC_Roles.ROLE_CLASSES, and uncovered combos contribute their -- fallback line. Corollary: a legacy role x slot table passed as -- `expect` behaves exactly like before (every combo falls back to -- its role line). -- * DEBUFFS = WARNING SIGNAL, not expectation fulfillment: -- rowVM.sumHas/sumExpected stay NIL on DEBUFFS (no x/y); instead -- rowVM.debuffCount = TOTAL count of distinct debuff names on the -- player (player.debuffs.names -- INCLUDING names beyond the -- DEBUFF_CAP columns), nil when the row is not readable; and -- group.debuffTotal = sum of debuffCount over the group's readable -- rows. Gap semantics stay unchanged INTERNALLY: afflicted cells -- keep driving row sort weight, footer, report and the ready pill. -- (debuffCount/debuffTotal are computed on every tab -- they are -- player properties -- the UI reads them on DEBUFFS.) -- * reportLines section header: the DEBUFFS tab says -- "Debuffs:", every other tab keeps "Missing per slot:". DopingControl = DopingControl or {} local DC = DopingControl DC_Model = DC_Model or {} local M = DC_Model -- Max report line length; the client chat limit is 255, we stay below. M.MAX_LINE = 240 -- Max dynamic debuff columns on the DEBUFFS tab. M.DEBUFF_CAP = 10 -- ================================================================== -- Hit columns (HIT tab). -- The hit ARITHMETIC lives in scan/hit.lua (DC_Hit.SkillFor / MeleeCap / -- RangedCap / TalentHit, all pure) and the constants in data/hit.lua and -- data/talenthit.lua -- this layer only resolves which number a cell -- shows and whether the cap is met, so there is exactly ONE -- implementation of the cap math in the addon. -- Every DC_Hit call is existence-guarded: without that module the cells -- still show their percentages, they just make no cap claim (a degraded -- claim is worse than none). Without data/hit.lua the ranged column -- cannot tell a bow from a wand either and stays NOTEXP throughout -- -- again silence rather than a guess. -- ================================================================== -- Percentages are carried with ONE decimal everywhere (cells, caps, -- averages, report) -- rounding once keeps display and capMet comparison -- consistent (a 7.96 that renders as "8.0" must also count as capped). -- One-decimal text for a hit percentage, without the sign or unit -- -- shared by the whisper text so a whispered number reads exactly like the -- one in the cell the player is being told about. -- ------------------------------------------------------------------ -- Which hit columns a player is actually asked about. -- -- Every player HAS all eight hit columns -- a warrior's shadow hit is a -- real number, it is simply 0. Telling that warrior "shadow 0.0/16.0" is -- noise, so the whisper (and the below-cap counter that offers it) only -- looks at the columns that match the ROLE's attack kind. Heals cannot -- miss, so healers are asked about nothing at all. -- -- This governs WHISPERING only. Every column stays visible and keeps its -- own cap marking on the tab -- the display never hides a number because -- a role "should not care" about it. -- ------------------------------------------------------------------ M.HIT_ROLE_KINDS = { TANK = { melee = true }, MELEE = { melee = true }, RANGED = { melee = true, ranged = true }, CASTER = { spell = true }, HEALER = {}, } function M.HitRelevant(role, slotdef) local kinds = role and M.HIT_ROLE_KINDS[role] if not kinds or not slotdef then return false end return kinds[slotdef.hitKind] == true end function M.HitNum(v) return string.format("%.1f", M.RoundTenth(v or 0)) end function M.RoundTenth(v) if type(v) ~= "number" then return nil end return math.floor(v * 10 + 0.5) / 10 end -- Display text for a hit percentage: always one decimal ("8.0", "12.5"), -- so a column of hit numbers stays visually aligned. The UI and -- reportLines both go through this. function M.HitText(v) if type(v) ~= "number" then return "?" end return string.format("%.1f", v) end -- One talent contribution, gated by the read flag. An unread talent tree -- contributes NOTHING -- never a guess -- which is exactly what keeps the -- displayed number and the tooltip's "excludes talents" sentence from -- contradicting each other. local function talentValue(v, talentsRead) if not talentsRead then return 0 end return tonumber(v) or 0 end -- One school's bonus out of ONE source table. -- The same school reaches this layer in two spellings, because two -- sources do: gear extras are keyed by the capitalized tooltip word -- ("Frost"), slot definitions and talent tables by the lower-case id -- ("frost"). DC.HIT_SCHOOL_KEY (data/hit.lua) is the bridge and is -- consulted first; the case-insensitive fallbacks below keep this pure -- layer working when that table is absent (offline tests, partial load). -- The FIRST match wins -- never a sum -- so a table that happens to carry -- both spellings can not double-count. Missing table, missing key or a -- non-numeric value all read as 0. -- Why not DC_Hit.SchoolHit (scan/hit.lua): that helper answers "what is -- this school worth in total", while the cell tooltip has to name each -- SOURCE apart (gear vs talents), so the two amounts are looked up -- separately here and only summed for display. local function schoolAmount(tbl, school) if type(tbl) ~= "table" or type(school) ~= "string" or school == "" then return 0 end local v = tbl[school] if type(v) == "number" then return v end local lower = string.lower(school) local bridged = DC.HIT_SCHOOL_KEY and DC.HIT_SCHOOL_KEY[lower] if bridged then v = tbl[bridged] if type(v) == "number" then return v end end v = tbl[lower] if type(v) == "number" then return v end v = tbl[string.upper(string.sub(school, 1, 1)) .. string.lower(string.sub(school, 2))] if type(v) == "number" then return v end for k, n in pairs(tbl) do if type(k) == "string" and type(n) == "number" and string.lower(k) == lower then return n end end return 0 end -- Effective weapon skill for a hit column, resolved at DISPLAY time: -- * a MEASURED skill wins (only the own character has one -- scan/hit -- .lua reads it from the skill window and sets the exact flag), -- * everyone else gets DC_Hit.SkillFor's estimate = base + racial -- specialization + weapon-skill book. -- Resolving here rather than trusting a value the scan wrote is -- deliberate: the book flags are user input, so a checkbox click must -- take effect on the next refresh, not on the next scan. Re-deriving the -- estimate is also idempotent -- it never adds a book twice, whatever the -- scan already filled in. -- Each column passes its OWN measured triple (the main hand's skill has -- no bearing on a bow) and its own subtype. -- Returns skill, exact(bool), skillName -- all nil/false when the subtype -- is no weapon at all (nothing equipped, shield, relic): no skill claim. -- `books` is ONE player's flag table (db.skillBooks[playerName]). local function effectiveSkill(player, subType, books, measured, measuredExact, measuredName) if measuredExact and measured then return measured, true, measuredName end if DC_Hit and DC_Hit.SkillFor then local skill, skillName = DC_Hit.SkillFor(subType, player.race, books) if skill then return skill, false, skillName end end return nil, false, nil end -- ================================================================== -- Data indices, built lazily from DC.CONSUMABLES / DC.CLASSBUFFS -- (data/*.lua load before first use; call M.RebuildIndex() after -- mutating the data tables, e.g. in tests). -- ================================================================== local indexBuilt = false local nameHasIds = {} -- [buffName] = true when the table entry has known spell IDs local idToName = {} -- [spellID] = buffName (for HAS detail on ID match) function M.RebuildIndex() nameHasIds = {} idToName = {} if DC.CONSUMABLES then for name, def in pairs(DC.CONSUMABLES) do if def.spellID and def.spellID > 0 then nameHasIds[name] = true idToName[def.spellID] = name end end end if DC.CLASSBUFFS then for name, def in pairs(DC.CLASSBUFFS) do if def.ids and table.getn(def.ids) > 0 then nameHasIds[name] = true local n = table.getn(def.ids) for i = 1, n do idToName[def.ids[i]] = name end end end end indexBuilt = true end local function ensureIndex() if not indexBuilt then M.RebuildIndex() end end local function nameMapsToSlot(name, slotId) local c = DC.CONSUMABLES and DC.CONSUMABLES[name] if c and c.slot == slotId then return true end local b = DC.CLASSBUFFS and DC.CLASSBUFFS[name] if b and b.slot == slotId then return true end return false end -- ================================================================== -- classify(player, slotdef, expected, books) -- -> state, detail, icon, item, value -- State machine per slotdef.kind (icon/item/value extra returns per the -- cell shape documented in the header). -- `books` is ONE player's weapon-skill-book flags -- (db.skillBooks[playerName]) and is read by the hit kind ONLY -- every -- other kind ignores it, so three-argument callers keep working. -- NOTEXP takes precedence over UNKNOWN (an unexpected slot is "not -- expected" even for an unreadable player) for the KINDS that consult -- `expected` at all -- debuff/resist ignore it outright (see their -- branches), and ench/trinket consult it LATER than every other kind -- (see the ench/trinket branch below: gear facts -- readability, class -- exemption, EMPTY -- are resolved FIRST, `expected` only ever decides -- the narrower "is this item enchanted" question). -- ================================================================== function M.classify(player, slotdef, expected, books) local S = DC.STATE local kind = slotdef.kind if kind == "debuff" then -- Expectations are NOT consulted on debuff columns -- (every column applies to every role) -- the `expected` param -- is deliberately ignored, so this branch sits BEFORE the -- expected-check. Readability = the existing aurasRead flag. if not player.aurasRead then return S.UNKNOWN, player.unreadableReason or "no data" end local d = player.debuffs if d and d.names and d.names[slotdef.id] then -- afflicted: icon = debuff texture (player's own entry wins, -- column icon as fallback); the ONE non-HAS icon case local icon = slotdef.icon if d.textures and d.textures[slotdef.id] then icon = d.textures[slotdef.id] end return S.MISSING, "Afflicted: " .. slotdef.full, icon end -- zero debuffs / absent debuffs field on a readable player is a -- LEGITIMATE clean state -- never read failure return S.HAS, nil end if kind == "resist" then -- RESIST tab: display only, expectations are NEVER consulted (the -- `expected` param is deliberately ignored, like debuff above). -- Readability rides on its OWN flag (player.resistsRead) -- fully -- independent of aurasRead/gearRead, since a foreign -- UnitResistance read is neither an aura nor a gear read. if not player.resistsRead then return S.UNKNOWN, player.unreadableReason or "no data" end local v = (player.resists and player.resists[slotdef.school]) or 0 -- NEVER MISSING, NEVER NOTEXP -- a display tab has no gaps; the -- resistance total rides as the 5th return (value), copied onto -- cell.value by build() (see the cell shape doc in the header). -- -- Provenance suffix (binding honesty rule, see scan/engine.lua's -- AssembleResists header): a RECONSTRUCTED number is not the same -- kind of fact as a MEASURED one. player.resistsSource == "gear" -- means this total came from item tooltips + the racial bonus, not -- from UnitResistance/GetUnitField -- the detail string says so -- rather than presenting a rebuilt number as an unqualified -- reading. Every other source ("token"/"guid"/"field", or nil for -- the always-measured own character) prints unchanged. local detail = slotdef.full .. ": " .. v if player.resistsSource == "gear" then detail = detail .. " (from gear)" end return S.HAS, detail, nil, nil, v end if kind == "hit" then -- HIT tab, display only: expectations are NEVER consulted (the -- `expected` param is ignored, like debuff/resist above), and no -- outcome can ever be MISSING -- a hit number below its cap is -- information, not a gap the addon nags about. local h = player.hit if not (h and h.hitRead) then -- no hit API exists on this client; without readable item -- tooltips there is no number at all (never a 0) return S.UNKNOWN, player.unreadableReason or "no data" end local caps = DC.HIT_CAPS or {} local hitKind = slotdef.hitKind or "melee" -- Talents: readable for the OWN character only, so the flag is -- read strictly (see the header). Anything but true keeps every -- talent contribution at 0 AND makes the tooltip say the number -- excludes talents -- one flag drives both. local talentsRead = (h.talentsRead == true) local b = { kind = hitKind, skillBase = DC.BASE_WEAPON_SKILL or 300, mainSub = h.mainSub, offSub = h.offSub, -- hit-granting auras (DC.HIT_AURAS, data/hit.lua) are folded -- into hit.gearX/hit.auraX at scan time (scan/engine.lua -- BuildPlayer) -- b.aura carries just the AURA portion so the -- tooltip can show "from gear" and "from auras" apart. A -- missing aurasRead flag (older/hand-built stores) reads as -- true: the aura contribution is a known zero, not unknown. aurasRead = (h.aurasRead ~= false), talentsRead = talentsRead, } if hitKind == "spell" then b.gear = h.gearSpell or h.spell or 0 -- SET bonuses are their own addend: scan/engine.lua already -- took them OUT of gearSpell, so adding them here counts them -- exactly once and lets the tooltip list them on their own line b.set = h.setSpell or 0 b.aura = h.auraSpell or 0 b.talent = talentValue(h.talentSpell, talentsRead) -- the school-INDEPENDENT part: what every school column has in -- common, printed as its own tooltip line there b.generic = M.RoundTenth(b.gear + b.set + b.aura + b.talent) -- spell hit knows no weapon skill: base cap, unadjusted. The -- cap itself is DERIVED from the server's 99% hit clamp (data/ -- hit.lua DC.HIT_CAPS header), not a measured value, but that -- is no longer flagged as "unverified" -- see the header note. b.capBase = M.RoundTenth(caps.spell) b.cap = b.capBase if slotdef.school then -- School column: generic spell hit + this school's gear -- bonus + this school's talent bonus. That sum is the -- whole reason the tab exists -- two casters of one class -- differ per school. b.schools stays nil here: every -- other school has its own column. b.school = slotdef.school -- h.schools is the TOTAL per school (items + set bonuses), -- so the set part is split out here too -- otherwise a -- school-specific set bonus would land in both lines b.setSchool = schoolAmount(h.setSchools, slotdef.school) b.gearSchool = schoolAmount(h.schools, slotdef.school) - b.setSchool b.talentSchool = 0 if talentsRead then b.talentSchool = schoolAmount(h.talentSchools, slotdef.school) end b.value = M.RoundTenth(b.generic + b.gearSchool + b.setSchool + b.talentSchool) else -- Generic spell column (no school on the slot): a -- school-specific bonus counts against that school ONLY, -- so it never enters the value; the whole table rides -- along for the tooltip (by reference, the model never -- copies store tables). b.value = b.generic b.schools = h.schools end else -- one gear number for melee AND ranged: this client has no -- separate ranged-hit item stat b.gear = h.gearMelee or h.melee or 0 b.set = h.setMelee or 0 if hitKind == "ranged" then local ranged = DC.RANGED_ATTACK_SUBTYPE or {} if not (h.rangedSub and ranged[h.rangedSub]) then -- Readable, but there is no ranged-hit question to -- ask. The two causes get their OWN reason, because -- the wand sentence is a non-sequitur for a player -- who simply carries nothing there -- and this string -- is what the cell tooltip prints verbatim. if h.rangedSub then return S.NOTEXP, "a wand or relic is not a ranged attack weapon" end return S.NOTEXP, "nothing in the ranged slot" end b.aura = h.auraRanged or 0 b.talent = talentValue(h.talentRanged, talentsRead) b.value = M.RoundTenth(b.gear + b.set + b.aura + b.talent) b.rangedSub = h.rangedSub -- the ranged skill is its OWN skill line (bows/guns/...), -- so the measured MAIN-HAND skill must not leak into it b.skill, b.skillExact, b.skillName = effectiveSkill(player, h.rangedSub, books, h.rangedSkill, h.rangedSkillExact, nil) b.capBase = M.RoundTenth(caps.ranged) if DC_Hit and DC_Hit.RangedCap then local rc = DC_Hit.RangedCap(h.rangedSub, b.skill, caps) if rc then b.cap = rc.cap b.skillName = b.skillName or rc.skillName end end else b.aura = h.auraMelee or 0 b.talent = talentValue(h.talentMelee, talentsRead) -- Off-hand hit talent (the warrior dual-wield talent): -- it raises OFF-HAND swings only, so it is deliberately -- NOT added to the melee number -- the tooltip mentions -- it while the player actually holds two weapons. b.talentOffhand = talentValue(h.talentOffhand, talentsRead) b.value = M.RoundTenth(b.gear + b.set + b.aura + b.talent) b.skill, b.skillExact, b.skillName = effectiveSkill(player, h.mainSub, books, h.skill, h.skillExact, h.skillName) b.skillName = b.skillName or h.skillName b.capBase = M.RoundTenth(caps.meleeYellow) if DC_Hit and DC_Hit.MeleeCap then local mc = DC_Hit.MeleeCap(h.mainSub, h.offSub, b.skill, caps) b.cap = mc.yellow b.dualWield = mc.dualWield if mc.dualWield then -- white swings keep their own, much higher cap. -- Stated at its BASE too (see the skill note -- below): the boss's floor does not move. b.capWhite = M.RoundTenth(caps.meleeWhiteDW) end b.skillName = b.skillName or mc.skillName end end -- did a ticked book go into this column's skill? (a MEASURED -- skill already contains it, the tooltip says so instead) b.book = (books and b.skillName and books[b.skillName]) and true or false end -- WEAPON SKILL counts on the PLAYER's side of the comparison, not -- as a discount on the boss's requirement. The two readings are -- arithmetically identical -- -- value >= cap - d <=> value + d >= cap -- -- but a +3-level boss's miss floor is a property of the BOSS and -- does not move because the attacker trained a weapon; skill is -- something the player brings, exactly like gear or a talent. So -- the skill-derived part becomes an addend of the value and the cap -- is reported at its base. b.skillHit carries it so the tooltip can -- show it as its own row of the addition. if b.cap and b.capBase then b.skillHit = M.RoundTenth(b.capBase - b.cap) if b.skillHit ~= 0 then b.value = M.RoundTenth(b.value + b.skillHit) end b.cap = b.capBase else b.skillHit = 0 end if b.cap == nil then -- no cap claim without the hit module (see the section -- header) -- the number itself is still shown b.capMet = false else b.capMet = b.value >= b.cap end return S.HAS, b, nil, nil, b.value end if kind == "ench" or kind == "trinket" then -- Equipment: gaps are shaped by what is EQUIPPED, not by the -- expectation grid. Order: class exemption (ench only, wins over -- everything -- e.g. a shaman never has a ranged enchant slot, -- readable or not), then gearRead/UNKNOWN, then whether the slot -- is EMPTY (always MISSING except the legitimately-empty -- exemptable case -- see the header). `expected` is consulted -- LAST and ONLY for the ench-kind "is this item enchanted" -- question; trinket-kind tiles never read `expected` at all -- -- an equipped item is HAS, an empty tile is MISSING, always. if kind == "ench" then local classExempt = DC.ENCH_CLASS_EXEMPT and DC.ENCH_CLASS_EXEMPT[slotdef.id] if classExempt and player.class and classExempt[player.class] then return S.NOTEXP, "no ranged enchant for this class" end end if not player.gearRead then return S.UNKNOWN, player.unreadableReason or "no data" end local entry = player.gear and player.gear[slotdef.invSlot] if entry == nil then return S.UNKNOWN, "no data" end local exempt = DC.ENCH_EXEMPT and DC.ENCH_EXEMPT[slotdef.id] if entry == "EMPTY" then if kind == "ench" and exempt then -- exemptable slot (off hand / ranged) with no item: -- nothing to enchant, and the item itself would have been -- exempt anyway -- legitimately empty, not a gap return S.NOTEXP, "no item" end -- every other empty slot -- armor, the enchantable neck/ring -- slots, and the trinket tiles -- is ALWAYS a gap, no matter -- what the expectation grid says (see the header) return S.MISSING, slotdef.full .. " empty" end -- entry is a table from here on -> it rides along as cell.item -- (by reference; the model never copies gear entries) if kind == "trinket" then -- tiles ignore `expected` entirely: an equipped item is HAS return S.HAS, entry, nil, entry end if exempt and entry.subType and exempt[entry.subType] then return S.NOTEXP, entry.subType .. " - not enchantable", nil, entry end if not expected then -- enchant not expected for this role/class: the item is real -- (not empty), so the tile still renders -- NOTEXP carries -- the item, counts nowhere, no green/red marker (ui/matrix.lua) return S.NOTEXP, nil, nil, entry end if entry.enchant and entry.enchant > 0 then return S.HAS, entry.name, nil, entry end return S.MISSING, "no enchant", nil, entry end if not expected then return S.NOTEXP, nil end if kind == "aura" then if not player.aurasRead then return S.UNKNOWN, player.unreadableReason or "no data" end ensureIndex() local auras = player.auras or {} local ids = auras.ids or {} local names = auras.names or {} local textures = auras.textures -- optional store field, nil-safe local spellToSlot = DC.SPELL_TO_SLOT or {} -- ID pass: spell ID is the primary identity for id in pairs(ids) do if spellToSlot[id] == slotdef.id then local nm = idToName[id] if nm then local icon = nil if textures then icon = textures[nm] end return S.HAS, nm .. " (spell " .. id .. ")", icon end -- no name carried the id -> icon stays nil (by design) return S.HAS, "spell " .. id end end -- Name fallback: when IDs were readable, only names WITHOUT known -- IDs still count (name-collision rule: the Shadow Protection -- potion aura must not satisfy the SPROT class-buff slot). local haveIds = (next(ids) ~= nil) for name in pairs(names) do if nameMapsToSlot(name, slotdef.id) then if not (haveIds and nameHasIds[name]) then -- Consumable/class-buff name collision (decision -- 2026-08-15, data/classbuffs.lua -- DC.CONSUMABLE_CLASSBUFF_NAME_COLLISION): a -- name-only match (this branch only runs when no -- spell ID resolved the buff) can never satisfy a -- CONSUMABLE slot when the name is also a class buff -- name -- UNKNOWN, not HAS. One-sided: the class-buff -- slot side of the same name is unaffected (see the -- constant's comment for why). local cdef = DC.CONSUMABLES and DC.CONSUMABLES[name] local collide = DC.CONSUMABLE_CLASSBUFF_NAME_COLLISION if cdef and cdef.slot == slotdef.id and collide and collide[name] then return S.UNKNOWN, "name-only, ambiguous with class buff: " .. name end local icon = nil if textures then icon = textures[name] end return S.HAS, name, icon end end end return S.MISSING, nil elseif kind == "weapon" then if player.weaponMain == nil then return S.UNKNOWN, player.unreadableReason or "no data" end if player.weaponMain then -- enchant name when the scan delivered one if player.weaponMainName then return S.HAS, player.weaponMainName end return S.HAS, "main hand temp enchant" end return S.MISSING, nil end return S.UNKNOWN, "unknown slot kind" end -- ================================================================== -- Gap label for chat output (whisper + report): -- consumables/class buffs -> full slot name; enchant -> "Enchant "; -- trinket -> "Trinket N empty". -- ================================================================== function M.gapLabel(slotdef) if slotdef.kind == "ench" then return "Enchant " .. slotdef.full elseif slotdef.kind == "trinket" then return slotdef.full .. " empty" end return slotdef.full end -- ================================================================== -- build(store, roleOf, expect, tab, now, books) -> vm -- roleOf(playerEntry) -> role, suggested(bool), reason(string|nil) -- expect = per-class expectation table from db: -- expect[role][class][slotId] = true|nil; unknown (role, -- class) combos fall back to the DC.DEFAULT_EXPECT role line -- tab = "CONSUMABLES" | "CLASSBUFFS" | "DEBUFFS" | "RESIST" | -- "HIT" | "EQUIPMENT" -- now = optional clock (GetTime seconds) for cell.last.age -- nil -- => no ages computed -- books = optional db.skillBooks (weapon-skill-book flags per player -- NAME, then per weapon skill). Read by the hit columns only; -- nil => nobody owns a book. Passed in rather than read from a -- global so the model stays offline-testable, and resolved on -- every build so a checkbox click shows up on the next refresh -- instead of the next scan. -- vm.unassigned = { count = n, names = sorted array } -- players whose -- role is only a heuristic suggestion (suggested==true), i.e. no -- confirmed assignment. Counts ALL players, readable or not: role -- assignment is independent of readability. -- ================================================================== local function tabSlots(tab) if tab == "CLASSBUFFS" then return DC.SLOTS_CLASSBUFFS elseif tab == "EQUIPMENT" then return DC.SLOTS_EQUIPMENT end return DC.SLOTS_CONSUMABLES end -- DISPLAY tabs: static columns, no expectations, no gaps, no sums, no -- whisper, a per-column average instead of a missing count. Named once so -- the four places that implement those rules cannot drift apart when a -- third display tab appears. function M.IsDisplayTab(tab) return tab == "RESIST" or tab == "HIT" end -- ================================================================== -- ChannelRead(player, tab) -> bool -- Did the read channel THIS TAB DRAWS FROM succeed for this player? -- Row readability (and every cell state) rides on the aura channel, -- which is raid-wide; gear and hit are near-range only, and foreign -- resistances are not transmitted at all, so one global count -- over-reports on every tab that is not aura-backed. Measured in a -- 26-man raid: auras 26/26, gear 15/26, resistances 1/26 while the -- pill said 26/26 everywhere. -- The offline gate comes first on purpose: a player who dropped out -- keeps whatever flags the last successful scan left behind, and those -- must not re-inflate the number that exists to stay honest. -- Deliberately NOT wired into row.readable -- that flag decides cell -- states and sums, and turning it per-tab would silently rewrite what -- UNKNOWN means on three tabs. This is the reporting number only. -- ================================================================== function M.ChannelRead(p, tab) if p.online == false then return false end if tab == "RESIST" then return p.resistsRead and true or false elseif tab == "HIT" then return (p.hit and p.hit.hitRead) and true or false elseif tab == "EQUIPMENT" then return p.gearRead and true or false end return p.aurasRead and true or false end -- ================================================================== -- CoverageLabel(tab) -> string -- The noun behind the coverage number. "readable" used to sit on the -- pill AND (as "AVERAGE (READABLE)") in the footer while meaning two -- different populations; naming the channel on the pill removes the -- collision instead of papering over it, and says in-game which read -- the number is about. Plain ASCII -- the 1.12 chat/UI font. -- ================================================================== function M.CoverageLabel(tab) if tab == "RESIST" then return "resist read" elseif tab == "HIT" then return "hit read" elseif tab == "EQUIPMENT" then return "gear read" end return "readable" end -- ------------------------------------------------------------------ -- Per-(role,class) expectation resolution. -- expect[role][class] is the explicit line; a missing role or class -- line falls back to the DC.DEFAULT_EXPECT role line, so -- saved tables that predate a class/role addition keep sane behavior. -- classify() stays untouched: these helpers run in the CALLER. -- ------------------------------------------------------------------ local EMPTY_LINE = {} local function expectLine(expect, role, class) local byClass = expect and expect[role] if byClass and class then local line = byClass[class] if type(line) == "table" then return line end end local def = DC.DEFAULT_EXPECT and DC.DEFAULT_EXPECT[role] return def or EMPTY_LINE end -- A slot column is active when ANY (role, performing class) resolves to -- expected. The domain is role x DC_Roles.ROLE_CLASSES -- [role] -- resolved with the same fallback as row cells, so uncovered -- combos keep their default columns visible. local function anyClassExpects(expect, slotId) local nRoles = table.getn(DC.ROLES) for r = 1, nRoles do local role = DC.ROLES[r] local classes = DC_Roles and DC_Roles.ROLE_CLASSES and DC_Roles.ROLE_CLASSES[role] if classes then for c = 1, table.getn(classes) do if expectLine(expect, role, classes[c])[slotId] then return true end end else -- defensive: without DC_Roles fall back to the role line if expectLine(expect, role, nil)[slotId] then return true end end end return false end -- Dynamic DEBUFFS columns: one column per distinct -- debuff name across READABLE players (same readability rule as the row -- loop), sorted by afflicted count desc then alpha, capped at -- M.DEBUFF_CAP. Returns slots array + overflow count (distinct beyond -- the cap; the report appends "+N more debuff types" from it). local function buildDebuffSlots(store) local counts = {} local icons = {} local names = {} local players = (store and store.players) or {} local nPlayers = table.getn(players) for i = 1, nPlayers do local p = players[i] -- readability rule: offline / aurasRead==false -- players contribute no columns if p.online ~= false and p.aurasRead and p.debuffs and p.debuffs.names then for nm in pairs(p.debuffs.names) do if counts[nm] == nil then counts[nm] = 0 table.insert(names, nm) end counts[nm] = counts[nm] + 1 if icons[nm] == nil and p.debuffs.textures then icons[nm] = p.debuffs.textures[nm] end end end end table.sort(names, function(a, b) if counts[a] ~= counts[b] then return counts[a] > counts[b] end return a < b end) local total = table.getn(names) local keep = total if keep > M.DEBUFF_CAP then keep = M.DEBUFF_CAP end local slots = {} for i = 1, keep do local nm = names[i] table.insert(slots, { id = nm, label = nil, full = nm, kind = "debuff", icon = icons[nm], }) end return slots, total - keep end -- Group-internal sort: gaps first (gaps desc), then unreadable, then -- complete; ties stable by name (table.sort is NOT stable in Lua 5.0, -- so name is an explicit final tiebreaker -> total deterministic order). local function rowLess(a, b) if a.sortBucket ~= b.sortBucket then return a.sortBucket < b.sortBucket end if a.sortBucket == 1 and a.gaps ~= b.gaps then return a.gaps > b.gaps end return a.player.name < b.player.name end function M.build(store, roleOf, expect, tab, now, books) local S = DC.STATE local slots local debuffMore = 0 if tab == "DEBUFFS" then -- columns come from the STORE, not from a data table, and -- expectations play no part slots, debuffMore = buildDebuffSlots(store) elseif tab == "RESIST" then -- static columns, always all 5, expectations play no part -- (display-only tab -- see the header) slots = DC.SLOTS_RESIST or {} elseif tab == "HIT" then -- static columns, always all 8 (melee, ranged, six schools) -- -- the second display-only tab, same rules throughout slots = DC.SLOTS_HIT or {} else local allSlots = tabSlots(tab) -- Dynamic columns: a slot is active -- iff at least one (role, performing class) expects it; trinkets -- are ALWAYS active on EQUIPMENT. slots = {} local nAll = table.getn(allSlots) for i = 1, nAll do local sd = allSlots[i] local active = false if sd.kind == "trinket" then active = true else active = anyClassExpects(expect, sd.id) end if active then table.insert(slots, sd) end end end local nSlots = table.getn(slots) -- Groups: always all five, fixed order. local groups = {} local groupsByRole = {} local nRoles = table.getn(DC.ROLES) for r = 1, nRoles do local role = DC.ROLES[r] local g = { role = role, label = DC.ROLE_LABEL[role], rows = {}, total = 0, readable = 0, unreadable = 0, gaps = 0, ready = 0, -- sum of debuffCount over readable rows debuffTotal = 0, } table.insert(groups, g) groupsByRole[role] = g end -- Footer: one entry per ACTIVE slot (count 0 included). local footer = {} for i = 1, nSlots do footer[slots[i].id] = { count = 0, names = {} } end -- readable = the aura-channel/row-readability count (unchanged, it -- backs the cell states); channelReadable = what THIS tab's own read -- channel actually covers -- see M.ChannelRead local coverage = { readable = 0, total = 0, offline = 0, readfailed = 0, far = 0, channelReadable = 0 } local unreadableNames = {} local unassignedNames = {} local players = store.players or {} local nPlayers = table.getn(players) coverage.total = nPlayers for i = 1, nPlayers do local p = players[i] -- Readability rule (binding): online==false => -- offline; otherwise aurasRead==false => read failed. Distance is -- diagnosis only, never a filter. local readable, urReason if p.online == false then readable = false urReason = p.unreadableReason or "offline" coverage.offline = coverage.offline + 1 elseif not p.aurasRead then readable = false urReason = p.unreadableReason or "read failed" coverage.readfailed = coverage.readfailed + 1 else readable = true end if readable then coverage.readable = coverage.readable + 1 else table.insert(unreadableNames, p.name) end if M.ChannelRead(p, tab) then coverage.channelReadable = coverage.channelReadable + 1 end -- Out-of-range flag: diagnostic ONLY -- far -- changes no cell state, no sum, no pill. Unreadable players are -- never "far" (their distance is already pure diagnosis). local far = false if readable and p.distance and DC.FAR_LIMIT and p.distance >= DC.FAR_LIMIT then far = true coverage.far = coverage.far + 1 end local role, suggested, roleReason = roleOf(p) if not groupsByRole[role] then role = DC.ROLES[1] -- defensive: roleOf must always return a known role end -- Unassigned aggregate: suggested == the role is only a heuristic -- proposal, not a confirmed assignment. EVERY such player counts, -- unreadable ones too (assignment is independent of readability). if suggested then table.insert(unassignedNames, p.name) end -- per-class expectation line: explicit -- expect[role][player.class] when present, else the -- DC.DEFAULT_EXPECT role line (default fallback) local rexp = expectLine(expect, role, p.class) -- this player's weapon-skill-book flags (hit columns only) local pBooks = books and books[p.name] or nil -- Last-known display layer: synthetic read-only -- player views over the cached tables, built ONCE per row, so the -- SAME classify() state machine below can re-run on the cached -- data (no duplicated rules). IRON RULE: results only decorate -- UNKNOWN cells as cell.last -- no counter below ever reads them. local lastAuraView, lastGearView, lastWeaponView if p.lastAuras then lastAuraView = { name = p.name, class = p.class, online = true, aurasRead = true, auras = p.lastAuras } end if p.lastGear then lastGearView = { name = p.name, class = p.class, online = true, gearRead = true, gear = p.lastGear } end if p.lastWeaponMain ~= nil then lastWeaponView = { name = p.name, class = p.class, online = true, weaponMain = p.lastWeaponMain, weaponMainName = p.lastWeaponMainName } end local cells = {} local gaps, unknowns, has = 0, 0, 0 -- HIT tab only: columns that are SHORT OF THEIR CAP. The hit -- tab produces no MISSING cells (a number below its cap is -- information, not an unfilled slot), so it needs its own -- counter to answer "is there anything to tell this player". local belowCap = 0 for si = 1, nSlots do local sd = slots[si] local expd = false if sd.kind == "debuff" or sd.kind == "resist" or sd.kind == "hit" then -- expectations are NOT consulted on debuff/resist/hit -- columns; classify ignores the flag anyway expd = true elseif rexp[sd.id] then expd = true end local state, detail, icon, item, value = M.classify(p, sd, expd, pBooks) cells[sd.id] = { state = state, detail = detail, icon = icon, item = item } if sd.kind == "resist" then cells[sd.id].value = value elseif sd.kind == "hit" then -- value/cap/capMet are lifted out of the breakdown table -- so the UI can paint a cell without unpacking it; they -- stay nil on NOTEXP/UNKNOWN (detail is a string there) cells[sd.id].value = value if type(detail) == "table" then cells[sd.id].cap = detail.cap cells[sd.id].capMet = detail.capMet -- BELOW-CAP counter -- the hit tab's equivalent of a -- gap, and the only thing that makes a hit row worth -- whispering about. A column with NO cap known makes -- no claim (capMet is false there too), so the cap -- itself has to be present before this counts. if detail.cap and detail.capMet == false and M.HitRelevant(role, sd) then belowCap = belowCap + 1 end end end if state == S.MISSING then gaps = gaps + 1 if readable then local f = footer[sd.id] f.count = f.count + 1 table.insert(f.names, p.name) end elseif state == S.UNKNOWN then unknowns = unknowns + 1 -- cell.last: classify the cached data with -- the same rules; attach only determinate outcomes (a -- cached UNKNOWN/NOTEXP keeps the plain gray "?"). Debuff -- kind has no last-known channel (no view is ever built). local view, at if sd.kind == "aura" then view = lastAuraView at = p.lastAurasAt elseif sd.kind == "weapon" then view = lastWeaponView at = p.lastWeaponAt elseif sd.kind == "ench" or sd.kind == "trinket" then view = lastGearView at = p.lastGearAt end if view then local ls, ld, li, lit = M.classify(view, sd, expd) if ls == S.HAS or ls == S.MISSING then local age = nil if now and at then age = now - at end cells[sd.id].last = { state = ls, detail = ld, icon = li, item = lit, age = age } end end elseif state == S.HAS then has = has + 1 end end -- total count of DISTINCT debuff names on the -- player -- straight off player.debuffs.names, so it INCLUDES -- names beyond the DEBUFF_CAP columns (warning signal, not -- column arithmetic). nil when the row is not readable; zero -- debuffs on a readable player is the verified-clean case. local debuffCount = nil if readable then debuffCount = 0 if p.debuffs and p.debuffs.names then for _ in pairs(p.debuffs.names) do debuffCount = debuffCount + 1 end end end local row = { player = p, tab = tab, role = role, suggested = suggested and true or false, reason = roleReason, readable = readable, unreadableReason = urReason, cells = cells, gaps = gaps, unknowns = unknowns, far = far, debuffCount = debuffCount, -- Class buffs are the BUFFER's duty, not the recipient's: -- whispering the player who lacks Arcane Intellect is -- pointless. Gaps on that tab go to the raid report, -- which already names slot + affected players. Debuffs are -- nobody's shopping list either: whisperEligible is ALWAYS -- false on DEBUFFS. The display tabs never produce gaps at -- all (gaps stays 0 there), but they are excluded explicitly -- too -- belt and suspenders, a display tab is never whisper -- bait. -- HIT is the one display tab a player CAN act on: being -- short of the hit cap is fixable by that player (gear, -- enchants, a weapon-skill book), unlike a resistance total. -- So it gets the whisper button, driven by belowCap instead -- of gaps -- RESIST stays silent. belowCap = belowCap, whisperEligible = readable and ((tab == "HIT" and belowCap > 0) or (gaps > 0 and tab ~= "CLASSBUFFS" and tab ~= "DEBUFFS" and not M.IsDisplayTab(tab))), } -- last-known row flags: an EQUIPMENT row that is readable by -- the aura rule but whose gear could not be read (cross-continent -- inspect limit) -- the UI renders a row-spanning note instead of -- 13 bare "?" cells. -- -- unreadNote states the OBSERVATION, not a guessed cause. It used -- to say "out of range", which named one possible reason as if it -- were measured: gearRead is false whenever not one of the 16 -- inventory slots returned a link, and distance is only one way -- that happens (no SuperWoW, a failed inspect, a different -- continent). The measured distance is printed separately by the -- UI, so a reader still gets the range hypothesis -- as a number -- they can judge, not as a verdict. row.gearUnreadable = false if tab == "EQUIPMENT" and readable and not p.gearRead then row.gearUnreadable = true end if p.online == false then row.unreadNote = "offline" elseif row.gearUnreadable then -- SHORT on purpose: this lands in the small status chip next -- to the player's name, which has room for a few words. The -- reason it is not "out of range" is that distance is only -- ONE way the gear read comes back empty -- the full sentence -- lives in the row-spanning message and the cell tooltip, -- where there is space for it. row.unreadNote = "items not readable" end if readable and tab ~= "DEBUFFS" and not M.IsDisplayTab(tab) then -- Sums over determinate cells only: UNKNOWN is excluded from -- BOTH numbers (never condensed into sums). -- On DEBUFFS and the display tabs the sums stay NIL even for -- readable rows (warning signal / display tab, no x/y -- the -- sum column renders debuffCount or nothing instead). row.sumHas = has row.sumExpected = has + gaps end if not readable then row.sortBucket = 2 elseif gaps > 0 then row.sortBucket = 1 else row.sortBucket = 3 end local g = groupsByRole[role] table.insert(g.rows, row) g.total = g.total + 1 if readable then g.readable = g.readable + 1 g.gaps = g.gaps + gaps g.debuffTotal = g.debuffTotal + (debuffCount or 0) -- ready = verified complete: readable, 0 gaps, 0 unknowns -- (an UNKNOWN cell never counts toward a green pill) if gaps == 0 and unknowns == 0 then g.ready = g.ready + 1 end else g.unreadable = g.unreadable + 1 end end for r = 1, nRoles do table.sort(groups[r].rows, rowLess) end for i = 1, nSlots do table.sort(footer[slots[i].id].names) end table.sort(unreadableNames) table.sort(unassignedNames) -- Display-tab averages (RESIST + HIT): computed straight off -- store.players, independent of row.readable (which tracks aura/ -- online status -- resistance and hit readability are their own -- flags, player.resistsRead and player.hit.hitRead). nil on every -- other tab. -- Hit columns bring their own denominator per column: a cell that is -- not HAS -- an unreadable player, or the ranged column of a player -- without a ranged weapon -- is left OUT of the average instead of -- being counted as 0. classify() is re-run for those columns so the -- average can never drift away from the rendered cells (same trick as -- the last-known layer: one state machine). local colAvg, colCount, resistReadable, hitReadable if M.IsDisplayTab(tab) then colAvg = {} resistReadable = 0 hitReadable = 0 local sums, counts = {}, {} -- the DENOMINATOR each average was actually divided by, per -- column: it equals resistReadable for the five resistances, but -- the ranged hit column skips every NOTEXP player, so a report -- that printed hitReadable next to it would name a population the -- average never covered colCount = counts for i = 1, nPlayers do local p = players[i] if p.resistsRead then resistReadable = resistReadable + 1 end local hitRead = (p.hit and p.hit.hitRead) and true or false if hitRead then hitReadable = hitReadable + 1 end for si = 1, nSlots do local sd = slots[si] if sd.kind == "hit" then if hitRead then local hs, _, _, _, hv = M.classify(p, sd, true, books and books[p.name] or nil) if hs == S.HAS and hv then sums[sd.id] = (sums[sd.id] or 0) + hv counts[sd.id] = (counts[sd.id] or 0) + 1 end end elseif p.resistsRead then local v = (p.resists and p.resists[sd.school]) or 0 sums[sd.id] = (sums[sd.id] or 0) + v counts[sd.id] = (counts[sd.id] or 0) + 1 end end end for si = 1, nSlots do local sd = slots[si] local n = counts[sd.id] or 0 if n <= 0 then colAvg[sd.id] = 0 elseif sd.kind == "hit" then colAvg[sd.id] = M.RoundTenth((sums[sd.id] or 0) / n) else colAvg[sd.id] = math.floor((sums[sd.id] or 0) / n + 0.5) end end end return { coverage = coverage, slots = slots, groups = groups, footer = footer, unreadable = { count = coverage.offline + coverage.readfailed, names = unreadableNames, }, unassigned = { count = table.getn(unassignedNames), names = unassignedNames, }, -- distinct debuff names beyond the DEBUFF_CAP columns (0 outside -- the DEBUFFS tab); reportLines appends "+N more debuff types" debuffMore = debuffMore, -- DISPLAY tabs only (nil elsewhere): rounded average per column -- over the players who deliver that column -- resistance readers -- for the five schools, hit readers with a HAS cell for the hit -- columns -- plus the per-column denominator behind it and the -- two channel reader counts. colAvg = colAvg, colCount = colCount, -- aliases (the SAME tables, not copies) for readers written when -- the resistances were the only display tab resistAvg = colAvg, resistCount = colCount, resistReadable = resistReadable, hitReadable = hitReadable, tab = tab, } end -- ================================================================== -- whisperText(rowVM, tabLabel, slots) -> string -- Pure ASCII, plain "-" separator (not an -- em dash): "DopingControl - missing before pull: Flask, Stamina/HP". -- Slot order = column order. tabLabel is part of the fixed signature -- but not of the output string (gap labels are self-explanatory). -- ================================================================== function M.whisperText(rowVM, tabLabel, slots) local parts = {} local n = table.getn(slots) -- HIT has no MISSING cells at all -- a number below its cap is -- information, not an unfilled slot. So it whispers the SHORTFALL, -- with both numbers: "Melee 5.0/8.0" says what to fix and how far, -- which a bare slot name could not. if rowVM.tab == "HIT" then for i = 1, n do local sd = slots[i] local c = rowVM.cells[sd.id] if c and c.cap and c.capMet == false and c.value and M.HitRelevant(rowVM.role, sd) then table.insert(parts, M.gapLabel(sd) .. " " .. M.HitNum(c.value) .. "/" .. M.HitNum(c.cap)) end end return "DopingControl - below the hit cap: " .. table.concat(parts, ", ") end for i = 1, n do local sd = slots[i] local c = rowVM.cells[sd.id] if c and c.state == DC.STATE.MISSING then table.insert(parts, M.gapLabel(sd)) end end return "DopingControl - missing before pull: " .. table.concat(parts, ", ") end -- ================================================================== -- reportLines(vm, tabLabel, timeStr) -> array of strings -- Each line <= M.MAX_LINE chars; long name lists continue on -- " ... continued:" lines. Pure ASCII ("|" separators -- -- chat output never uses middle dots). -- ================================================================== local CONT_PREFIX = " ... continued: " local function emitNameList(lines, prefix, names) local cur = prefix local first = true local n = table.getn(names) for i = 1, n do local nm = names[i] if not first and string.len(cur) + 2 + string.len(nm) > M.MAX_LINE then table.insert(lines, cur) cur = CONT_PREFIX .. nm elseif first then cur = cur .. nm first = false else cur = cur .. ", " .. nm end end table.insert(lines, cur) end function M.reportLines(vm, tabLabel, timeStr) local lines = {} -- the tab's own channel count, not the aura one: the header names a -- tab, so the number behind it has to be that tab's (see -- M.ChannelRead). Falls back for hand-built vms from older callers. table.insert(lines, "DopingControl | " .. timeStr .. " | " .. tabLabel .. " | coverage " .. (vm.coverage.channelReadable or vm.coverage.readable) .. "/" .. vm.coverage.total) -- The display tabs are no expectation check -- no missing lists at -- all, one average line per column instead. Early return: the -- gap-oriented machinery below (order/footer/debuffMore) does not -- apply here. if M.IsDisplayTab(vm.tab) then if vm.tab == "HIT" then table.insert(lines, "Hit (average, readable players):") else table.insert(lines, "Resistances (average, readable players):") end local nSlots = table.getn(vm.slots) for i = 1, nSlots do local sd = vm.slots[i] local avg = (vm.colAvg and vm.colAvg[sd.id]) or 0 -- hit columns print percent with one decimal; resistances -- print a plain integer local avgTxt if sd.kind == "hit" then avgTxt = M.HitText(avg) .. "%" else avgTxt = tostring(avg) end -- The count named here is the population the average was -- actually taken over, NOT the channel's reader count: the -- ranged hit column drops every player without a ranged -- weapon, so quoting the reader count would overstate it. local readers if vm.colCount and vm.colCount[sd.id] then readers = vm.colCount[sd.id] elseif sd.kind == "hit" then readers = vm.hitReadable or 0 else readers = vm.resistReadable or 0 end local noun = (readers == 1) and " player)" or " players)" table.insert(lines, " " .. sd.full .. ": avg " .. avgTxt .. " (" .. readers .. noun) end if vm.unreadable and vm.unreadable.count > 0 then emitNameList(lines, "Not readable (" .. vm.unreadable.count .. "): ", vm.unreadable.names) end return lines end -- DEBUFFS is a warning list, not an expectation check -- -- its section header word is "Debuffs:"; the per-debuff name lists -- below stay exactly as they are. Expectation tabs keep the pinned -- "Missing per slot:". if vm.tab == "DEBUFFS" then table.insert(lines, "Debuffs:") else table.insert(lines, "Missing per slot:") end -- slots with gaps, sorted by count desc; ties keep column order local order = {} local nSlots = table.getn(vm.slots) for i = 1, nSlots do local sd = vm.slots[i] local f = vm.footer[sd.id] if f and f.count > 0 then table.insert(order, { sd = sd, f = f, idx = i }) end end table.sort(order, function(a, b) if a.f.count ~= b.f.count then return a.f.count > b.f.count end return a.idx < b.idx end) local nOrder = table.getn(order) if nOrder == 0 then table.insert(lines, " none") end for i = 1, nOrder do local e = order[i] emitNameList(lines, " " .. M.gapLabel(e.sd) .. " (" .. e.f.count .. "): ", e.f.names) end -- "Not readable" is its OWN line; unreadable players never appear in -- the missing lists above (UNKNOWN is never counted as MISSING). if vm.unreadable and vm.unreadable.count > 0 then emitNameList(lines, "Not readable (" .. vm.unreadable.count .. "): ", vm.unreadable.names) end -- DEBUFFS column-cap overflow: the report's slot -- lines cover the capped columns; the overflow is appended as one -- line so the report never silently hides debuff types. if vm.debuffMore and vm.debuffMore > 0 then table.insert(lines, "+" .. vm.debuffMore .. " more debuff types") end return lines end -- ================================================================== -- unknownBuffs(store) -> { [buffName] = count } -- Aura names no data table knows (no silent discard); -- count = number of readable players carrying the aura. -- ================================================================== function M.unknownBuffs(store) local out = {} local players = (store and store.players) or {} local n = table.getn(players) for i = 1, n do local p = players[i] if p.aurasRead and p.auras and p.auras.names then for name in pairs(p.auras.names) do local known = (DC.CONSUMABLES and DC.CONSUMABLES[name]) or (DC.CLASSBUFFS and DC.CLASSBUFFS[name]) if not known then out[name] = (out[name] or 0) + 1 end end end end return out end