diff --git a/help.txt b/help.txt
index b728173120d..90b07d3c225 100644
--- a/help.txt
+++ b/help.txt
@@ -133,9 +133,11 @@ Loadouts can be selected from the dropdown in the top middle of the screen. Sele
3) If there is only one set for a set type (except passive tree), e.g. "Default" config set, it will be assigned to all existing loadouts.
-The "New Loadout" option allows the user to create all four sets from a single popup for convenience.
+The "Manage Loadouts... (ctrl-l)" option opens a popup that lists your loadouts and lets you manage them as a whole: create (New), rename, copy and delete a loadout (its tree, item, skill and config sets together), and drag to reorder loadouts without moving each set individually. Deleting a loadout will not delete any items used by it, and a set shared between loadouts (or the only set of its type) is left in place.
The "Sync" option is a backup option to force the UI to update in case the user has changed this data behind the scenes.
+Note that a loadout is matched by the plain set name, so a loadout whose tree is on an older tree version (shown with a "[version]" prefix, e.g. "[3.28 (alternate)]") still groups with its plainly-named item, skill and config sets.
+
---[Party Tab]
diff --git a/manifest.xml b/manifest.xml
index 7ca1036c36d..d2f47647281 100644
--- a/manifest.xml
+++ b/manifest.xml
@@ -149,6 +149,7 @@
+
diff --git a/spec/System/TestManageLoadouts_spec.lua b/spec/System/TestManageLoadouts_spec.lua
new file mode 100644
index 00000000000..54a1595cb43
--- /dev/null
+++ b/spec/System/TestManageLoadouts_spec.lua
@@ -0,0 +1,117 @@
+describe("ManageLoadouts", function()
+ before_each(function()
+ newBuild()
+ end)
+
+ -- pick a tree version that is not the latest, to exercise the version-prefix handling
+ local function nonLatestVersion()
+ for _, version in ipairs(treeVersionList) do
+ if version ~= latestTreeVersion then
+ return version
+ end
+ end
+ end
+
+ local function titleExists(sets, orderList, title)
+ for _, id in ipairs(orderList) do
+ if (sets[id].title or "Default") == title then
+ return true
+ end
+ end
+ return false
+ end
+
+ it("strips known tree-version prefixes but leaves other bracketed names alone", function()
+ local display = treeVersions[nonLatestVersion()].display
+ assert.are.equal("Lvl 1", build:StripTreeVersionPrefix("["..display.."] Lvl 1"))
+ -- unknown bracketed text is not a version, so it must be preserved
+ assert.are.equal("[Boss] Setup", build:StripTreeVersionPrefix("[Boss] Setup"))
+ assert.are.equal("Plain Name", build:StripTreeVersionPrefix("Plain Name"))
+ end)
+
+ it("recognises a loadout even when its tree is on a non-latest version", function()
+ build:CreateLoadout("Leveling")
+ -- move the new tree to an older version; the item/skill/config sets keep the plain name
+ build.treeTab.specList[#build.treeTab.specList].treeVersion = nonLatestVersion()
+
+ local loadouts = build:GetLoadouts()
+ local found = false
+ for _, loadout in ipairs(loadouts) do
+ if loadout.name == "Leveling" then
+ found = true
+ end
+ end
+ assert.is_true(found)
+ end)
+
+ it("deletes every set belonging to a loadout", function()
+ local specCount = #build.treeTab.specList
+ local itemCount = #build.itemsTab.itemSetOrderList
+ local skillCount = #build.skillsTab.skillSetOrderList
+ local configCount = #build.configTab.configSetOrderList
+
+ build:CreateLoadout("Boss")
+ assert.are.equal(specCount + 1, #build.treeTab.specList)
+ assert.are.equal(itemCount + 1, #build.itemsTab.itemSetOrderList)
+
+ local target
+ for _, loadout in ipairs(build:GetLoadouts()) do
+ if loadout.name == "Boss" then
+ target = loadout
+ end
+ end
+ assert.is_not_nil(target)
+
+ build:DeleteLoadout(target)
+
+ assert.are.equal(specCount, #build.treeTab.specList)
+ assert.are.equal(itemCount, #build.itemsTab.itemSetOrderList)
+ assert.are.equal(skillCount, #build.skillsTab.skillSetOrderList)
+ assert.are.equal(configCount, #build.configTab.configSetOrderList)
+
+ assert.is_false(titleExists(build.itemsTab.itemSets, build.itemsTab.itemSetOrderList, "Boss"))
+ assert.is_false(titleExists(build.skillsTab.skillSets, build.skillsTab.skillSetOrderList, "Boss"))
+ assert.is_false(titleExists(build.configTab.configSets, build.configTab.configSetOrderList, "Boss"))
+ end)
+
+ it("reorders every set of a loadout together", function()
+ build:CreateLoadout("A")
+ build:CreateLoadout("B")
+
+ local loadouts = build:GetLoadouts()
+ -- find A and B and build a new order with their positions swapped
+ local indexA, indexB
+ for i, loadout in ipairs(loadouts) do
+ if loadout.name == "A" then indexA = i end
+ if loadout.name == "B" then indexB = i end
+ end
+ assert.is_not_nil(indexA)
+ assert.is_not_nil(indexB)
+
+ loadouts[indexA], loadouts[indexB] = loadouts[indexB], loadouts[indexA]
+ build:ApplyLoadoutOrder(loadouts)
+
+ -- the trees and the id-based order lists must all reflect the new order
+ local specTitles = { }
+ for _, spec in ipairs(build.treeTab.specList) do
+ table.insert(specTitles, spec.title or "Default")
+ end
+ local posA, posB
+ for i, title in ipairs(specTitles) do
+ if title == "A" then posA = i end
+ if title == "B" then posB = i end
+ end
+ assert.is_true(posB < posA)
+
+ local itemTitles = { }
+ for _, id in ipairs(build.itemsTab.itemSetOrderList) do
+ table.insert(itemTitles, build.itemsTab.itemSets[id].title or "Default")
+ end
+ local itemPosA, itemPosB
+ for i, title in ipairs(itemTitles) do
+ if title == "A" then itemPosA = i end
+ if title == "B" then itemPosB = i end
+ end
+ assert.is_true(itemPosB < itemPosA)
+ end)
+end)
diff --git a/src/Classes/ManageLoadoutsListControl.lua b/src/Classes/ManageLoadoutsListControl.lua
new file mode 100644
index 00000000000..e63131fdea1
--- /dev/null
+++ b/src/Classes/ManageLoadoutsListControl.lua
@@ -0,0 +1,139 @@
+-- Path of Building
+--
+-- Class: Manage Loadouts List
+-- List control for managing whole loadouts (a passive tree + item/skill/config sets sharing a name).
+--
+local ipairs = ipairs
+
+local ManageLoadoutsListClass = newClass("ManageLoadoutsListControl", "ListControl", function(self, anchor, rect, build)
+ self.ListControl(anchor, rect, 16, "VERTICAL", true, { })
+ self.build = build
+ self:BuildList()
+
+ self.controls.copy = new("ButtonControl", {"BOTTOMLEFT",self,"TOP"}, {2, -4, 60, 18}, "Copy", function()
+ self:CopyLoadout(self.selValue)
+ end)
+ self.controls.copy.enabled = function()
+ return self.selValue ~= nil
+ end
+ self.controls.delete = new("ButtonControl", {"LEFT",self.controls.copy,"RIGHT"}, {4, 0, 60, 18}, "Delete", function()
+ self:OnSelDelete(self.selIndex, self.selValue)
+ end)
+ self.controls.delete.enabled = function()
+ return self.selValue ~= nil and #self.list > 1
+ end
+ self.controls.rename = new("ButtonControl", {"BOTTOMRIGHT",self,"TOP"}, {-2, -4, 60, 18}, "Rename", function()
+ self:RenameLoadout(self.selValue)
+ end)
+ self.controls.rename.enabled = function()
+ return self.selValue ~= nil
+ end
+ self.controls.new = new("ButtonControl", {"RIGHT",self.controls.rename,"LEFT"}, {-4, 0, 60, 18}, "New", function()
+ self.build:OpenNewLoadoutPopup(function()
+ self:BuildList()
+ end)
+ end)
+end)
+
+-- Rebuild the list of loadout descriptors, preserving the current selection by tree spec identity.
+function ManageLoadoutsListClass:BuildList()
+ local prevSpec = self.selValue and self.selValue.spec
+ self.list = self.build:GetLoadouts()
+ self.selIndex = nil
+ self.selValue = nil
+ if prevSpec then
+ for index, loadout in ipairs(self.list) do
+ if loadout.spec == prevSpec then
+ self.selIndex = index
+ self.selValue = loadout
+ break
+ end
+ end
+ end
+end
+
+function ManageLoadoutsListClass:GetRowValue(column, index, loadout)
+ if column == 1 then
+ local spec = loadout.spec
+ local used = spec:CountAllocNodes()
+ local className = spec.curAscendClassName ~= "None" and spec.curAscendClassName or spec.curClassName
+ local isCurrent = self.build.treeTab.specList[self.build.treeTab.activeSpec] == spec
+ return (spec.treeVersion ~= latestTreeVersion and ("["..treeVersions[spec.treeVersion].display.."] ") or "")
+ .. loadout.name
+ .. " (" .. className .. ", " .. used .. " points)"
+ .. (isCurrent and " ^9(Current)" or "")
+ end
+end
+
+function ManageLoadoutsListClass:OnSelClick(index, loadout, doubleClick)
+ if doubleClick then
+ self.build:SetActiveLoadout(loadout)
+ self:BuildList()
+ end
+end
+
+function ManageLoadoutsListClass:OnOrderChange()
+ self.build:ApplyLoadoutOrder(self.list)
+ self:BuildList()
+end
+
+function ManageLoadoutsListClass:OnSelDelete(index, loadout)
+ if not loadout or #self.list <= 1 then
+ return
+ end
+ main:OpenConfirmPopup("Delete Loadout",
+ "Are you sure you want to delete the '"..loadout.name.."' loadout?\n"
+ .. "This will delete its passive tree, item set, skill set and config set.\n"
+ .. "This will not delete any items used by the set.", "Delete", function()
+ self.build:DeleteLoadout(loadout)
+ self:BuildList()
+ end)
+end
+
+function ManageLoadoutsListClass:RenameLoadout(loadout)
+ if not loadout then
+ return
+ end
+ local controls = { }
+ controls.label = new("LabelControl", nil, {0, 20, 0, 16}, "^7Enter name for this loadout:")
+ controls.edit = new("EditControl", nil, {0, 40, 350, 20}, loadout.name, nil, nil, 100, function(buf)
+ controls.save.enabled = buf:match("%S")
+ end)
+ controls.save = new("ButtonControl", nil, {-45, 70, 80, 20}, "Save", function()
+ self.build:RenameLoadout(loadout, controls.edit.buf)
+ self:BuildList()
+ main:ClosePopup()
+ end)
+ controls.save.enabled = false
+ controls.cancel = new("ButtonControl", nil, {45, 70, 80, 20}, "Cancel", function()
+ main:ClosePopup()
+ end)
+ main:OpenPopup(370, 100, "Rename Loadout", controls, "save", "edit", "cancel")
+end
+
+function ManageLoadoutsListClass:CopyLoadout(loadout)
+ if not loadout then
+ return
+ end
+ local controls = { }
+ controls.label = new("LabelControl", nil, {0, 20, 0, 16}, "^7Enter name for the copied loadout:")
+ controls.edit = new("EditControl", nil, {0, 40, 350, 20}, loadout.name, nil, nil, 100, function(buf)
+ controls.save.enabled = buf:match("%S")
+ end)
+ controls.save = new("ButtonControl", nil, {-45, 70, 80, 20}, "Save", function()
+ self.build:CopyLoadout(loadout, controls.edit.buf)
+ self:BuildList()
+ main:ClosePopup()
+ end)
+ controls.save.enabled = false
+ controls.cancel = new("ButtonControl", nil, {45, 70, 80, 20}, "Cancel", function()
+ main:ClosePopup()
+ end)
+ main:OpenPopup(370, 100, "Copy Loadout", controls, "save", "edit", "cancel")
+end
+
+function ManageLoadoutsListClass:OnSelKeyDown(index, loadout, key)
+ if key == "F2" then
+ self:RenameLoadout(loadout)
+ end
+end
diff --git a/src/Modules/Build.lua b/src/Modules/Build.lua
index b7376f5d6bb..84767fba4fa 100644
--- a/src/Modules/Build.lua
+++ b/src/Modules/Build.lua
@@ -7,6 +7,7 @@ local pairs = pairs
local ipairs = ipairs
local next = next
local t_insert = table.insert
+local t_remove = table.remove
local t_sort = table.sort
local m_min = math.min
local m_max = math.max
@@ -313,41 +314,8 @@ function buildMode:Init(dbFileName, buildName, buildXML, convertBuild, importLin
self.controls.buildLoadouts:SetSel(1)
return
end
- if value == "^7^7New Loadout" then
- local controls = { }
- controls.label = new("LabelControl", nil, {0, 20, 0, 16}, "^7Enter name for this loadout:")
- controls.edit = new("EditControl", nil, {0, 40, 350, 20}, "New Loadout", nil, nil, 100, function(buf)
- controls.save.enabled = buf:match("%S")
- end)
- controls.save = new("ButtonControl", nil, {-45, 70, 80, 20}, "Save", function()
- local loadout = controls.edit.buf
-
- local newSpec = new("PassiveSpec", self, latestTreeVersion)
- newSpec.title = loadout
- t_insert(self.treeTab.specList, newSpec)
-
- local itemSet = self.itemsTab:NewItemSet(#self.itemsTab.itemSets + 1)
- t_insert(self.itemsTab.itemSetOrderList, itemSet.id)
- itemSet.title = loadout
-
- local skillSet = self.skillsTab:NewSkillSet(#self.skillsTab.skillSets + 1)
- t_insert(self.skillsTab.skillSetOrderList, skillSet.id)
- skillSet.title = loadout
-
- local configSet = self.configTab:NewConfigSet(#self.configTab.configSets + 1)
- t_insert(self.configTab.configSetOrderList, configSet.id)
- configSet.title = loadout
-
- self:SyncLoadouts()
- self.modFlag = true
- main:ClosePopup()
- end)
- controls.save.enabled = false
- controls.cancel = new("ButtonControl", nil, {45, 70, 80, 20}, "Cancel", function()
- main:ClosePopup()
- end)
- main:OpenPopup(370, 100, "Set Name", controls, "save", "edit", "cancel")
-
+ if value == "^7^7Manage Loadouts... (ctrl-l)" then
+ self:OpenManageLoadoutsPopup()
self.controls.buildLoadouts:SetSel(1)
return
end
@@ -388,10 +356,13 @@ function buildMode:Init(dbFileName, buildName, buildXML, convertBuild, importLin
local oneItem = self.itemsTab and #self.itemsTab.itemSetOrderList == 1
local oneConfig = self.configTab and #self.configTab.configSetOrderList == 1
+ -- strip any tree-version prefix (e.g. "[3.28 (alternate)] ") so item/skill/config titles match by plain name
+ local bareValue = self:StripTreeVersionPrefix(value)
+
local newSpecId = findNamedSetId(self.treeTab:GetSpecList(), value, self.treeListSpecialLinks)
- local newItemId = oneItem and 1 or findSetId(self.itemsTab.itemSetOrderList, value, self.itemsTab.itemSets, self.itemListSpecialLinks)
- local newSkillId = oneSkill and 1 or findSetId(self.skillsTab.skillSetOrderList, value, self.skillsTab.skillSets, self.skillListSpecialLinks)
- local newConfigId = oneConfig and 1 or findSetId(self.configTab.configSetOrderList, value, self.configTab.configSets, self.configListSpecialLinks)
+ local newItemId = oneItem and 1 or findSetId(self.itemsTab.itemSetOrderList, bareValue, self.itemsTab.itemSets, self.itemListSpecialLinks)
+ local newSkillId = oneSkill and 1 or findSetId(self.skillsTab.skillSetOrderList, bareValue, self.skillsTab.skillSets, self.skillListSpecialLinks)
+ local newConfigId = oneConfig and 1 or findSetId(self.configTab.configSetOrderList, bareValue, self.configTab.configSets, self.configListSpecialLinks)
-- if exact match nor special grouping cannot find setIds, bail
if newSpecId == nil or newItemId == nil or newSkillId == nil or newConfigId == nil then
@@ -785,7 +756,13 @@ function buildMode:SyncLoadouts()
transferTable = {}
end
else
- t_insert(treeList, (spec.treeVersion ~= latestTreeVersion and ("["..treeVersions[spec.treeVersion].display.."] ") or "")..(specTitle))
+ -- store the plain name (for matching against item/skill/config sets) and the
+ -- version-prefixed display name (shown in the dropdown) separately, so a loadout
+ -- whose tree is on a non-latest version still groups with its plainly-named sets
+ t_insert(treeList, {
+ name = specTitle,
+ display = (spec.treeVersion ~= latestTreeVersion and ("["..treeVersions[spec.treeVersion].display.."] ") or "")..(specTitle),
+ })
end
end
@@ -819,9 +796,10 @@ function buildMode:SyncLoadouts()
identifyLinks(self.configTab.configSetOrderList, self.configTab.configSets, configList, self.configListSpecialLinks, self.treeListSpecialLinks)
-- loop over all for exact match loadouts
+ -- match on the plain tree name (version prefix stripped) but insert the prefixed display name
for id, tree in ipairs(treeList) do
- if (oneItem or itemList[tree]) and (oneSkill or skillList[tree]) and (oneConfig or configList[tree]) then
- t_insert(filteredList, tree)
+ if (oneItem or itemList[tree.name]) and (oneSkill or skillList[tree.name]) and (oneConfig or configList[tree.name]) then
+ t_insert(filteredList, tree.display)
end
end
-- loop over the identifiers found within braces and set the loadout name to the TreeSet
@@ -835,7 +813,7 @@ function buildMode:SyncLoadouts()
-- giving the options unique formatting so it can not match with user-created sets
t_insert(filteredList, "^7^7-----")
- t_insert(filteredList, "^7^7New Loadout")
+ t_insert(filteredList, "^7^7Manage Loadouts... (ctrl-l)")
t_insert(filteredList, "^7^7Sync")
t_insert(filteredList, "^7^7Help >>")
@@ -845,10 +823,13 @@ function buildMode:SyncLoadouts()
-- Try to select loadout in dropdown based on currently selected tree
if self.treeTab then
- local treeName = self.treeTab.specList[self.treeTab.activeSpec].title or "Default"
+ local activeTreeSpec = self.treeTab.specList[self.treeTab.activeSpec]
+ local bareTreeName = activeTreeSpec.title or "Default"
+ -- the dropdown entry uses the version-prefixed display name for non-latest trees
+ local treeName = (activeTreeSpec.treeVersion ~= latestTreeVersion and ("["..treeVersions[activeTreeSpec.treeVersion].display.."] ") or "")..bareTreeName
for i, loadout in ipairs(filteredList) do
if loadout == treeName then
- local linkMatch = string.match(treeName, "%{(%w+)%}") or treeName
+ local linkMatch = string.match(bareTreeName, "%{(%w+)%}") or bareTreeName
if linkMatch then
local skillName = self.skillsTab.skillSets[self.skillsTab.activeSkillSetId].title or "Default"
local skillMatch = oneSkill or skillName:find(linkMatch, 1, true)
@@ -871,6 +852,350 @@ function buildMode:SyncLoadouts()
return treeList, itemList, skillList, configList
end
+function buildMode:StripTreeVersionPrefix(name)
+ local prefix = name:match("^%[(.-)%] ")
+ if prefix then
+ -- only strip when the brackets hold a known version display, so user brackets aren't eaten
+ for _, ver in pairs(treeVersions) do
+ if ver.display == prefix then
+ return (name:gsub("^%[.-%] ", "", 1))
+ end
+ end
+ end
+ return name
+end
+
+function buildMode:UpdateItemsTabPassiveTreeDropdown()
+ local specSelect = self.itemsTab and self.itemsTab.controls.specSelect
+ if not specSelect then
+ return
+ end
+ local newSpecList = { }
+ for i = 1, #self.treeTab.specList do
+ newSpecList[i] = self.treeTab.specList[i].title or "Default"
+ end
+ specSelect:SetList(newSpecList)
+ specSelect.selIndex = self.treeTab.activeSpec
+end
+
+-- A loadout is a tree spec whose plain title also matches an item, skill and config set (or, for a
+-- set type with only one set, that set applies to every loadout). Matches on the plain spec.title,
+-- so it ignores the version prefix that only appears in display strings. Ordered by the spec list.
+function buildMode:GetLoadouts()
+ local loadouts = { }
+ if not (self.treeTab and self.itemsTab and self.skillsTab and self.configTab) then
+ return loadouts
+ end
+
+ local oneItem = #self.itemsTab.itemSetOrderList == 1
+ local oneSkill = #self.skillsTab.skillSetOrderList == 1
+ local oneConfig = #self.configTab.configSetOrderList == 1
+
+ -- map plain title -> first set id of that name, in order
+ local function firstIdByTitle(orderList, sets)
+ local map = { }
+ for _, id in ipairs(orderList) do
+ local title = sets[id].title or "Default"
+ if map[title] == nil then
+ map[title] = id
+ end
+ end
+ return map
+ end
+ local itemByTitle = firstIdByTitle(self.itemsTab.itemSetOrderList, self.itemsTab.itemSets)
+ local skillByTitle = firstIdByTitle(self.skillsTab.skillSetOrderList, self.skillsTab.skillSets)
+ local configByTitle = firstIdByTitle(self.configTab.configSetOrderList, self.configTab.configSets)
+
+ for specIndex, spec in ipairs(self.treeTab.specList) do
+ local title = spec.title or "Default"
+ local itemSetId = oneItem and self.itemsTab.itemSetOrderList[1] or itemByTitle[title]
+ local skillSetId = oneSkill and self.skillsTab.skillSetOrderList[1] or skillByTitle[title]
+ local configSetId = oneConfig and self.configTab.configSetOrderList[1] or configByTitle[title]
+ if itemSetId and skillSetId and configSetId then
+ t_insert(loadouts, {
+ name = title,
+ spec = spec,
+ specIndex = specIndex,
+ itemSetId = itemSetId,
+ skillSetId = skillSetId,
+ configSetId = configSetId,
+ })
+ end
+ end
+ return loadouts
+end
+
+function buildMode:SetActiveLoadout(loadout)
+ local specIndex = isValueInArray(self.treeTab.specList, loadout.spec)
+ if specIndex and specIndex ~= self.treeTab.activeSpec then
+ self.treeTab:SetActiveSpec(specIndex)
+ end
+ if loadout.itemSetId ~= self.itemsTab.activeItemSetId then
+ self.itemsTab:SetActiveItemSet(loadout.itemSetId)
+ end
+ if loadout.skillSetId ~= self.skillsTab.activeSkillSetId then
+ self.skillsTab:SetActiveSkillSet(loadout.skillSetId)
+ end
+ if loadout.configSetId ~= self.configTab.activeConfigSetId then
+ self.configTab:SetActiveConfigSet(loadout.configSetId)
+ end
+ self:SyncLoadouts()
+end
+
+function buildMode:CreateLoadout(name)
+ local newSpec = new("PassiveSpec", self, latestTreeVersion)
+ newSpec.title = name
+ t_insert(self.treeTab.specList, newSpec)
+
+ local itemSet = self.itemsTab:NewItemSet(#self.itemsTab.itemSets + 1)
+ t_insert(self.itemsTab.itemSetOrderList, itemSet.id)
+ itemSet.title = name
+
+ local skillSet = self.skillsTab:NewSkillSet(#self.skillsTab.skillSets + 1)
+ t_insert(self.skillsTab.skillSetOrderList, skillSet.id)
+ skillSet.title = name
+
+ local configSet = self.configTab:NewConfigSet(#self.configTab.configSets + 1)
+ t_insert(self.configTab.configSetOrderList, configSet.id)
+ configSet.title = name
+
+ self.modFlag = true
+ self:SyncLoadouts()
+ self:UpdateItemsTabPassiveTreeDropdown()
+ return { spec = newSpec, itemSetId = itemSet.id, skillSetId = skillSet.id, configSetId = configSet.id }
+end
+
+function buildMode:OpenNewLoadoutPopup(onCreated)
+ local controls = { }
+ controls.label = new("LabelControl", nil, {0, 20, 0, 16}, "^7Enter name for this loadout:")
+ controls.edit = new("EditControl", nil, {0, 40, 350, 20}, "New Loadout", nil, nil, 100, function(buf)
+ controls.save.enabled = buf:match("%S")
+ end)
+ controls.save = new("ButtonControl", nil, {-45, 70, 80, 20}, "Save", function()
+ self:CreateLoadout(controls.edit.buf)
+ main:ClosePopup()
+ if onCreated then
+ onCreated()
+ end
+ end)
+ controls.save.enabled = false
+ controls.cancel = new("ButtonControl", nil, {45, 70, 80, 20}, "Cancel", function()
+ main:ClosePopup()
+ end)
+ main:OpenPopup(370, 100, "Set Name", controls, "save", "edit", "cancel")
+end
+
+function buildMode:CountLoadoutSetRefs(field, id)
+ local count = 0
+ for _, loadout in ipairs(self:GetLoadouts()) do
+ if loadout[field] == id then
+ count = count + 1
+ end
+ end
+ return count
+end
+
+function buildMode:DeleteLoadout(loadout)
+ -- only delete a set that isn't the sole set of its type and isn't shared by another loadout
+ if #self.itemsTab.itemSetOrderList > 1 and self:CountLoadoutSetRefs("itemSetId", loadout.itemSetId) <= 1 then
+ local pos = isValueInArray(self.itemsTab.itemSetOrderList, loadout.itemSetId)
+ if pos then
+ t_remove(self.itemsTab.itemSetOrderList, pos)
+ self.itemsTab.itemSets[loadout.itemSetId] = nil
+ if self.itemsTab.activeItemSetId == loadout.itemSetId then
+ self.itemsTab:SetActiveItemSet(self.itemsTab.itemSetOrderList[m_max(1, pos - 1)])
+ end
+ end
+ end
+ -- skill set
+ if #self.skillsTab.skillSetOrderList > 1 and self:CountLoadoutSetRefs("skillSetId", loadout.skillSetId) <= 1 then
+ local pos = isValueInArray(self.skillsTab.skillSetOrderList, loadout.skillSetId)
+ if pos then
+ t_remove(self.skillsTab.skillSetOrderList, pos)
+ self.skillsTab.skillSets[loadout.skillSetId] = nil
+ if self.skillsTab.activeSkillSetId == loadout.skillSetId then
+ self.skillsTab:SetActiveSkillSet(self.skillsTab.skillSetOrderList[m_max(1, pos - 1)])
+ end
+ end
+ end
+ -- config set
+ if #self.configTab.configSetOrderList > 1 and self:CountLoadoutSetRefs("configSetId", loadout.configSetId) <= 1 then
+ local pos = isValueInArray(self.configTab.configSetOrderList, loadout.configSetId)
+ if pos then
+ t_remove(self.configTab.configSetOrderList, pos)
+ self.configTab.configSets[loadout.configSetId] = nil
+ if self.configTab.activeConfigSetId == loadout.configSetId then
+ self.configTab:SetActiveConfigSet(self.configTab.configSetOrderList[m_max(1, pos - 1)])
+ end
+ end
+ end
+ -- tree spec (each loadout maps to a distinct spec)
+ if #self.treeTab.specList > 1 then
+ local specPos = isValueInArray(self.treeTab.specList, loadout.spec)
+ if specPos then
+ t_remove(self.treeTab.specList, specPos)
+ if self.treeTab.activeSpec == specPos then
+ self.treeTab:SetActiveSpec(m_max(1, specPos - 1))
+ else
+ self.treeTab.activeSpec = isValueInArray(self.treeTab.specList, self.spec)
+ end
+ end
+ end
+
+ self.modFlag = true
+ self:SyncLoadouts()
+ self:UpdateItemsTabPassiveTreeDropdown()
+end
+
+function buildMode:RenameLoadout(loadout, newName)
+ local function applyName(oldTitle)
+ -- keep any {group} identifier suffix intact
+ local brace = (oldTitle or "Default"):match("(%{[%w,]+%})")
+ return brace and (newName .. " " .. brace) or newName
+ end
+
+ loadout.spec.title = applyName(loadout.spec.title)
+
+ if #self.itemsTab.itemSetOrderList > 1 and self:CountLoadoutSetRefs("itemSetId", loadout.itemSetId) <= 1 then
+ self.itemsTab.itemSets[loadout.itemSetId].title = applyName(self.itemsTab.itemSets[loadout.itemSetId].title)
+ end
+ if #self.skillsTab.skillSetOrderList > 1 and self:CountLoadoutSetRefs("skillSetId", loadout.skillSetId) <= 1 then
+ self.skillsTab.skillSets[loadout.skillSetId].title = applyName(self.skillsTab.skillSets[loadout.skillSetId].title)
+ end
+ if #self.configTab.configSetOrderList > 1 and self:CountLoadoutSetRefs("configSetId", loadout.configSetId) <= 1 then
+ self.configTab.configSets[loadout.configSetId].title = applyName(self.configTab.configSets[loadout.configSetId].title)
+ end
+
+ self.modFlag = true
+ self:SyncLoadouts()
+ self:UpdateItemsTabPassiveTreeDropdown()
+end
+
+-- Duplicate an entire loadout into four new sets sharing newName (mirrors each tab's own Copy).
+function buildMode:CopyLoadout(loadout, newName)
+ -- tree (same pattern as PassiveSpecListControl Copy)
+ local srcSpec = loadout.spec
+ local newSpec = new("PassiveSpec", self, srcSpec.treeVersion)
+ newSpec.title = newName
+ newSpec.jewels = copyTable(srcSpec.jewels)
+ newSpec:RestoreUndoState(srcSpec:CreateUndoState())
+ newSpec:BuildClusterJewelGraphs()
+ t_insert(self.treeTab.specList, newSpec)
+
+ -- item set (same pattern as ItemSetListControl Copy)
+ local newItemSet = copyTable(self.itemsTab.itemSets[loadout.itemSetId])
+ newItemSet.id = 1
+ while self.itemsTab.itemSets[newItemSet.id] do
+ newItemSet.id = newItemSet.id + 1
+ end
+ newItemSet.title = newName
+ self.itemsTab.itemSets[newItemSet.id] = newItemSet
+ t_insert(self.itemsTab.itemSetOrderList, newItemSet.id)
+
+ -- skill set (deep copy, same pattern as SkillSetListControl Copy)
+ local srcSkillSet = self.skillsTab.skillSets[loadout.skillSetId]
+ local newSkillSet = copyTable(srcSkillSet, true)
+ newSkillSet.socketGroupList = { }
+ for _, socketGroup in ipairs(srcSkillSet.socketGroupList) do
+ local newGroup = copyTable(socketGroup, true)
+ newGroup.gemList = { }
+ for gemIndex, gem in pairs(socketGroup.gemList) do
+ newGroup.gemList[gemIndex] = copyTable(gem, true)
+ end
+ t_insert(newSkillSet.socketGroupList, newGroup)
+ end
+ newSkillSet.id = 1
+ while self.skillsTab.skillSets[newSkillSet.id] do
+ newSkillSet.id = newSkillSet.id + 1
+ end
+ newSkillSet.title = newName
+ self.skillsTab.skillSets[newSkillSet.id] = newSkillSet
+ t_insert(self.skillsTab.skillSetOrderList, newSkillSet.id)
+
+ -- config set (same pattern as ConfigSetListControl Copy)
+ local newConfigSet = copyTable(self.configTab.configSets[loadout.configSetId])
+ newConfigSet.id = 1
+ while self.configTab.configSets[newConfigSet.id] do
+ newConfigSet.id = newConfigSet.id + 1
+ end
+ newConfigSet.title = newName
+ self.configTab.configSets[newConfigSet.id] = newConfigSet
+ t_insert(self.configTab.configSetOrderList, newConfigSet.id)
+
+ self.modFlag = true
+ self:SyncLoadouts()
+ self:UpdateItemsTabPassiveTreeDropdown()
+end
+
+function buildMode:ApplyLoadoutOrder(orderedLoadouts)
+ -- reorder only the loadout members within fullList, leaving any non-loadout sets in place
+ local function reorderMembers(fullList, orderedMembers)
+ local memberSet = { }
+ for _, member in ipairs(orderedMembers) do
+ memberSet[member] = true
+ end
+ local slots = { }
+ for i, value in ipairs(fullList) do
+ if memberSet[value] then
+ t_insert(slots, i)
+ end
+ end
+ if #slots ~= #orderedMembers then
+ return
+ end
+ for k, slot in ipairs(slots) do
+ fullList[slot] = orderedMembers[k]
+ end
+ end
+
+ -- returns the ordered set ids for a field only if they are all distinct
+ local function distinctIds(field)
+ local ids, seen = { }, { }
+ for _, loadout in ipairs(orderedLoadouts) do
+ local id = loadout[field]
+ if not id or seen[id] then
+ return nil
+ end
+ seen[id] = true
+ t_insert(ids, id)
+ end
+ return ids
+ end
+
+ local specOrder = { }
+ for _, loadout in ipairs(orderedLoadouts) do
+ t_insert(specOrder, loadout.spec)
+ end
+ reorderMembers(self.treeTab.specList, specOrder)
+ self.treeTab.activeSpec = isValueInArray(self.treeTab.specList, self.spec)
+
+ local itemIds = distinctIds("itemSetId")
+ if itemIds then
+ reorderMembers(self.itemsTab.itemSetOrderList, itemIds)
+ end
+ local skillIds = distinctIds("skillSetId")
+ if skillIds then
+ reorderMembers(self.skillsTab.skillSetOrderList, skillIds)
+ end
+ local configIds = distinctIds("configSetId")
+ if configIds then
+ reorderMembers(self.configTab.configSetOrderList, configIds)
+ end
+
+ self.modFlag = true
+ self:SyncLoadouts()
+ self:UpdateItemsTabPassiveTreeDropdown()
+end
+
+function buildMode:OpenManageLoadoutsPopup()
+ main:OpenPopup(370, 290, "Manage Loadouts", {
+ new("ManageLoadoutsListControl", nil, {0, 50, 350, 200}, self),
+ new("ButtonControl", nil, {0, 259, 90, 20}, "Done", function()
+ main:ClosePopup()
+ end),
+ })
+end
+
function buildMode:EstimatePlayerProgress()
local PointsUsed, AscUsed, SecondaryAscUsed = self.spec:CountAllocNodes()
local extra = self.calcsTab.mainOutput and self.calcsTab.mainOutput.ExtraPoints or 0
@@ -1167,6 +1492,9 @@ function buildMode:OnFrame(inputEvents)
self.viewMode = "NOTES"
elseif event.key == "7" then
self.viewMode = "PARTY"
+ elseif event.key == "l" then
+ self:OpenManageLoadoutsPopup()
+ inputEvents[id] = nil
end
end
end