From 987b0fc57576a8d3f7dc9e2732d7a2a98242e45d Mon Sep 17 00:00:00 2001 From: JuJuFX-dev Date: Wed, 16 Sep 2026 19:53:23 +0200 Subject: [PATCH 1/2] Mythic+ Tools: add the Run Summary end-of-key overview Records every finished Mythic+ key and shows an overview panel with one row per party member: spec icon, class-coloured name with item level, M+ score with the gain from the run, looted item, DPS, damage taken, interrupts and deaths, with the best DPS and the lowest damage taken highlighted. The header carries the dungeon, key level, time against the timer, upgrade or depletion and deaths. A picker switches between the stored runs, /ov reopens them, the panel is moved by dragging its header, and a preview shows a sample run. Off by default. Data sources, since the combat log is gone in Midnight. The run comes from C_ChallengeMode.GetChallengeCompletionInfo. Per-player combat numbers come from C_DamageMeter's Overall session, diffed against a sample taken at key start so they are right whether or not the Damage Meters module is loaded. Party score gain has no API, so it is the rating summary sampled at key start subtracted from the one after; our own gain is the server's value. Item level and spec come from an inspect queue that runs through the key and at the chest. Loot is read from the rewards payload, ENCOUNTER_LOOT_RECEIVED and CHAT_MSG_LOOT together; the generated docs describe ENCOUNTER_LOOT_RECEIVED arguments 5 and 6 as itemName and fileName, but BossBannerToast binds them as playerName and className, which is what makes other players' loot readable. Only equippable epic items count; keystones, quest items, housing decor and reagents are skipped. Deaths are counted from the meter's per-death rows. The meter rows stay classified for a while after combat, under either the Combat or the ChallengeMode restriction. After completion the run keeps settling until we leave the instance, retrying the harvest and the late score push on ADDON_RESTRICTION_STATE_CHANGED, PLAYER_REGEN_ENABLED, CHALLENGE_MODE_MEMBER_INFO_UPDATED and LOOT_CLOSED, with PLAYER_ENTERING_WORLD closing it out. The inspect queue chains on INSPECT_READY and restarts when a pull ends. There are no timers and no OnUpdate; every registration is derived from state in one place, and nothing is registered or built while disabled. Secret values are guarded on every read, including walking the source list inside pcall since type() reports a secret table as a table. Unit tokens are checked against the recorded GUID before reuse, since the group can reshuffle after the key. Run records live in a new per-character SavedVariable, EllesmereUIMythicRunsDB, rather than the profile DB, which is exported, imported and stripped as settings. History is capped and pruned when a dungeon leaves the season pool. --- EllesmereUILocales/_keys.txt | 11 +- .../EUI_MythicTimer_RunSummary.lua | 1786 +++++++++++++++++ .../EllesmereUIMythicTimer.lua | 21 + .../EllesmereUIMythicTimer.toc | 2 + 4 files changed, 1819 insertions(+), 1 deletion(-) create mode 100644 EllesmereUIMythicTimer/EUI_MythicTimer_RunSummary.lua diff --git a/EllesmereUILocales/_keys.txt b/EllesmereUILocales/_keys.txt index e4e971518..f6b490f5a 100644 --- a/EllesmereUILocales/_keys.txt +++ b/EllesmereUILocales/_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 -# (789 unique). Regenerate after wrapping new strings. Keys passed as +# (798 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) @@ -9,6 +9,7 @@ (guild bank) Enemy Forces %1$d Death -%2$s +%1$d Deaths %1$d Deaths -%2$s %1$d did not fit %1$s (max %2$s) @@ -53,6 +54,8 @@ + Add New Utility Bar + Add Spell + Add Threshold ++%1$d Upgrade ++%1$d Upgrades , %1$d not shown in this layout 1H 2H @@ -137,6 +140,7 @@ Bank Bank (%1$d / %2$d) Bar %d Blacklisted spells never display. +Blizzard damage meter is off BoE Border Style is only configurable when the Power Bar is detached. Attached Power Bars always use Solid. Border is only available when the Power Bar is shown. @@ -180,6 +184,7 @@ Color Bands Color Picker Color the bar differently at several stack counts. The highest count you have reached wins. Colors are shared globally from the \ +Combat data stayed restricted for this run Conflicting Keybind Context Menu Continue @@ -243,6 +248,7 @@ Delete Spell Delete \ Delete this filter? It is removed from every indicator using it. Delete this global group for all specs +Depleted Deposit Reagents Deposit Warbound Items Deselect All @@ -464,8 +470,10 @@ No buffs assigned. Right click a button in the preview to assign buffs. No death recap available No gems in bags. No houses found. +No loot recorded for this player in this run. No matches No patch notes yet. +No runs recorded yet No spec macros for No spells yet. Add spell IDs above. No talent @@ -583,6 +591,7 @@ Right-click a tab in the bank sidebar to rename it or set its deposit filters. Right-click: favorite Right-click: remove favorite Role +Run Summary is disabled in Mythic+ Tools. Rune Save Save & Exit diff --git a/EllesmereUIMythicTimer/EUI_MythicTimer_RunSummary.lua b/EllesmereUIMythicTimer/EUI_MythicTimer_RunSummary.lua new file mode 100644 index 000000000..a86426c2c --- /dev/null +++ b/EllesmereUIMythicTimer/EUI_MythicTimer_RunSummary.lua @@ -0,0 +1,1786 @@ +if EUI_CLIENT_BLOCKED then return end -- pre-12.1 client failsafe (EllesmereUI_ClientGate.lua) +-------------------------------------------------------------------------------- +-- EUI_MythicTimer_RunSummary.lua -- Run Summary (Mythic+ Tools): end-of-key +-- overview with one row per party member, plus a per character history of +-- finished runs (/ov). Nothing is registered or built until enabled. +-- Sources: C_ChallengeMode for the run and the local player's score delta, +-- C_DamageMeter for every per player combat number (no combat log in +-- Midnight), rating summaries sampled before and after for party score gain. +-------------------------------------------------------------------------------- +local ADDON_NAME, ns = ... + +local db -- Lite DB handle, handed over by ns.RS_OnEnable + +local SV_NAME = "EllesmereUIMythicRunsDB" +local SCHEMA = 1 + +local UNIT_TOKENS = { "player", "party1", "party2", "party3", "party4" } + +local DM_TYPE = Enum and Enum.DamageMeterType +local DM_SESSION = Enum and Enum.DamageMeterSessionType +local SESSION_OVERALL = DM_SESSION and DM_SESSION.Overall + +-- Interrupts and deaths arrive as totalAmount under their own meter type; +-- there is no count field on a combat source. +local METRICS = { + { key = "damage", dmType = DM_TYPE and DM_TYPE.DamageDone }, + { key = "damageTaken", dmType = DM_TYPE and DM_TYPE.DamageTaken }, + { key = "interrupts", dmType = DM_TYPE and DM_TYPE.Interrupts }, + -- One source row per death, most recent first; a real death carries a + -- deathRecapID and totalAmount is not a count, so rows are counted. + { key = "deaths", dmType = DM_TYPE and DM_TYPE.Deaths, countRows = true }, +} + +-- Declared up here rather than with the rest of the collector state: the meter +-- harvest below banks class and spec onto it and must see it as an upvalue. +local roster -- [guid] = member record + +local ShowWindow -- assigned further down, once the panel is defined +local RefreshWindowIfOpen -- same +local RegisterUnlock -- same +local ApplyPosition -- same + +-------------------------------------------------------------------------------- +-- Small helpers +-------------------------------------------------------------------------------- +local function Cfg() + local p = db and db.profile + return p and p.runSummary +end + +local function Enabled() + local c = Cfg() + return c and c.enabled == true +end + +local function IsSecret(v) + return issecretvalue ~= nil and issecretvalue(v) +end + +local function PlainNumber(v) + if type(v) == "number" and not IsSecret(v) then return v end + return nil +end + +local function PlainString(v) + if type(v) == "string" and not IsSecret(v) and v ~= "" then return v end + return nil +end + +-------------------------------------------------------------------------------- +-- Store: a per character SavedVariable, deliberately NOT the profile DB. +-- EUILite's logout StripDefaults pass and the profile export/import machinery +-- both treat profile data as settings, so run records do not belong there. +-- Same split as EllesmereUIChat's scrollback history. +-------------------------------------------------------------------------------- +local function GetSV() + local sv = _G[SV_NAME] + if type(sv) ~= "table" then sv = {}; _G[SV_NAME] = sv end + if type(sv.runs) ~= "table" then sv.runs = {} end + -- Stamped only when absent. Writing it on every read would relabel older + -- records as current and leave a future migration nothing to branch on. + if sv.schema == nil then sv.schema = SCHEMA end + return sv +end + +local function HistorySize() + local c = Cfg() + local n = c and tonumber(c.historySize) or 20 + if n < 5 then n = 5 elseif n > 50 then n = 50 end + return n +end + +local function StoreRun(record) + if type(record) ~= "table" then return end + local sv = GetSV() + table.insert(sv.runs, 1, record) + local cap = HistorySize() + for i = #sv.runs, cap + 1, -1 do + sv.runs[i] = nil + end +end + +function ns.RS_GetRuns() + return GetSV().runs +end + +function ns.RS_ClearHistory() + local sv = GetSV() + wipe(sv.runs) + if RefreshWindowIfOpen then RefreshWindowIfOpen() end +end + +-- Season purge: drop records for dungeons that are no longer in the pool, the +-- same shape EMT:OnInitialize already uses for bestObjectiveSplits. +local function PurgeOldSeasons() + local sv = _G[SV_NAME] + if type(sv) ~= "table" or type(sv.runs) ~= "table" then return end + local maps = C_ChallengeMode and C_ChallengeMode.GetMapTable and C_ChallengeMode.GetMapTable() + if type(maps) ~= "table" or #maps == 0 then return end + local valid = {} + for _, mapID in ipairs(maps) do valid[mapID] = true end + for i = #sv.runs, 1, -1 do + local rec = sv.runs[i] + local mapID = rec and tonumber(rec.mapID) + if not mapID or not valid[mapID] then + table.remove(sv.runs, i) + end + end +end + +-------------------------------------------------------------------------------- +-- C_DamageMeter harvest. Every GetCombatSession* call is SecretWhenInCombat +-- and declassification lags regen, so every field is guarded and the caller +-- retries once. Overall totals are diffed against a baseline taken at key +-- start rather than trusted to be empty: EllesmereUIDamageMeters resets the +-- sessions on CHALLENGE_MODE_START and may simply not be loaded. +-------------------------------------------------------------------------------- +local function MeterAvailableIsTrue() + return C_DamageMeter.IsDamageMeterAvailable() == true +end + +local function MeterReady() + if not (C_DamageMeter and C_DamageMeter.GetCombatSessionFromType) then return false end + if SESSION_OVERALL == nil then return false end + if not C_DamageMeter.IsDamageMeterAvailable then return true end + -- The truthiness test runs inside the pcall so a raise cannot take the + -- whole harvest with it. + local ok, ready = pcall(MeterAvailableIsTrue) + return ok and ready == true +end + +local function WalkSources(session, totals, perSec, identityRoster, countRows) + for _, src in ipairs(session.combatSources) do + local guid = PlainString(src.sourceGUID) + local total = PlainNumber(src.totalAmount) + if countRows then + local rid = PlainNumber(src.deathRecapID) + if guid and rid and rid > 0 then totals[guid] = (totals[guid] or 0) + 1 end + elseif guid and total then + totals[guid] = total + if perSec then perSec[guid] = PlainNumber(src.amountPerSecond) end + end + if identityRoster and guid and identityRoster[guid] then + local rec = identityRoster[guid] + rec.specIcon = rec.specIcon or PlainNumber(src.specIconID) + rec.class = rec.class or PlainString(src.classFilename) + end + end +end + +-- Fills totals[guid] and, when perSec is given, perSec[guid]. Returns false if +-- the session could not be read at all. +-- identityRoster, when given, also banks classFilename and specIconID onto it. +-- Both are NeverSecret, so they read even when the amounts beside them do not. +local function HarvestTotals(dmType, totals, perSec, identityRoster, countRows) + if dmType == nil then return false end + local ok, session = pcall(C_DamageMeter.GetCombatSessionFromType, SESSION_OVERALL, dmType) + if not ok or type(session) ~= "table" or type(session.combatSources) ~= "table" then + return false + end + -- The whole walk sits inside the pcall, not just the call that produced + -- the session: type() answers "table" for a secret table too, so indexing + -- one raises rather than reading nil. + pcall(WalkSources, session, totals, perSec, identityRoster, countRows) + return true +end + +local function SessionDuration() + if not (C_DamageMeter and C_DamageMeter.GetSessionDurationSeconds) then return nil end + if SESSION_OVERALL == nil then return nil end + -- One argument only: the session type. There is no meter type parameter. + local ok, dur = pcall(C_DamageMeter.GetSessionDurationSeconds, SESSION_OVERALL) + if not ok then return nil end + return PlainNumber(dur) +end + +-------------------------------------------------------------------------------- +-- Collector state +-------------------------------------------------------------------------------- +local frame -- built on first enable +local collecting -- true between CHALLENGE_MODE_START and the panel showing +local rosterOrder -- ordered guids +local baseTotals -- [metricKey][guid] = total at key start +local baseDuration -- Overall session duration at key start +local hasBaseline -- false when the key start sample failed or was skipped +local inspectPending -- guid of the outstanding NotifyInspect + +-- Everything the finishing phase needs, captured when the key completes and +-- deliberately out of ResetCollector's reach. The key resets behind us on the +-- way out of the instance while the run is still waiting on the meter, on the +-- chest and on the late score push, so none of that may live in state the +-- reset wipes. +local lastRun -- { record, rst, ord, base, baseDur, based, awaitingMeter, lootArmed, settled } + +-- Collecting-phase state only. The finished run lives in lastRun. +local function ResetCollector() + collecting = false + roster = {} + rosterOrder = {} + baseTotals = {} + baseDuration = nil + hasBaseline = false + inspectPending = nil +end +ResetCollector() + +local function EnsureMember(guid) + local rec = roster[guid] + if not rec then + rec = { guid = guid } + roster[guid] = rec + rosterOrder[#rosterOrder + 1] = guid + end + return rec +end + +local function StillIs(rec) + if not (rec and rec.unit and rec.guid) then return false end + if not UnitExists(rec.unit) then return false end + return PlainString(UnitGUID(rec.unit)) == rec.guid +end + +-- Item link straight out of a chat line. Matching the hyperlink rather than the +-- sentence keeps this independent of client language. +local function LinkFromText(text) + if type(text) ~= "string" or IsSecret(text) then return nil end + return text:match("(|c%x+|Hitem:.-|h.-|h|r)") or text:match("(|Hitem:.-|h.-|h)") +end + +local function IDFromLink(link) + if type(link) ~= "string" then return nil end + return tonumber(link:match("|Hitem:(%d+)")) +end + +-- Records a looted item against a roster member, found by GUID when we have one +-- and by short name otherwise. Never overwrites an item already recorded. +local ITEM_CLASS = Enum and Enum.ItemClass +local ITEM_QUALITY = Enum and Enum.ItemQuality +local GetInstant = (C_Item and C_Item.GetItemInfoInstant) or GetItemInfoInstant + +-- The column is for the chest's gear. Keystones, quest items, housing decor, +-- reagents and anything below epic arrive through the same loot channels and +-- are skipped. Quality is read from the link where there is one, since bonus +-- IDs can raise it; an item not yet cached reports no quality and is let +-- through on its equip slot alone. +local function IsExcludedLootID(id, link) + if not id then return true end + if id == 180653 then return true end + if C_Item and C_Item.IsItemKeystoneByID and C_Item.IsItemKeystoneByID(id) == true then + return true + end + if GetInstant then + local _, _, _, equipLoc, _, classID = GetInstant(id) + classID = PlainNumber(classID) + if ITEM_CLASS and (classID == ITEM_CLASS.Questitem or classID == ITEM_CLASS.Housing) then + return true + end + equipLoc = PlainString(equipLoc) + if not equipLoc or equipLoc == "INVTYPE_NON_EQUIP_IGNORE" then return true end + end + if ITEM_QUALITY and C_Item and C_Item.GetItemQualityByID then + local q = PlainNumber(C_Item.GetItemQualityByID(link or id)) + if q and q < ITEM_QUALITY.Epic then return true end + end + return false +end + +local function IsExcludedLoot(link) + if link:find("|Hkeystone:", 1, true) then return true end + return IsExcludedLootID(IDFromLink(link), link) +end + +local function RecordLoot(rst, ord, guid, name, link) + if not (rst and ord and link) then return false end + if IsExcludedLoot(link) then return false end + local rec = guid and rst[guid] + if not rec and name then + local short = name:match("^([^%-]+)") or name + for _, g in ipairs(ord) do + local r = rst[g] + if r and r.name and (r.name:match("^([^%-]+)") or r.name) == short then + rec = r + break + end + end + end + if not rec or rec.lootLink then return false end + rec.lootLink = link + rec.lootID = IDFromLink(link) or rec.lootID + return true +end + +local function ScoreFor(unit) + if not (C_PlayerInfo and C_PlayerInfo.GetPlayerMythicPlusRatingSummary) then return nil end + local ok, summary = pcall(C_PlayerInfo.GetPlayerMythicPlusRatingSummary, unit) + if not ok or type(summary) ~= "table" then return nil end + return PlainNumber(summary.currentSeasonScore) +end + +-- Walks Blizzard's own five token list. Fills anything still missing; never +-- overwrites a value already captured, so a member who leaves after completion +-- keeps the data collected while they were present. +local function ScanRoster(withStartScore) + for _, unit in ipairs(UNIT_TOKENS) do + if UnitExists(unit) then + local guid = PlainString(UnitGUID(unit)) + if guid then + local rec = EnsureMember(guid) + rec.unit = unit + rec.name = rec.name or PlainString(UnitName(unit)) + if not rec.class then + local _, classFile = UnitClass(unit) + rec.class = PlainString(classFile) + end + if UnitGroupRolesAssignedEnum then + rec.role = PlainNumber(UnitGroupRolesAssignedEnum(unit)) or rec.role + end + if withStartScore and rec.scoreStart == nil then + rec.scoreStart = ScoreFor(unit) + end + end + end + end +end + +-------------------------------------------------------------------------------- +-- Inspect queue: item level and spec icon. GetInspectItemLevel reads whichever +-- player the client currently has cached, so exactly one inspect may be +-- outstanding at a time. The queue sweeps every few seconds during the key and +-- stops once everyone has both values. Taking the spec icon from here rather +-- than from the meter means it does not depend on the meter at all. +-------------------------------------------------------------------------------- +local function OwnSpecIcon() + local CSI = C_SpecializationInfo + if not (CSI and CSI.GetSpecialization and CSI.GetSpecializationInfo) then return nil end + local idx = CSI.GetSpecialization() + if not idx then return nil end + local _, _, _, icon = CSI.GetSpecializationInfo(idx) + return PlainNumber(icon) +end + +-- GetSpecializationInfoForSpecID is the documented name; the ByID global is +-- undocumented but still present and used elsewhere in the suite, so it stays +-- as the fallback. +local function SpecIconForSpecID(specID) + if not specID or specID == 0 then return nil end + local fn = GetSpecializationInfoForSpecID or GetSpecializationInfoByID + if not fn then return nil end + return PlainNumber(select(4, fn(specID))) +end + +local function InspectSpecIcon(unit) + local CSI = C_SpecializationInfo + if not (CSI and CSI.GetInspectSpecialization) then return nil end + return SpecIconForSpecID(PlainNumber(CSI.GetInspectSpecialization(unit))) +end + +local function OwnItemLevel() + if GetAverageItemLevel then + local _, equipped = GetAverageItemLevel() + local v = PlainNumber(equipped) + if v and v > 0 then return math.floor(v) end + end + if C_PaperDollInfo and C_PaperDollInfo.GetInspectItemLevel then + local v = PlainNumber(C_PaperDollInfo.GetInspectItemLevel("player")) + if v and v > 0 then return math.floor(v) end + end + return nil +end + +local INSPECT_MAX_TRIES = 8 + +local function NeedsInspect(rec) + return not rec.ilvl or not rec.specIcon +end + +-- The roster being worked on: the live one during the key, then the finished +-- run's snapshot while it settles, when the group stands at the chest out of +-- combat and in range -- the best inspect window of the whole run. +local function InspectRoster() + if collecting then return roster, rosterOrder end + if lastRun and not lastRun.settled then return lastRun.rst, lastRun.ord end + return nil +end + +-- Event driven: INSPECT_READY chains to the next member; a pull ending, a +-- roster change or the loot window closing restarts it. Fewest attempts goes +-- first, so one player out of range cannot starve everyone behind them. +local function InspectSweep() + local rst, ord = InspectRoster() + if not rst then return end + + local missing = false + for _, guid in ipairs(ord) do + local rec = rst[guid] + if rec then + if rec.unit == "player" then + rec.ilvl = rec.ilvl or OwnItemLevel() + rec.specIcon = rec.specIcon or OwnSpecIcon() + end + if NeedsInspect(rec) then missing = true end + end + end + if not missing or inspectPending or InCombatLockdown() + or (InspectFrame and InspectFrame:IsShown()) then + return + end + + local pick, fewest + for _, guid in ipairs(ord) do + local rec = rst[guid] + local tries = rec and (rec.inspectTries or 0) + if rec and rec.unit ~= "player" and NeedsInspect(rec) + and tries < INSPECT_MAX_TRIES and StillIs(rec) + and UnitIsVisible(rec.unit) and CanInspect(rec.unit) + and (fewest == nil or tries < fewest) then + pick, fewest = rec, tries + end + end + if not pick then return end + pick.inspectTries = fewest + 1 + inspectPending = pick.guid + if ClearInspectPlayer then ClearInspectPlayer() end + NotifyInspect(pick.unit) +end + +-- Returns true when it filled something in. +local function OnInspectReady(guid) + guid = PlainString(guid) + if not guid then return false end + if inspectPending == guid then inspectPending = nil end + local rst = InspectRoster() + local rec = rst and rst[guid] + if not rec or not UnitTokenFromGUID then return false end + local unit = PlainString(UnitTokenFromGUID(guid)) + if not unit or not UnitExists(unit) then return false end + local changed = false + if not rec.ilvl and C_PaperDollInfo and C_PaperDollInfo.GetInspectItemLevel then + local val = PlainNumber(C_PaperDollInfo.GetInspectItemLevel(unit)) + if val and val > 0 then + rec.ilvl = math.floor(val) + changed = true + end + end + if not rec.specIcon then + rec.specIcon = InspectSpecIcon(unit) + changed = changed or rec.specIcon ~= nil + end + return changed +end + +-------------------------------------------------------------------------------- +-- Run lifecycle +-------------------------------------------------------------------------------- +-- While a Combat or ChallengeMode restriction is up, the meter's source rows +-- read classified (sourceGUID and totalAmount come back secret), so a harvest +-- banks nothing. Declassification lags the end of combat, which is exactly the +-- window CHALLENGE_MODE_COMPLETED lands in. +local RESTRICTION_KINDS = { "Combat", "ChallengeMode" } +local function RestrictionActive() + local CRA = C_RestrictedActions + if not (CRA and CRA.IsAddOnRestrictionActive and Enum.AddOnRestrictionType) then + return false + end + for _, kind in ipairs(RESTRICTION_KINDS) do + local t = Enum.AddOnRestrictionType[kind] + if t ~= nil and CRA.IsAddOnRestrictionActive(t) == true then return true end + end + return false +end + +-- The finished run is "settling" from completion until we leave the instance: +-- the meter may still be classified, party scores arrive late, and loot is +-- announced as the chest is opened. Leaving the instance ends it. +local function Settling() + return lastRun ~= nil and not lastRun.settled +end + +local function SetEvent(event, on) + if on then frame:RegisterEvent(event) else frame:UnregisterEvent(event) end +end + +-- Every registration derives from state here, so no path can leave an event +-- registered or dropped out of step. Loot has three sources, none complete: +-- the rewards payload (our own item), ENCOUNTER_LOOT_RECEIVED (docs say args +-- 5/6 are itemName/fileName; BossBannerToast binds playerName/className) and +-- CHAT_MSG_LOOT (looter GUID in arg 12). +local function SyncEvents() + if not frame then return end + local on = Enabled() == true + local settling = on and Settling() + local waiting = settling and lastRun.awaitingMeter == true + SetEvent("CHALLENGE_MODE_START", on) + SetEvent("CHALLENGE_MODE_COMPLETED", on) + SetEvent("CHALLENGE_MODE_RESET", on) + SetEvent("INSPECT_READY", on and (collecting or settling)) + SetEvent("GROUP_ROSTER_UPDATE", on and (collecting or settling)) + SetEvent("CHALLENGE_MODE_MEMBER_INFO_UPDATED", on and (collecting or settling)) + SetEvent("PLAYER_REGEN_ENABLED", on and (collecting or settling)) + SetEvent("ADDON_RESTRICTION_STATE_CHANGED", waiting) + SetEvent("CHALLENGE_MODE_COMPLETED_REWARDS", settling) + SetEvent("ENCOUNTER_LOOT_RECEIVED", settling) + SetEvent("CHAT_MSG_LOOT", settling) + SetEvent("LOOT_CLOSED", settling) + SetEvent("PLAYER_ENTERING_WORLD", settling) +end + +-- skipBaseline is for the recovery path at CHALLENGE_MODE_COMPLETED, where a +-- reload lost the key start. Sampling a baseline there would bank the finished +-- run's own totals and make every delta zero; with none, the deltas are the +-- Overall session, which is the closest honest answer available. +local function StartCollecting(skipBaseline) + -- A new key supersedes anything the previous one was still waiting for. + lastRun = nil + ResetCollector() + collecting = true + ScanRoster(true) + if not skipBaseline and MeterReady() then + local allRead = true + for _, m in ipairs(METRICS) do + local t = {} + if not HarvestTotals(m.dmType, t, nil, nil, m.countRows) then allRead = false end + baseTotals[m.key] = t + end + -- Right after the reset at key start there is no session yet and the + -- duration reads nil; that is an empty session, so it counts as zero. + baseDuration = SessionDuration() or 0 + -- An unread baseline is not a zero baseline: without this flag a failed + -- read silently turns into "the whole session counts as this run". + hasBaseline = allRead + end + SyncEvents() + InspectSweep() +end + +-- Leaves the finish watch alone on purpose: the key resetting behind us is +-- normal on the way out of the instance, and the finished run is still waiting +-- for the meter to declassify. It holds its own roster snapshot, so the wipe +-- below cannot reach it. +local function StopCollecting() + ResetCollector() + SyncEvents() +end + +-- Reads the four meter types, subtracts the key start baseline and writes the +-- numbers onto the run's roster. The baseline is passed in rather than read +-- from the live upvalue: retries land after the key has reset, which wipes it. +local scratchTotals, scratchPerSec = {}, {} + +local function HarvestInto(record, rst, base, baseDur, based) + rst = rst or roster + base = base or baseTotals + if baseDur == nil then baseDur = baseDuration end + if based == nil then based = hasBaseline end + if not MeterReady() then + -- Must be recorded here too, or the panel shows empty combat columns + -- with no word of why the meter had nothing to give. + record.meterAvailable = false + return false + end + + local totals, perSec = scratchTotals, scratchPerSec + local okAny = false + local matched = 0 + local duration = SessionDuration() + local deltaDuration + -- Without a trustworthy key start sample the session duration covers + -- everything since login, so it must not be used as the run's duration. + -- amountPerSecond is Blizzard's own figure and is the honest fallback. + if based and duration and baseDur and duration > baseDur then + deltaDuration = duration - baseDur + end + record.hasBaseline = based and true or false + + for _, m in ipairs(METRICS) do + wipe(totals); wipe(perSec) + local isDamage = (m.key == "damage") + if HarvestTotals(m.dmType, totals, isDamage and perSec or nil, isDamage and rst or nil, m.countRows) then + okAny = true + local b = base[m.key] + for guid, total in pairs(totals) do + local rec = rst[guid] + if rec then + local delta = total - ((b and b[guid]) or 0) + if delta < 0 then delta = total end + rec[m.key] = delta + if isDamage then + matched = matched + 1 + if deltaDuration and deltaDuration > 1 then + rec.dps = delta / deltaDuration + else + rec.dps = perSec[guid] + end + end + end + end + end + end + + record.meterAvailable = okAny + -- How many roster members actually came back with a damage figure. Zero + -- with a readable session means the rows were still classified, which is + -- what the retries below wait out. + record._matched = matched + return okAny +end + +local function ApplyScores(record, rst, ord) + rst, ord = rst or roster, ord or rosterOrder + for _, guid in ipairs(ord) do + local rec = rst[guid] + if StillIs(rec) then + local now = ScoreFor(rec.unit) + if now then + rec.score = now + -- No API reports another player's delta for the run, so it is + -- the score after the key minus the score sampled at its start. + -- Without a baseline the cell stays empty rather than showing a + -- wrong number. + if rec.scoreStart then rec.scoreGain = now - rec.scoreStart end + end + end + end + -- The local player has a real server side delta; prefer it. + if record.scoreOld and record.scoreNew then + local ownGUID = PlainString(UnitGUID("player")) + local own = ownGUID and rst[ownGUID] + if own then + own.score = record.scoreNew + own.scoreGain = record.scoreNew - record.scoreOld + end + end +end + +-- Tank, healer, then damage by output, the order these panels are read in. +-- Every value was validated plain before it was stored, so comparing cannot +-- raise. +local ROLE_RANK = {} +if Enum and Enum.LFGRole then + ROLE_RANK[Enum.LFGRole.Tank] = 1 + ROLE_RANK[Enum.LFGRole.Healer] = 2 + ROLE_RANK[Enum.LFGRole.Damage] = 3 +end + +local function MemberOrder(a, b) + local ra = (a.role ~= nil and ROLE_RANK[a.role]) or 4 + local rb = (b.role ~= nil and ROLE_RANK[b.role]) or 4 + if ra ~= rb then return ra < rb end + local da, dbm = a.damage or -1, b.damage or -1 + if da ~= dbm then return da > dbm end + return (a.name or "") < (b.name or "") +end + +local MEMBER_FIELDS = { + "guid", "name", "class", "role", "specIcon", "ilvl", "score", "scoreGain", + "damage", "dps", "damageTaken", "interrupts", "deaths", "lootID", "lootLink", +} + +-- Rebuilt on every settling event, so the member tables already stored on the +-- record are overwritten in place rather than allocated again. +local function BuildMembers(record, rst, ord) + rst, ord = rst or roster, ord or rosterOrder + local out = record.members + if type(out) ~= "table" then out = {}; record.members = out end + local n = 0 + for _, guid in ipairs(ord) do + local rec = rst[guid] + if rec and rec.name then + n = n + 1 + local m = out[n] + if type(m) ~= "table" then m = {}; out[n] = m end + for _, f in ipairs(MEMBER_FIELDS) do m[f] = rec[f] end + end + end + for i = #out, n + 1, -1 do out[i] = nil end + table.sort(out, MemberOrder) +end + +local function TryFinishHarvest() + local lr = lastRun + if not (lr and lr.awaitingMeter) then return end + if RestrictionActive() then return end + HarvestInto(lr.record, lr.rst, lr.base, lr.baseDur, lr.based) + BuildMembers(lr.record, lr.rst, lr.ord) + if RefreshWindowIfOpen then RefreshWindowIfOpen() end + if (lr.record._matched or 0) > 0 then + lr.awaitingMeter = false + SyncEvents() + end +end + +-- Party members' rating is pushed by the server after the key and has no event +-- of its own, so it is re-read on the settling events that do fire. +local function RefreshScores() + local lr = lastRun + if not (lr and not lr.settled) then return end + ApplyScores(lr.record, lr.rst, lr.ord) + BuildMembers(lr.record, lr.rst, lr.ord) + if RefreshWindowIfOpen then RefreshWindowIfOpen() end +end + +local function FinishRun() + local info = C_ChallengeMode and C_ChallengeMode.GetChallengeCompletionInfo + and C_ChallengeMode.GetChallengeCompletionInfo() + if type(info) ~= "table" then return end + + -- Capture the party tokens here and now: Blizzard's own completion banner + -- does the same because members can leave immediately afterwards. + ScanRoster(false) + + -- info.members is the server side roster and survives people leaving. + if type(info.members) == "table" then + for _, m in ipairs(info.members) do + local guid = PlainString(m.memberGUID) + if guid then + local rec = EnsureMember(guid) + rec.name = rec.name or PlainString(m.name) + if not rec.class then + -- UnitClass takes a unit token, so it cannot answer for + -- someone who already left. GetPlayerInfoByGUID is the + -- GUID keyed equivalent. + if GetPlayerInfoByGUID then + rec.class = PlainString(select(2, GetPlayerInfoByGUID(guid))) + end + if not rec.class and UnitTokenFromGUID then + local u = PlainString(UnitTokenFromGUID(guid)) + if u and UnitExists(u) then + rec.class = PlainString(select(2, UnitClass(u))) + end + end + end + end + end + end + + local mapID = PlainNumber(info.mapChallengeModeID) + local mapName, _, timeLimit + if mapID and C_ChallengeMode.GetMapUIInfo then + mapName, _, timeLimit = C_ChallengeMode.GetMapUIInfo(mapID) + end + local deaths, timeLost + if C_ChallengeMode.GetDeathCount then + deaths, timeLost = C_ChallengeMode.GetDeathCount() + end + + local record = { + mapID = mapID, + mapName = PlainString(mapName), + timeLimit = PlainNumber(timeLimit), + level = PlainNumber(info.level), + timeMS = PlainNumber(info.time), -- milliseconds + onTime = info.onTime == true, + upgrades = PlainNumber(info.keystoneUpgradeLevels) or 0, + practice = info.practiceRun == true, + mapRecord = info.isMapRecord == true, + deaths = PlainNumber(deaths) or 0, + timeLost = PlainNumber(timeLost) or 0, + scoreOld = PlainNumber(info.oldOverallDungeonScore), + scoreNew = PlainNumber(info.newOverallDungeonScore), + finishedAt = time(), + } + + local lr = { + record = record, rst = roster, ord = rosterOrder, + base = baseTotals, baseDur = baseDuration, based = hasBaseline, + } + lastRun = lr + PurgeOldSeasons() + HarvestInto(record, lr.rst, lr.base, lr.baseDur, lr.based) + ApplyScores(record, lr.rst, lr.ord) + BuildMembers(record, lr.rst, lr.ord) + StoreRun(record) + + -- If the rows are still classified, the restriction lift, the next pull + -- ending, the loot window closing or leaving the instance each retry. + lr.awaitingMeter = (record._matched or 0) == 0 + SyncEvents() +end + +local function ShowPending() + local lr = lastRun + if not lr then return end + lr.lootArmed = false + BuildMembers(lr.record, lr.rst, lr.ord) + if ShowWindow then ShowWindow(lr.record) end + -- The roster scan is done; the settling watch, inspects included, runs on. + collecting = false + SyncEvents() + InspectSweep() +end + +-- Leaving the instance closes the settling phase: one last attempt at the +-- meter and the scores, the panel if it never opened, then everything that was +-- only listening for this run is dropped. +local function EndSettling() + local lr = lastRun + if not (lr and not lr.settled) then return end + TryFinishHarvest() + RefreshScores() + if lr.lootArmed then ShowPending() end + lr.awaitingMeter = false + lr.settled = true + SyncEvents() + if RefreshWindowIfOpen then RefreshWindowIfOpen() end +end + +local function ArmLootWait() + local lr = lastRun + if not lr then return end + local c = Cfg() + if not (c and c.showAfterLoot) then + ShowPending() + return + end + -- Opens on the rewards payload or the loot window closing; if neither + -- comes, leaving the instance opens it. + lr.lootArmed = true +end + +-------------------------------------------------------------------------------- +-- Events +-------------------------------------------------------------------------------- +local function OnEvent(_, event, ...) + if event == "CHALLENGE_MODE_START" then + StartCollecting() + + elseif event == "CHALLENGE_MODE_RESET" then + StopCollecting() + + elseif event == "CHALLENGE_MODE_COMPLETED" then + -- lastRun is only set by FinishRun and only cleared when a new key + -- starts, so its presence means this key was already recorded. Without + -- this, a repeated delivery would store the run twice, the second time + -- with no baseline and with every score sampled after the fact. + if lastRun then return end + if not collecting then + -- Reloading mid key loses the key start; collect what is still + -- readable rather than dropping the run entirely. + StartCollecting(true) + end + FinishRun() + ArmLootWait() + + elseif event == "CHALLENGE_MODE_COMPLETED_REWARDS" then + -- rewards carry the LOCAL player's chest loot only; personal loot is + -- never reported for anyone else. + local lr = lastRun + if lr then + local rewards = select(5, ...) + local ownGUID = PlainString(UnitGUID("player")) + local own = ownGUID and lr.rst[ownGUID] + if own and type(rewards) == "table" then + for _, reward in ipairs(rewards) do + local id = PlainNumber(reward.rewardID) + if id and reward.isCurrency ~= true and not IsExcludedLootID(id) then + -- Kept as an ID: the item is often not in the client's + -- cache yet here, so icon and tooltip resolve from the + -- ID at render time. A link from one of the two loot + -- events below is preferred when it arrives. + own.lootID = own.lootID or id + break + end + end + end + BuildMembers(lr.record, lr.rst, lr.ord) + if RefreshWindowIfOpen then RefreshWindowIfOpen() end + end + if lr and lr.lootArmed then ShowPending() end + + elseif event == "ENCOUNTER_LOOT_RECEIVED" then + local lr = lastRun + if lr then + local link, who = select(3, ...), select(5, ...) + if RecordLoot(lr.rst, lr.ord, nil, PlainString(who), PlainString(link)) then + BuildMembers(lr.record, lr.rst, lr.ord) + if RefreshWindowIfOpen then RefreshWindowIfOpen() end + end + end + + elseif event == "CHAT_MSG_LOOT" then + local lr = lastRun + if lr then + local text, who, guid = select(1, ...), select(2, ...), select(12, ...) + local link = LinkFromText(text) + if link and RecordLoot(lr.rst, lr.ord, PlainString(guid), PlainString(who), link) then + BuildMembers(lr.record, lr.rst, lr.ord) + if RefreshWindowIfOpen then RefreshWindowIfOpen() end + end + end + + elseif event == "LOOT_CLOSED" then + if lastRun and lastRun.lootArmed then ShowPending() end + TryFinishHarvest() + RefreshScores() + InspectSweep() + + elseif event == "ADDON_RESTRICTION_STATE_CHANGED" then + TryFinishHarvest() + + elseif event == "PLAYER_REGEN_ENABLED" then + -- A pull ended: an inspect still pending from before it was dropped, + -- so free the slot and carry on. + inspectPending = nil + InspectSweep() + TryFinishHarvest() + + elseif event == "PLAYER_ENTERING_WORLD" then + EndSettling() + + elseif event == "INSPECT_READY" then + if OnInspectReady((select(1, ...))) and lastRun and not lastRun.settled then + BuildMembers(lastRun.record, lastRun.rst, lastRun.ord) + if RefreshWindowIfOpen then RefreshWindowIfOpen() end + end + InspectSweep() + + elseif event == "CHALLENGE_MODE_MEMBER_INFO_UPDATED" or event == "GROUP_ROSTER_UPDATE" then + if collecting then ScanRoster(true) end + InspectSweep() + RefreshScores() + end +end + +-------------------------------------------------------------------------------- +-- Enable / disable. Nothing exists until the feature is switched on. +-------------------------------------------------------------------------------- +-- Lets the options page repaint an open panel when a column or the scale +-- changes, without exposing the panel internals. +function ns.RS_Refresh() + if RefreshWindowIfOpen then RefreshWindowIfOpen() end +end + +function ns.RS_Apply() + if Enabled() then + if not frame then + frame = CreateFrame("Frame") + frame:SetScript("OnEvent", OnEvent) + end + SyncEvents() + -- Enabling mid key still picks the run up from here on. Guarded on + -- lastRun because IsChallengeModeActive stays true after completion + -- until the key resets, and every options change calls back in here: + -- without it, touching a setting inside the instance would restart the + -- collector and discard the run that just finished. + if not collecting and not lastRun + and C_ChallengeMode and C_ChallengeMode.IsChallengeModeActive + and C_ChallengeMode.IsChallengeModeActive() then + StartCollecting() + end + -- Unlock Mode entry intentionally not registered for now; the panel is + -- moved by dragging its header. RegisterUnlock is kept for later. + elseif frame then + frame:UnregisterAllEvents() + lastRun = nil + StopCollecting() + end +end + +function ns.RS_OnEnable(database) + db = database + if not db then return end + ns.RS_Apply() +end + +-------------------------------------------------------------------------------- +-- Panel. EllesmereUI.Widgets is deliberately not used: EllesmereUIOptions is +-- LoadOnDemand, so a widget call would force load the whole options addon at +-- the end of every key. Chrome mirrors Damage Meters' own window, and all +-- geometry goes through EllesmereUI.PP, the game grid, never PanelPP. +-------------------------------------------------------------------------------- +local EUI = EllesmereUI +local format = string.format +local floor = math.floor + +-- Sized for reading at a glance rather than as a compact HUD element; the +-- Panel Scale setting multiplies all of it on top of the player's UI scale. +local PAD = 15 +local ROW_H = 24 +local HEAD_H = 56 +local COLHDR_H = 22 +local COL_GAP = 15 +local SUB_GAP = 5 +local ICON_SZ = 20 +local FONT_SZ = 15 +local TITLE_SZ = 17 +local SUBLINE_SZ = 13 +local COLHDR_SZ = 12 +local PICKER_SZ = 12 +local NAME_MIN = 150 +local PICKER_W = 240 +local PICKER_H = 22 +local CLOSE_W = 14 +local HEADER_GAP = 10 +local CLOSE_ICON = "Interface\\AddOns\\EllesmereUI\\media\\icons\\eui-close.png" +local FONT_FALLBACK = "Interface\\AddOns\\EllesmereUI\\media\\fonts\\Expressway.TTF" +local CLASS_ICON_TEX = "Interface\\GLUES\\CHARACTERCREATE\\UI-CHARACTERCREATE-CLASSES" + +-- `sub` marks a column that carries a second, separately styled font string. +-- "inline" keeps it right after the primary's own text (item level after a +-- name); "column" parks it at a fixed offset so the primaries stay right +-- aligned under each other and the extras all start on the same x (score). +local COLUMNS = { + { key = "spec", header = "", kind = "icon", minW = ICON_SZ, cfg = "showSpecIcons", gapAfter = SUB_GAP }, + { key = "name", header = "Player", kind = "text", justify = "LEFT", minW = NAME_MIN, sub = "inline" }, + { key = "score", header = "Score", kind = "text", justify = "RIGHT", minW = 42, cfg = "colScore", sub = "column" }, + { key = "loot", header = "Loot", kind = "icon", minW = ICON_SZ, cfg = "colLoot" }, + { key = "dps", header = "DPS", kind = "text", justify = "RIGHT", minW = 52, cfg = "colDps" }, + { key = "damageTaken", header = "Taken", kind = "text", justify = "RIGHT", minW = 52, cfg = "colDamageTaken" }, + { key = "interrupts", header = "Interrupts", kind = "text", justify = "RIGHT", minW = 28, cfg = "colInterrupts" }, + { key = "deaths", header = "Deaths", kind = "text", justify = "RIGHT", minW = 28, cfg = "colDeaths" }, +} + +-- Secondary font strings: item level and score gain, both two sizes down and +-- unbolded. +local SUB_STYLE = { + name = { size = FONT_SZ - 2, r = 0.55, g = 0.55, b = 0.55 }, + score = { size = FONT_SZ - 2, r = 0.35, g = 0.95, b = 0.35 }, +} + +local win, rows, colHdr, titleFS, subFS, pickerBtn, pickerLbl +local visibleCols = {} +local currentRecord + +-------------------------------------------------------------------------------- +-- Formatting +-------------------------------------------------------------------------------- +-- flags = nil follows the module's font setting; pass "" for an unbolded run. +local function SetFS(fs, size, flags) + local path = (EUI.GetFontPath and EUI.GetFontPath("mythicTimer")) or FONT_FALLBACK + local outline = flags + if outline == nil then + outline = (EUI.GetFontOutlineFlag and EUI.GetFontOutlineFlag("mythicTimer")) or "" + end + fs:SetFont(path, size or FONT_SZ, outline) +end + +local function Hex(r, g, b) + return format("%02x%02x%02x", floor((r or 1) * 255), floor((g or 1) * 255), floor((b or 1) * 255)) +end + +local function Abbrev(v) + if type(v) ~= "number" then return nil end + if v >= 1e9 then return format("%.2fB", v / 1e9) end + if v >= 1e6 then return format("%.2fM", v / 1e6) end + if v >= 1e3 then return format("%.1fK", v / 1e3) end + return format("%d", floor(v)) +end + +local function Clock(seconds) + if type(seconds) ~= "number" or seconds < 0 then seconds = 0 end + local whole = floor(seconds) + return format("%d:%02d", floor(whole / 60), whole % 60) +end + +-- Indexing RAID_CLASS_COLORS with a secret token throws, so this is the guard +-- shape Damage Meters already uses on its own rows. +local function ClassHex(class) + if class and not IsSecret(class) and RAID_CLASS_COLORS and RAID_CLASS_COLORS[class] then + local cc = EUI.GetClassColor and EUI.GetClassColor(class) + if cc then return Hex(cc.r, cc.g, cc.b) end + end + return "ffffff" +end + +local function ShortName(name) + if type(name) ~= "string" then return "?" end + return name:match("^([^%-]+)") or name +end + +local function ScoreText(m) + if not m.score then return "-" end + local hex = "ffffff" + local col = C_ChallengeMode and C_ChallengeMode.GetDungeonScoreRarityColor + and C_ChallengeMode.GetDungeonScoreRarityColor(m.score) + if type(col) == "table" and col.r then hex = Hex(col.r, col.g, col.b) end + return format("|cff%s%d|r", hex, floor(m.score)) +end + +-- Zero and missing both read as a dash in the combat columns. +local function NonZero(v) + if type(v) == "number" and floor(v) > 0 then return v end + return nil +end + +local function CellText(col, m, c) + local k = col.key + if k == "name" then return format("|cff%s%s|r", ClassHex(m.class), ShortName(m.name)) end + if k == "score" then return ScoreText(m) end + if k == "dps" then return Abbrev(NonZero(m.dps)) or "-" end + if k == "damageTaken" then return Abbrev(NonZero(m.damageTaken)) or "-" end + if k == "interrupts" then return NonZero(m.interrupts) and format("%d", floor(m.interrupts)) or "-" end + if k == "deaths" then return NonZero(m.deaths) and format("%d", floor(m.deaths)) or "-" end + return "" +end + +local function SubText(col, m, c) + if col.key == "name" then + if c and c.colItemLevel ~= false and m.ilvl then return format("(%d)", m.ilvl) end + return "" + end + if col.key == "score" then + if m.scoreGain and m.scoreGain > 0 then return format("(+%d)", floor(m.scoreGain)) end + return "" + end + return "" +end + +-- date() without a leading "!" formats in the player's local time. +local function RunLabel(rec) + local when = rec.finishedAt and date("%H:%M %Y-%m-%d", rec.finishedAt) or "" + return format("%s +%d %s", rec.mapName or "?", rec.level or 0, when) +end + +-------------------------------------------------------------------------------- +-- Chrome +-------------------------------------------------------------------------------- +local function SavePositionFromFrame() + local c = Cfg() + if not (c and win) then return end + local point, _, relPoint, x, y = win:GetPoint(1) + if not point then return end + c.position = { point = point, relPoint = relPoint or point, x = x, y = y } +end + +ApplyPosition = function() + if not win then return end + local c = Cfg() + local pos = c and c.position + win:ClearAllPoints() + if pos and pos.point then + local PP = EUI.PP + local es = win:GetEffectiveScale() or 1 + local x, y = pos.x or 0, pos.y or 0 + if PP and PP.SnapForES then + x, y = PP.SnapForES(x, es), PP.SnapForES(y, es) + end + win:SetPoint(pos.point, UIParent, pos.relPoint or pos.point, x, y) + else + win:SetPoint("CENTER", UIParent, "CENTER", 0, 120) + end +end + +local function MakeRow(parent) + local row = CreateFrame("Frame", nil, parent) + row:SetHeight(ROW_H) + row.text, row.sub, row.icons = {}, {}, {} + local px = (EUI.PP and EUI.PP.mult) or 1 + for _, col in ipairs(COLUMNS) do + local fs = EUI.MakeFont(row, FONT_SZ, nil, 1, 1, 1, 1) + SetFS(fs, FONT_SZ) + fs:SetJustifyH(col.justify or (col.kind == "icon" and "CENTER" or "LEFT")) + fs:SetWordWrap(false) + row.text[col.key] = fs + if col.kind == "icon" then + local cell = CreateFrame("Frame", nil, row) + cell:SetSize(ICON_SZ, ICON_SZ) + cell.tex = cell:CreateTexture(nil, "ARTWORK") + if col.key == "spec" or col.key == "loot" then + -- Square icon on a black plate, inset by one physical pixel so + -- the plate reads as a 1px border. + cell.bg = cell:CreateTexture(nil, "BACKGROUND") + cell.bg:SetAllPoints() + cell.bg:SetColorTexture(0, 0, 0, 1) + cell.tex:SetPoint("TOPLEFT", cell, "TOPLEFT", px, -px) + cell.tex:SetPoint("BOTTOMRIGHT", cell, "BOTTOMRIGHT", -px, px) + else + cell.tex:SetAllPoints() + end + row.icons[col.key] = cell + else + local style = col.sub and SUB_STYLE[col.key] + if style then + local sfs = EUI.MakeFont(row, style.size, nil, style.r, style.g, style.b, 1) + SetFS(sfs, style.size, "") + sfs:SetJustifyH("LEFT") + sfs:SetWordWrap(false) + row.sub[col.key] = sfs + end + end + end + return row +end + +local ShowPicker -- assigned below, once the record list is reachable + +local function BuildWindow() + if win then return win end + local PP = EUI.PP + + win = CreateFrame("Frame", "EUIMythicRunSummaryFrame", UIParent) + -- FULLSCREEN: above the options window (DIALOG), so Show Preview is not + -- hidden behind it, and below FULLSCREEN_DIALOG, where the run picker menu, + -- confirmation popups and Unlock Mode movers live. + win:SetFrameStrata("FULLSCREEN") + win:SetClampedToScreen(true) + win:SetMovable(true) + win:EnableMouse(true) + win:Hide() + + win._bg = EUI.SolidTex(win, "BACKGROUND", 0.05, 0.07, 0.09, 0.95) + win._bg:SetPoint("TOPLEFT", win, "TOPLEFT", 0, -HEAD_H) + win._bg:SetPoint("BOTTOMRIGHT", win, "BOTTOMRIGHT", 0, 0) + + local header = CreateFrame("Frame", nil, win) + header:SetFrameLevel(win:GetFrameLevel() + 5) + header:SetPoint("TOPLEFT", win, "TOPLEFT", 0, 0) + header:SetPoint("TOPRIGHT", win, "TOPRIGHT", 0, 0) + header:SetHeight(HEAD_H) + header:EnableMouse(true) + header:RegisterForDrag("LeftButton") + header:SetScript("OnDragStart", function() win:StartMoving() end) + header:SetScript("OnDragStop", function() + win:StopMovingOrSizing() + SavePositionFromFrame() + end) + win._header = header + + local hbg = EUI.SolidTex(header, "BACKGROUND", 0.08, 0.10, 0.12, 0.95) + hbg:SetAllPoints() + + local px = (PP and PP.mult) or 1 + local sep = header:CreateTexture(nil, "ARTWORK") + sep:SetHeight(px) + sep:SetPoint("BOTTOMLEFT", header, "BOTTOMLEFT", 0, 0) + sep:SetPoint("BOTTOMRIGHT", header, "BOTTOMRIGHT", 0, 0) + sep:SetColorTexture(0.15, 0.15, 0.15, 1) + + titleFS = EUI.MakeFont(header, TITLE_SZ, nil, 1, 1, 1, 1) + SetFS(titleFS, TITLE_SZ) + titleFS:SetPoint("TOPLEFT", header, "TOPLEFT", PAD, -HEADER_GAP) + titleFS:SetJustifyH("LEFT") + + subFS = EUI.MakeFont(header, SUBLINE_SZ, nil, 1, 1, 1, 0.8) + SetFS(subFS, SUBLINE_SZ) + subFS:SetPoint("TOPLEFT", titleFS, "BOTTOMLEFT", 0, -SUB_GAP) + subFS:SetJustifyH("LEFT") + + local close = CreateFrame("Button", nil, header) + close:SetSize(CLOSE_W, CLOSE_W) + close:SetPoint("TOPRIGHT", header, "TOPRIGHT", -PAD, -(HEADER_GAP + 2)) + close.icon = close:CreateTexture(nil, "OVERLAY") + close.icon:SetAllPoints() + close.icon:SetTexture(CLOSE_ICON) + close.icon:SetAlpha(0.7) + close:SetScript("OnEnter", function() close.icon:SetAlpha(0.9) end) + close:SetScript("OnLeave", function() close.icon:SetAlpha(0.7) end) + close:SetScript("OnClick", function() win:Hide() end) + + pickerBtn = CreateFrame("Button", nil, header) + pickerBtn:SetSize(PICKER_W, PICKER_H) + pickerBtn:SetPoint("TOPRIGHT", close, "TOPLEFT", -HEADER_GAP, (PICKER_H - CLOSE_W) / 2) + local _, _, lbl = EUI.MakeStyledButton(pickerBtn, "", PICKER_SZ, EUI.WB_COLOURS, function() + if ShowPicker then ShowPicker(pickerBtn) end + end) + pickerLbl = lbl + if pickerLbl then + -- Constrained and non-wrapping: a long dungeon name must truncate + -- inside the button rather than run back over the title. + pickerLbl:SetWidth(PICKER_W - 32) + pickerLbl:SetWordWrap(false) + pickerLbl:SetJustifyH("CENTER") + end + if EUI.MakeDropdownArrow then EUI.MakeDropdownArrow(pickerBtn, 4, PP) end + + colHdr = MakeRow(win) + colHdr:SetPoint("TOPLEFT", win, "TOPLEFT", 0, -HEAD_H) + colHdr:SetPoint("TOPRIGHT", win, "TOPRIGHT", 0, -HEAD_H) + colHdr:SetHeight(COLHDR_H) + for _, col in ipairs(COLUMNS) do + local fs = colHdr.text[col.key] + if fs then + SetFS(fs, COLHDR_SZ) + fs:SetTextColor(1, 1, 1, 0.45) + end + end + -- The header carries no icons, and the spec cell's black plate would show + -- as an empty square if its frame were left visible. + for _, cell in pairs(colHdr.icons) do cell:Hide() end + + local hsep = win:CreateTexture(nil, "ARTWORK") + hsep:SetHeight(px) + hsep:SetPoint("TOPLEFT", colHdr, "BOTTOMLEFT", PAD, 0) + hsep:SetPoint("TOPRIGHT", colHdr, "BOTTOMRIGHT", -PAD, 0) + hsep:SetColorTexture(1, 1, 1, 0.10) + + rows = {} + -- The border spans the whole frame, header included. MakeBorder parks its + -- host one level above the frame, which the header (frame level + 5) would + -- draw straight over, leaving only the body looking framed. + if EUI.MakeBorder then + win._border = EUI.MakeBorder(win, 1, 1, 1, 0.15, PP) + if win._border._frame then + win._border._frame:SetFrameLevel(win:GetFrameLevel() + 10) + end + end + return win +end + +-------------------------------------------------------------------------------- +-- Layout and render +-------------------------------------------------------------------------------- +local function VisibleColumns() + local c = Cfg() or {} + wipe(visibleCols) + for _, col in ipairs(COLUMNS) do + if not col.cfg or c[col.cfg] ~= false then + visibleCols[#visibleCols + 1] = col + end + end + return visibleCols +end + +local function MeasuredWidth(fs, fallback) + if not fs then return fallback end + local w = fs:GetStringWidth() + if type(w) ~= "number" or IsSecret(w) then return fallback end + -- Rounded up with a pixel spare: a font string sized to its exact + -- fractional width truncates its own text to an ellipsis. + if w > 0 then w = math.ceil(w) + 1 end + return w +end + +-- Spec icon where the meter reported one, class icon otherwise. Both are +-- cropped so the round frame baked into the art does not show inside the +-- square plate. +local function FillSpecCell(cell, m) + local zoom = 0.08 + local id = m.specIcon + if type(id) == "number" and id ~= 0 then + cell.tex:SetTexture(id) + cell.tex:SetTexCoord(zoom, 1 - zoom, zoom, 1 - zoom) + cell:Show() + return + end + local coords = m.class and not IsSecret(m.class) + and CLASS_ICON_TCOORDS and CLASS_ICON_TCOORDS[m.class] + if coords then + cell.tex:SetTexture(CLASS_ICON_TEX) + local l, r, t, b = coords[1], coords[2], coords[3], coords[4] + local dx, dy = (r - l) * zoom, (b - t) * zoom + cell.tex:SetTexCoord(l + dx, r - dx, t + dy, b - dy) + cell:Show() + return + end + cell:Hide() +end + +-- Hoisted so a render does not build four closures per row; the cell carries +-- its item on _itemID and the handlers branch on that. +local function LootEnter(self) + if self._link or self._itemID then + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + -- The link carries the item's actual level and bonuses; the bare ID is + -- the fallback for the rewards payload, which has no link. + if self._link then + GameTooltip:SetHyperlink(self._link) + else + GameTooltip:SetItemByID(self._itemID) + end + GameTooltip:Show() + elseif EUI.ShowWidgetTooltip then + EUI.ShowWidgetTooltip(self, EllesmereUI.L("No loot recorded for this player in this run.")) + end +end + +local function LootLeave(self) + if self._link or self._itemID then + GameTooltip:Hide() + elseif EUI.HideWidgetTooltip then + EUI.HideWidgetTooltip() + end +end + +local function FillLootCell(cell, m) + cell:EnableMouse(true) + cell:Show() + cell._itemID = m.lootID + cell._link = m.lootLink + if m.lootID or m.lootLink then + local id = m.lootID or IDFromLink(m.lootLink) + local icon = id and C_Item and C_Item.GetItemIconByID and C_Item.GetItemIconByID(id) + cell.tex:SetTexture(icon or "Interface\Icons\INV_Misc_QuestionMark") + cell.tex:SetTexCoord(0.08, 0.92, 0.08, 0.92) + else + cell.tex:SetTexture(nil) + end + -- No plate without an item, or an empty cell would show a black square. + if cell.bg then cell.bg:SetShown(m.lootID ~= nil or m.lootLink ~= nil) end + cell:SetScript("OnEnter", LootEnter) + cell:SetScript("OnLeave", LootLeave) +end + +-- Places every visible column of one container (the column header row or a +-- member row) at the widths measured in the second pass, and hides the cells +-- of columns the user switched off. +local function PlaceInto(container, cols, widths, primW, subW) + local x = PAD + for idx, col in ipairs(cols) do + local wt = widths[idx] + local fs = container.text[col.key] + local sfs = container.sub and container.sub[col.key] + local icon = container.icons[col.key] + if icon then + icon:ClearAllPoints() + icon:SetPoint("LEFT", container, "LEFT", x + (wt - ICON_SZ) / 2, 0) + end + if fs then + fs:ClearAllPoints() + fs:SetPoint("LEFT", container, "LEFT", x, 0) + if col.sub == "inline" then + fs:SetWidth(wt) + if sfs then + -- Anchored off this row's own text, so the item level sits + -- against the name instead of in a column of its own. + local own = MeasuredWidth(fs, 0) + sfs:ClearAllPoints() + sfs:SetPoint("LEFT", container, "LEFT", x + own + SUB_GAP, 0) + sfs:SetWidth(math.max(1, wt - own - SUB_GAP)) + sfs:Show() + end + else + fs:SetWidth(primW[idx]) + if sfs then + sfs:ClearAllPoints() + sfs:SetPoint("LEFT", container, "LEFT", x + primW[idx] + SUB_GAP, 0) + sfs:SetWidth(math.max(1, subW[idx])) + sfs:Show() + end + end + fs:Show() + end + x = x + wt + (col.gapAfter or COL_GAP) + end + for _, col in ipairs(COLUMNS) do + local shown = false + for _, vc in ipairs(cols) do + if vc.key == col.key then shown = true break end + end + if not shown then + local fs = container.text[col.key] + if fs then fs:Hide() end + local sfs = container.sub and container.sub[col.key] + if sfs then sfs:Hide() end + local icon = container.icons[col.key] + if icon then icon:Hide() end + end + end +end + +-- Marks the best damage output and the lowest damage taken in the accent +-- colour. Only with at least two members carrying a value, so a solo or +-- meterless run never highlights a row by default. +local function HighlightExtremes(members) + local best, bestI, low, lowI, nDps, nTaken + nDps, nTaken = 0, 0 + for i = 1, #members do + local d = members[i].dps + if type(d) == "number" then + nDps = nDps + 1 + if best == nil or d > best then best, bestI = d, i end + end + local t = members[i].damageTaken + if type(t) == "number" then + nTaken = nTaken + 1 + if low == nil or t < low then low, lowI = t, i end + end + end + local r, g, b = 1, 1, 1 + if EUI.GetAccentColor then r, g, b = EUI.GetAccentColor() end + if nDps > 1 and bestI and rows[bestI] and rows[bestI].text.dps then + rows[bestI].text.dps:SetTextColor(r, g, b, 1) + end + if nTaken > 1 and lowI and rows[lowI] and rows[lowI].text.damageTaken then + rows[lowI].text.damageTaken:SetTextColor(r, g, b, 1) + end +end + +local function RenderHeader(record) + local lvlHex = "ffffff" + local kcol = C_ChallengeMode and C_ChallengeMode.GetKeystoneLevelRarityColor + and C_ChallengeMode.GetKeystoneLevelRarityColor(record.level or 0) + if type(kcol) == "table" and kcol.r then lvlHex = Hex(kcol.r, kcol.g, kcol.b) end + titleFS:SetText(format("%s |cff%s+%d|r", record.mapName or "?", lvlHex, record.level or 0)) + + local timeHex = record.onTime and "40ff40" or "ff6060" + local sub = format("|cff%s%s|r / %s", timeHex, Clock((record.timeMS or 0) / 1000), + Clock(record.timeLimit or 0)) + if (record.upgrades or 0) > 1 then + sub = sub .. " " .. EllesmereUI.Lf("+%1$d Upgrades", record.upgrades) + elseif (record.upgrades or 0) > 0 then + sub = sub .. " " .. EllesmereUI.Lf("+%1$d Upgrade", record.upgrades) + elseif not record.onTime then + sub = sub .. " " .. EllesmereUI.L("Depleted") + end + sub = sub .. " " .. EllesmereUI.Lf("%1$d Deaths", record.deaths or 0) + if (record.timeLost or 0) > 0 then + sub = sub .. format(" |cff808080(-%s)|r", Clock(record.timeLost)) + end + -- One warning slot, most fundamental first. + local warn + if record.meterAvailable == false then + warn = EllesmereUI.L("Blizzard damage meter is off") + elseif (record._matched or 0) == 0 then + warn = EllesmereUI.L("Combat data stayed restricted for this run") + end + if warn then sub = sub .. " |cffff8040" .. warn .. "|r" end + subFS:SetText(sub) + + if pickerLbl then pickerLbl:SetText(RunLabel(record)) end +end + +local scratchWidths, scratchPrim, scratchSub = {}, {}, {} + +local function Render(record) + if not record or not win then return end + local c = Cfg() or {} + local members = record.members or {} + local cols = VisibleColumns() + + RenderHeader(record) + + -- First pass: grow the row pool and give every cell its final text, so the + -- widths measured below are the ones actually rendered. + for i = 1, #members do + if not rows[i] then + local y = -(HEAD_H + COLHDR_H + (i - 1) * ROW_H) + rows[i] = MakeRow(win) + rows[i]:SetPoint("TOPLEFT", win, "TOPLEFT", 0, y) + rows[i]:SetPoint("TOPRIGHT", win, "TOPRIGHT", 0, y) + end + local row, m = rows[i], members[i] + row:Show() + for _, col in ipairs(COLUMNS) do + local fs = row.text[col.key] + if fs then + fs:SetText(CellText(col, m, c)) + fs:SetTextColor(1, 1, 1, 1) + end + local sfs = row.sub[col.key] + if sfs then sfs:SetText(SubText(col, m, c)) end + end + if row.icons.spec then FillSpecCell(row.icons.spec, m) end + if row.icons.loot then FillLootCell(row.icons.loot, m) end + end + for i = #members + 1, #rows do rows[i]:Hide() end + + HighlightExtremes(members) + + for _, col in ipairs(COLUMNS) do + local fs = colHdr.text[col.key] + if fs then fs:SetText(col.header ~= "" and EUI.L(col.header) or "") end + local sfs = colHdr.sub[col.key] + if sfs then sfs:SetText("") end + end + + -- Second pass: measure, then place. A column holding only dashes collapses + -- to its minimum width instead of reserving room it does not need. + local widths, primW, subW, total = scratchWidths, scratchPrim, scratchSub, 0 + wipe(widths); wipe(primW); wipe(subW) + for idx, col in ipairs(cols) do + local wp, ws, wt = col.minW, 0, col.minW + if col.kind == "icon" then + wt = math.max(col.minW, MeasuredWidth(colHdr.text[col.key], 0)) + wp = wt + elseif col.kind == "text" then + wp = MeasuredWidth(colHdr.text[col.key], 0) + for i = 1, #members do + wp = math.max(wp, MeasuredWidth(rows[i].text[col.key], 0)) + if col.sub then ws = math.max(ws, MeasuredWidth(rows[i].sub[col.key], 0)) end + end + if col.sub == "inline" then + -- The primary is sized per row here, so the column only has to + -- fit the widest name plus its own extra. + wt = math.max(col.minW, MeasuredWidth(colHdr.text[col.key], 0)) + for i = 1, #members do + local a = MeasuredWidth(rows[i].text[col.key], 0) + local b = MeasuredWidth(rows[i].sub[col.key], 0) + wt = math.max(wt, a + (b > 0 and (SUB_GAP + b) or 0)) + end + else + wp = math.max(wp, col.minW) + wt = wp + (ws > 0 and (SUB_GAP + ws) or 0) + end + end + primW[idx], subW[idx], widths[idx] = wp, ws, wt + total = total + wt + end + for idx = 1, #cols - 1 do + total = total + (cols[idx].gapAfter or COL_GAP) + end + total = total + PAD * 2 + + PlaceInto(colHdr, cols, widths, primW, subW) + for i = 1, #members do PlaceInto(rows[i], cols, widths, primW, subW) end + + -- The header is laid out independently of the table, so with enough columns + -- switched off the picker and title would overrun a table-width frame. + local headerMin = math.max( + PAD + MeasuredWidth(titleFS, 0) + COL_GAP + PICKER_W + HEADER_GAP + CLOSE_W + PAD, + PAD + MeasuredWidth(subFS, 0) + PAD) + total = math.max(total, headerMin) + + local PP = EUI.PP + local height = HEAD_H + COLHDR_H + #members * ROW_H + PAD + -- Scale first: PP.Size snaps against the frame's current effective scale, + -- so changing the scale afterwards would throw that snap away. + local scale = tonumber(c.scale) or 1 + if scale < 0.5 then scale = 0.5 elseif scale > 2 then scale = 2 end + win:SetScale(scale) + if PP and PP.Size then PP.Size(win, total, height) else win:SetSize(total, height) end +end + +-------------------------------------------------------------------------------- +-- Show, refresh, run picker +-------------------------------------------------------------------------------- +ShowWindow = function(record) + if not record then return end + BuildWindow() + currentRecord = record + Render(record) + ApplyPosition() + win:Show() +end + +RefreshWindowIfOpen = function() + if win and win:IsShown() and currentRecord then Render(currentRecord) end +end + +ShowPicker = function(anchor) + local runs = ns.RS_GetRuns() + local items = {} + for i = 1, #runs do + local rec = runs[i] + items[#items + 1] = { text = RunLabel(rec), onClick = function() ShowWindow(rec) end } + end + if #items == 0 then + items[1] = { text = EllesmereUI.L("No runs recorded yet"), isDisabled = function() return true end } + end + if EUI.ShowContextMenu then EUI.ShowContextMenu(anchor, items) end +end + +-- Opens the n-th most recent run (1 = newest). +function ns.RS_Show(index) + local i = math.floor(tonumber(index) or 1) + if i < 1 then i = 1 end + local rec = ns.RS_GetRuns()[i] + if not rec then + EUI.Print("|cffff6060[EllesmereUI]|r " .. EllesmereUI.L("No runs recorded yet")) + return + end + ShowWindow(rec) +end + +-- Preview: a synthetic run so the panel can be checked without going into a +-- key. Built on demand and never written to the history. Row 3 deliberately +-- carries no score gain, which is what a member without a start baseline +-- looks like. +local PREVIEW_CLASSES = { "PALADIN", "PRIEST", "ROGUE", "DRUID", "MAGE" } +-- Protection, Holy, Assassination, Balance, Frost. Resolved through the +-- global when it exists; otherwise nil and the class icon fallback shows. +local PREVIEW_SPEC_IDS = { 66, 257, 259, 102, 64 } +local function PREVIEW_SPEC_ICON(i) + return SpecIconForSpecID(PREVIEW_SPEC_IDS[i]) +end +function ns.RS_ShowPreview() + local R = Enum and Enum.LFGRole + local members = {} + for i = 1, 5 do + members[i] = { + name = "Preview " .. i, + class = PREVIEW_CLASSES[i], + role = R and ((i == 1 and R.Tank) or (i == 2 and R.Healer) or R.Damage), + specIcon = PREVIEW_SPEC_ICON(i), + ilvl = 668 + i * 3, + score = 2950 + i * 90, + scoreGain = (i ~= 3) and (6 + i * 3) or nil, + damage = 30000000 + i * 8000000, + dps = 21000 + i * 6000, + damageTaken = 9200000 - i * 1200000, + interrupts = 5 - i, + deaths = i % 2, + } + end + -- Resolve the dungeon name from its ID so the preview is localized by the + -- client. Out of season the API returns nothing and the English name shows; + -- it is not a translator key, so it is not wrapped in L(). + local mapName = C_ChallengeMode and C_ChallengeMode.GetMapUIInfo + and C_ChallengeMode.GetMapUIInfo(2648) + ShowWindow({ + mapID = 2648, + mapName = PlainString(mapName) or "The Rookery", + level = 12, + timeMS = 1380000, + timeLimit = 1920, + onTime = true, + upgrades = 2, + deaths = 3, + timeLost = 15, + meterAvailable = true, + _matched = #members, + finishedAt = time(), + members = members, + }) +end + +-------------------------------------------------------------------------------- +-- Unlock mode +-------------------------------------------------------------------------------- +local unlockDone +RegisterUnlock = function() + if unlockDone or not Enabled() then return end + if not (EUI.RegisterUnlockElements and EUI.MakeUnlockElement) then return end + unlockDone = true + local MK = EUI.MakeUnlockElement + EUI:RegisterUnlockElements({ + MK({ + key = "EMT_RunSummary", + label = "Mythic+ Run Summary", + group = "Mythic+", + order = 525, + noResize = true, + getFrame = function() return BuildWindow() end, + getSize = function() + if win then return win:GetWidth(), win:GetHeight() end + return 520, 180 + end, + isHidden = function() return false end, + savePos = function(_, point, relPoint, x, y) + local c = Cfg() + if not c then return end + c.position = { point = point, relPoint = relPoint or point, x = x, y = y } + if not EUI._unlockActive then ApplyPosition() end + end, + loadPos = function() + local c = Cfg() + local pos = c and c.position + if not (pos and pos.point) then return nil end + -- A copy: callers may rebase the table and the live entry must not move with it. + return { point = pos.point, relPoint = pos.relPoint, x = pos.x, y = pos.y } + end, + clearPos = function() + local c = Cfg() + if c then c.position = nil end + end, + applyPos = function() ApplyPosition() end, + }), + }) +end + +-------------------------------------------------------------------------------- +-- Slash command +-------------------------------------------------------------------------------- +SLASH_EUIMPLUS1 = "/ov" +SLASH_EUIMPLUS2 = "/euimplus" +SlashCmdList.EUIMPLUS = function(msg) + if not Enabled() then + EUI.Print("|cffff6060[EllesmereUI]|r " .. EllesmereUI.L("Run Summary is disabled in Mythic+ Tools.")) + return + end + local lower = msg and msg:lower() or "" + if lower:find("preview", 1, true) then + ns.RS_ShowPreview() + return + end + ns.RS_Show(msg and tonumber(msg:match("%d+"))) +end diff --git a/EllesmereUIMythicTimer/EllesmereUIMythicTimer.lua b/EllesmereUIMythicTimer/EllesmereUIMythicTimer.lua index 2b9e0ef12..1e42a4206 100644 --- a/EllesmereUIMythicTimer/EllesmereUIMythicTimer.lua +++ b/EllesmereUIMythicTimer/EllesmereUIMythicTimer.lua @@ -340,6 +340,26 @@ local DB_DEFAULTS = { targetSize = 10, }, }, + -- Run Summary (Mythic+ Tools tab): end-of-key overview panel with one + -- row per party member, plus a per-character history of finished runs. + -- Disabled by default; the collector registers its events only while + -- enabled (zero cost off). Runtime in EUI_MythicTimer_RunSummary.lua. + -- The run records themselves live in the per-character SavedVariable + -- EllesmereUIMythicRunsDB, not here -- profile data is settings only. + runSummary = { + enabled = false, + showAfterLoot = true, + historySize = 20, + scale = 1, + showSpecIcons = true, + colItemLevel = true, + colScore = true, + colLoot = true, + colDps = true, + colDamageTaken = true, + colInterrupts = true, + colDeaths = true, + }, }, } @@ -3052,6 +3072,7 @@ function EMT:OnEnable() -- timer feature turned off. Both are no-ops while their flags are off. if ns.TSB_OnEnable then ns.TSB_OnEnable(db) end if ns.TFB_OnEnable then ns.TFB_OnEnable(db) end + if ns.RS_OnEnable then ns.RS_OnEnable(db) end if not db or not db.profile.enabled then return end if EllesmereUI and EllesmereUI.RegisterUnlockModeListener then diff --git a/EllesmereUIMythicTimer/EllesmereUIMythicTimer.toc b/EllesmereUIMythicTimer/EllesmereUIMythicTimer.toc index 70a055b60..fd91016c4 100644 --- a/EllesmereUIMythicTimer/EllesmereUIMythicTimer.toc +++ b/EllesmereUIMythicTimer/EllesmereUIMythicTimer.toc @@ -7,6 +7,7 @@ ## Version: 9.1.8 ## Dependencies: EllesmereUI ## SavedVariables: EllesmereUIMythicTimerDB +## SavedVariablesPerCharacter: EllesmereUIMythicRunsDB ## IconTexture: Interface\AddOns\EllesmereUI\media\eg-logo.tga # Main Lua @@ -15,5 +16,6 @@ EllesmereUIMythicTimer.lua # Mythic+ Tools features EUI_MythicTimer_TargetedSpellBars.lua EUI_MythicTimer_TargetFocusBars.lua +EUI_MythicTimer_RunSummary.lua # Options From 42fcac6bb254493fb734d98ced801ef0b54015cb Mon Sep 17 00:00:00 2001 From: JuJuFX-dev Date: Wed, 16 Sep 2026 17:56:38 +0200 Subject: [PATCH 2/2] Mythic+ Tools: add the Run Summary options page New Run Summary page in the Mythic+ Tools options: enable (off by default), Show After Looting, history size and panel scale, a Columns section with Show Spec Icons and a toggle per column, and Show Preview and Clear Run History buttons. Column and scale changes repaint an open panel. --- .../EUI_MythicTimer_Options.lua | 131 +++++++++++++++++- 1 file changed, 130 insertions(+), 1 deletion(-) diff --git a/EllesmereUIOptions/EUI_MythicTimer_Options.lua b/EllesmereUIOptions/EUI_MythicTimer_Options.lua index 0397abcbe..5b254e4fd 100644 --- a/EllesmereUIOptions/EUI_MythicTimer_Options.lua +++ b/EllesmereUIOptions/EUI_MythicTimer_Options.lua @@ -10,6 +10,7 @@ if not ns then return end -- module disabled: no options page local PAGE_DISPLAY = "Mythic+ Timer" local PAGE_TSB = "Targeted Spell Bars" local PAGE_TFB = "Target/Focus Bars" +local PAGE_RS = "Run Summary" local initFrame = CreateFrame("Frame") initFrame:RegisterEvent("PLAYER_LOGIN") @@ -1868,16 +1869,144 @@ initFrame:SetScript("OnEvent", function(self) parent:SetHeight(math.abs(y - yOffset)) end + ----------------------------------------------------------------------- + -- Run Summary page + ----------------------------------------------------------------------- + local function RSCfg() + local p = DB() + return p and p.runSummary + end + + local function RSGet(key, fallback) + local c = RSCfg() + local v = c and c[key] + if v == nil then return fallback end + return v + end + + local function RSSet(key, val) + local c = RSCfg() + if c then c[key] = val end + if ns.RS_Apply then ns.RS_Apply() end + -- Column toggles and the scale change how an already open panel looks, + -- so repaint it instead of waiting for the next time it is opened. + if ns.RS_Refresh then ns.RS_Refresh() end + end + + local function RSOn() return RSCfg() ~= nil and RSCfg().enabled == true end + local function RSOff() return not RSOn() end + + local function BuildRSPage(pageName, parent, yOffset) + local W = EllesmereUI.Widgets + local y = yOffset + local row, h + + if EllesmereUI.ClearContentHeader then EllesmereUI:ClearContentHeader() end + parent._showRowDivider = true + + local REQ = "Enable Run Summary" + + row, h = W:SectionHeader(parent, "RUN SUMMARY", y); y = y - h + + row, h = W:DualRow(parent, y, + { type="toggle", text="Enable Run Summary", + tooltip="Records every finished Mythic+ key and shows an overview of the group when the run ends. Nothing is registered or created while this is off.", + getValue=RSOn, + setValue=function(v) RSSet("enabled", v and true or false); EllesmereUI:RefreshPage() end }, + { type="toggle", text="Show After Looting", + tooltip="Open the overview once the end of run chest has been looted. With this off it opens as soon as the key ends. /ov reopens it at any time.", + disabled=RSOff, disabledTooltip=REQ, + getValue=function() return RSGet("showAfterLoot", true) == true end, + setValue=function(v) RSSet("showAfterLoot", v and true or false) end }); y = y - h + + row, h = W:DualRow(parent, y, + { type="slider", text="History Size", min=5, max=50, step=1, + disabled=RSOff, disabledTooltip=REQ, + getValue=function() return RSGet("historySize", 20) end, + setValue=function(v) RSSet("historySize", v) end }, + { type="slider", text="Panel Scale", min=0.5, max=2, step=0.05, + disabled=RSOff, disabledTooltip=REQ, + getValue=function() return RSGet("scale", 1) end, + setValue=function(v) RSSet("scale", v) end }); y = y - h + + row, h = W:SectionHeader(parent, "COLUMNS", y); y = y - h + + row, h = W:DualRow(parent, y, + { type="toggle", text="Show Spec Icons", + disabled=RSOff, disabledTooltip=REQ, + getValue=function() return RSGet("showSpecIcons", true) == true end, + setValue=function(v) RSSet("showSpecIcons", v and true or false) end }, + { type="toggle", text="Item Level", + tooltip="Shown in grey next to each name. Item levels are read by inspecting party members during the run, so a member who stayed out of range shows none.", + disabled=RSOff, disabledTooltip=REQ, + getValue=function() return RSGet("colItemLevel", true) == true end, + setValue=function(v) RSSet("colItemLevel", v and true or false) end }); y = y - h + + row, h = W:DualRow(parent, y, + { type="toggle", text="M+ Score", + tooltip="Current season score plus the gain from this run. Your own gain is the exact value the server reports; for party members it is their score before the key subtracted from their score after it.", + disabled=RSOff, disabledTooltip=REQ, + getValue=function() return RSGet("colScore", true) == true end, + setValue=function(v) RSSet("colScore", v and true or false) end }, + { type="toggle", text="Loot", + tooltip="What each player looted. Your own chest reward always appears; other players' items only when the server announces the loot to the group, which it does not always do for the end of run chest.", + disabled=RSOff, disabledTooltip=REQ, + getValue=function() return RSGet("colLoot", true) == true end, + setValue=function(v) RSSet("colLoot", v and true or false) end }); y = y - h + + row, h = W:DualRow(parent, y, + { type="toggle", text="DPS", + tooltip="Read from Blizzard's own damage meter. With that meter switched off this column, Damage Taken and Interrupts stay empty.", + disabled=RSOff, disabledTooltip=REQ, + getValue=function() return RSGet("colDps", true) == true end, + setValue=function(v) RSSet("colDps", v and true or false) end }, + { type="toggle", text="Damage Taken", + disabled=RSOff, disabledTooltip=REQ, + getValue=function() return RSGet("colDamageTaken", true) == true end, + setValue=function(v) RSSet("colDamageTaken", v and true or false) end }); y = y - h + + row, h = W:DualRow(parent, y, + { type="toggle", text="Interrupts", + disabled=RSOff, disabledTooltip=REQ, + getValue=function() return RSGet("colInterrupts", true) == true end, + setValue=function(v) RSSet("colInterrupts", v and true or false) end }, + { type="toggle", text="Deaths", + disabled=RSOff, disabledTooltip=REQ, + getValue=function() return RSGet("colDeaths", true) == true end, + setValue=function(v) RSSet("colDeaths", v and true or false) end }); y = y - h + + row, h = W:WideButton(parent, "Show Preview", y, function() + if ns.RS_ShowPreview then ns.RS_ShowPreview() end + end); y = y - h + + row, h = W:WideButton(parent, "Clear Run History", y, function() + EllesmereUI:ShowConfirmPopup({ + title = "Clear Run History", + message = "Delete every recorded Mythic+ run for this character?", + confirmText = "Delete", + cancelText = "Cancel", + onConfirm = function() + if ns.RS_ClearHistory then ns.RS_ClearHistory() end + end, + }) + end); y = y - h + + row, h = W:Spacer(parent, y, 20); y = y - h + parent:SetHeight(math.abs(y - yOffset)) + end + -- RegisterModule EllesmereUI:RegisterModule("EllesmereUIMythicTimer", { title = "Mythic+ Tools", description = "Mythic+ timer, targeted spell bars, and standalone cast bars.", - pages = { PAGE_DISPLAY, PAGE_TSB, PAGE_TFB }, + pages = { PAGE_DISPLAY, PAGE_TSB, PAGE_TFB, PAGE_RS }, buildPage = function(pageName, parent, yOffset) if pageName == PAGE_TSB then return BuildTSBPage(pageName, parent, yOffset) elseif pageName == PAGE_TFB then return BuildTFBPage(pageName, parent, yOffset) + elseif pageName == PAGE_RS then + return BuildRSPage(pageName, parent, yOffset) end return BuildPage(pageName, parent, yOffset) end,