diff --git a/spec/System/TestRadiusJewelStatDiff_spec.lua b/spec/System/TestRadiusJewelStatDiff_spec.lua index 008fa01e54..64b8fc1454 100644 --- a/spec/System/TestRadiusJewelStatDiff_spec.lua +++ b/spec/System/TestRadiusJewelStatDiff_spec.lua @@ -122,6 +122,35 @@ local function setupAllocatedSocket() return spec, socketNode end +local function setupAllocatedSockets(count) + local spec = build.spec + local sockets = { } + local sortedSockets = { } + for _, node in pairs(spec.nodes) do + if node.isJewelSocket then + sortedSockets[#sortedSockets + 1] = node + end + end + table.sort(sortedSockets, function(a, b) + return a.id < b.id + end) + for _, socketNode in ipairs(sortedSockets) do + if allocatePathToNode(spec, socketNode) then + sockets[#sockets + 1] = socketNode + if #sockets >= count then + break + end + end + end + if #sockets < count then + pending("Could not allocate the requested number of jewel sockets for this tree layout") + return spec, sockets + end + spec:BuildAllDependsAndPaths() + runCallback("OnFrame") + return spec, sockets +end + local function rebuildBuild() build.buildFlag = true runCallback("OnFrame") @@ -172,6 +201,15 @@ local function newPlainJewel() "Implicits: 0\n") end +local function newSplitPersonality() + return new("Item", "Rarity: UNIQUE\n" .. + "Split Personality\n" .. + "Crimson Jewel\n" .. + "Implicits: 0\n" .. + "+5 to Strength\n" .. + "This Jewel's Socket has 25% increased effect per Allocated Passive Skill between it and your Class' starting location\n") +end + -- Helper: minimal Impossible Escape item. Uses "Radius: Small" and targets -- a specific keystone. The parser populates both impossibleEscapeKeystone -- and impossibleEscapeKeystones from the "in Radius of X" mod. @@ -597,4 +635,169 @@ describe("TestRadiusJewelStatDiff", function() "tooltip should contain a 'Removing this item' comparison header") end) + it("AddItemTooltip avoids rebuilding unused limited-unique socket comparisons without a target slot", function() + local spec, sockets = setupAllocatedSockets(2) + + local item = newThreadOfHope() + item.limit = 1 + equipJewelInSocket(item, sockets[1]) + spec:BuildAllDependsAndPaths() + runCallback("OnFrame") + + local specClass = getmetatable(spec) + local originalBuildAllDependsAndPaths = specClass.BuildAllDependsAndPaths + local rebuilds = 0 + specClass.BuildAllDependsAndPaths = function(self, ...) + rebuilds = rebuilds + 1 + return originalBuildAllDependsAndPaths(self, ...) + end + + local ok, err = pcall(function() + local tooltip = new("Tooltip") + build.itemsTab:AddItemTooltip(tooltip, item) + end) + specClass.BuildAllDependsAndPaths = originalBuildAllDependsAndPaths + if not ok then + error(err) + end + + assert.are.equals(1, rebuilds, + "limited unique radius jewels should rebuild only the same-unique slot that will be displayed") + end) + + it("AddItemTooltip reuses targeted radius jewel comparison specs until output changes", function() + local spec, sockets = setupAllocatedSockets(2) + + local item = newCustomLeapJewel("Cached Leap") + local slot = equipJewelInSocket(item, sockets[1]) + spec:BuildAllDependsAndPaths() + runCallback("OnFrame") + + local originalSlotOnlyTooltips = main.slotOnlyTooltips + main.slotOnlyTooltips = true + local specClass = getmetatable(spec) + local originalBuildAllDependsAndPaths = specClass.BuildAllDependsAndPaths + local rebuilds = 0 + specClass.BuildAllDependsAndPaths = function(self, ...) + rebuilds = rebuilds + 1 + return originalBuildAllDependsAndPaths(self, ...) + end + + local ok, err = pcall(function() + local tooltip = new("Tooltip") + build.itemsTab:AddItemTooltip(tooltip, item, slot) + tooltip = new("Tooltip") + build.itemsTab:AddItemTooltip(tooltip, item, slot) + assert.are.equals(1, rebuilds, + "targeted radius jewel hover should reuse its cached comparison spec") + + build.outputRevision = build.outputRevision + 1 + tooltip = new("Tooltip") + build.itemsTab:AddItemTooltip(tooltip, item, slot) + assert.are.equals(2, rebuilds, + "targeted radius jewel comparison spec cache should reset when output changes") + end) + specClass.BuildAllDependsAndPaths = originalBuildAllDependsAndPaths + main.slotOnlyTooltips = originalSlotOnlyTooltips + if not ok then + error(err) + end + end) + + it("AddItemTooltip skips UI path rebuilds for temporary radius jewel specs", function() + local spec, sockets = setupAllocatedSockets(2) + + local radiusItem = newThreadOfHope() + local radiusSlot = equipJewelInSocket(radiusItem, sockets[1]) + local splitItem = newSplitPersonality() + equipJewelInSocket(splitItem, sockets[2]) + spec:BuildAllDependsAndPaths() + runCallback("OnFrame") + + assert.is_true((spec.nodes[sockets[2].id].distanceToClassStart or 0) > 0, + "Split Personality socket should have a class-start distance in the base spec") + + local originalSlotOnlyTooltips = main.slotOnlyTooltips + main.slotOnlyTooltips = true + local specClass = getmetatable(spec) + local originalBuildPathFromNode = specClass.BuildPathFromNode + local originalSetNodeDistanceToClassStart = specClass.SetNodeDistanceToClassStart + local buildPathCalls = 0 + local distanceCalls = 0 + specClass.BuildPathFromNode = function(self, ...) + buildPathCalls = buildPathCalls + 1 + return originalBuildPathFromNode(self, ...) + end + specClass.SetNodeDistanceToClassStart = function(self, ...) + distanceCalls = distanceCalls + 1 + return originalSetNodeDistanceToClassStart(self, ...) + end + + local ok, err = pcall(function() + local tooltip = new("Tooltip") + build.itemsTab:AddItemTooltip(tooltip, radiusItem, radiusSlot) + end) + specClass.BuildPathFromNode = originalBuildPathFromNode + specClass.SetNodeDistanceToClassStart = originalSetNodeDistanceToClassStart + main.slotOnlyTooltips = originalSlotOnlyTooltips + if not ok then + error(err) + end + + assert.are.equals(0, buildPathCalls, + "temporary tooltip specs should not rebuild UI node paths") + assert.is_true(distanceCalls > 0, + "temporary tooltip specs should still refresh jewel socket distances used by calc") + end) + + it("AddItemTooltip reuses full radius jewel comparison outputs until output changes", function() + local spec, sockets = setupAllocatedSockets(2) + + local item = newCustomLeapJewel("Cached Full Leap") + local slot = equipJewelInSocket(item, sockets[1]) + spec:BuildAllDependsAndPaths() + runCallback("OnFrame") + + local originalSlotOnlyTooltips = main.slotOnlyTooltips + main.slotOnlyTooltips = false + build.itemsTab.jewelComparisonOutputCache = nil + build.itemsTab.targetedJewelComparisonSpecCache = nil + + local originalGetMiscCalculator = build.calcsTab.GetMiscCalculator + local calcCalls = 0 + build.calcsTab.GetMiscCalculator = function(self, ...) + local calcFunc, calcBase = originalGetMiscCalculator(self, ...) + return function(...) + calcCalls = calcCalls + 1 + return calcFunc(...) + end, calcBase + end + + local ok, err = pcall(function() + local tooltip = new("Tooltip") + build.itemsTab:AddItemTooltip(tooltip, item, slot) + local firstPassCalcCalls = calcCalls + assert.is_true(firstPassCalcCalls > 0, + "full radius jewel tooltip should calculate outputs on first pass") + + tooltip = new("Tooltip") + build.itemsTab:AddItemTooltip(tooltip, item, slot) + local secondPassCalcCalls = calcCalls - firstPassCalcCalls + assert.is_true(secondPassCalcCalls < firstPassCalcCalls, + "full radius jewel tooltip should reuse cached radius outputs on second pass") + + build.outputRevision = build.outputRevision + 1 + local beforeInvalidationCalcCalls = calcCalls + tooltip = new("Tooltip") + build.itemsTab:AddItemTooltip(tooltip, item, slot) + assert.is_true(calcCalls - beforeInvalidationCalcCalls > secondPassCalcCalls, + "full radius jewel output cache should reset when output changes") + end) + build.calcsTab.GetMiscCalculator = originalGetMiscCalculator + main.slotOnlyTooltips = originalSlotOnlyTooltips + if not ok then + error(err) + end + end) + end) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index 24506313c6..bad5982642 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -3727,6 +3727,7 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) local hoverX, hoverY = 0, 0 local hoverW, hoverH = 0, 0 local hoverItemsTab = nil + local hoverSlotName = nil -- Track item copy button clicks local clickedCopySlot = nil @@ -3808,6 +3809,7 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) if rowHoverItem then hoverItem = rowHoverItem hoverItemsTab = rowHoverItemsTab + hoverSlotName = pHover and equipSlotName or cHover and copySlotName or nil hoverX, hoverY = rowHoverX, rowHoverY hoverW, hoverH = rowHoverW, rowHoverH end @@ -3890,8 +3892,10 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) SetViewport() local maxTooltipWidth = m_min(600, m_max(260, vp.width - 24)) if hoverItem and hoverItemsTab then - self.itemTooltip:Clear() - hoverItemsTab:AddItemTooltip(self.itemTooltip, hoverItem, nil, nil, maxTooltipWidth) + local hoverBuild = hoverItemsTab.build + if self.itemTooltip:CheckForUpdate(hoverItemsTab, hoverItem, hoverSlotName, maxTooltipWidth, main.slotOnlyTooltips, launch.devModeAlt, hoverBuild and hoverBuild.outputRevision) then + hoverItemsTab:AddItemTooltip(self.itemTooltip, hoverItem, hoverSlotName, nil, maxTooltipWidth) + end SetDrawLayer(nil, 100) self.itemTooltip:Draw(vp.x + hoverX, vp.y + checkboxOffset + hoverY, hoverW, hoverH, vp) SetDrawLayer(nil, 0) diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 10b3140f61..0d2a5d1dba 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -3798,6 +3798,37 @@ local function itemChangesPassiveTreeRadius(item) and (item.jewelData.conqueredBy or item.jewelData.intuitiveLeapLike or item.jewelData.impossibleEscapeKeystone)) end +local function getStoredItemId(itemsTab, item) + if not item then + return "" + end + local itemId = item.id + if itemId and itemsTab.items[itemId] == item then + return tostring(itemId) + end +end + +local function getJewelComparisonOutputCache(itemsTab) + local outputRevision = itemsTab.build and itemsTab.build.outputRevision or 0 + local cache = itemsTab.jewelComparisonOutputCache + if not cache or cache.outputRevision ~= outputRevision then + cache = { + outputRevision = outputRevision, + outputs = { }, + } + itemsTab.jewelComparisonOutputCache = cache + end + return cache +end + +local function getJewelComparisonOutputCacheKey(itemsTab, compareSlot, replacementItem) + local replacementItemId = getStoredItemId(itemsTab, replacementItem) + if not replacementItemId then + return + end + return tostring(compareSlot.slotName) .. ":" .. tostring(compareSlot.nodeId or "") .. ":" .. tostring(compareSlot.selItemId or "") .. ":" .. replacementItemId +end + -- Radius jewels can change conquered nodes and orphaned allocations, so compare -- against a rebuilt spec instead of approximating the diff with removeNodes. -- Keep this list in sync with PassiveSpec's constructor, Init, and Select* @@ -3834,7 +3865,7 @@ local function cloneSpecForJewelComparison(spec) local nodeCopy = setmetatable({ }, getmetatable(node)) for key, value in pairs(node) do if key ~= "linked" and key ~= "depends" and key ~= "intuitiveLeapLikesAffecting" - and key ~= "path" and key ~= "power" then + and key ~= "path" and key ~= "pathDist" and key ~= "distanceToClassStart" and key ~= "power" then nodeCopy[key] = value end end @@ -3874,12 +3905,33 @@ local function cloneSpecForJewelComparison(spec) return specCopy end -local function buildSpecForJewelComparison(itemsTab, compareSlot, replacementItem) +local function buildSpecForJewelComparison(itemsTab, compareSlot, replacementItem, useCache) local tempItemId + local replacementItemId = replacementItem and replacementItem.id + local replacementItemIsStored = replacementItemId and itemsTab.items[replacementItemId] == replacementItem + local canCache = useCache and (not replacementItem or replacementItemIsStored) + local cacheKey + local cache + if canCache then + local outputRevision = itemsTab.build and itemsTab.build.outputRevision or 0 + cache = itemsTab.targetedJewelComparisonSpecCache + if not cache or cache.outputRevision ~= outputRevision then + cache = { + outputRevision = outputRevision, + specs = { }, + } + itemsTab.targetedJewelComparisonSpecCache = cache + end + cacheKey = tostring(compareSlot.nodeId) .. ":" .. tostring(replacementItemId or "") + if cache.specs[cacheKey] then + return cache.specs[cacheKey] + end + end + local spec = cloneSpecForJewelComparison(itemsTab.build.spec) if replacementItem then - if replacementItem.id and itemsTab.items[replacementItem.id] == replacementItem then - spec.jewels[compareSlot.nodeId] = replacementItem.id + if replacementItemIsStored then + spec.jewels[compareSlot.nodeId] = replacementItemId else tempItemId = -1 while itemsTab.items[tempItemId] do @@ -3893,7 +3945,8 @@ local function buildSpecForJewelComparison(itemsTab, compareSlot, replacementIte end local ok, err = xpcall(function() - spec:BuildAllDependsAndPaths() + -- Tooltip comparison specs only need calc state; node paths are UI data. + spec:BuildAllDependsAndPaths(true) end, debug.traceback) if tempItemId then itemsTab.items[tempItemId] = nil @@ -3901,6 +3954,9 @@ local function buildSpecForJewelComparison(itemsTab, compareSlot, replacementIte if not ok then error(err, 0) end + if cacheKey then + cache.specs[cacheKey] = spec + end return spec end @@ -4537,18 +4593,32 @@ function ItemsTabClass:AddItemTooltip(tooltip, item, slot, dbMode, maxWidth) tooltip:AddLine(14, colorCodes.TIP .. "Tip: Press Ctrl+D to disable the display of stat differences.") - local function getReplacedItemAndOutput(compareSlot) - local selItem = self.items[compareSlot.selItemId] + local function getReplacedItemAndOutput(compareSlot, selItem, useJewelComparisonSpecCache, useJewelComparisonOutputCache) + selItem = selItem or self.items[compareSlot.selItemId] local override = { repSlotName = compareSlot.slotName, repItem = item ~= selItem and item or nil } + local outputCache + local outputCacheKey if compareSlot.nodeId and (itemChangesPassiveTreeRadius(selItem) or itemChangesPassiveTreeRadius(item)) then - override.spec = buildSpecForJewelComparison(self, compareSlot, override.repItem) + if useJewelComparisonOutputCache then + outputCacheKey = getJewelComparisonOutputCacheKey(self, compareSlot, override.repItem) + if outputCacheKey then + outputCache = getJewelComparisonOutputCache(self) + if outputCache.outputs[outputCacheKey] then + return selItem, outputCache.outputs[outputCacheKey] + end + end + end + override.spec = buildSpecForJewelComparison(self, compareSlot, override.repItem, useJewelComparisonSpecCache) end local output = calcFunc(override) + if outputCacheKey then + outputCache.outputs[outputCacheKey] = output + end return selItem, output end - local function addCompareForSlot(compareSlot, selItem, output) + local function addCompareForSlot(compareSlot, selItem, output, useJewelComparisonSpecCache) if not selItem or not output then - selItem, output = getReplacedItemAndOutput(compareSlot) + selItem, output = getReplacedItemAndOutput(compareSlot, nil, useJewelComparisonSpecCache) end local header if item == selItem then @@ -4561,29 +4631,38 @@ function ItemsTabClass:AddItemTooltip(tooltip, item, slot, dbMode, maxWidth) -- if we have a specific slot to compare to, and the user has "Show -- tooltips only for affected slots" checked, we can just compare that - -- one slot + -- one slot. + local compareOnlySlot = type(slot) ~= "string" and slot or self.slots[slot] if main.slotOnlyTooltips and slot then - slot = type(slot) ~= "string" and slot or self.slots[slot] - if slot then addCompareForSlot(slot) end + if compareOnlySlot then addCompareForSlot(compareOnlySlot, nil, nil, true) end return end - - local slots = {} local isUnique = item.rarity == "UNIQUE" or item.rarity == "RELIC" local currentSameUniqueCount = 0 + local slotCandidates = {} for _, compareSlot in ipairs(compareSlots) do - local selItem, output = getReplacedItemAndOutput(compareSlot) + local selItem = self.items[compareSlot.selItemId] local isSameUnique = isUnique and selItem and item.name == selItem.name if isUnique and isSameUnique and item.limit then currentSameUniqueCount = currentSameUniqueCount + 1 end - table.insert(slots, - { selItem = selItem, output = output, compareSlot = compareSlot, isSameUnique = isSameUnique }) + table.insert(slotCandidates, + { selItem = selItem, compareSlot = compareSlot, isSameUnique = isSameUnique }) + end + local isLimitedUniqueAtLimit = (isUnique and item.limit and currentSameUniqueCount == item.limit) or false + + local slots = {} + for _, slotEntry in ipairs(slotCandidates) do + if not isLimitedUniqueAtLimit or slotEntry.isSameUnique then + local _, output = getReplacedItemAndOutput(slotEntry.compareSlot, slotEntry.selItem, nil, true) + slotEntry.output = output + table.insert(slots, slotEntry) + end end -- limited uniques: only compare to slots with the same item if more don't fit - if currentSameUniqueCount == item.limit then + if isLimitedUniqueAtLimit then for _, slotEntry in ipairs(slots) do if slotEntry.isSameUnique then addCompareForSlot(slotEntry.compareSlot, slotEntry.selItem, slotEntry.output) diff --git a/src/Classes/PassiveSpec.lua b/src/Classes/PassiveSpec.lua index e28e74a86b..a806a2d04c 100644 --- a/src/Classes/PassiveSpec.lua +++ b/src/Classes/PassiveSpec.lua @@ -1088,7 +1088,7 @@ function PassiveSpecClass:NodesInIntuitiveLeapLikeRadius(node) end -- Rebuilds dependencies and paths for all nodes -function PassiveSpecClass:BuildAllDependsAndPaths() +function PassiveSpecClass:BuildAllDependsAndPaths(skipNodePathRebuild) -- This table will keep track of which nodes have been visited during each path-finding attempt local visited = { } local attributes = { "Dexterity", "Intelligence", "Strength" } @@ -1546,15 +1546,22 @@ function PassiveSpecClass:BuildAllDependsAndPaths() -- Reset and rebuild all node paths for id, node in pairs(self.nodes) do - node.pathDist = (node.alloc and #node.intuitiveLeapLikesAffecting == 0) and 0 or 1000 - node.path = nil + if skipNodePathRebuild then + node.pathDist = nil + node.path = nil + else + node.pathDist = (node.alloc and #node.intuitiveLeapLikesAffecting == 0) and 0 or 1000 + node.path = nil + end if node.isJewelSocket or node.expansionJewel then node.distanceToClassStart = 0 end end for id, node in pairs(self.allocNodes) do if #node.intuitiveLeapLikesAffecting == 0 or node.connectedToStart then - self:BuildPathFromNode(node) + if not skipNodePathRebuild then + self:BuildPathFromNode(node) + end if node.isJewelSocket or node.expansionJewel then self:SetNodeDistanceToClassStart(node) end