From eebec10d0c4bb81b7a3447ebddeeb7540f2812b4 Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:34:28 +0300 Subject: [PATCH 1/4] Port "Improve calc performance" (#10228) --- spec/System/TestCommon_spec.lua | 4 +- spec/System/TestItemMods_spec.lua | 4 +- src/Classes/ModDB.lua | 15 +++- src/Modules/CalcSetup.lua | 132 ++++++++++++++++++++++++------ 4 files changed, 122 insertions(+), 33 deletions(-) diff --git a/spec/System/TestCommon_spec.lua b/spec/System/TestCommon_spec.lua index 61a848e14e..4ff1906d83 100644 --- a/spec/System/TestCommon_spec.lua +++ b/spec/System/TestCommon_spec.lua @@ -5,7 +5,7 @@ describe("Common", function() function ParentClass:ConstructorTestParentClass() return self end - local ChildClass = newClass("ConstructorTestProblemChildClass", "ConstructorTestParentClass") + local ChildClass = newClass("ConstructorTestProblemChild", "ConstructorTestParentClass") function ChildClass:ConstructorTestProblemChild() -- Intentionally does not call self:ConstructorTestParentClass() return self @@ -40,7 +40,7 @@ describe("Common", function() return self end - local ChildClass = newClass("ConstructorTestProblemChildClass", "ConstructorTestParentClass") + local ChildClass = newClass("ConstructorTestProblemChild", "ConstructorTestParentClass") function ChildClass:ConstructorTestProblemChild() self.ConstructorTestParentClass() return self diff --git a/spec/System/TestItemMods_spec.lua b/spec/System/TestItemMods_spec.lua index 515ba8eef0..2d641c3515 100644 --- a/spec/System/TestItemMods_spec.lua +++ b/spec/System/TestItemMods_spec.lua @@ -728,8 +728,8 @@ describe("TetsItemMods", function() end, }) - local attributeModList = calcs.buildModListForNode(env, attributeNode, 0, false) - local smallModList = calcs.buildModListForNode(env, smallNode, 0, false) + local attributeModList = calcs.buildModListForNode(env, attributeNode, nil, 0, false) + local smallModList = calcs.buildModListForNode(env, smallNode, nil, 0, false) GlobalCache.cachedData[envMode] = nil assert.are.equals(7, attributeModList:Sum("BASE", nil, "Str")) diff --git a/src/Classes/ModDB.lua b/src/Classes/ModDB.lua index d9ccf0d772..f5026c2cdc 100644 --- a/src/Classes/ModDB.lua +++ b/src/Classes/ModDB.lua @@ -134,7 +134,7 @@ end function ModDBClass:SumInternal(context, modType, cfg, flags, keywordFlags, source, ...) local result = 0 - local globalLimits = { } + local globalLimits for i = 1, select('#', ...) do local modList = self.mods[select(i, ...)] if modList then @@ -142,6 +142,9 @@ function ModDBClass:SumInternal(context, modType, cfg, flags, keywordFlags, sour local mod = modList[i] if mod.type == modType and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or ( mod.source and (mod.source:match("[^:]+") == source or mod.source == source))) then if mod[1] then + if not globalLimits then + globalLimits = {} + end local value = context:EvalMod(mod, cfg, globalLimits) or 0 result = result + value else @@ -160,7 +163,7 @@ end function ModDBClass:MoreInternal(context, cfg, flags, keywordFlags, source, ...) local result = 1 local modPrecision = nil - local globalLimits = { } + local globalLimits for i = 1, select('#', ...) do local modList = self.mods[select(i, ...)] local modResult = 1 --The more multipliers for each mod are computed to the nearest percent then applied. @@ -170,6 +173,9 @@ function ModDBClass:MoreInternal(context, cfg, flags, keywordFlags, source, ...) if mod.type == "MORE" and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or mod.source:match("[^:]+") == source) then local value if mod[1] then + if not globalLimits then + globalLimits = {} + end value = context:EvalMod(mod, cfg, globalLimits) or 0 else value = mod.value or 0 @@ -270,7 +276,7 @@ function ModDBClass:ListInternal(context, result, cfg, flags, keywordFlags, sour end function ModDBClass:TabulateInternal(context, result, modType, cfg, flags, keywordFlags, source, ...) - local globalLimits = { } + local globalLimits for i = 1, select('#', ...) do local modName = select(i, ...) local modList = self.mods[modName] @@ -280,6 +286,9 @@ function ModDBClass:TabulateInternal(context, result, modType, cfg, flags, keywo if (mod.type == modType or not modType) and band(flags, mod.flags) == mod.flags and MatchKeywordFlags(keywordFlags, mod.keywordFlags) and (not source or mod.source:match("[^:]+") == source) then local value if mod[1] then + if not globalLimits then + globalLimits = {} + end value = context:EvalMod(mod, cfg, globalLimits) else value = mod.value diff --git a/src/Modules/CalcSetup.lua b/src/Modules/CalcSetup.lua index f4c2b257d5..0392224493 100644 --- a/src/Modules/CalcSetup.lua +++ b/src/Modules/CalcSetup.lua @@ -179,10 +179,23 @@ local function refreshJewelStatCache(env) end end -function calcs.buildModListForNode(env, node, incSmallPassiveSkill, includeKeystoneMods) +-- Recycle a modlist so that we do not allocate many tables for each node. +local function resetModList(list) + for i = #list, 1, -1 do + list[i] = nil + end + list.multipliers = wipeTable(list.multipliers) + list.conditions = wipeTable(list.conditions) + list.actor = wipeTable(list.actor) + list.parent = false + return list +end + +---@param reuse table|nil A ModList to recycle instead of allocating. Only safe when the caller discards the result. +function calcs.buildModListForNode(env, node, reuse, incSmallPassiveSkill, includeKeystoneMods) local localSmallIncEffect = 0 local localNotableIncEffect = 0 - local modList = new("ModList"):ModList() + local modList = reuse and resetModList(reuse) or new("ModList"):ModList() if node.type == "Keystone" then if includeKeystoneMods then modList:AddList(node.modList) @@ -219,46 +232,107 @@ function calcs.buildModListForNode(env, node, incSmallPassiveSkill, includeKeyst end end - if modList:Flag(nil, "PassiveSkillHasNoEffect") or (env.allocNodes[node.id] and modList:Flag(nil, "AllocatedPassiveSkillHasNoEffect")) then + -- prefilter the modlist so that every :Flag() call does not have to go through the entire mod list + local hasNoEffect, hasAllocNoEffect, hasScale, hasOtherEffect, hasExtraSkill, hasExplode + for i = 1, #modList do + local name = modList[i].name + if name == "PassiveSkillHasNoEffect" then + hasNoEffect = true + elseif name == "AllocatedPassiveSkillHasNoEffect" then + hasAllocNoEffect = true + elseif name == "PassiveSkillEffect" then + hasScale = true + elseif name == "PassiveSkillHasOtherEffect" then + hasOtherEffect = true + elseif name == "ExtraSkill" then + hasExtraSkill = true + elseif name == "CanExplode" then + hasExplode = true + end + end + + if (hasNoEffect and modList:Flag(nil, "PassiveSkillHasNoEffect")) or (env.allocNodes[node.id] and (hasAllocNoEffect and modList:Flag(nil, "AllocatedPassiveSkillHasNoEffect"))) then wipeTable(modList) + hasScale = false + hasOtherEffect = nil + hasExtraSkill = nil + hasExplode = nil end -- Apply effect scaling - local scale = calcLib.mod(modList, nil, "PassiveSkillEffect") - if scale ~= 1 then - local scaledList = new("ModList"):ModList() - scaledList:ScaleAddList(modList, scale) - modList = scaledList + if hasScale then + local scale = calcLib.mod(modList, nil, "PassiveSkillEffect") + if scale ~= 1 then + local scaledList = new("ModList"):ModList() + scaledList:ScaleAddList(modList, scale) + modList = scaledList + end end -- Run second pass radius jewels - for _, rad in pairs(env.radiusJewelList) do + local rescan = false + for i = 1, #env.radiusJewelList do + local rad = env.radiusJewelList[i] if rad.nodes[node.id] and rad.nodes[node.id].type ~= "Mastery" and (rad.type == "Threshold" or (rad.type == "Self" and env.allocNodes[node.id]) or (rad.type == "SelfUnalloc" and not env.allocNodes[node.id])) then runRadiusJewelFunc(rad, node, modList, rad.data) + rescan = true + hasOtherEffect = nil + hasExtraSkill = nil + hasExplode = nil + end + end + + if rescan then + for i = 1, #modList do + local name = modList[i].name + if name == "PassiveSkillHasOtherEffect" then + hasOtherEffect = true + elseif name == "ExtraSkill" then + hasExtraSkill = true + elseif name == "CanExplode" then + hasExplode = true + end end end - if modList:Flag(nil, "PassiveSkillHasOtherEffect") then - for i, mod in ipairs(modList:List(skillCfg, "NodeModifier")) do - if i == 1 then wipeTable(modList) end - modList:AddMod(mod.mod) + if hasOtherEffect and modList:Flag(nil, "PassiveSkillHasOtherEffect") then + local newMods = modList:List(nil, "NodeModifier") + for i = 1, #newMods do + local mod = newMods[i].mod + if i == 1 then + wipeTable(modList) + hasExtraSkill = nil + hasExplode = nil + end + if mod.name == "ExtraSkill" then + hasExtraSkill = true + elseif mod.name == "CanExplode" then + hasExplode = true + end + modList:AddMod(mod) end end - node.grantedSkills = { } - for _, skill in ipairs(modList:List(nil, "ExtraSkill")) do - if skill.name ~= "Unknown" then - t_insert(node.grantedSkills, { - skillId = skill.skillId, - level = skill.level, - noSupports = true, - source = "Tree:"..node.id - }) + node.grantedSkills = wipeTable(node.grantedSkills) + if hasExtraSkill then + local list = modList:List(nil, "ExtraSkill") + for i = 1, #list do + local skill = list[i] + if skill.name ~= "Unknown" then + t_insert(node.grantedSkills, { + skillId = skill.skillId, + level = skill.level, + noSupports = true, + source = "Tree:" .. node.id + }) + end end end - if modList:Flag(nil, "CanExplode") then - t_insert(env.explodeSources, node) + if hasExplode then + if modList:Flag(nil, "CanExplode") then + t_insert(env.explodeSources, node) + end end for i, mod in ipairs(modList) do @@ -348,8 +422,14 @@ function calcs.buildModListForNodeList(env, nodeList, finishJewels, includeKeyst -- Add node modifiers local modList = new("ModList"):ModList() + local explodeSources = {} + -- Outside MAIN mode the per-node list is merged into modList and then + -- dropped, so a single list can be recycled for every node instead of + -- allocating one each time. + local scratch = env.mode ~= "MAIN" and new("ModList"):ModList() or nil for _, node in pairs(nodeList) do - local nodeModList = calcs.buildModListForNode(env, node, inc, includeKeystoneMods) + local nodeModList, explode = calcs.buildModListForNode(env, node, scratch, inc, includeKeystoneMods) + t_insert(explodeSources, explode) modList:AddList(nodeModList) if env.mode == "MAIN" then node.finalModList = nodeModList @@ -359,7 +439,7 @@ function calcs.buildModListForNodeList(env, nodeList, finishJewels, includeKeyst if finishJewels then -- Process extra radius nodes; these are unallocated nodes near conversion or threshold jewels that need to be processed for _, node in pairs(env.extraRadiusNodeList) do - local nodeModList = calcs.buildModListForNode(env, node, inc) + local nodeModList = calcs.buildModListForNode(env, node, scratch, inc) if env.mode == "MAIN" then node.finalModList = nodeModList end From 889cf682321ba064d9c6f8416490e139a073a6b2 Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:34:57 +0300 Subject: [PATCH 2/4] Use multisource bfs for bdap --- src/Classes/PassiveSpec.lua | 204 +++++++++++++++++++++--------------- 1 file changed, 119 insertions(+), 85 deletions(-) diff --git a/src/Classes/PassiveSpec.lua b/src/Classes/PassiveSpec.lua index 9e90ffc946..333dfecac1 100644 --- a/src/Classes/PassiveSpec.lua +++ b/src/Classes/PassiveSpec.lua @@ -1057,24 +1057,28 @@ function PassiveSpecClass:FindStartFromNode(node, visited, noAscend, allocMode, 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 -- 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 - local startIndex = #visited + 1 + local startIndex = nodeAscendancy and #visited + 1 local otherAlloc = other.alloc or (alternateClassStartNodes and alternateClassStartNodes[other.id]) - if otherAlloc and self:CanPathThroughAllocMode(allocMode, other) and - (other.type == "ClassStart" or other.type == "AscendClassStart" or - (not other.visited and node.type ~= "Mastery" and self:FindStartFromNode(other, visited, noAscend, allocMode, alternateClassStartNodes)) - ) then - if node.ascendancyName and not other.ascendancyName then - -- Pathing out of Ascendant, un-visit the outside nodes - for i = startIndex, #visited do - visited[i].visited = false - visited[i] = nil + if otherAlloc and self:CanPathThroughAllocMode(allocMode, other) then + local otherType = other.type + if + (otherType == "ClassStart" or otherType == "AscendClassStart" or + (not other.visited and node.type ~= "Mastery" and self:FindStartFromNode(other, visited, noAscend, allocMode, alternateClassStartNodes)) + ) then + if nodeAscendancy and not other.ascendancyName then + -- Pathing out of Ascendant, un-visit the outside nodes + for i = startIndex, #visited do + visited[i].visited = false + visited[i] = nil + end + elseif not noAscend or otherType ~= "AscendClassStart" then + return true end - elseif not noAscend or other.type ~= "AscendClassStart" then - return true end end end @@ -1263,72 +1267,6 @@ function PassiveSpecClass:CollectGrantedPassiveNodesFromItems(itemsTab, baseAllo return granted end --- Perform a breadth-first search of the tree, starting from this node, and determine if it is the closest node to any other nodes -function PassiveSpecClass:BuildPathFromNode(root) - root.pathDist = 0 - root.path = { } - root.pathRoot = root - local queue = { root } - local o, i = 1, 2 -- Out, in - while o < i do - -- Nodes are processed in a queue, until there are no nodes left - -- All nodes that are 1 node away from the root will be processed first, then all nodes that are 2 nodes away, etc - local node = queue[o] - o = o + 1 - - if node.unlockConstraint then - for _, nodeId in ipairs(node.unlockConstraint.nodes) do - if not self.nodes[nodeId].alloc then - goto continue - end - end - end - local curDist = node.pathDist - -- Iterate through all nodes that are connected to this one - for _, other in ipairs(node.linked) do - -- Paths must obey these rules: - -- 1. They must not pass through class or ascendancy class start nodes (but they can start from such nodes) - -- 2. They cannot pass between different ascendancy classes or between an ascendancy class and the main tree - -- The one exception to that rule is that a path may start from an ascendancy node and pass into the main tree - -- This permits pathing from the Ascendant 'Path of the X' nodes into the respective class start areas - -- 3. They must not pass away from mastery nodes - -- 4. Unlock constraints must be satisfied - - -- validate if the other node have unlockConstraints met - local canPath = true - if other.unlockConstraint then - for _, nodeId in ipairs(other.unlockConstraint.nodes) do - if not self.nodes[nodeId].alloc then - canPath = false - break - end - end - end - - if not other.pathDist then - ConPrintTable(other, true) - end - if node.type ~= "Mastery" and other.type ~= "ClassStart" and other.type ~= "AscendClassStart" and (not other.alloc or self:CanPathThroughAllocMode(root.allocMode or 0, other)) and other.pathDist > curDist and (node.ascendancyName == other.ascendancyName or (curDist == 0 and not other.ascendancyName)) and canPath then - -- The shortest path to the other node is through the current node - other.pathDist = curDist - if not other.alloc then - other.pathDist = other.pathDist + 1 - end - other.path = wipeTable(other.path) - other.pathRoot = root - other.path[1] = other - for i, n in ipairs(node.path) do - other.path[i+1] = n - end - -- Add the other node to the end of the queue - queue[i] = other - i = i + 1 - end - end - ::continue:: - end -end - -- Determine this node's distance from the class' start -- Only allocated nodes can be traversed function PassiveSpecClass:SetNodeDistanceToClassStart(root) @@ -1422,6 +1360,101 @@ function PassiveSpecClass:NodesInIntuitiveLeapLikeRadius(node) return result end +local function traversable(curDist, node, other) + -- Paths must obey these rules: + -- 1. They must not pass through class or ascendancy class start nodes (but they can start from such nodes) + -- 2. They cannot pass between different ascendancy classes or between an ascendancy class and the main tree + -- The one exception to that rule is that a path may start from an ascendancy node and pass into the main tree + -- This permits pathing from the Ascendant 'Path of the X' nodes into the respective class start areas + -- 3. They must not pass away from mastery nodes + return node.type ~= "Mastery" and other.type ~= "ClassStart" and other.type ~= "AscendClassStart" and (not other.alloc or self:CanPathThroughAllocMode(root.allocMode or 0, other)) and other.pathDist > curDist and (node.ascendancyName == other.ascendancyName or (curDist == 0 and not other.ascendancyName)) and canPath +end + +-- Cluster subgraph rebuilds can replace node objects while retaining IDs. +-- Normalize stale link references to the canonical node object. +function PassiveSpecClass:NormalizeNodeLinks(node) + local linked = node.linked + for index = #linked, 1, -1 do + local other = linked[index] + local canonicalNode = other and other.id and self.nodes[other.id] + if not canonicalNode then + t_remove(linked, index) + elseif canonicalNode ~= other then + linked[index] = canonicalNode + end + 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) + -- A dequeue. We will keep a pointer to the start and end of this to keep + -- track of its length + local q = {} + for _, node in ipairs(roots) do + node.pathDist = 0 + node.path = wipeTable(node.path) + node.pathRoot = node + t_insert(q, node) + end + local qStart = 1 + local qLen = #q + while qStart <= qLen do + -- pop front + local node = q[qStart] + qStart = qStart + 1 + if node.unlockConstraint then + for _, nodeId in ipairs(node.unlockConstraint.nodes) do + if not self.nodes[nodeId].alloc then + goto continueBuildPath + end + end + end + 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 + + -- validate if the other node have unlockConstraints met + local canPath = true + if other.unlockConstraint then + for _, nodeId in ipairs(other.unlockConstraint.nodes) do + if not self.nodes[nodeId].alloc then + canPath = false + break + end + end + end + + if (distViaNode < (other.pathDist or math.huge)) + and node.type ~= "Mastery" and other.type ~= "ClassStart" and other.type ~= "AscendClassStart" and (not other.alloc or self:CanPathThroughAllocMode(node.allocMode or 0, other)) and (node.ascendancyName == other.ascendancyName or (nodeDist == 0 and not other.ascendancyName)) and canPath then + -- if this node is free, push it to the front so that it can shorten paths + if weight == 0 then + qStart = qStart - 1 + q[qStart] = other + -- otherwise push to back + else + qLen = qLen + 1 + q[qLen] = other + end + + -- save path and distance for the node + other.pathDist = distViaNode + local path = wipeTable(other.path) + path[1] = other + for i = 1, #nodePath do + path[i + 1] = nodePath[i] + end + other.path = path + other.pathRoot = node + end + end + ::continueBuildPath:: + end +end -- Rebuilds dependencies and paths for all nodes function PassiveSpecClass:BuildAllDependsAndPaths() -- This table will keep track of which nodes have been visited during each path-finding attempt @@ -1460,7 +1493,7 @@ function PassiveSpecClass:BuildAllDependsAndPaths() self.switchableNodes = { } for id, node in pairs(self.nodes) do node.depends = wipeTable(node.depends) - node.intuitiveLeapLikesAffecting = { } + node.intuitiveLeapLikesAffecting = wipeTable(node.intuitiveLeapLikesAffecting) node.conqueredBy = nil -- ignore cluster jewel nodes that don't have an id in the tree @@ -2009,17 +2042,18 @@ function PassiveSpecClass:BuildAllDependsAndPaths() node.distanceToClassStart = 0 end end - for id, node in pairs(self.allocNodes) do + local rootList = {} + for _, node in pairs(self.allocNodes) do if #node.intuitiveLeapLikesAffecting == 0 or node.connectedToStart then - self:BuildPathFromNode(node) - if node.isJewelSocket or node.expansionJewel then - self:SetNodeDistanceToClassStart(node) - end + t_insert(rootList, node) end end + self:BuildNodePathsToRootNodes(rootList) + local alternateClassStartNodesArray = {} for _, node in pairs(alternateClassStartNodes) do - self:BuildPathFromNode(node) + alternateClassStartNodesArray[#alternateClassStartNodesArray + 1] = node end + self:BuildNodePathsToRootNodes(alternateClassStartNodesArray) end function PassiveSpecClass:ReplaceNode(old, newNode) From 848c77cb40204300b10e581f2b3b595a5204c856 Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:05:19 +0300 Subject: [PATCH 3/4] Fix allocmode issue --- src/Classes/PassiveSpec.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Classes/PassiveSpec.lua b/src/Classes/PassiveSpec.lua index 333dfecac1..a78622f618 100644 --- a/src/Classes/PassiveSpec.lua +++ b/src/Classes/PassiveSpec.lua @@ -1449,7 +1449,7 @@ function PassiveSpecClass:BuildNodePathsToRootNodes(roots) path[i + 1] = nodePath[i] end other.path = path - other.pathRoot = node + other.pathRoot = node.pathRoot end end ::continueBuildPath:: From 2a9122767eb7a7f5861c72f6bd0c6e9a43edec39 Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Sat, 29 Aug 2026 08:05:13 +1000 Subject: [PATCH 4/4] Fix issues with the new passive tree pathfinding Restore jewel socket distance calculations, preserve weapon-set paths when a normal path is available, and promote them when they are required. Also remove unused code and add tests for both pathing cases. --- spec/System/TestPassiveSpec_spec.lua | 26 ++++++++++++++++++++- src/Classes/PassiveSpec.lua | 34 +++++++--------------------- src/Modules/CalcSetup.lua | 4 +--- 3 files changed, 34 insertions(+), 30 deletions(-) diff --git a/spec/System/TestPassiveSpec_spec.lua b/spec/System/TestPassiveSpec_spec.lua index d16b587dc3..416fff0a31 100644 --- a/spec/System/TestPassiveSpec_spec.lua +++ b/spec/System/TestPassiveSpec_spec.lua @@ -547,6 +547,13 @@ Item Level: 80 return node end + it("rebuilds an allocated jewel socket's distance from the class start", function() + local socket = build.spec.nodes[60735] + build.spec:AllocNode(socket) + + assert.True(socket.distanceToClassStart > 0) + end) + it("normal passive allocation promotes the shortest path instead of using a longer detour", function() local spec = build.spec allocNode(spec, 56651, 0) @@ -564,7 +571,7 @@ Item Level: 80 assert.are.equals(0, weaponSetNode.allocMode) end) - it("normal passive allocation promotes the weapon-set chain behind the path root", function() + it("normal passive allocation preserves an unused weapon-set path", function() local spec = build.spec allocNode(spec, 56651, 0) allocNode(spec, 35324, 0) @@ -577,6 +584,23 @@ Item Level: 80 spec.allocMode = 0 spec:AllocNode(promotedNode) + assert.True(promotedNode.alloc) + assert.are.equals(0, promotedNode.allocMode) + assert.are.equals(1, spec.nodes[18548].allocMode) + assert.are.equals(1, spec.nodes[35660].allocMode) + end) + + it("normal passive allocation promotes a required weapon-set path", function() + local spec = build.spec + allocNode(spec, 35660, 1) + allocNode(spec, 18548, 1) + + local promotedNode = spec.nodes[28992] + assert.are.equals("Honed Instincts", promotedNode.dn) + + spec.allocMode = 0 + spec:AllocNode(promotedNode) + assert.True(promotedNode.alloc) assert.are.equals(0, promotedNode.allocMode) assert.are.equals(0, spec.nodes[18548].allocMode) diff --git a/src/Classes/PassiveSpec.lua b/src/Classes/PassiveSpec.lua index a78622f618..7b6da9ca25 100644 --- a/src/Classes/PassiveSpec.lua +++ b/src/Classes/PassiveSpec.lua @@ -1360,31 +1360,6 @@ function PassiveSpecClass:NodesInIntuitiveLeapLikeRadius(node) return result end -local function traversable(curDist, node, other) - -- Paths must obey these rules: - -- 1. They must not pass through class or ascendancy class start nodes (but they can start from such nodes) - -- 2. They cannot pass between different ascendancy classes or between an ascendancy class and the main tree - -- The one exception to that rule is that a path may start from an ascendancy node and pass into the main tree - -- This permits pathing from the Ascendant 'Path of the X' nodes into the respective class start areas - -- 3. They must not pass away from mastery nodes - return node.type ~= "Mastery" and other.type ~= "ClassStart" and other.type ~= "AscendClassStart" and (not other.alloc or self:CanPathThroughAllocMode(root.allocMode or 0, other)) and other.pathDist > curDist and (node.ascendancyName == other.ascendancyName or (curDist == 0 and not other.ascendancyName)) and canPath -end - --- Cluster subgraph rebuilds can replace node objects while retaining IDs. --- Normalize stale link references to the canonical node object. -function PassiveSpecClass:NormalizeNodeLinks(node) - local linked = node.linked - for index = #linked, 1, -1 do - local other = linked[index] - local canonicalNode = other and other.id and self.nodes[other.id] - if not canonicalNode then - t_remove(linked, index) - elseif canonicalNode ~= other then - linked[index] = canonicalNode - end - 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) @@ -1417,6 +1392,8 @@ function PassiveSpecClass:BuildNodePathsToRootNodes(roots) local other = linked[i] local weight = other.alloc and 0 or 1 local distViaNode = nodeDist + weight + local otherDist = other.pathDist or math.huge + local preferNormalRoot = distViaNode == otherDist and (node.pathRoot.allocMode or 0) == 0 and other.pathRoot and (other.pathRoot.allocMode or 0) ~= 0 -- validate if the other node have unlockConstraints met local canPath = true @@ -1429,7 +1406,7 @@ function PassiveSpecClass:BuildNodePathsToRootNodes(roots) end end - if (distViaNode < (other.pathDist or math.huge)) + if (distViaNode < otherDist or preferNormalRoot) and node.type ~= "Mastery" and other.type ~= "ClassStart" and other.type ~= "AscendClassStart" and (not other.alloc or self:CanPathThroughAllocMode(node.allocMode or 0, other)) and (node.ascendancyName == other.ascendancyName or (nodeDist == 0 and not other.ascendancyName)) and canPath then -- if this node is free, push it to the front so that it can shorten paths if weight == 0 then @@ -2049,6 +2026,11 @@ function PassiveSpecClass:BuildAllDependsAndPaths() end end self:BuildNodePathsToRootNodes(rootList) + for _, node in ipairs(rootList) do + if node.isJewelSocket or node.expansionJewel then + self:SetNodeDistanceToClassStart(node) + end + end local alternateClassStartNodesArray = {} for _, node in pairs(alternateClassStartNodes) do alternateClassStartNodesArray[#alternateClassStartNodesArray + 1] = node diff --git a/src/Modules/CalcSetup.lua b/src/Modules/CalcSetup.lua index 0392224493..7f0f8a2fde 100644 --- a/src/Modules/CalcSetup.lua +++ b/src/Modules/CalcSetup.lua @@ -422,14 +422,12 @@ function calcs.buildModListForNodeList(env, nodeList, finishJewels, includeKeyst -- Add node modifiers local modList = new("ModList"):ModList() - local explodeSources = {} -- Outside MAIN mode the per-node list is merged into modList and then -- dropped, so a single list can be recycled for every node instead of -- allocating one each time. local scratch = env.mode ~= "MAIN" and new("ModList"):ModList() or nil for _, node in pairs(nodeList) do - local nodeModList, explode = calcs.buildModListForNode(env, node, scratch, inc, includeKeystoneMods) - t_insert(explodeSources, explode) + local nodeModList = calcs.buildModListForNode(env, node, scratch, inc, includeKeystoneMods) modList:AddList(nodeModList) if env.mode == "MAIN" then node.finalModList = nodeModList