Files
PathOfBuilding/src/Classes/TreeTab.lua
2025-10-30 01:40:46 -05:00

2438 lines
104 KiB
Lua

-- Path of Building
--
-- Module: Tree Tab
-- Passive skill tree tab for the current build.
--
local ipairs = ipairs
local pairs = pairs
local next = next
local t_insert = table.insert
local t_remove = table.remove
local t_sort = table.sort
local t_concat = table.concat
local m_max = math.max
local m_min = math.min
local m_floor = math.floor
local m_abs = math.abs
local s_format = string.format
local s_gsub = string.gsub
local s_byte = string.byte
local dkjson = require "dkjson"
local TreeTabClass = newClass("TreeTab", "ControlHost", function(self, build)
self.ControlHost()
self.build = build
self.isComparing = false;
self.isCustomMaxDepth = false;
self.viewer = new("PassiveTreeView")
self.specList = { }
self.specList[1] = new("PassiveSpec", build, latestTreeVersion)
self:SetActiveSpec(1)
self:SetCompareSpec(1)
self.anchorControls = new("Control", nil, {0, 0, 0, 20})
-- Tree list dropdown
self.controls.specSelect = new("DropDownControl", { "LEFT",self.anchorControls,"RIGHT" }, { 0, 0, 190, 20 }, nil, function(index, value)
if self.specList[index] then
self.build.modFlag = true
self:SetActiveSpec(index)
else
self:OpenSpecManagePopup()
end
end)
self.controls.specSelect.maxDroppedWidth = 1000
self.controls.specSelect.enableDroppedWidth = true
self.controls.specSelect.enableChangeBoxWidth = true
self.controls.specSelect.controls.scrollBar.enabled = true
self.controls.specSelect.tooltipFunc = function(tooltip, mode, selIndex, selVal)
tooltip:Clear()
if mode ~= "OUT" then
local spec = self.specList[selIndex]
if spec then
local used, ascUsed, secondaryAscUsed, sockets = spec:CountAllocNodes()
tooltip:AddLine(16, "Class: "..spec.curClassName)
tooltip:AddLine(16, "Ascendancy: "..spec.curAscendClassName)
tooltip:AddLine(16, "Points used: "..used)
if sockets > 0 then
tooltip:AddLine(16, "Jewel sockets: "..sockets)
end
if selIndex ~= self.activeSpec then
local calcFunc, calcBase = self.build.calcsTab:GetMiscCalculator()
if calcFunc then
local output = calcFunc({ spec = spec })
self.build:AddStatComparesToTooltip(tooltip, calcBase, output, "^7Switching to this tree will give you:")
end
if spec.curClassId == self.build.spec.curClassId then
local respec = 0
for nodeId, node in pairs(self.build.spec.allocNodes) do
-- Assumption: Nodes >= 65536 are small cluster passives.
if node.type ~= "ClassStart" and node.type ~= "AscendClassStart"
and (self.build.spec.tree.clusterNodeMap[node.dn] == nil or node.isKeystone or node.isJewelSocket) and nodeId < 65536
and not spec.allocNodes[nodeId] then
if node.ascendancyName then
respec = respec + 5
else
respec = respec + 1
end
end
end
if respec > 0 then
tooltip:AddLine(16, "^7Switching to this tree requires "..respec.." refund points.")
end
end
end
tooltip:AddLine(16, "Game Version: "..treeVersions[spec.treeVersion].display)
end
end
end
-- Compare checkbox
self.controls.compareCheck = new("CheckBoxControl", { "LEFT", self.controls.specSelect, "RIGHT" }, { 74, 0, 20 }, "Compare:", function(state)
self.isComparing = state
self:SetCompareSpec(self.activeCompareSpec)
self.controls.compareSelect.shown = state
if state then
self.controls.reset:SetAnchor("LEFT", self.controls.compareSelect, "RIGHT", nil, nil, nil)
else
self.controls.reset:SetAnchor("LEFT", self.controls.compareCheck, "RIGHT", nil, nil, nil)
end
end)
-- Compare tree dropdown
self.controls.compareSelect = new("DropDownControl", { "LEFT", self.controls.compareCheck, "RIGHT" }, { 8, 0, 190, 20 }, nil, function(index, value)
if self.specList[index] then
self:SetCompareSpec(index)
else
self:OpenSpecManagePopup()
end
end)
self.controls.compareSelect.shown = false
self.controls.compareSelect.maxDroppedWidth = 1000
self.controls.compareSelect.enableDroppedWidth = true
self.controls.compareSelect.enableChangeBoxWidth = true
self.controls.reset = new("ButtonControl", { "LEFT", self.controls.compareCheck, "RIGHT" }, { 8, 0, 145, 20 }, "Reset Tree/Tattoos", function()
local controls = { }
local buttonY = 65
controls.warningLabel = new("LabelControl", nil, { 0, 30, 0, 16 }, "^7Warning: resetting your passive tree or removing all tattoos cannot be undone.\n")
controls.reset = new("ButtonControl", nil, { -130, buttonY, 100, 20 }, "Reset Tree", function()
self.build.spec:ResetNodes()
self.build.spec:BuildAllDependsAndPaths()
self.build.spec:AddUndoState()
self.build.buildFlag = true
main:ClosePopup()
end)
controls.removeTattoo = new("ButtonControl", nil, { 0, buttonY, 144, 20 }, "Remove All Tattoos", function()
for id, node in pairs(self.build.spec.hashOverrides) do --hashOverrides will contain only the nodes that have been tattoo-ed
if node.isTattoo then
self:RemoveTattooFromNode(self.build.spec.nodes[id])
end
end
self.modFlag = true
self.build.buildFlag = true
main:ClosePopup()
end)
controls.cancel = new("ButtonControl", nil, { 130, buttonY, 100, 20 }, "Cancel", function()
main:ClosePopup()
end)
main:OpenPopup(570, 100, "Reset Tree/Tattoos", controls, nil, "edit", "cancel")
end)
-- Tree Version Dropdown
self.treeVersions = { }
for _, num in ipairs(treeVersionList) do
local value = {
label = treeVersions[num].display,
value = num
}
t_insert(self.treeVersions, value)
end
self.controls.versionText = new("LabelControl", { "LEFT", self.controls.reset, "RIGHT" }, { 8, 0, 0, 16 }, "Version:")
self.controls.versionSelect = new("DropDownControl", { "LEFT", self.controls.versionText, "RIGHT" }, { 8, 0, 100, 20 }, self.treeVersions, function(index, selected)
if selected.value ~= self.build.spec.treeVersion then
self:OpenVersionConvertPopup(selected.value, true)
end
end)
self.controls.versionSelect.maxDroppedWidth = 1000
self.controls.versionSelect.enableDroppedWidth = true
self.controls.versionSelect.enableChangeBoxWidth = true
self.controls.versionSelect:CheckDroppedWidth(true)
self.controls.versionSelect.selIndex = #self.treeVersions
-- Tree Search Textbox
self.controls.treeSearch = new("EditControl", { "LEFT", self.controls.versionSelect, "RIGHT" }, { 8, 0, main.portraitMode and 200 or 300, 20 }, "", "Search", "%c", 100, function(buf)
self.viewer.searchStr = buf
self.searchFlag = buf ~= self.viewer.searchStrSaved
end, nil, nil, true)
self.controls.treeSearch.tooltipText = "Uses Lua pattern matching for complex searches.\nPrefix your search with \"oil:\" to search by anoint recipe.\nTo search for multiple terms: (increased.fire.damage|increased.area.of.effect|etc)"
self.tradeLeaguesList = { }
-- Find Timeless Jewel Button
self.controls.findTimelessJewel = new("ButtonControl", { "LEFT", self.controls.treeSearch, "RIGHT" }, { 8, 0, 150, 20 }, "Find Timeless Jewel", function()
self:FindTimelessJewel()
end)
--Default index for Tattoos
self.defaultTattoo = { }
-- Show Node Power Checkbox
self.controls.treeHeatMap = new("CheckBoxControl", { "LEFT", self.controls.findTimelessJewel, "RIGHT" }, { 130, 0, 20 }, "Show Node Power:", function(state)
self.viewer.showHeatMap = state
self.controls.treeHeatMapStatSelect.shown = state
if state == false then
self.controls.powerReportList.shown = false
end
end)
-- Control for setting max node depth to limit calculation time of the heat map
self.controls.nodePowerMaxDepthSelect = new("DropDownControl", { "LEFT", self.controls.treeHeatMap, "RIGHT" }, { 8, 0, 55, 20 }, { "All", 5, 10, 15, "Custom" }, function(index, value)
-- Show custom value control and resize/move elements
self.isCustomMaxDepth = value == "Custom"
if self.isCustomMaxDepth then
self.controls.nodePowerMaxDepthSelect.width = 70
self.controls.nodePowerMaxDepthCustom.shown = true
self.controls.treeHeatMapStatSelect:SetAnchor("LEFT", self.controls.nodePowerMaxDepthCustom, "RIGHT", nil, nil, nil)
return
end
self.controls.nodePowerMaxDepthSelect.width = 55
self.controls.nodePowerMaxDepthCustom.shown = false
self.controls.treeHeatMapStatSelect:SetAnchor("LEFT", self.controls.nodePowerMaxDepthSelect, "RIGHT", nil, nil, nil)
local oldMax = self.build.calcsTab.nodePowerMaxDepth
if type(value) == "number" then
self.build.calcsTab.nodePowerMaxDepth = value
else
self.build.calcsTab.nodePowerMaxDepth = nil
end
-- If the heat map is shown, tell it to recalculate
-- if the new value is larger than the old
if oldMax ~= value and self.viewer.showHeatMap then
if oldMax ~= nil and (self.build.calcsTab.nodePowerMaxDepth == nil or self.build.calcsTab.nodePowerMaxDepth > oldMax) then
self:SetPowerCalc(self.build.calcsTab.powerStat)
end
end
end)
self.controls.nodePowerMaxDepthSelect.tooltipText = "Limit of Node distance to search (lower = faster)"
-- Control for setting max node depth by custom value
self.controls.nodePowerMaxDepthCustom = new("EditControl", { "LEFT", self.controls.nodePowerMaxDepthSelect, "RIGHT" }, { 8, 0, 70, 20 }, "0", nil, "%D", nil, function(value)
self.build.calcsTab.nodePowerMaxDepth = tonumber(value)
-- If the heat map is shown, recalculate it with new value
if self.viewer.showHeatMap then
self:SetPowerCalc(self.build.calcsTab.powerStat)
end
end)
self.controls.nodePowerMaxDepthCustom.shown = false
-- Control for selecting the power stat to sort by (Defense, DPS, etc)
self.controls.treeHeatMapStatSelect = new("DropDownControl", { "LEFT", self.controls.nodePowerMaxDepthSelect, "RIGHT" }, { 8, 0, 150, 20 }, nil, function(index, value)
self:SetPowerCalc(value)
end)
self.controls.treeHeatMap.tooltipText = function()
local offCol, defCol = main.nodePowerTheme:match("(%a+)/(%a+)")
return "When enabled, an estimate of the offensive and defensive strength of\neach unallocated passive is calculated and displayed visually.\nOffensive power shows as "..offCol:lower()..", defensive power as "..defCol:lower().."."
end
self.powerStatList = { }
for _, stat in ipairs(data.powerStatList) do
if not stat.ignoreForNodes then
t_insert(self.powerStatList, stat)
end
end
-- Show/Hide Power Report Button
self.controls.powerReport = new("ButtonControl", { "LEFT", self.controls.treeHeatMapStatSelect, "RIGHT" }, { 8, 0, 150, 20 },
function() return self.controls.powerReportList.shown and "Hide Power Report" or "Show Power Report" end, function()
self.controls.powerReportList.shown = not self.controls.powerReportList.shown
end)
-- Power Report List
local yPos = self.controls.treeHeatMap.y == 0 and self.controls.specSelect.height + 4 or self.controls.specSelect.height * 2 + 8
self.controls.powerReportList = new("PowerReportListControl", { "TOPLEFT", self.controls.specSelect, "BOTTOMLEFT" }, { 0, yPos, 700, 170 }, function(selectedNode)
-- this code is called by the list control when the user "selects" one of the passives in the list.
-- we use this to set a flag which causes the next Draw() to recenter the passive tree on the desired node.
if selectedNode.x then
self.jumpToNode = true
self.jumpToX = selectedNode.x
self.jumpToY = selectedNode.y
end
end)
self.controls.powerReportList.shown = false
self.build.powerBuilderCallback = function()
local powerStat = self.build.calcsTab.powerStat or data.powerStatList[1]
local report = self:BuildPowerReportList(powerStat)
self.controls.powerReportList:SetReport(powerStat, report)
end
self.controls.specConvertText = new("LabelControl", { "BOTTOMLEFT", self.controls.specSelect, "TOPLEFT" }, { 0, -14, 0, 16 }, "^7This is an older tree version, which may not be fully compatible with the current game version.")
self.controls.specConvertText.shown = function()
return self.showConvert
end
local function getLatestTreeVersion()
return latestTreeVersion .. (self.specList[self.activeSpec].treeVersion:match("^" .. latestTreeVersion .. "(.*)") or "")
end
local function buildConvertButtonLabel()
return colorCodes.POSITIVE.."Convert to "..treeVersions[getLatestTreeVersion()].display
end
local function buildConvertAllButtonLabel()
return colorCodes.POSITIVE.."Convert all trees to "..treeVersions[getLatestTreeVersion()].display
end
self.controls.specConvert = new("ButtonControl", { "LEFT", self.controls.specConvertText, "RIGHT" }, { 8, 0, function() return DrawStringWidth(16, "VAR", buildConvertButtonLabel()) + 20 end, 20 }, buildConvertButtonLabel, function()
self:ConvertToVersion(getLatestTreeVersion(), false, true)
end)
self.controls.specConvertAll = new("ButtonControl", { "LEFT", self.controls.specConvert, "RIGHT" }, { 8, 0, function() return DrawStringWidth(16, "VAR", buildConvertAllButtonLabel()) + 20 end, 20 }, buildConvertAllButtonLabel, function()
self:OpenVersionConvertAllPopup(getLatestTreeVersion())
end)
self.jumpToNode = false
self.jumpToX = 0
self.jumpToY = 0
end)
function TreeTabClass:RemoveTattooFromNode(node)
self.build.spec.tree.nodes[node.id].isTattoo = false
self.build.spec.hashOverrides[node.id] = nil
self.build.spec:ReplaceNode(node, self.build.spec.tree.nodes[node.id])
self.build.spec:BuildAllDependsAndPaths()
end
function TreeTabClass:Draw(viewPort, inputEvents)
self.anchorControls.x = viewPort.x + 4
self.anchorControls.y = viewPort.y + viewPort.height - 24
for id, event in ipairs(inputEvents) do
if event.type == "KeyDown" then
if event.key == "z" and IsKeyDown("CTRL") then
self.build.spec:Undo()
self.build.buildFlag = true
inputEvents[id] = nil
elseif event.key == "y" and IsKeyDown("CTRL") then
self.build.spec:Redo()
self.build.buildFlag = true
inputEvents[id] = nil
elseif event.key == "UP" then
local index = self.activeSpec - 1
if self.specList[index] and not self.controls.specSelect:IsMouseOver() and not self.controls.specSelect.dropped then
self.build.modFlag = true
self:SetActiveSpec(index)
end
elseif event.key == "DOWN" and not self.controls.specSelect:IsMouseOver() and not self.controls.specSelect.dropped then
local index = self.activeSpec + 1
if self.specList[index] then
self.build.modFlag = true
self:SetActiveSpec(index)
end
elseif event.key == "f" and IsKeyDown("CTRL") then
self:SelectControl(self.controls.treeSearch)
elseif event.key == "m" and IsKeyDown("CTRL") then
self:OpenSpecManagePopup()
end
end
end
self:ProcessControlsInput(inputEvents, viewPort)
-- Determine positions if one line of controls doesn't fit in the screen width
local linesHeight = 24
local rightMargin = 10
local widthFirstLineControls = self.controls.specSelect.width + 8
+ self.controls.compareCheck.width + self.controls.compareCheck.x
+ self.controls.reset.width + self.controls.reset.x
+ self.controls.versionText.width() + self.controls.versionText.x
+ self.controls.versionSelect.width + self.controls.versionSelect.x
+ (self.isComparing and (self.controls.compareSelect.width + self.controls.compareSelect.x) or 0)
local widthSecondLineControls = self.controls.treeSearch.width + 8
+ self.controls.findTimelessJewel.width + self.controls.findTimelessJewel.x
+ self.controls.treeHeatMap.width + 130
+ self.controls.nodePowerMaxDepthSelect.width + self.controls.nodePowerMaxDepthSelect.x
+ (self.isCustomMaxDepth and (self.controls.nodePowerMaxDepthCustom.width + self.controls.nodePowerMaxDepthCustom.x) or 0)
+ (self.viewer.showHeatMap and (self.controls.treeHeatMapStatSelect.width + self.controls.treeHeatMapStatSelect.x
+ self.controls.powerReport.width + self.controls.powerReport.x) or 0)
-- Check first line
if viewPort.width >= widthFirstLineControls + widthSecondLineControls + rightMargin then
linesHeight = 0
self.controls.treeSearch:SetAnchor("LEFT", self.controls.versionSelect, "RIGHT", 8, 0)
self.controls.powerReportList:SetAnchor("TOPLEFT", self.controls.specSelect, "BOTTOMLEFT", 0, self.controls.specSelect.height + 6)
else
self.controls.treeSearch:SetAnchor("TOPLEFT", self.controls.specSelect, "BOTTOMLEFT", 0, 4)
self.controls.powerReportList:SetAnchor("TOPLEFT", self.controls.treeSearch, "BOTTOMLEFT", 0, self.controls.treeSearch.height + 6)
end
-- Check second line
if viewPort.width >= widthSecondLineControls + rightMargin then
self.controls.treeHeatMap:SetAnchor("LEFT", self.controls.findTimelessJewel, "RIGHT", 130, 0)
else
linesHeight = linesHeight * 2
self.controls.treeHeatMap:SetAnchor("TOPLEFT", self.controls.treeSearch, "BOTTOMLEFT", 124, 4)
self.controls.powerReportList:SetAnchor("TOPLEFT", self.controls.treeHeatMap, "BOTTOMLEFT", -124, self.controls.treeHeatMap.height + 6)
end
-- determine positions for convert line of controls
local convertTwoLineHeight = 24
local convertMaxWidth = 900
if viewPort.width >= convertMaxWidth then
convertTwoLineHeight = 0
self.controls.specConvert:SetAnchor("LEFT", self.controls.specConvertText, "RIGHT", 8, 0)
self.controls.specConvertText:SetAnchor("BOTTOMLEFT", self.controls.specSelect, "TOPLEFT", 0, -14)
else
self.controls.specConvert:SetAnchor("TOPLEFT", self.controls.specConvertText, "BOTTOMLEFT", 0, 4)
self.controls.specConvertText:SetAnchor("BOTTOMLEFT", self.controls.specSelect, "TOPLEFT", 0, -38)
end
local bottomDrawerHeight = self.controls.powerReportList.shown and 194 or 0
self.controls.specSelect.y = -bottomDrawerHeight - linesHeight
local treeViewPort = { x = viewPort.x, y = viewPort.y, width = viewPort.width, height = viewPort.height - (self.showConvert and 64 + bottomDrawerHeight + linesHeight or 32 + bottomDrawerHeight + linesHeight)}
if self.jumpToNode then
self.viewer:Focus(self.jumpToX, self.jumpToY, treeViewPort, self.build)
self.jumpToNode = false
end
self.viewer.compareSpec = self.isComparing and self.specList[self.activeCompareSpec] or nil
self.viewer:Draw(self.build, treeViewPort, inputEvents)
local newSpecList = self:GetSpecList()
self.controls.compareSelect.selIndex = self.activeCompareSpec
self.controls.compareSelect:SetList(newSpecList)
t_insert(newSpecList, "Manage trees... (ctrl-m)")
self.controls.specSelect.selIndex = self.activeSpec
self.controls.specSelect:SetList(newSpecList)
if not self.controls.treeSearch.hasFocus then
self.controls.treeSearch:SetText(self.viewer.searchStr)
end
self.controls.treeHeatMap.state = self.viewer.showHeatMap
self.controls.treeHeatMapStatSelect.shown = self.viewer.showHeatMap
self.controls.treeHeatMapStatSelect.list = self.powerStatList
self.controls.treeHeatMapStatSelect.selIndex = 1
self.controls.treeHeatMapStatSelect:CheckDroppedWidth(true)
if self.build.calcsTab.powerStat then
self.controls.treeHeatMapStatSelect:SelByValue(self.build.calcsTab.powerStat.stat, "stat")
end
SetDrawLayer(1)
SetDrawColor(0.05, 0.05, 0.05)
DrawImage(nil, viewPort.x, viewPort.y + viewPort.height - (28 + bottomDrawerHeight + linesHeight), viewPort.width, 28 + bottomDrawerHeight + linesHeight)
if self.showConvert then
local height = viewPort.width < convertMaxWidth and (bottomDrawerHeight + linesHeight) or 0
SetDrawColor(0.05, 0.05, 0.05)
DrawImage(nil, viewPort.x, viewPort.y + viewPort.height - (60 + bottomDrawerHeight + linesHeight + convertTwoLineHeight), viewPort.width, 28 + height)
SetDrawColor(0.85, 0.85, 0.85)
DrawImage(nil, viewPort.x, viewPort.y + viewPort.height - (64 + bottomDrawerHeight + linesHeight + convertTwoLineHeight), viewPort.width, 4)
end
-- let white lines overwrite the black sections, regardless of showConvert
SetDrawColor(0.85, 0.85, 0.85)
DrawImage(nil, viewPort.x, viewPort.y + viewPort.height - (32 + bottomDrawerHeight + linesHeight), viewPort.width, 4)
self:DrawControls(viewPort)
end
function TreeTabClass:GetSpecList()
local newSpecList = { }
for _, spec in ipairs(self.specList) do
t_insert(newSpecList, (spec.treeVersion ~= latestTreeVersion and ("["..treeVersions[spec.treeVersion].display.."] ") or "")..(spec.title or "Default"))
end
return newSpecList
end
function TreeTabClass:Load(xml, dbFileName)
self.specList = { }
if xml.elem == "Spec" then
-- Import single spec from old build
self.specList[1] = new("PassiveSpec", self.build, defaultTreeVersion)
self.specList[1]:Load(xml, dbFileName)
self.activeSpec = 1
self.build.spec = self.specList[1]
return
end
for _, node in pairs(xml) do
if type(node) == "table" then
if node.elem == "Spec" then
if node.attrib.treeVersion and not treeVersions[node.attrib.treeVersion] then
main:OpenMessagePopup("Unknown Passive Tree Version", "The build you are trying to load uses an unrecognised version of the passive skill tree.\nYou may need to update the program before loading this build.")
return true
end
local newSpec = new("PassiveSpec", self.build, node.attrib.treeVersion or defaultTreeVersion)
newSpec:Load(node, dbFileName)
t_insert(self.specList, newSpec)
end
end
end
if not self.specList[1] then
self.specList[1] = new("PassiveSpec", self.build, latestTreeVersion)
end
self:SetActiveSpec(tonumber(xml.attrib.activeSpec) or 1)
end
function TreeTabClass:PostLoad()
for _, spec in ipairs(self.specList) do
spec:PostLoad()
end
end
function TreeTabClass:Save(xml)
xml.attrib = {
activeSpec = tostring(self.activeSpec)
}
for specId, spec in ipairs(self.specList) do
local child = {
elem = "Spec"
}
spec:Save(child)
t_insert(xml, child)
end
end
function TreeTabClass:SetActiveSpec(specId)
local prevSpec = self.build.spec
self.activeSpec = m_min(specId, #self.specList)
local curSpec = self.specList[self.activeSpec]
data.setJewelRadiiGlobally(curSpec.treeVersion)
self.build.spec = curSpec
self.build.buildFlag = true
self.build.spec:SetWindowTitleWithBuildClass()
self.build:UpdateClassDropdowns(curSpec.treeVersion)
for _, slot in pairs(self.build.itemsTab.slots) do
if slot.nodeId then
if prevSpec then
-- Update the previous spec's jewel for this slot
prevSpec.jewels[slot.nodeId] = slot.selItemId
end
if curSpec.jewels[slot.nodeId] then
-- Socket the jewel for the new spec
slot.selItemId = curSpec.jewels[slot.nodeId]
else
-- Unsocket the old jewel from the previous spec
slot.selItemId = 0
end
end
end
self.showConvert = not curSpec.treeVersion:match("^" .. latestTreeVersion)
if self.build.itemsTab.itemOrderList[1] then
-- Update item slots if items have been loaded already
self.build.itemsTab:PopulateSlots()
end
-- Update the passive tree dropdown control in itemsTab
self.build.itemsTab.controls.specSelect.selIndex = specId
-- Update Version dropdown to active spec's
if self.controls.versionSelect then
self.controls.versionSelect:SelByValue(curSpec.treeVersion, 'value')
end
self.build:SyncLoadouts()
end
function TreeTabClass:SetCompareSpec(specId)
self.activeCompareSpec = m_min(specId, #self.specList)
local curSpec = self.specList[self.activeCompareSpec]
self.compareSpec = curSpec
end
function TreeTabClass:ConvertToVersion(version, remove, success, ignoreTreeSubType)
local treeSubTypeCapture = self.build.spec.treeVersion:match("(_%l+_?%l*)")
if not ignoreTreeSubType and treeSubTypeCapture and not version:match(treeSubTypeCapture) then
if isValueInTable(treeVersionList, version..treeSubTypeCapture) then
version = version..treeSubTypeCapture
end
end
local newSpec = new("PassiveSpec", self.build, version)
newSpec.title = self.build.spec.title
newSpec.jewels = copyTable(self.build.spec.jewels)
newSpec:RestoreUndoState(self.build.spec:CreateUndoState(), version)
newSpec:BuildClusterJewelGraphs()
t_insert(self.specList, self.activeSpec + 1, newSpec)
if remove then
t_remove(self.specList, self.activeSpec)
-- activeSpec + 1 is shifted down one on remove, otherwise we would set the spec below it if it exists
self:SetActiveSpec(self.activeSpec)
else
self:SetActiveSpec(self.activeSpec + 1)
end
self.modFlag = true
if success then
main:OpenMessagePopup("Tree Converted", "The tree has been converted to "..treeVersions[version].display..".\nNote that some or all of the passives may have been de-allocated due to changes in the tree.\n\nYou can switch back to the old tree using the tree selector at the bottom left.")
end
end
function TreeTabClass:ConvertAllToVersion(version)
local currActiveSpec = self.activeSpec
local specVersionList = { }
for _, spec in ipairs(self.specList) do
t_insert(specVersionList, spec.treeVersion)
end
for index, specVersion in ipairs(specVersionList) do
if specVersion ~= version then
self:SetActiveSpec(index)
self:ConvertToVersion(version, true, false)
end
end
self:SetActiveSpec(currActiveSpec)
end
function TreeTabClass:OpenSpecManagePopup()
local importTree =
new("ButtonControl", nil, {-99, 259, 90, 20}, "Import Tree", function()
self:OpenImportPopup()
end)
local exportTree =
new("ButtonControl", {"LEFT", importTree, "RIGHT"}, {8, 0, 90, 20}, "Export Tree", function()
self:OpenExportPopup()
end)
main:OpenPopup(370, 290, "Manage Passive Trees", {
new("PassiveSpecListControl", nil, {0, 50, 350, 200}, self),
importTree,
exportTree,
new("ButtonControl", {"LEFT", exportTree, "RIGHT"}, {8, 0, 90, 20}, "Done", function()
main:ClosePopup()
end),
})
end
function TreeTabClass:OpenVersionConvertPopup(version, ignoreTreeSubType)
local controls = { }
controls.warningLabel = new("LabelControl", nil, {0, 20, 0, 16}, "^7Warning: some or all of the passives may be de-allocated due to changes in the tree.\n\n" ..
"Convert will replace your current tree.\nCopy + Convert will backup your current tree.\n")
controls.convert = new("ButtonControl", nil, {-125, 105, 100, 20}, "Convert", function()
self:ConvertToVersion(version, true, false, ignoreTreeSubType)
main:ClosePopup()
end)
controls.convertCopy = new("ButtonControl", nil, {0, 105, 125, 20}, "Copy + Convert", function()
self:ConvertToVersion(version, false, false, ignoreTreeSubType)
main:ClosePopup()
end)
controls.cancel = new("ButtonControl", nil, {125, 105, 100, 20}, "Cancel", function()
self.controls.versionSelect:SelByValue(self.build.spec.treeVersion, 'value')
main:ClosePopup()
end)
main:OpenPopup(570, 140, "Convert to Version "..treeVersions[version].display, controls, "convert", "edit")
end
function TreeTabClass:OpenVersionConvertAllPopup(version)
local controls = { }
controls.warningLabel = new("LabelControl", nil, {0, 20, 0, 16}, "^7Warning: some or all of the passives may be de-allocated due to changes in the tree.\n\n" ..
"Convert will replace all trees that are not Version "..treeVersions[version].display..".\nThis action cannot be undone.\n")
controls.convert = new("ButtonControl", nil, {-58, 105, 100, 20}, "Convert", function()
self:ConvertAllToVersion(version)
main:ClosePopup()
end)
controls.cancel = new("ButtonControl", nil, {58, 105, 100, 20}, "Cancel", function()
main:ClosePopup()
end)
main:OpenPopup(570, 140, "Convert all to Version "..treeVersions[version].display, controls, "convert", "edit")
end
function TreeTabClass:OpenImportPopup()
local versionLookup = "tree/([0-9]+)%.([0-9]+)%.([0-9]+)/"
local controls = { }
local function decodePoePlannerTreeLink(treeLink)
-- treeVersion is not known at this point. We need to decode the URL to get it.
local tmpSpec = new("PassiveSpec", self.build, latestTreeVersion)
local newTreeVersion_or_errMsg = tmpSpec:DecodePoePlannerURL(treeLink, true)
-- Check for an error message
if string.find(newTreeVersion_or_errMsg, "Invalid") then
controls.msg.label = "^1"..newTreeVersion_or_errMsg
return
end
-- 20230908. We always create a new Spec()
local newSpec = new("PassiveSpec", self.build, newTreeVersion_or_errMsg)
newSpec.title = controls.name.buf
newSpec:DecodePoePlannerURL(treeLink, false) --DecodePoePlannerURL was used above and URL proven correct.
t_insert(self.specList, newSpec)
-- trigger all the things that go with changing a spec
self:SetActiveSpec(#self.specList)
self.modFlag = true
self.build.spec:AddUndoState()
self.build.buildFlag = true
main:ClosePopup()
end
local function decodeTreeLink(treeLink, newTreeVersion)
-- newTreeVersion is passed in as an output of validateTreeVersion(). It will always be a valid tree version text string
-- 20230908. We always create a new Spec()
ConPrintf("Tree version: " .. newTreeVersion)
local newSpec = new("PassiveSpec", self.build, newTreeVersion)
newSpec.title = controls.name.buf
local errMsg = newSpec:DecodeURL(treeLink)
if errMsg then
controls.msg.label = "^1"..errMsg.."^7"
else
t_insert(self.specList, newSpec)
-- trigger all the things that go with changing a spec
self:SetActiveSpec(#self.specList)
self.modFlag = true
self.build.spec:AddUndoState()
self.build.buildFlag = true
main:ClosePopup()
end
end
local function validateTreeVersion(alternateType, major, minor)
-- Take the Major and Minor version numbers and confirm it is a valid tree version. The point release is also passed in but it is not used
-- Return: the passed in tree version as text or latestTreeVersion
if major and minor then
--need leading 0 here
local newTreeVersionNum = tonumber(string.format("%d.%02d", major, minor))
if newTreeVersionNum >= treeVersions[defaultTreeVersion].num and newTreeVersionNum <= treeVersions[latestTreeVersion].num then
-- no leading 0 here
return string.format("%s_%s", major, minor) .. (alternateType and ("_" .. alternateType:gsub("-", "_")) or "")
else
print(string.format("Version '%d_%02d' is out of bounds", major, minor))
end
end
return latestTreeVersion .. (alternateType and ("_" .. alternateType:gsub("-", "_")) or "")
end
controls.nameLabel = new("LabelControl", nil, {-180, 20, 0, 16}, "Enter name for this passive tree:")
controls.name = new("EditControl", nil, {100, 20, 350, 18}, "", nil, nil, nil, function(buf)
controls.msg.label = ""
controls.import.enabled = buf:match("%S") and controls.edit.buf:match("%S")
end)
controls.editLabel = new("LabelControl", nil, {-150, 45, 0, 16}, "Enter passive tree link:")
controls.edit = new("EditControl", nil, {100, 45, 350, 18}, "", nil, nil, nil, function(buf)
controls.msg.label = ""
controls.import.enabled = buf:match("%S") and controls.name.buf:match("%S")
end)
controls.msg = new("LabelControl", nil, {0, 65, 0, 16}, "")
controls.import = new("ButtonControl", nil, {-45, 85, 80, 20}, "Import", function()
local treeLink = controls.edit.buf
if #treeLink == 0 then
return
end
-- EG: http://poeurl.com/dABz
if treeLink:match("poeurl%.com/") then
controls.import.enabled = false
controls.msg.label = "Resolving PoEURL link..."
local id = LaunchSubScript([[
local treeLink = ...
local curl = require("lcurl.safe")
local easy = curl.easy()
easy:setopt_url(treeLink)
easy:setopt_writefunction(function(data)
return true
end)
easy:perform()
local redirect = easy:getinfo(curl.INFO_REDIRECT_URL)
easy:close()
if not redirect or redirect:match("poeurl%.com/") then
return nil, "Failed to resolve PoEURL link"
end
return redirect
]], "", "", treeLink)
if id then
launch:RegisterSubScript(id, function(treeLink, errMsg)
if errMsg then
controls.msg.label = "^1"..errMsg.."^7"
controls.import.enabled = true
return
else
decodeTreeLink(treeLink, validateTreeVersion(treeLink:match("tree/(%l+%-?%l*)"), treeLink:match(versionLookup)))
end
end)
end
elseif treeLink:match("poeplanner.com/") then
decodePoePlannerTreeLink(treeLink:gsub("/%?v=.+#","/"))
elseif treeLink:match("poeskilltree.com/") then
local oldStyleVersionLookup = "/%?v=([0-9]+)%.([0-9]+)%.([0-9]+)%-?%w?%-?%w?#"
-- Strip the version from the tree : https://poeskilltree.com/?v=3.6.0#AAAABAMAABEtfIOFMo6-ksHfsOvu -> https://poeskilltree.com/AAAABAMAABEtfIOFMo6-ksHfsOvu
decodeTreeLink(treeLink:gsub("/%?v=.+#","/"), validateTreeVersion(treeLink:match("%-(%l+%-?%l*)#"), treeLink:match(oldStyleVersionLookup)))
else
-- EG: https://www.pathofexile.com/passive-skill-tree/3.15.0/AAAABgMADI6-HwKSwQQHLJwtH9-wTLNfKoP3ES3r5AAA
-- EG: https://www.pathofexile.com/fullscreen-passive-skill-tree/3.15.0/AAAABgMADAQHES0fAiycLR9Ms18qg_eOvpLB37Dr5AAA
-- EG: https://www.pathofexile.com/passive-skill-tree/ruthless/AAAABgAAAAAA (Ruthless doesn't have versions)
-- EG: https://www.pathofexile.com/passive-skill-tree/ruthless-alternate/AAAABgAAAAAA
-- EG: https://www.pathofexile.com/passive-skill-tree/alternate/AAAABgAAAAAA
decodeTreeLink(treeLink, validateTreeVersion(treeLink:match("tree/(%l+%-?%l*)"), treeLink:match(versionLookup)))
end
end)
controls.import.enabled = false
controls.cancel = new("ButtonControl", nil, {45, 85, 80, 20}, "Cancel", function()
main:ClosePopup()
end)
main:OpenPopup(580, 115, "Import Tree", controls, "import", "name")
end
function TreeTabClass:OpenExportPopup()
local treeLink = self.build.spec:EncodeURL(treeVersions[self.build.spec.treeVersion].url)
local popup
local controls = { }
controls.label = new("LabelControl", nil, {0, 20, 0, 16}, "Passive tree link:")
controls.edit = new("EditControl", nil, {0, 40, 350, 18}, treeLink, nil, "%Z")
controls.shrink = new("ButtonControl", nil, {-90, 70, 140, 20}, "Shrink with PoEURL", function()
controls.shrink.enabled = false
controls.shrink.label = "Shrinking..."
launch:DownloadPage("http://poeurl.com/shrink.php?url="..treeLink, function(response, errMsg)
controls.shrink.label = "Done"
if errMsg or not response.body:match("%S") then
main:OpenMessagePopup("PoEURL Shortener", "Failed to get PoEURL link. Try again later.")
else
treeLink = "http://poeurl.com/"..response.body
controls.edit:SetText(treeLink)
popup:SelectControl(controls.edit)
end
end)
end)
controls.copy = new("ButtonControl", nil, {30, 70, 80, 20}, "Copy", function()
Copy(treeLink)
end)
controls.done = new("ButtonControl", nil, {120, 70, 80, 20}, "Done", function()
main:ClosePopup()
end)
popup = main:OpenPopup(380, 100, "Export Tree", controls, "done", "edit")
end
function TreeTabClass:ModifyNodePopup(selectedNode)
local controls = { }
local modGroups = { }
local treeNodes = self.build.spec.tree.nodes
local nodeName = treeNodes[selectedNode.id].dn
local function buildMods(selectedNode)
wipeTable(modGroups)
local numLinkedNodes = selectedNode.linkedId and #selectedNode.linkedId or 0
local nodeValue = treeNodes[selectedNode.id].sd[1] or ""
for id, node in pairs(self.build.spec.tree.tattoo.nodes) do
if (nodeName:match(node.targetType:gsub("^Small ", "")) or (node.targetValue ~= "" and nodeValue:match(node.targetValue)) or
(node.targetType == "Small Attribute" and (nodeName == "Intelligence" or nodeName == "Strength" or nodeName == "Dexterity"))
or (node.targetType == "Keystone" and treeNodes[selectedNode.id].type == node.targetType))
and node.MinimumConnected <= numLinkedNodes and ((node.legacy == nil or node.legacy == false) or node.legacy == self.showLegacyTattoo) then
local combine = false
for id, desc in pairs(node.stats) do
combine = (id:match("^local_display.*") and #node.stats == (#node.sd - 1)) or combine
if combine then break end
end
local descriptionsAndReminders = copyTable(node.sd)
if combine then
t_remove(descriptionsAndReminders, 1)
t_remove(descriptionsAndReminders, 1)
t_insert(descriptionsAndReminders, 1, node.sd[1] .. " " .. node.sd[2])
end
local descriptionsAndReminders = combine and { [1] = table.concat(node.sd, " ") } or copyTable(node.sd)
if node.reminderText then
t_insert(descriptionsAndReminders, node.reminderText[1])
end
t_insert(modGroups, {
label = node.dn .. " " .. table.concat(node.sd, ","),
descriptions = descriptionsAndReminders,
id = id,
})
end
end
table.sort(modGroups, function(a, b) return a.label < b.label end)
end
local function addModifier(selectedNode)
local newTattooNode = self.build.spec.tree.tattoo.nodes[modGroups[controls.modSelect.selIndex].id]
newTattooNode.id = selectedNode.id
self.build.spec.hashOverrides[selectedNode.id] = newTattooNode
self.build.spec:ReplaceNode(selectedNode, newTattooNode)
self.build.spec:BuildAllDependsAndPaths()
end
local function constructUI(modGroup)
local totalHeight = 43
local maxWidth = 375
local i = 1
while controls[i] do
controls[i] = nil
i = i + 1
end
local wrapTable = {}
for idx, desc in ipairs(modGroup.descriptions) do
for _, wrappedDesc in ipairs(main:WrapString(desc, 16, maxWidth)) do
t_insert(wrapTable, wrappedDesc)
end
end
for idx, desc in ipairs(wrapTable) do
controls[idx] = new("LabelControl", {"TOPLEFT", controls[idx-1] or controls.modSelect,"TOPLEFT"}, {0, 20, 600, 16}, "^7"..desc)
totalHeight = totalHeight + 20
end
main.popups[1].height = totalHeight + 75
local buttonHeight = totalHeight + 15
controls.save.y = buttonHeight
controls.reset.y = buttonHeight
controls.close.y = buttonHeight
controls.totalTattoos.y = buttonHeight + 30
end
buildMods(selectedNode)
controls.modSelectLabel = new("LabelControl", {"TOPRIGHT",nil,"TOPLEFT"}, {150, 25, 0, 16}, "^7Modifier:")
controls.modSelect = new("DropDownControl", {"TOPLEFT",nil,"TOPLEFT"}, {155, 25, 250, 18}, modGroups, function(idx) constructUI(modGroups[idx]) end)
controls.modSelect.selIndex = self.defaultTattoo[nodeName] or 1
controls.modSelect.tooltipFunc = function(tooltip, mode, index, value)
tooltip:Clear()
if mode ~= "OUT" and value then
for _, line in ipairs(value.descriptions) do
tooltip:AddLine(16, "^7"..line)
end
end
end
controls.save = new("ButtonControl", nil, {-90, 75, 80, 20}, "Add", function()
addModifier(selectedNode)
self.build.spec:AddUndoState()
self.modFlag = true
self.build.buildFlag = true
self.defaultTattoo[nodeName] = controls.modSelect.selIndex
main:ClosePopup()
end)
controls.reset = new("ButtonControl", nil, {0, 75, 80, 20}, "Reset Node", function()
self:RemoveTattooFromNode(selectedNode)
self.build.spec:AddUndoState()
self.modFlag = true
self.build.buildFlag = true
self.defaultTattoo[nodeName] = nil
main:ClosePopup()
end)
controls.close = new("ButtonControl", nil, {90, 75, 80, 20}, "Cancel", function()
main:ClosePopup()
end)
local function getTattooCount()
local count = 0
for _, node in pairs(self.build.spec.hashOverrides) do
if node.isTattoo and not node.dn:find("Runegraft") then
count = count + 1
end
end
if count > 50 then
count = colorCodes.NEGATIVE..count
end
return count
end
controls.totalTattoos = new("LabelControl", nil, { 0, 95, 0, 16 }, "^7Tattoo Count: ".. getTattooCount() .."/50" )
main:OpenPopup(600, 105, "Replace Modifier of Node", controls, "save")
constructUI(modGroups[self.defaultTattoo[nodeName] or 1])
-- Show Legacy Tattoos
controls.showLegacyTattoo = new("CheckBoxControl", { "LEFT", controls.totalTattoos, "RIGHT" }, { 205, 0, 20 }, "Show Legacy Tattoos:", function(state)
self.showLegacyTattoo = state
buildMods(selectedNode)
end)
controls.showLegacyTattoo.state = self.showLegacyTattoo
end
function TreeTabClass:SaveMasteryPopup(node, listControl)
if listControl.selValue == nil then
return
end
local effect = self.build.spec.tree.masteryEffects[listControl.selValue.id]
node.sd = effect.sd
node.allMasteryOptions = false
node.reminderText = { "Tip: Right click to select a different effect" }
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)
end
self.build.spec:AddUndoState()
self.modFlag = true
self.build.buildFlag = true
main:ClosePopup()
end
function TreeTabClass:OpenMasteryPopup(node, viewPort)
local controls = { }
local effects = { }
local cachedSd = node.sd
local cachedAllMasteryOption = node.allMasteryOptions
wipeTable(effects)
for _, effect in pairs(node.masteryEffects) do
local assignedNodeId = isValueInTable(self.build.spec.masterySelections, effect.effect)
if not assignedNodeId or assignedNodeId == node.id then
t_insert(effects, {label = t_concat(effect.stats, " / "), id = effect.effect})
end
end
--Check to make sure that the effects list has a potential mod to apply to a mastery
if not (next(effects) == nil) then
local passiveMasteryControlHeight = (#effects + 1) * 14 + 2
controls.close = new("ButtonControl", nil, {0, 30 + passiveMasteryControlHeight, 90, 20}, "Cancel", function()
node.sd = cachedSd
node.allMasteryOptions = cachedAllMasteryOption
self.build.spec.tree:ProcessStats(node)
main:ClosePopup()
end)
controls.effect = new("PassiveMasteryControl", {"TOPLEFT",nil,"TOPLEFT"}, {6, 25, 0, passiveMasteryControlHeight}, effects, self, node, controls.save)
main:OpenPopup(controls.effect.width + 12, controls.effect.height + 60, node.name, controls)
end
end
function TreeTabClass:SetPowerCalc(powerStat)
self.viewer.showHeatMap = true
self.build.buildFlag = true
self.build.calcsTab.powerBuildFlag = true
self.build.calcsTab.powerStat = powerStat
self.controls.powerReportList:SetReport(powerStat, nil)
end
function TreeTabClass:BuildPowerReportList(currentStat)
local report = {}
if not (currentStat and currentStat.stat) then
return report
end
-- locate formatting information for the type of heat map being used.
-- maybe a better place to find this? At the moment, it is the only place
-- in the code that has this information in a tidy place.
local displayStat = nil
for index, ds in ipairs(self.build.displayStats) do
if ds.stat == currentStat.stat then
displayStat = ds
break
end
end
-- not every heat map has an associated "stat" in the displayStats table
-- this is due to not every stat being displayed in the sidebar, I believe.
-- But, we do want to use the formatting knowledge stored in that table rather than duplicating it here.
-- If no corresponding stat is found, just default to a generic stat display (>0=good, one digit of precision).
if not displayStat then
displayStat = {
fmt = ".1f"
}
end
-- search all nodes, ignoring ascendancies, sockets, etc.
for nodeId, node in pairs(self.build.spec.nodes) do
local isAlloc = node.alloc or self.build.calcsTab.mainEnv.grantedPassives[nodeId]
if (node.type == "Normal" or node.type == "Keystone" or node.type == "Notable") and not node.ascendancyName then
local pathDist
if isAlloc then
pathDist = #(node.depends or { }) == 0 and 1 or #node.depends
else
pathDist = #(node.path or { }) == 0 and 1 or #node.path
end
local nodePower = (node.power.singleStat or 0) * ((displayStat.pc or displayStat.mod) and 100 or 1)
local pathPower = (node.power.pathPower or 0) / pathDist * ((displayStat.pc or displayStat.mod) and 100 or 1)
local nodePowerStr = s_format("%"..displayStat.fmt, nodePower)
local pathPowerStr = s_format("%"..displayStat.fmt, pathPower)
nodePowerStr = formatNumSep(nodePowerStr)
pathPowerStr = formatNumSep(pathPowerStr)
if (nodePower > 0 and not displayStat.lowerIsBetter) or (nodePower < 0 and displayStat.lowerIsBetter) then
nodePowerStr = colorCodes.POSITIVE .. nodePowerStr
elseif (nodePower < 0 and not displayStat.lowerIsBetter) or (nodePower > 0 and displayStat.lowerIsBetter) then
nodePowerStr = colorCodes.NEGATIVE .. nodePowerStr
end
if (pathPower > 0 and not displayStat.lowerIsBetter) or (pathPower < 0 and displayStat.lowerIsBetter) then
pathPowerStr = colorCodes.POSITIVE .. pathPowerStr
elseif (pathPower < 0 and not displayStat.lowerIsBetter) or (pathPower > 0 and displayStat.lowerIsBetter) then
pathPowerStr = colorCodes.NEGATIVE .. pathPowerStr
end
t_insert(report, {
name = node.dn,
power = nodePower,
powerStr = nodePowerStr,
pathPower = pathPower,
pathPowerStr = pathPowerStr,
allocated = isAlloc,
id = node.id,
x = node.x,
y = node.y,
type = node.type,
pathDist = pathDist
})
end
end
-- search all cluster notables and add to the list
for nodeName, node in pairs(self.build.spec.tree.clusterNodeMap) do
local isAlloc = node.alloc
if not isAlloc then
local nodePower = (node.power and node.power.singleStat or 0) * ((displayStat.pc or displayStat.mod) and 100 or 1)
local nodePowerStr = s_format("%"..displayStat.fmt, nodePower)
nodePowerStr = formatNumSep(nodePowerStr)
if (nodePower > 0 and not displayStat.lowerIsBetter) or (nodePower < 0 and displayStat.lowerIsBetter) then
nodePowerStr = colorCodes.POSITIVE .. nodePowerStr
elseif (nodePower < 0 and not displayStat.lowerIsBetter) or (nodePower > 0 and displayStat.lowerIsBetter) then
nodePowerStr = colorCodes.NEGATIVE .. nodePowerStr
end
t_insert(report, {
name = node.dn,
power = nodePower,
powerStr = nodePowerStr,
pathPower = 0,
pathPowerStr = "--",
id = node.id,
type = node.type,
pathDist = "Cluster"
})
end
end
-- sort it
if displayStat.lowerIsBetter then
t_sort(report, function (a,b)
return a.power < b.power
end)
else
t_sort(report, function (a,b)
return a.power > b.power
end)
end
return report
end
function TreeTabClass:FindTimelessJewel()
local socketViewer = new("PassiveTreeView")
local treeData = self.build.spec.tree
local legionNodes = treeData.legion.nodes
local legionAdditions = treeData.legion.additions
local timelessData = self.build.timelessData
local controls = { }
local modData = { }
local ignoredMods = { "Might of the Vaal", "Legacy of the Vaal", "Strength", "Add Strength", "Dex", "Add Dexterity", "Devotion", "Price of Glory" }
local totalMods = { [2] = "Strength", [3] = "Dexterity", [4] = "Devotion" }
local totalModIDs = {
["total_strength"] = { ["karui_notable_add_strength"] = true, ["karui_attribute_strength"] = true, ["karui_small_strength"] = true },
["total_dexterity"] = { ["maraketh_notable_add_dexterity"] = true, ["maraketh_attribute_dex"] = true, ["maraketh_small_dex"] = true },
["total_devotion"] = { ["templar_notable_devotion"] = true, ["templar_devotion_node"] = true, ["templar_small_devotion"] = true }
}
local reverseTotalModIDs = {
["karui_notable_add_strength"] = true,
["karui_attribute_strength"] = true,
["karui_small_strength"] = true,
["maraketh_notable_add_dexterity"] = true,
["maraketh_attribute_dex"] = true,
["maraketh_small_dex"] = true,
["templar_notable_devotion"] = true,
["templar_devotion_node"] = true,
["templar_small_devotion"] = true
}
local jewelTypes = {
{ label = "Glorious Vanity", name = "vaal", id = 1 },
{ label = "Lethal Pride", name = "karui", id = 2 },
{ label = "Brutal Restraint", name = "maraketh", id = 3 },
{ label = "Militant Faith", name = "templar", id = 4 },
{ label = "Elegant Hubris", name = "eternal", id = 5 }
}
-- rebuild `timelessData.jewelType` as we only store the minimum amount of `jewelType` data in build XML
if next(timelessData.jewelType) then
for idx, jewelType in ipairs(jewelTypes) do
if jewelType.id == timelessData.jewelType.id then
timelessData.jewelType = jewelType
break
end
end
else
timelessData.jewelType = jewelTypes[1]
end
local conquerorTypes = {
[1] = {
{ label = "Any", id = 1 },
{ label = "Doryani (Corrupted Soul)", id = 2 },
{ label = "Xibaqua (Divine Flesh)", id = 3 },
{ label = "Ahuana (Immortal Ambition)", id = 4 }
},
[2] = {
{ label = "Any", id = 1 },
{ label = "Kaom (Strength of Blood)", id = 2 },
{ label = "Rakiata (Tempered by War)", id = 3 },
{ label = "Akoya (Chainbreaker)", id = 4 }
},
[3] = {
{ label = "Any", id = 1 },
{ label = "Asenath (Dance with Death)", id = 2 },
{ label = "Nasima (Second Sight)", id = 3 },
{ label = "Balbala (The Traitor)", id = 4 }
},
[4] = {
{ label = "Any", id = 1 },
{ label = "Avarius (Power of Purpose)", id = 2 },
{ label = "Dominus (Inner Conviction)", id = 3 },
{ label = "Maxarius (Transcendence)", id = 4 }
},
[5] = {
{ label = "Any", id = 1 },
{ label = "Cadiro (Supreme Decadence)", id = 2 },
{ label = "Victario (Supreme Grandstanding)", id = 3 },
{ label = "Caspiro (Supreme Ostentation)", id = 4 }
}
}
-- rebuild `timelessData.conquerorType` as we only store the minimum amount of `conquerorType` data in build XML
if next(timelessData.conquerorType) then
for idx, conquerorType in ipairs(conquerorTypes[timelessData.jewelType.id]) do
if conquerorType.id == timelessData.conquerorType.id then
timelessData.conquerorType = conquerorType
break
end
end
else
timelessData.conquerorType = conquerorTypes[timelessData.jewelType.id][1]
end
local devotionVariants = {
{ id = 1 , label = "Any" },
{ id = 2 , label = "Totem Damage" },
{ id = 3 , label = "Brand Damage" },
{ id = 4 , label = "Channelling Damage" },
{ id = 5 , label = "Area Damage" },
{ id = 6 , label = "Elemental Damage" },
{ id = 7 , label = "Elemental Resistances" },
{ id = 8 , label = "Effect of non-Damaging Ailments" },
{ id = 9 , label = "Elemental Ailment Duration" },
{ id = 10, label = "Duration of Curses" },
{ id = 11, label = "Minion Attack and Cast Speed" },
{ id = 12, label = "Minions Accuracy Rating" },
{ id = 13, label = "Mana Regen" },
{ id = 14, label = "Skill Cost" },
{ id = 15, label = "Non-Curse Aura Effect" },
{ id = 16, label = "Defences from Shield" }
}
local jewelSockets = { }
for socketId, socketData in pairs(self.build.spec.nodes) do
if socketData.isJewelSocket and socketData.name ~= "Charm Socket"then
local keystone = "Unknown"
if socketId == 26725 then
keystone = "Marauder"
elseif socketId == 54127 then
keystone = "Duelist"
elseif socketId == 7960 then
keystone = "Templar/Witch"
else
local minDistance = math.huge
for _, nodeInRadius in pairs(treeData.nodes[socketId].nodesInRadius[3]) do
if nodeInRadius.isKeystone then
local distance = math.sqrt((nodeInRadius.x - socketData.x) ^ 2 + (nodeInRadius.y - socketData.y) ^ 2)
if distance < minDistance then
keystone = nodeInRadius.name
minDistance = distance
end
end
end
end
local label = keystone .. ": " .. socketId
if self.build.spec.allocNodes[socketId] then
label = "# " .. label
end
t_insert(jewelSockets, {
label = label,
keystone = keystone,
id = socketId
})
end
end
t_sort(jewelSockets, function(a, b) return a.label < b.label end)
-- rebuild `timelessData.jewelSocket` as we only store the minimum amount of `jewelSocket` data in build XML
if next(timelessData.jewelSocket) then
for idx, jewelSocket in ipairs(jewelSockets) do
if jewelSocket.id == timelessData.jewelSocket.id then
timelessData.jewelSocket = jewelSocket
break
end
end
else
timelessData.jewelSocket = jewelSockets[1]
end
local function buildMods()
wipeTable(modData)
local smallModData = { }
for _, node in pairs(legionNodes) do
if node.id:match("^" .. timelessData.jewelType.name .. "_.+") and not isValueInArray(ignoredMods, node.dn) and not node.ks then
if node["not"] then
t_insert(modData, {
label = node.dn .. " " .. node.sd[1],
descriptions = copyTable(node.sd),
type = timelessData.jewelType.name,
id = node.id
})
if node.sd[2] then
modData[#modData].label = modData[#modData].label .. " " .. node.sd[2]
end
else
t_insert(smallModData, {
label = node.dn,
descriptions = copyTable(node.sd),
type = timelessData.jewelType.name,
id = node.id
})
end
end
end
for _, addition in pairs(legionAdditions) do
-- exclude passives that are already added (vaal, attributes, devotion)
if addition.id:match("^" .. timelessData.jewelType.name .. "_.+") and not isValueInArray(ignoredMods, addition.dn) and timelessData.jewelType.name ~= "vaal" then
t_insert(modData, {
label = addition.dn,
descriptions = copyTable(addition.sd),
type = timelessData.jewelType.name,
id = addition.id
})
end
end
t_sort(modData, function(a, b) return a.label < b.label end)
t_sort(smallModData, function (a, b) return a.label < b.label end)
if totalMods[timelessData.jewelType.id] then
t_insert(modData, 1, {
label = "Total " .. totalMods[timelessData.jewelType.id],
descriptions = { "This is a hybrid node containing all additions to " .. totalMods[timelessData.jewelType.id] },
type = timelessData.jewelType.name,
id = "total_" .. totalMods[timelessData.jewelType.id]:lower(),
totalMod = true
})
end
t_insert(modData, 1, { label = "..." })
for i = 1, #smallModData do
modData[#modData + 1] = smallModData[i]
end
end
local function getNodeWeights()
local nodeWeights = {
[1] = controls.nodeSliderValue.label:sub(3):lower(),
[2] = controls.nodeSlider2Value.label:sub(3):lower(),
[3] = controls.nodeSlider3Value.label:sub(3):lower()
}
for i, nodeWeight in ipairs(nodeWeights) do
if tonumber(nodeWeight) ~= nil then
nodeWeights[i] = round(tonumber(nodeWeight), 3)
end
end
return nodeWeights
end
local searchListTbl = { }
local searchListFallbackTbl = { }
local function parseSearchList(mode, fallback)
if mode == 0 then
if fallback then
-- timelessData.searchListFallback => searchListFallbackTbl
if timelessData.searchListFallback then
searchListFallbackTbl = { }
for inputLine in timelessData.searchListFallback:gmatch("[^\r\n]+") do
searchListFallbackTbl[#searchListFallbackTbl + 1] = { }
for splitLine in inputLine:gmatch("([^,%s]+)") do
searchListFallbackTbl[#searchListFallbackTbl][#searchListFallbackTbl[#searchListFallbackTbl] + 1] = splitLine
end
end
end
else
-- timelessData.searchList => searchListTbl
if timelessData.searchList then
searchListTbl = { }
for inputLine in timelessData.searchList:gmatch("[^\r\n]+") do
searchListTbl[#searchListTbl + 1] = { }
for splitLine in inputLine:gmatch("([^,%s]+)") do
searchListTbl[#searchListTbl][#searchListTbl[#searchListTbl] + 1] = splitLine
end
end
end
end
else
if fallback then
-- searchListFallbackTbl => controls.searchListFallback
if controls.searchListFallback and controls.nodeSelect then
local searchText = ""
for _, curRow in ipairs(searchListFallbackTbl) do
if curRow[1] == controls.nodeSelect.list[controls.nodeSelect.selIndex].id then
local nodeWeights = getNodeWeights()
curRow[2] = nodeWeights[1]
curRow[3] = nodeWeights[2]
curRow[4] = nodeWeights[3]
end
if #searchText > 0 then
searchText = searchText .. "\n"
end
searchText = searchText .. t_concat(curRow, ", ")
end
if timelessData.searchListFallback ~= searchText then
timelessData.searchListFallback = searchText
controls.searchListFallback:SetText(searchText)
self.build.modFlag = true
end
end
else
-- searchListTbl => controls.searchList
if controls.searchList and controls.nodeSelect then
local searchText = ""
for _, curRow in ipairs(searchListTbl) do
if curRow[1] == controls.nodeSelect.list[controls.nodeSelect.selIndex].id then
local nodeWeights = getNodeWeights()
curRow[2] = nodeWeights[1]
curRow[3] = nodeWeights[2]
curRow[4] = nodeWeights[3]
end
if #searchText > 0 then
searchText = searchText .. "\n"
end
searchText = searchText .. t_concat(curRow, ", ")
end
if timelessData.searchList ~= searchText then
timelessData.searchList = searchText
controls.searchList:SetText(searchText)
self.build.modFlag = true
end
end
end
end
end
parseSearchList(0, false) -- initial load: [timelessData.searchList => searchListTbl]
parseSearchList(0, true) -- initial load: [timelessData.searchListFallback => searchListFallbackTbl]
local function updateSearchList(text, fallback)
if fallback then
timelessData.searchListFallback = text
controls.searchListFallback:SetText(text)
else
timelessData.searchList = text
controls.searchList:SetText(text)
end
parseSearchList(0, fallback)
self.build.modFlag = true
end
controls.devotionSelectLabel = new("LabelControl", {"TOPRIGHT", nil, "TOPLEFT"}, {820, 25, 0, 16}, "^7Devotion modifiers:")
controls.devotionSelectLabel.shown = timelessData.jewelType.id == 4
controls.devotionSelect1 = new("DropDownControl", {"TOP", controls.devotionSelectLabel, "BOTTOM"}, {0, 8, 200, 18}, devotionVariants, function(index, value)
timelessData.devotionVariant1 = index
end)
controls.devotionSelect1.selIndex = timelessData.devotionVariant1
controls.devotionSelect2 = new("DropDownControl", {"TOP", controls.devotionSelect1, "BOTTOM"}, {0, 7, 200, 18}, devotionVariants, function(index, value)
timelessData.devotionVariant2 = index
end)
controls.devotionSelect2.selIndex = timelessData.devotionVariant2
controls.jewelSelectLabel = new("LabelControl", {"TOPRIGHT", nil, "TOPLEFT"}, {405, 25, 0, 16}, "^7Jewel Type:")
controls.jewelSelect = new("DropDownControl", {"LEFT", controls.jewelSelectLabel, "RIGHT"}, {10, 0, 200, 18}, jewelTypes, function(index, value)
timelessData.jewelType = value
controls.devotionSelectLabel.shown = value.id == 4 -- Militant Faith
controls.protectAllocatedLabel.shown = (value.id == 4 and controls.socketFilter.state)
controls.conquerorSelect.list = conquerorTypes[timelessData.jewelType.id]
controls.conquerorSelect.selIndex = 1
timelessData.conquerorType = conquerorTypes[timelessData.jewelType.id][1]
controls.nodeSelect.selIndex = 1
buildMods()
updateSearchList("", false)
updateSearchList("", true)
end)
controls.jewelSelect.selIndex = timelessData.jewelType.id
controls.conquerorSelectLabel = new("LabelControl", {"TOPRIGHT", nil, "TOPLEFT"}, {405, 50, 0, 16}, "^7Conqueror:")
controls.conquerorSelect = new("DropDownControl", {"LEFT", controls.conquerorSelectLabel, "RIGHT"}, {10, 0, 200, 18}, conquerorTypes[timelessData.jewelType.id], function(index, value)
timelessData.conquerorType = value
self.build.modFlag = true
end)
controls.conquerorSelect.selIndex = timelessData.conquerorType.id
local allocatedNodes = { }
local protectedNodes = { }
local protectedNodesCount = 0
self.allocatedNodesInRadiusCount = 0
local function setAllocatedNodes() -- find allocated nodes in radius for Militant Faith filtering / protected nodes dropdown
local nodeNames = { }
local radiusNodes = treeData.nodes[timelessData.jewelSocket.id].nodesInRadius[3] -- large radius around timelessData.jewelSocket.id
for nodeId in pairs(radiusNodes) do
if self.build.calcsTab.mainEnv.grantedPassives[nodeId] ~= nil or self.build.spec.allocNodes[nodeId] ~= nil then
allocatedNodes[nodeId] = true
if treeData.nodes[nodeId] and treeData.nodes[nodeId].isNotable then
t_insert(nodeNames, treeData.nodes[nodeId].dn)
end
end
end
controls.protectAllocatedSelect:SetList(nodeNames)
self.allocatedNodesInRadiusCount = #nodeNames
end
controls.socketSelectLabel = new("LabelControl", {"TOPRIGHT", nil, "TOPLEFT"}, {405, 75, 0, 16}, "^7Jewel Socket:")
controls.socketSelect = new("TimelessJewelSocketControl", {"LEFT", controls.socketSelectLabel, "RIGHT"}, {10, 0, 200, 18}, jewelSockets, function(index, value)
timelessData.jewelSocket = value
setAllocatedNodes() -- reset list when changing sockets
self.build.modFlag = true
end, self.build, socketViewer)
-- we need to search through `jewelSockets` for the correct `id` as the `idx` can become stale due to dynamic sorting
for idx, jewelSocket in ipairs(jewelSockets) do
if jewelSocket.id == timelessData.jewelSocket.id then
controls.socketSelect.selIndex = idx
break
end
end
local function clearProtected() -- clear all controls, nodes related to Militant Faith filtering
protectedNodesCount = 0
protectedNodes = { }
for index, _ in pairs(controls) do
if index:find("protected:") then
controls[index] = nil
end
end
end
controls.socketFilterLabel = new("LabelControl", { "TOPRIGHT", nil, "TOPLEFT" }, { 405, 100, 0, 16 }, "^7Filter Nodes:")
controls.socketFilter = new("CheckBoxControl", { "LEFT", controls.socketFilterLabel, "RIGHT" }, { 10, 0, 18 }, nil, function(value)
timelessData.socketFilter = value
self.build.modFlag = true
controls.socketFilterAdditionalDistanceLabel.shown = value
controls.socketFilterAdditionalDistance.shown = value
controls.socketFilterAdditionalDistanceValue.shown = value
controls.protectAllocatedLabel.shown = (value and timelessData.jewelType.label == "Militant Faith")
if value then
setAllocatedNodes()
else
clearProtected()
end
end)
controls.socketFilter.tooltipFunc = function(tooltip, mode, index, value)
tooltip:Clear()
tooltip:AddLine(16, "^7Enable this option to exclude nodes that you do not have allocated on your active passive skill tree.")
tooltip:AddLine(16, "^7This can be useful if you're never going to path towards those excluded nodes and don't care what happens to them.")
end
controls.socketFilter.state = timelessData.socketFilter
-- Militant Faith protect notables controls
controls.protectAllocatedLabel = new("LabelControl", { "TOPLEFT", nil, "TOPLEFT" }, { 15, 25, 0, 16 }, "^7Protect allocated nodes from changing:")
controls.protectAllocatedSelect = new("DropDownControl", { "TOPLEFT", controls.protectAllocatedLabel, "BOTTOMLEFT" }, { 0, 8, 200, 18 }, nil, nil)
controls.protectAllocatedButtonAdd = new("ButtonControl", { "LEFT", controls.protectAllocatedSelect, "RIGHT" }, { 5, 0, 44, 18 }, "Add", function()
local selValue = controls.protectAllocatedSelect:GetSelValue()
if selValue and not controls["protected:"..selValue] then
protectedNodesCount = protectedNodesCount + 1
t_insert(protectedNodes, selValue)
controls["protected:"..selValue] = new("LabelControl", { "TOPLEFT", controls.protectAllocatedSelect, "BOTTOMLEFT" }, { 0, 16 * protectedNodesCount - 10, 0, 16 }, "^7"..selValue)
end
end)
controls.protectAllocatedButtonClear = new("ButtonControl", { "LEFT", controls.protectAllocatedButtonAdd, "RIGHT" }, { 5, 0, 44, 18 }, "Clear", function()
clearProtected()
end)
-- set shown and list on load
if controls.socketFilter.state then
setAllocatedNodes()
end
controls.protectAllocatedLabel.shown = controls.jewelSelect.selIndex == 4 and controls.socketFilter.state
controls.protectAllocatedButtonAdd.tooltipFunc = function(tooltip, mode, index, value)
tooltip:Clear()
tooltip:AddLine(16, "^7Protect allocated nodes during search.")
tooltip:AddLine(16, "^7This can be useful if transforming certain notables would break your build.")
end
local socketFilterAdditionalDistanceMAX = 10
controls.socketFilterAdditionalDistanceLabel = new("LabelControl", {"LEFT", controls.socketFilter, "RIGHT"}, {10, 0, 0, 16}, "^7Node Distance:")
controls.socketFilterAdditionalDistance = new("SliderControl", {"LEFT", controls.socketFilterAdditionalDistanceLabel, "RIGHT"}, {10, 0, 66, 18}, function(value)
timelessData.socketFilterDistance = m_floor(value * socketFilterAdditionalDistanceMAX + 0.01)
controls.socketFilterAdditionalDistanceValue.label = s_format("^7%d", timelessData.socketFilterDistance)
end, { ["SHIFT"] = 1, ["CTRL"] = 1 / (socketFilterAdditionalDistanceMAX * 2), ["DEFAULT"] = 1 / socketFilterAdditionalDistanceMAX })
controls.socketFilterAdditionalDistance.tooltipFunc = function(tooltip, mode, index, value)
tooltip:Clear()
if not controls.socketFilterAdditionalDistance.dragging then
tooltip:AddLine(16, "^7This controls the maximum amount of points that need to be spent to grab a node before its filtered out")
end
end
controls.socketFilterAdditionalDistance.tooltip.realDraw = controls.socketFilterAdditionalDistance.tooltip.Draw
controls.socketFilterAdditionalDistance.tooltip.Draw = function(self, x, y, width, height, viewPort)
local sliderOffsetX = round(184 * (1 - controls.socketFilterAdditionalDistance.val))
local tooltipWidth, tooltipHeight = self:GetSize()
if main.screenW >= 1384 - sliderOffsetX then
return controls.socketFilterAdditionalDistance.tooltip.realDraw(self, x - 8 - sliderOffsetX, y - 4 - tooltipHeight, width, height, viewPort)
end
return controls.socketFilterAdditionalDistance.tooltip.realDraw(self, x, y, width, height, viewPort)
end
controls.socketFilterAdditionalDistanceValue = new("LabelControl", {"LEFT", controls.socketFilterAdditionalDistance, "RIGHT"}, {5, 0, 0, 16}, "^70")
controls.socketFilterAdditionalDistance:SetVal((timelessData.socketFilterDistance or 0) / socketFilterAdditionalDistanceMAX)
controls.socketFilterAdditionalDistanceLabel.shown = timelessData.socketFilter
controls.socketFilterAdditionalDistance.shown = timelessData.socketFilter
controls.socketFilterAdditionalDistanceValue.shown = timelessData.socketFilter
local scrollWheelSpeedTbl = { ["SHIFT"] = 0.01, ["CTRL"] = 0.0001, ["DEFAULT"] = 0.001 }
local scrollWheelSpeedTbl2 = { ["SHIFT"] = 0.2, ["CTRL"] = 0.002, ["DEFAULT"] = 0.02 }
local nodeSliderStatLabel = "None"
controls.nodeSliderLabel = new("LabelControl", {"TOPRIGHT", nil, "TOPLEFT"}, {405, 125, 0, 16}, "^7Primary Node Weight:")
controls.nodeSlider = new("SliderControl", {"LEFT", controls.nodeSliderLabel, "RIGHT"}, {10, 0, 200, 16}, function(value)
controls.nodeSliderValue.label = s_format("^7%.3f", value * 10)
parseSearchList(1, controls.searchListFallback and controls.searchListFallback.shown or false)
end, scrollWheelSpeedTbl)
controls.nodeSlider.tooltipFunc = function(tooltip, mode, index, value)
tooltip:Clear()
if not controls.nodeSlider.dragging then
if nodeSliderStatLabel == "None" then
tooltip:AddLine(16, "^7For nodes with multiple stats this slider controls the weight of the first stat listed.")
else
tooltip:AddLine(16, "^7This slider controls the weight of the following stat:")
tooltip:AddLine(16, "^7 " .. nodeSliderStatLabel)
end
end
end
controls.nodeSliderValue = new("LabelControl", {"LEFT", controls.nodeSlider, "RIGHT"}, {5, 0, 0, 16}, "^71.000")
controls.nodeSlider.tooltip.realDraw = controls.nodeSlider.tooltip.Draw
controls.nodeSlider.tooltip.Draw = function(self, x, y, width, height, viewPort)
local sliderOffsetX = round(184 * (1 - controls.nodeSlider.val))
local tooltipWidth, tooltipHeight = self:GetSize()
if main.screenW >= 1338 - sliderOffsetX then
return controls.nodeSlider.tooltip.realDraw(self, x - 8 - sliderOffsetX, y - 4 - tooltipHeight, width, height, viewPort)
end
return controls.nodeSlider.tooltip.realDraw(self, x, y, width, height, viewPort)
end
controls.nodeSlider:SetVal(0.1)
local nodeSlider2StatLabel = "None"
controls.nodeSlider2Label = new("LabelControl", {"TOPRIGHT", nil, "TOPLEFT"}, {405, 150, 0, 16}, "^7Secondary Node Weight:")
controls.nodeSlider2 = new("SliderControl", {"LEFT", controls.nodeSlider2Label, "RIGHT"}, {10, 0, 200, 16}, function(value)
controls.nodeSlider2Value.label = s_format("^7%.3f", value * 10)
parseSearchList(1, controls.searchListFallback and controls.searchListFallback.shown or false)
end, scrollWheelSpeedTbl)
controls.nodeSlider2.tooltipFunc = function(tooltip, mode, index, value)
tooltip:Clear()
if not controls.nodeSlider2.dragging then
if nodeSlider2StatLabel == "None" then
tooltip:AddLine(16, "^7For nodes with multiple stats this slider controls the weight of the second stat listed.")
else
tooltip:AddLine(16, "^7This slider controls the weight of the following stat:")
tooltip:AddLine(16, "^7 " .. nodeSlider2StatLabel)
end
end
end
controls.nodeSlider2Value = new("LabelControl", {"LEFT", controls.nodeSlider2, "RIGHT"}, {5, 0, 0, 16}, "^71.000")
controls.nodeSlider2.tooltip.realDraw = controls.nodeSlider2.tooltip.Draw
controls.nodeSlider2.tooltip.Draw = function(self, x, y, width, height, viewPort)
local sliderOffsetX = round(184 * (1 - controls.nodeSlider2.val))
local tooltipWidth, tooltipHeight = self:GetSize()
if main.screenW >= 1384 - sliderOffsetX then
return controls.nodeSlider2.tooltip.realDraw(self, x - 8 - sliderOffsetX, y - 4 - tooltipHeight, width, height, viewPort)
end
return controls.nodeSlider2.tooltip.realDraw(self, x, y, width, height, viewPort)
end
controls.nodeSlider2:SetVal(0.1)
controls.nodeSlider3Label = new("LabelControl", {"TOPRIGHT", nil, "TOPLEFT"}, {405, 175, 0, 16}, "^7Minimum Node Weight:")
controls.nodeSlider3 = new("SliderControl", {"LEFT", controls.nodeSlider3Label, "RIGHT"}, {10, 0, 200, 16}, function(value)
if value == 1 then
controls.nodeSlider3Value.label = "^7Required"
else
controls.nodeSlider3Value.label = s_format("^7%.f", value * 500)
end
parseSearchList(1, controls.searchListFallback and controls.searchListFallback.shown or false)
end, scrollWheelSpeedTbl2)
controls.nodeSlider3.tooltipFunc = function(tooltip, mode, index, value)
tooltip:Clear()
if not controls.nodeSlider3.dragging then
tooltip:AddLine(16, "^7Seeds that do not meet the minimum weight threshold for a desired node are excluded from the search results.")
end
end
controls.nodeSlider3Value = new("LabelControl", {"LEFT", controls.nodeSlider3, "RIGHT"}, {5, 0, 0, 16}, "^70")
controls.nodeSlider3.tooltip.realDraw = controls.nodeSlider3.tooltip.Draw
controls.nodeSlider3.tooltip.Draw = function(self, x, y, width, height, viewPort)
local sliderOffsetX = round(184 * (1 - controls.nodeSlider3.val))
local tooltipWidth, tooltipHeight = self:GetSize()
if main.screenW >= 1728 - sliderOffsetX then
return controls.nodeSlider3.tooltip.realDraw(self, x - 8 - sliderOffsetX, y - 4 - tooltipHeight, width, height, viewPort)
end
return controls.nodeSlider3.tooltip.realDraw(self, x, y, width, height, viewPort)
end
controls.nodeSlider3:SetVal(0)
local function updateSliders(sliderData)
if sliderData[2] == "required" then
controls.nodeSlider.val = 1
controls.nodeSliderValue.label = s_format("^7%.3f", 10)
else
controls.nodeSlider.val = m_min(m_max((tonumber(sliderData[2]) or 0) / 10, 0), 1)
controls.nodeSliderValue.label = s_format("^7%.3f", controls.nodeSlider.val * 10)
end
if controls.nodeSlider2.enabled then
if sliderData[3] == "required" then
controls.nodeSlider2.val = 1
controls.nodeSlider2Value.label = s_format("^7%.3f", 10)
else
controls.nodeSlider2.val = m_min(m_max((tonumber(sliderData[3]) or 0) / 10, 0), 1)
controls.nodeSlider2Value.label = s_format("^7%.3f", controls.nodeSlider2.val * 10)
end
end
if sliderData[4] == "required" then
controls.nodeSlider3.val = 1
controls.nodeSlider3Value.label = "^7Required"
else
controls.nodeSlider3.val = m_min(m_max((tonumber(sliderData[4]) or 0) / 500, 0), 1)
controls.nodeSlider3Value.label = s_format("^7%.f", controls.nodeSlider3.val * 500)
end
end
buildMods()
controls.nodeSelectLabel = new("LabelControl", {"TOPRIGHT", nil, "TOPLEFT"}, {405, 200, 0, 16}, "^7Search for Node:")
controls.nodeSelect = new("DropDownControl", {"LEFT", controls.nodeSelectLabel, "RIGHT"}, {10, 0, 200, 18}, modData, function(index, value)
nodeSliderStatLabel = "None"
nodeSlider2StatLabel = "None"
if value.id then
local statCount = 0
for _, legionNode in ipairs(legionNodes) do
if legionNode.id == value.id then
statCount = #legionNode.sd
nodeSliderStatLabel = legionNode.sd[1] or "None"
nodeSlider2StatLabel = legionNode.sd[2] or "None"
break
end
end
if statCount == 0 then
for _, legionAddition in ipairs(legionAdditions) do
if legionAddition.id == value.id then
statCount = #legionAddition.sd
nodeSliderStatLabel = legionAddition.sd[1] or "None"
nodeSlider2StatLabel = legionAddition.sd[2] or "None"
break
end
end
end
if statCount <= 1 then
controls.nodeSlider2Label.label = "^9Secondary Node Weight:"
controls.nodeSlider2.val = 0
controls.nodeSlider2Value.label = s_format("^9%.3f", 0)
else
controls.nodeSlider2Label.label = "^7Secondary Node Weight:"
controls.nodeSlider2Value.label = s_format("^7%.3f", controls.nodeSlider2.val * 10)
end
controls.nodeSlider2.enabled = statCount > 1
local nodeWeights = getNodeWeights()
local newNode = value.id .. ", " .. nodeWeights[1] .. ", " .. nodeWeights[2] .. ", " .. nodeWeights[3]
if controls.searchListFallback and controls.searchListFallback.shown then
for _, searchRow in ipairs(searchListFallbackTbl) do
-- update nodeSlider values and prevent duplicate searchList entries
if searchRow[1] == value.id then
updateSliders(searchRow)
return
end
end
controls.searchListFallback.caret = #controls.searchListFallback.buf + 1
controls.searchListFallback:Insert((#controls.searchListFallback.buf > 0 and "\n" or "") .. newNode)
else
for _, searchRow in ipairs(searchListTbl) do
-- update nodeSlider values and prevent duplicate searchList entries
if searchRow[1] == value.id then
updateSliders(searchRow)
return
end
end
controls.searchList.caret = #controls.searchList.buf + 1
controls.searchList:Insert((#controls.searchList.buf > 0 and "\n" or "") .. newNode)
end
self.build.modFlag = true
end
end)
controls.nodeSelect.tooltipFunc = function(tooltip, mode, index, value)
tooltip:Clear()
if mode ~= "OUT" and value.descriptions then
for _, line in ipairs(value.descriptions) do
tooltip:AddLine(16, "^7" .. line)
end
end
end
local function generateFallbackWeights(nodes, selection)
local calcFunc, calcBase = self.build.calcsTab:GetMiscCalculator(self.build)
local newList = { }
local baseOutput = calcFunc()
if baseOutput.Minion then
baseOutput = baseOutput.Minion
end
local baseValue = baseOutput[selection.stat] or 1
if selection.transform then
baseValue = selection.transform(baseValue)
end
for _, newNode in ipairs(nodes) do
local output = nil
if newNode.calcMultiple then
output = calcFunc({ addNodes = { [newNode.node[1]] = true } })
else
output = calcFunc({ addNodes = { [newNode] = true } })
end
if output.Minion then
output = output.Minion
end
local outputValue = output[selection.stat] or 0
if selection.transform then
outputValue = selection.transform(outputValue)
end
outputValue = outputValue / baseValue
if outputValue ~= outputValue then
outputValue = 1
end
t_insert(newList, {
id = newNode.id,
weight1 = (outputValue - 1) / (newNode.divisor or 1)
})
if newNode.calcMultiple then
output = calcFunc({ addNodes = { [newNode.node[2]] = true } })
if output.Minion then
output = output.Minion
end
outputValue = output[selection.stat] or 0
if selection.transform then
outputValue = selection.transform(outputValue)
end
outputValue = outputValue / baseValue
if outputValue ~= outputValue then
outputValue = 1
end
newList[#newList].weight2 = (outputValue - 1) / (newNode.divisor or 1)
end
end
return newList
end
local function setupFallbackWeights()
-- replaceHelperFunc is duplicated from PassiveSpec.lua
local replaceHelperFunc = function(statToFix, statKey, statMod, value)
if statMod.fmt == "g" then -- note the only one we actually care about is "Ritual of Flesh" life regen
if statKey:find("per_minute") then
value = round(value / 60, 1)
elseif statKey:find("permyriad") then
value = value / 100
elseif statKey:find("_ms") then
value = value / 1000
end
end
--if statMod.fmt == "d" then -- only ever d or g, and we want both past here
if statMod.min ~= statMod.max then
return statToFix:gsub("%(" .. statMod.min .. "%-" .. statMod.max .. "%)", value)
elseif statMod.min ~= value then -- only true for might/legacy of the vaal which can combine stats
return statToFix:gsub(statMod.min, value)
end
return statToFix -- if it doesn't need to be changed
end
local nodes = { }
for _, modNode in ipairs(modData) do
if modNode.id then
local newNode = nil
for _, legionNode in ipairs(legionNodes) do
if legionNode.id == modNode.id or (totalModIDs[modNode.id] and totalModIDs[modNode.id][legionNode.id]) then
newNode = { }
newNode.id = modNode.id
if modNode.type == "vaal" then
if #legionNode.sd == 2 then
newNode.calcMultiple = true
if legionNode.modListGenerated then
newNode.node = copyTable(legionNode.modListGenerated)
else
-- generate modList
local modList1, extra1 = modLib.parseMod(replaceHelperFunc(legionNode.sd[1], legionNode.sortedStats[1], legionNode.stats[legionNode.sortedStats[1]], 100))
local modList2, extra2 = modLib.parseMod(replaceHelperFunc(legionNode.sd[2], legionNode.sortedStats[2], legionNode.stats[legionNode.sortedStats[2]], 100))
local modLists = { { modList = modList1 }, { modList = modList2 } }
legionNode.modListGenerated = copyTable(modLists)
newNode.node = copyTable(modLists)
end
newNode.node[1].id = legionNode.id
newNode.node[2].id = legionNode.id
else
if legionNode.modListGenerated then
newNode.modList = copyTable(legionNode.modListGenerated)
else
-- generate modList
local modList, extra = modLib.parseMod(replaceHelperFunc(legionNode.sd[1], legionNode.sortedStats[1], legionNode.stats[legionNode.sortedStats[1]], 100))
legionNode.modListGenerated = modList
newNode.modList = modList
end
end
newNode.divisor = 100
else
newNode.modList = legionNode.modList
if modNode.totalMod then
newNode.divisor = legionNode.modList[1].value
end
end
break
end
end
if not newNode then
for _, legionAddition in ipairs(legionAdditions) do
if legionAddition.id == modNode.id or (totalModIDs[modNode.id] and totalModIDs[modNode.id][legionAddition.id]) then
newNode = { }
newNode.id = modNode.id
if legionAddition.modList then
newNode.modList = legionAddition.modList
elseif legionAddition.modListGenerated then
newNode.modList = legionAddition.modListGenerated
else
-- generate modList
local line = legionAddition.sd[1]
if modNode.type == "vaal" then
for key, stat in legionAddition.stats do -- should only be length 1
line = replaceHelperFunc(line, key, stat, 100)
end
end
local modList, extra = modLib.parseMod(line)
legionAddition.modListGenerated = modList
newNode.modList = modList
end
if modNode.type == "vaal" then
newNode.divisor = 100
elseif modNode.totalMod then
newNode.divisor = newNode.modList[1].value
end
break
end
end
end
if newNode then
t_insert(nodes, newNode)
end
end
end
local output = generateFallbackWeights(nodes, controls.fallbackWeightsList.list[controls.fallbackWeightsList.selIndex])
local newList = ""
local weightScalar = 100
for _, legionNode in ipairs(output) do
if legionNode.weight1 ~= 0 or (legionNode.weight2 and legionNode.weight2 ~= 0) then
if #newList > 0 then
newList = newList .. "\n"
end
newList = newList .. legionNode.id .. ", " .. round(legionNode.weight1 * weightScalar, 3) .. ", " .. round((legionNode.weight2 or 0) * weightScalar, 3) .. ", 0"
end
end
updateSearchList(newList, true)
end
controls.fallbackWeightsLabel = new("LabelControl", {"TOPRIGHT", nil, "TOPLEFT"}, {405, 225, 0, 16}, "^7Fallback Weight Mode:")
local fallbackWeightsList = { }
for id, stat in pairs(data.powerStatList) do
if not stat.ignoreForItems and stat.label ~= "Name" then
t_insert(fallbackWeightsList, {
label = "Sort by " .. stat.label,
stat = stat.stat,
transform = stat.transform,
})
end
end
controls.fallbackWeightsList = new("DropDownControl", {"LEFT", controls.fallbackWeightsLabel, "RIGHT"}, {10, 0, 200, 18}, fallbackWeightsList, function(index)
timelessData.fallbackWeightMode.idx = index
end)
controls.fallbackWeightsList.selIndex = timelessData.fallbackWeightMode.idx or 1
controls.fallbackWeightsButton = new("ButtonControl", {"LEFT", controls.fallbackWeightsList, "RIGHT"}, {5, 0, 66, 18}, "Generate", function()
setupFallbackWeights()
controls.searchListFallbackButton.label = "^4Fallback Nodes"
end)
controls.fallbackWeightsButton.tooltipFunc = function(tooltip, mode, index, value)
tooltip:Clear()
tooltip:AddLine(16, "^7Click this button to generate new fallback node weights, replacing your old ones.")
end
controls.totalMinimumWeightLabel = new("LabelControl", {"TOPRIGHT", nil, "TOPLEFT"}, {405, 250, 0, 16}, "^7Total Minimum Weight:")
controls.totalMinimumWeight = new("EditControl", {"LEFT", controls.totalMinimumWeightLabel, "RIGHT"}, {10, 0, 60, 18}, "", nil, "%D", nil, function(val)
local num = tonumber(val)
timelessData.totalMinimumWeight = num or nil
self.build.modFlag = true
end)
controls.totalMinimumWeight.tooltipFunc = function(tooltip, mode, index, value)
tooltip:Clear()
tooltip:AddLine(16, "^7Optional: Only show results where total weight meets or exceeds this value.")
end
controls.searchListButton = new("ButtonControl", {"TOPLEFT", nil, "TOPLEFT"}, {12, 250, 106, 20}, "^7Desired Nodes", function()
if controls.searchListFallback.shown then
controls.searchListFallback.shown = false
controls.searchListFallback.enabled = false
controls.searchList.shown = true
controls.searchList.enabled = true
end
end)
controls.searchListButton.tooltipFunc = function(tooltip, mode, index, value)
tooltip:Clear()
tooltip:AddLine(16, "^7This contains a list of your desired nodes along with their primary, secondary, and minimum weights.")
tooltip:AddLine(16, "^7This list can be updated manually or by selecting the node you want to update via the search dropdown list and then moving the node weight sliders.")
end
controls.searchListButton.locked = function() return controls.searchList.shown end
controls.searchListFallbackButton = new("ButtonControl", {"LEFT", controls.searchListButton, "RIGHT"}, {5, 0, 110, 20}, "^7Fallback Nodes", function()
controls.searchList.shown = false
controls.searchList.enabled = false
controls.searchListFallback.shown = true
controls.searchListFallback.enabled = true
controls.searchListFallbackButton.label = "^7Fallback Nodes"
end)
controls.searchListFallbackButton.tooltipFunc = function(tooltip, mode, index, value)
tooltip:Clear()
tooltip:AddLine(16, "^7This contains a list of your fallback nodes along with their primary, secondary, and minimum weights.")
tooltip:AddLine(16, "^7This list can be updated manually or by selecting the node you want to update via the search dropdown list and then moving the node weight sliders.")
tooltip:AddLine(16, "^7Fallback node weights are only used when no matching entry exists in the desired nodes list, allowing you to override or disable specific automatic weights.")
tooltip:AddLine(16, "^7Fallback node weights typically contain automatically generated stat weights based on your current build.")
tooltip:AddLine(16, "^7Any manual changes made to your fallback nodes are lost when you click the generate button, as it completely replaces them.")
end
controls.searchListFallbackButton.locked = function() return controls.searchListFallback.shown end
controls.searchList = new("EditControl", {"TOPLEFT", nil, "TOPLEFT"}, {12, 275, 438, 200}, timelessData.searchList, nil, "^%C\t\n", nil, function(value)
timelessData.searchList = value
parseSearchList(0, false)
self.build.modFlag = true
end, 16, true)
controls.searchList.shown = true
controls.searchList.enabled = true
controls.searchList:SetText(timelessData.searchList and timelessData.searchList or "")
controls.searchListFallback = new("EditControl", {"TOPLEFT", nil, "TOPLEFT"}, {12, 275, 438, 200}, timelessData.searchListFallback, nil, "^%C\t\n", nil, function(value)
timelessData.searchListFallback = value
parseSearchList(0, true)
self.build.modFlag = true
end, 16, true)
controls.searchListFallback.shown = false
controls.searchListFallback.enabled = false
controls.searchListFallback:SetText(timelessData.searchListFallback and timelessData.searchListFallback or "")
controls.searchResultsLabel = new("LabelControl", { "TOPLEFT", nil, "TOPRIGHT" }, { -390, 250, 0, 16 }, "^7Results:")
controls.searchResults = new("TimelessJewelListControl", { "TOPLEFT", nil, "TOPRIGHT" }, { -450, 275, 438, 200 }, self.build)
controls.searchTradeLeagueSelect = new("DropDownControl", { "BOTTOMRIGHT", controls.searchResults, "TOPRIGHT" }, { -175, -5, 140, 20 }, nil, function(_, value)
self.timelessJewelLeagueSelect = value
end)
self.tradeQueryRequests = new("TradeQueryRequests")
controls.msg = new("LabelControl", nil, { -280, 5, 0, 16 }, "")
if #self.tradeLeaguesList > 0 then
controls.searchTradeLeagueSelect:SetList(self.tradeLeaguesList)
-- restore the last league selected
for i, league in ipairs(self.tradeLeaguesList) do
if league == self.timelessJewelLeagueSelect then
controls.searchTradeLeagueSelect:SetSel(i)
break
end
end
else
self.tradeQueryRequests:FetchLeagues("pc", function(leagues, errMsg)
if errMsg then
controls.msg.label = "^1Error fetching league list, default league will be used\n"..errMsg.."^7"
return
end
local tempLeagueTable = { }
for _, league in ipairs(leagues) do
if league ~= "Standard" and league ~= "Ruthless" and league ~= "Hardcore" and league ~= "Hardcore Ruthless" then
if not (league:find("Hardcore") or league:find("Ruthless")) then
-- set the dynamic, base league name to index 1 to sync league shown in dropdown on load with default/old behavior of copy trade url
t_insert(tempLeagueTable, league)
for _, val in ipairs(self.tradeLeaguesList) do
t_insert(tempLeagueTable, val)
end
self.tradeLeaguesList = copyTable(tempLeagueTable)
else
t_insert(self.tradeLeaguesList, league)
end
end
end
t_insert(self.tradeLeaguesList, "Standard")
t_insert(self.tradeLeaguesList, "Hardcore")
t_insert(self.tradeLeaguesList, "Ruthless")
t_insert(self.tradeLeaguesList, "Hardcore Ruthless")
controls.searchTradeLeagueSelect:SetList(self.tradeLeaguesList)
end)
end
controls.searchTradeButton = new("ButtonControl", { "BOTTOMRIGHT", controls.searchResults, "TOPRIGHT" }, { 0, -5, 170, 20 }, "Copy Trade URL", function()
local seedTrades = {}
local startRow = controls.searchResults.selIndex or 1
local endRow = startRow + m_floor(10 / ((timelessData.sharedResults.conqueror.id == 1) and 3 or 1))
if controls.searchResults.highlightIndex then
startRow = m_min(controls.searchResults.selIndex, controls.searchResults.highlightIndex)
endRow = m_max(controls.searchResults.selIndex, controls.searchResults.highlightIndex)
end
local seedCount = m_min(#timelessData.searchResults - startRow, endRow - startRow) + 1
-- update if not highlighted already
local prevSearch = controls.searchTradeButton.lastSearch
if prevSearch and prevSearch[1] == startRow and prevSearch[2] == seedCount then
startRow = endRow + 1
if (startRow > #timelessData.searchResults) then
return
end
seedCount = m_min(#timelessData.searchResults - startRow + 1, seedCount)
endRow = startRow + seedCount - 1
end
controls.searchResults.selIndex = startRow
controls.searchResults.highlightIndex = endRow
controls.searchTradeButton.lastSearch = {startRow, seedCount}
for i = startRow, startRow + seedCount - 1 do
local result = timelessData.searchResults[i]
local conquerorKeystoneTradeIds = data.timelessJewelTradeIDs[timelessData.jewelType.id].keystone
local conquerorTradeIds = { conquerorKeystoneTradeIds[1], conquerorKeystoneTradeIds[2], conquerorKeystoneTradeIds[3] }
if timelessData.sharedResults.conqueror.id > 1 then
conquerorTradeIds = { conquerorKeystoneTradeIds[timelessData.sharedResults.conqueror.id - 1] }
end
for _, tradeId in ipairs(conquerorTradeIds) do
t_insert(seedTrades, {
id = tradeId,
value = {
min = result.seed,
max = result.seed
}
})
end
end
local search = {
query = {
status = {
option = "online"
},
stats = {
{
filters = seedTrades,
type = "count",
value = {
min = 1
}
}
}
},
sort = {
price = "asc"
}
}
if data.timelessJewelTradeIDs[timelessData.jewelType.id].devotion ~= nil then
local devotionFilters = {}
if timelessData.sharedResults.devotionVariant1.id > 1 then
t_insert(devotionFilters, { id = data.timelessJewelTradeIDs[timelessData.jewelType.id].devotion[timelessData.sharedResults.devotionVariant1.id - 1] })
end
if timelessData.sharedResults.devotionVariant2.id > 1 then
t_insert(devotionFilters, { id = data.timelessJewelTradeIDs[timelessData.jewelType.id].devotion[timelessData.sharedResults.devotionVariant2.id - 1] })
end
if next(devotionFilters) then
t_insert(search.query.stats, {
filters = devotionFilters,
type = "and"
})
end
end
-- if the league was not selected via dropdown, then default to the first league in the dropdown or "" if the leagues could not be read
self.timelessJewelLeagueSelect = self.timelessJewelLeagueSelect or (self.tradeLeaguesList and #self.tradeLeaguesList > 0 and self.tradeLeaguesList[1]) or ""
Copy("https://www.pathofexile.com/trade/search/"..(self.timelessJewelLeagueSelect).."/?q=" .. (s_gsub(dkjson.encode(search), "[^a-zA-Z0-9]", function(a)
return s_format("%%%02X", s_byte(a))
end)))
controls.searchTradeButton.label = "Copy Next Trade URL"
end)
controls.searchTradeButton.enabled = timelessData.searchResults and #timelessData.searchResults > 0
controls.searchTradeButton.tooltipFunc = function(tooltip, mode, index, value)
tooltip:Clear()
tooltip:AddLine(16, "^7Click to generate and copy a trade URL for searching for jewels in this list.")
tooltip:AddLine(16, "^7Paste the URL in a web browser to search.")
tooltip:AddLine(16, "")
tooltip:AddLine(16, "^7You can click to select a row so that search begins from there.")
tooltip:AddLine(16, "^7After selecting a row You can also shift+click on another row to select a range of rows to search.")
end
local width = 80
local divider = 10
local buttons = 3
local totalWidth = m_floor(width * buttons + divider * (buttons - 1))
local buttonX = -totalWidth / 2 + width / 2
controls.searchButton = new("ButtonControl", nil, {buttonX, 485, width, 20}, "Search", function()
if treeData.nodes[timelessData.jewelSocket.id] and treeData.nodes[timelessData.jewelSocket.id].isJewelSocket then
local radiusNodes = treeData.nodes[timelessData.jewelSocket.id].nodesInRadius[3] -- large radius around timelessData.jewelSocket.id
local allocatedNodes = { }
local unAllocatedNodesDistance = { }
local targetNodes = { }
local targetSmallNodes = { ["attributeSmalls"] = 0, ["otherSmalls"] = 0 }
local desiredNodes = { }
local minimumWeights = { }
local resultNodes = { }
local rootNodes = { }
local desiredIdx = 0
local searchListCombinedTbl = { }
local searchListNodeFound = { }
for _, curRow in ipairs(searchListTbl) do
searchListNodeFound[curRow[1]] = true
searchListCombinedTbl[#searchListCombinedTbl + 1] = copyTable(curRow)
end
for _, curRow in ipairs(searchListFallbackTbl) do
if not searchListNodeFound[curRow[1]] then
searchListCombinedTbl[#searchListCombinedTbl + 1] = copyTable(curRow)
end
end
for _, desiredNode in ipairs(searchListCombinedTbl) do
if #desiredNode > 1 then
local displayName = nil
local singleStat = false
if totalMods[timelessData.jewelType.id] and desiredNode[1] == "total_" .. totalMods[timelessData.jewelType.id]:lower() then
desiredNode[1] = "totalStat"
displayName = totalMods[timelessData.jewelType.id]
end
if displayName == nil then
for _, legionNode in ipairs(legionNodes) do
if legionNode.id == desiredNode[1] then
-- non-vaal replacements only support one nodeWeight
if timelessData.jewelType.id > 1 then
singleStat = true
end
displayName = t_concat(legionNode.sd, " + ")
break
end
end
end
if displayName == nil then
for _, legionAddition in ipairs(legionAdditions) do
if legionAddition.id == desiredNode[1] then
-- additions only support one nodeWeight
singleStat = true
displayName = t_concat(legionAddition.sd, " + ")
break
end
end
end
if displayName ~= nil then
for i, val in ipairs(desiredNode) do
if singleStat and i == 2 then
desiredNode[2] = tonumber(desiredNode[2]) or tonumber(desiredNode[3]) or 1
end
if val == "required" then
desiredNode[i] = (singleStat and i == 2) and desiredNode[2] or 0
if desiredNode[4] == nil or desiredNode[4] < 0.001 then
desiredNode[4] = 0.001
end
end
end
if desiredNode[4] ~= nil and tonumber(desiredNode[4]) > 0 then
t_insert(minimumWeights, { reqNode = desiredNode[1], weight = tonumber(desiredNode[4]) })
end
-- if we're protecting a node and the number of protected nodes is less than the total allocated in radius and the total desired nodes is less than the total allocated in radius
-- these constraints avoid a blank result in the case where you set a min weight of 1 onto a non devotion stat with zero unprotected nodes
if protectedNodesCount > 0 and protectedNodesCount < self.allocatedNodesInRadiusCount and (#searchListCombinedTbl < self.allocatedNodesInRadiusCount) then
t_insert(minimumWeights, { reqNode = desiredNode[1], weight = 1 })
end
if desiredNodes[desiredNode[1]] then
desiredNodes[desiredNode[1]] = {
nodeWeight = tonumber(desiredNode[2]) or 0.001,
nodeWeight2 = tonumber(desiredNode[3]) or 0.001,
displayName = displayName or desiredNode[1],
desiredIdx = desiredNodes[desiredNode[1]].desiredIdx
}
else
desiredIdx = desiredIdx + 1
desiredNodes[desiredNode[1]] = {
nodeWeight = tonumber(desiredNode[2]) or 0.001,
nodeWeight2 = tonumber(desiredNode[3]) or 0.001,
displayName = displayName or desiredNode[1],
desiredIdx = desiredIdx
}
end
end
end
end
wipeTable(searchListCombinedTbl)
for _, class in pairs(treeData.classes) do
rootNodes[class.startNodeId] = true
end
if controls.socketFilter.state then
timelessData.socketFilterDistance = timelessData.socketFilterDistance or 0
for nodeId in pairs(radiusNodes) do
allocatedNodes[nodeId] = self.build.calcsTab.mainEnv.grantedPassives[nodeId] ~= nil or self.build.spec.allocNodes[nodeId] ~= nil
if timelessData.socketFilterDistance > 0 then
unAllocatedNodesDistance[nodeId] = self.build.spec.nodes[nodeId].pathDist or 1000
end
end
end
for nodeId in pairs(radiusNodes) do
if not rootNodes[nodeId]
and not treeData.nodes[nodeId].isJewelSocket
and not treeData.nodes[nodeId].isKeystone
and (not controls.socketFilter.state or allocatedNodes[nodeId] or (timelessData.socketFilterDistance > 0 and unAllocatedNodesDistance[nodeId] <= timelessData.socketFilterDistance)) then
if (treeData.nodes[nodeId].isNotable or timelessData.jewelType.id == 1) then
targetNodes[nodeId] = true
elseif desiredNodes["totalStat"] and not treeData.nodes[nodeId].isNotable then
if isValueInArray({ "Strength", "Intelligence", "Dexterity" }, treeData.nodes[nodeId].dn) then
targetSmallNodes.attributeSmalls = targetSmallNodes.attributeSmalls + 1
else
targetSmallNodes.otherSmalls = targetSmallNodes.otherSmalls + 1
end
end
end
end
local seedWeights = { }
local seedMultiplier = timelessData.jewelType.id == 5 and 20 or 1 -- Elegant Hubris
for curSeed = data.timelessJewelSeedMin[timelessData.jewelType.id] * seedMultiplier, data.timelessJewelSeedMax[timelessData.jewelType.id] * seedMultiplier, seedMultiplier do
seedWeights[curSeed] = 0
resultNodes[curSeed] = { }
for targetNode in pairs(targetNodes) do
local jewelDataTbl = data.readLUT(curSeed, targetNode, timelessData.jewelType.id)
if not next(jewelDataTbl) then
ConPrintf("Missing LUT: " .. timelessData.jewelType.label)
else
local curNode = nil
local curNodeId = nil
if (timelessData.jewelType.id == 4 and isValueInTable(protectedNodes, treeData.nodes[targetNode].dn)) then
if not desiredNodes["totalStat"] then -- only add if user has not entered their own Devotion to the table
desiredNodes["totalStat"] = {
nodeWeight = 0.1, -- keeps total score low to let desired stats decide sort
nodeWeight2 = 0,
displayName = "Devotion",
desiredIdx = desiredIdx + 1
}
end
curNodeId = "totalStat"
end
if jewelDataTbl[1] >= data.timelessJewelAdditions and not isValueInTable(protectedNodes, treeData.nodes[targetNode].dn) then -- replace
curNode = legionNodes[jewelDataTbl[1] + 1 - data.timelessJewelAdditions]
curNodeId = curNode and legionNodes[jewelDataTbl[1] + 1 - data.timelessJewelAdditions].id or nil
else -- add
curNode = legionAdditions[jewelDataTbl[1] + 1]
curNodeId = curNode and legionAdditions[jewelDataTbl[1] + 1].id or nil
end
if desiredNodes["totalStat"] and reverseTotalModIDs[curNodeId] then
curNodeId = "totalStat"
end
if timelessData.jewelType.id == 1 then
local headerSize = #jewelDataTbl
if headerSize == 2 or headerSize == 3 then
if desiredNodes[curNodeId] then
resultNodes[curSeed][curNodeId] = resultNodes[curSeed][curNodeId] or { targetNodeNames = { }, totalWeight = 0 }
local statMod1 = curNode.stats[curNode.sortedStats[1]]
local weight = desiredNodes[curNodeId].nodeWeight * jewelDataTbl[statMod1.index + 1]
local statMod2 = curNode.stats[curNode.sortedStats[2]]
if statMod2 then
weight = weight + desiredNodes[curNodeId].nodeWeight2 * jewelDataTbl[statMod2.index + 1]
end
t_insert(resultNodes[curSeed][curNodeId], targetNode)
t_insert(resultNodes[curSeed][curNodeId].targetNodeNames, treeData.nodes[targetNode].name)
resultNodes[curSeed][curNodeId].totalWeight = resultNodes[curSeed][curNodeId].totalWeight + weight
seedWeights[curSeed] = seedWeights[curSeed] + weight
end
elseif headerSize == 6 or headerSize == 8 then
for i, jewelData in ipairs(jewelDataTbl) do
curNode = legionAdditions[jewelDataTbl[i] + 1]
curNodeId = curNode and legionAdditions[jewelDataTbl[i] + 1].id or nil
if i <= (headerSize / 2) then
if desiredNodes[curNodeId] then
resultNodes[curSeed][curNodeId] = resultNodes[curSeed][curNodeId] or { targetNodeNames = { }, totalWeight = 0 }
local weight = desiredNodes[curNodeId].nodeWeight * jewelDataTbl[i + (headerSize / 2)]
resultNodes[curSeed][curNodeId].totalWeight = resultNodes[curSeed][curNodeId].totalWeight + weight
t_insert(resultNodes[curSeed][curNodeId], targetNode)
t_insert(resultNodes[curSeed][curNodeId].targetNodeNames, treeData.nodes[targetNode].name)
seedWeights[curSeed] = seedWeights[curSeed] + weight
end
else
break
end
end
end
elseif desiredNodes[curNodeId] then
resultNodes[curSeed][curNodeId] = resultNodes[curSeed][curNodeId] or { targetNodeNames = { }, totalWeight = 0 }
resultNodes[curSeed][curNodeId].totalWeight = resultNodes[curSeed][curNodeId].totalWeight + desiredNodes[curNodeId].nodeWeight
t_insert(resultNodes[curSeed][curNodeId], targetNode)
t_insert(resultNodes[curSeed][curNodeId].targetNodeNames, treeData.nodes[targetNode].name)
seedWeights[curSeed] = seedWeights[curSeed] + desiredNodes[curNodeId].nodeWeight
end
end
end
if desiredNodes["totalStat"] then
resultNodes[curSeed]["totalStat"] = resultNodes[curSeed]["totalStat"] or { targetNodeNames = { }, totalWeight = 0 }
if timelessData.jewelType.id == 4 then -- Militant Faith
local addedWeight = desiredNodes["totalStat"].nodeWeight * (5 * targetSmallNodes.otherSmalls + 10 * targetSmallNodes.attributeSmalls)
addedWeight = addedWeight + resultNodes[curSeed]["totalStat"].totalWeight * 4
resultNodes[curSeed]["totalStat"].totalWeight = resultNodes[curSeed]["totalStat"].totalWeight + addedWeight
seedWeights[curSeed] = seedWeights[curSeed] + addedWeight
else
local addedWeight = desiredNodes["totalStat"].nodeWeight * (4 * targetSmallNodes.otherSmalls + 2 * targetSmallNodes.attributeSmalls)
addedWeight = addedWeight + resultNodes[curSeed]["totalStat"].totalWeight * 19
resultNodes[curSeed]["totalStat"].totalWeight = resultNodes[curSeed]["totalStat"].totalWeight + addedWeight
seedWeights[curSeed] = seedWeights[curSeed] + addedWeight
end
end
-- check minimum weights
for _, val in ipairs(minimumWeights) do
if (resultNodes[curSeed][val.reqNode] and resultNodes[curSeed][val.reqNode].totalWeight or 0) < val.weight then
resultNodes[curSeed] = nil
break
end
end
end
wipeTable(timelessData.searchResults)
wipeTable(timelessData.sharedResults)
timelessData.sharedResults.type = timelessData.jewelType
timelessData.sharedResults.conqueror = timelessData.conquerorType
timelessData.sharedResults.devotionVariant1 = devotionVariants[timelessData.devotionVariant1]
timelessData.sharedResults.devotionVariant2 = devotionVariants[timelessData.devotionVariant2]
timelessData.sharedResults.socket = timelessData.jewelSocket
timelessData.sharedResults.desiredNodes = desiredNodes
local function formatSearchValue(input)
local matchPattern1 = " 0"
local replacePattern1 = " "
local matchPattern2 = ".0 "
local replacePattern2 = " "
local matchPattern3 = " %."
local replacePattern3 = "0."
local matchPattern4 = "%.([0-9])0"
local replacePattern4 = ".%1 "
return (" " .. s_format("%006.2f", input))
:gsub(matchPattern1, replacePattern1):gsub(matchPattern1, replacePattern1)
:gsub(matchPattern2, replacePattern2):gsub(matchPattern2, replacePattern2)
:gsub(matchPattern3, replacePattern3)
:gsub(matchPattern4, replacePattern4)
end
local searchResultsIdx = 1
for seedMatch, seedData in pairs(resultNodes) do
-- filter out the results so that only the ones that beat the total minimum weight parameter remain in search results
local passesMin = (not timelessData.totalMinimumWeight) or (seedWeights[seedMatch] >= timelessData.totalMinimumWeight)
if seedWeights[seedMatch] > 0 and passesMin then
timelessData.searchResults[searchResultsIdx] = { label = seedMatch .. ":" }
if timelessData.jewelType.id == 1 or timelessData.jewelType.id == 3 then
-- Glorious Vanity [100-8000], Brutal Restraint [500-8000]
if seedMatch < 1000 then
timelessData.searchResults[searchResultsIdx].label = " " .. timelessData.searchResults[searchResultsIdx].label
end
elseif timelessData.jewelType.id == 4 then
-- Militant Faith [2000-10000]
if seedMatch < 10000 then
timelessData.searchResults[searchResultsIdx].label = " " .. timelessData.searchResults[searchResultsIdx].label
end
else
-- Elegant Hubris [2000-160000]
if seedMatch < 10000 then
timelessData.searchResults[searchResultsIdx].label = " " .. timelessData.searchResults[searchResultsIdx].label
elseif seedMatch < 100000 then
timelessData.searchResults[searchResultsIdx].label = " " .. timelessData.searchResults[searchResultsIdx].label
end
end
local sortedNodeArray = { }
for legionId, desiredNode in pairs(desiredNodes) do
if seedData[legionId] then
if desiredNode.desiredIdx == 8 then
sortedNodeArray[8] = " ..."
elseif desiredNode.desiredIdx < 8 then
sortedNodeArray[desiredNode.desiredIdx] = formatSearchValue(seedData[legionId].totalWeight)
end
timelessData.searchResults[searchResultsIdx][legionId] = timelessData.searchResults[searchResultsIdx][legionId] or { }
timelessData.searchResults[searchResultsIdx][legionId].targetNodeNames = seedData[legionId].targetNodeNames
elseif desiredNode.desiredIdx < 8 then
sortedNodeArray[desiredNode.desiredIdx] = " 0 "
end
end
timelessData.searchResults[searchResultsIdx].label = timelessData.searchResults[searchResultsIdx].label .. t_concat(sortedNodeArray)
timelessData.searchResults[searchResultsIdx].seed = seedMatch
timelessData.searchResults[searchResultsIdx].total = seedWeights[seedMatch]
searchResultsIdx = searchResultsIdx + 1
end
end
t_sort(timelessData.searchResults, function(a, b) return a.total > b.total end)
controls.searchTradeButton.enabled = timelessData.searchResults and #timelessData.searchResults > 0
controls.searchTradeButton.lastSearch = nil
controls.searchTradeButton.label = "Copy Trade URL"
controls.searchResults.highlightIndex = nil
controls.searchResults.selIndex = 1
end
end)
controls.resetButton = new("ButtonControl", nil, {buttonX + (width + divider), 485, width, 20}, "Reset", function()
updateSearchList("", true)
updateSearchList("", false)
wipeTable(timelessData.searchResults)
controls.searchTradeButton.enabled = false
clearProtected()
end)
controls.closeButton = new("ButtonControl", nil, {buttonX + (width + divider) * 2, 485, width, 20}, "Cancel", function()
main:ClosePopup()
end)
main:OpenPopup(910, 517, "Find a Timeless Jewel", controls)
end