diff --git a/.pkgmeta b/.pkgmeta index b21614da..b6872888 100644 --- a/.pkgmeta +++ b/.pkgmeta @@ -36,6 +36,8 @@ externals: tag: latest Libs/LibKeystone: url: https://github.com/BigWigsMods/LibKeystone.git/LibKeystone + Libs/LibDurability: + url: https://repos.wowace.com/wow/libdurability/trunk/LibDurability enable-nolib-creation: no diff --git a/EllesmereUI.lua b/EllesmereUI.lua index d920dfc5..a8da8dba 100644 --- a/EllesmereUI.lua +++ b/EllesmereUI.lua @@ -4343,6 +4343,52 @@ function EllesmereUI.GetClassColor(classToken) return EllesmereUI._colorCache.class[classToken] or EllesmereUI._COLOR_WHITE end +-- Where durability stops being cosmetic: the point the tint reaches full red +-- and the point a check flags someone. One number, because a module that +-- paints "alarming" at one threshold and reports it at another is telling the +-- user two different things about the same gear. +EllesmereUI.DURABILITY_LOW = 20 + +-- Durability tint: white at full, fading to soft red over the range above +-- DURABILITY_LOW, and fully red at or below it. +-- +-- The game exposes no colour scale for this, so the suite owns one, and it +-- lives here because more than one module shows durability: the DataBars block +-- and the Raid Tools consumable check must not drift apart on what "low" looks +-- like. +function EllesmereUI.GetDurabilityColor(pct) + local low = EllesmereUI.DURABILITY_LOW + local t = ((pct or 100) - low) * (100 / (100 - low)) + if t < 0 then t = 0 elseif t > 100 then t = 100 end + local gb = 0.35 + 0.65 * (t / 100) + return 1, gb, gb +end + +-- Weapon enchant summary in the legacy GetWeaponEnchantInfo tuple shape: +-- hasMH, mhExpireMs, mhCharges, mhEnchantID, hasOH, ohExpireMs, ohCharges, +-- ohEnchantID. +-- +-- Prefers C_PaperDollInfo.GetTemporaryEnchantmentInfo where it exists: on 12.1 +-- GetWeaponEnchantInfo is a deprecation-CVar shim. remainingTimeMs matches the +-- legacy ms expiration values one to one, so a call site written against the +-- old API reads the new one unchanged. +-- +-- Here rather than in a module because two of them ask this question -- Aura +-- Buff Reminders nags you to re-oil, the Raid Tools consumable check reports it +-- to the raid -- and a second copy would mean the 12.1 workaround living in +-- only one of them. Same reasoning as EllesmereUI_RaidBuffs.lua. +function EllesmereUI.WeaponEnchants() + if C_PaperDollInfo and C_PaperDollInfo.GetTemporaryEnchantmentInfo then + local mh = C_PaperDollInfo.GetTemporaryEnchantmentInfo(INVSLOT_MAINHAND) + local oh = C_PaperDollInfo.GetTemporaryEnchantmentInfo(INVSLOT_OFFHAND) + return (mh and true or false), mh and mh.remainingTimeMs, + mh and mh.chargesRemaining, mh and mh.enchantID, + (oh and true or false), oh and oh.remainingTimeMs, + oh and oh.chargesRemaining, oh and oh.enchantID + end + return GetWeaponEnchantInfo() +end + -- Get power color (cached, darken baked in). Returns nil for unknown keys. function EllesmereUI.GetPowerColor(powerKey) if EllesmereUI._colorCacheDirty then EllesmereUI._RebuildColorCache() end diff --git a/EllesmereUI.toc b/EllesmereUI.toc index a722cd6e..0e11633c 100644 --- a/EllesmereUI.toc +++ b/EllesmereUI.toc @@ -14,6 +14,10 @@ Libs\CallbackHandler-1.0\CallbackHandler-1.0.lua Libs\LibSharedMedia-3.0\LibSharedMedia-3.0.lua Libs\LibDeflate\LibDeflate.lua Libs\LibKeystone\LibKeystone.lua +# Durability of other players is not readable locally; every addon that shows +# it speaks this library's channel, so embedding it means our users appear in +# other addons' raid checks and theirs appear in ours. Embedded unmodified. +Libs\LibDurability\LibDurability.lua # Lightweight addon framework (replaces Ace3) EllesmereUI_Lite.lua @@ -63,6 +67,8 @@ EUI_PartyMode_Options.lua EllesmereUI_Glows.lua EllesmereUI_Range.lua EllesmereUI_AuraKit.lua +EllesmereUI_RaidBuffs.lua +EllesmereUI_Comms.lua EllesmereUI_FirstInstall.lua EllesmereUI_RaidFramesPopup.lua EllesmereUI_PatchNotesPopup.lua diff --git a/EllesmereUIAuraBuffReminders/EllesmereUIAuraBuffReminders.lua b/EllesmereUIAuraBuffReminders/EllesmereUIAuraBuffReminders.lua index 60ffaa24..304d6fb7 100644 --- a/EllesmereUIAuraBuffReminders/EllesmereUIAuraBuffReminders.lua +++ b/EllesmereUIAuraBuffReminders/EllesmereUIAuraBuffReminders.lua @@ -874,36 +874,20 @@ _G._EABR_SpellName = function(spellID, fallback) return n or fallback end --- Weapon enchant summary in the legacy GetWeaponEnchantInfo tuple shape: --- hasMH, mhExpireMs, mhCharges, mhEnchantID, hasOH, ohExpireMs, ohCharges, --- ohEnchantID. Prefers C_PaperDollInfo.GetTemporaryEnchantmentInfo where it --- exists (12.1: GetWeaponEnchantInfo is a deprecation-CVar shim there); --- remainingTimeMs matches the legacy ms expiration values one to one. --- Stored on EABR, not a file local (this file runs at the 200-local cap). -EABR.WeaponEnchants = function() - if C_PaperDollInfo and C_PaperDollInfo.GetTemporaryEnchantmentInfo then - local mh = C_PaperDollInfo.GetTemporaryEnchantmentInfo(INVSLOT_MAINHAND) - local oh = C_PaperDollInfo.GetTemporaryEnchantmentInfo(INVSLOT_OFFHAND) - return (mh and true or false), mh and mh.remainingTimeMs, - mh and mh.chargesRemaining, mh and mh.enchantID, - (oh and true or false), oh and oh.remainingTimeMs, - oh and oh.chargesRemaining, oh and oh.enchantID - end - return GetWeaponEnchantInfo() -end - -local RAID_BUFFS = { - { key="motw", class="DRUID", name="Mark of the Wild", castSpell=1126, buffIDs={1126,432661}, check="raid" }, - { key="bshout", class="WARRIOR", name="Battle Shout", castSpell=6673, buffIDs={6673}, check="raid", benefit="attackPower" }, - { key="fort", class="PRIEST", name="Power Word: Fortitude", castSpell=21562, buffIDs={21562}, check="raid" }, - { key="ai", class="MAGE", name="Arcane Intellect", castSpell=1459, buffIDs={1459,432778}, check="raid", benefit="intellect" }, - { key="bronze", class="EVOKER", name="Blessing of the Bronze", castSpell=364342, - buffIDs={381732,381741,381746,381748,381749,381750,381751,381752,381753,381754,381756,381757,381758}, - check="raid" }, - { key="sky", class="SHAMAN", name="Skyfury", castSpell=462854, buffIDs={462854}, check="raid" }, - -- Hunter's Mark: disabled (under maintenance) - -- { key="hmark", class="HUNTER", name="Hunter's Mark", castSpell=257284, buffIDs={257284}, check="huntersMark" }, -} +-- Moved to EllesmereUI.WeaponEnchants in the parent: the Raid Tools consumable +-- check asks the same question, and a second copy would mean the 12.1 +-- deprecation workaround living in only one of the two. Same move, and same +-- reason, as RAID_BUFFS just below. +-- +-- Still a field on EABR rather than a file local: the call sites below are +-- unchanged that way, and this file runs at the 200-local cap. +EABR.WeaponEnchants = EllesmereUI.WeaponEnchants + +-- Moved to EllesmereUI_RaidBuffs.lua in the parent: the Raid Tools consumable +-- check needs the same answers, and a second copy would mean two lists to +-- update with one of them silently wrong. Every child has the parent, so +-- neither module now depends on the other being enabled. +local RAID_BUFFS = EllesmereUI.RaidBuffs ------------------------------------------------------------------------------- -- SPELL DATA Auras (some non-secret, some still OOC-only) diff --git a/EllesmereUIDataBars/EllesmereUIDataBars_Blocks.lua b/EllesmereUIDataBars/EllesmereUIDataBars_Blocks.lua index c6fb6d53..6e478618 100644 --- a/EllesmereUIDataBars/EllesmereUIDataBars_Blocks.lua +++ b/EllesmereUIDataBars/EllesmereUIDataBars_Blocks.lua @@ -302,14 +302,10 @@ function ns.BlockIconDefault(bType) return ns.GetAccent() end if bType == "durability" then - -- White (100%) fading to soft red (1, 0.35, 0.35). The gradient - -- spans 20..100: at or below 20% durability the tint is already - -- fully red. - local pct = _lastDurabilityPct or 100 - local t = (pct - 20) * (100 / 80) - if t < 0 then t = 0 elseif t > 100 then t = 100 end - local gb = 0.35 + 0.65 * (t / 100) - return 1, gb, gb + -- Shared with the Raid Tools consumable check, which shows the same + -- reading for everyone in the group: one ramp, so "low" looks the same + -- wherever the suite says it. + return EllesmereUI.GetDurabilityColor(_lastDurabilityPct) end local d = ICON_DEFAULTS[bType] if d then return d[1], d[2], d[3] end diff --git a/EllesmereUIQoL/EUI_QoL_RaidTools_Options.lua b/EllesmereUIQoL/EUI_QoL_RaidTools_Options.lua index 4727de4a..9847ce34 100644 --- a/EllesmereUIQoL/EUI_QoL_RaidTools_Options.lua +++ b/EllesmereUIQoL/EUI_QoL_RaidTools_Options.lua @@ -288,6 +288,53 @@ initFrame:SetScript("OnEvent", function(self) _, h = W:DualRow(parent, y, PullSlider(1), PullSlider(2)); y = y - h _, h = W:DualRow(parent, y, PullSlider(3), { type="spacer" }); y = y - h + -- RAID CHECK + -- + -- Its own feature in its own file and its own profile slice, but its + -- controls live here: it is triggered by a ready check, and the ready + -- check button is on this page. Page grouping is a UI decision, not a + -- DB one. Every read and write goes through the ns accessors the + -- feature publishes, so this page knows nothing about its slice. + _, h = W:SectionHeader(parent, "RAID CHECK", y); y = y - h + + local function RCDisabled() return not ns.RaidCheckEnabled() end + + _, h = W:DualRow(parent, y, + { type = "toggle", text = "Show on Ready Check", + tooltip = "Lists every group member against the consumables a raid expects -- flask, food, augment rune, vantus -- whenever a ready check starts, whoever started it.", + getValue = ns.RaidCheckEnabled, + setValue = function(v) + ns.RaidCheckEnabled(v) + EllesmereUI:RefreshPage() + end }, + { type = "toggle", text = "Show Without Lead or Assist", + tooltip = "Shows the window even when you can do nothing about what it reports. Every column is read from your own client, so this view is complete rather than degraded.", + disabled = RCDisabled, + getValue = ns.RaidCheckShowWithoutRank, + setValue = ns.RaidCheckShowWithoutRank } + ); y = y - h + + _, h = W:DualRow(parent, y, + { type = "slider", text = "Raid Check Window Scale", min = 0.5, max = 2.0, step = 0.05, + disabled = RCDisabled, + getValue = ns.RaidCheckScale, + setValue = ns.RaidCheckScale }, + { type = "toggle", text = "Hide Inapplicable Columns", + tooltip = "Drops columns nothing in this group can satisfy instead of greying them: no mage means no Intellect column, and a Mythic+ key means no Vantus. Turn off to keep every column in place whatever the group.", + disabled = RCDisabled, + getValue = ns.RaidCheckHideInapplicable, + setValue = ns.RaidCheckHideInapplicable } + ); y = y - h + + _, h = W:DualRow(parent, y, + { type = "toggle", text = "Show Only Players Missing Something", + tooltip = "Lists only the people something is actually wrong with, so a thirty-man roster becomes the three names you need to whisper. Someone whose client has not reported yet is not counted as missing.", + disabled = RCDisabled, + getValue = ns.RaidCheckHideReady, + setValue = ns.RaidCheckHideReady }, + { type = "label", text = "" } + ); y = y - h + return math.abs(y) end diff --git a/EllesmereUIQoL/EllesmereUIQoL.toc b/EllesmereUIQoL/EllesmereUIQoL.toc index 7850de99..6a84e392 100644 --- a/EllesmereUIQoL/EllesmereUIQoL.toc +++ b/EllesmereUIQoL/EllesmereUIQoL.toc @@ -20,6 +20,7 @@ EllesmereUIQoL_TeleportPrompt.lua EllesmereUIQoL_Shifter.lua EllesmereUIQoL_MovementAlert.lua EllesmereUIQoL_RaidTools.lua +EllesmereUIQoL_RaidCheck.lua EUI_UpgradeCalc.lua # Options diff --git a/EllesmereUIQoL/EllesmereUIQoL_RaidCheck.lua b/EllesmereUIQoL/EllesmereUIQoL_RaidCheck.lua new file mode 100644 index 00000000..abafb042 --- /dev/null +++ b/EllesmereUIQoL/EllesmereUIQoL_RaidCheck.lua @@ -0,0 +1,1080 @@ +------------------------------------------------------------------------------- +-- EllesmereUIQoL_RaidCheck.lua -- Consumable check on a ready check +-- +-- Shows on ANY ready check in the group, whoever started it, and lists every +-- member against what a raid expects of them: flask, food, augment rune, +-- vantus rune, the group-wide buffs, weapon enchant and durability. The grid +-- is the whole report -- there is no summary line, because a raid leader +-- reading twelve columns does not also need them counted in prose. +-- +-- WHAT ANSWERS A COLUMN. Every column declares how it is matched, and the +-- vectors are not interchangeable: +-- +-- ids exact spell ids. The only vector that survives restricted +-- content, and the only one that rots -- new consumables need new +-- ids every expansion. +-- icons "Well Fed" reuses a handful of icons, so any recipe matches and +-- nothing needs updating. +-- prefix every Vantus buff is ": ", so the prefix catches +-- every boss of every tier. +-- class a group-wide buff. Not a matcher: it says whose absence makes +-- the column meaningless. +-- selfRead read live off your own client. No API reports a weapon enchant +-- for anyone else, so YOUR row is answered locally and everyone +-- else's is volunteered over the comms layer. The local read is +-- applied last and wins: it is current, and it still works when +-- there is no group and so no transport at all. +-- (Durability reaches the same place by another road -- see the +-- column, LibDurability already reports your own value locally -- +-- so it needs nothing from this mechanism.) +-- +-- MIDNIGHT AURA RESTRICTIONS. The game offers two ways to read another +-- player's auras and each is blind where the other sees, so both ship: +-- +-- * the index sweep answers with the FULL aura -- icon and name included -- +-- which is what lets an unlisted consumable register. It is refused +-- outright in restricted content. +-- * GetUnitAuraBySpellID survives restriction, and combat, but only for +-- ids the client does not consider secret, and never exposes an icon. +-- So there the id tables are all we have. +-- +-- A secret result reads as "unknown" rather than "missing". Claiming someone +-- has no flask because the client refused to answer is worse than saying +-- nothing, and that principle runs through the whole file: a column with no +-- answer is blank, never a cross. +-- +-- The honest limit: inside a raid this window knows exactly what it has been +-- told to look for. A client reading its OWN auras and volunteering the +-- result is what would remove that, and is the road the enchant column is +-- already on -- read locally, reported outward. +------------------------------------------------------------------------------- +local _, ns = ... + +local GetRaidRosterInfo = GetRaidRosterInfo +local GetNumGroupMembers = GetNumGroupMembers +local GetNumSubgroupMembers = GetNumSubgroupMembers +local IsInRaid, IsInGroup = IsInRaid, IsInGroup +local UnitIsGroupLeader, UnitIsGroupAssistant = UnitIsGroupLeader, UnitIsGroupAssistant +local UnitExists, UnitIsPlayer = UnitExists, UnitIsPlayer +local UnitIsUnit = UnitIsUnit +local UnitName, UnitClass, UnitIsConnected = UnitName, UnitClass, UnitIsConnected +local Ambiguate = Ambiguate +local issecretvalue = issecretvalue +local GetAuraDataByIndex = C_UnitAuras.GetAuraDataByIndex +local GetUnitAuraBySpellID = C_UnitAuras.GetUnitAuraBySpellID + +-- Two member columns of twenty: a 40-man roster in one screenful, without a +-- scroll frame and without a window taller than the game. Sized to be legible +-- at a glance mid-raid rather than to save pixels -- the window fits the group +-- and the column set, so there is room, and the scale slider is there for +-- anyone who disagrees. +local MEMBER_COLS = 2 +local COL_ROWS = 20 +local NAME_W = 112 +local CELL_W = 30 +local ROW_H = 22 +local COL_GAP = 16 +local PAD = 14 +local TITLE_H = 26 +local HEADER_H = 20 +local NAME_SIZE = 12 +local SMALL_SIZE = 11 +local ICON_SZ = 18 +local AURA_SCAN_LIMIT = 40 +local SWEEP_PERIOD = 2 + +-- Blizzard's own ready-check art: exactly these semantics, guaranteed present, +-- and no texture of ours to ship. +local TEX_OK = "Interface\\RaidFrame\\ReadyCheck-Ready" +local TEX_MISS = "Interface\\RaidFrame\\ReadyCheck-NotReady" + +-- QoL's key in EllesmereUI._addonKeyToFolder. On-screen text has to resolve +-- through GetFontPath: EllesmereUI.MakeFont hardcodes the options-panel font, +-- and using it here would make this the one QoL window ignoring the user's +-- Global Font setting. +local FONT_KEY = "extras" + +local fonts = {} -- every fontstring the window owns, with its size + +local function FontPath() + return (EllesmereUI.GetFontPath and EllesmereUI.GetFontPath(FONT_KEY)) + or STANDARD_TEXT_FONT +end + +local function MakeText(parent, size) + local fs = parent:CreateFontString(nil, "OVERLAY") + fs:SetFont(FontPath(), size, "") + fs:SetTextColor(1, 1, 1) + fonts[#fonts + 1] = { fs = fs, size = size } + return fs +end + +-- Re-resolved every time the window opens rather than only at build: the +-- window is built once and lives for the session, so a Global Font change +-- between two ready checks would otherwise never reach it. +local function ApplyFonts() + local path = FontPath() + for _, e in ipairs(fonts) do e.fs:SetFont(path, e.size, "") end +end + +-- "Well Fed" has reused the same handful of icons across expansions, so food +-- is identifiable without knowing its spell id at all. That is the one +-- consumable column that does not rot -- a new recipe keeps the icon and keeps +-- being detected. +local FOOD_ICONS = { + [136000] = true, -- the stat "Well Fed" + [134062] = true, -- plain "Well Fed", no stat line + [132805] = true, + [133950] = true, +} + +-- The Vantus prefix is not hardcoded: it is read off a known Vantus rune and +-- cut at the first separator, so every client gets it correctly localized. The +-- seed spell is only a name source -- which rune it is does not matter, and an +-- old one is the safest choice because it will never be removed. +local VANTUS_SEED = 237825 +local vantusPrefix, vantusTried +local function VantusPrefix() + if vantusTried then return vantusPrefix end + vantusTried = true + local info = C_Spell and C_Spell.GetSpellInfo and C_Spell.GetSpellInfo(VANTUS_SEED) + local name = info and info.name + if name then vantusPrefix = name:match("^(.-)[:%-:]") end + return vantusPrefix +end + +-- Consumables, in display order. `seed` and `icon` are only the header art; +-- `seed` fetches it from the game so a header cannot drift from what it checks. +-- +-- Only flask and rune carry ids, and they are the only entries here that will +-- ever need a patch-day edit. /euiraidcheck is the maintenance tool. +local CHECKS = { + { key = "flask", label = "Flask", seed = 1235110, + ids = { [1236763] = true, [1239355] = true, [1235057] = true, [1239755] = true, + [1236767] = true, [1235111] = true, [1235110] = true, [1235108] = true } }, + { key = "food", label = "Food", icon = 136000, icons = FOOD_ICONS }, + { key = "rune", label = "Rune", seed = 1264426, + ids = { [1264426] = true, [1242347] = true, [1234969] = true, [453250] = true, + [393438] = true } }, + -- Vantus runes apply to raid bosses, so in a Mythic+ key the column is not + -- merely unlikely to be filled, it is meaningless. + { key = "vantus", label = "Vantus", seed = VANTUS_SEED, + prefix = VantusPrefix, raidOnly = true }, +} + +local DURABILITY_KEY = "durability" +local WENCHANT_KEY = "wenchant" +local MSG_REPORT = "rc" -- a client describing itself +local MSG_QUERY = "rcq" -- someone asking the group to describe itself + +-- What each client volunteered about itself, from either wire. One store, so +-- a future field lands here rather than growing a third parallel map -- and +-- the paint path never has to care which transport a value arrived on. +local reported = {} -- player name -> { dur = number, we = enchantID } + +-- The library reports names Ambiguate'd to "none" while UnitName gives the +-- bare name, so normalisation happens once, here, at the store boundary. +local function Note(name, field, value) + if type(name) ~= "string" then return end + name = Ambiguate(name, "short") + local e = reported[name] + if not e then e = {}; reported[name] = e end + e[field] = value +end + +-- Your own main-hand enchant id, or 0 for none. Only your own client can +-- answer this, for you or for anyone -- which is why it also goes on the wire. +-- +-- Through the parent, not GetWeaponEnchantInfo directly: that call is a +-- deprecation shim on 12.1, and the parent owns the one reader that knows it. +-- +-- Main hand only: off-hand items that cannot take an enchant at all are +-- common, and reading those as missing would be a false accusation. +local function MyEnchantID() + local has, _, _, enchantID = EllesmereUI.WeaponEnchants() + return (has and enchantID) or 0 +end + +-- The single place an enchant id becomes a verdict, so your row and everyone +-- else's are judged by the same rule -- one arrives locally and one off the +-- wire, and that must not be a difference the user can see. +local function EnchantOK(id) + return (id or 0) > 0 +end + +------------------------------------------------------------------------------- +-- Columns +------------------------------------------------------------------------------- + +local COLUMNS = {} -- display order +local SELF_COLS = {} -- columns that read your own row locally +local ID_TO_KEY = {} -- spell id -> column key, flattened from every id set +local ICON_COLS = {} -- columns matched on aura icon +local NAME_COLS = {} -- columns matched on an aura name prefix +local columnsBuilt + +local function LibDur() + return LibStub and LibStub("LibDurability", true) +end + +-- Built on first open rather than at load: nothing here needs to exist until +-- the window does, and by then every file has run. +local function EnsureColumns() + if columnsBuilt then return end + columnsBuilt = true + + for _, def in ipairs(CHECKS) do + COLUMNS[#COLUMNS + 1] = def + end + + -- Oils and sharpening stones are one thing to the game -- a temporary + -- weapon enchant -- so this is one column rather than two. Which + -- consumable a player used is their business; whether the weapon is + -- enchanted is the raid's, and it means no id table to maintain. + -- + -- The column exists whether or not the transport does: `selfRead` answers + -- your own row from your own client, so it is filled solo, filled before + -- any reply could arrive, and never wrong because a message was dropped. + -- Comms only ever fills OTHER people's rows. + COLUMNS[#COLUMNS + 1] = { key = WENCHANT_KEY, label = "Weapon Enchant", + selfRead = function() return EnchantOK(MyEnchantID()) end, + note = "Oil or sharpening stone on the main hand. Other players' rows need them to be running EllesmereUI.", + icon = "Interface\\Icons\\INV_Stone_SharpeningStone_05" } + + -- Only offered when the library is embedded: a column nobody can ever + -- answer is worse than none. + -- + -- The note is not a detail. Every client on the shared channel reports an + -- average across its equipped slots, so this number is not the one the + -- DataBars durability block shows, which is the single worst piece. Both + -- are right; only the average is comparable between people, which is why + -- the column cannot use the other reading. + if LibDur() then + COLUMNS[#COLUMNS + 1] = { key = DURABILITY_KEY, label = "Durability", + numeric = true, + note = "Average across equipped items, as reported by each player.", + icon = "Interface\\Icons\\Trade_BlackSmithing" } + end + + -- Raid buff definitions are not ours and are not copied: they live in the + -- parent, shared with Aura Buff Reminders. Every child addon has the + -- parent, so these columns exist whatever else the user has switched off, + -- and there is one list to maintain rather than two. + for _, b in ipairs(EllesmereUI.RaidBuffs) do + -- The table also carries entries checked per player rather than + -- group-wide; only the group-wide ones belong in a raid check. + if b.check == "raid" then + local ids = {} + for _, id in ipairs(b.buffIDs) do ids[id] = true end + COLUMNS[#COLUMNS + 1] = { key = b.key, class = b.class, ids = ids, + seed = b.castSpell, fallbackName = b.name } + end + end + + -- One flat lookup instead of walking every column's id set per aura. + for _, def in ipairs(COLUMNS) do + if def.selfRead then SELF_COLS[#SELF_COLS + 1] = def end + if def.ids then + for id in pairs(def.ids) do ID_TO_KEY[id] = def.key end + end + if def.icons then ICON_COLS[#ICON_COLS + 1] = def end + if def.prefix then NAME_COLS[#NAME_COLS + 1] = def end + end +end + +-- A buff column carries no label of ours: its name is the spell's, already +-- localized by the game. Resolved once and kept. +local columnName = {} +local function ColumnName(def) + if def.label then return EllesmereUI.L(def.label) end + local n = columnName[def.key] + if not n then + local info = def.seed and C_Spell and C_Spell.GetSpellInfo + and C_Spell.GetSpellInfo(def.seed) + n = (info and info.name) or def.fallbackName or def.key + columnName[def.key] = n + end + return n +end + +------------------------------------------------------------------------------- +-- Reading +------------------------------------------------------------------------------- + +-- AuraKit is 12.1-gated and does not exist at all on an older client (see the +-- LIVE GATE at the top of EllesmereUI_AuraKit.lua), so the nil check is load +-- bearing, not defensive noise -- do not remove it. False is the right answer +-- there: aura restriction arrived WITH 12.1, so a client without AuraKit is a +-- client where nothing is restricted and the index sweep always works. +local function Restricted() + local AK = EllesmereUI.AuraKit + if not AK then return false end + return AK.AurasRestricted() +end + +-- Can this column be answered at all right now? One predicate, so nothing +-- downstream branches on a column's name. +local function Answerable(def, restricted, classPresent) + if def.class then return classPresent[def.class] or false end + if def.raidOnly and not IsInRaid() then return false end + if def.ids then return true end + -- Icon and name prefix live on the full aura, which only the sweep hands + -- back, so those columns cannot answer under restriction. A prefix that + -- did not resolve is the same situation: better no column than one that + -- crosses everybody. + if def.prefix then return not restricted and def.prefix() ~= nil end + if def.icons then return not restricted end + return true -- volunteered: askable always, blank until someone answers +end + +-- Unrestricted path. One sweep answers every column at once AND hands back the +-- icon and name, which is what lets an unlisted consumable register. +-- +-- A named function so the pcall wraps the whole unit in one call rather than +-- allocating a closure per unit -- restriction was already decided by the +-- caller, so a per-index catch buys nothing here. +local function SweepBody(unit, out) + for i = 1, AURA_SCAN_LIMIT do + local aura = GetAuraDataByIndex(unit, i, "HELPFUL") + if not aura then return end + local id = aura.spellId + if id and not (issecretvalue and issecretvalue(id)) then + local key = ID_TO_KEY[id] + if key then out[key] = true end + if aura.icon then + for j = 1, #ICON_COLS do + local def = ICON_COLS[j] + if def.icons[aura.icon] then out[def.key] = true end + end + end + if aura.name then + for j = 1, #NAME_COLS do + local def = NAME_COLS[j] + -- Each column resolves its own prefix; the lookup is + -- memoised, so this is a table read per aura. + local p = def.prefix() + if p and aura.name:find(p, 1, true) == 1 then out[def.key] = true end + end + end + end + end +end + +-- Restricted path. Returns true, false, or nil when the client refused to +-- answer -- and nil matters: see the header. +local function UnitHasAny(unit, ids) + local unknown = false + for id in pairs(ids) do + local ok, aura = pcall(GetUnitAuraBySpellID, unit, id) + if not ok then + unknown = true + elseif aura ~= nil then + if issecretvalue and issecretvalue(aura) then + unknown = true + else + return true + end + end + end + if unknown then return nil end + return false +end + +local function UnitChecks(unit, answerable, restricted) + local out = {} + if restricted then + for _, def in ipairs(COLUMNS) do + if answerable[def.key] and def.ids then + out[def.key] = UnitHasAny(unit, def.ids) + end + end + else + -- Seeded only on this path: the sweep reports what it FINDS, so a + -- column it never touches has to already read as absent. + for _, def in ipairs(COLUMNS) do + if answerable[def.key] and (def.ids or def.icons or def.prefix) then + out[def.key] = false + end + end + pcall(SweepBody, unit, out) + end + return out +end + +-- Enumerating a group is three different APIs, not one, and mixing them is how +-- a Delve companion lands in a raid check: +-- +-- * In a raid, GetRaidRosterInfo(i) pairs with raid and is the only +-- source of the subgroup number. +-- * In a party it pairs with nothing -- a party is the player plus +-- party1..party4, and there are no subgroups. +-- * Solo there is no group, and the window reports the player, since +-- checking your own consumables before you join something is the same +-- question this answers. +-- +-- Cheap on purpose: no auras are read here. Which buff columns are worth +-- reading depends on which classes are present, so the roster has to exist +-- before a single aura call is made. +local function ReadMembers() + local out = {} + + local function Add(unit, subgroup) + -- UnitIsPlayer keeps followers out: a Delve companion is a group + -- member as far as these APIs are concerned, and it does not eat. + if not UnitExists(unit) or not UnitIsPlayer(unit) then return end + local _, class = UnitClass(unit) + out[#out + 1] = { + unit = unit, + name = UnitName(unit) or "?", + class = class, + online = UnitIsConnected(unit), + group = subgroup or 1, + -- Not a cosmetic flag: your own row is the one that can be read + -- locally instead of waited for. + isSelf = UnitIsUnit(unit, "player"), + } + end + + if IsInRaid() then + for i = 1, GetNumGroupMembers() do + local _, _, subgroup = GetRaidRosterInfo(i) + Add("raid" .. i, subgroup) + end + elseif IsInGroup() then + Add("player") + for i = 1, GetNumSubgroupMembers() do Add("party" .. i) end + else + Add("player") + end + + table.sort(out, function(a, b) + if a.group ~= b.group then return a.group < b.group end + return a.name < b.name + end) + return out +end + +------------------------------------------------------------------------------- +-- Volunteered reports +------------------------------------------------------------------------------- + +-- The raw id travels, not a verdict: the receiver applies EnchantOK, so a +-- later refinement of what counts works against everyone immediately instead +-- of waiting for the whole raid to update. +local function MyReport() + return "we=" .. MyEnchantID() +end + +-- Bounded parse: this is another player's client talking, so a field that is +-- not exactly what is expected is dropped rather than coerced. +local function ReadReport(sender, payload) + if type(payload) ~= "string" then return end + local we = payload:match("we=(%d+)") + if we then Note(sender, "we", tonumber(we)) end +end + +local function NoteDurability(percent, _, name) + if type(percent) == "number" then Note(name, "dur", percent) end +end + +------------------------------------------------------------------------------- +-- Permission +------------------------------------------------------------------------------- + +local db +local function P() + return db and db.profile and db.profile.raidCheck +end + +local function HasRank() + return IsInGroup() + and (UnitIsGroupLeader("player") or UnitIsGroupAssistant("player")) +end + +-- The option only widens who SEES it; it grants nothing, because there is +-- nothing to grant -- every column is either a local read or volunteered. +local function MayShow() + local p = P() + if not p or not p.enabled then return false end + if p.showWithoutRank then return true end + return HasRank() +end + +local DB_DEFAULTS = { + profile = { + raidCheck = { + -- Off by default: this opens a window on an event the user did not + -- ask for, so it is opt-in like every other QoL feature. + enabled = false, + showWithoutRank = false, + -- Drop columns nothing in this group can satisfy instead of dimming + -- them. On by default -- a dimmed column still costs the width and the + -- eye that a used one would. + hideInapplicable = true, + -- Show only the people something is wrong with. Off by default: the + -- full roster is what most people expect to open. + hideReady = false, + scale = 1, + pos = {}, + }, + }, +} + +------------------------------------------------------------------------------- +-- Window +------------------------------------------------------------------------------- + +local win +local rows = {} -- flat, member-column major +local colHeader = {} -- column key -> one frame per member column +local sweeper + +local function MakeRow(parent, index) + local r = CreateFrame("Frame", nil, parent) + r._name = MakeText(r, NAME_SIZE) + r._name:SetPoint("LEFT", r, "LEFT", 2, 0) + r._name:SetWidth(NAME_W - 6) + r._name:SetJustifyH("LEFT") + + -- Geometry belongs to Relayout, which runs before the window is ever + -- shown: anything positioned here would only be overwritten. + r._cells = {} + r._cellTex = {} + for c, def in ipairs(COLUMNS) do + if def.numeric then + local fs = MakeText(r, SMALL_SIZE) + fs:SetWidth(CELL_W) + fs:SetJustifyH("CENTER") + r._cells[c] = fs + -- Full durability is the common case and three digits is the + -- widest thing the grid draws, for the one value that says + -- nothing. It gets the same tick every other column uses. + local tex = r:CreateTexture(nil, "ARTWORK") + tex:SetSize(ICON_SZ, ICON_SZ) + tex:SetTexture(TEX_OK) + tex:SetAlpha(0.9) + tex:Hide() + r._cellTex[c] = tex + else + local tex = r:CreateTexture(nil, "ARTWORK") + tex:SetSize(ICON_SZ, ICON_SZ) + tex:SetAlpha(0.9) + r._cells[c] = tex + end + end + + rows[index] = r + return r +end + +local function Build() + EnsureColumns() + + win = CreateFrame("Frame", "EllesmereUIRaidCheckWindow", UIParent) + win:SetFrameStrata("DIALOG") + win:SetFrameLevel(200) + win:EnableMouse(true) + win:SetMovable(true) + win:SetClampedToScreen(true) + win:Hide() + EllesmereUI.RegisterEscapeClose(win) + + EllesmereUI.SolidTex(win, "BACKGROUND", 0.06, 0.08, 0.10, 0.95):SetAllPoints() + EllesmereUI.MakeBorder(win, 1, 1, 1, EllesmereUI.DD_BRD_A, EllesmereUI.PP) + + local title = MakeText(win, 13) + title:SetPoint("TOPLEFT", win, "TOPLEFT", PAD, -PAD) + title:SetText(EllesmereUI.L("Raid Check")) + local function TintTitle() title:SetTextColor(EllesmereUI.GetAccentColor()) end + TintTitle() + -- Set once at build would go stale on a theme change. + EllesmereUI.RegAccent({ type = "callback", fn = TintTitle }) + + -- Headers, once per member column so both halves are labelled. Icons + -- rather than captions: twelve columns leave no room for words, and the + -- spell's own art needs no translating. Each is a frame so it can be + -- hovered -- twelve icons and not a word says nothing on its own. + for mc = 1, MEMBER_COLS do + for _, def in ipairs(COLUMNS) do + local h = CreateFrame("Frame", nil, win) + h:SetSize(ICON_SZ, ICON_SZ) + local tex = h:CreateTexture(nil, "ARTWORK") + tex:SetAllPoints() + local icon = def.icon + if not icon and def.seed and C_Spell and C_Spell.GetSpellInfo then + local info = C_Spell.GetSpellInfo(def.seed) + icon = info and info.iconID + end + tex:SetTexture(icon or TEX_MISS) + + h:EnableMouse(true) + h:SetScript("OnEnter", function(self) + GameTooltip:SetOwner(self, "ANCHOR_BOTTOM") + GameTooltip:AddLine(ColumnName(def)) + if def.note then + GameTooltip:AddLine(EllesmereUI.L(def.note), 1, 1, 1, true) + end + GameTooltip:Show() + end) + h:SetScript("OnLeave", function() GameTooltip:Hide() end) + + colHeader[def.key] = colHeader[def.key] or {} + colHeader[def.key][mc] = h + end + end + + -- Dragging the window, and remembering where it was left. + win:RegisterForDrag("LeftButton") + win:SetScript("OnDragStart", function(self) self:StartMoving() end) + win:SetScript("OnDragStop", function(self) + self:StopMovingOrSizing() + local p = P() + if not p then return end + local point, _, relPoint, x, y = self:GetPoint() + p.pos = { point = point, relPoint = relPoint, x = x, y = y } + end) + + -- Hooked on the frame rather than done in HideRaidCheck: Escape closes + -- this through RegisterEscapeClose, which hides the frame directly. Arming + -- and disarming anywhere else leaves the sweep running on a window nobody + -- can see, for the rest of the session. + win:HookScript("OnHide", function() + if sweeper then sweeper.Stop() end + end) + + local close = CreateFrame("Button", nil, win) + close:SetSize(16, 16) + close:SetPoint("TOPRIGHT", win, "TOPRIGHT", -PAD, -PAD) + local closeLbl = MakeText(close, 14) + closeLbl:SetPoint("CENTER") + closeLbl:SetText("x") + closeLbl:SetAlpha(0.7) + close:SetScript("OnEnter", function() closeLbl:SetAlpha(1) end) + close:SetScript("OnLeave", function() closeLbl:SetAlpha(0.7) end) + close:SetScript("OnClick", function() win:Hide() end) + + for i = 1, MEMBER_COLS * COL_ROWS do MakeRow(win, i) end +end + +-- Puts every header and cell where the current column set says it belongs, and +-- hides the ones that are not in it. +-- +-- Guarded by a signature because it is real work -- forty rows times twelve +-- cells -- and the column set changes when the group does, not every two +-- seconds. +local lastLayoutSig +local function Relayout(visible, slotOf, memberCols, bodyW) + local sig = memberCols .. "|" .. table.concat(visible, ",") + if sig == lastLayoutSig then return end + lastLayoutSig = sig + + local headerY = -(PAD + TITLE_H) + for mc = 1, MEMBER_COLS do + local baseX = PAD + (mc - 1) * (bodyW + COL_GAP) + for _, def in ipairs(COLUMNS) do + local h = colHeader[def.key][mc] + local slot = slotOf[def.key] + h:SetShown(slot ~= nil and mc <= memberCols) + if slot then + h:ClearAllPoints() + h:SetPoint("TOPLEFT", win, "TOPLEFT", + baseX + NAME_W + (slot - 1) * CELL_W + (CELL_W - ICON_SZ) / 2, + headerY) + end + end + end + + for i = 1, #rows do + local r = rows[i] + local mc = math.floor((i - 1) / COL_ROWS) + local line = (i - 1) % COL_ROWS + r:SetSize(bodyW, ROW_H) + r:ClearAllPoints() + r:SetPoint("TOPLEFT", win, "TOPLEFT", + PAD + mc * (bodyW + COL_GAP), + headerY - HEADER_H - line * ROW_H) + for ci, def in ipairs(COLUMNS) do + local slot = slotOf[def.key] + if slot then + local x = NAME_W + (slot - 1) * CELL_W + local cell = r._cells[ci] + cell:ClearAllPoints() + if def.numeric then + cell:SetPoint("LEFT", r, "LEFT", x, 0) + -- The tick shares the slot with the number; only one of + -- the two is ever shown. + local tex = r._cellTex[ci] + tex:ClearAllPoints() + tex:SetPoint("LEFT", r, "LEFT", x + (CELL_W - ICON_SZ) / 2, 0) + else + cell:SetPoint("LEFT", r, "LEFT", x + (CELL_W - ICON_SZ) / 2, 0) + end + end + end + end +end + +-- Repaints everything from a fresh roster read. +local function Refresh() + if not win or not win:IsShown() then return end + + -- Roster first, auras second: a raid buff nobody present can cast is not a + -- failing, and knowing that spares reading its ids on every member. With + -- no Evoker, Blessing of the Bronze alone is thirteen ids across forty + -- people that answer a column nobody will ever look at. + local roster = ReadMembers() + local classPresent = {} + for _, e in ipairs(roster) do + if e.class then classPresent[e.class] = true end + end + + -- Roster-invariant, so decided once rather than per member per column. + local restricted = Restricted() + local answerable = {} + for _, def in ipairs(COLUMNS) do + answerable[def.key] = Answerable(def, restricted, classPresent) + end + + for _, e in ipairs(roster) do + e.checks = UnitChecks(e.unit, answerable, restricted) + local r = reported[e.name] + if r then + e.durability = r.dur + if r.we then e.checks[WENCHANT_KEY] = EnchantOK(r.we) end + end + -- Last, so the local read beats anything that came off the wire: it is + -- current, and your own row must not depend on a message that is not + -- sent at all when you are alone. + if e.isSelf then + for _, def in ipairs(SELF_COLS) do e.checks[def.key] = def.selfRead() end + end + end + + -- Which columns are on screen. Dimming an inapplicable one still spends + -- width on it, so it can be dropped outright instead -- and then the + -- remaining columns close ranks. + local hide = ns.RaidCheckHideInapplicable() + local visible, slotOf = {}, {} + for _, def in ipairs(COLUMNS) do + if answerable[def.key] or not hide then + visible[#visible + 1] = def.key + slotOf[def.key] = #visible + end + end + + -- Only the people something is wrong with, and only when asked -- nobody + -- reads a fault count that is not on screen, so the whole pass is skipped + -- rather than computed and discarded. + -- + -- A missing verdict is NOT a fault. Someone whose client has not reported, + -- or whose aura the game refused to disclose, has not failed anything, so + -- only an explicit failure keeps a row. + if ns.RaidCheckHideReady() then + local short = {} + local low = EllesmereUI.DURABILITY_LOW + for _, e in ipairs(roster) do + for _, def in ipairs(COLUMNS) do + if answerable[def.key] then + local bad + if def.numeric then + bad = e.durability ~= nil and e.durability <= low + else + bad = e.checks[def.key] == false + end + -- One fault is enough to keep the row; what it is shows in + -- the grid. + if bad then + short[#short + 1] = e + break + end + end + end + end + roster = short + end + + -- The window fits the group rather than the largest group possible. + local n = #roster + local memberCols = math.max(1, math.ceil(n / COL_ROWS)) + local rowsShown = math.min(math.max(n, 1), COL_ROWS) + local bodyW = NAME_W + #visible * CELL_W + win:SetSize(PAD * 2 + memberCols * bodyW + (memberCols - 1) * COL_GAP, + PAD * 2 + TITLE_H + HEADER_H + rowsShown * ROW_H) + Relayout(visible, slotOf, memberCols, bodyW) + + for i = 1, #rows do + local r, e = rows[i], roster[i] + if e then + local c = EllesmereUI.GetClassColor(e.class) + r._name:SetText(e.name) + r._name:SetTextColor(c.r, c.g, c.b, e.online and 1 or 0.4) + for ci, def in ipairs(COLUMNS) do + local cell = r._cells[ci] + local tex = r._cellTex[ci] + if not slotOf[def.key] then + cell:Hide() + if tex then tex:Hide() end + elseif def.numeric then + -- The number, tinted on the suite's shared ramp -- except + -- at full, where the tick says the same in less room. + local pct = e.durability + local shown = pct and math.floor(pct + 0.5) + cell:SetShown(shown ~= nil and shown < 100) + tex:SetShown(shown == 100) + if shown and shown < 100 then + cell:SetText(shown) + cell:SetTextColor(EllesmereUI.GetDurabilityColor(pct)) + end + else + -- Deliberately not `answerable and checks or nil`: `and` + -- binds tighter, so a false verdict would fall through to + -- nil and a missing consumable would draw nothing at all. + local v + if answerable[def.key] then v = e.checks[def.key] end + if v == true then + cell:SetTexture(TEX_OK) + cell:Show() + elseif v == false then + cell:SetTexture(TEX_MISS) + cell:Show() + else + -- Unanswerable, or the client would not say. + cell:Hide() + end + end + end + r:Show() + else + r:Hide() + end + end + + -- With hiding off, an unanswerable column stays but is dimmed, so it reads + -- as "no data" rather than "nobody has it". + for _, def in ipairs(COLUMNS) do + if slotOf[def.key] then + local on = answerable[def.key] + for mc = 1, MEMBER_COLS do colHeader[def.key][mc]:SetAlpha(on and 0.8 or 0.2) end + end + end + + return true -- the ticker keeps going while the window is up +end + +------------------------------------------------------------------------------- +-- Options accessors +-- +-- The page's entire view of this feature. Each reads with no argument and +-- writes with one, so the slice name and the profile walk never leave here. +------------------------------------------------------------------------------- + +function ns.ApplyRaidCheckScale() + if not win then return end + local base = (EllesmereUI.GetPopupScale and EllesmereUI.GetPopupScale()) or 1 + local p = P() + win:SetScale(base * ((p and p.scale) or 1)) +end + +function ns.RaidCheckScale(v) + local p = P() + if v == nil then return (p and p.scale) or 1 end + if not p then return end + p.scale = v + ns.ApplyRaidCheckScale() +end + +function ns.RaidCheckHideReady(v) + local p = P() + if v == nil then return (p and p.hideReady) == true end + if not p then return end + p.hideReady = v + lastLayoutSig = nil -- the row count changed, so the grid is re-laid out + Refresh() +end + +function ns.RaidCheckHideInapplicable(v) + local p = P() + if v == nil then return not p or p.hideInapplicable ~= false end + if not p then return end + p.hideInapplicable = v + lastLayoutSig = nil -- the column set changed + Refresh() +end + +function ns.RaidCheckEnabled(v) + local p = P() + if v == nil then return (p and p.enabled) == true end + if not p then return end + p.enabled = v + if not v then ns.HideRaidCheck() end +end + +function ns.RaidCheckShowWithoutRank(v) + local p = P() + if v == nil then return (p and p.showWithoutRank) == true end + if not p then return end + p.showWithoutRank = v + if not MayShow() then ns.HideRaidCheck() end +end + +------------------------------------------------------------------------------- +-- Show / hide +------------------------------------------------------------------------------- + +function ns.HideRaidCheck() + if win then win:Hide() end -- OnHide stops the sweep +end + +-- `fromReadyCheck` suppresses the query: on that path every client has already +-- volunteered unprompted, and asking again would make each of them broadcast +-- once more for every leader whose window opened. +function ns.ShowRaidCheck(fromReadyCheck) + if not MayShow() then return end + if not win then Build() end + + ApplyFonts() + ns.ApplyRaidCheckScale() + win:ClearAllPoints() + local p = P() + local pos = p and p.pos + if pos and pos.point then + win:SetPoint(pos.point, UIParent, pos.relPoint or pos.point, pos.x or 0, pos.y or 0) + else + win:SetPoint("CENTER") + end + + -- Ask before painting: answers land over the next few seconds and the + -- sweep picks them up. Both transports throttle their own requests, so + -- reopening the window repeatedly costs nothing. + local LD = LibDur() + if LD then LD:RequestDurability() end + if not fromReadyCheck and EllesmereUI.Comms then + EllesmereUI.Comms.Send(MSG_QUERY, "") + end + + win:Show() + Refresh() + -- An interval driver, not a per-frame one: people drink their flask DURING + -- the check so the grid has to follow, but not at frame rate. The frame is + -- created here, in this addon's chunk, because the engine bills a + -- handler's whole call tree to the addon that created the frame (see + -- EllesmereUI_Ticker.lua) -- the shared driver would charge the parent. + sweeper = sweeper or EllesmereUI.Tick.NewAnimTicker(CreateFrame("Frame"), Refresh, SWEEP_PERIOD) + sweeper.Start() +end + +------------------------------------------------------------------------------- +-- Lifecycle +------------------------------------------------------------------------------- + +local boot = CreateFrame("Frame") +boot:RegisterEvent("PLAYER_LOGIN") +boot:SetScript("OnEvent", function(self) + self:UnregisterAllEvents() + if not (EllesmereUI and EllesmereUI.Lite and EllesmereUI.Lite.NewDB) then return end + -- Merges DB_DEFAULTS into the shared QoL profile, the arrangement every + -- QoL feature uses. + db = EllesmereUI.Lite.NewDB("EllesmereUIQoLDB", DB_DEFAULTS, true) + if not EllesmereUI._onScaleChanged then EllesmereUI._onScaleChanged = {} end + EllesmereUI._onScaleChanged[#EllesmereUI._onScaleChanged + 1] = ns.ApplyRaidCheckScale + + -- Listening and answering are unconditional and session-long: someone + -- else's raid check should work whether or not you use your own, and a + -- report that arrived before the window opened is already there when it + -- does. Reports also answer other addons' requests, so the durability + -- column is populated the moment the window opens. + local LD = LibDur() + if LD then LD:Register("EllesmereUIRaidCheck", NoteDurability) end + + local C = EllesmereUI.Comms + if C then + C.On(MSG_QUERY, function() C.Send(MSG_REPORT, MyReport(), C.REPLY_SPREAD) end) + C.On(MSG_REPORT, ReadReport) + end +end) + +local ev = CreateFrame("Frame") +ev:RegisterEvent("READY_CHECK") +ev:RegisterEvent("GROUP_ROSTER_UPDATE") +ev:SetScript("OnEvent", function(_, event) + if event == "READY_CHECK" then + -- Volunteer on every ready check, feature enabled or not: a ready + -- check is already the moment the group asks, so nobody needs to send + -- a query and the answers are in flight before any window opens. + local C = EllesmereUI.Comms + if C then C.Send(MSG_REPORT, MyReport(), C.REPLY_SPREAD) end + -- Any ready check, whoever started it: an assistant checking the raid + -- and the leader should see the same thing. + ns.ShowRaidCheck(true) + return + end + -- Leaving the group, or losing rank without the option, closes it. + if win and win:IsShown() and not MayShow() then ns.HideRaidCheck() end +end) + +------------------------------------------------------------------------------- +-- Maintenance +-- +-- Consumable ids change every patch, so this ships rather than living in a +-- branch: `ids` audits what is configured, `buffs` lists what the player is +-- carrying so a new id can be read off and pasted in. Inert until invoked, +-- the same arrangement as /euiloc. +------------------------------------------------------------------------------- +SLASH_EUIRAIDCHECK1 = "/euiraidcheck" +SlashCmdList["EUIRAIDCHECK"] = function(msg) + local Print = EllesmereUI.Print + local tag = "|cff0cd29fEllesmereUI:|r " + EnsureColumns() + + if msg == "show" then + -- Opening it without starting a ready check: looking at the raid + -- should not require pinging everyone in it. + if win and win:IsShown() then ns.HideRaidCheck() else ns.ShowRaidCheck() end + return + end + + if msg == "buffs" then + -- The player's own auras, one line each: spell id, icon id, name, and + -- the column it satisfies. When a consumable reads as missing, this + -- says whether the id is simply unlisted or the sweep never saw the + -- aura at all. + -- + -- Matched through the same tables the grid uses, so it can never + -- claim a hit the grid would not. + for i = 1, AURA_SCAN_LIMIT do + local ok, aura = pcall(GetAuraDataByIndex, "player", i, "HELPFUL") + if not ok or not aura then break end + local id = aura.spellId + if id and not (issecretvalue and issecretvalue(id)) then + local key + for _, def in ipairs(ICON_COLS) do + if aura.icon and def.icons[aura.icon] then key = def.key end + end + for _, def in ipairs(NAME_COLS) do + local p = def.prefix() + if p and aura.name and aura.name:find(p, 1, true) == 1 then key = def.key end + end + key = ID_TO_KEY[id] or key + Print(tag .. id .. " icon " .. tostring(aura.icon) .. " " + .. (aura.name or "?") + .. (key and (" |cff0cd29f-> " .. key .. "|r") or "")) + end + end + return + end + + Print(tag .. "auras restricted: " .. tostring(Restricted()) + .. " vantus prefix: " .. (VantusPrefix() or "|cffff5555unresolved|r")) + for _, def in ipairs(COLUMNS) do + if def.ids then + for id in pairs(def.ids) do + local secret = C_Secrets and C_Secrets.ShouldSpellAuraBeSecret + and C_Secrets.ShouldSpellAuraBeSecret(id) + local info = C_Spell and C_Spell.GetSpellInfo and C_Spell.GetSpellInfo(id) + Print(tag .. ColumnName(def) .. " " .. id .. " " + .. ((info and info.name) or "|cffff5555?|r") + .. (secret and " |cffff5555SECRET|r" or "")) + end + end + end +end diff --git a/EllesmereUI_Comms.lua b/EllesmereUI_Comms.lua new file mode 100644 index 00000000..7dfe722a --- /dev/null +++ b/EllesmereUI_Comms.lua @@ -0,0 +1,184 @@ +------------------------------------------------------------------------------- +-- EllesmereUI_Comms.lua -- Shared addon-to-addon messaging +-- +-- One transport for every module that needs to ask the group something the +-- client will not tell it: durability, weapon enchants, later a shared note. +-- It lives in the parent for the same reason EllesmereUI_Glows.lua does -- +-- modules attach here instead of each growing its own channel, its own +-- throttle and its own parser. +-- +-- ONE PREFIX, TYPED MESSAGES. Registering a prefix per message kind burns a +-- scarce global budget for nothing; the kind travels inside instead: +-- +-- || +-- +-- Version leads so a future format can be recognised and skipped rather than +-- misread. An unknown type is dropped in silence, which is what makes an old +-- client safe in a group with a new one. +-- +-- THE INBOUND HANDLER IS THE ATTACK SURFACE. Everything here arrives from +-- another player's client and is assumed hostile: +-- +-- * the envelope is matched by a pattern with a bounded type charset, so a +-- malformed line cannot reach a handler at all +-- * no value off the wire indexes a table, sizes an allocation, or is +-- concatenated into anything executable -- there is no loadstring here +-- and there must never be one +-- * senders are rate limited individually, so one client cannot make every +-- other client work by shouting +-- * payloads are handed to modules as opaque strings; each validates its +-- own shape, because only it knows what shape it expects +-- +-- THE GAME'S OWN LIMITS, handled once here rather than in every caller: +-- +-- * 255 bytes per message. Anything longer is the caller's problem for now; +-- chunking arrives with the first feature that needs it. +-- * INSTANCE_CHAT is mandatory inside instanced group content. Sending to +-- RAID there does not route, and that is a silent failure -- Channel() +-- is the single place that decision is made. +-- * the client throttles bursts, so sends leave through a queue, and forty +-- clients answering one request stagger their replies. Without that +-- spread most of the answers are dropped and nobody is told. +------------------------------------------------------------------------------- + +local PREFIX = "EllesmereUI" +local VERSION = 1 +local MAX_BYTES = 255 +local SEND_PERIOD = 0.12 -- ~8 messages a second, well inside the throttle +local REPLY_SPREAD = 3 -- seconds a broadcast reply may wait before leaving + +-- Per sender, per second. A raid check is one reply each; anything past this +-- is either a bug or an attempt to make us work. +local RATE_LIMIT = 5 + +local Comms = {} +EllesmereUI.Comms = Comms + +local handlers = {} -- type -> function(sender, payload) +local queue = {} -- pending outbound, already formatted +local seen = {} -- sender -> { count, window } + +------------------------------------------------------------------------------- +-- Outbound +------------------------------------------------------------------------------- + +-- The one place the channel is decided. INSTANCE_CHAT is not a preference +-- inside instanced content -- RAID simply does not route there. +local function Channel() + local inInstance, kind = IsInInstance() + if inInstance and (kind == "party" or kind == "raid") then return "INSTANCE_CHAT" end + if IsInRaid() then return "RAID" end + if IsInGroup() then return "PARTY" end + return nil +end + +-- Returns falsy when the queue runs dry, which is the shared ticker's signal +-- to stop itself -- so an idle transport costs nothing at all. +local function Drain() + local msg = table.remove(queue, 1) + if not msg then return false end + local channel = Channel() + if channel then C_ChatInfo.SendAddonMessage(PREFIX, msg, channel) end + return true -- keep draining even if that one was dropped +end + +-- An interval driver rather than a per-frame one accumulating dt: the C engine +-- sleeps between fires, so this costs no Lua at frame rate. +local sender = EllesmereUI.Tick.NewAnimTicker(CreateFrame("Frame"), Drain, SEND_PERIOD) + +-- `payload` must be a string the receiving module knows how to read; this +-- layer never inspects it. `spread` is seconds: with it, the message leaves +-- after a random slice of that window, which is what keeps forty clients +-- answering one broadcast from landing in a single frame and mostly being +-- dropped. That is a property of the transport, not of any caller. +function Comms.Send(msgType, payload, spread) + if not Channel() then return end + local text = VERSION .. "|" .. msgType .. "|" .. (payload or "") + -- Silently truncating would hand the receiver a half message that parses. + -- Refusing is the honest failure until chunking exists. + if #text > MAX_BYTES then return end + if spread then + C_Timer.After(math.random() * spread, function() + queue[#queue + 1] = text + sender.Start() + end) + return + end + queue[#queue + 1] = text + sender.Start() +end + +-- Seconds a broadcast answer may wait before leaving. Exposed so a caller says +-- Comms.Send(t, p, Comms.REPLY_SPREAD) rather than inventing its own number. +Comms.REPLY_SPREAD = REPLY_SPREAD + +------------------------------------------------------------------------------- +-- Inbound +------------------------------------------------------------------------------- + +-- Register a handler for one message type. Called as fn(sender, payload) with +-- payload an unvalidated string: the module owns its own format, so only the +-- module can check it. +function Comms.On(msgType, fn) + if type(msgType) ~= "string" or type(fn) ~= "function" then return end + handlers[msgType] = fn +end + +-- True while this sender is inside its budget. The table is wiped on every +-- roster change, so it cannot grow across an evening of pugs. +local function WithinRate(sender) + local now = GetTime() + local e = seen[sender] + if not e then + seen[sender] = { count = 1, window = now } + return true + end + if now - e.window >= 1 then + e.count, e.window = 1, now + return true + end + e.count = e.count + 1 + return e.count <= RATE_LIMIT +end + +local ev = CreateFrame("Frame") +ev:RegisterEvent("CHAT_MSG_ADDON") +ev:RegisterEvent("GROUP_ROSTER_UPDATE") +ev:SetScript("OnEvent", function(_, event, prefix, text, _, sender) + if event == "GROUP_ROSTER_UPDATE" then + wipe(seen) + return + end + if prefix ~= PREFIX then return end + if type(text) ~= "string" or #text > MAX_BYTES then return end + if type(sender) ~= "string" or not WithinRate(sender) then return end + + -- Bounded on purpose: a version that is not digits, or a type carrying + -- anything but word characters, never reaches a handler. + -- + -- The version is the ENVELOPE's, and it is compared as "not from the + -- future" rather than for equality. Equality would make one envelope bump + -- a hard fork for every message type at once, including the ones that did + -- not change -- and the churn is all in payloads, which each module + -- versions for itself. + local v, msgType, payload = text:match("^(%d+)|([%w_]+)|(.*)$") + v = v and tonumber(v) + if not v or v > VERSION then return end + + local fn = handlers[msgType] + if fn then pcall(fn, sender, payload) end +end) + +------------------------------------------------------------------------------- +-- Lifecycle +------------------------------------------------------------------------------- + +-- Registering the prefix is what makes CHAT_MSG_ADDON fire for it at all. +local boot = CreateFrame("Frame") +boot:RegisterEvent("PLAYER_LOGIN") +boot:SetScript("OnEvent", function(self) + self:UnregisterAllEvents() + if C_ChatInfo and C_ChatInfo.RegisterAddonMessagePrefix then + C_ChatInfo.RegisterAddonMessagePrefix(PREFIX) + end +end) diff --git a/EllesmereUI_RaidBuffs.lua b/EllesmereUI_RaidBuffs.lua new file mode 100644 index 00000000..9f518a18 --- /dev/null +++ b/EllesmereUI_RaidBuffs.lua @@ -0,0 +1,43 @@ +------------------------------------------------------------------------------- +-- EllesmereUI_RaidBuffs.lua -- Shared raid buff definitions +-- +-- Which class provides which group-wide buff, and the aura ids that prove it +-- landed. Facts about the game, not about any one feature, which is why they +-- live in the parent: every child addon has the parent, so a module reading +-- this depends on nothing the user might have switched off. +-- +-- Aura Buff Reminders owned this table first, because it needed it first -- +-- it is the module that nags people to recast a missing buff. The Raid Tools +-- consumable check needs the same answers, and a second copy would mean two +-- lists to update and one of them silently wrong. Same reasoning as +-- EllesmereUI_Glows.lua: modules attach here instead of duplicating. +-- +-- FIELDS +-- key stable identifier, used as a settings key -- never rename one +-- class the class token that can cast it. A buff nobody present can +-- provide is not missing, and consumers use this to say so. +-- name English fallback only; the localized name comes from the game +-- through castSpell. +-- castSpell the spell a player casts. Also the source of the icon. +-- buffIDs every aura id that counts as "has it" -- a buff can land under +-- more than one id depending on who cast it or how. +-- check "raid" for group-wide buffs. Anything else is a per-player +-- reminder and does not belong in a raid-wide check. +-- benefit which stat it grants, where that distinction matters. +-- +-- Ids age slowly here: these are class spells, so an expansion adds one +-- rather than invalidating the rest. +------------------------------------------------------------------------------- + +EllesmereUI.RaidBuffs = { + { key="motw", class="DRUID", name="Mark of the Wild", castSpell=1126, buffIDs={1126,432661}, check="raid" }, + { key="bshout", class="WARRIOR", name="Battle Shout", castSpell=6673, buffIDs={6673}, check="raid", benefit="attackPower" }, + { key="fort", class="PRIEST", name="Power Word: Fortitude", castSpell=21562, buffIDs={21562}, check="raid" }, + { key="ai", class="MAGE", name="Arcane Intellect", castSpell=1459, buffIDs={1459,432778}, check="raid", benefit="intellect" }, + { key="bronze", class="EVOKER", name="Blessing of the Bronze", castSpell=364342, + buffIDs={381732,381741,381746,381748,381749,381750,381751,381752,381753,381754,381756,381757,381758}, + check="raid" }, + { key="sky", class="SHAMAN", name="Skyfury", castSpell=462854, buffIDs={462854}, check="raid" }, + -- Hunter's Mark: disabled (under maintenance) + -- { key="hmark", class="HUNTER", name="Hunter's Mark", castSpell=257284, buffIDs={257284}, check="huntersMark" }, +} diff --git a/Locales/_keys.txt b/Locales/_keys.txt index ab237edb..a8dacf07 100644 --- a/Locales/_keys.txt +++ b/Locales/_keys.txt @@ -1,6 +1,6 @@ # Auto-generated by .tools/extract-locale-keys.sh -- do not edit by hand. # Canonical list of translatable English keys passed as string literals -# (646 unique). Regenerate after wrapping new strings. Keys passed as +# (647 unique). Regenerate after wrapping new strings. Keys passed as # variables are not listed here -- use the in-game /euiloc harvester for # the complete runtime set. (Raid) @@ -439,6 +439,7 @@ Quickbind Quickbind: hover a spell, press a key REAGENTS Racial +Raid Check Raid Tools cannot be toggled by slash command in combat -- use the keybind. Raid Tools is disabled in the EllesmereUI options. Raids