-- Path of Building -- -- Class: Passive Spec -- Passive tree spec class. -- Manages node allocation and pathing for a given passive spec -- local launch, main = ... local pairs = pairs local ipairs = ipairs local t_insert = table.insert local m_min = math.min local m_max = math.max local m_floor = math.floor local PassiveSpecClass = common.NewClass("PassiveSpec", "UndoHandler", function(self, build) self.UndoHandler() self.build = build self.tree = build.tree -- Make a local copy of the passive tree that we can modify self.nodes = { } for _, treeNode in ipairs(self.tree.nodes) do self.nodes[treeNode.id] = setmetatable({ linked = { }, power = { } }, treeNode.meta) end for id, node in pairs(self.nodes) do for _, otherId in ipairs(node.linkedId) do t_insert(node.linked, self.nodes[otherId]) end end -- List of currently allocated nodes -- Keys are node IDs, values are nodes self.allocNodes = { } -- Table of jewels equipped in this tree -- Keys are node IDs, values are items self.jewels = { } self:SelectClass(0) end) function PassiveSpecClass:Load(xml, dbFileName) local url self.title = xml.attrib.title for _, node in pairs(xml) do if type(node) == "table" then if node.elem == "URL" then if type(node[1]) ~= "string" then launch:ShowErrMsg("^1Error parsing '%s': 'URL' element missing content", dbFileName) return true end url = node[1] elseif node.elem == "Sockets" then for _, child in ipairs(node) do if child.elem == "Socket" then if not child.attrib.nodeId then launch:ShowErrMsg("^1Error parsing '%s': 'Socket' element missing 'nodeId' attribute", dbFileName) return true end if not child.attrib.itemId then launch:ShowErrMsg("^1Error parsing '%s': 'Socket' element missing 'itemId' attribute", dbFileName) return true end self.jewels[tonumber(child.attrib.nodeId)] = tonumber(child.attrib.itemId) end end end end end if url then self:DecodeURL(url) end self:ResetUndo() end function PassiveSpecClass:Save(xml) xml.attrib = { title = self.title, } t_insert(xml, { elem = "URL", [1] = self:EncodeURL("https://www.pathofexile.com/passive-skill-tree/") }) local sockets = { elem = "Sockets" } for nodeId, itemId in pairs(self.jewels) do t_insert(sockets, { elem = "Socket", attrib = { nodeId = tostring(nodeId), itemId = tostring(itemId) } }) end t_insert(xml, sockets) self.modFlag = false end -- Import passive spec from the provided class IDs and node hash list function PassiveSpecClass:ImportFromNodeList(classId, ascendClassId, hashList) self:ResetNodes() self:SelectClass(classId) for _, id in pairs(hashList) do local node = self.nodes[id] if node then node.alloc = true self.allocNodes[id] = node end end self:SelectAscendClass(ascendClassId) end -- Decode the given passive tree URL -- Supports both the official skill tree links as well as PoE Planner links function PassiveSpecClass:DecodeURL(url) local b = common.base64.decode(url:gsub("^.+/",""):gsub("-","+"):gsub("_","/")) if not b or #b < 6 then return "Invalid tree link (unrecognised format)" end local classId, ascendClassId, bandits, nodes if b:byte(1) == 0 and b:byte(2) == 2 then -- Hold on to your headgear, it looks like a PoE Planner link -- Let's grab a scalpel and start peeling back the 50 layers of base 64 encoding local treeLinkLen = b:byte(4) * 256 + b:byte(5) local treeLink = b:sub(6, 6 + treeLinkLen - 1) b = common.base64.decode(treeLink:gsub("^.+/",""):gsub("-","+"):gsub("_","/")) classId = b:byte(3) ascendClassId = b:byte(4) bandits = b:byte(5) nodes = b:sub(8, -1) elseif b:byte(1) == 0 and b:byte(2) == 4 then -- PoE Planner version 4 -- Now with 50% fewer layers of base 64 encoding classId = b:byte(6) % 16 ascendClassId = m_floor(b:byte(6) / 16) bandits = b:byte(7) local numNodes = b:byte(8) * 256 + b:byte(9) nodes = b:sub(10, 10 + numNodes * 2 - 1) else local ver = b:byte(1) * 16777216 + b:byte(2) * 65536 + b:byte(3) * 256 + b:byte(4) if ver > 4 then return "Invalid tree link (unknown version number '"..ver.."')" end classId = b:byte(5) ascendClassId = 0--(ver >= 4) and b:byte(6) or 0 -- This value would be reliable if the developer of a certain online skill tree planner *cough* PoE Planner *cough* hadn't bollocked up -- the generation of the official tree URL. The user would most likely import the PoE Planner URL instead but that can't be relied upon. nodes = b:sub(ver >= 4 and 8 or 7, -1) end if not self.tree.classes[classId] then return "Invalid tree link (bad class ID '"..classId.."')" end self:ResetNodes() self:SelectClass(classId) for i = 1, #nodes - 1, 2 do local id = nodes:byte(i) * 256 + nodes:byte(i + 1) local node = self.nodes[id] if node then node.alloc = true self.allocNodes[id] = node if ascendClassId == 0 and node.ascendancyName then -- Just guess the ascendancy class based on the allocated nodes ascendClassId = self.tree.ascendNameMap[node.ascendancyName].ascendClassId end end end self:SelectAscendClass(ascendClassId) if bandits then -- Decode bandits from PoEPlanner local lookup = { [0] = "None", "Alira", "Kraityn", "Oak" } self.build.banditNormal = lookup[bandits % 4] self.build.banditCruel = lookup[m_floor(bandits / 4) % 4] self.build.banditMerciless = lookup[m_floor(bandits / 16) % 4] end end -- Encodes the current spec into a URL, using the official skill tree's format -- Prepends the URL with an optional prefix function PassiveSpecClass:EncodeURL(prefix) local a = { 0, 0, 0, 4, self.curClassId, self.curAscendClassId, 0 } for id, node in pairs(self.allocNodes) do if node.type ~= "classStart" and node.type ~= "ascendClassStart" then t_insert(a, m_floor(id / 256)) t_insert(a, id % 256) end end return (prefix or "")..common.base64.encode(string.char(unpack(a))):gsub("+","-"):gsub("/","_") end -- Change the current class, preserving currently allocated nodes if they connect to the new class's starting node function PassiveSpecClass:SelectClass(classId) if self.curClassId then -- Deallocate the current class's starting node local oldStartNodeId = self.curClass.startNodeId self.nodes[oldStartNodeId].alloc = false self.allocNodes[oldStartNodeId] = nil end self.curClassId = classId local class = self.tree.classes[classId] self.curClass = class self.curClassName = class.name -- Allocate the new class's starting node local startNode = self.nodes[class.startNodeId] startNode.alloc = true self.allocNodes[startNode.id] = startNode -- Reset the ascendancy class -- This will also rebuild the node paths and dependancies self:SelectAscendClass(0) end function PassiveSpecClass:SelectAscendClass(ascendClassId) self.curAscendClassId = ascendClassId local ascendClass = self.curClass.classes[ascendClassId] or self.curClass.classes[0] self.curAscendClass = ascendClass self.curAscendClassName = ascendClass.name -- Deallocate any allocated ascendancy nodes that don't belong to the new ascendancy class for id, node in pairs(self.allocNodes) do if node.ascendancyName and node.ascendancyName ~= ascendClass.name then node.alloc = false self.allocNodes[id] = nil end end if ascendClass.startNodeId then -- Allocate the new ascendancy class's start node local startNode = self.nodes[ascendClass.startNodeId] startNode.alloc = true self.allocNodes[startNode.id] = startNode end -- Rebuild all the node paths and dependancies self:BuildAllDependsAndPaths() end -- Determines if the given class's start node is connected to the current class's start node -- Attempts to find a path between the nodes which doesn't pass through any ascendancy nodes (i.e Ascendant) function PassiveSpecClass:IsClassConnected(classId) for _, other in ipairs(self.nodes[self.tree.classes[classId].startNodeId].linked) do -- For each of the nodes to which the given class's start node connects... if other.alloc then -- If the node is allocated, try to find a path back to the current class's starting node other.visited = true local visited = { } local found = self:FindStartFromNode(other, visited, true) for i, n in ipairs(visited) do n.visited = false end other.visited = false if found then -- Found a path, so the given class's start node is definately connected to the current class's start node -- There might still be nodes which are connected to the current tree by an entirely different path though -- E.g via Ascendant or by connecting to another "first passive node" return true end end end return false end -- Clear the allocated status of all non-class-start nodes function PassiveSpecClass:ResetNodes() for id, node in pairs(self.nodes) do if node.type ~= "classStart" and node.type ~= "ascendClassStart" then node.alloc = false self.allocNodes[id] = nil end end 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 function PassiveSpecClass:AllocNode(node, altPath) if not node.path then -- Node cannot be connected to the tree as there is no possible path return end -- Allocate all nodes along the path if node.dependsOnIntuitiveLeap then node.alloc = true self.allocNodes[node.id] = node else for _, pathNode in ipairs(altPath or node.path) do pathNode.alloc = true self.allocNodes[pathNode.id] = pathNode end end if node.isMultipleChoiceOption then -- For multiple choice passives, make sure no other choices are allocated local parent = node.linked[1] for _, optNode in ipairs(parent.linked) do if optNode.isMultipleChoiceOption and optNode.alloc and optNode ~= node then optNode.alloc = false self.allocNodes[optNode.id] = nil end end end -- Rebuild all dependancies and paths for all allocated nodes self:BuildAllDependsAndPaths() end -- Deallocate the given node, and all nodes which depend on it (i.e which are only connected to the tree through this node) function PassiveSpecClass:DeallocNode(node) for _, depNode in ipairs(node.depends) do depNode.alloc = false self.allocNodes[depNode.id] = nil end -- Rebuild all paths and dependancies for all allocated nodes self:BuildAllDependsAndPaths() end -- Count the number of allocated nodes and allocated ascendancy nodes function PassiveSpecClass:CountAllocNodes() local used, ascUsed, sockets = 0, 0, 0 for _, node in pairs(self.allocNodes) do if node.type ~= "classStart" and node.type ~= "ascendClassStart" then if node.ascendancyName then if not node.isMultipleChoiceOption then ascUsed = ascUsed + 1 end else used = used + 1 end if node.type == "socket" then sockets = sockets + 1 end end end return used, ascUsed, sockets end -- 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... 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 if other.alloc and (other.type == "classStart" or other.type == "ascendClassStart" or (not other.visited and self:FindStartFromNode(other, visited, noAscend))) then if not noAscend or other.type ~= "ascendClassStart" then return true end end end 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 = { } 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 local curDist = node.pathDist + 1 -- Iterate through all nodes that are connected to this one for _, other in ipairs(node.linked) do -- Paths must obey two 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 if other.type ~= "classStart" and other.type ~= "ascendClassStart" and other.pathDist > curDist and (node.ascendancyName == other.ascendancyName or (curDist == 1 and not other.ascendancyName)) then -- The shortest path to the other node is through the current node other.pathDist = curDist other.path = wipeTable(other.path) 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 end end -- Rebuilds dependancies and paths for all nodes function PassiveSpecClass:BuildAllDependsAndPaths() -- This table will keep track of which nodes have been visited during each path-finding attempt local visited = { } -- Check all nodes for other nodes which depend on them (i.e are only connected to the tree through that node) for id, node in pairs(self.nodes) do node.depends = wipeTable(node.depends) node.dependsOnIntuitiveLeap = false if node.type ~= "classStart" then for nodeId, itemId in pairs(self.jewels) do if self.allocNodes[nodeId] and self.nodes[nodeId].nodesInRadius[1][node.id] then if itemId ~= 0 and self.build.itemsTab.list[itemId] and self.build.itemsTab.list[itemId].jewelData and self.build.itemsTab.list[itemId].jewelData.intuitiveLeap then -- This node depends on Intuitive Leap -- This flag: -- 1. Prevents generation of paths from this node -- 2. Prevents this node from being deallocted via dependancy -- 3. Prevents allocation of path nodes when this node is being allocated node.dependsOnIntuitiveLeap = true break end end end end if node.alloc then node.depends[1] = node -- All nodes depend on themselves end end for id, node in pairs(self.allocNodes) do node.visited = true 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 dependant 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 elseif self:FindStartFromNode(other, visited) then -- We found a path through the other node, therefore the other node cannot be dependant on this node anyStartFound = 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 dependant on this node for i, n in ipairs(visited) do if not n.dependsOnIntuitiveLeap then t_insert(node.depends, n) 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] and self.nodes[nodeId].nodesInRadius[1][depNode.id] then if itemId ~= 0 and (not self.build.itemsTab.list[itemId] or (self.build.itemsTab.list[itemId].jewelData and self.build.itemsTab.list[itemId].jewelData.intuitiveLeap)) then -- Hold off on the pruning; this node is within the radius of a jewel that is or could be Intuitive Leap prune = false t_insert(self.nodes[nodeId].depends, depNode) break end end end if prune then depNode.alloc = false self.allocNodes[depNode.id] = nil end end end end -- Reset and rebuild all node paths for id, node in pairs(self.nodes) do node.pathDist = (node.alloc and not node.dependsOnIntuitiveLeap) and 0 or 1000 node.path = nil end for id, node in pairs(self.allocNodes) do if not node.dependsOnIntuitiveLeap then self:BuildPathFromNode(node) end end end function PassiveSpecClass:CreateUndoState() return self:EncodeURL() end function PassiveSpecClass:RestoreUndoState(state) self:DecodeURL(state) end