1757 lines
79 KiB
Lua
1757 lines
79 KiB
Lua
-- 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
|
|
-- "<full> 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 "<full> 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: <name>", 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 = "<full>: <value>".
|
|
-- 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 - <At> 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 <Slot>";
|
|
-- 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
|