From d2cb2ed590e2497f9ec1693db090b51671d4871d Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Sat, 5 Sep 2026 15:48:28 +0200 Subject: [PATCH] Add cached inventory count index for tooltips --- Core/InventoryIndex.lua | 447 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 447 insertions(+) create mode 100644 Core/InventoryIndex.lua diff --git a/Core/InventoryIndex.lua b/Core/InventoryIndex.lua new file mode 100644 index 0000000..d283037 --- /dev/null +++ b/Core/InventoryIndex.lua @@ -0,0 +1,447 @@ +-- Guda ClassicAPI Inventory Count Index +-- Replaces per-mouseover full inventory scans with a cached itemID index. +-- Lua 5.0 compatible. + +local addon = Guda + +local InventoryIndex = {} +addon.Modules.InventoryIndex = InventoryIndex + +local staticIndex = nil -- other characters / shared characters +local currentIndex = nil -- current character live bags + bank/mail/equipment +local staticDirty = true +local currentDirty = true +local eventFrame = nil + +-- Reused presentation tables to avoid per-mouseover garbage. +local displayCharacters = {} +local breakdownParts = {} +local charParts = {} + +local function GetItemID(itemData) + if not itemData then return nil end + + if type(itemData) == "number" then + return itemData + end + + if type(itemData) == "table" then + if itemData.itemID then + return tonumber(itemData.itemID) + end + itemData = itemData.link + end + + if type(itemData) ~= "string" then return nil end + local _, _, id = string.find(itemData, "item:(%d+)") + return id and tonumber(id) or nil +end + +local function NewIndex() + return {} +end + +local function EnsureItemEntry(index, itemID) + local entry = index[itemID] + if not entry then + entry = { + totalBags = 0, + totalBank = 0, + totalMail = 0, + totalEquipped = 0, + characters = {}, + characterMap = {}, + } + index[itemID] = entry + end + return entry +end + +local function EnsureCharacterEntry(itemEntry, charKey, charName, charData, isCurrent, isShared) + local entry = itemEntry.characterMap[charKey] + if not entry then + entry = { + name = (charData and charData.name) or charName, + classToken = charData and charData.classToken, + bagCount = 0, + bankCount = 0, + mailCount = 0, + equippedCount = 0, + isCurrent = isCurrent and true or false, + isShared = isShared and true or false, + } + itemEntry.characterMap[charKey] = entry + table.insert(itemEntry.characters, entry) + end + return entry +end + +local function AddCount(index, itemID, count, source, charKey, charName, charData, isCurrent, isShared) + itemID = tonumber(itemID) + count = tonumber(count) or 1 + if not itemID or count <= 0 then return end + + local itemEntry = EnsureItemEntry(index, itemID) + local charEntry = EnsureCharacterEntry(itemEntry, charKey, charName, charData, isCurrent, isShared) + + if source == "bags" then + itemEntry.totalBags = itemEntry.totalBags + count + charEntry.bagCount = charEntry.bagCount + count + elseif source == "bank" then + itemEntry.totalBank = itemEntry.totalBank + count + charEntry.bankCount = charEntry.bankCount + count + elseif source == "mail" then + itemEntry.totalMail = itemEntry.totalMail + count + charEntry.mailCount = charEntry.mailCount + count + elseif source == "equipped" then + itemEntry.totalEquipped = itemEntry.totalEquipped + count + charEntry.equippedCount = charEntry.equippedCount + count + end +end + +local function IndexContainers(index, containers, source, charKey, charName, charData, isCurrent, isShared) + if type(containers) ~= "table" then return end + + for _, bagData in pairs(containers) do + if type(bagData) == "table" and type(bagData.slots) == "table" then + for _, itemData in pairs(bagData.slots) do + if itemData then + local itemID = GetItemID(itemData) + if itemID then + AddCount(index, itemID, itemData.count or 1, source, + charKey, charName, charData, isCurrent, isShared) + end + end + end + end + end +end + +local function IndexMailbox(index, mailboxData, charKey, charName, charData, isCurrent, isShared) + if type(mailboxData) ~= "table" then return end + + for _, mail in ipairs(mailboxData) do + if type(mail) == "table" then + if type(mail.items) == "table" then + for _, item in ipairs(mail.items) do + local itemID = GetItemID(item) + if itemID then + AddCount(index, itemID, item.count or 1, "mail", + charKey, charName, charData, isCurrent, isShared) + end + end + elseif mail.item then + local itemID = GetItemID(mail.item) + if itemID then + AddCount(index, itemID, mail.item.count or 1, "mail", + charKey, charName, charData, isCurrent, isShared) + end + end + end + end +end + +local function IndexSavedEquipped(index, equippedData, charKey, charName, charData, isCurrent, isShared) + if type(equippedData) ~= "table" then return end + + for _, itemData in pairs(equippedData) do + local itemID = GetItemID(itemData) + if itemID then + AddCount(index, itemID, 1, "equipped", + charKey, charName, charData, isCurrent, isShared) + end + end +end + +local function IndexLiveEquipped(index, charKey, charName, charData) + for slotID = 1, 19 do + local link = GetInventoryItemLink("player", slotID) + if link then + local itemID = GetItemID(link) + if itemID then + AddCount(index, itemID, 1, "equipped", + charKey, charName, charData, true, false) + end + end + end +end + +function InventoryIndex:InvalidateCurrent() + currentDirty = true +end + +function InventoryIndex:InvalidateAll() + staticDirty = true + currentDirty = true +end + +local function IsIncludedCharacter(charName, charData, currentRealm) + if type(charData) ~= "table" then return false end + if charData.realm ~= currentRealm then return false end + if addon.Modules.DB and addon.Modules.DB.IsGoldBlacklisted + and addon.Modules.DB:IsGoldBlacklisted(charName) then + return false + end + return true +end + +function InventoryIndex:BuildStaticIndex() + local index = NewIndex() + local currentPlayerName = addon.Modules.DB:GetPlayerFullName() + local currentRealm = GetRealmName() + + local function AddSource(data, isShared) + if type(data) ~= "table" then return end + + for charName, charData in pairs(data) do + if charName ~= currentPlayerName + and IsIncludedCharacter(charName, charData, currentRealm) then + local charKey = (isShared and "shared:" or "local:") .. charName + IndexContainers(index, charData.bags, "bags", charKey, charName, charData, false, isShared) + IndexContainers(index, charData.bank, "bank", charKey, charName, charData, false, isShared) + IndexMailbox(index, charData.mailbox, charKey, charName, charData, false, isShared) + IndexSavedEquipped(index, charData.equipped, charKey, charName, charData, false, isShared) + end + end + end + + AddSource(Guda_DB and Guda_DB.characters, false) + AddSource(addon.sharedCharacters, true) + + staticIndex = index + staticDirty = false +end + +function InventoryIndex:BuildCurrentIndex() + local index = NewIndex() + local currentPlayerName = addon.Modules.DB:GetPlayerFullName() + local charData = Guda_DB and Guda_DB.characters and Guda_DB.characters[currentPlayerName] + + if type(charData) ~= "table" then + currentIndex = index + currentDirty = false + return + end + + local currentRealm = GetRealmName() + if not IsIncludedCharacter(currentPlayerName, charData, currentRealm) then + currentIndex = index + currentDirty = false + return + end + + local charKey = "current:" .. currentPlayerName + + -- Bags: use BagScanner's already-refreshed runtime snapshot. This is the + -- same state consumed by the bag UI, so tooltip counting performs no + -- independent container scan. + local bagData = nil + if addon.Modules.BagScanner and addon.Modules.BagScanner.GetBagData then + bagData = addon.Modules.BagScanner:GetBagData() + else + bagData = charData.bags + end + IndexContainers(index, bagData, "bags", charKey, currentPlayerName, charData, true, false) + + -- Bank: use live cached bank data only while the NPC bank session is open; + -- otherwise use the last persisted snapshot. + local bankData = charData.bank + if addon.Modules.BankScanner and addon.Modules.BankScanner.IsBankOpen + and addon.Modules.BankScanner:IsBankOpen() + and addon.Modules.BankScanner.GetBankData then + bankData = addon.Modules.BankScanner:GetBankData() + end + IndexContainers(index, bankData, "bank", charKey, currentPlayerName, charData, true, false) + + -- MailboxScanner persists changes with a short debounce. Reading the + -- snapshot here avoids rescanning 100+ mails from every item tooltip. + IndexMailbox(index, charData.mailbox, charKey, currentPlayerName, charData, true, false) + + -- Equipment is only 19 slots and is indexed once per invalidation instead + -- of once per mouseover. + IndexLiveEquipped(index, charKey, currentPlayerName, charData) + + currentIndex = index + currentDirty = false +end + +function InventoryIndex:GetItem(itemID) + itemID = tonumber(itemID) + if not itemID then return nil, nil end + + if staticDirty or not staticIndex then + self:BuildStaticIndex() + end + if currentDirty or not currentIndex then + self:BuildCurrentIndex() + end + + return currentIndex[itemID], staticIndex[itemID] +end + +function InventoryIndex:GetStats() + local staticItems = 0 + local currentItems = 0 + if staticIndex then for _ in pairs(staticIndex) do staticItems = staticItems + 1 end end + if currentIndex then for _ in pairs(currentIndex) do currentItems = currentItems + 1 end end + return { + staticItems = staticItems, + currentItems = currentItems, + staticDirty = staticDirty, + currentDirty = currentDirty, + } +end + +local function GetClassColor(classToken) + if not classToken then return 1.0, 1.0, 1.0 end + local color = RAID_CLASS_COLORS[classToken] + if color then return color.r, color.g, color.b end + return 1.0, 1.0, 1.0 +end + +local function CollectCharacters(currentEntry, staticEntry) + for i = 1, table.getn(displayCharacters) do + displayCharacters[i] = nil + end + + local index = 0 + if currentEntry and currentEntry.characters then + for _, entry in ipairs(currentEntry.characters) do + index = index + 1 + displayCharacters[index] = entry + end + end + if staticEntry and staticEntry.characters then + for _, entry in ipairs(staticEntry.characters) do + index = index + 1 + displayCharacters[index] = entry + end + end + + table.sort(displayCharacters, function(a, b) + if a.isShared ~= b.isShared then return not a.isShared end + if a.isCurrent and not b.isCurrent then return true end + if not a.isCurrent and b.isCurrent then return false end + return (a.name or "") < (b.name or "") + end) + + return displayCharacters +end + +-- Fast replacement for Tooltip:AddInventoryInfo. The expensive inventory walk +-- happens only when an index is dirty; normal mouseovers are direct itemID +-- lookups plus formatting. +local Tooltip = addon.Modules.Tooltip +if Tooltip then + function Tooltip:AddInventoryInfo(tooltip, link) + if not addon.Modules.DB:GetSetting("showTooltipCounts") then return end + if not Guda_DB or type(Guda_DB) ~= "table" then return end + if not Guda_DB.characters or type(Guda_DB.characters) ~= "table" then return end + + local itemID = GetItemID(link) + if not itemID then return end + + local currentEntry, staticEntry = InventoryIndex:GetItem(itemID) + if not currentEntry and not staticEntry then return end + + local totalBags = (currentEntry and currentEntry.totalBags or 0) + + (staticEntry and staticEntry.totalBags or 0) + local totalBank = (currentEntry and currentEntry.totalBank or 0) + + (staticEntry and staticEntry.totalBank or 0) + local totalMail = (currentEntry and currentEntry.totalMail or 0) + + (staticEntry and staticEntry.totalMail or 0) + local totalEquipped = (currentEntry and currentEntry.totalEquipped or 0) + + (staticEntry and staticEntry.totalEquipped or 0) + local totalCount = totalBags + totalBank + totalMail + totalEquipped + if totalCount <= 0 then return end + + tooltip:AddLine(" ") + tooltip:AddLine("|cFFFFD200" .. Guda_L["Inventory"] .. "|r") + + local totalText = "|cFF00FFFF" .. Guda_L["Total"] .. "|r: |cFFFFFFFF" .. totalCount .. "|r" + local bpIndex = 0 + if totalBags > 0 then bpIndex = bpIndex + 1; breakdownParts[bpIndex] = "|cFF00FFFF" .. Guda_L["Bags"] .. "|r: |cFFFFFFFF" .. totalBags .. "|r" end + if totalBank > 0 then bpIndex = bpIndex + 1; breakdownParts[bpIndex] = "|cFF00FFFF" .. Guda_L["Bank"] .. "|r: |cFFFFFFFF" .. totalBank .. "|r" end + if totalMail > 0 then bpIndex = bpIndex + 1; breakdownParts[bpIndex] = "|cFF00FFFF" .. Guda_L["Mail"] .. "|r: |cFFFFFFFF" .. totalMail .. "|r" end + if totalEquipped > 0 then bpIndex = bpIndex + 1; breakdownParts[bpIndex] = "|cFF00FFFF" .. Guda_L["Equipped"] .. "|r: |cFFFFFFFF" .. totalEquipped .. "|r" end + for i = bpIndex + 1, table.getn(breakdownParts) do breakdownParts[i] = nil end + + local breakdownText = "" + if bpIndex > 0 then + breakdownText = "(" .. table.concat(breakdownParts, " | ") .. ")" + end + tooltip:AddDoubleLine(totalText, breakdownText, 1, 1, 1, 1, 1, 1) + + local characters = CollectCharacters(currentEntry, staticEntry) + local sharedSeparatorShown = false + for _, charInfo in ipairs(characters) do + if charInfo.isShared and not sharedSeparatorShown then + tooltip:AddLine("|cFF80C0FF" .. Guda_L["Other Accounts"] .. "|r") + sharedSeparatorShown = true + end + + local r, g, b = GetClassColor(charInfo.classToken) + local pIndex = 0 + if charInfo.bagCount > 0 then pIndex = pIndex + 1; charParts[pIndex] = "|cFF00FFFF" .. Guda_L["Bags"] .. "|r: |cFFFFFFFF" .. charInfo.bagCount .. "|r" end + if charInfo.bankCount > 0 then pIndex = pIndex + 1; charParts[pIndex] = "|cFF00FFFF" .. Guda_L["Bank"] .. "|r: |cFFFFFFFF" .. charInfo.bankCount .. "|r" end + if charInfo.mailCount > 0 then pIndex = pIndex + 1; charParts[pIndex] = "|cFF00FFFF" .. Guda_L["Mail"] .. "|r: |cFFFFFFFF" .. charInfo.mailCount .. "|r" end + if charInfo.equippedCount > 0 then pIndex = pIndex + 1; charParts[pIndex] = "|cFF00FFFF" .. Guda_L["Equipped"] .. "|r: |cFFFFFFFF" .. charInfo.equippedCount .. "|r" end + for i = pIndex + 1, table.getn(charParts) do charParts[i] = nil end + + local countText = pIndex > 0 and table.concat(charParts, " | ") or "" + local displayName = charInfo.name or "?" + if charInfo.isCurrent then + displayName = displayName .. " |cFFFFFF00(*)|r" + end + tooltip:AddDoubleLine(displayName, countText, r, g, b, 1, 1, 1) + end + end + + -- Wrap Tooltip initialization so its legacy ClearCache definition is + -- replaced after the original hooks are installed. + local OriginalTooltipInitialize = Tooltip.Initialize + function Tooltip:Initialize() + OriginalTooltipInitialize(self) + + function Tooltip:ClearCache() + InventoryIndex:InvalidateCurrent() + addon:Debug("Tooltip inventory index invalidated") + end + + InventoryIndex:Initialize() + end +end + +function InventoryIndex:Initialize() + if eventFrame then return end + + eventFrame = CreateFrame("Frame", "Guda_InventoryIndexEvents") + eventFrame:RegisterEvent("BAG_UPDATE") + eventFrame:RegisterEvent("PLAYERBANKSLOTS_CHANGED") + eventFrame:RegisterEvent("PLAYERBANKBAGSLOTS_CHANGED") + eventFrame:RegisterEvent("BANKFRAME_OPENED") + eventFrame:RegisterEvent("BANKFRAME_CLOSED") + eventFrame:RegisterEvent("MAIL_INBOX_UPDATE") + eventFrame:RegisterEvent("MAIL_SHOW") + eventFrame:RegisterEvent("MAIL_CLOSED") + eventFrame:RegisterEvent("MAIL_SEND_SUCCESS") + eventFrame:RegisterEvent("UNIT_INVENTORY_CHANGED") + eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD") + + eventFrame:SetScript("OnEvent", function() + if event == "PLAYER_ENTERING_WORLD" then + InventoryIndex:InvalidateAll() + elseif event == "MAIL_SEND_SUCCESS" then + -- Sending mail may update another locally stored character too. + InventoryIndex:InvalidateAll() + elseif event == "UNIT_INVENTORY_CHANGED" then + if not arg1 or arg1 == "player" then + InventoryIndex:InvalidateCurrent() + end + else + InventoryIndex:InvalidateCurrent() + end + end) + + addon:Debug("ClassicAPI inventory count index initialized") +end