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