From 34be14e7f2f5fe3bcf25ce8b8af6def965503944 Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:41:07 +0300 Subject: [PATCH 1/3] Optimise FindStartFromNode() somewhat --- src/Classes/PassiveSpec.lua | 474 +++++++++++++++++++++--------------- 1 file changed, 272 insertions(+), 202 deletions(-) diff --git a/src/Classes/PassiveSpec.lua b/src/Classes/PassiveSpec.lua index 3e64512407..ec89ad260b 100644 --- a/src/Classes/PassiveSpec.lua +++ b/src/Classes/PassiveSpec.lua @@ -865,15 +865,19 @@ function PassiveSpecClass:CountAllocNodes() return used, ascUsed, secondaryAscUsed, sockets end +---@type table Nodes sorted by distance from the source of the graph +local neighboursForNode = {} -- Attempt to find a class start node starting from the given node -- Unless noAscent == true it will also look for an ascendancy class start node function PassiveSpecClass:FindStartFromNode(node, visited, noAscend) -- Mark the current node as visited so we don't go around in circles node.visited = true t_insert(visited, node) - -- For each node which is connected to this one, check if... local nodeAscendancy = node.ascendancyName - for _, other in ipairs(node.linked) do + local neighbours = neighboursForNode[node.id] or node.linked + -- For each node which is connected to this one, check if... + for i = 1, #neighbours do + local other = neighbours[i] -- Either: -- - the other node is a start node, or -- - there is a path to a start node through the other node which didn't pass through any nodes which have already been visited @@ -1088,6 +1092,267 @@ function PassiveSpecClass:NodesInIntuitiveLeapLikeRadius(node) return result end +-- For each node u, gather a list of nodes, which would become orphans if u were to be unallocated +function PassiveSpecClass:FindDependencies() + -- This table will keep track of which nodes have been visited during each path-finding attempt + local visited = {} + local potentialDeps = {} + local intuitiveLeaps = {} + for _, node in pairs(self.allocNodes) do + node.visited = true + node.connectedToStart = false + local anyStartFound = (node.type == "ClassStart" or node.type == "AscendClassStart") + local nodeId = node.id + for _, other in ipairs(node.linked) do + if other.alloc and not isValueInArray(node.depends, other) then + -- The other node is allocated and isn't already dependent on this node, so try and find a path to a start node through it + if other.type == "ClassStart" or other.type == "AscendClassStart" then + -- Well that was easy! + anyStartFound = true + node.connectedToStart = true + elseif self:FindStartFromNode(other, visited) then + -- We found a path through the other node, therefore the other node cannot be dependent on this node + anyStartFound = true + node.connectedToStart = true + for i, n in ipairs(visited) do + n.visited = false + visited[i] = nil + end + else + -- No path was found, so all the nodes visited while trying to find the path must be dependent on this node + -- except for mastery nodes that have linked allocated nodes that weren't visited + local depIds = {} + for i = 1, #visited do + local n = visited[i] + if #n.intuitiveLeapLikesAffecting == 0 then + depIds[n.id] = true + end + end + for i = 1, #visited do + local n = visited[i] + if n.type == "Mastery" then + local otherPath = false + local allocatedLinkCount = 0 + for _, linkedNode in ipairs(n.linked) do + if linkedNode.alloc then + allocatedLinkCount = allocatedLinkCount + 1 + end + end + if allocatedLinkCount > 1 then + for _, linkedNode in ipairs(n.linked) do + if linkedNode.alloc and not depIds[linkedNode.id] then + otherPath = true + end + end + end + if not otherPath then + t_insert(node.depends, n) + end + else + -- If n is dependent on intuitive leap then it may be dependent on this node + if #n.intuitiveLeapLikesAffecting > 0 then + if not potentialDeps[n.id] then + potentialDeps[n.id] = {} + end + + t_insert(potentialDeps[n.id], node) + else + t_insert(node.depends, n) + end + + -- If n is a jewel socket containing an intuitive leap-like jewel, nodes in its radius (or the radius of the keystone) + -- may be dependent on this node if they're found to be unconnected to the start + if not intuitiveLeaps[node.id] then + intuitiveLeaps[node.id] = self:NodesInIntuitiveLeapLikeRadius(n) + else + for _, affectedNode in ipairs(self:NodesInIntuitiveLeapLikeRadius(n)) do + t_insert(intuitiveLeaps[nodeId], affectedNode) + end + end + end + n.visited = false + visited[i] = nil + end + end + end + end + node.visited = false + if not anyStartFound then + -- No start nodes were found through ANY nodes + -- Therefore this node and all nodes depending on it are orphans and should be pruned + for _, depNode in ipairs(node.depends) do + local prune = true + for nodeId, itemId in pairs(self.jewels) do + if self.allocNodes[nodeId] then + if itemId ~= 0 and ( + self.build.itemsTab.items[itemId] and ( + self.build.itemsTab.items[itemId].jewelData + and self.build.itemsTab.items[itemId].jewelData.intuitiveLeapLike + and self.build.itemsTab.items[itemId].jewelRadiusIndex + and self.nodes[nodeId].nodesInRadius + and self.nodes[nodeId].nodesInRadius[self.build.itemsTab.items[itemId].jewelRadiusIndex][depNode.id] + ) or ( + self.build.itemsTab.items[itemId].jewelData + and self.build.itemsTab.items[itemId].jewelData.impossibleEscapeKeystones + and self:NodeInKeystoneRadius(self.build.itemsTab.items[itemId].jewelData.impossibleEscapeKeystones, depNode.id, self.build.itemsTab.items[itemId].jewelRadiusIndex) + ) + ) then + -- Hold off on the pruning; this node could be supported by Intuitive Leap-like jewel + prune = false + if not intuitiveLeaps[nodeId] then + intuitiveLeaps[nodeId] = {} + end + t_insert(intuitiveLeaps[nodeId], depNode) + break + end + end + end + if prune then + self:DeallocSingleNode(depNode) + end + end + end + end + local seen = {} + -- All other dependencies resolved, add dependencies to nodes affected by + -- intuitive leap-like jewels Nodes that are unconnected to the start depend + -- on all nodes that connect the jewel socket to the start Nodes that are + -- connected to the start depend on all nodes that connect them *and* the + -- jewel socket to the start In both cases, the nodes can be affected by + -- multiple jewels at the same time + for id, deps in pairs(potentialDeps) do + local potentialNode = self.nodes[id] + wipeTable(seen) + for _, node in ipairs(deps) do + local allDep = true + for _, intuitiveLeapLikeProvider in pairs(potentialNode.intuitiveLeapLikesAffecting) do + if not isValueInArray(node.depends, intuitiveLeapLikeProvider) then + allDep = false + end + end + if allDep and not seen[node.id] then + t_insert(node.depends, potentialNode) + seen[node.id] = true + end + end + end + + for id, deps in pairs(intuitiveLeaps) do + local node = self.nodes[id] + wipeTable(seen) + for _, dep in ipairs(deps) do + if not dep.connectedToStart then + local allDep = true + for _, intuitiveDep in ipairs(dep.intuitiveLeapLikesAffecting) do + if not isValueInArray(node.depends, intuitiveDep) then + allDep = false + break + end + end + if allDep and not seen[dep.id] then + t_insert(node.depends, dep) + seen[dep.id] = true + end + end + end + end +end + +function PassiveSpecClass:SortNodesByStartDist() + local qLen = 0 + local q = {} + local dist = {} + for id, node in pairs(self.allocNodes) do + if node.type == "ClassStart" or node.type == "AscendClassStart" then + dist[id] = 0 + qLen += 1 + q[qLen] = node + end + end + local qStart = 1 + -- perform bfs to find distances + while qStart <= qLen do + local node = q[qStart] + qStart += 1 + -- mastery nodes cannot be pathed out of + if node.type ~= "Mastery" then + local nodeDist = dist[node.id] + local neighbours = node.linked + for i = 1, #neighbours do + local other = neighbours[i] + local otherId = other.id + if other.alloc and dist[otherId] == nil then + dist[otherId] = nodeDist + 1 + qLen += 1 + q[qLen] = neighbours[i] + end + end + end + end + for id, node in pairs(self.allocNodes) do + local linked = node.linked + local sorted = {} + for i = 1, #linked do sorted[i] = linked[i] end + table.sort(sorted, function(a, b) + local distA = dist[a.id] or math.huge + local distB = dist[b.id] or math.huge + if distA ~= distB then + return distA < distB + end + return a.id < b.id + end) + neighboursForNode[id] = sorted + end +end + +-- Multi-source 0-1 BFS to find what other root (i.e., allocated) nodes each node is closest to +---@param roots Node[] A list of currently allocated, and other nodes which should be considered as the sources of distances. +function PassiveSpecClass:BuildNodePathsToRootNodes(roots) + local queue = {} + for _, node in ipairs(roots) do + node.pathDist = 0 + node.path = wipeTable(node.path) + t_insert(queue, node) + end + local queueStart = 1 + local queueLength = #queue + while queueStart <= queueLength do + local node = queue[queueStart] + queueStart = queueStart + 1 + local linked = node.linked + local nodeDist = node.pathDist + local nodePath = node.path + for i = 1, #linked do + local other = linked[i] + local weight = other.alloc and 0 or 1 + local distViaNode = nodeDist + weight + -- Paths cannot pass through start nodes, cross ascendancies, or move + -- away from masteries. Ascendant paths may leave at distance zero. + local canTraverse = node.type ~= "Mastery" + and other.type ~= "ClassStart" + and other.type ~= "AscendClassStart" + and (node.ascendancyName == other.ascendancyName or (nodeDist == 0 and not other.ascendancyName)) + if distViaNode < (other.pathDist or math.huge) and canTraverse then + if weight == 0 then + -- Free nodes go to the front so they can shorten paid paths immediately. + queueStart = queueStart - 1 + queue[queueStart] = other + else + queueLength = queueLength + 1 + queue[queueLength] = other + end + + other.pathDist = distViaNode + local path = wipeTable(other.path) + path[1] = other + for pathIndex = 1, #nodePath do + path[pathIndex + 1] = nodePath[pathIndex] + end + other.path = path + end + end + end +end -- Rebuilds dependencies and paths for all nodes function PassiveSpecClass:BuildAllDependsAndPaths() local timelessJewelTypeByConqueror = { @@ -1103,8 +1368,6 @@ function PassiveSpecClass:BuildAllDependsAndPaths() abyss_ghastly = 10, abyss_special = 11, } - -- This table will keep track of which nodes have been visited during each path-finding attempt - local visited = { } local attributes = { "Dexterity", "Intelligence", "Strength" } -- Read Abyss changes before resetting the nodes. Zorath needs the currently -- allocated path from its socket to the class starting node. @@ -1467,161 +1730,11 @@ function PassiveSpecClass:BuildAllDependsAndPaths() end end - local potentialDeps = { } - local intuitiveLeaps = { } - for id, node in pairs(self.allocNodes) do - node.visited = true - node.connectedToStart = false - local anyStartFound = (node.type == "ClassStart" or node.type == "AscendClassStart") - for _, other in ipairs(node.linked) do - if other.alloc and not isValueInArray(node.depends, other) then - -- The other node is allocated and isn't already dependent on this node, so try and find a path to a start node through it - if other.type == "ClassStart" or other.type == "AscendClassStart" then - -- Well that was easy! - anyStartFound = true - node.connectedToStart = true - elseif self:FindStartFromNode(other, visited) then - -- We found a path through the other node, therefore the other node cannot be dependent on this node - anyStartFound = true - node.connectedToStart = true - for i, n in ipairs(visited) do - n.visited = false - visited[i] = nil - end - else - -- No path was found, so all the nodes visited while trying to find the path must be dependent on this node - -- except for mastery nodes that have linked allocated nodes that weren't visited - local depIds = { } - for _, n in ipairs(visited) do - if #n.intuitiveLeapLikesAffecting == 0 then - depIds[n.id] = true - end - end - for i, n in ipairs(visited) do - if n.type == "Mastery" then - local otherPath = false - local allocatedLinkCount = 0 - for _, linkedNode in ipairs(n.linked) do - if linkedNode.alloc then - allocatedLinkCount = allocatedLinkCount + 1 - end - end - if allocatedLinkCount > 1 then - for _, linkedNode in ipairs(n.linked) do - if linkedNode.alloc and not depIds[linkedNode.id] then - otherPath = true - end - end - end - if not otherPath then - t_insert(node.depends, n) - end - else - -- If n is dependent on intuitive leap then it may be dependent on this node - if #n.intuitiveLeapLikesAffecting > 0 then - if not potentialDeps[n.id] then - potentialDeps[n.id] = { } - end - - t_insert(potentialDeps[n.id], node) - else - t_insert(node.depends, n) - end - - -- If n is a jewel socket containing an intuitive leap-like jewel, nodes in its radius (or the radius of the keystone) - -- may be dependent on this node if they're found to be unconnected to the start - if not intuitiveLeaps[node.id] then - intuitiveLeaps[node.id] = self:NodesInIntuitiveLeapLikeRadius(n) - else - for _, affectedNode in ipairs(self:NodesInIntuitiveLeapLikeRadius(n)) do - t_insert(intuitiveLeaps[node.id], affectedNode) - end - end - end - n.visited = false - visited[i] = nil - end - end - end - end - node.visited = false - if not anyStartFound then - -- No start nodes were found through ANY nodes - -- Therefore this node and all nodes depending on it are orphans and should be pruned - for _, depNode in ipairs(node.depends) do - local prune = true - for nodeId, itemId in pairs(self.jewels) do - if self.allocNodes[nodeId] then - if itemId ~= 0 and ( - self.build.itemsTab.items[itemId] and ( - self.build.itemsTab.items[itemId].jewelData - and self.build.itemsTab.items[itemId].jewelData.intuitiveLeapLike - and self.build.itemsTab.items[itemId].jewelRadiusIndex - and self.nodes[nodeId].nodesInRadius - and self.nodes[nodeId].nodesInRadius[self.build.itemsTab.items[itemId].jewelRadiusIndex][depNode.id] - ) or ( - self.build.itemsTab.items[itemId].jewelData - and self.build.itemsTab.items[itemId].jewelData.impossibleEscapeKeystones - and self:NodeInKeystoneRadius(self.build.itemsTab.items[itemId].jewelData.impossibleEscapeKeystones, depNode.id, self.build.itemsTab.items[itemId].jewelRadiusIndex) - ) - ) then - -- Hold off on the pruning; this node could be supported by Intuitive Leap-like jewel - prune = false - if not intuitiveLeaps[nodeId] then - intuitiveLeaps[nodeId] = { } - end - t_insert(intuitiveLeaps[nodeId], depNode) - break - end - end - end - if prune then - self:DeallocSingleNode(depNode) - end - end - end - end - - -- All other dependencies resolved, add dependencies to nodes affected by intuitive leap-like jewels - -- Nodes that are unconnected to the start depend on all nodes that connect the jewel socket to the start - -- Nodes that are connected to the start depend on all nodes that connect them *and* the jewel socket to the start - -- In both cases, the nodes can be affected by multiple jewels at the same time - for id, deps in pairs(potentialDeps) do - local potentialNode = self.nodes[id] - local seen = { } - for _, node in ipairs(deps) do - local allDep = true - for _, intuitiveLeapLikeProvider in pairs(potentialNode.intuitiveLeapLikesAffecting) do - if not isValueInArray(node.depends, intuitiveLeapLikeProvider) then - allDep = false - end - end - if allDep and not seen[node.id] then - t_insert(node.depends, potentialNode) - seen[node.id] = true - end - end - end + -- Sort neighbours for each node, so that we will approach the start node first during path finding + self:SortNodesByStartDist() + -- For each node v, gather a list of nodes that would become orphans if v were to be unallocated + self:FindDependencies() - for id, deps in pairs(intuitiveLeaps) do - local node = self.nodes[id] - local seen = { } - for _, dep in ipairs(deps) do - if not dep.connectedToStart then - local allDep = true - for _, intuitiveDep in ipairs(dep.intuitiveLeapLikesAffecting) do - if not isValueInArray(node.depends, intuitiveDep) then - allDep = false - break - end - end - if allDep and not seen[dep.id] then - t_insert(node.depends, dep) - seen[dep.id] = true - end - end - end - end -- Reset and rebuild all node paths for _, node in pairs(self.nodes) do @@ -1640,50 +1753,7 @@ function PassiveSpecClass:BuildAllDependsAndPaths() -- Use a multi-source 0-1 BFS to find the closest allocated node. Allocated -- nodes have zero weight, while each unallocated node costs one passive point. - local queue = { } - for _, node in ipairs(rootList) do - node.pathDist = 0 - node.path = wipeTable(node.path) - t_insert(queue, node) - end - local queueStart = 1 - local queueLength = #queue - while queueStart <= queueLength do - local node = queue[queueStart] - queueStart = queueStart + 1 - local linked = node.linked - local nodeDist = node.pathDist - local nodePath = node.path - for i = 1, #linked do - local other = linked[i] - local weight = other.alloc and 0 or 1 - local distViaNode = nodeDist + weight - -- Paths cannot pass through start nodes, cross ascendancies, or move - -- away from masteries. Ascendant paths may leave at distance zero. - local canTraverse = node.type ~= "Mastery" - and other.type ~= "ClassStart" - and other.type ~= "AscendClassStart" - and (node.ascendancyName == other.ascendancyName or (nodeDist == 0 and not other.ascendancyName)) - if distViaNode < (other.pathDist or math.huge) and canTraverse then - if weight == 0 then - -- Free nodes go to the front so they can shorten paid paths immediately. - queueStart = queueStart - 1 - queue[queueStart] = other - else - queueLength = queueLength + 1 - queue[queueLength] = other - end - - other.pathDist = distViaNode - local path = wipeTable(other.path) - path[1] = other - for pathIndex = 1, #nodePath do - path[pathIndex + 1] = nodePath[pathIndex] - end - other.path = path - end - end - end + self:BuildNodePathsToRootNodes(rootList) for _, node in ipairs(rootList) do if node.isJewelSocket or node.expansionJewel then From 3fa96ebc47c2c9bae189055d4002c7604d18abba Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:46:43 +0300 Subject: [PATCH 2/3] Link node path directly to parent node instead of copying a full array of the path to every node --- spec/System/TestTreeTab_spec.lua | 5 +- src/Classes/CalcsTab.lua | 20 ++++--- src/Classes/CompareTab.lua | 2 +- src/Classes/PassiveSpec.lua | 89 ++++++++++++++++++++++++++------ src/Classes/PassiveTree.lua | 1 - src/Classes/PassiveTreeView.lua | 33 ++++++------ src/Classes/TreeTab.lua | 8 +-- 7 files changed, 111 insertions(+), 47 deletions(-) diff --git a/spec/System/TestTreeTab_spec.lua b/spec/System/TestTreeTab_spec.lua index d8b7ae3340..b8e6d4f976 100644 --- a/spec/System/TestTreeTab_spec.lua +++ b/spec/System/TestTreeTab_spec.lua @@ -69,12 +69,11 @@ describe("TreeTab", function() { effect = 101 }, { effect = 102 }, }, - path = { parentNode, false }, + pathParent = parentNode, + pathLen = 2, x = 10, y = 20, } - masteryNode.path[2] = masteryNode - treeTab.build.displayStats = { { stat = "Damage", label = "Damage", fmt = ".1f" }, } diff --git a/src/Classes/CalcsTab.lua b/src/Classes/CalcsTab.lua index 7d79e865d9..2bf73e0243 100644 --- a/src/Classes/CalcsTab.lua +++ b/src/Classes/CalcsTab.lua @@ -534,10 +534,11 @@ function CalcsTabClass:PowerBuilder() return not assignedNodeId or assignedNodeId == node.id end + ---@param node Node local function calculateAddNodePower(power, distance, node, output, buildPathNodes) if self.powerStat and self.powerStat.stat and not self.powerStat.ignoreForNodes then power.singleStat = self:CalculatePowerStat(self.powerStat, output, calcBase) - if node.path and not node.ascendancyName then + if node.pathLen and not node.ascendancyName then newPowerMax.singleStat = m_max(newPowerMax.singleStat, power.singleStat) power.pathPower = power.singleStat if distance > 1 then @@ -547,7 +548,7 @@ function CalcsTabClass:PowerBuilder() elseif not self.powerStat or not self.powerStat.ignoreForNodes then power.offence, power.defence = self:CalculateCombinedOffDefStat(output, calcBase) power.singleStat = power.offence - if node.path and not node.ascendancyName then + if node.pathLen and not node.ascendancyName then newPowerMax.offence = m_max(newPowerMax.offence, power.offence) newPowerMax.defence = m_max(newPowerMax.defence, power.defence) newPowerMax.offencePerPoint = m_max(newPowerMax.offencePerPoint, power.offence / distance) @@ -609,6 +610,7 @@ function CalcsTabClass:PowerBuilder() break end for nodeId, node in pairs(nodes) do + ---@cast node Node if not node.alloc and node.modKey ~= "" and not self.mainEnv.grantedPassives[nodeId] then if not cache[node.modKey] then cache[node.modKey] = calcFunc({ addNodes = { [node] = true } }, useFullDPS) @@ -616,8 +618,10 @@ function CalcsTabClass:PowerBuilder() local output = cache[node.modKey] calculateAddNodePower(node.power, distance, node, output, function() local pathNodes = { } - for _, pathNode in pairs(node.path) do - pathNodes[pathNode] = true + local currentNode = node + while currentNode and currentNode.pathLen > 0 do + pathNodes[currentNode] = true + currentNode = currentNode.pathParent end return pathNodes end) @@ -679,10 +683,10 @@ function CalcsTabClass:PowerBuilder() local pathNodes = { [effectNode] = true } - for _, pathNode in pairs(node.path) do - if pathNode ~= node then - pathNodes[pathNode] = true - end + local currentNode = node.pathParent + while currentNode and currentNode.pathLen > 0 do + pathNodes[currentNode] = true + currentNode = currentNode.pathParent end return pathNodes end) diff --git a/src/Classes/CompareTab.lua b/src/Classes/CompareTab.lua index bcd470eab7..ec2d35541f 100644 --- a/src/Classes/CompareTab.lua +++ b/src/Classes/CompareTab.lua @@ -2676,7 +2676,7 @@ function CompareTabClass:ComparePowerBuilder(compareEntry, powerStat, categories local impact = self.primaryBuild.calcsTab:CalculatePowerStat(powerStat, output, calcBase) local pathDist = pNode.pathDist or 0 if pathDist == 0 then - pathDist = #(pNode.path or {}) + pathDist = pNode.pathLen or 0 if pathDist == 0 then pathDist = 1 end end local perPoint = impact / pathDist diff --git a/src/Classes/PassiveSpec.lua b/src/Classes/PassiveSpec.lua index ec89ad260b..6faf35b09d 100644 --- a/src/Classes/PassiveSpec.lua +++ b/src/Classes/PassiveSpec.lua @@ -16,6 +16,41 @@ local b_rshift = bit.rshift local band = bit.band local bor = bit.bor +-- Incomplete: tree data puts a lot more on a node than this +---@class Node +---@field id integer +---@field type "Normal"|"Notable"|"Keystone"|"Mastery"|"Socket"|"ClassStart"|"AscendClassStart" +---@field dn string +---@field sd string[] +---@field icon string +---@field group table +---@field x number +---@field y number +---@field rsq number +---@field ascendancyName string? +---@field linked Node[] +---@field linkedId integer[] +---@field mods table[] +---@field modList ModList +---@field modKey string +---@field alloc boolean +---@field visited boolean Used by BuildAllDependsAndPaths path finding +---@field depends Node[] nodes that reach the tree only through this one +---@field pathParent Node? +---@field pathLen integer? Path length to the closest allocated node. Nil if the node cannot be pathed to +---@field pathDist number Cost to allocate this node +---@field distanceToClassStart number +---@field connectedToStart boolean +---@field intuitiveLeapLikesAffecting Node[] jewel sockets whose radius covers this node +---@field isJewelSocket boolean +---@field isMultipleChoiceOption boolean +---@field isProxy boolean +---@field isTattoo boolean +---@field overrideType string? +---@field expansionJewel table? +---@field conqueredBy table? +---@field masteryEffects table? +---@field power table? ---@class PassiveSpec: UndoHandler ---@field nodes table ---@field allocNodes table @@ -52,7 +87,6 @@ function PassiveSpecClass:Init(treeVersion, convert) for _, treeNode in pairs(self.tree.nodes) do -- Exclude proxy or groupless nodes, as well as expansion sockets if treeNode.group and not treeNode.isProxy and not treeNode.group.isProxy and (not treeNode.expansionJewel or not treeNode.expansionJewel.parent) then - ---@class Node self.nodes[treeNode.id] = setmetatable({ linked = { }, power = { } @@ -775,11 +809,29 @@ function PassiveSpecClass:ResetNodes() wipeTable(self.masterySelections) end +---@param node Node +---@param rev boolean? +---@return Node[] path Starting from the given node and ending at the closest allocated node +function PassiveSpecClass:WalkNodePathToArray(node, rev) + local path = {} + local current = node + while current and current.pathLen and current.pathLen > 0 do + if rev then + table.insert(path, 1, current) + else + path[#path + 1] = current + end + current = current.pathParent + end + return path +end -- Allocate the given node, if possible, and all nodes along the path to the node -- An alternate path to the node may be provided, otherwise the default path will be used -- The path must always contain the given node, as will be the case for the default path +---@param node Node +---@param altPath Node[]? function PassiveSpecClass:AllocNode(node, altPath) - if not node.path then + if not node.pathLen then -- Node cannot be connected to the tree as there is no possible path return end @@ -791,10 +843,20 @@ function PassiveSpecClass:AllocNode(node, altPath) node.alloc = true self.allocNodes[node.id] = node else - for _, pathNode in ipairs(altPath or node.path) do - rebuildClusterJewelGraphs = rebuildClusterJewelGraphs or not pathNode.alloc and pathNode.expansionJewel ~= nil - pathNode.alloc = true - self.allocNodes[pathNode.id] = pathNode + if altPath then + for _, pathNode in ipairs(altPath) do + rebuildClusterJewelGraphs = rebuildClusterJewelGraphs or not pathNode.alloc and pathNode.expansionJewel ~= nil + pathNode.alloc = true + self.allocNodes[pathNode.id] = pathNode + end + else + local currentNode = node + while currentNode and currentNode.pathLen > 0 do + rebuildClusterJewelGraphs = rebuildClusterJewelGraphs or not currentNode.alloc and currentNode.expansionJewel ~= nil + currentNode.alloc = true + self.allocNodes[currentNode.id] = currentNode + currentNode = currentNode.pathParent + end end end @@ -1308,10 +1370,11 @@ end -- Multi-source 0-1 BFS to find what other root (i.e., allocated) nodes each node is closest to ---@param roots Node[] A list of currently allocated, and other nodes which should be considered as the sources of distances. function PassiveSpecClass:BuildNodePathsToRootNodes(roots) + ---@type Node[] local queue = {} for _, node in ipairs(roots) do node.pathDist = 0 - node.path = wipeTable(node.path) + node.pathLen = 0 t_insert(queue, node) end local queueStart = 1 @@ -1321,7 +1384,6 @@ function PassiveSpecClass:BuildNodePathsToRootNodes(roots) queueStart = queueStart + 1 local linked = node.linked local nodeDist = node.pathDist - local nodePath = node.path for i = 1, #linked do local other = linked[i] local weight = other.alloc and 0 or 1 @@ -1343,12 +1405,8 @@ function PassiveSpecClass:BuildNodePathsToRootNodes(roots) end other.pathDist = distViaNode - local path = wipeTable(other.path) - path[1] = other - for pathIndex = 1, #nodePath do - path[pathIndex + 1] = nodePath[pathIndex] - end - other.path = path + other.pathLen = node.pathLen + 1 + other.pathParent = node end end end @@ -1739,7 +1797,8 @@ function PassiveSpecClass:BuildAllDependsAndPaths() -- Reset and rebuild all node paths for _, node in pairs(self.nodes) do node.pathDist = (node.alloc and #node.intuitiveLeapLikesAffecting == 0) and 0 or 1000 - node.path = nil + node.pathParent = nil + node.pathLen = nil if node.isJewelSocket or node.expansionJewel then node.distanceToClassStart = 0 end diff --git a/src/Classes/PassiveTree.lua b/src/Classes/PassiveTree.lua index 2692c70301..4ed38d3f69 100644 --- a/src/Classes/PassiveTree.lua +++ b/src/Classes/PassiveTree.lua @@ -519,7 +519,6 @@ function PassiveTreeClass:PassiveTree(treeVersion) self.masteryEffects = { } local nodeMap = { } for _, n in pairs(self.nodes) do - ---@class Node local node = n -- Migration... if versionNum < 3.10 then diff --git a/src/Classes/PassiveTreeView.lua b/src/Classes/PassiveTreeView.lua index c7f8976b47..d34b84b4fc 100644 --- a/src/Classes/PassiveTreeView.lua +++ b/src/Classes/PassiveTreeView.lua @@ -299,6 +299,7 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) self.tracePath = nil end + ---@type Node? local hoverNode local hoverCompareNode -- Track compare-only node hover separately if mOver then @@ -334,17 +335,17 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) self.hoverNode = hoverNode -- If hovering over a node, find the path to it (if unallocated) or the list of dependent nodes (if allocated) local hoverPath, hoverDep + -- Path tracing mode is enabled if self.traceMode then - -- Path tracing mode is enabled - if hoverNode then - if not hoverNode.path then + -- Mastery nodes cannot be traced through as they are allocated through + -- a popup, and cannot be pathed out of + if hoverNode and hoverNode.type ~= "Mastery" then + if not hoverNode.pathLen then -- Don't highlight the node if it can't be pathed to hoverNode = nil elseif not self.tracePath[1] then -- Initialise the trace path using this node's path - for _, pathNode in ipairs(hoverNode.path) do - t_insert(self.tracePath, 1, pathNode) - end + self.tracePath = build.spec:WalkNodePathToArray(hoverNode, true) else local lastPathNode = self.tracePath[#self.tracePath] if hoverNode ~= lastPathNode then @@ -355,8 +356,6 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) -- Node is already in the trace path, remove it first t_remove(self.tracePath, index) t_insert(self.tracePath, hoverNode) - elseif lastPathNode.type == "Mastery" then - hoverNode = nil else t_insert(self.tracePath, hoverNode) end @@ -371,12 +370,14 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) for _, pathNode in pairs(self.tracePath) do hoverPath[pathNode] = true end - elseif hoverNode and hoverNode.path then + elseif hoverNode and hoverNode.pathLen then -- Use the node's own path and dependence list hoverPath = { } if #hoverNode.intuitiveLeapLikesAffecting == 0 then - for _, pathNode in pairs(hoverNode.path) do - hoverPath[pathNode] = true + local currentNode = hoverNode + while currentNode and currentNode.pathLen > 0 do + hoverPath[currentNode] = true + currentNode = currentNode.pathParent end end hoverDep = { } @@ -507,11 +508,11 @@ function PassiveTreeViewClass:Draw(build, viewPort, inputEvents) end -- Normal node allocation (non-ascendancy or same ascendancy) - if hoverNode.path and not hoverNode.alloc then + if hoverNode.pathLen and not hoverNode.alloc then if hoverNode.type == "Mastery" and hoverNode.masteryEffects then build.treeTab:OpenMasteryPopup(hoverNode, viewPort) else - spec:AllocNode(hoverNode, self.tracePath and hoverNode == self.tracePath[#self.tracePath] and self.tracePath) + spec:AllocNode(hoverNode, self.tracePath and hoverNode == self.tracePath[#self.tracePath] and self.tracePath or nil) spec:AddUndoState() build.buildFlag = true end @@ -1683,7 +1684,7 @@ function PassiveTreeViewClass:AddNodeTooltip(tooltip, node, build, returnEarly) if self.showStatDifferences then local calcFunc, calcBase = build.calcsTab:GetMiscCalculator(build) tooltip:AddSeparator(14) - local path = (node.alloc and node.depends) or self.tracePath or node.path or { } + local path = (node.alloc and node.depends) or self.tracePath or build.spec:WalkNodePathToArray(node) local pathLength = #path local pathNodes = { } for _, node in pairs(path) do @@ -1735,14 +1736,14 @@ function PassiveTreeViewClass:AddNodeTooltip(tooltip, node, build, returnEarly) -- Pathing distance tooltip:AddSeparator(14) - if node.path and #node.path > 0 then + if node.pathLen and node.pathLen > 0 then if self.traceMode and isValueInArray(self.tracePath, node) then tooltip:AddLine(14, "^7"..#self.tracePath .. " nodes in trace path") tooltip:AddLine(14, colorCodes.TIP) else tooltip:AddLine(14, "^7"..node.pathDist .. " points to node" .. (#node.intuitiveLeapLikesAffecting > 0 and " ^8(Can be allocated without pathing to it)" or "")) tooltip:AddLine(14, colorCodes.TIP) - if #node.path > 1 then + if node.pathLen > 1 then -- Handy hint! tooltip:AddLine(14, "Tip: To reach this node by a different path, hold Shift, then trace the path and click this node") end diff --git a/src/Classes/TreeTab.lua b/src/Classes/TreeTab.lua index 842b3b4e09..2f2c3ea4bb 100644 --- a/src/Classes/TreeTab.lua +++ b/src/Classes/TreeTab.lua @@ -1009,7 +1009,7 @@ function TreeTabClass:SaveMasteryPopup(node, listControl) self.build.spec.tree:ProcessStats(node) self.build.spec.masterySelections[node.id] = effect.id if not node.alloc then - self.build.spec:AllocNode(node, self.viewer.tracePath and node == self.viewer.tracePath[#self.viewer.tracePath] and self.viewer.tracePath) + self.build.spec:AllocNode(node, self.viewer.tracePath and node == self.viewer.tracePath[#self.viewer.tracePath] and self.viewer.tracePath or nil) end self.build.spec:AddUndoState() self.modFlag = true @@ -1096,11 +1096,13 @@ function TreeTabClass:BuildPowerReportList(currentStat) end return powerStr end + ---@param node Node + ---@param isAlloc boolean local function getNodePathDist(node, isAlloc) if isAlloc then - return #(node.depends or { }) == 0 and 1 or #node.depends + return (node.depends and #node.depends or 0) == 0 and 1 or #node.depends end - return node.power.distance or #(node.path or {}) == 0 and 1 or #node.path + return node.power.distance or ((node.pathLen or 0) == 0 and 1 or node.pathLen) end local function addReportEntry(node, name, nodePower, pathPower, pathDist, isAlloc, pathPowerStr) t_insert(report, { From 910adabc4c5907b40a910cf0471e7bfc1f606881 Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:03:01 +0300 Subject: [PATCH 3/3] Avoid using upvalue for neighbour map --- src/Classes/ItemsTab.lua | 1 + src/Classes/PassiveSpec.lua | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 0b3852aba5..27bd9b4e42 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -4146,6 +4146,7 @@ local function cloneSpecForJewelComparison(spec) specCopy.allocSubgraphNodes = { } specCopy.allocExtendedNodes = { } specCopy.subGraphs = { } + specCopy.neighboursForNode = {} return specCopy end diff --git a/src/Classes/PassiveSpec.lua b/src/Classes/PassiveSpec.lua index 6faf35b09d..dd79f7ef2e 100644 --- a/src/Classes/PassiveSpec.lua +++ b/src/Classes/PassiveSpec.lua @@ -77,6 +77,8 @@ function PassiveSpecClass:Init(treeVersion, convert) self.ignoredNodes = { } self.ignoreAllocatingSubgraph = false self.checkNodeLinks = false + ---@type table Nodes sorted by distance from the source of the graph + self.neighboursForNode = {} local previousTreeNodes = { } if convert then previousTreeNodes = self.build.spec.nodes @@ -927,8 +929,6 @@ function PassiveSpecClass:CountAllocNodes() return used, ascUsed, secondaryAscUsed, sockets end ----@type table Nodes sorted by distance from the source of the graph -local neighboursForNode = {} -- Attempt to find a class start node starting from the given node -- Unless noAscent == true it will also look for an ascendancy class start node function PassiveSpecClass:FindStartFromNode(node, visited, noAscend) @@ -936,7 +936,7 @@ function PassiveSpecClass:FindStartFromNode(node, visited, noAscend) node.visited = true t_insert(visited, node) local nodeAscendancy = node.ascendancyName - local neighbours = neighboursForNode[node.id] or node.linked + local neighbours = self.neighboursForNode[node.id] or node.linked -- For each node which is connected to this one, check if... for i = 1, #neighbours do local other = neighbours[i] @@ -1363,7 +1363,7 @@ function PassiveSpecClass:SortNodesByStartDist() end return a.id < b.id end) - neighboursForNode[id] = sorted + self.neighboursForNode[id] = sorted end end