From 621d3671b56e0eae34d26524d2242f7ac3b27642 Mon Sep 17 00:00:00 2001 From: Kneeull <156554816+Kneeull@users.noreply.github.com> Date: Fri, 15 May 2026 00:20:25 +0100 Subject: [PATCH 01/16] Add EUI_UpgradeCalc: gear upgrade planner with crest tracker, queue, and profile persistence - Tile-based gear view grouped by upgrade need vs at-max - Upgrade queue with per-slot crest/gold cost summary - Crest table with Need/Owned/Missing/Earned columns - +/-80 manual crest offset buttons for crafted gear (Hero/Myth) - Two-pass Upgrader NPC scan for exact crest/gold costs - Watermark discount awareness for accurate estimates - Voidforged detection (+9 ilvl cap for eligible slots) - Profile system integration (persists via EllesmereUIDB) - Queue and crest offsets persist across reloads - First-run auto-detection hides irrelevant crest rows - Combat lockout: auto-close + block open/scan - Equipment change debounce (0.3s) to avoid refresh spam - Scan abort on combat transition (belt-and-suspenders) - Cached DB/Opts refs post-login for O(1) hot-path reads - All rank caps data-driven via td.ranks table - Single CraftedBandFromIlvl() shared helper (no duplication) - SEASON UPDATE comments at every season-sensitive value - Options page: filters, display toggles, opacity, reset --- EllesmereUIQoL/EUI_QoL_Options.lua | 9 +- EllesmereUIQoL/EUI_UpgradeCalc.lua | 1626 ++++++++++++++++++++ EllesmereUIQoL/EUI_UpgradeCalc_Options.lua | 235 +++ EllesmereUIQoL/EllesmereUIQoL.toc | 2 + 4 files changed, 1870 insertions(+), 2 deletions(-) create mode 100644 EllesmereUIQoL/EUI_UpgradeCalc.lua create mode 100644 EllesmereUIQoL/EUI_UpgradeCalc_Options.lua diff --git a/EllesmereUIQoL/EUI_QoL_Options.lua b/EllesmereUIQoL/EUI_QoL_Options.lua index d9cf5586..23e4413e 100644 --- a/EllesmereUIQoL/EUI_QoL_Options.lua +++ b/EllesmereUIQoL/EUI_QoL_Options.lua @@ -8,6 +8,7 @@ local PAGE_QOL = "Quality of Life" local PAGE_CURSOR = "Cursor" local PAGE_BREZ = "BattleRes" local PAGE_AUTOLOG = "Auto Logging" +local PAGE_UPGCALC = "Upgrade Calculator" local initFrame = CreateFrame("Frame") initFrame:RegisterEvent("PLAYER_LOGIN") @@ -1138,8 +1139,8 @@ initFrame:SetScript("OnEvent", function(self) EllesmereUI:RegisterModule("EllesmereUIQoL", { title = "Quality of Life", description = "Quality of life features and custom cursor.", - pages = { PAGE_QOL, PAGE_CURSOR, PAGE_BREZ, PAGE_AUTOLOG }, - searchTerms = { "brez", "bres", "battle res", "combat res", "cursor", "macro", "fps", "logging", "combat log", "warcraft logs" }, + pages = { PAGE_QOL, PAGE_CURSOR, PAGE_BREZ, PAGE_AUTOLOG, PAGE_UPGCALC }, + searchTerms = { "brez", "bres", "battle res", "combat res", "cursor", "macro", "fps", "logging", "combat log", "warcraft logs", "upgrade", "ilvl", "item level", "crest", "upgrade calculator" }, buildPage = function(pageName, parent, yOffset) if pageName == PAGE_QOL then return BuildQoLPage(pageName, parent, yOffset) @@ -1153,6 +1154,9 @@ initFrame:SetScript("OnEvent", function(self) if pageName == PAGE_AUTOLOG and _G._EUI_BuildAutoLoggingPage then return _G._EUI_BuildAutoLoggingPage(pageName, parent, yOffset) end + if pageName == PAGE_UPGCALC and _G._EUI_BuildUpgradeCalcPage then + return _G._EUI_BuildUpgradeCalcPage(pageName, parent, yOffset) + end end, onReset = function() if EllesmereUIDB then @@ -1180,6 +1184,7 @@ initFrame:SetScript("OnEvent", function(self) EllesmereUIDB.autoRepairGuild = false end EllesmereUIDB.autoLogging = nil + if _G._EUI_ResetUpgradeCalc then _G._EUI_ResetUpgradeCalc() end if _G._EBS_ResetCursor then _G._EBS_ResetCursor() end if EllesmereUI._applyHideBlizzardPartyFrame then EllesmereUI._applyHideBlizzardPartyFrame() end EllesmereUI:InvalidatePageCache() diff --git a/EllesmereUIQoL/EUI_UpgradeCalc.lua b/EllesmereUIQoL/EUI_UpgradeCalc.lua new file mode 100644 index 00000000..6e36b0bc --- /dev/null +++ b/EllesmereUIQoL/EUI_UpgradeCalc.lua @@ -0,0 +1,1626 @@ +------------------------------------------------------------------------------- +-- EUI_UpgradeCalc.lua (part of EllesmereUIQoL) +-- Gear upgrade planner: data tables, game logic, and calculator UI. +-- Frame: EUIUpgCalcFrame | Slash: /euic +------------------------------------------------------------------------------- + +EUIUpgCalc = EUIUpgCalc or {} +EUIUpgCalc.Data = {} + +local Calc = EUIUpgCalc +local Data = EUIUpgCalc.Data +local EUI = EllesmereUI +local PP = EUI.PP + +------------------------------------------------------------------------------- +-- DATA +------------------------------------------------------------------------------- + +-- ── SEASON UPDATE: replace all values in Data.tracks each new season. ──────── +-- Per track: goldPer (gold per upgrade step), crestName (tooltip display name), +-- hexColor (UI tint, can stay if the crest colour is unchanged), currID (currency +-- ID from Blizzard — check Wowhead or /dump C_CurrencyInfo.GetCurrencyInfo(id)), +-- ranks (ilvl at each of the 6 upgrade ranks, lowest to highest). +-- Add or remove tracks from Data.trackOrder to match the season's track list. +Data.tracks = { + Adventurer = { + goldPer = 10, crestName = "Adventurer Crest", + hexColor = "|cff1eff00", currID = 3383, tier = 1, + ranks = { 220, 224, 227, 230, 233, 237 }, + }, + Veteran = { + goldPer = 20, crestName = "Veteran Crest", + hexColor = "|cff0070dd", currID = 3341, tier = 2, + ranks = { 233, 237, 240, 243, 246, 250 }, + }, + Champion = { + goldPer = 30, crestName = "Champion Crest", + hexColor = "|cffa335ee", currID = 3343, tier = 3, + ranks = { 246, 250, 253, 256, 259, 263 }, + }, + Hero = { + goldPer = 40, crestName = "Hero Crest", + hexColor = "|cffff8000", currID = 3345, tier = 4, + ranks = { 259, 263, 266, 269, 272, 276 }, + }, + Myth = { + goldPer = 50, crestName = "Myth Crest", + hexColor = "|cffffd100", currID = 3347, tier = 5, + ranks = { 272, 276, 279, 282, 285, 289 }, + }, +} + +Data.trackOrder = { "Adventurer", "Veteran", "Champion", "Hero", "Myth" } + +-- All equippable character slot IDs (paper-doll order; excludes ammo/relic). +Data.equipSlots = { 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17 } + +-- Human-readable slot names keyed by slot ID. +Data.slotNames = { + [1] = "Head", [2] = "Neck", [3] = "Shoulder", + [5] = "Chest", [6] = "Waist", [7] = "Legs", + [8] = "Feet", [9] = "Wrist", [10] = "Hands", + [11] = "Ring 1", [12] = "Ring 2", [13] = "Trinket 1", + [14] = "Trinket 2", [15] = "Back", [16] = "Main Hand", + [17] = "Off Hand", +} + +-- SEASON UPDATE: if Voidcore (or its equivalent) is removed, set voidcoreBonus=0 +-- and clear voidcoreEligibleSlots. If new slots become eligible, add their IDs here. +-- The bonus ilvl value (currently 9) should match the Voidcore item level increase. +Data.voidcoreEligibleSlots = { 13, 14, 16, 17 } +Data.voidcoreBonus = 9 + +-- Reverse lookup: currencyID → crestName (built after track table is complete). +local _currIDToCrestName = {} +for _, td in pairs(Data.tracks) do + if td.currID and td.currID > 0 then + _currIDToCrestName[td.currID] = td.crestName + end +end + +-- SEASON UPDATE: update these ilvl steps to match the base-crafted tier ilvls +-- for the new season (currently T1–T5 = 246/249/252/255/259). +-- Referenced in GetCraftedInfo; hoisted here so it is allocated once, not per call. +Data.craftedTierSteps = { 246, 249, 252, 255, 259 } + +------------------------------------------------------------------------------- +-- CORE +------------------------------------------------------------------------------- + +-- Pointer to the active EllesmereUIDB profile slice for this module. +-- Set on PLAYER_LOGIN once the profile system initialises. +-- All persistent reads/writes go through DB() and Opts() which use this. +local _euicProfileRef = nil +-- Cached direct references to the sub-tables, set once in PLAYER_LOGIN so that +-- every subsequent DB()/Opts() call is a simple local read with no table traversal. +local _dbCache = nil +local _optsCache = nil + +local function DB() + if _dbCache then return _dbCache end + local store + if _euicProfileRef then + store = _euicProfileRef + else + EllesmereUIQoLDB = EllesmereUIQoLDB or {} + store = EllesmereUIQoLDB + end + store.upgradeCalc = store.upgradeCalc or {} + local db = store.upgradeCalc + db.cache = db.cache or { slots = {}, ts = 0 } + db.discounts = db.discounts or {} + db.calibrated = db.calibrated or false + db.queue = db.queue or {} + db.crestManualAdds = db.crestManualAdds or {} + return db +end + +local function Opts() + if _optsCache then return _optsCache end + local store + if _euicProfileRef then + store = _euicProfileRef + else + EllesmereUIQoLDB = EllesmereUIQoLDB or {} + store = EllesmereUIQoLDB + end + store.upgradeCalcOpts = store.upgradeCalcOpts or {} + return store.upgradeCalcOpts +end + +-- Exposed so EUI_UpgradeCalc_Options.lua can always read the live opts table, +-- regardless of whether the profile system has been initialised yet. +Calc.GetOptsDB = function() return Opts() end +Calc.GetCalcDB = function() return DB() end -- exposed for Options reset helper + +-- Slot IDs grouped by category, used for filter settings. +Data.slotGroups = { + Armour = { 1, 3, 5, 6, 7, 8, 9, 10, 15 }, -- head, shoulder, chest, waist, legs, feet, wrist, hands, back + Jewellery = { 2, 11, 12 }, -- neck, ring 1, ring 2 + Trinkets = { 13, 14 }, + Weapons = { 16, 17 }, +} +-- Build reverse lookup: slotID -> group name +Data.slotToGroup = {} +for grp, ids in pairs(Data.slotGroups) do + for _, id in ipairs(ids) do Data.slotToGroup[id] = grp end +end + +-- Two off-screen tooltips: one for upgrade/Voidforged scans, one for crafted. +local _upgTip = CreateFrame("GameTooltip", "EUIUpgCalcUpgradeTip", + UIParent, "GameTooltipTemplate") +_upgTip:SetOwner(UIParent, "ANCHOR_NONE") + +local _craftTip = CreateFrame("GameTooltip", "EUIUpgCalcCraftedTip", + UIParent, "GameTooltipTemplate") +_craftTip:SetOwner(UIParent, "ANCHOR_NONE") + +local function ForEachTooltipLine(tooltip, fn) + -- Pre-cache the per-line FontString references using the tooltip's name prefix + -- so the inner loop avoids a string concat + global lookup on every iteration. + local prefix = tooltip:GetName() .. "TextLeft" + for i = 1, tooltip:NumLines() do + local fs = _G[prefix .. i] + local text = fs and fs:GetText() + if text and text ~= "" then fn(text) end + end +end + +-- Strip WoW inline colour codes from a string. +local function Plain(s) + return s and (s:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "")) or "" +end + +-- Returns { slot, link, ilvl } for every equipped item on `unit` (default "player"). +function Calc:GetEquippedGear(unit) + unit = unit or "player" + local gear = {} + for _, slot in ipairs(Data.equipSlots) do + local link = GetInventoryItemLink(unit, slot) + if link then + local ilvl = C_Item.GetDetailedItemLevelInfo(link) or 0 + gear[#gear + 1] = { slot = slot, link = link, ilvl = ilvl } + end + end + return gear +end + +Calc._tipCache = {} -- [link] = { track, rank, isCrafted, craftBand, craftMaxIlvl, isVoidforged } + +function Calc:ScanItemLink(link) + if not link then return nil end + local cached = Calc._tipCache[link] + if cached then return cached end + + local result = { + track = nil, rank = nil, + isCrafted = false, craftBand = nil, craftMaxIlvl = nil, + isVoidforged = false, + } + + _upgTip:ClearLines() + _upgTip:SetHyperlink(link) + local foundTrack, foundRank, foundVoid = nil, nil, false + ForEachTooltipLine(_upgTip, function(text) + if not foundTrack and text:find("Upgrade Level") then + -- Capture rank and max separately so a future rank cap change (e.g. /8) still works. + local t, r = text:match("Upgrade Level:%s+(%a+)%s+(%d+)/%d+") + if t then foundTrack = t; foundRank = tonumber(r) end + end + if not foundVoid and Plain(text):find("Ascendant Voidforged") then + foundVoid = true + end + end) + result.track = foundTrack + result.rank = foundRank + result.isVoidforged = foundVoid + + -- Only scan the crafted tooltip if no upgrade track was found. + if not foundTrack then + _craftTip:ClearLines() + _craftTip:SetHyperlink(link) + local isCrafted, sawHero, sawMyth = false, false, false + ForEachTooltipLine(_craftTip, function(raw) + local t = Plain(raw) + if t:find("Crafted") or t:find("Optional Reagents") or t:find("Recrafting") or t:find("Made by") then + isCrafted = true + end + if t:find("Hero") then sawHero = true end + if t:find("Myth") then sawMyth = true end + end) + result.isCrafted = isCrafted + + if isCrafted then + if sawMyth then + result.craftBand = "Myth"; result.craftMaxIlvl = 285 + elseif sawHero then + result.craftBand = "Hero"; result.craftMaxIlvl = 272 + else + result.craftBand = "None"; result.craftMaxIlvl = 259 + end + end + end + + Calc._tipCache[link] = result + return result +end + +-- Returns: trackName (string|nil), rank (number|nil). +function Calc:GetItemTrackAndRank(link) + local r = self:ScanItemLink(link) + return r and r.track, r and r.rank +end + +-- Shared helper: given an ilvl, returns band ("Myth"/"Hero"/"None") and maxIlvl. +-- Called by both GetCraftedInfo (tooltip fallback) and the PopulateGear heuristic +-- so the two code paths can never silently diverge on a season update. +-- SEASON UPDATE: update these thresholds and maxIlvl caps each new season. +local function CraftedBandFromIlvl(ilvl) + if ilvl >= 272 then + return "Myth", math.max(285, ilvl) + elseif ilvl >= 259 then + return "Hero", math.max(272, ilvl) + else + return "None", math.max(259, ilvl) + end +end + +-- Returns: isCrafted, band, tier, maxIlvl. +function Calc:GetCraftedInfo(link, ilvl) + local r = self:ScanItemLink(link) + if not r or not r.isCrafted then return false end + local band, maxIlvl = r.craftBand or "None", r.craftMaxIlvl or 259 + + -- If tooltip gave no band keywords, fall back to ilvl thresholds via shared helper. + -- (See CraftedBandFromIlvl above for the SEASON UPDATE note.) + if r.craftBand == "None" then + band, maxIlvl = CraftedBandFromIlvl(ilvl) + end + + local tier + if band == "None" then + local steps = Data.craftedTierSteps + local best, bestDist = 1, math.huge + for i, s in ipairs(steps) do + local d = math.abs(ilvl - s) + if d < bestDist then bestDist = d; best = i end + end + tier = best + end + return true, band, tier, maxIlvl +end + +-- Returns true when the item has "Ascendant Voidforged" in its tooltip. +-- SEASON UPDATE: if the Voidforged modifier is renamed or replaced, update the +-- search string below to match the new tooltip text. +function Calc:IsVoidforged(link) + local r = self:ScanItemLink(link) + return r and r.isVoidforged or false +end + +-- Returns the ilvl gain from the next upgrade step, or nil if already at max. +function Calc:GetNextUpgradeGain(item) + local track, rank = self:GetItemTrackAndRank(item.link) + if not track or not rank then return nil end + local td = Data.tracks[track] + if not td or rank >= #td.ranks then return nil end + return (td.ranks[rank + 1] or 0) - (td.ranks[rank] or 0) +end + +-- Returns a table mapping crestName -> { quantity, cap, earned } for each track. +function Calc:GetPlayerCrests() + local owned = {} + for _, td in pairs(Data.tracks) do + if td.currID and td.currID > 0 then + local info = C_CurrencyInfo.GetCurrencyInfo(td.currID) + if info then + owned[td.crestName] = { + quantity = info.quantity or 0, + cap = (info.maxQuantity and info.maxQuantity > 0) and info.maxQuantity or nil, + earned = info.totalEarned or 0, + } + end + end + end + return owned +end + +-- Returns detailed cost info for a single item. +-- Non-crafted: trackName, rank, crestCost, goldCost, maxIlvl +-- Crafted: "Crafted", nil, nil, nil, maxIlvl, tierLabel, band +function Calc:GetItemUpgradeCost(item) + local track, rank = self:GetItemTrackAndRank(item.link) + + if not track then + local isCrafted, band, tier, maxIlvl = self:GetCraftedInfo(item.link, item.ilvl) + if isCrafted then + local label = (band == "Hero" and "Hero Craft") + or (band == "Myth" and "Myth Craft") + or (tier and "T" .. tier .. "/5") + or "Crafted" + return "Crafted", nil, nil, nil, maxIlvl, label, band + end + return nil + end + + local td = Data.tracks[track] + if not td then return nil end + + local db = DB() + + -- Track-based max: always derived from data table, not the API's maxItemLevel + -- (the NPC API returns the season ceiling for all items, not the track ceiling). + -- SEASON UPDATE: update Data.tracks ranks array for the new season; + -- expectedMax derives from the last entry automatically. + local expectedMax = td.ranks[#td.ranks] + for _, vs in ipairs(Data.voidcoreEligibleSlots) do + if vs == item.slot then expectedMax = expectedMax + Data.voidcoreBonus; break end + end + + -- Priority 1: exact costs from Upgrader NPC API (calibrated). + local slotCache = db.calibrated and db.cache.slots[item.slot] + if slotCache and slotCache.crestAmounts then + local exactCrests = 0 + for _, v in pairs(slotCache.crestAmounts) do exactCrests = exactCrests + v end + local exactGold = math.floor((slotCache.copperTotal or 0) / 10000) + return track, rank, exactCrests, exactGold, expectedMax + end + + -- Priority 2: estimate using watermark discount data. + -- SEASON UPDATE: 20 crests per upgrade step is the MN S1/S2 constant. + -- Update if Blizzard changes the crest cost per rank in a future season. + local rankCap = #td.ranks + local upgradesLeft = rankCap - rank + local crestCost = upgradesLeft * 20 + + if db.calibrated and td.currID and td.currID > 0 then + local wm = self:GetDiscountWatermark(item.slot, td.currID) + if wm and wm > 0 then + crestCost = 0 + for step = rank + 1, rankCap do + if (td.ranks[step] or 0) > wm then crestCost = crestCost + 20 end + end + end + end + + -- Priority 3: raw estimate. + return track, rank, crestCost, upgradesLeft * td.goldPer, expectedMax +end + +function Calc:IsUpgraderOpen() + return (ItemUpgradeFrame and ItemUpgradeFrame.IsShown and ItemUpgradeFrame:IsShown()) or false +end + +local function SelectSlotInUpgrader(loc) + -- Never touch protected API in combat; would cause taint. + if not loc or InCombatLockdown() then return false end + if C_ItemUpgrade and C_ItemUpgrade.ClearItemUpgrade then + pcall(C_ItemUpgrade.ClearItemUpgrade) + end + for _, fnName in ipairs({ "SetItemUpgradeFromItemLocation", "SetItemUpgradeFromLocation" }) do + if C_ItemUpgrade and C_ItemUpgrade[fnName] then + if pcall(C_ItemUpgrade[fnName], loc) then return true end + end + end + return false +end + +local function TallySlotCosts(info) + local crestAmounts, copper, watermarks = {}, 0, {} + local curr = info.currUpgrade or 0 + for _, lvl in ipairs(info.upgradeLevelInfos or {}) do + if (lvl.upgradeLevel or 0) > curr then + copper = copper + (lvl.moneyCost or 0) + for _, cc in ipairs(lvl.currencyCostsToUpgrade or {}) do + crestAmounts[cc.currencyID] = (crestAmounts[cc.currencyID] or 0) + (cc.cost or 0) + local di = cc.discountInfo + if di and (di.discountHighWatermark or 0) > 0 then + watermarks[cc.currencyID] = di.discountHighWatermark + end + end + end + end + return crestAmounts, copper, watermarks +end + +-- Scans every equipped slot at the Upgrader NPC, building an accurate cost cache. +-- Two-pass design: +-- Pass 1 (select): cycles through all slots via SelectSlotInUpgrader so the +-- Upgrader frame loads each item's upgrade cost data into the client cache. +-- Pass 2 (collect): after a short wait, reads GetItemUpgradeItemInfo for every +-- slot — data is now reliably present for all slots. +-- Without the pre-warm, GetItemUpgradeItemInfo returns nil for slots not already +-- shown in the frame; the select triggers an async load that isn't ready on the +-- same tick, so a single-pass approach always misses most slots on the first run. +function Calc:ScanEquippedAtUpgrader(onDone) + if InCombatLockdown() then + EllesmereUI.Print("|cffff4444EUIItemCalc|r Cannot scan during combat.") + if onDone then onDone(false) end + return + end + if Calc._scanning then + EllesmereUI.Print("|cffff4444EUIItemCalc|r Scan already in progress.") + if onDone then onDone(false) end + return + end + local db = DB() + if not self:IsUpgraderOpen() then + EllesmereUI.Print("|cffff4444EUIItemCalc|r Open the Item Upgrade window first.") + if onDone then onDone(false) end + return + end + + Calc._scanning = true + Calc._tipCache = {} + + -- Build into a fresh scratch table; only swap into db.cache on success. + local newSlots = {} + local slots = Data.equipSlots + local total = #slots + + local function onScanDone(ok) + Calc._scanning = false + if ok then + db.cache = { slots = newSlots, ts = time() } + db.calibrated = true + EllesmereUI.Print(string.format( + "|cff20ff20EUIItemCalc|r Scan complete (%d/%d). Costs are now accurate.", total, total)) + end + if onDone then onDone(ok) end + end + + local function saveSlotInfo(slotID, info) + if not (info and info.upgradeLevelInfos) then return end + local crestAmounts, copperTotal, watermarks = TallySlotCosts(info) + newSlots[slotID] = { + trackName = info.customUpgradeString, + rankCurrent = info.currUpgrade, + rankMax = info.maxUpgrade, + ilvlCap = info.maxItemLevel, + crestAmounts = crestAmounts, + copperTotal = copperTotal, + } + if next(watermarks) then + db.discounts[slotID] = db.discounts[slotID] or {} + for cid, wm in pairs(watermarks) do + local prev = db.discounts[slotID][cid] or 0 + if wm > prev then db.discounts[slotID][cid] = wm end + end + end + end + + -- Pass 2: harvest upgrade data for every slot (all now in client cache). + local function doCollectPass() + local ci = 1 + local function collectNext() + if InCombatLockdown() then onScanDone(false); return end + if ci > total then onScanDone(true); return end + local slotID = slots[ci] + local loc = ItemLocation and ItemLocation:CreateFromEquipmentSlot(slotID) + if loc then + local info = C_ItemUpgrade and C_ItemUpgrade.GetItemUpgradeItemInfo + and C_ItemUpgrade.GetItemUpgradeItemInfo(loc) + saveSlotInfo(slotID, info) + end + ci = ci + 1 + C_Timer.After(0.05, collectNext) + end + collectNext() + end + + -- Pass 1: select each slot so the Upgrader frame loads its cost data async. + -- After all slots are selected, wait 0.5 s before collecting. + local si = 1 + local function doSelectPass() + if InCombatLockdown() then onScanDone(false); return end + if si > total then + C_Timer.After(0.5, doCollectPass) + return + end + local loc = ItemLocation and ItemLocation:CreateFromEquipmentSlot(slots[si]) + if loc then SelectSlotInUpgrader(loc) end + si = si + 1 + C_Timer.After(0.12, doSelectPass) + end + + EllesmereUI.Print("|cff20ff20EUIItemCalc|r Scanning equipped items at Upgrader...") + doSelectPass() +end + +function Calc:GetDiscountWatermark(slotID, currencyID) + local t = DB().discounts[slotID] + return (t and t[currencyID]) or 0 +end + +function Calc:ClearCache() + local db = DB() + db.cache = { slots = {}, ts = 0 } + db.discounts = {} + db.calibrated = false +end + +------------------------------------------------------------------------------- +-- UI +------------------------------------------------------------------------------- + +local function SolidTex(p,l,r,g,b,a) return EUI.SolidTex(p,l,r,g,b,a) end +local function MFont(p,s,f,r,g,b,a) return EUI.MakeFont(p,s,f,r,g,b,a) end + +local G = EUI.ELLESMERE_GREEN +local ROW_H, HDR_H, FRAME_W, FRAME_H = 20, 20, 860, 730 + +-- Tile layout constants +local TILE_W, TILE_H = 183, 50 +local TILE_COLS = 3 +local TILE_GAP = 5 +local TILE_ROW_W = TILE_COLS * TILE_W + (TILE_COLS - 1) * TILE_GAP -- 559 +local QUEUE_X_OFF = TILE_ROW_W + 16 -- 575 +local QUEUE_W = FRAME_W - 20 - QUEUE_X_OFF -- 265 + +-- Per-track RGB accent colours used on tile left-edge bars +local TRACK_RGB = { + Adventurer = {0.12, 1.0, 0.0 }, + Veteran = {0.0, 0.44, 0.87}, + Champion = {0.64, 0.21, 0.93}, + Hero = {1.0, 0.50, 0.0 }, + Myth = {1.0, 0.82, 0.0 }, + Voidforged = {0.55, 0.0, 1.0 }, +} + +local CREST_COLS = { + {key="crest", label="Crest", x=0, w=150, align="LEFT" }, + {key="need", label="Need", x=150, w=70, align="CENTER"}, + {key="owned", label="Owned", x=220, w=70, align="CENTER"}, + {key="missing", label="Missing", x=290, w=70, align="CENTER"}, + {key="cap", label="Earned / Cap", x=360, w=140, align="CENTER"}, +} + +local f = CreateFrame("Frame", "EUIUpgCalcFrame", UIParent) +PP.Size(f, FRAME_W, FRAME_H) +f:SetPoint("LEFT", UIParent, "LEFT", 30, 0) +f:SetFrameStrata("DIALOG") +f:SetMovable(true) +f:EnableMouse(true) +f:RegisterForDrag("LeftButton") +f:SetScript("OnDragStart", function(self) self:StartMoving() end) +f:SetScript("OnDragStop", function(self) self:StopMovingOrSizing() end) +f:Hide() + +local fBg = SolidTex(f, "BACKGROUND", 0.05, 0.07, 0.09, 1) +fBg:SetAllPoints(f) + +function Calc.ApplyBgOpacity() + local opts = Opts() + local alpha = opts and opts.bgOpacity + if alpha == nil then alpha = 96 end + fBg:SetColorTexture(0.05, 0.07, 0.09, alpha / 100) +end + +local brd = EUI.MakeBorder(f, 0.13, 0.75, 0.55, 1) +if brd.SetColor then brd:SetColor(0.13, 0.75, 0.55, 1) end + +local titleBg = SolidTex(f, "BORDER", 0.08, 0.11, 0.14, 1) +PP.Point(titleBg, "TOPLEFT", f, "TOPLEFT", 1, -1) +PP.Point(titleBg, "TOPRIGHT", f, "TOPRIGHT", -1, 0) +PP.Height(titleBg, 32) + +local titleTxt = MFont(f, 13, "OUTLINE", G.r, G.g, G.b, 1) +PP.Point(titleTxt, "TOPLEFT", f, "TOPLEFT", 12, -10) +titleTxt:SetText("EllesmereUI |cffffffff- Upgrade Calculator|r") + +local closeBtn = CreateFrame("Button", nil, f) +PP.Size(closeBtn, 18, 18) +PP.Point(closeBtn, "TOPRIGHT", f, "TOPRIGHT", -8, -8) +SolidTex(closeBtn, "ARTWORK", 0.7, 0.2, 0.2, 0.9) +local closeTxt = MFont(closeBtn, 11, "OUTLINE", 1, 1, 1, 1) +closeTxt:SetAllPoints() +closeTxt:SetJustifyH("CENTER") +closeTxt:SetText("X") +closeBtn:SetScript("OnClick", function() f:Hide() end) + +table.insert(UISpecialFrames, "EUIUpgCalcFrame") + +local tabY = -36 + +local tabSep = SolidTex(f, "BORDER", G.r, G.g, G.b, 0.4) +PP.Point(tabSep, "TOPLEFT", f, "TOPLEFT", 8, tabY - 22) +PP.Point(tabSep, "TOPRIGHT", f, "TOPRIGHT", -8, tabY - 22) +PP.Height(tabSep, 1) + +-- Row helpers +local function MakeTableHeader(parent, cols, yOffset) + local hdrBg = SolidTex(parent, "BACKGROUND", 0.1, 0.13, 0.17, 1) + PP.Point(hdrBg, "TOPLEFT", parent, "TOPLEFT", 0, yOffset) + PP.Point(hdrBg, "TOPRIGHT", parent, "TOPRIGHT", 0, yOffset) + PP.Height(hdrBg, HDR_H) + for _, col in ipairs(cols) do + local lbl = MFont(parent, 10, "OUTLINE", G.r, G.g, G.b, 1) + PP.Point(lbl, "TOPLEFT", parent, "TOPLEFT", col.x + 4, yOffset - 2) + PP.Width(lbl, col.w) + lbl:SetJustifyH(col.align) + lbl:SetText(col.label) + end +end + +local function MakeRow(parent, cols, yOffset, isAlt) + local row = {} + if isAlt then + local bg = SolidTex(parent, "BACKGROUND", 0.08, 0.1, 0.13, 0.5) + PP.Point(bg, "TOPLEFT", parent, "TOPLEFT", 0, yOffset) + PP.Point(bg, "TOPRIGHT", parent, "TOPRIGHT", 0, yOffset) + PP.Height(bg, ROW_H) + row.altBg = bg -- stored so PopulateGear can reposition and hide/show it + end + for _, col in ipairs(cols) do + local cell = MFont(parent, 10, nil, 0.85, 0.85, 0.85, 1) + PP.Point(cell, "TOPLEFT", parent, "TOPLEFT", col.x + 4, yOffset - 2) + PP.Width(cell, col.w - 8) + cell:SetJustifyH(col.align) + row[col.key] = cell + end + return row +end + +local function MakeButton(parent, label, w, h, yOff, xOff) + local btn = CreateFrame("Button", nil, parent) + PP.Size(btn, w, h) + PP.Point(btn, "TOPLEFT", parent, "TOPLEFT", xOff, yOff) + SolidTex(btn, "BACKGROUND", 0.1, 0.14, 0.18, 1) + local bb = EUI.MakeBorder(btn, 0.13, 0.75, 0.55, 0.6) + if bb.SetColor then bb:SetColor(0.13, 0.75, 0.55, 0.6) end + local txt = MFont(btn, 10, "OUTLINE", G.r, G.g, G.b, 1) + txt:SetAllPoints(); txt:SetJustifyH("CENTER"); txt:SetText(label) + btn:SetScript("OnEnter", function() txt:SetTextColor(1, 1, 1, 1) end) + btn:SetScript("OnLeave", function() txt:SetTextColor(G.r, G.g, G.b, 1) end) + return btn, txt +end + +-- Character Pane ────────────────────────────────────────────────────────────── +f.charPane = CreateFrame("Frame", nil, f) +f.charPane:SetAllPoints(f) + +local ilvlStatLbl = MFont(f.charPane, 11, "OUTLINE", G.r, G.g, G.b, 1) +PP.Point(ilvlStatLbl, "TOPLEFT", f.charPane, "TOPLEFT", 14, tabY - 26) +ilvlStatLbl:SetText("Current iLvl: - Max Possible: -") + +local cc = CreateFrame("Frame", nil, f.charPane) +PP.Point(cc, "TOPLEFT", f.charPane, "TOPLEFT", 10, tabY - 46) +PP.Point(cc, "TOPRIGHT", f.charPane, "TOPRIGHT", -10, tabY - 46) +PP.Height(cc, FRAME_H - 100) + +-- ── iLvl Timeline bar ────────────────────────────────────────────────────────── +local tlTrack = SolidTex(cc, "BACKGROUND", 0.1, 0.12, 0.16, 1) +PP.Point(tlTrack, "TOPLEFT", cc, "TOPLEFT", 0, -2) +PP.Point(tlTrack, "TOPRIGHT", cc, "TOPRIGHT", 0, -2) +PP.Height(tlTrack, 16) + +local tlFill = SolidTex(cc, "ARTWORK", G.r, G.g, G.b, 0.3) +PP.Point(tlFill, "TOPLEFT", cc, "TOPLEFT", 0, -2) +PP.Height(tlFill, 16) +tlFill:SetWidth(1) -- updated each refresh + +local tlCurLbl = MFont(cc, 9, "OUTLINE", 0.65, 0.65, 0.65, 1) +PP.Point(tlCurLbl, "TOPLEFT", cc, "TOPLEFT", 2, -20) +local tlMaxLbl = MFont(cc, 9, "OUTLINE", G.r, G.g, G.b, 1) +PP.Point(tlMaxLbl, "TOPRIGHT", cc, "TOPRIGHT", -2, -20) +-- ── Tile frames ────────────────────────────────────────────────────────────────── +local ToggleTileQueue -- forward declaration (defined in queue section) + +local tileFrames = {} +for i = 1, 18 do + local btn = CreateFrame("Button", nil, cc) + PP.Size(btn, TILE_W, TILE_H) + btn:Hide() + local bg = SolidTex(btn, "BACKGROUND", 0.07, 0.09, 0.12, 1) + bg:SetAllPoints(btn) + btn.bg = bg + -- Left accent bar (3 px, track colour) + local accentBar = SolidTex(btn, "BORDER", 0.5, 0.5, 0.5, 1) + PP.Point(accentBar, "TOPLEFT", btn, "TOPLEFT", 0, 0) + PP.Point(accentBar, "BOTTOMLEFT", btn, "BOTTOMLEFT", 0, 0) + PP.Width(accentBar, 3) + btn.accentBar = accentBar + -- Queue-selection highlight overlay + local selHL = SolidTex(btn, "OVERLAY", 1, 0.85, 0.1, 0.12) + selHL:SetAllPoints(btn) + selHL:Hide() + btn.selHL = selHL + -- Top-left: slot name + local sLbl = MFont(btn, 11, "OUTLINE", 0.9, 0.9, 0.9, 1) + PP.Point(sLbl, "TOPLEFT", btn, "TOPLEFT", 7, -4) + PP.Width(sLbl, TILE_W - 82) + sLbl:SetJustifyH("LEFT") + btn.sLbl = sLbl + -- Top-right: current ^ max ilvl + local iLbl = MFont(btn, 10, "OUTLINE", 0.8, 0.8, 0.8, 1) + PP.Point(iLbl, "TOPRIGHT", btn, "TOPRIGHT", -5, -4) + PP.Width(iLbl, 76) + iLbl:SetJustifyH("RIGHT") + btn.iLbl = iLbl + -- Bottom-left: track name + local tLbl = MFont(btn, 10, "OUTLINE", 0.55, 0.55, 0.55, 1) + PP.Point(tLbl, "BOTTOMLEFT", btn, "BOTTOMLEFT", 7, 5) + PP.Width(tLbl, TILE_W - 82) + tLbl:SetJustifyH("LEFT") + btn.tLbl = tLbl + -- Bottom-right: rank badge + local rLbl = MFont(btn, 10, "OUTLINE", 0.8, 0.8, 0.8, 1) + PP.Point(rLbl, "BOTTOMRIGHT", btn, "BOTTOMRIGHT", -5, 5) + PP.Width(rLbl, 76) + rLbl:SetJustifyH("RIGHT") + btn.rLbl = rLbl + + btn.tileEntry = nil + + -- Handlers wired ONCE here — no closures created during refresh. + btn:SetScript("OnClick", function(self) + if self.tileEntry then ToggleTileQueue(self.tileEntry, self) end + end) + btn:SetScript("OnEnter", function(self) + self.bg:SetColorTexture(0.12, 0.16, 0.22, 1) + local e = self.tileEntry + if not e or not EUI.ShowWidgetTooltip then return end + local lines = {} + if e.isAtMax then + lines[#lines + 1] = "|cff20c020At maximum item level|r" + elseif e.trackKey == "Crafted" then + lines[#lines + 1] = "Crafted item — cannot be upgraded here" + elseif e.trackKey then + local td = Data.tracks[e.trackKey] + local snap = DB() + local sc = snap.calibrated and snap.cache.slots[e.slotID] or nil + if sc and sc.crestAmounts and next(sc.crestAmounts) then + for cid, amt in pairs(sc.crestAmounts) do + local cn = _currIDToCrestName[cid] + if cn and amt > 0 then + lines[#lines + 1] = amt .. "x " .. cn + end + end + local gold = math.floor((sc.copperTotal or 0) / 10000) + if gold > 0 then lines[#lines + 1] = gold .. "g" end + elseif (e.crestCost or 0) > 0 then + lines[#lines + 1] = "~" .. e.crestCost .. "x " .. (td and td.crestName or "Crest") + if (e.goldCost or 0) > 0 then + lines[#lines + 1] = "~" .. e.goldCost .. "g" + end + lines[#lines + 1] = "|cff888888Scan at Upgrader for exact costs|r" + elseif (e.goldCost or 0) > 0 then + lines[#lines + 1] = e.goldCost .. "g" + end + end + if #lines > 0 then + EUI.ShowWidgetTooltip(self, table.concat(lines, "\n")) + end + end) + btn:SetScript("OnLeave", function(self) + local e = self.tileEntry + if not e then return end + if e.isAtMax then + self.bg:SetColorTexture(0.04, 0.13, 0.05, 1) + elseif type(e.max) == "number" and (e.max - e.ilvl) >= 10 then + self.bg:SetColorTexture(0.14, 0.05, 0.04, 1) + else + self.bg:SetColorTexture(0.14, 0.10, 0.02, 1) + end + if EUI.HideWidgetTooltip then EUI.HideWidgetTooltip() end + end) + + tileFrames[i] = btn +end + +-- Section header labels and group separator line (repositioned each refresh) +local sHdrNeeds = MFont(cc, 9, "OUTLINE", G.r, G.g, G.b, 1) +local sHdrMax = MFont(cc, 9, "OUTLINE", 0.48, 0.48, 0.48, 1) +local groupSepLine = SolidTex(cc, "BORDER", 0.25, 0.28, 0.32, 1) +PP.Height(groupSepLine, 1) +PP.Width(groupSepLine, TILE_ROW_W) + + +-- ── Queue panel ────────────────────────────────────────────────────────────────── +local queuePane = CreateFrame("Frame", nil, cc) +PP.Size(queuePane, QUEUE_W, FRAME_H - 140) +PP.Point(queuePane, "TOPLEFT", cc, "TOPLEFT", QUEUE_X_OFF, -36) + +local qHdrBg = SolidTex(queuePane, "BACKGROUND", 0.08, 0.11, 0.15, 1) +PP.Point(qHdrBg, "TOPLEFT", queuePane, "TOPLEFT", 0, 0) +PP.Point(qHdrBg, "TOPRIGHT", queuePane, "TOPRIGHT", 0, 0) +PP.Height(qHdrBg, 20) +local qHdrLbl = MFont(queuePane, 10, "OUTLINE", G.r, G.g, G.b, 1) +PP.Point(qHdrLbl, "TOPLEFT", queuePane, "TOPLEFT", 4, -2) +qHdrLbl:SetText("UPGRADE QUEUE") +local qSubLbl = MFont(queuePane, 9, "OUTLINE", 0.38, 0.38, 0.38, 1) +PP.Point(qSubLbl, "TOPRIGHT", queuePane, "TOPRIGHT", -4, -2) +qSubLbl:SetText("click tiles to plan") + +local qEmptyLbl = MFont(queuePane, 9, "OUTLINE", 0.32, 0.32, 0.32, 1) +PP.Point(qEmptyLbl, "TOPLEFT", queuePane, "TOPLEFT", 4, -24) +qEmptyLbl:SetText("No items queued.") + +-- 16 pre-created queue entry rows +local queueEntries = {} +for i = 1, 16 do + local ef = CreateFrame("Frame", nil, queuePane) + PP.Size(ef, QUEUE_W, 20) + PP.Point(ef, "TOPLEFT", queuePane, "TOPLEFT", 0, -(18 + (i - 1) * 20)) + ef:Hide() + if i % 2 == 0 then + local ebg = SolidTex(ef, "BACKGROUND", 0.07, 0.09, 0.12, 0.5) + ebg:SetAllPoints(ef) + end + local nLbl = MFont(ef, 9, "OUTLINE", 0.8, 0.8, 0.8, 1) + PP.Point(nLbl, "TOPLEFT", ef, "TOPLEFT", 4, -2) + PP.Width(nLbl, QUEUE_W - 84) + nLbl:SetJustifyH("LEFT") + local cLbl = MFont(ef, 9, nil, 0.8, 0.8, 0.8, 1) + PP.Point(cLbl, "TOPRIGHT", ef, "TOPRIGHT", -4, -2) + PP.Width(cLbl, 82) + cLbl:SetJustifyH("RIGHT") + ef.nLbl = nLbl; ef.cLbl = cLbl + queueEntries[i] = ef +end + +local qTotalSep = SolidTex(queuePane, "BORDER", G.r, G.g, G.b, 0.22) +PP.Point(qTotalSep, "TOPLEFT", queuePane, "TOPLEFT", 0, -42) +PP.Point(qTotalSep, "TOPRIGHT", queuePane, "TOPRIGHT", 0, -42) +PP.Height(qTotalSep, 1) +qTotalSep:Hide() + +local qTotalLbl = MFont(queuePane, 9, "OUTLINE", G.r, G.g, G.b, 1) +PP.Point(qTotalLbl, "TOPLEFT", queuePane, "TOPLEFT", 4, -46) +qTotalLbl:SetText("") +qTotalLbl:Hide() + +local qClearBtn = MakeButton(queuePane, "Clear Queue", QUEUE_W - 6, 20, -70, 0) +qClearBtn:Hide() + +-- Queue state +local queueItems = {} -- ordered list of tileEntry tables +local queueSlotSet = {} -- slotName -> position in queueItems +local _queueLoaded = false -- true once saved queue has been applied after session start + +local crestManualAdds = { ["Hero Crest"] = 0, ["Myth Crest"] = 0 } + +-- Persist the current queue (slot IDs only) to the profile DB. +local function SaveQueue() + local db = DB() + db.queue = {} + for i, it in ipairs(queueItems) do db.queue[i] = it.slotID end +end + +-- Persist the current crest manual-add offsets to the profile DB. +local function SaveCrestManualAdds() + local db = DB() + db.crestManualAdds = db.crestManualAdds or {} + for k, v in pairs(crestManualAdds) do db.crestManualAdds[k] = v end +end + +local function UpdateQueueDisplay() + local n = #queueItems + qEmptyLbl:SetText(n == 0 and "No items queued." or "") + + local totalGoldQ, totalCrests = 0, {} + for i, entry in ipairs(queueItems) do + local qe = queueEntries[i] + qe:Show() + local parts = {} + if entry.trackKey and entry.trackKey ~= "Crafted" then + local td = Data.tracks[entry.trackKey] + if td and (entry.crestCost or 0) > 0 then + parts[#parts + 1] = entry.crestCost .. " " .. + (td.crestName or entry.trackKey):gsub(" Crest", "") + totalCrests[td.crestName] = (totalCrests[td.crestName] or 0) + entry.crestCost + end + if (entry.goldCost or 0) > 0 then + parts[#parts + 1] = entry.goldCost .. "g" + totalGoldQ = totalGoldQ + entry.goldCost + end + end + local costStr = #parts > 0 and table.concat(parts, " ") or "|cff20c020Max|r" + local gain = (not entry.isAtMax and type(entry.max) == "number" and entry.max > entry.ilvl) + and (entry.max - entry.ilvl) or nil + local nameStr = gain and (entry.slotName .. " |cff888888+" .. gain .. "|r") or entry.slotName + qe.nLbl:SetText(nameStr) + qe.cLbl:SetText(costStr) + end + for i = n + 1, 16 do queueEntries[i]:Hide() end + + if n > 0 then + local parts = {} + for _, trackName in ipairs(Data.trackOrder) do + local td = Data.tracks[trackName] + local ckey = td and td.crestName or trackName + local amt = totalCrests[ckey] or 0 + if amt > 0 then parts[#parts + 1] = amt .. " " .. ckey:gsub(" Crest", "") end + end + if totalGoldQ > 0 then parts[#parts + 1] = totalGoldQ .. "g" end + + local sepY = -(18 + n * 20 + 4) + qTotalSep:ClearAllPoints() + PP.Point(qTotalSep, "TOPLEFT", queuePane, "TOPLEFT", 0, sepY) + PP.Point(qTotalSep, "TOPRIGHT", queuePane, "TOPRIGHT", 0, sepY) + qTotalLbl:ClearAllPoints() + PP.Point(qTotalLbl, "TOPLEFT", queuePane, "TOPLEFT", 4, sepY - 4) + qClearBtn:ClearAllPoints() + PP.Point(qClearBtn, "TOPLEFT", queuePane, "TOPLEFT", 0, sepY - 28) + + qTotalLbl:SetText(#parts > 0 and table.concat(parts, " ") or "Nothing needed") + qTotalLbl:Show(); qTotalSep:Show(); qClearBtn:Show() + else + qTotalLbl:Hide(); qTotalSep:Hide(); qClearBtn:Hide() + end +end + +ToggleTileQueue = function(entry, btn) + local sn = entry.slotName + if queueSlotSet[sn] then + local idx = queueSlotSet[sn] + table.remove(queueItems, idx) + queueSlotSet = {} + for i, it in ipairs(queueItems) do queueSlotSet[it.slotName] = i end + btn.selHL:Hide() + else + queueItems[#queueItems + 1] = entry + queueSlotSet[sn] = #queueItems + btn.selHL:Show() + end + UpdateQueueDisplay() + SaveQueue() +end + +qClearBtn:SetScript("OnClick", function() + queueItems = {} + queueSlotSet = {} + for _, btn in ipairs(tileFrames) do btn.selHL:Hide() end + UpdateQueueDisplay() + SaveQueue() +end) + +-- ── Crest section — parented to a repositionable container frame ────────────── +-- crestSection is moved each PopulateGear so the window height stays tight. +local crestSection = CreateFrame("Frame", nil, cc) +PP.Point(crestSection, "TOPLEFT", cc, "TOPLEFT", 0, -430) -- initial; overwritten each refresh +PP.Point(crestSection, "TOPRIGHT", cc, "TOPRIGHT", 0, -430) +PP.Height(crestSection, 200) -- large enough; content determines visible area + +local gearSep = SolidTex(crestSection, "BORDER", 0.2, 0.24, 0.28, 1) +PP.Point(gearSep, "TOPLEFT", crestSection, "TOPLEFT", 0, 4) +PP.Point(gearSep, "TOPRIGHT", crestSection, "TOPRIGHT", 0, 4) +PP.Height(gearSep, 1) + +-- Accuracy label floats right of the separator line; updated each PopulateGear. +local crestAccuracyLbl = MFont(crestSection, 9, "OUTLINE", 0.38, 0.38, 0.38, 1) +PP.Point(crestAccuracyLbl, "TOPRIGHT", crestSection, "TOPRIGHT", -4, 12) +crestAccuracyLbl:SetText("") + +-- Build crest table header once. The cap column label is kept as a separate +-- reference so it can be shown or hidden without recreating any frames. +do + local baseCols = { CREST_COLS[1], CREST_COLS[2], CREST_COLS[3], CREST_COLS[4] } + MakeTableHeader(crestSection, baseCols, 0) +end +local capHdrLbl = MFont(crestSection, 10, "OUTLINE", G.r, G.g, G.b, 1) +PP.Point(capHdrLbl, "TOPLEFT", crestSection, "TOPLEFT", CREST_COLS[5].x + 4, -2) +PP.Width(capHdrLbl, CREST_COLS[5].w) +capHdrLbl:SetJustifyH(CREST_COLS[5].align) +capHdrLbl:SetText(CREST_COLS[5].label) +capHdrLbl:Hide() + +-- Forward-declared so crest-row +/-80 buttons can call it. +local PopulateGear + +local crestRows = {} +for i = 1, #Data.trackOrder do + local rowY = -(HDR_H + (i - 1) * ROW_H) + crestRows[i] = MakeRow(crestSection, CREST_COLS, rowY, i % 2 == 0) + -- +/-80 buttons on Hero and Myth rows for manually budgeting crafted-item crests + local tn = Data.trackOrder[i] + if tn == "Hero" or tn == "Myth" then + local ckey = Data.tracks[tn].crestName -- "Hero Crest" / "Myth Crest" + local mBtn = CreateFrame("Button", nil, crestSection) + PP.Size(mBtn, 26, 16) + PP.Point(mBtn, "TOPLEFT", crestSection, "TOPLEFT", 143, rowY - 2) + local mBg = SolidTex(mBtn, "ARTWORK", 0.18, 0.08, 0.08, 0.9) + mBg:SetAllPoints(mBtn) + local mTxt = MFont(mBtn, 8, "OUTLINE", 0.9, 0.45, 0.45, 1) + mTxt:SetAllPoints(); mTxt:SetJustifyH("CENTER"); mTxt:SetText("-80") + mBtn:SetScript("OnEnter", function() + mBg:SetColorTexture(0.28, 0.10, 0.10, 1) + if EUI.ShowWidgetTooltip then + EUI.ShowWidgetTooltip(mBtn, "Subtract 80 " .. ckey .. "s from the total.\n" + .. "Use this to account for crafted gear\nthat shares this currency.") + end + end) + mBtn:SetScript("OnLeave", function() + mBg:SetColorTexture(0.18, 0.08, 0.08, 0.9) + if EUI.HideWidgetTooltip then EUI.HideWidgetTooltip() end + end) + mBtn:SetScript("OnClick", function() + crestManualAdds[ckey] = math.max(0, (crestManualAdds[ckey] or 0) - 80) + SaveCrestManualAdds() + PopulateGear() + end) + local pBtn = CreateFrame("Button", nil, crestSection) + PP.Size(pBtn, 26, 16) + PP.Point(pBtn, "TOPLEFT", crestSection, "TOPLEFT", 200, rowY - 2) + local pBg = SolidTex(pBtn, "ARTWORK", 0.06, 0.18, 0.08, 0.9) + pBg:SetAllPoints(pBtn) + local pTxt = MFont(pBtn, 8, "OUTLINE", 0.45, 0.9, 0.45, 1) + pTxt:SetAllPoints(); pTxt:SetJustifyH("CENTER"); pTxt:SetText("+80") + pBtn:SetScript("OnEnter", function() + pBg:SetColorTexture(0.10, 0.28, 0.10, 1) + if EUI.ShowWidgetTooltip then + EUI.ShowWidgetTooltip(pBtn, "Add 80 " .. ckey .. "s to the total.\n" + .. "Use this to account for crafted gear\nthat shares this currency.") + end + end) + pBtn:SetScript("OnLeave", function() + pBg:SetColorTexture(0.06, 0.18, 0.08, 0.9) + if EUI.HideWidgetTooltip then EUI.HideWidgetTooltip() end + end) + pBtn:SetScript("OnClick", function() + crestManualAdds[ckey] = (crestManualAdds[ckey] or 0) + 80 + SaveCrestManualAdds() + PopulateGear() + end) + crestRows[i].mBtn = mBtn + crestRows[i].pBtn = pBtn + end +end + +-- Summary label and action buttons — initially anchored at y=0; repositioned +-- every PopulateGear call to sit below the last visible crest row. +local summaryLbl = MFont(crestSection, 11, "OUTLINE", G.r, G.g, G.b, 1) +PP.Point(summaryLbl, "TOPLEFT", crestSection, "TOPLEFT", 4, 0) +summaryLbl:SetText("Missing Upgrades: - Gold Needed: -") + +local refreshBtn = MakeButton(crestSection, "Refresh", 140, 22, 0, 0) +local scanBtn, scanBtnTxt = MakeButton(crestSection, "Update at Upgrader", 160, 22, 0, 150) + +-- ── FormatCost helper ───────────────────────────────────────────────────────── +local function FormatCost(cAmt, gAmt, trackName) + if cAmt == 0 and gAmt == 0 then return "|cff20c020Max|r" end + local td = trackName and Data.tracks[trackName] + local cn = (td and td.crestName) or "Crest" + local parts = {} + if cAmt > 0 then parts[#parts + 1] = cAmt .. " " .. cn end + if gAmt > 0 then parts[#parts + 1] = gAmt .. "g" end + return table.concat(parts, " ") +end + +-- ── PopulateGear ────────────────────────────────────────────────────────────── +PopulateGear = function() + local gear = Calc:GetEquippedGear() + local owned = Calc:GetPlayerCrests() + local totalMissing, totalGold, crestNeeds, maxTotal = 0, 0, {}, 0 + local tileEntries = {} + + -- Pre-build per-slot crest breakdown from scan data. + -- Done once here from a single DB snapshot so every item in the loop + -- sees a consistent view of the cache with no per-item DB reads. + local slotCrestMap = {} + local dbSnap = DB() + if dbSnap.calibrated then + for slotID, sc in pairs(dbSnap.cache.slots or {}) do + if sc and sc.crestAmounts then + local byName = {} + for cid, amt in pairs(sc.crestAmounts) do + local cn = _currIDToCrestName[cid] + if cn and amt > 0 then byName[cn] = (byName[cn] or 0) + amt end + end + if next(byName) then slotCrestMap[slotID] = byName end + end + end + end + -- Read persistent settings once per refresh + local opts = Opts() + local hideCrafted = opts.hideCrafted + local showMaxed = opts.showMaxed + local slotFilter = opts.slotFilter -- table of group -> bool (nil = all shown) + local crestFilter = opts.crestFilter -- table of trackName -> bool (nil/true = shown) + + -- Build per-slot data + for _, item in ipairs(gear) do + local grp = Data.slotToGroup[item.slot] + if not (slotFilter and grp and slotFilter[grp] == false) then + local sn = Data.slotNames[item.slot] or ("Slot " .. item.slot) + local pOk, pa, pb, pc, pd, pe, pf = pcall(Calc.GetItemUpgradeCost, Calc, item) + local track, rank, crestCost, goldCost, maxIlvl, craftLabel + if pOk then track, rank, crestCost, goldCost, maxIlvl, craftLabel = pa, pb, pc, pd, pe, pf end + local dt, dc, dm, du = "-", "-", "-", "-" + local isAtMax, shouldAdd = false, true + + if track == "Crafted" then + if hideCrafted then + shouldAdd = false + else + dt = craftLabel or "Crafted"; dm = maxIlvl or item.ilvl; dc = "|cff888888Crafted|r" + isAtMax = true + du = "Crafted" + end + elseif track then + totalMissing = totalMissing + (6 - (rank or 0)) + totalGold = totalGold + (goldCost or 0) + local td = Data.tracks[track] + local cn_map = slotCrestMap[item.slot] + if cn_map then + for cn, amt in pairs(cn_map) do + crestNeeds[cn] = (crestNeeds[cn] or 0) + amt + end + elseif td and (crestCost or 0) > 0 then + crestNeeds[td.crestName] = (crestNeeds[td.crestName] or 0) + crestCost + end + dt = track; dm = maxIlvl or item.ilvl + du = rank and (rank .. "/" .. (td and #td.ranks or 6)) or "-" + dc = FormatCost(crestCost or 0, goldCost or 0, track) + isAtMax = (rank == (td and #td.ranks or 6)) + else + if Calc:IsVoidforged(item.link) then + dt = "Voidforged"; dm = item.ilvl; du = "Max"; dc = "|cff20c020Max|r"; isAtMax = true + elseif item.ilvl >= 200 then + -- Use the shared helper so thresholds stay in one place. + local band, maxI = CraftedBandFromIlvl(item.ilvl) + local label = band == "Myth" and "Myth Craft" + or band == "Hero" and "Hero Craft" or "Crafted" + dt = label; dm = maxI + du = item.ilvl >= maxI and "Max" or "-" + dc = item.ilvl >= maxI and "|cff20c020Max|r" or "|cff888888Crafted|r" + isAtMax = item.ilvl >= maxI + else + dm = item.ilvl; isAtMax = true + end + end + + if shouldAdd then + maxTotal = maxTotal + (type(dm) == "number" and dm or item.ilvl) + tileEntries[#tileEntries + 1] = { + slotName = sn, slotID = item.slot, + ilvl = item.ilvl, max = dm, + upgrade = du, trackName = dt, cost = dc, + isAtMax = isAtMax, trackKey = track, + rank = rank, crestCost = crestCost, goldCost = goldCost, + } + end + end + end + + -- Fold in manual crest additions (from the +/-80 buttons on Hero/Myth rows) + for ckey, amt in pairs(crestManualAdds) do + if (amt or 0) > 0 then + crestNeeds[ckey] = (crestNeeds[ckey] or 0) + amt + end + end + + -- Sort: needs-upgrades first, then at-max. Within each group follow character sheet slot order. + table.sort(tileEntries, function(a, b) + if a.isAtMax ~= b.isAtMax then return not a.isAtMax end + return a.slotID < b.slotID + end) + + -- Restore queue from DB on the first PopulateGear call after a session start. + -- We only do this once (_queueLoaded guard) so that subsequent Refresh calls + -- don't overwrite in-session queue changes the user has made. + if not _queueLoaded then + _queueLoaded = true + local savedSlots = DB().queue + if savedSlots and #savedSlots > 0 then + local slotToEntry = {} + for _, e in ipairs(tileEntries) do slotToEntry[e.slotID] = e end + queueItems = {}; queueSlotSet = {} + for _, slotID in ipairs(savedSlots) do + local e = slotToEntry[slotID] + if e and not queueSlotSet[e.slotName] then + queueItems[#queueItems + 1] = e + queueSlotSet[e.slotName] = #queueItems + end + end + end + end + + -- Timeline bar + local curAvg = select(2, GetAverageItemLevel()) or 0 + -- Blizzard counts 2H weapons as two slots; clamp so max never shows below current. + local maxAvg = math.max(curAvg, #gear > 0 and maxTotal / #gear or 0) + local minBase = 200 + local frac = (maxAvg > minBase and curAvg > minBase) + and math.min(1, (curAvg - minBase) / math.max(1, maxAvg - minBase)) or 0 + tlFill:SetWidth(math.max(1, math.floor(frac * (FRAME_W - 22)))) + tlCurLbl:SetText(string.format("%.1f", curAvg)) + tlMaxLbl:SetText(string.format("max %.1f", maxAvg)) + ilvlStatLbl:SetText(string.format( + "Current iLvl: |cffffffff%.1f|r Max Possible: |cffffffff%.1f|r |cff888888(estimated)|r", + curAvg, maxAvg)) + + -- Section header positions + local needsCount = 0 + for _, e in ipairs(tileEntries) do if not e.isAtMax then needsCount = needsCount + 1 end end + local maxCount = #tileEntries - needsCount + local needsRows = math.ceil(math.max(1, needsCount) / TILE_COLS) + + if needsCount > 0 then + sHdrNeeds:ClearAllPoints() + PP.Point(sHdrNeeds, "TOPLEFT", cc, "TOPLEFT", 2, -36) + sHdrNeeds:SetText(string.format("v NEEDS UPGRADES (%d)", needsCount)) + sHdrNeeds:Show() + else + sHdrNeeds:SetText(""); sHdrNeeds:Hide() + end + + local atMaxHdrY = -36 - (needsCount > 0 and needsRows * 55 + 18 or 0) + if maxCount > 0 and showMaxed then + groupSepLine:ClearAllPoints() + PP.Point(groupSepLine, "TOPLEFT", cc, "TOPLEFT", 0, atMaxHdrY - 2) + groupSepLine:Show() + sHdrMax:ClearAllPoints() + PP.Point(sHdrMax, "TOPLEFT", cc, "TOPLEFT", 2, atMaxHdrY - 6) + sHdrMax:SetText(string.format("v AT MAX (%d)", maxCount)) + sHdrMax:Show() + else + groupSepLine:Hide(); sHdrMax:Hide() + end + + -- Position and fill tile frames + local function getTilePos(group_start_y, local_idx) + local row = math.floor(local_idx / TILE_COLS) + local col = local_idx % TILE_COLS + return col * (TILE_W + TILE_GAP), group_start_y - row * 55 + end + + local needsStartY = -54 + local atMaxStartY = needsStartY - needsRows * 55 - (needsCount > 0 and 34 or 18) + + for _, btn in ipairs(tileFrames) do btn:Hide() end + + local ni, mi = 0, 0 + for idx, entry in ipairs(tileEntries) do + if idx > 18 then break end + local btn = tileFrames[idx] + local tx, ty + if not entry.isAtMax then + tx, ty = getTilePos(needsStartY, ni); ni = ni + 1 + elseif showMaxed then + tx, ty = getTilePos(atMaxStartY, mi); mi = mi + 1 + end + if tx then + btn:ClearAllPoints() + PP.Point(btn, "TOPLEFT", cc, "TOPLEFT", tx, ty) + btn:Show() + + -- Tile background colour by upgrade gap + if entry.isAtMax then + btn.bg:SetColorTexture(0.04, 0.13, 0.05, 1) + elseif type(entry.max) == "number" and (entry.max - entry.ilvl) >= 10 then + btn.bg:SetColorTexture(0.14, 0.05, 0.04, 1) + else + btn.bg:SetColorTexture(0.14, 0.10, 0.02, 1) + end + + -- Left accent bar: track colour + local rgb = (entry.trackKey and TRACK_RGB[entry.trackKey]) + or TRACK_RGB[entry.trackName] + or {0.4, 0.4, 0.4} + btn.accentBar:SetColorTexture(rgb[1], rgb[2], rgb[3], 1) + + -- Text labels + btn.sLbl:SetText(entry.slotName) + local maxStr = type(entry.max) == "number" and tostring(entry.max) or "-" + btn.iLbl:SetText(entry.ilvl .. " ^ " .. maxStr) + btn.tLbl:SetText(entry.trackName) + btn.rLbl:SetText(entry.upgrade) + + local txtA = entry.isAtMax and 0.45 or 0.9 + btn.sLbl:SetTextColor(txtA, txtA, txtA, 1) + btn.iLbl:SetTextColor(txtA, txtA, txtA, 1) + btn.rLbl:SetTextColor(txtA, txtA, txtA, 1) + + -- Restore queue highlight + if queueSlotSet[entry.slotName] then btn.selHL:Show() + else btn.selHL:Hide() end + + btn.tileEntry = entry + end + end + + -- Reposition the crest section immediately below the last rendered tile row. + -- needsRows already computed above; maxRows only adds if showMaxed is on. + local maxRows = (showMaxed and maxCount > 0) and math.ceil(maxCount / TILE_COLS) or 0 + local totalTileRows = needsRows + maxRows + -- tile area starts at cc y=-54 and each row is 55px; add 20px gap before crest section + local crestY = -54 - totalTileRows * 55 - 20 + -- atMaxHdrY already accounts for the header gap; push crest below it when showing maxed + if showMaxed and maxCount > 0 then + local atMaxRows = math.ceil(maxCount / TILE_COLS) + crestY = atMaxStartY - atMaxRows * 55 - 20 + end + crestSection:ClearAllPoints() + PP.Point(crestSection, "TOPLEFT", cc, "TOPLEFT", 0, crestY) + PP.Point(crestSection, "TOPRIGHT", cc, "TOPRIGHT", 0, crestY) + + -- Resize the outer frame to fit content: title(32) + tabY(-36) + cc offset(46) + + -- tile area + crest section (rows + summary + buttons) + bottom padding + local visibleCrestRows = 0 + for _, tn in ipairs(Data.trackOrder) do + if crestFilter == nil or crestFilter[tn] ~= false then + visibleCrestRows = visibleCrestRows + 1 + end + end + local crestSectionH = HDR_H + visibleCrestRows * ROW_H + 10 + 22 + 38 -- hdr+rows+gap+summary+btns + local contentH = math.abs(crestY) + crestSectionH + -- cc is anchored at y = tabY - 46 = -82 from frame top; add title bar (32) + padding (12) + local newFrameH = contentH + 82 + 32 + 12 + PP.Size(f, FRAME_W, newFrameH) + + -- Crest accuracy label + local db = DB() + if db.calibrated then + crestAccuracyLbl:SetText("|cff20ff20(exact — Upgrader scan)|r") + else + crestAccuracyLbl:SetText("|cffaaaaaa(estimated)|r") + end + + -- Crest table — compact visible rows to consecutive y positions so that + -- filtered-out rows leave no gap, and +/-80 buttons/summary/action buttons + -- always appear immediately below the last visible row. + local showCap = opts.showEarnedCap + if showCap then capHdrLbl:Show() else capHdrLbl:Hide() end + local visualIdx = 0 + for i, trackName in ipairs(Data.trackOrder) do + local td = Data.tracks[trackName] + local ckey = td and td.crestName or trackName + local visible = crestFilter == nil or crestFilter[trackName] ~= false + local rowFrame = crestRows[i] + local rowY = -(HDR_H + visualIdx * ROW_H) + if visible then + -- Reposition alt-row background stripe to the compacted visual position + if rowFrame.altBg then + rowFrame.altBg:ClearAllPoints() + PP.Point(rowFrame.altBg, "TOPLEFT", crestSection, "TOPLEFT", 0, rowY) + PP.Point(rowFrame.altBg, "TOPRIGHT", crestSection, "TOPRIGHT", 0, rowY) + rowFrame.altBg:Show() + end + -- Reposition every cell to the current visual slot + for _, col in ipairs(CREST_COLS) do + local cell = rowFrame[col.key] + cell:ClearAllPoints() + PP.Point(cell, "TOPLEFT", crestSection, "TOPLEFT", col.x + 4, rowY - 2) + end + -- Reposition and show +/-80 buttons if this row has them + if rowFrame.mBtn then + rowFrame.mBtn:ClearAllPoints() + PP.Point(rowFrame.mBtn, "TOPLEFT", crestSection, "TOPLEFT", 143, rowY - 2) + rowFrame.pBtn:ClearAllPoints() + PP.Point(rowFrame.pBtn, "TOPLEFT", crestSection, "TOPLEFT", 200, rowY - 2) + rowFrame.mBtn:Show() + rowFrame.pBtn:Show() + end + local need = crestNeeds[ckey] or 0 + local info = owned[ckey] + local have = info and info.quantity or 0 + local miss = math.max(0, need - have) + rowFrame.crest:SetText(ckey) + rowFrame.need:SetText(need > 0 and need or "-") + rowFrame.owned:SetText(have > 0 and have or "-") + rowFrame.missing:SetText(miss > 0 + and ("|cffff6060" .. miss .. "|r") or "|cff20ff20-|r") + if showCap then + local cap = info and info.cap + local earnedStr = info and info.earned and info.earned > 0 and tostring(info.earned) or "-" + local capStr = cap and tostring(cap) or "-" + rowFrame.cap:SetText(earnedStr .. " / " .. capStr) + rowFrame.cap:Show() + else + rowFrame.cap:Hide() + end + rowFrame.crest:Show(); rowFrame.need:Show() + rowFrame.owned:Show(); rowFrame.missing:Show() + visualIdx = visualIdx + 1 + else + rowFrame.crest:Hide(); rowFrame.need:Hide() + rowFrame.owned:Hide(); rowFrame.missing:Hide(); rowFrame.cap:Hide() + if rowFrame.altBg then rowFrame.altBg:Hide() end + if rowFrame.mBtn then + rowFrame.mBtn:Hide() + rowFrame.pBtn:Hide() + end + end + end + + -- Reposition summary label and action buttons immediately below the last visible row + local crestBotY = -(HDR_H + visualIdx * ROW_H) + summaryLbl:ClearAllPoints() + PP.Point(summaryLbl, "TOPLEFT", crestSection, "TOPLEFT", 4, crestBotY - 10) + refreshBtn:ClearAllPoints() + PP.Point(refreshBtn, "TOPLEFT", crestSection, "TOPLEFT", 0, crestBotY - 38) + scanBtn:ClearAllPoints() + PP.Point(scanBtn, "TOPLEFT", crestSection, "TOPLEFT", 150, crestBotY - 38) + + local crestParts = {} + for _, trackName in ipairs(Data.trackOrder) do + local td = Data.tracks[trackName] + local ckey = td and td.crestName or trackName + local amt = crestNeeds[ckey] or 0 + if amt > 0 then + local hexColor = (td and td.hexColor) or "|cffffffff" + crestParts[#crestParts + 1] = "|cffffffff" .. amt .. "|r " .. hexColor .. trackName .. "|r" + end + end + local crestStr = #crestParts > 0 and (" Crests: " .. table.concat(crestParts, " ")) or "" + summaryLbl:SetText(string.format( + "Missing Upgrades: |cffffffff%d|r%s Gold: |cffffffff%dg|r", + totalMissing, crestStr, totalGold)) +end + +refreshBtn:SetScript("OnClick", PopulateGear) +Calc.PopulateGear = PopulateGear -- exposed for options page live-refresh +refreshBtn:HookScript("OnEnter", function(self) + if EUI.ShowWidgetTooltip then + EUI.ShowWidgetTooltip(self, + "Refresh using tooltip scan data.\n" + .. "For exact costs, use |cffffffff'Update at Upgrader'|r\n" + .. "while at an Item Upgrade NPC.") + end +end) +refreshBtn:HookScript("OnLeave", function() + if EUI.HideWidgetTooltip then EUI.HideWidgetTooltip() end +end) + +scanBtn:HookScript("OnEnter", function(self) + if EUI.ShowWidgetTooltip then + local tip = "Scan all equipped gear costs at the Item Upgrade NPC.\n" + .. "Requires the Item Upgrade window to be open." + if not Calc:IsUpgraderOpen() then + tip = tip .. "\n|cffff6060Item Upgrade window is not open.|r" + end + EUI.ShowWidgetTooltip(self, tip) + end +end) +scanBtn:HookScript("OnLeave", function() + if EUI.HideWidgetTooltip then EUI.HideWidgetTooltip() end +end) + +scanBtn:SetScript("OnClick", function() + if Calc._scanning then return end + scanBtnTxt:SetText("Scanning...") + scanBtn:SetAlpha(0.5) + Calc:ScanEquippedAtUpgrader(function(ok) + scanBtnTxt:SetText("Update at Upgrader") + scanBtn:SetAlpha(1) + if ok then + crestManualAdds["Hero Crest"] = 0 + crestManualAdds["Myth Crest"] = 0 + SaveCrestManualAdds() + PopulateGear() + end + end) +end) + +local equipListener = CreateFrame("Frame") +equipListener:SetScript("OnEvent", function(_, event, slotID) + if event == "PLAYER_REGEN_DISABLED" then + -- Combat started: close the frame silently + if f:IsShown() then f:Hide() end + return + end + -- Invalidate the tip cache for the changed slot (or all if slotID is missing). + if slotID and slotID > 0 then + local link = GetInventoryItemLink("player", slotID) + if link then Calc._tipCache[link] = nil end + else + Calc._tipCache = {} + end + -- Debounce: wait 0.3 s after the last equip event before refreshing. + -- This prevents hammering PopulateGear when the user swaps multiple pieces. + if not f:IsShown() then return end + if _equipDebounce then _equipDebounce:Cancel() end + _equipDebounce = C_Timer.NewTimer(0.3, function() + _equipDebounce = nil + if f:IsShown() then PopulateGear() end + end) +end) + +-- Show / Hide +-- Debounce timer handle for PLAYER_EQUIPMENT_CHANGED: coalesces rapid gear swaps +-- (e.g. multiple pieces at once) into a single PopulateGear call. +local _equipDebounce = nil + +f:SetScript("OnShow", function() + if InCombatLockdown() then f:Hide(); return end + Calc.ApplyBgOpacity() + -- Reload persisted crest manual-add offsets each time the frame opens, + -- so that values the user set before logging out are visible immediately. + local dbAdds = DB().crestManualAdds + for k in pairs(crestManualAdds) do + crestManualAdds[k] = (dbAdds and dbAdds[k]) or 0 + end + equipListener:RegisterEvent("PLAYER_EQUIPMENT_CHANGED") + equipListener:RegisterEvent("PLAYER_REGEN_DISABLED") + PopulateGear() +end) +f:SetScript("OnHide", function() + equipListener:UnregisterAllEvents() + -- Cancel any pending equipment-change debounce. + if _equipDebounce then _equipDebounce:Cancel(); _equipDebounce = nil end + -- If hidden mid-scan (e.g. combat, /reload), unblock future scans. + Calc._scanning = false + Calc._tipCache = {} + -- crestManualAdds are now persisted to DB; no longer reset on hide. +end) + +SLASH_EUIUPGCALC1 = "/euic" +SLASH_EUIUPGCALC2 = "/upgcalc" +SlashCmdList["EUIUPGCALC"] = function() + if InCombatLockdown() then + EllesmereUI.Print("|cffff4444EUIItemCalc|r Cannot open during combat.") + return + end + if f:IsShown() then f:Hide() else f:Show() end +end + +-- ── Profile integration + first-run crest filter ──────────────────────────── +-- On PLAYER_LOGIN we call NewDB so our data lives inside EllesmereUIDB.profiles +-- (the same place Cursor, BattleRes etc store theirs). Without this, NewDB +-- wipes EllesmereUIQoLDB and our saved data is lost every session. +-- The first-run filter runs once (guarded by opts.firstRunDone) to auto-hide +-- crest tracks that have no upgradeable items on the player's current gear. +local _firstRunEvt = CreateFrame("Frame") +_firstRunEvt:RegisterEvent("PLAYER_LOGIN") +_firstRunEvt:SetScript("OnEvent", function(self) + self:UnregisterAllEvents() + -- Register with the profile system; this MUST happen before Opts()/DB() are + -- called so data is read from / written to the correct persistent location. + if EllesmereUI and EllesmereUI.Lite and EllesmereUI.Lite.NewDB then + local profileDB = EllesmereUI.Lite.NewDB("EllesmereUIQoLDB", { + profile = { + upgradeCalcOpts = {}, + upgradeCalc = { + cache = { slots = {}, ts = 0 }, + discounts = {}, + calibrated = false, + }, + }, + }) + _euicProfileRef = profileDB.profile + -- Populate the direct sub-table caches now so DB()/Opts() are O(1) + -- for the rest of the session with no repeated table traversal. + local store = _euicProfileRef + store.upgradeCalc = store.upgradeCalc or {} + local db = store.upgradeCalc + db.cache = db.cache or { slots = {}, ts = 0 } + db.discounts = db.discounts or {} + db.calibrated = db.calibrated or false + db.queue = db.queue or {} + db.crestManualAdds = db.crestManualAdds or {} + store.upgradeCalcOpts = store.upgradeCalcOpts or {} + _dbCache = db + _optsCache = store.upgradeCalcOpts + end + local opts = Opts() + if opts.firstRunDone then return end + -- Brief delay so the client has fully loaded item data before tooltip scanning. + C_Timer.After(1.5, function() + opts.firstRunDone = true + local tracksNeeded = {} + for _, slotID in ipairs(Data.equipSlots) do + local link = GetInventoryItemLink("player", slotID) + if link then + local r = Calc:ScanItemLink(link) + if r and r.track and (r.rank or 0) < 6 then + tracksNeeded[r.track] = true + end + end + end + -- Only apply a filter if at least one track can be hidden. + local anyHidden = false + for _, tn in ipairs(Data.trackOrder) do + if not tracksNeeded[tn] then anyHidden = true; break end + end + if anyHidden then + opts.crestFilter = opts.crestFilter or {} + for _, tn in ipairs(Data.trackOrder) do + if not tracksNeeded[tn] then + opts.crestFilter[tn] = false + end + end + end + end) +end) diff --git a/EllesmereUIQoL/EUI_UpgradeCalc_Options.lua b/EllesmereUIQoL/EUI_UpgradeCalc_Options.lua new file mode 100644 index 00000000..8fd8419e --- /dev/null +++ b/EllesmereUIQoL/EUI_UpgradeCalc_Options.lua @@ -0,0 +1,235 @@ +------------------------------------------------------------------------------- +-- EUI_UpgradeCalc_Options.lua +-- Options page for the Upgrade Calculator feature (part of EllesmereUIQoL). +------------------------------------------------------------------------------- + +local function GetAddonDB() + -- Always delegate to the main module so we read from the same profile + -- slice that persists via EllesmereUIDB (not the wiped EllesmereUIQoLDB). + if EUIUpgCalc and EUIUpgCalc.GetOptsDB then + return EUIUpgCalc.GetOptsDB() + end + EllesmereUIQoLDB = EllesmereUIQoLDB or {} + EllesmereUIQoLDB.upgradeCalcOpts = EllesmereUIQoLDB.upgradeCalcOpts or {} + return EllesmereUIQoLDB.upgradeCalcOpts +end + +local function BuildUpgradeCalcPage(pageName, parent, yOffset) + local W = EllesmereUI.Widgets + local y = yOffset + local _, h + + parent._showRowDivider = true + _, h = W:Spacer(parent, y, 20); y = y - h + + --------------------------------------------------------------------------- + -- DISPLAY + --------------------------------------------------------------------------- + _, h = W:SectionHeader(parent, "DISPLAY", y); y = y - h + + _, h = W:Toggle(parent, + "Open on Login", + y, + function() return GetAddonDB().openOnLogin or false end, + function(v) GetAddonDB().openOnLogin = v end, + "Automatically opens the Upgrade Calculator window when you log in." + ); y = y - h + + --------------------------------------------------------------------------- + -- ACTIONS + --------------------------------------------------------------------------- + _, h = W:SectionHeader(parent, "ACTIONS", y); y = y - h + + local openBtnFrame + openBtnFrame, h = W:WideButton(parent, "Open Calculator", y, function() + local frame = _G["EUIUpgCalcFrame"] + if frame then + if frame:IsShown() then frame:Hide() else frame:Show() end + end + end) + local innerBtn = select(1, openBtnFrame:GetChildren()) + if innerBtn then + innerBtn:HookScript("OnEnter", function(self) + if EllesmereUI.ShowWidgetTooltip then + EllesmereUI.ShowWidgetTooltip(self, "Slash command: |cffffffff/euic|r") + end + end) + innerBtn:HookScript("OnLeave", function() + if EllesmereUI.HideWidgetTooltip then EllesmereUI.HideWidgetTooltip() end + end) + end + y = y - h + + _, h = W:WideButton(parent, "Clear Upgrade Cache", y, function() + if EUIUpgCalc and EUIUpgCalc.ClearCache then + EUIUpgCalc:ClearCache() + EllesmereUI.Print("|cff20ff20EUIItemCalc|r Cache cleared.") + end + end); y = y - h + + --------------------------------------------------------------------------- + -- FILTERS + --------------------------------------------------------------------------- + local PP = EllesmereUI.PanelPP + + local function LiveRefresh() + local fr = _G["EUIUpgCalcFrame"] + if fr and fr:IsShown() and EUIUpgCalc and EUIUpgCalc.PopulateGear then + EUIUpgCalc.PopulateGear() + end + end + + local SLOT_GROUP_ITEMS = { + { key = "Armour", label = "Armour" }, + { key = "Jewellery", label = "Jewellery" }, + { key = "Trinkets", label = "Trinkets" }, + { key = "Weapons", label = "Weapons" }, + } + + local CREST_TRACK_ITEMS = { + { key = "Adventurer", label = "Adventurer" }, + { key = "Veteran", label = "Veteran" }, + { key = "Champion", label = "Champion" }, + { key = "Hero", label = "Hero" }, + { key = "Myth", label = "Myth" }, + } + + _, h = W:SectionHeader(parent, "FILTERS", y); y = y - h + + -- Row 1: "Show Fully-Upgraded Items" toggle | "Slot Groups" checkbox dropdown + local slotRow, slotRowH = W:DualRow(parent, y, + { type = "toggle", text = "Show Fully-Upgraded Items", + tooltip = "Show gear tiles for items already at their maximum item level.", + getValue = function() return GetAddonDB().showMaxed or false end, + setValue = function(v) GetAddonDB().showMaxed = v; LiveRefresh() end }, + { type = "dropdown", text = "Slot Groups", + values = { __placeholder = "..." }, order = { "__placeholder" }, + getValue = function() return "__placeholder" end, + setValue = function() end } + ) + do + local rightRgn = slotRow._rightRegion + if rightRgn._control then rightRgn._control:Hide() end + local cbDD, cbDDRefresh = EllesmereUI.BuildVisOptsCBDropdown( + rightRgn, 210, rightRgn:GetFrameLevel() + 2, + SLOT_GROUP_ITEMS, + function(k) + local sf = GetAddonDB().slotFilter + return sf == nil or sf[k] ~= false + end, + function(k, v) + local db = GetAddonDB() + db.slotFilter = db.slotFilter or {} + db.slotFilter[k] = v + LiveRefresh() + end + ) + PP.Point(cbDD, "RIGHT", rightRgn, "RIGHT", -20, 0) + rightRgn._control = cbDD + rightRgn._lastInline = nil + EllesmereUI.RegisterWidgetRefresh(cbDDRefresh) + end + y = y - slotRowH + + -- Row 2: "Hide Crafted Items" toggle | "Crest Rows" checkbox dropdown + local crestRow, crestRowH = W:DualRow(parent, y, + { type = "toggle", text = "Hide Crafted Items", + tooltip = "Hide crafted items from the gear tile list.\nCrafted items cannot be upgraded at the Upgrade NPC.", + getValue = function() return GetAddonDB().hideCrafted or false end, + setValue = function(v) GetAddonDB().hideCrafted = v; LiveRefresh() end }, + { type = "dropdown", text = "Crest Rows", + values = { __placeholder = "..." }, order = { "__placeholder" }, + getValue = function() return "__placeholder" end, + setValue = function() end } + ) + do + local rightRgn = crestRow._rightRegion + if rightRgn._control then rightRgn._control:Hide() end + local cbDD, cbDDRefresh = EllesmereUI.BuildVisOptsCBDropdown( + rightRgn, 210, rightRgn:GetFrameLevel() + 2, + CREST_TRACK_ITEMS, + function(k) + local cf = GetAddonDB().crestFilter + return cf == nil or cf[k] ~= false + end, + function(k, v) + local db = GetAddonDB() + db.crestFilter = db.crestFilter or {} + db.crestFilter[k] = v + LiveRefresh() + end + ) + PP.Point(cbDD, "RIGHT", rightRgn, "RIGHT", -20, 0) + rightRgn._control = cbDD + rightRgn._lastInline = nil + EllesmereUI.RegisterWidgetRefresh(cbDDRefresh) + end + y = y - crestRowH + + _, h = W:Toggle(parent, + "Show Earned / Cap Column", + y, + function() return GetAddonDB().showEarnedCap or false end, + function(v) GetAddonDB().showEarnedCap = v; LiveRefresh() end, + "Show the seasonal Earned / Cap column in the crest table." + ); y = y - h + + --------------------------------------------------------------------------- + -- APPEARANCE + --------------------------------------------------------------------------- + _, h = W:SectionHeader(parent, "APPEARANCE", y); y = y - h + + _, h = W:Slider(parent, + "Background Opacity", + y, + 10, 100, 5, + function() return GetAddonDB().bgOpacity or 96 end, + function(v) + GetAddonDB().bgOpacity = v + if EUIUpgCalc and EUIUpgCalc.ApplyBgOpacity then + EUIUpgCalc.ApplyBgOpacity() + end + end, + "Controls how transparent the calculator window background is." + ); y = y - h + + _, h = W:Spacer(parent, y, 20); y = y - h + + parent:SetHeight(math.abs(y - yOffset)) + + return math.abs(y) +end + +-- Open-on-login hook +local loginFrame = CreateFrame("Frame") +loginFrame:RegisterEvent("PLAYER_LOGIN") +loginFrame:SetScript("OnEvent", function(self) + self:UnregisterEvent("PLAYER_LOGIN") + if GetAddonDB().openOnLogin then + C_Timer.After(1, function() + local fr = _G["EUIUpgCalcFrame"] + if fr then fr:Show() end + end) + end +end) + +-- Expose page builder for EUI_QoL_Options.lua +_G._EUI_BuildUpgradeCalcPage = BuildUpgradeCalcPage + +-- Expose reset helper for QoL onReset +_G._EUI_ResetUpgradeCalc = function() + if EUIUpgCalc and EUIUpgCalc.GetOptsDB then + local opts = EUIUpgCalc.GetOptsDB() + for k in pairs(opts) do opts[k] = nil end + elseif EllesmereUIQoLDB then + EllesmereUIQoLDB.upgradeCalcOpts = {} + end + if EUIUpgCalc and EUIUpgCalc.ClearCache then + EUIUpgCalc:ClearCache() + end + -- Also wipe the persisted queue and crest manual-add offsets. + if EUIUpgCalc and EUIUpgCalc.GetOptsDB then + local db = EUIUpgCalc.GetCalcDB and EUIUpgCalc.GetCalcDB() + if db then db.queue = {}; db.crestManualAdds = {} end + end +end diff --git a/EllesmereUIQoL/EllesmereUIQoL.toc b/EllesmereUIQoL/EllesmereUIQoL.toc index c4869f6a..d8c9b652 100644 --- a/EllesmereUIQoL/EllesmereUIQoL.toc +++ b/EllesmereUIQoL/EllesmereUIQoL.toc @@ -14,9 +14,11 @@ EllesmereUIQoL.lua EllesmereUIQoL_Cursor.lua EllesmereUIQoL_BattleRes.lua EllesmereUIQoL_AutoLogging.lua +EUI_UpgradeCalc.lua # Options EUI_QoL_Options.lua EUI_QoL_Cursor_Options.lua EUI_QoL_BattleRes_Options.lua EUI_QoL_AutoLogging_Options.lua +EUI_UpgradeCalc_Options.lua From 75bde59b1136cc79f0ef2c94ae4662ef6dc11669 Mon Sep 17 00:00:00 2001 From: Kneeull <156554816+Kneeull@users.noreply.github.com> Date: Fri, 15 May 2026 00:25:17 +0100 Subject: [PATCH 02/16] Remove all chat output to eliminate taint risk from print calls --- EllesmereUIQoL/EUI_UpgradeCalc.lua | 11 +---------- EllesmereUIQoL/EUI_UpgradeCalc_Options.lua | 1 - 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/EllesmereUIQoL/EUI_UpgradeCalc.lua b/EllesmereUIQoL/EUI_UpgradeCalc.lua index 6e36b0bc..1015dbba 100644 --- a/EllesmereUIQoL/EUI_UpgradeCalc.lua +++ b/EllesmereUIQoL/EUI_UpgradeCalc.lua @@ -435,18 +435,15 @@ end -- same tick, so a single-pass approach always misses most slots on the first run. function Calc:ScanEquippedAtUpgrader(onDone) if InCombatLockdown() then - EllesmereUI.Print("|cffff4444EUIItemCalc|r Cannot scan during combat.") if onDone then onDone(false) end return end if Calc._scanning then - EllesmereUI.Print("|cffff4444EUIItemCalc|r Scan already in progress.") if onDone then onDone(false) end return end local db = DB() if not self:IsUpgraderOpen() then - EllesmereUI.Print("|cffff4444EUIItemCalc|r Open the Item Upgrade window first.") if onDone then onDone(false) end return end @@ -464,8 +461,6 @@ function Calc:ScanEquippedAtUpgrader(onDone) if ok then db.cache = { slots = newSlots, ts = time() } db.calibrated = true - EllesmereUI.Print(string.format( - "|cff20ff20EUIItemCalc|r Scan complete (%d/%d). Costs are now accurate.", total, total)) end if onDone then onDone(ok) end end @@ -524,7 +519,6 @@ function Calc:ScanEquippedAtUpgrader(onDone) C_Timer.After(0.12, doSelectPass) end - EllesmereUI.Print("|cff20ff20EUIItemCalc|r Scanning equipped items at Upgrader...") doSelectPass() end @@ -1549,10 +1543,7 @@ end) SLASH_EUIUPGCALC1 = "/euic" SLASH_EUIUPGCALC2 = "/upgcalc" SlashCmdList["EUIUPGCALC"] = function() - if InCombatLockdown() then - EllesmereUI.Print("|cffff4444EUIItemCalc|r Cannot open during combat.") - return - end + if InCombatLockdown() then return end if f:IsShown() then f:Hide() else f:Show() end end diff --git a/EllesmereUIQoL/EUI_UpgradeCalc_Options.lua b/EllesmereUIQoL/EUI_UpgradeCalc_Options.lua index 8fd8419e..bb036ecc 100644 --- a/EllesmereUIQoL/EUI_UpgradeCalc_Options.lua +++ b/EllesmereUIQoL/EUI_UpgradeCalc_Options.lua @@ -63,7 +63,6 @@ local function BuildUpgradeCalcPage(pageName, parent, yOffset) _, h = W:WideButton(parent, "Clear Upgrade Cache", y, function() if EUIUpgCalc and EUIUpgCalc.ClearCache then EUIUpgCalc:ClearCache() - EllesmereUI.Print("|cff20ff20EUIItemCalc|r Cache cleared.") end end); y = y - h From f6778bac5ed067f235ac8ca755644d22ce699a18 Mon Sep 17 00:00:00 2001 From: Kneeull <156554816+Kneeull@users.noreply.github.com> Date: Fri, 15 May 2026 01:12:14 +0100 Subject: [PATCH 03/16] Fix Upgrader NPC scan: single-pass with correct API usage, exact discount costs, live slot refresh on upgrade --- EllesmereUIQoL/EUI_UpgradeCalc.lua | 174 +++++++++++++++-------------- 1 file changed, 88 insertions(+), 86 deletions(-) diff --git a/EllesmereUIQoL/EUI_UpgradeCalc.lua b/EllesmereUIQoL/EUI_UpgradeCalc.lua index 1015dbba..45abb903 100644 --- a/EllesmereUIQoL/EUI_UpgradeCalc.lua +++ b/EllesmereUIQoL/EUI_UpgradeCalc.lua @@ -109,7 +109,6 @@ local function DB() store.upgradeCalc = store.upgradeCalc or {} local db = store.upgradeCalc db.cache = db.cache or { slots = {}, ts = 0 } - db.discounts = db.discounts or {} db.calibrated = db.calibrated or false db.queue = db.queue or {} db.crestManualAdds = db.crestManualAdds or {} @@ -367,25 +366,10 @@ function Calc:GetItemUpgradeCost(item) return track, rank, exactCrests, exactGold, expectedMax end - -- Priority 2: estimate using watermark discount data. - -- SEASON UPDATE: 20 crests per upgrade step is the MN S1/S2 constant. - -- Update if Blizzard changes the crest cost per rank in a future season. - local rankCap = #td.ranks - local upgradesLeft = rankCap - rank - local crestCost = upgradesLeft * 20 - - if db.calibrated and td.currID and td.currID > 0 then - local wm = self:GetDiscountWatermark(item.slot, td.currID) - if wm and wm > 0 then - crestCost = 0 - for step = rank + 1, rankCap do - if (td.ranks[step] or 0) > wm then crestCost = crestCost + 20 end - end - end - end - - -- Priority 3: raw estimate. - return track, rank, crestCost, upgradesLeft * td.goldPer, expectedMax + -- Priority 2: raw estimate (upgrader scan not available for this slot). + -- SEASON UPDATE: full price = 20 crests/step. Update if Blizzard changes this. + local upgradesLeft = #td.ranks - rank + return track, rank, upgradesLeft * 20, upgradesLeft * td.goldPer, expectedMax end function Calc:IsUpgraderOpen() @@ -407,32 +391,26 @@ local function SelectSlotInUpgrader(loc) end local function TallySlotCosts(info) - local crestAmounts, copper, watermarks = {}, 0, {} + local crestAmounts, copper = {}, 0 local curr = info.currUpgrade or 0 for _, lvl in ipairs(info.upgradeLevelInfos or {}) do if (lvl.upgradeLevel or 0) > curr then copper = copper + (lvl.moneyCost or 0) for _, cc in ipairs(lvl.currencyCostsToUpgrade or {}) do + -- cc.cost is already the correct amount to pay (Blizzard returns 10 for + -- discounted steps, 20 for full-price steps). No halving needed here. crestAmounts[cc.currencyID] = (crestAmounts[cc.currencyID] or 0) + (cc.cost or 0) - local di = cc.discountInfo - if di and (di.discountHighWatermark or 0) > 0 then - watermarks[cc.currencyID] = di.discountHighWatermark - end end end end - return crestAmounts, copper, watermarks + return crestAmounts, copper end -- Scans every equipped slot at the Upgrader NPC, building an accurate cost cache. --- Two-pass design: --- Pass 1 (select): cycles through all slots via SelectSlotInUpgrader so the --- Upgrader frame loads each item's upgrade cost data into the client cache. --- Pass 2 (collect): after a short wait, reads GetItemUpgradeItemInfo for every --- slot — data is now reliably present for all slots. --- Without the pre-warm, GetItemUpgradeItemInfo returns nil for slots not already --- shown in the frame; the select triggers an async load that isn't ready on the --- same tick, so a single-pass approach always misses most slots on the first run. +-- Single-pass: selects each slot via SelectSlotInUpgrader, waits 0.3 s for the +-- Upgrader frame to populate async data for that slot, then reads the result via +-- C_ItemUpgrade.GetItemUpgradeItemInfo() (no arguments — returns data for the +-- currently-selected slot). Passing a loc argument is silently ignored by the API. function Calc:ScanEquippedAtUpgrader(onDone) if InCombatLockdown() then if onDone then onDone(false) end @@ -467,73 +445,84 @@ function Calc:ScanEquippedAtUpgrader(onDone) local function saveSlotInfo(slotID, info) if not (info and info.upgradeLevelInfos) then return end - local crestAmounts, copperTotal, watermarks = TallySlotCosts(info) + local crestAmounts, copperTotal = TallySlotCosts(info) newSlots[slotID] = { - trackName = info.customUpgradeString, - rankCurrent = info.currUpgrade, - rankMax = info.maxUpgrade, - ilvlCap = info.maxItemLevel, crestAmounts = crestAmounts, copperTotal = copperTotal, } - if next(watermarks) then - db.discounts[slotID] = db.discounts[slotID] or {} - for cid, wm in pairs(watermarks) do - local prev = db.discounts[slotID][cid] or 0 - if wm > prev then db.discounts[slotID][cid] = wm end - end - end end - -- Pass 2: harvest upgrade data for every slot (all now in client cache). - local function doCollectPass() - local ci = 1 - local function collectNext() - if InCombatLockdown() then onScanDone(false); return end - if ci > total then onScanDone(true); return end - local slotID = slots[ci] - local loc = ItemLocation and ItemLocation:CreateFromEquipmentSlot(slotID) - if loc then - local info = C_ItemUpgrade and C_ItemUpgrade.GetItemUpgradeItemInfo - and C_ItemUpgrade.GetItemUpgradeItemInfo(loc) - saveSlotInfo(slotID, info) - end - ci = ci + 1 - C_Timer.After(0.05, collectNext) - end - collectNext() - end - - -- Pass 1: select each slot so the Upgrader frame loads its cost data async. - -- After all slots are selected, wait 0.5 s before collecting. + -- Single-pass scan: select each slot, wait 0.3 s for the Upgrader frame to + -- populate async data, then call GetItemUpgradeItemInfo() with NO arguments + -- (the API returns data for whichever slot is currently selected; passing a + -- loc argument is silently ignored and the call returns nil). local si = 1 - local function doSelectPass() + local function scanNext() if InCombatLockdown() then onScanDone(false); return end - if si > total then - C_Timer.After(0.5, doCollectPass) - return + if si > total then onScanDone(true); return end + local slotID = slots[si] + local loc = ItemLocation and ItemLocation:CreateFromEquipmentSlot(slotID) + if loc and SelectSlotInUpgrader(loc) then + -- Wait for the Upgrader frame to load this slot's data, then read it. + C_Timer.After(0.3, function() + if InCombatLockdown() then onScanDone(false); return end + local info = C_ItemUpgrade and C_ItemUpgrade.GetItemUpgradeItemInfo + and C_ItemUpgrade.GetItemUpgradeItemInfo() + saveSlotInfo(slotID, info) + si = si + 1 + scanNext() + end) + else + -- Slot has no item or select failed; skip it. + si = si + 1 + C_Timer.After(0.05, scanNext) end - local loc = ItemLocation and ItemLocation:CreateFromEquipmentSlot(slots[si]) - if loc then SelectSlotInUpgrader(loc) end - si = si + 1 - C_Timer.After(0.12, doSelectPass) end - doSelectPass() -end - -function Calc:GetDiscountWatermark(slotID, currencyID) - local t = DB().discounts[slotID] - return (t and t[currencyID]) or 0 + scanNext() end function Calc:ClearCache() local db = DB() - db.cache = { slots = {}, ts = 0 } - db.discounts = {} + db.cache = { slots = {}, ts = 0 } db.calibrated = false end +-- Rescans a single slot at the Upgrader NPC and updates the cache entry for it. +-- Used after an upgrade so the display reflects the new remaining cost immediately. +-- Calls onDone(ok) when finished; ok=false if the NPC is closed, combat fires, or +-- the API returns no data for the slot. +function Calc:RescanSlot(slotID, onDone) + if InCombatLockdown() or Calc._scanning then + if onDone then onDone(false) end; return + end + if not self:IsUpgraderOpen() then + if onDone then onDone(false) end; return + end + local loc = ItemLocation and ItemLocation:CreateFromEquipmentSlot(slotID) + if not loc or not SelectSlotInUpgrader(loc) then + if onDone then onDone(false) end; return + end + local db = DB() + C_Timer.After(0.3, function() + if InCombatLockdown() then + if onDone then onDone(false) end; return + end + local info = C_ItemUpgrade and C_ItemUpgrade.GetItemUpgradeItemInfo + and C_ItemUpgrade.GetItemUpgradeItemInfo() + if info and info.upgradeLevelInfos then + local crestAmounts, copperTotal = TallySlotCosts(info) + db.cache = db.cache or { slots = {}, ts = 0 } + db.cache.slots = db.cache.slots or {} + db.cache.slots[slotID] = { + crestAmounts = crestAmounts, + copperTotal = copperTotal, + } + end + if onDone then onDone(true) end + end) +end + ------------------------------------------------------------------------------- -- UI ------------------------------------------------------------------------------- @@ -1461,6 +1450,7 @@ end) scanBtn:HookScript("OnEnter", function(self) if EUI.ShowWidgetTooltip then local tip = "Scan all equipped gear costs at the Item Upgrade NPC.\n" + .. "Scans each slot one at a time — this can take up to 10 seconds.\n" .. "Requires the Item Upgrade window to be open." if not Calc:IsUpgraderOpen() then tip = tip .. "\n|cffff6060Item Upgrade window is not open.|r" @@ -1502,9 +1492,23 @@ equipListener:SetScript("OnEvent", function(_, event, slotID) else Calc._tipCache = {} end + if not f:IsShown() then return end + -- If the Upgrader NPC is open and we have a valid slot ID, rescan just that + -- slot so the exact post-upgrade cost is shown without a full re-scan. + if slotID and slotID > 0 and Calc:IsUpgraderOpen() and not Calc._scanning then + -- Invalidate the slot's cache entry so PopulateGear won't stale-serve + -- the old data while the rescan is in flight. + local db = DB() + if db.cache and db.cache.slots then + db.cache.slots[slotID] = nil + end + Calc:RescanSlot(slotID, function() + if f:IsShown() then PopulateGear() end + end) + return + end -- Debounce: wait 0.3 s after the last equip event before refreshing. -- This prevents hammering PopulateGear when the user swaps multiple pieces. - if not f:IsShown() then return end if _equipDebounce then _equipDebounce:Cancel() end _equipDebounce = C_Timer.NewTimer(0.3, function() _equipDebounce = nil @@ -1565,7 +1569,6 @@ _firstRunEvt:SetScript("OnEvent", function(self) upgradeCalcOpts = {}, upgradeCalc = { cache = { slots = {}, ts = 0 }, - discounts = {}, calibrated = false, }, }, @@ -1577,7 +1580,6 @@ _firstRunEvt:SetScript("OnEvent", function(self) store.upgradeCalc = store.upgradeCalc or {} local db = store.upgradeCalc db.cache = db.cache or { slots = {}, ts = 0 } - db.discounts = db.discounts or {} db.calibrated = db.calibrated or false db.queue = db.queue or {} db.crestManualAdds = db.crestManualAdds or {} From f838a60111e2458373077b62ece7f4663dde743d Mon Sep 17 00:00:00 2001 From: Kneeull <156554816+Kneeull@users.noreply.github.com> Date: Fri, 15 May 2026 01:22:55 +0100 Subject: [PATCH 04/16] Fix RescanSlot race: set _scanning during async window to prevent concurrent slot overwrites --- EllesmereUIQoL/EUI_UpgradeCalc.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/EllesmereUIQoL/EUI_UpgradeCalc.lua b/EllesmereUIQoL/EUI_UpgradeCalc.lua index 45abb903..cb22dcfd 100644 --- a/EllesmereUIQoL/EUI_UpgradeCalc.lua +++ b/EllesmereUIQoL/EUI_UpgradeCalc.lua @@ -503,8 +503,12 @@ function Calc:RescanSlot(slotID, onDone) if not loc or not SelectSlotInUpgrader(loc) then if onDone then onDone(false) end; return end + -- Guard the async window with _scanning so a second upgrade event within + -- 0.3 s can't launch a concurrent rescan and overwrite the wrong slot. + Calc._scanning = true local db = DB() C_Timer.After(0.3, function() + Calc._scanning = false if InCombatLockdown() then if onDone then onDone(false) end; return end From 91d22352e6085c8858875455260715632d9763c5 Mon Sep 17 00:00:00 2001 From: Kneeull <156554816+Kneeull@users.noreply.github.com> Date: Fri, 15 May 2026 01:42:57 +0100 Subject: [PATCH 05/16] Audit fixes: data-driven rank caps, queue ref staleness, phantom row, _equipDebounce upvalue, tileFrames bound comment --- EllesmereUIQoL/EUI_UpgradeCalc.lua | 40 ++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/EllesmereUIQoL/EUI_UpgradeCalc.lua b/EllesmereUIQoL/EUI_UpgradeCalc.lua index cb22dcfd..32be0abd 100644 --- a/EllesmereUIQoL/EUI_UpgradeCalc.lua +++ b/EllesmereUIQoL/EUI_UpgradeCalc.lua @@ -299,6 +299,7 @@ function Calc:IsVoidforged(link) end -- Returns the ilvl gain from the next upgrade step, or nil if already at max. +-- (Reserved for future use; not currently called by PopulateGear.) function Calc:GetNextUpgradeGain(item) local track, rank = self:GetItemTrackAndRank(item.link) if not track or not rank then return nil end @@ -1126,9 +1127,9 @@ PopulateGear = function() du = "Crafted" end elseif track then - totalMissing = totalMissing + (6 - (rank or 0)) - totalGold = totalGold + (goldCost or 0) local td = Data.tracks[track] + totalMissing = totalMissing + ((td and #td.ranks or 6) - (rank or 0)) + totalGold = totalGold + (goldCost or 0) local cn_map = slotCrestMap[item.slot] if cn_map then for cn, amt in pairs(cn_map) do @@ -1204,6 +1205,24 @@ PopulateGear = function() end end + -- On every refresh, sync queue item references to the current tileEntries so + -- UpdateQueueDisplay shows live costs rather than costs snapshotted at session open. + -- Safe to run on the first PopulateGear call too (entries are already fresh then). + if #queueItems > 0 then + local slotToEntry = {} + for _, e in ipairs(tileEntries) do slotToEntry[e.slotID] = e end + local newQueue, newSet = {}, {} + for _, old in ipairs(queueItems) do + local fresh = slotToEntry[old.slotID] + if fresh then + newQueue[#newQueue + 1] = fresh + newSet[fresh.slotName] = #newQueue + end + end + queueItems = newQueue + queueSlotSet = newSet + end + -- Timeline bar local curAvg = select(2, GetAverageItemLevel()) or 0 -- Blizzard counts 2H weapons as two slots; clamp so max never shows below current. @@ -1222,7 +1241,7 @@ PopulateGear = function() local needsCount = 0 for _, e in ipairs(tileEntries) do if not e.isAtMax then needsCount = needsCount + 1 end end local maxCount = #tileEntries - needsCount - local needsRows = math.ceil(math.max(1, needsCount) / TILE_COLS) + local needsRows = needsCount > 0 and math.ceil(needsCount / TILE_COLS) or 0 if needsCount > 0 then sHdrNeeds:ClearAllPoints() @@ -1260,7 +1279,7 @@ PopulateGear = function() local ni, mi = 0, 0 for idx, entry in ipairs(tileEntries) do - if idx > 18 then break end + if idx > #tileFrames then break end -- tileFrames has 18 slots; equipSlots has 16 local btn = tileFrames[idx] local tx, ty if not entry.isAtMax then @@ -1482,6 +1501,13 @@ scanBtn:SetScript("OnClick", function() end) end) +-- Debounce timer handle for PLAYER_EQUIPMENT_CHANGED: coalesces rapid gear swaps +-- (e.g. multiple pieces at once) into a single PopulateGear call. +-- Declared before equipListener so both the OnEvent and OnHide closures capture +-- the same upvalue (declaring it after would cause OnEvent to use the global slot +-- instead, making OnHide unable to cancel a pending debounce timer). +local _equipDebounce = nil + local equipListener = CreateFrame("Frame") equipListener:SetScript("OnEvent", function(_, event, slotID) if event == "PLAYER_REGEN_DISABLED" then @@ -1521,9 +1547,6 @@ equipListener:SetScript("OnEvent", function(_, event, slotID) end) -- Show / Hide --- Debounce timer handle for PLAYER_EQUIPMENT_CHANGED: coalesces rapid gear swaps --- (e.g. multiple pieces at once) into a single PopulateGear call. -local _equipDebounce = nil f:SetScript("OnShow", function() if InCombatLockdown() then f:Hide(); return end @@ -1601,7 +1624,8 @@ _firstRunEvt:SetScript("OnEvent", function(self) local link = GetInventoryItemLink("player", slotID) if link then local r = Calc:ScanItemLink(link) - if r and r.track and (r.rank or 0) < 6 then + local td = r and r.track and Data.tracks[r.track] + if td and (r.rank or 0) < #td.ranks then tracksNeeded[r.track] = true end end From 2a807e04b93fb3128413ce968353334510ba5f55 Mon Sep 17 00:00:00 2001 From: Kneeull <156554816+Kneeull@users.noreply.github.com> Date: Fri, 15 May 2026 01:54:57 +0100 Subject: [PATCH 06/16] Make crafted band thresholds data-driven via Data.craftedBands (season update single-location) --- EllesmereUIQoL/EUI_UpgradeCalc.lua | 39 +++++++++++++++++++----------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/EllesmereUIQoL/EUI_UpgradeCalc.lua b/EllesmereUIQoL/EUI_UpgradeCalc.lua index 32be0abd..3fa49fd8 100644 --- a/EllesmereUIQoL/EUI_UpgradeCalc.lua +++ b/EllesmereUIQoL/EUI_UpgradeCalc.lua @@ -84,6 +84,19 @@ end -- Referenced in GetCraftedInfo; hoisted here so it is allocated once, not per call. Data.craftedTierSteps = { 246, 249, 252, 255, 259 } +-- SEASON UPDATE: update minIlvl/maxIlvl each new season to match crafted item ilvl caps. +-- minIlvl: lowest ilvl at which an item belongs to this crafted band (checked highest-first). +-- maxIlvl: ilvl ceiling for crafted items in this band. +-- Ordered highest-first so CraftedBandFromIlvl can short-circuit on the first match. +Data.craftedBands = { + { name = "Myth", minIlvl = 272, maxIlvl = 285 }, + { name = "Hero", minIlvl = 259, maxIlvl = 272 }, + { name = "None", minIlvl = 0, maxIlvl = 259 }, +} +-- Reverse lookup: band name → band data (for O(1) access in ScanItemLink). +Data.craftedBandByName = {} +for _, b in ipairs(Data.craftedBands) do Data.craftedBandByName[b.name] = b end + ------------------------------------------------------------------------------- -- CORE ------------------------------------------------------------------------------- @@ -231,13 +244,10 @@ function Calc:ScanItemLink(link) result.isCrafted = isCrafted if isCrafted then - if sawMyth then - result.craftBand = "Myth"; result.craftMaxIlvl = 285 - elseif sawHero then - result.craftBand = "Hero"; result.craftMaxIlvl = 272 - else - result.craftBand = "None"; result.craftMaxIlvl = 259 - end + local bname = sawMyth and "Myth" or sawHero and "Hero" or "None" + local bdata = Data.craftedBandByName[bname] + result.craftBand = bname + result.craftMaxIlvl = bdata and bdata.maxIlvl or 259 end end @@ -254,15 +264,16 @@ end -- Shared helper: given an ilvl, returns band ("Myth"/"Hero"/"None") and maxIlvl. -- Called by both GetCraftedInfo (tooltip fallback) and the PopulateGear heuristic -- so the two code paths can never silently diverge on a season update. --- SEASON UPDATE: update these thresholds and maxIlvl caps each new season. +-- Thresholds are read from Data.craftedBands — update that table each new season. local function CraftedBandFromIlvl(ilvl) - if ilvl >= 272 then - return "Myth", math.max(285, ilvl) - elseif ilvl >= 259 then - return "Hero", math.max(272, ilvl) - else - return "None", math.max(259, ilvl) + for _, band in ipairs(Data.craftedBands) do + if ilvl >= band.minIlvl then + return band.name, math.max(band.maxIlvl, ilvl) + end end + -- Fallback: treat as base crafted (should never be reached; ilvl < 0 is impossible). + local base = Data.craftedBandByName["None"] + return "None", math.max(base and base.maxIlvl or 259, ilvl) end -- Returns: isCrafted, band, tier, maxIlvl. From 2bbd2ad87166f4cc13db26b7b37ff3a8bfc2de55 Mon Sep 17 00:00:00 2001 From: Kneeull <156554816+Kneeull@users.noreply.github.com> Date: Fri, 15 May 2026 02:08:43 +0100 Subject: [PATCH 07/16] Add weekly remaining column, fix queue panel not showing on open/relog --- EllesmereUIQoL/EUI_UpgradeCalc.lua | 57 +++++++++++++++++----- EllesmereUIQoL/EUI_UpgradeCalc_Options.lua | 8 +++ 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/EllesmereUIQoL/EUI_UpgradeCalc.lua b/EllesmereUIQoL/EUI_UpgradeCalc.lua index 3fa49fd8..ddecb4bf 100644 --- a/EllesmereUIQoL/EUI_UpgradeCalc.lua +++ b/EllesmereUIQoL/EUI_UpgradeCalc.lua @@ -319,7 +319,7 @@ function Calc:GetNextUpgradeGain(item) return (td.ranks[rank + 1] or 0) - (td.ranks[rank] or 0) end --- Returns a table mapping crestName -> { quantity, cap, earned } for each track. +-- Returns a table mapping crestName -> { quantity, cap, earned, weeklyEarned, weeklyCap } for each track. function Calc:GetPlayerCrests() local owned = {} for _, td in pairs(Data.tracks) do @@ -327,9 +327,11 @@ function Calc:GetPlayerCrests() local info = C_CurrencyInfo.GetCurrencyInfo(td.currID) if info then owned[td.crestName] = { - quantity = info.quantity or 0, - cap = (info.maxQuantity and info.maxQuantity > 0) and info.maxQuantity or nil, - earned = info.totalEarned or 0, + quantity = info.quantity or 0, + cap = (info.maxQuantity and info.maxQuantity > 0) and info.maxQuantity or nil, + earned = info.totalEarned or 0, + weeklyEarned = info.quantityEarnedThisWeek or 0, + weeklyCap = (info.maxWeeklyQuantity and info.maxWeeklyQuantity > 0) and info.maxWeeklyQuantity or nil, } end end @@ -568,11 +570,12 @@ local TRACK_RGB = { } local CREST_COLS = { - {key="crest", label="Crest", x=0, w=150, align="LEFT" }, - {key="need", label="Need", x=150, w=70, align="CENTER"}, - {key="owned", label="Owned", x=220, w=70, align="CENTER"}, - {key="missing", label="Missing", x=290, w=70, align="CENTER"}, - {key="cap", label="Earned / Cap", x=360, w=140, align="CENTER"}, + {key="crest", label="Crest", x=0, w=150, align="LEFT" }, + {key="need", label="Need", x=150, w=70, align="CENTER"}, + {key="owned", label="Owned", x=220, w=70, align="CENTER"}, + {key="missing", label="Missing", x=290, w=70, align="CENTER"}, + {key="cap", label="Earned / Cap", x=360, w=130, align="CENTER"}, + {key="weeklyRem", label="Left This Week", x=490, w=110, align="CENTER"}, } local f = CreateFrame("Frame", "EUIUpgCalcFrame", UIParent) @@ -1005,6 +1008,13 @@ capHdrLbl:SetJustifyH(CREST_COLS[5].align) capHdrLbl:SetText(CREST_COLS[5].label) capHdrLbl:Hide() +local weeklyRemHdrLbl = MFont(crestSection, 10, "OUTLINE", G.r, G.g, G.b, 1) +PP.Point(weeklyRemHdrLbl, "TOPLEFT", crestSection, "TOPLEFT", CREST_COLS[6].x + 4, -2) +PP.Width(weeklyRemHdrLbl, CREST_COLS[6].w) +weeklyRemHdrLbl:SetJustifyH(CREST_COLS[6].align) +weeklyRemHdrLbl:SetText(CREST_COLS[6].label) +weeklyRemHdrLbl:Hide() + -- Forward-declared so crest-row +/-80 buttons can call it. local PopulateGear @@ -1070,7 +1080,9 @@ end -- Summary label and action buttons — initially anchored at y=0; repositioned -- every PopulateGear call to sit below the last visible crest row. local summaryLbl = MFont(crestSection, 11, "OUTLINE", G.r, G.g, G.b, 1) -PP.Point(summaryLbl, "TOPLEFT", crestSection, "TOPLEFT", 4, 0) +PP.Point(summaryLbl, "TOPLEFT", crestSection, "TOPLEFT", 4, 0) +PP.Point(summaryLbl, "TOPRIGHT", crestSection, "TOPRIGHT", 0, 0) +summaryLbl:SetJustifyH("LEFT") summaryLbl:SetText("Missing Upgrades: - Gold Needed: -") local refreshBtn = MakeButton(crestSection, "Refresh", 140, 22, 0, 0) @@ -1378,8 +1390,10 @@ PopulateGear = function() -- Crest table — compact visible rows to consecutive y positions so that -- filtered-out rows leave no gap, and +/-80 buttons/summary/action buttons -- always appear immediately below the last visible row. - local showCap = opts.showEarnedCap - if showCap then capHdrLbl:Show() else capHdrLbl:Hide() end + local showCap = opts.showEarnedCap + local showWeeklyRem = opts.showWeeklyRemaining + if showCap then capHdrLbl:Show() else capHdrLbl:Hide() end + if showWeeklyRem then weeklyRemHdrLbl:Show() else weeklyRemHdrLbl:Hide() end local visualIdx = 0 for i, trackName in ipairs(Data.trackOrder) do local td = Data.tracks[trackName] @@ -1428,12 +1442,26 @@ PopulateGear = function() else rowFrame.cap:Hide() end + if showWeeklyRem then + local wCap = info and info.weeklyCap + local wEarn = info and info.weeklyEarned or 0 + if wCap then + local rem = math.max(0, wCap - wEarn) + local remStr = rem > 0 and tostring(rem) or "|cff20ff200|r" + rowFrame.weeklyRem:SetText(remStr) + else + rowFrame.weeklyRem:SetText("-") + end + rowFrame.weeklyRem:Show() + else + rowFrame.weeklyRem:Hide() + end rowFrame.crest:Show(); rowFrame.need:Show() rowFrame.owned:Show(); rowFrame.missing:Show() visualIdx = visualIdx + 1 else rowFrame.crest:Hide(); rowFrame.need:Hide() - rowFrame.owned:Hide(); rowFrame.missing:Hide(); rowFrame.cap:Hide() + rowFrame.owned:Hide(); rowFrame.missing:Hide(); rowFrame.cap:Hide(); rowFrame.weeklyRem:Hide() if rowFrame.altBg then rowFrame.altBg:Hide() end if rowFrame.mBtn then rowFrame.mBtn:Hide() @@ -1465,6 +1493,9 @@ PopulateGear = function() summaryLbl:SetText(string.format( "Missing Upgrades: |cffffffff%d|r%s Gold: |cffffffff%dg|r", totalMissing, crestStr, totalGold)) + + -- Sync queue panel text to match restored/refreshed queue state. + UpdateQueueDisplay() end refreshBtn:SetScript("OnClick", PopulateGear) diff --git a/EllesmereUIQoL/EUI_UpgradeCalc_Options.lua b/EllesmereUIQoL/EUI_UpgradeCalc_Options.lua index bb036ecc..77c56eed 100644 --- a/EllesmereUIQoL/EUI_UpgradeCalc_Options.lua +++ b/EllesmereUIQoL/EUI_UpgradeCalc_Options.lua @@ -173,6 +173,14 @@ local function BuildUpgradeCalcPage(pageName, parent, yOffset) "Show the seasonal Earned / Cap column in the crest table." ); y = y - h + _, h = W:Toggle(parent, + "Show Weekly Remaining Column", + y, + function() return GetAddonDB().showWeeklyRemaining or false end, + function(v) GetAddonDB().showWeeklyRemaining = v; LiveRefresh() end, + "Show how many crests you can still earn this week (weekly cap minus earned so far)." + ); y = y - h + --------------------------------------------------------------------------- -- APPEARANCE --------------------------------------------------------------------------- From d0e2fc327ef1c84c0c0418a51b9ba9b7564f569f Mon Sep 17 00:00:00 2001 From: Kneeull <156554816+Kneeull@users.noreply.github.com> Date: Fri, 15 May 2026 02:14:01 +0100 Subject: [PATCH 08/16] Audit: TILE_STEP constant, dead crestY branch removed, dead maxRows removed --- EllesmereUIQoL/EUI_UpgradeCalc.lua | 55 ++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/EllesmereUIQoL/EUI_UpgradeCalc.lua b/EllesmereUIQoL/EUI_UpgradeCalc.lua index ddecb4bf..9c2d5abb 100644 --- a/EllesmereUIQoL/EUI_UpgradeCalc.lua +++ b/EllesmereUIQoL/EUI_UpgradeCalc.lua @@ -552,10 +552,12 @@ local G = EUI.ELLESMERE_GREEN local ROW_H, HDR_H, FRAME_W, FRAME_H = 20, 20, 860, 730 -- Tile layout constants -local TILE_W, TILE_H = 183, 50 -local TILE_COLS = 3 -local TILE_GAP = 5 -local TILE_ROW_W = TILE_COLS * TILE_W + (TILE_COLS - 1) * TILE_GAP -- 559 +local TILE_W = 183 +local TILE_H = 50 -- tile frame height +local TILE_STEP = TILE_H + 5 -- row stride: tile height + gap (used in all layout math) +local TILE_COLS = 3 +local TILE_GAP = 5 +local TILE_ROW_W = TILE_COLS * TILE_W + (TILE_COLS - 1) * TILE_GAP -- 559 local QUEUE_X_OFF = TILE_ROW_W + 16 -- 575 local QUEUE_W = FRAME_W - 20 - QUEUE_X_OFF -- 265 @@ -1103,9 +1105,29 @@ end PopulateGear = function() local gear = Calc:GetEquippedGear() local owned = Calc:GetPlayerCrests() - local totalMissing, totalGold, crestNeeds, maxTotal = 0, 0, {}, 0 + local totalMissing, totalGold, crestNeeds = 0, 0, {} local tileEntries = {} + -- Pre-pass: compute the theoretical max ilvl across ALL equipped slots, + -- deliberately ignoring slotFilter and hideCrafted so the timeline bar and + -- "Max Possible" stat always reflect the full character potential. + -- Running this first also warms _tipCache so the display loop below pays no + -- extra tooltip scanning cost. + local maxTotal = 0 + for _, item in ipairs(gear) do + local pOk, _, _, _, _, maxIlvl = pcall(Calc.GetItemUpgradeCost, Calc, item) + if pOk and type(maxIlvl) == "number" then + maxTotal = maxTotal + maxIlvl + elseif Calc:IsVoidforged(item.link) then + maxTotal = maxTotal + item.ilvl + elseif item.ilvl >= 200 then + local _, maxI = CraftedBandFromIlvl(item.ilvl) + maxTotal = maxTotal + maxI + else + maxTotal = maxTotal + item.ilvl + end + end + -- Pre-build per-slot crest breakdown from scan data. -- Done once here from a single DB snapshot so every item in the loop -- sees a consistent view of the cache with no per-item DB reads. @@ -1183,7 +1205,6 @@ PopulateGear = function() end if shouldAdd then - maxTotal = maxTotal + (type(dm) == "number" and dm or item.ilvl) tileEntries[#tileEntries + 1] = { slotName = sn, slotID = item.slot, ilvl = item.ilvl, max = dm, @@ -1275,7 +1296,7 @@ PopulateGear = function() sHdrNeeds:SetText(""); sHdrNeeds:Hide() end - local atMaxHdrY = -36 - (needsCount > 0 and needsRows * 55 + 18 or 0) + local atMaxHdrY = -36 - (needsCount > 0 and needsRows * TILE_STEP + 18 or 0) if maxCount > 0 and showMaxed then groupSepLine:ClearAllPoints() PP.Point(groupSepLine, "TOPLEFT", cc, "TOPLEFT", 0, atMaxHdrY - 2) @@ -1292,11 +1313,11 @@ PopulateGear = function() local function getTilePos(group_start_y, local_idx) local row = math.floor(local_idx / TILE_COLS) local col = local_idx % TILE_COLS - return col * (TILE_W + TILE_GAP), group_start_y - row * 55 + return col * (TILE_W + TILE_GAP), group_start_y - row * TILE_STEP end - local needsStartY = -54 - local atMaxStartY = needsStartY - needsRows * 55 - (needsCount > 0 and 34 or 18) + local needsStartY = -54 -- below timeline bar(16) + labels(18) + section header(20) + local atMaxStartY = needsStartY - needsRows * TILE_STEP - (needsCount > 0 and 34 or 18) for _, btn in ipairs(tileFrames) do btn:Hide() end @@ -1351,15 +1372,13 @@ PopulateGear = function() end -- Reposition the crest section immediately below the last rendered tile row. - -- needsRows already computed above; maxRows only adds if showMaxed is on. - local maxRows = (showMaxed and maxCount > 0) and math.ceil(maxCount / TILE_COLS) or 0 - local totalTileRows = needsRows + maxRows - -- tile area starts at cc y=-54 and each row is 55px; add 20px gap before crest section - local crestY = -54 - totalTileRows * 55 - 20 - -- atMaxHdrY already accounts for the header gap; push crest below it when showing maxed + local crestY if showMaxed and maxCount > 0 then - local atMaxRows = math.ceil(maxCount / TILE_COLS) - crestY = atMaxStartY - atMaxRows * 55 - 20 + -- atMaxStartY is where the at-max group starts; offset by its rows + gap. + crestY = atMaxStartY - math.ceil(maxCount / TILE_COLS) * TILE_STEP - 20 + else + -- Only needs-upgrade tiles are visible (or none at all). + crestY = needsStartY - needsRows * TILE_STEP - 20 end crestSection:ClearAllPoints() PP.Point(crestSection, "TOPLEFT", cc, "TOPLEFT", 0, crestY) From e81deedc73e8ba3aaa327c04ed110e0e8275a751 Mon Sep 17 00:00:00 2001 From: Kneeull <156554816+Kneeull@users.noreply.github.com> Date: Fri, 15 May 2026 02:16:38 +0100 Subject: [PATCH 09/16] Fix: weeklyCap field name wrong (maxWeeklyQuantity -> maxEarnablePerWeek) --- EllesmereUIQoL/EUI_UpgradeCalc.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EllesmereUIQoL/EUI_UpgradeCalc.lua b/EllesmereUIQoL/EUI_UpgradeCalc.lua index 9c2d5abb..e7b5ef48 100644 --- a/EllesmereUIQoL/EUI_UpgradeCalc.lua +++ b/EllesmereUIQoL/EUI_UpgradeCalc.lua @@ -331,7 +331,7 @@ function Calc:GetPlayerCrests() cap = (info.maxQuantity and info.maxQuantity > 0) and info.maxQuantity or nil, earned = info.totalEarned or 0, weeklyEarned = info.quantityEarnedThisWeek or 0, - weeklyCap = (info.maxWeeklyQuantity and info.maxWeeklyQuantity > 0) and info.maxWeeklyQuantity or nil, + weeklyCap = (info.maxEarnablePerWeek and info.maxEarnablePerWeek > 0) and info.maxEarnablePerWeek or nil, } end end From 18b048cc63bc3fb1c3d4bec4a0577a9a772dbf48 Mon Sep 17 00:00:00 2001 From: Kneeull <156554816+Kneeull@users.noreply.github.com> Date: Fri, 15 May 2026 02:25:48 +0100 Subject: [PATCH 10/16] Fix: weeklyCap field (maxWeeklyQuantity); remove dead dc/FormatCost/cost/rank from tileEntry --- EllesmereUIQoL/EUI_UpgradeCalc.lua | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/EllesmereUIQoL/EUI_UpgradeCalc.lua b/EllesmereUIQoL/EUI_UpgradeCalc.lua index e7b5ef48..30a33a51 100644 --- a/EllesmereUIQoL/EUI_UpgradeCalc.lua +++ b/EllesmereUIQoL/EUI_UpgradeCalc.lua @@ -331,7 +331,7 @@ function Calc:GetPlayerCrests() cap = (info.maxQuantity and info.maxQuantity > 0) and info.maxQuantity or nil, earned = info.totalEarned or 0, weeklyEarned = info.quantityEarnedThisWeek or 0, - weeklyCap = (info.maxEarnablePerWeek and info.maxEarnablePerWeek > 0) and info.maxEarnablePerWeek or nil, + weeklyCap = (info.canEarnPerWeek and info.maxWeeklyQuantity > 0) and info.maxWeeklyQuantity or nil, } end end @@ -1090,17 +1090,6 @@ summaryLbl:SetText("Missing Upgrades: - Gold Needed: -") local refreshBtn = MakeButton(crestSection, "Refresh", 140, 22, 0, 0) local scanBtn, scanBtnTxt = MakeButton(crestSection, "Update at Upgrader", 160, 22, 0, 150) --- ── FormatCost helper ───────────────────────────────────────────────────────── -local function FormatCost(cAmt, gAmt, trackName) - if cAmt == 0 and gAmt == 0 then return "|cff20c020Max|r" end - local td = trackName and Data.tracks[trackName] - local cn = (td and td.crestName) or "Crest" - local parts = {} - if cAmt > 0 then parts[#parts + 1] = cAmt .. " " .. cn end - if gAmt > 0 then parts[#parts + 1] = gAmt .. "g" end - return table.concat(parts, " ") -end - -- ── PopulateGear ────────────────────────────────────────────────────────────── PopulateGear = function() local gear = Calc:GetEquippedGear() @@ -1160,14 +1149,14 @@ PopulateGear = function() local pOk, pa, pb, pc, pd, pe, pf = pcall(Calc.GetItemUpgradeCost, Calc, item) local track, rank, crestCost, goldCost, maxIlvl, craftLabel if pOk then track, rank, crestCost, goldCost, maxIlvl, craftLabel = pa, pb, pc, pd, pe, pf end - local dt, dc, dm, du = "-", "-", "-", "-" + local dt, dm, du = "-", "-", "-" local isAtMax, shouldAdd = false, true if track == "Crafted" then if hideCrafted then shouldAdd = false else - dt = craftLabel or "Crafted"; dm = maxIlvl or item.ilvl; dc = "|cff888888Crafted|r" + dt = craftLabel or "Crafted"; dm = maxIlvl or item.ilvl isAtMax = true du = "Crafted" end @@ -1185,11 +1174,10 @@ PopulateGear = function() end dt = track; dm = maxIlvl or item.ilvl du = rank and (rank .. "/" .. (td and #td.ranks or 6)) or "-" - dc = FormatCost(crestCost or 0, goldCost or 0, track) isAtMax = (rank == (td and #td.ranks or 6)) else if Calc:IsVoidforged(item.link) then - dt = "Voidforged"; dm = item.ilvl; du = "Max"; dc = "|cff20c020Max|r"; isAtMax = true + dt = "Voidforged"; dm = item.ilvl; du = "Max"; isAtMax = true elseif item.ilvl >= 200 then -- Use the shared helper so thresholds stay in one place. local band, maxI = CraftedBandFromIlvl(item.ilvl) @@ -1197,7 +1185,6 @@ PopulateGear = function() or band == "Hero" and "Hero Craft" or "Crafted" dt = label; dm = maxI du = item.ilvl >= maxI and "Max" or "-" - dc = item.ilvl >= maxI and "|cff20c020Max|r" or "|cff888888Crafted|r" isAtMax = item.ilvl >= maxI else dm = item.ilvl; isAtMax = true @@ -1208,9 +1195,9 @@ PopulateGear = function() tileEntries[#tileEntries + 1] = { slotName = sn, slotID = item.slot, ilvl = item.ilvl, max = dm, - upgrade = du, trackName = dt, cost = dc, + upgrade = du, trackName = dt, isAtMax = isAtMax, trackKey = track, - rank = rank, crestCost = crestCost, goldCost = goldCost, + crestCost = crestCost, goldCost = goldCost, } end end From ba6b82f4da06924a6625ca2585d2c64b9b955c96 Mon Sep 17 00:00:00 2001 From: Kneeull <156554816+Kneeull@users.noreply.github.com> Date: Fri, 15 May 2026 02:26:51 +0100 Subject: [PATCH 11/16] Left This Week slides into Earned/Cap space when that column is hidden --- EllesmereUIQoL/EUI_UpgradeCalc.lua | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/EllesmereUIQoL/EUI_UpgradeCalc.lua b/EllesmereUIQoL/EUI_UpgradeCalc.lua index 30a33a51..51845579 100644 --- a/EllesmereUIQoL/EUI_UpgradeCalc.lua +++ b/EllesmereUIQoL/EUI_UpgradeCalc.lua @@ -1399,7 +1399,15 @@ PopulateGear = function() local showCap = opts.showEarnedCap local showWeeklyRem = opts.showWeeklyRemaining if showCap then capHdrLbl:Show() else capHdrLbl:Hide() end - if showWeeklyRem then weeklyRemHdrLbl:Show() else weeklyRemHdrLbl:Hide() end + -- When Earned/Cap is hidden, Left This Week slides into that column's space. + local weeklyRemX = (showWeeklyRem and not showCap) and CREST_COLS[5].x or CREST_COLS[6].x + if showWeeklyRem then + weeklyRemHdrLbl:ClearAllPoints() + PP.Point(weeklyRemHdrLbl, "TOPLEFT", crestSection, "TOPLEFT", weeklyRemX + 4, -2) + weeklyRemHdrLbl:Show() + else + weeklyRemHdrLbl:Hide() + end local visualIdx = 0 for i, trackName in ipairs(Data.trackOrder) do local td = Data.tracks[trackName] @@ -1419,7 +1427,8 @@ PopulateGear = function() for _, col in ipairs(CREST_COLS) do local cell = rowFrame[col.key] cell:ClearAllPoints() - PP.Point(cell, "TOPLEFT", crestSection, "TOPLEFT", col.x + 4, rowY - 2) + local cellX = (col.key == "weeklyRem") and weeklyRemX or col.x + PP.Point(cell, "TOPLEFT", crestSection, "TOPLEFT", cellX + 4, rowY - 2) end -- Reposition and show +/-80 buttons if this row has them if rowFrame.mBtn then From 5d3b101368a918cfa3fc2761bec2f46386a9719c Mon Sep 17 00:00:00 2001 From: Kneeull <156554816+Kneeull@users.noreply.github.com> Date: Fri, 15 May 2026 02:28:57 +0100 Subject: [PATCH 12/16] Left This Week: compute as cap-earned (drop broken weekly API fields) --- EllesmereUIQoL/EUI_UpgradeCalc.lua | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/EllesmereUIQoL/EUI_UpgradeCalc.lua b/EllesmereUIQoL/EUI_UpgradeCalc.lua index 51845579..f2f61a1b 100644 --- a/EllesmereUIQoL/EUI_UpgradeCalc.lua +++ b/EllesmereUIQoL/EUI_UpgradeCalc.lua @@ -319,7 +319,7 @@ function Calc:GetNextUpgradeGain(item) return (td.ranks[rank + 1] or 0) - (td.ranks[rank] or 0) end --- Returns a table mapping crestName -> { quantity, cap, earned, weeklyEarned, weeklyCap } for each track. +-- Returns a table mapping crestName -> { quantity, cap, earned } for each track. function Calc:GetPlayerCrests() local owned = {} for _, td in pairs(Data.tracks) do @@ -327,11 +327,9 @@ function Calc:GetPlayerCrests() local info = C_CurrencyInfo.GetCurrencyInfo(td.currID) if info then owned[td.crestName] = { - quantity = info.quantity or 0, - cap = (info.maxQuantity and info.maxQuantity > 0) and info.maxQuantity or nil, - earned = info.totalEarned or 0, - weeklyEarned = info.quantityEarnedThisWeek or 0, - weeklyCap = (info.canEarnPerWeek and info.maxWeeklyQuantity > 0) and info.maxWeeklyQuantity or nil, + quantity = info.quantity or 0, + cap = (info.maxQuantity and info.maxQuantity > 0) and info.maxQuantity or nil, + earned = info.totalEarned or 0, } end end @@ -1458,10 +1456,10 @@ PopulateGear = function() rowFrame.cap:Hide() end if showWeeklyRem then - local wCap = info and info.weeklyCap - local wEarn = info and info.weeklyEarned or 0 - if wCap then - local rem = math.max(0, wCap - wEarn) + local cap = info and info.cap + local earned = info and info.earned or 0 + if cap then + local rem = math.max(0, cap - earned) local remStr = rem > 0 and tostring(rem) or "|cff20ff200|r" rowFrame.weeklyRem:SetText(remStr) else From a0f0f5e85d593ed2424ebd4203dd6ae53224c44d Mon Sep 17 00:00:00 2001 From: Kneeull <156554816+Kneeull@users.noreply.github.com> Date: Fri, 15 May 2026 02:32:49 +0100 Subject: [PATCH 13/16] Rename column: Left This Week -> Still Available --- EllesmereUIQoL/EUI_UpgradeCalc.lua | 4 ++-- EllesmereUIQoL/EUI_UpgradeCalc_Options.lua | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/EllesmereUIQoL/EUI_UpgradeCalc.lua b/EllesmereUIQoL/EUI_UpgradeCalc.lua index f2f61a1b..7bea696a 100644 --- a/EllesmereUIQoL/EUI_UpgradeCalc.lua +++ b/EllesmereUIQoL/EUI_UpgradeCalc.lua @@ -575,7 +575,7 @@ local CREST_COLS = { {key="owned", label="Owned", x=220, w=70, align="CENTER"}, {key="missing", label="Missing", x=290, w=70, align="CENTER"}, {key="cap", label="Earned / Cap", x=360, w=130, align="CENTER"}, - {key="weeklyRem", label="Left This Week", x=490, w=110, align="CENTER"}, + {key="weeklyRem", label="Still Available", x=490, w=110, align="CENTER"}, } local f = CreateFrame("Frame", "EUIUpgCalcFrame", UIParent) @@ -1397,7 +1397,7 @@ PopulateGear = function() local showCap = opts.showEarnedCap local showWeeklyRem = opts.showWeeklyRemaining if showCap then capHdrLbl:Show() else capHdrLbl:Hide() end - -- When Earned/Cap is hidden, Left This Week slides into that column's space. + -- When Earned/Cap is hidden, Still Available slides into that column's space. local weeklyRemX = (showWeeklyRem and not showCap) and CREST_COLS[5].x or CREST_COLS[6].x if showWeeklyRem then weeklyRemHdrLbl:ClearAllPoints() diff --git a/EllesmereUIQoL/EUI_UpgradeCalc_Options.lua b/EllesmereUIQoL/EUI_UpgradeCalc_Options.lua index 77c56eed..c36bb676 100644 --- a/EllesmereUIQoL/EUI_UpgradeCalc_Options.lua +++ b/EllesmereUIQoL/EUI_UpgradeCalc_Options.lua @@ -174,11 +174,11 @@ local function BuildUpgradeCalcPage(pageName, parent, yOffset) ); y = y - h _, h = W:Toggle(parent, - "Show Weekly Remaining Column", + "Show Still Available Column", y, function() return GetAddonDB().showWeeklyRemaining or false end, function(v) GetAddonDB().showWeeklyRemaining = v; LiveRefresh() end, - "Show how many crests you can still earn this week (weekly cap minus earned so far)." + "Show how many crests you can still earn before hitting the season cap (cap minus earned so far)." ); y = y - h --------------------------------------------------------------------------- From 6edeefbadfb4371e92be17357c62998baee36cd5 Mon Sep 17 00:00:00 2001 From: Kneeull <156554816+Kneeull@users.noreply.github.com> Date: Sat, 16 May 2026 22:02:42 +0100 Subject: [PATCH 14/16] Fix: store upgradeCalc data per-character, not per-profile --- EllesmereUIQoL/EUI_UpgradeCalc.lua | 31 +++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/EllesmereUIQoL/EUI_UpgradeCalc.lua b/EllesmereUIQoL/EUI_UpgradeCalc.lua index 7bea696a..4287d4a0 100644 --- a/EllesmereUIQoL/EUI_UpgradeCalc.lua +++ b/EllesmereUIQoL/EUI_UpgradeCalc.lua @@ -109,6 +109,9 @@ local _euicProfileRef = nil -- every subsequent DB()/Opts() call is a simple local read with no table traversal. local _dbCache = nil local _optsCache = nil +-- Character key ("Name - Realm") set at PLAYER_LOGIN; used to scope DB() data +-- per character so alts on the same profile don't share queue/scan data. +local _charKey = nil local function DB() if _dbCache then return _dbCache end @@ -1649,23 +1652,25 @@ _firstRunEvt:SetScript("OnEvent", function(self) local profileDB = EllesmereUI.Lite.NewDB("EllesmereUIQoLDB", { profile = { upgradeCalcOpts = {}, - upgradeCalc = { - cache = { slots = {}, ts = 0 }, - calibrated = false, - }, + chars = {}, }, }) _euicProfileRef = profileDB.profile - -- Populate the direct sub-table caches now so DB()/Opts() are O(1) - -- for the rest of the session with no repeated table traversal. + -- Store character data under a per-character key so alts on the same + -- profile each have their own queue, scan cache, and crest offsets. + local charKey = UnitName("player") .. " - " .. GetRealmName() + _charKey = charKey local store = _euicProfileRef - store.upgradeCalc = store.upgradeCalc or {} - local db = store.upgradeCalc - db.cache = db.cache or { slots = {}, ts = 0 } - db.calibrated = db.calibrated or false - db.queue = db.queue or {} - db.crestManualAdds = db.crestManualAdds or {} - store.upgradeCalcOpts = store.upgradeCalcOpts or {} + store.chars = store.chars or {} + store.chars[charKey] = store.chars[charKey] or {} + local charStore = store.chars[charKey] + charStore.upgradeCalc = charStore.upgradeCalc or {} + local db = charStore.upgradeCalc + db.cache = db.cache or { slots = {}, ts = 0 } + db.calibrated = db.calibrated or false + db.queue = db.queue or {} + db.crestManualAdds = db.crestManualAdds or {} + store.upgradeCalcOpts = store.upgradeCalcOpts or {} _dbCache = db _optsCache = store.upgradeCalcOpts end From c6b5378b4093882263fccb6b67681fcf74c3436a Mon Sep 17 00:00:00 2001 From: Kneeull <156554816+Kneeull@users.noreply.github.com> Date: Sat, 16 May 2026 23:11:44 +0100 Subject: [PATCH 15/16] Spawn at TOPLEFT to avoid options panel; add UI Scale slider (50-150%) --- EllesmereUIQoL/EUI_UpgradeCalc.lua | 10 +++++++++- EllesmereUIQoL/EUI_UpgradeCalc_Options.lua | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/EllesmereUIQoL/EUI_UpgradeCalc.lua b/EllesmereUIQoL/EUI_UpgradeCalc.lua index 4287d4a0..370ef2a2 100644 --- a/EllesmereUIQoL/EUI_UpgradeCalc.lua +++ b/EllesmereUIQoL/EUI_UpgradeCalc.lua @@ -583,7 +583,7 @@ local CREST_COLS = { local f = CreateFrame("Frame", "EUIUpgCalcFrame", UIParent) PP.Size(f, FRAME_W, FRAME_H) -f:SetPoint("LEFT", UIParent, "LEFT", 30, 0) +f:SetPoint("TOPLEFT", UIParent, "TOPLEFT", 20, -40) f:SetFrameStrata("DIALOG") f:SetMovable(true) f:EnableMouse(true) @@ -602,6 +602,13 @@ function Calc.ApplyBgOpacity() fBg:SetColorTexture(0.05, 0.07, 0.09, alpha / 100) end +function Calc.ApplyScale() + local opts = Opts() + local scale = opts and opts.uiScale + if scale == nil then scale = 100 end + f:SetScale(scale / 100) +end + local brd = EUI.MakeBorder(f, 0.13, 0.75, 0.55, 1) if brd.SetColor then brd:SetColor(0.13, 0.75, 0.55, 1) end @@ -1609,6 +1616,7 @@ end) f:SetScript("OnShow", function() if InCombatLockdown() then f:Hide(); return end Calc.ApplyBgOpacity() + Calc.ApplyScale() -- Reload persisted crest manual-add offsets each time the frame opens, -- so that values the user set before logging out are visible immediately. local dbAdds = DB().crestManualAdds diff --git a/EllesmereUIQoL/EUI_UpgradeCalc_Options.lua b/EllesmereUIQoL/EUI_UpgradeCalc_Options.lua index c36bb676..a48ae740 100644 --- a/EllesmereUIQoL/EUI_UpgradeCalc_Options.lua +++ b/EllesmereUIQoL/EUI_UpgradeCalc_Options.lua @@ -186,6 +186,20 @@ local function BuildUpgradeCalcPage(pageName, parent, yOffset) --------------------------------------------------------------------------- _, h = W:SectionHeader(parent, "APPEARANCE", y); y = y - h + _, h = W:Slider(parent, + "UI Scale", + y, + 50, 150, 5, + function() return GetAddonDB().uiScale or 100 end, + function(v) + GetAddonDB().uiScale = v + if EUIUpgCalc and EUIUpgCalc.ApplyScale then + EUIUpgCalc.ApplyScale() + end + end, + "Scales the entire Upgrade Calculator window up or down." + ); y = y - h + _, h = W:Slider(parent, "Background Opacity", y, From b5b14aec08af7f16138dff2f711c9bc2bc43dc6b Mon Sep 17 00:00:00 2001 From: Kneeull <156554816+Kneeull@users.noreply.github.com> Date: Mon, 18 May 2026 12:11:15 +0100 Subject: [PATCH 16/16] Queue sort gold-first; fix +/-80 button overlap; add /eec; dead code audit - Queue sort: gold-only upgrades first, then Adv > Vet > Champ > Hero > Myth - Move +/-80 buttons into right end of Crest column (x=79/107) so they no longer overlap the Need column text; cap crest label to 73px for those rows - Add /eec as a third slash command alias - Dead code audit: remove unused _charKey module variable; simplify queue sync duplicate assignment; add link validation to tile tooltip cache lookup; fix SolidTex SetAllPoints on closeBtn and MakeButton backgrounds --- EllesmereUIQoL/EUI_UpgradeCalc.lua | 148 +++++++++++++++++++++-------- 1 file changed, 106 insertions(+), 42 deletions(-) diff --git a/EllesmereUIQoL/EUI_UpgradeCalc.lua b/EllesmereUIQoL/EUI_UpgradeCalc.lua index 370ef2a2..a63ffff6 100644 --- a/EllesmereUIQoL/EUI_UpgradeCalc.lua +++ b/EllesmereUIQoL/EUI_UpgradeCalc.lua @@ -109,9 +109,8 @@ local _euicProfileRef = nil -- every subsequent DB()/Opts() call is a simple local read with no table traversal. local _dbCache = nil local _optsCache = nil --- Character key ("Name - Realm") set at PLAYER_LOGIN; used to scope DB() data --- per character so alts on the same profile don't share queue/scan data. -local _charKey = nil +-- (Character key used only locally in PLAYER_LOGIN to index per-character storage; +-- not retained as a module variable since _dbCache is set directly.) local function DB() if _dbCache then return _dbCache end @@ -373,8 +372,11 @@ function Calc:GetItemUpgradeCost(item) end -- Priority 1: exact costs from Upgrader NPC API (calibrated). + -- Only use if the cached link matches the current item (guards against stale + -- data after a gear swap before the next Upgrader scan). local slotCache = db.calibrated and db.cache.slots[item.slot] - if slotCache and slotCache.crestAmounts then + if slotCache and slotCache.crestAmounts + and slotCache.link == item.link then local exactCrests = 0 for _, v in pairs(slotCache.crestAmounts) do exactCrests = exactCrests + v end local exactGold = math.floor((slotCache.copperTotal or 0) / 10000) @@ -461,7 +463,9 @@ function Calc:ScanEquippedAtUpgrader(onDone) local function saveSlotInfo(slotID, info) if not (info and info.upgradeLevelInfos) then return end local crestAmounts, copperTotal = TallySlotCosts(info) + local link = GetInventoryItemLink("player", slotID) newSlots[slotID] = { + link = link, crestAmounts = crestAmounts, copperTotal = copperTotal, } @@ -534,6 +538,7 @@ function Calc:RescanSlot(slotID, onDone) db.cache = db.cache or { slots = {}, ts = 0 } db.cache.slots = db.cache.slots or {} db.cache.slots[slotID] = { + link = GetInventoryItemLink("player", slotID), crestAmounts = crestAmounts, copperTotal = copperTotal, } @@ -573,12 +578,12 @@ local TRACK_RGB = { } local CREST_COLS = { - {key="crest", label="Crest", x=0, w=150, align="LEFT" }, - {key="need", label="Need", x=150, w=70, align="CENTER"}, - {key="owned", label="Owned", x=220, w=70, align="CENTER"}, - {key="missing", label="Missing", x=290, w=70, align="CENTER"}, - {key="cap", label="Earned / Cap", x=360, w=130, align="CENTER"}, - {key="weeklyRem", label="Still Available", x=490, w=110, align="CENTER"}, + {key="crest", label="Crest", x=0, w=135, align="LEFT" }, + {key="need", label="Need", x=135, w=60, align="CENTER"}, + {key="owned", label="Owned", x=195, w=60, align="CENTER"}, + {key="missing", label="Missing", x=255, w=65, align="CENTER"}, + {key="cap", label="Earned / Cap", x=320, w=120, align="CENTER"}, + {key="weeklyRem", label="Still Available", x=440, w=119, align="CENTER"}, } local f = CreateFrame("Frame", "EUIUpgCalcFrame", UIParent) @@ -624,7 +629,8 @@ titleTxt:SetText("EllesmereUI |cffffffff- Upgrade Calculator|r") local closeBtn = CreateFrame("Button", nil, f) PP.Size(closeBtn, 18, 18) PP.Point(closeBtn, "TOPRIGHT", f, "TOPRIGHT", -8, -8) -SolidTex(closeBtn, "ARTWORK", 0.7, 0.2, 0.2, 0.9) +local closeBg = SolidTex(closeBtn, "ARTWORK", 0.7, 0.2, 0.2, 0.9) +closeBg:SetAllPoints() local closeTxt = MFont(closeBtn, 11, "OUTLINE", 1, 1, 1, 1) closeTxt:SetAllPoints() closeTxt:SetJustifyH("CENTER") @@ -647,7 +653,7 @@ local function MakeTableHeader(parent, cols, yOffset) PP.Point(hdrBg, "TOPRIGHT", parent, "TOPRIGHT", 0, yOffset) PP.Height(hdrBg, HDR_H) for _, col in ipairs(cols) do - local lbl = MFont(parent, 10, "OUTLINE", G.r, G.g, G.b, 1) + local lbl = MFont(parent, 11, "OUTLINE", G.r, G.g, G.b, 1) PP.Point(lbl, "TOPLEFT", parent, "TOPLEFT", col.x + 4, yOffset - 2) PP.Width(lbl, col.w) lbl:SetJustifyH(col.align) @@ -665,7 +671,7 @@ local function MakeRow(parent, cols, yOffset, isAlt) row.altBg = bg -- stored so PopulateGear can reposition and hide/show it end for _, col in ipairs(cols) do - local cell = MFont(parent, 10, nil, 0.85, 0.85, 0.85, 1) + local cell = MFont(parent, 11, nil, 0.85, 0.85, 0.85, 1) PP.Point(cell, "TOPLEFT", parent, "TOPLEFT", col.x + 4, yOffset - 2) PP.Width(cell, col.w - 8) cell:SetJustifyH(col.align) @@ -678,7 +684,8 @@ local function MakeButton(parent, label, w, h, yOff, xOff) local btn = CreateFrame("Button", nil, parent) PP.Size(btn, w, h) PP.Point(btn, "TOPLEFT", parent, "TOPLEFT", xOff, yOff) - SolidTex(btn, "BACKGROUND", 0.1, 0.14, 0.18, 1) + local btnBg = SolidTex(btn, "BACKGROUND", 0.1, 0.14, 0.18, 1) + btnBg:SetAllPoints() local bb = EUI.MakeBorder(btn, 0.13, 0.75, 0.55, 0.6) if bb.SetColor then bb:SetColor(0.13, 0.75, 0.55, 0.6) end local txt = MFont(btn, 10, "OUTLINE", G.r, G.g, G.b, 1) @@ -692,7 +699,7 @@ end f.charPane = CreateFrame("Frame", nil, f) f.charPane:SetAllPoints(f) -local ilvlStatLbl = MFont(f.charPane, 11, "OUTLINE", G.r, G.g, G.b, 1) +local ilvlStatLbl = MFont(f.charPane, 12, "OUTLINE", G.r, G.g, G.b, 1) PP.Point(ilvlStatLbl, "TOPLEFT", f.charPane, "TOPLEFT", 14, tabY - 26) ilvlStatLbl:SetText("Current iLvl: - Max Possible: -") @@ -712,9 +719,9 @@ PP.Point(tlFill, "TOPLEFT", cc, "TOPLEFT", 0, -2) PP.Height(tlFill, 16) tlFill:SetWidth(1) -- updated each refresh -local tlCurLbl = MFont(cc, 9, "OUTLINE", 0.65, 0.65, 0.65, 1) +local tlCurLbl = MFont(cc, 10, "OUTLINE", 0.65, 0.65, 0.65, 1) PP.Point(tlCurLbl, "TOPLEFT", cc, "TOPLEFT", 2, -20) -local tlMaxLbl = MFont(cc, 9, "OUTLINE", G.r, G.g, G.b, 1) +local tlMaxLbl = MFont(cc, 10, "OUTLINE", G.r, G.g, G.b, 1) PP.Point(tlMaxLbl, "TOPRIGHT", cc, "TOPRIGHT", -2, -20) -- ── Tile frames ────────────────────────────────────────────────────────────────── local ToggleTileQueue -- forward declaration (defined in queue section) @@ -739,25 +746,25 @@ for i = 1, 18 do selHL:Hide() btn.selHL = selHL -- Top-left: slot name - local sLbl = MFont(btn, 11, "OUTLINE", 0.9, 0.9, 0.9, 1) + local sLbl = MFont(btn, 12, "OUTLINE", 0.9, 0.9, 0.9, 1) PP.Point(sLbl, "TOPLEFT", btn, "TOPLEFT", 7, -4) PP.Width(sLbl, TILE_W - 82) sLbl:SetJustifyH("LEFT") btn.sLbl = sLbl -- Top-right: current ^ max ilvl - local iLbl = MFont(btn, 10, "OUTLINE", 0.8, 0.8, 0.8, 1) + local iLbl = MFont(btn, 11, "OUTLINE", 0.8, 0.8, 0.8, 1) PP.Point(iLbl, "TOPRIGHT", btn, "TOPRIGHT", -5, -4) PP.Width(iLbl, 76) iLbl:SetJustifyH("RIGHT") btn.iLbl = iLbl -- Bottom-left: track name - local tLbl = MFont(btn, 10, "OUTLINE", 0.55, 0.55, 0.55, 1) + local tLbl = MFont(btn, 11, "OUTLINE", 0.55, 0.55, 0.55, 1) PP.Point(tLbl, "BOTTOMLEFT", btn, "BOTTOMLEFT", 7, 5) PP.Width(tLbl, TILE_W - 82) tLbl:SetJustifyH("LEFT") btn.tLbl = tLbl -- Bottom-right: rank badge - local rLbl = MFont(btn, 10, "OUTLINE", 0.8, 0.8, 0.8, 1) + local rLbl = MFont(btn, 11, "OUTLINE", 0.8, 0.8, 0.8, 1) PP.Point(rLbl, "BOTTOMRIGHT", btn, "BOTTOMRIGHT", -5, 5) PP.Width(rLbl, 76) rLbl:SetJustifyH("RIGHT") @@ -782,6 +789,10 @@ for i = 1, 18 do local td = Data.tracks[e.trackKey] local snap = DB() local sc = snap.calibrated and snap.cache.slots[e.slotID] or nil + -- Discard cached entry if the item in that slot has changed since the scan. + if sc and sc.link and sc.link ~= (GetInventoryItemLink("player", e.slotID) or "") then + sc = nil + end if sc and sc.crestAmounts and next(sc.crestAmounts) then for cid, amt in pairs(sc.crestAmounts) do local cn = _currIDToCrestName[cid] @@ -822,8 +833,8 @@ for i = 1, 18 do end -- Section header labels and group separator line (repositioned each refresh) -local sHdrNeeds = MFont(cc, 9, "OUTLINE", G.r, G.g, G.b, 1) -local sHdrMax = MFont(cc, 9, "OUTLINE", 0.48, 0.48, 0.48, 1) +local sHdrNeeds = MFont(cc, 10, "OUTLINE", G.r, G.g, G.b, 1) +local sHdrMax = MFont(cc, 10, "OUTLINE", 0.48, 0.48, 0.48, 1) local groupSepLine = SolidTex(cc, "BORDER", 0.25, 0.28, 0.32, 1) PP.Height(groupSepLine, 1) PP.Width(groupSepLine, TILE_ROW_W) @@ -838,14 +849,30 @@ local qHdrBg = SolidTex(queuePane, "BACKGROUND", 0.08, 0.11, 0.15, 1) PP.Point(qHdrBg, "TOPLEFT", queuePane, "TOPLEFT", 0, 0) PP.Point(qHdrBg, "TOPRIGHT", queuePane, "TOPRIGHT", 0, 0) PP.Height(qHdrBg, 20) -local qHdrLbl = MFont(queuePane, 10, "OUTLINE", G.r, G.g, G.b, 1) +local qHdrLbl = MFont(queuePane, 11, "OUTLINE", G.r, G.g, G.b, 1) PP.Point(qHdrLbl, "TOPLEFT", queuePane, "TOPLEFT", 4, -2) qHdrLbl:SetText("UPGRADE QUEUE") -local qSubLbl = MFont(queuePane, 9, "OUTLINE", 0.38, 0.38, 0.38, 1) +local qSubLbl = MFont(queuePane, 10, "OUTLINE", 0.38, 0.38, 0.38, 1) PP.Point(qSubLbl, "TOPRIGHT", queuePane, "TOPRIGHT", -4, -2) qSubLbl:SetText("click tiles to plan") -local qEmptyLbl = MFont(queuePane, 9, "OUTLINE", 0.32, 0.32, 0.32, 1) +-- Sort-by-crest button sits in the header bar, right-aligned (swaps with qSubLbl) +local qSortBtn = CreateFrame("Button", nil, queuePane) +PP.Size(qSortBtn, 50, 16) +PP.Point(qSortBtn, "TOPRIGHT", queuePane, "TOPRIGHT", -4, -2) +local qSortBg = SolidTex(qSortBtn, "BACKGROUND", 0.1, 0.14, 0.2, 1) +qSortBg:SetAllPoints(qSortBtn) +local qSortTxt = MFont(qSortBtn, 9, "OUTLINE", G.r, G.g, G.b, 1) +qSortTxt:SetAllPoints(); qSortTxt:SetJustifyH("CENTER"); qSortTxt:SetText("Sort") +qSortBtn:SetScript("OnEnter", function(self) + if EUI.ShowWidgetTooltip then EUI.ShowWidgetTooltip(self, "Sort queue by crest type (cheapest first)") end +end) +qSortBtn:SetScript("OnLeave", function() + if EUI.HideWidgetTooltip then EUI.HideWidgetTooltip() end +end) +qSortBtn:Hide() -- shown only when queue has items + +local qEmptyLbl = MFont(queuePane, 10, "OUTLINE", 0.32, 0.32, 0.32, 1) PP.Point(qEmptyLbl, "TOPLEFT", queuePane, "TOPLEFT", 4, -24) qEmptyLbl:SetText("No items queued.") @@ -860,11 +887,11 @@ for i = 1, 16 do local ebg = SolidTex(ef, "BACKGROUND", 0.07, 0.09, 0.12, 0.5) ebg:SetAllPoints(ef) end - local nLbl = MFont(ef, 9, "OUTLINE", 0.8, 0.8, 0.8, 1) + local nLbl = MFont(ef, 10, "OUTLINE", 0.8, 0.8, 0.8, 1) PP.Point(nLbl, "TOPLEFT", ef, "TOPLEFT", 4, -2) PP.Width(nLbl, QUEUE_W - 84) nLbl:SetJustifyH("LEFT") - local cLbl = MFont(ef, 9, nil, 0.8, 0.8, 0.8, 1) + local cLbl = MFont(ef, 10, nil, 0.8, 0.8, 0.8, 1) PP.Point(cLbl, "TOPRIGHT", ef, "TOPRIGHT", -4, -2) PP.Width(cLbl, 82) cLbl:SetJustifyH("RIGHT") @@ -878,7 +905,7 @@ PP.Point(qTotalSep, "TOPRIGHT", queuePane, "TOPRIGHT", 0, -42) PP.Height(qTotalSep, 1) qTotalSep:Hide() -local qTotalLbl = MFont(queuePane, 9, "OUTLINE", G.r, G.g, G.b, 1) +local qTotalLbl = MFont(queuePane, 10, "OUTLINE", G.r, G.g, G.b, 1) PP.Point(qTotalLbl, "TOPLEFT", queuePane, "TOPLEFT", 4, -46) qTotalLbl:SetText("") qTotalLbl:Hide() @@ -910,6 +937,7 @@ end local function UpdateQueueDisplay() local n = #queueItems qEmptyLbl:SetText(n == 0 and "No items queued." or "") + if n > 0 then qSortBtn:Show(); qSubLbl:Hide() else qSortBtn:Hide(); qSubLbl:Show() end local totalGoldQ, totalCrests = 0, {} for i, entry in ipairs(queueItems) do @@ -963,6 +991,26 @@ local function UpdateQueueDisplay() end end +-- Sort the queue by crest tier (cheapest/lowest first: Adventurer→Myth→Crafted). +local function SortQueueByCrest() + if #queueItems == 0 then return end + local trackIdx = {} + for i, tn in ipairs(Data.trackOrder) do trackIdx[tn] = i end + table.sort(queueItems, function(a, b) + -- Gold (no crest cost) sorts first (0), then by track order, unknowns last. + local ia = (a.crestCost or 0) == 0 and 0 or (trackIdx[a.trackKey] or 99) + local ib = (b.crestCost or 0) == 0 and 0 or (trackIdx[b.trackKey] or 99) + if ia ~= ib then return ia < ib end + return (a.slotID or 0) < (b.slotID or 0) + end) + queueSlotSet = {} + for i, it in ipairs(queueItems) do queueSlotSet[it.slotName] = i end + UpdateQueueDisplay() + SaveQueue() +end + +qSortBtn:SetScript("OnClick", SortQueueByCrest) + ToggleTileQueue = function(entry, btn) local sn = entry.slotName if queueSlotSet[sn] then @@ -1001,7 +1049,7 @@ PP.Point(gearSep, "TOPRIGHT", crestSection, "TOPRIGHT", 0, 4) PP.Height(gearSep, 1) -- Accuracy label floats right of the separator line; updated each PopulateGear. -local crestAccuracyLbl = MFont(crestSection, 9, "OUTLINE", 0.38, 0.38, 0.38, 1) +local crestAccuracyLbl = MFont(crestSection, 10, "OUTLINE", 0.38, 0.38, 0.38, 1) PP.Point(crestAccuracyLbl, "TOPRIGHT", crestSection, "TOPRIGHT", -4, 12) crestAccuracyLbl:SetText("") @@ -1011,14 +1059,14 @@ do local baseCols = { CREST_COLS[1], CREST_COLS[2], CREST_COLS[3], CREST_COLS[4] } MakeTableHeader(crestSection, baseCols, 0) end -local capHdrLbl = MFont(crestSection, 10, "OUTLINE", G.r, G.g, G.b, 1) +local capHdrLbl = MFont(crestSection, 11, "OUTLINE", G.r, G.g, G.b, 1) PP.Point(capHdrLbl, "TOPLEFT", crestSection, "TOPLEFT", CREST_COLS[5].x + 4, -2) PP.Width(capHdrLbl, CREST_COLS[5].w) capHdrLbl:SetJustifyH(CREST_COLS[5].align) capHdrLbl:SetText(CREST_COLS[5].label) capHdrLbl:Hide() -local weeklyRemHdrLbl = MFont(crestSection, 10, "OUTLINE", G.r, G.g, G.b, 1) +local weeklyRemHdrLbl = MFont(crestSection, 11, "OUTLINE", G.r, G.g, G.b, 1) PP.Point(weeklyRemHdrLbl, "TOPLEFT", crestSection, "TOPLEFT", CREST_COLS[6].x + 4, -2) PP.Width(weeklyRemHdrLbl, CREST_COLS[6].w) weeklyRemHdrLbl:SetJustifyH(CREST_COLS[6].align) @@ -1038,7 +1086,7 @@ for i = 1, #Data.trackOrder do local ckey = Data.tracks[tn].crestName -- "Hero Crest" / "Myth Crest" local mBtn = CreateFrame("Button", nil, crestSection) PP.Size(mBtn, 26, 16) - PP.Point(mBtn, "TOPLEFT", crestSection, "TOPLEFT", 143, rowY - 2) + PP.Point(mBtn, "TOPLEFT", crestSection, "TOPLEFT", 79, rowY - 2) local mBg = SolidTex(mBtn, "ARTWORK", 0.18, 0.08, 0.08, 0.9) mBg:SetAllPoints(mBtn) local mTxt = MFont(mBtn, 8, "OUTLINE", 0.9, 0.45, 0.45, 1) @@ -1061,7 +1109,7 @@ for i = 1, #Data.trackOrder do end) local pBtn = CreateFrame("Button", nil, crestSection) PP.Size(pBtn, 26, 16) - PP.Point(pBtn, "TOPLEFT", crestSection, "TOPLEFT", 200, rowY - 2) + PP.Point(pBtn, "TOPLEFT", crestSection, "TOPLEFT", 107, rowY - 2) local pBg = SolidTex(pBtn, "ARTWORK", 0.06, 0.18, 0.08, 0.9) pBg:SetAllPoints(pBtn) local pTxt = MFont(pBtn, 8, "OUTLINE", 0.45, 0.9, 0.45, 1) @@ -1084,12 +1132,14 @@ for i = 1, #Data.trackOrder do end) crestRows[i].mBtn = mBtn crestRows[i].pBtn = pBtn + -- Cap the crest-name label to leave room for the ±80 buttons on this row. + crestRows[i].crest:SetWidth(73) end end -- Summary label and action buttons — initially anchored at y=0; repositioned -- every PopulateGear call to sit below the last visible crest row. -local summaryLbl = MFont(crestSection, 11, "OUTLINE", G.r, G.g, G.b, 1) +local summaryLbl = MFont(crestSection, 12, "OUTLINE", G.r, G.g, G.b, 1) PP.Point(summaryLbl, "TOPLEFT", crestSection, "TOPLEFT", 4, 0) PP.Point(summaryLbl, "TOPRIGHT", crestSection, "TOPRIGHT", 0, 0) summaryLbl:SetJustifyH("LEFT") @@ -1128,11 +1178,13 @@ PopulateGear = function() -- Pre-build per-slot crest breakdown from scan data. -- Done once here from a single DB snapshot so every item in the loop -- sees a consistent view of the cache with no per-item DB reads. + -- Link validation: skip any slot whose cached link doesn't match current gear. local slotCrestMap = {} local dbSnap = DB() if dbSnap.calibrated then for slotID, sc in pairs(dbSnap.cache.slots or {}) do - if sc and sc.crestAmounts then + local currentLink = GetInventoryItemLink("player", slotID) + if sc and sc.crestAmounts and sc.link == currentLink then local byName = {} for cid, amt in pairs(sc.crestAmounts) do local cn = _currIDToCrestName[cid] @@ -1246,6 +1298,8 @@ PopulateGear = function() -- On every refresh, sync queue item references to the current tileEntries so -- UpdateQueueDisplay shows live costs rather than costs snapshotted at session open. + -- Also prunes entries whose slot is now at max rank (tile hidden, can't be clicked + -- to dequeue — e.g. equipped a fully-upgraded item in that slot). -- Safe to run on the first PopulateGear call too (entries are already fresh then). if #queueItems > 0 then local slotToEntry = {} @@ -1253,13 +1307,18 @@ PopulateGear = function() local newQueue, newSet = {}, {} for _, old in ipairs(queueItems) do local fresh = slotToEntry[old.slotID] - if fresh then + if fresh and not fresh.isAtMax then newQueue[#newQueue + 1] = fresh newSet[fresh.slotName] = #newQueue end end + local pruned = (#newQueue ~= #queueItems) queueItems = newQueue queueSlotSet = newSet + if pruned then + -- At least one entry was pruned; persist the trimmed queue. + SaveQueue() + end end -- Timeline bar @@ -1377,7 +1436,7 @@ PopulateGear = function() end crestSection:ClearAllPoints() PP.Point(crestSection, "TOPLEFT", cc, "TOPLEFT", 0, crestY) - PP.Point(crestSection, "TOPRIGHT", cc, "TOPRIGHT", 0, crestY) + PP.Width(crestSection, TILE_ROW_W) -- Resize the outer frame to fit content: title(32) + tabY(-36) + cc offset(46) + -- tile area + crest section (rows + summary + buttons) + bottom padding @@ -1390,8 +1449,13 @@ PopulateGear = function() local crestSectionH = HDR_H + visibleCrestRows * ROW_H + 10 + 22 + 38 -- hdr+rows+gap+summary+btns local contentH = math.abs(crestY) + crestSectionH -- cc is anchored at y = tabY - 46 = -82 from frame top; add title bar (32) + padding (12) - local newFrameH = contentH + 82 + 32 + 12 + -- Also ensure the frame is tall enough to show the full queue panel (queue is at y=-36, + -- 590px tall → needs 36+590=626px of cc height → 626+82+32+12=752px minimum). + local queueH = HDR_H + 24 + #queueItems * 20 + (#queueItems > 0 and 54 or 0) + 30 + local minFrameH = queueH + 82 + 32 + 12 + 36 + local newFrameH = math.max(contentH + 82 + 32 + 12, minFrameH) PP.Size(f, FRAME_W, newFrameH) + PP.Size(queuePane, QUEUE_W, newFrameH - 140) -- Crest accuracy label local db = DB() @@ -1441,9 +1505,9 @@ PopulateGear = function() -- Reposition and show +/-80 buttons if this row has them if rowFrame.mBtn then rowFrame.mBtn:ClearAllPoints() - PP.Point(rowFrame.mBtn, "TOPLEFT", crestSection, "TOPLEFT", 143, rowY - 2) + PP.Point(rowFrame.mBtn, "TOPLEFT", crestSection, "TOPLEFT", 79, rowY - 2) rowFrame.pBtn:ClearAllPoints() - PP.Point(rowFrame.pBtn, "TOPLEFT", crestSection, "TOPLEFT", 200, rowY - 2) + PP.Point(rowFrame.pBtn, "TOPLEFT", crestSection, "TOPLEFT", 107, rowY - 2) rowFrame.mBtn:Show() rowFrame.pBtn:Show() end @@ -1639,6 +1703,7 @@ end) SLASH_EUIUPGCALC1 = "/euic" SLASH_EUIUPGCALC2 = "/upgcalc" +SLASH_EUIUPGCALC3 = "/eec" SlashCmdList["EUIUPGCALC"] = function() if InCombatLockdown() then return end if f:IsShown() then f:Hide() else f:Show() end @@ -1667,7 +1732,6 @@ _firstRunEvt:SetScript("OnEvent", function(self) -- Store character data under a per-character key so alts on the same -- profile each have their own queue, scan cache, and crest offsets. local charKey = UnitName("player") .. " - " .. GetRealmName() - _charKey = charKey local store = _euicProfileRef store.chars = store.chars or {} store.chars[charKey] = store.chars[charKey] or {}