diff --git a/spec/System/TestRadiusJewelStatDiff_spec.lua b/spec/System/TestRadiusJewelStatDiff_spec.lua index 0fd2fa0389..36c4f7bbc5 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"):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. @@ -600,4 +638,215 @@ 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"):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 slot-only radius jewel comparison outputs 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 + build.itemsTab.jewelComparisonOutputCache = nil + local specClass = getmetatable(spec) + local originalBuildAllDependsAndPaths = specClass.BuildAllDependsAndPaths + local rebuilds = 0 + specClass.BuildAllDependsAndPaths = function(self, ...) + rebuilds = rebuilds + 1 + return originalBuildAllDependsAndPaths(self, ...) + end + 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"):Tooltip() + build.itemsTab:AddItemTooltip(tooltip, item, slot) + local firstPassTooltipText = tooltipText(tooltip) + local firstPassCalcCalls = calcCalls + assert.is_true(firstPassCalcCalls > 0, + "slot-only radius jewel hover should calculate its output on first pass") + tooltip = new("Tooltip"):Tooltip() + build.itemsTab:AddItemTooltip(tooltip, item, slot) + assert.are.equals(firstPassTooltipText, tooltipText(tooltip), + "cached slot-only radius output should preserve tooltip content") + assert.are.equals(1, rebuilds, + "slot-only radius jewel hover should reuse its cached comparison output") + assert.are.equals(firstPassCalcCalls, calcCalls, + "slot-only radius jewel hover should not recalculate a cached output") + + build.outputRevision = build.outputRevision + 1 + tooltip = new("Tooltip"):Tooltip() + build.itemsTab:AddItemTooltip(tooltip, item, slot) + assert.are.equals(2, rebuilds, + "slot-only radius jewel output cache should reset when output changes") + assert.is_true(calcCalls > firstPassCalcCalls, + "slot-only radius jewel comparison should recalculate after output changes") + end) + specClass.BuildAllDependsAndPaths = originalBuildAllDependsAndPaths + build.calcsTab.GetMiscCalculator = originalGetMiscCalculator + 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 originalBuildAllDependsAndPaths = specClass.BuildAllDependsAndPaths + local originalBuildPathFromNode = specClass.BuildPathFromNode + local originalSetNodeDistanceToClassStart = specClass.SetNodeDistanceToClassStart + local originalBuildSplitPersonalityPath = specClass.BuildSplitPersonalityPath + local buildPathCalls = 0 + local distanceCalls = 0 + local splitPersonalityPathCalls = 0 + specClass.BuildPathFromNode = function(self, ...) + buildPathCalls = buildPathCalls + 1 + return originalBuildPathFromNode(self, ...) + end + specClass.SetNodeDistanceToClassStart = function(self, ...) + distanceCalls = distanceCalls + 1 + return originalSetNodeDistanceToClassStart(self, ...) + end + specClass.BuildSplitPersonalityPath = function(self, ...) + splitPersonalityPathCalls = splitPersonalityPathCalls + 1 + return originalBuildSplitPersonalityPath(self, ...) + end + + local ok, err = pcall(function() + local tooltip = new("Tooltip"):Tooltip() + build.itemsTab:AddItemTooltip(tooltip, radiusItem, radiusSlot) + local calculationOnlyTooltipText = tooltipText(tooltip) + assert.are.equals(0, buildPathCalls, + "calculation-only tooltip specs should not rebuild UI node paths") + assert.is_true(distanceCalls > 0, + "calculation-only tooltip specs should refresh jewel socket distances used by calc") + assert.are.equals(0, splitPersonalityPathCalls, + "calculation-only tooltip specs should not rebuild Split Personality highlight paths") + + build.itemsTab.jewelComparisonOutputCache = nil + specClass.BuildAllDependsAndPaths = function(self) + return originalBuildAllDependsAndPaths(self) + end + local fullRebuildTooltip = new("Tooltip"):Tooltip() + build.itemsTab:AddItemTooltip(fullRebuildTooltip, radiusItem, radiusSlot) + assert.are.equals(calculationOnlyTooltipText, tooltipText(fullRebuildTooltip), + "calculation-only radius jewel specs should preserve full-rebuild tooltip output") + assert.is_true(splitPersonalityPathCalls > 0, + "the full-rebuild comparison should exercise Split Personality highlight paths") + end) + specClass.BuildAllDependsAndPaths = originalBuildAllDependsAndPaths + specClass.BuildPathFromNode = originalBuildPathFromNode + specClass.SetNodeDistanceToClassStart = originalSetNodeDistanceToClassStart + specClass.BuildSplitPersonalityPath = originalBuildSplitPersonalityPath + main.slotOnlyTooltips = originalSlotOnlyTooltips + if not ok then + error(err) + end + + 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 + + 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"):Tooltip() + build.itemsTab:AddItemTooltip(tooltip, item, slot) + local firstPassCalcCalls = calcCalls + local firstPassTooltipText = tooltipText(tooltip) + assert.is_true(firstPassCalcCalls > 0, + "full radius jewel tooltip should calculate outputs on first pass") + + tooltip = new("Tooltip"):Tooltip() + build.itemsTab:AddItemTooltip(tooltip, item, slot) + assert.are.equals(firstPassTooltipText, tooltipText(tooltip), + "cached radius outputs should preserve tooltip content") + 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"):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 eca7261903..8f2667d586 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -3777,6 +3777,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 @@ -3878,6 +3879,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 @@ -3960,8 +3962,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, IsKeyDown("SHIFT"), 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) @@ -3969,7 +3973,7 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents) -- Draw stat comparison tooltip when hovering Equip button if hoverEquipItem and hoverEquipSlotName and not hoverItem then - self.itemTooltip:Clear() + self.itemTooltip:Clear(true) self.itemTooltip.maxWidth = maxTooltipWidth local calcFunc, calcBase = self.calcs.getMiscCalculator(self.primaryBuild) if calcFunc then diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 3ae6e046aa..6968a2a01c 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -4232,6 +4232,39 @@ local function itemChangesPassiveTree(item) and (item.jewelData.intuitiveLeapLike or item.jewelData.impossibleEscapeKeystone))) end +-- An empty key part represents no replacement; replacement items without a stored ID +-- return nil so their output is not cached. +local function getJewelComparisonItemCacheKeyPart(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 replacementItemKeyPart = getJewelComparisonItemCacheKeyPart(itemsTab, replacementItem) + if not replacementItemKeyPart then + return + end + return tostring(compareSlot.slotName) .. ":" .. tostring(compareSlot.nodeId or "") .. ":" .. tostring(compareSlot.selItemId or "") .. ":" .. replacementItemKeyPart +end + -- These jewels can replace passive nodes or disconnect allocated passives, so -- rebuild the passive tree before comparing their stats. -- Keep this list in sync with PassiveSpec's constructor, Init, and Select* @@ -4268,7 +4301,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 @@ -4327,7 +4360,10 @@ local function buildSpecForJewelComparison(itemsTab, compareSlot, replacementIte end local ok, err = xpcall(function() - spec:BuildAllDependsAndPaths() + -- These temporary specs only feed the misc calculator, which does not read regular + -- node paths or Split Personality highlight paths. Jewel socket distances are still + -- rebuilt for Split Personality modifier scaling. + spec:BuildAllDependsAndPaths(true) end, debug.traceback) if tempItemId then itemsTab.items[tempItemId] = nil @@ -5058,13 +5094,25 @@ function ItemsTabClass:AddItemTooltip(tooltip, item, slot, dbMode, maxWidth) tooltip:AddLine(14, colorCodes.TIP .. "Tip: Press Ctrl+D"..itemTabHint.." to disable the display of stat differences.") - local function getReplacedItemAndOutput(compareSlot) - local selItem = self.items[compareSlot.selItemId] + local function getReplacedItemAndOutput(compareSlot, selItem) + 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 (itemChangesPassiveTree(selItem) or itemChangesPassiveTree(item)) 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 override.spec = buildSpecForJewelComparison(self, compareSlot, override.repItem) end local output = calcFunc(override) + if outputCacheKey then + outputCache.outputs[outputCacheKey] = output + end return selItem, output end local function addCompareForSlot(compareSlot, selItem, output) @@ -5082,29 +5130,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) 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) + 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 cd16dd659f..ba9cf5458e 100644 --- a/src/Classes/PassiveSpec.lua +++ b/src/Classes/PassiveSpec.lua @@ -1098,8 +1098,11 @@ function PassiveSpecClass:NodesInIntuitiveLeapLikeRadius(node) return result end --- Rebuilds dependencies and paths for all nodes -function PassiveSpecClass:BuildAllDependsAndPaths() +-- Rebuilds dependencies and calculation distances for all nodes. +-- Calculation-only specs leave UI path fields unset while still refreshing jewel +-- socket distanceToClassStart values used by the calculator. +---@param calculationOnly? boolean +function PassiveSpecClass:BuildAllDependsAndPaths(calculationOnly) local timelessJewelTypeByConqueror = { vaal = 1, karui = 2, @@ -1597,22 +1600,31 @@ 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 calculationOnly 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 calculationOnly then + self:BuildPathFromNode(node) + end if node.isJewelSocket or node.expansionJewel then self:SetNodeDistanceToClassStart(node) end end end - self:BuildSplitPersonalityPath() + if not calculationOnly then + self:BuildSplitPersonalityPath() + end end function PassiveSpecClass:ReplaceNode(old, newNode)