From 0ed635c3772ec3e22bd719537aa7c5d28214c4ac Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 30 Mar 2026 09:57:59 +0200 Subject: [PATCH 1/3] Add Config Set import/export feature --- spec/System/TestConfigSetCodec_spec.lua | 177 ++++++++++++++++++++++++ src/Classes/ConfigTab.lua | 114 ++++++++++++++- 2 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 spec/System/TestConfigSetCodec_spec.lua diff --git a/spec/System/TestConfigSetCodec_spec.lua b/spec/System/TestConfigSetCodec_spec.lua new file mode 100644 index 0000000000..4514a61603 --- /dev/null +++ b/spec/System/TestConfigSetCodec_spec.lua @@ -0,0 +1,177 @@ +describe("TestConfigSetCodec", function() + local savedDeflate, savedInflate + + before_each(function() + newBuild() + -- Headless stubs for Deflate/Inflate return "", + -- which would break encode/decode. + -- Replaced them with identity functions, + -- so that the base64 + XML layer is tested in isolation. + savedDeflate = Deflate + savedInflate = Inflate + _G.Deflate = function(data) return data end + _G.Inflate = function(data) return data end + end) + + after_each(function() + _G.Deflate = savedDeflate + _G.Inflate = savedInflate + end) + + -- Mirrors the serialisation logic in ConfigTabClass:OpenExportConfigSetPopup. + local function encodeConfigSet(configSet, configTab) + local xmlNode = { elem = "ConfigSet", attrib = { title = configSet.title } } + for k, v in pairs(configSet.input) do + if v ~= configTab:GetDefaultState(k, type(v)) then + local node = { elem = "Input", attrib = { name = k } } + if type(v) == "number" then + node.attrib.number = tostring(v) + elseif type(v) == "boolean" then + node.attrib.boolean = tostring(v) + else + node.attrib.string = tostring(v) + end + table.insert(xmlNode, node) + end + end + for k, v in pairs(configSet.placeholder) do + local node = { elem = "Placeholder", attrib = { name = k } } + if type(v) == "number" then + node.attrib.number = tostring(v) + else + node.attrib.string = tostring(v) + end + table.insert(xmlNode, node) + end + local xmlText = common.xml.ComposeXML(xmlNode) + return common.base64.encode(Deflate(xmlText)):gsub("+", "-"):gsub("/", "_") + end + + -- Mirrors the deserialisation logic in ConfigTabClass:OpenImportConfigSetPopup. + local function decodeConfigSet(code, configTab, name) + local xmlText = Inflate(common.base64.decode(code:gsub("-", "+"):gsub("_", "/"))) + if not xmlText or #xmlText == 0 then + return nil, "decode failed" + end + local parsedXML, errMsg = common.xml.ParseXML(xmlText) + if errMsg or not parsedXML or not parsedXML[1] or parsedXML[1].elem ~= "ConfigSet" then + return nil, errMsg or "invalid config set code" + end + local xmlConfigSet = parsedXML[1] + local newConfigSet = configTab:NewConfigSet(nil, name or xmlConfigSet.attrib.title or "Imported") + for _, child in ipairs(xmlConfigSet) do + if child.elem == "Input" and child.attrib.name then + if child.attrib.number then + newConfigSet.input[child.attrib.name] = tonumber(child.attrib.number) + elseif child.attrib.boolean then + newConfigSet.input[child.attrib.name] = child.attrib.boolean == "true" + elseif child.attrib.string then + newConfigSet.input[child.attrib.name] = child.attrib.string + end + elseif child.elem == "Placeholder" and child.attrib.name then + if child.attrib.number then + newConfigSet.placeholder[child.attrib.name] = tonumber(child.attrib.number) + elseif child.attrib.string then + newConfigSet.placeholder[child.attrib.name] = child.attrib.string + end + end + end + return newConfigSet, nil + end + + it("export produces a non-empty base64 code", function() + local configTab = build.configTab + local configSet = configTab.configSets[configTab.activeConfigSetId] + configSet.input["usePowerCharges"] = true + local code = encodeConfigSet(configSet, configTab) + assert.is_not_nil(code) + assert.is_true(#code > 0) + end) + + it("roundtrip preserves boolean inputs", function() + local configTab = build.configTab + local configSet = configTab.configSets[configTab.activeConfigSetId] + configSet.input["usePowerCharges"] = true + local code = encodeConfigSet(configSet, configTab) + local imported, err = decodeConfigSet(code, configTab, "Test") + assert.is_nil(err) + assert.is_not_nil(imported) + assert.are.equal(true, imported.input["usePowerCharges"]) + end) + + it("roundtrip preserves numeric inputs", function() + local configTab = build.configTab + local configSet = configTab.configSets[configTab.activeConfigSetId] + -- Use 75 to avoid matching any boss-level placeholder (83/84/85), + -- which would cause GetDefaultState skip export. + configSet.input["enemyLevel"] = 75 + local code = encodeConfigSet(configSet, configTab) + local imported, err = decodeConfigSet(code, configTab, "Test") + assert.is_nil(err) + assert.is_not_nil(imported) + assert.are.equal(75, imported.input["enemyLevel"]) + end) + + it("roundtrip preserves string inputs", function() + local configTab = build.configTab + local configSet = configTab.configSets[configTab.activeConfigSetId] + -- "Uber" is non-default (default is "Pinnacle" from defaultIndex=3) + configSet.input["enemyIsBoss"] = "Uber" + local code = encodeConfigSet(configSet, configTab) + local imported, err = decodeConfigSet(code, configTab, "Test") + assert.is_nil(err) + assert.is_not_nil(imported) + assert.are.equal("Uber", imported.input["enemyIsBoss"]) + end) + + it("roundtrip preserves multiple values simultaneously", function() + local configTab = build.configTab + local configSet = configTab.configSets[configTab.activeConfigSetId] + configSet.input["usePowerCharges"] = true + configSet.input["enemyLevel"] = 75 + configSet.input["enemyIsBoss"] = "Uber" + local code = encodeConfigSet(configSet, configTab) + local imported, err = decodeConfigSet(code, configTab, "Multi") + assert.is_nil(err) + assert.is_not_nil(imported) + assert.are.equal(true, imported.input["usePowerCharges"]) + assert.are.equal(75, imported.input["enemyLevel"]) + assert.are.equal("Uber", imported.input["enemyIsBoss"]) + end) + + it("import uses the provided name, not the exported title", function() + local configTab = build.configTab + local configSet = configTab.configSets[configTab.activeConfigSetId] + configSet.title = "Original Title" + local code = encodeConfigSet(configSet, configTab) + local imported, err = decodeConfigSet(code, configTab, "Custom Name") + assert.is_nil(err) + assert.is_not_nil(imported) + assert.are.equal("Custom Name", imported.title) + end) + + it("export and re-import of an unmodified config set succeeds", function() + local configTab = build.configTab + local configSet = configTab.configSets[configTab.activeConfigSetId] + local code = encodeConfigSet(configSet, configTab) + assert.is_true(#code > 0) + local imported, err = decodeConfigSet(code, configTab, "Imported") + assert.is_nil(err) + assert.is_not_nil(imported) + assert.are.equal("Imported", imported.title) + end) + + it("returns error for garbage input", function() + local configTab = build.configTab + local _, err = decodeConfigSet("!!!not_valid!!!", configTab, "Test") + assert.is_not_nil(err) + end) + + it("returns error when decoded XML root is not a ConfigSet", function() + local configTab = build.configTab + local xmlText = "" + local code = common.base64.encode(Deflate(xmlText)):gsub("+", "-"):gsub("/", "_") + local _, err = decodeConfigSet(code, configTab, "Test") + assert.is_not_nil(err) + end) +end) diff --git a/src/Classes/ConfigTab.lua b/src/Classes/ConfigTab.lua index da28d4e3f6..df2b33b99e 100644 --- a/src/Classes/ConfigTab.lua +++ b/src/Classes/ConfigTab.lua @@ -946,14 +946,124 @@ function ConfigTabClass:RestoreUndoState(state) end function ConfigTabClass:OpenConfigSetManagePopup() + local listControl = new("ConfigSetListControl", nil, {0, 50, 350, 200}, self) + local importConfig = new("ButtonControl", nil, {-99, 259, 90, 20}, "Import Config", function() + self:OpenImportConfigSetPopup() + end) + local exportConfig = new("ButtonControl", {"LEFT", importConfig, "RIGHT"}, {8, 0, 90, 20}, "Export Config", function() + if listControl.selValue then + self:OpenExportConfigSetPopup(self.configSets[listControl.selValue]) + end + end) + exportConfig.enabled = function() + return listControl.selValue ~= nil + end main:OpenPopup(370, 290, "Manage Config Sets", { - new("ConfigSetListControl", nil, {0, 50, 350, 200}, self), - new("ButtonControl", nil, {0, 260, 90, 20}, "Done", function() + listControl, + importConfig, + exportConfig, + new("ButtonControl", {"LEFT", exportConfig, "RIGHT"}, {8, 0, 90, 20}, "Done", function() main:ClosePopup() end), }) end +function ConfigTabClass:OpenExportConfigSetPopup(configSet) + local xmlNode = { elem = "ConfigSet", attrib = { title = configSet.title } } + for k, v in pairs(configSet.input) do + if v ~= self:GetDefaultState(k, type(v)) then + local node = { elem = "Input", attrib = { name = k } } + if type(v) == "number" then + node.attrib.number = tostring(v) + elseif type(v) == "boolean" then + node.attrib.boolean = tostring(v) + else + node.attrib.string = tostring(v) + end + t_insert(xmlNode, node) + end + end + for k, v in pairs(configSet.placeholder) do + local node = { elem = "Placeholder", attrib = { name = k } } + if type(v) == "number" then + node.attrib.number = tostring(v) + else + node.attrib.string = tostring(v) + end + t_insert(xmlNode, node) + end + local xmlText = common.xml.ComposeXML(xmlNode) + local code = common.base64.encode(Deflate(xmlText)):gsub("+", "-"):gsub("/", "_") + local controls = { } + controls.label = new("LabelControl", nil, {0, 20, 0, 16}, "Config set code:") + controls.edit = new("EditControl", nil, {0, 40, 350, 18}, code, nil, "%Z") + controls.copy = new("ButtonControl", nil, {-45, 70, 80, 20}, "Copy", function() + Copy(code) + end) + controls.done = new("ButtonControl", nil, {45, 70, 80, 20}, "Done", function() + main:ClosePopup() + end) + main:OpenPopup(380, 100, "Export Config Set", controls, "done", "edit") +end + +function ConfigTabClass:OpenImportConfigSetPopup() + local controls = { } + controls.nameLabel = new("LabelControl", nil, {-180, 20, 0, 16}, "Enter name for this config set:") + 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 config set code:") + 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 buf = controls.edit.buf + if #buf == 0 then return end + local xmlText = Inflate(common.base64.decode(buf:gsub("-", "+"):gsub("_", "/"))) + if not xmlText then + controls.msg.label = "^1Invalid code" + return + end + local parsedXML, errMsg = common.xml.ParseXML(xmlText) + if errMsg or not parsedXML or not parsedXML[1] or parsedXML[1].elem ~= "ConfigSet" then + controls.msg.label = "^1Invalid config set code" + return + end + local xmlConfigSet = parsedXML[1] + local newConfigSet = self:NewConfigSet(nil, controls.name.buf) + for _, child in ipairs(xmlConfigSet) do + if child.elem == "Input" and child.attrib.name then + if child.attrib.number then + newConfigSet.input[child.attrib.name] = tonumber(child.attrib.number) + elseif child.attrib.boolean then + newConfigSet.input[child.attrib.name] = child.attrib.boolean == "true" + elseif child.attrib.string then + newConfigSet.input[child.attrib.name] = child.attrib.string + end + elseif child.elem == "Placeholder" and child.attrib.name then + if child.attrib.number then + newConfigSet.placeholder[child.attrib.name] = tonumber(child.attrib.number) + elseif child.attrib.string then + newConfigSet.placeholder[child.attrib.name] = child.attrib.string + end + end + end + t_insert(self.configSetOrderList, newConfigSet.id) + self.modFlag = true + self:AddUndoState() + self.build:SyncLoadouts() + main:ClosePopup() + end) + controls.import.enabled = false + controls.cancel = new("ButtonControl", nil, {45, 85, 80, 20}, "Cancel", function() + main:ClosePopup() + end) + main:OpenPopup(580, 115, "Import Config Set", controls, "import", "name") +end + -- Creates a new config set function ConfigTabClass:NewConfigSet(configSetId, title) local configSet = { id = configSetId, title = title, input = { }, placeholder = { } } From 8ca0ebd95d155fe5bcafbce52a63f20c7f125fac Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 30 Mar 2026 17:40:45 +0200 Subject: [PATCH 2/3] Fix label alignment --- src/Classes/ConfigTab.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Classes/ConfigTab.lua b/src/Classes/ConfigTab.lua index df2b33b99e..ed1df638a3 100644 --- a/src/Classes/ConfigTab.lua +++ b/src/Classes/ConfigTab.lua @@ -1008,7 +1008,7 @@ end function ConfigTabClass:OpenImportConfigSetPopup() local controls = { } - controls.nameLabel = new("LabelControl", nil, {-180, 20, 0, 16}, "Enter name for this config set:") + controls.nameLabel = new("LabelControl", nil, {-175, 20, 0, 16}, "Enter name for this config set:") 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") From bb3f328237d5f432dae6903d9ec278cf997bb66c Mon Sep 17 00:00:00 2001 From: Thomas Date: Sat, 18 Apr 2026 00:41:26 +0200 Subject: [PATCH 3/3] Reuse shared config set encode/decode helpers --- spec/System/TestConfigSetCodec_spec.lua | 91 ++------- src/Classes/ConfigTab.lua | 235 ++++++++++++------------ 2 files changed, 128 insertions(+), 198 deletions(-) diff --git a/spec/System/TestConfigSetCodec_spec.lua b/spec/System/TestConfigSetCodec_spec.lua index 4514a61603..477916312b 100644 --- a/spec/System/TestConfigSetCodec_spec.lua +++ b/spec/System/TestConfigSetCodec_spec.lua @@ -18,72 +18,11 @@ describe("TestConfigSetCodec", function() _G.Inflate = savedInflate end) - -- Mirrors the serialisation logic in ConfigTabClass:OpenExportConfigSetPopup. - local function encodeConfigSet(configSet, configTab) - local xmlNode = { elem = "ConfigSet", attrib = { title = configSet.title } } - for k, v in pairs(configSet.input) do - if v ~= configTab:GetDefaultState(k, type(v)) then - local node = { elem = "Input", attrib = { name = k } } - if type(v) == "number" then - node.attrib.number = tostring(v) - elseif type(v) == "boolean" then - node.attrib.boolean = tostring(v) - else - node.attrib.string = tostring(v) - end - table.insert(xmlNode, node) - end - end - for k, v in pairs(configSet.placeholder) do - local node = { elem = "Placeholder", attrib = { name = k } } - if type(v) == "number" then - node.attrib.number = tostring(v) - else - node.attrib.string = tostring(v) - end - table.insert(xmlNode, node) - end - local xmlText = common.xml.ComposeXML(xmlNode) - return common.base64.encode(Deflate(xmlText)):gsub("+", "-"):gsub("/", "_") - end - - -- Mirrors the deserialisation logic in ConfigTabClass:OpenImportConfigSetPopup. - local function decodeConfigSet(code, configTab, name) - local xmlText = Inflate(common.base64.decode(code:gsub("-", "+"):gsub("_", "/"))) - if not xmlText or #xmlText == 0 then - return nil, "decode failed" - end - local parsedXML, errMsg = common.xml.ParseXML(xmlText) - if errMsg or not parsedXML or not parsedXML[1] or parsedXML[1].elem ~= "ConfigSet" then - return nil, errMsg or "invalid config set code" - end - local xmlConfigSet = parsedXML[1] - local newConfigSet = configTab:NewConfigSet(nil, name or xmlConfigSet.attrib.title or "Imported") - for _, child in ipairs(xmlConfigSet) do - if child.elem == "Input" and child.attrib.name then - if child.attrib.number then - newConfigSet.input[child.attrib.name] = tonumber(child.attrib.number) - elseif child.attrib.boolean then - newConfigSet.input[child.attrib.name] = child.attrib.boolean == "true" - elseif child.attrib.string then - newConfigSet.input[child.attrib.name] = child.attrib.string - end - elseif child.elem == "Placeholder" and child.attrib.name then - if child.attrib.number then - newConfigSet.placeholder[child.attrib.name] = tonumber(child.attrib.number) - elseif child.attrib.string then - newConfigSet.placeholder[child.attrib.name] = child.attrib.string - end - end - end - return newConfigSet, nil - end - it("export produces a non-empty base64 code", function() local configTab = build.configTab local configSet = configTab.configSets[configTab.activeConfigSetId] configSet.input["usePowerCharges"] = true - local code = encodeConfigSet(configSet, configTab) + local code = configTab:EncodeConfigSet(configSet) assert.is_not_nil(code) assert.is_true(#code > 0) end) @@ -92,8 +31,8 @@ describe("TestConfigSetCodec", function() local configTab = build.configTab local configSet = configTab.configSets[configTab.activeConfigSetId] configSet.input["usePowerCharges"] = true - local code = encodeConfigSet(configSet, configTab) - local imported, err = decodeConfigSet(code, configTab, "Test") + local code = configTab:EncodeConfigSet(configSet) + local imported, err = configTab:DecodeConfigSet(code, "Test") assert.is_nil(err) assert.is_not_nil(imported) assert.are.equal(true, imported.input["usePowerCharges"]) @@ -105,8 +44,8 @@ describe("TestConfigSetCodec", function() -- Use 75 to avoid matching any boss-level placeholder (83/84/85), -- which would cause GetDefaultState skip export. configSet.input["enemyLevel"] = 75 - local code = encodeConfigSet(configSet, configTab) - local imported, err = decodeConfigSet(code, configTab, "Test") + local code = configTab:EncodeConfigSet(configSet) + local imported, err = configTab:DecodeConfigSet(code, "Test") assert.is_nil(err) assert.is_not_nil(imported) assert.are.equal(75, imported.input["enemyLevel"]) @@ -117,8 +56,8 @@ describe("TestConfigSetCodec", function() local configSet = configTab.configSets[configTab.activeConfigSetId] -- "Uber" is non-default (default is "Pinnacle" from defaultIndex=3) configSet.input["enemyIsBoss"] = "Uber" - local code = encodeConfigSet(configSet, configTab) - local imported, err = decodeConfigSet(code, configTab, "Test") + local code = configTab:EncodeConfigSet(configSet) + local imported, err = configTab:DecodeConfigSet(code, "Test") assert.is_nil(err) assert.is_not_nil(imported) assert.are.equal("Uber", imported.input["enemyIsBoss"]) @@ -130,8 +69,8 @@ describe("TestConfigSetCodec", function() configSet.input["usePowerCharges"] = true configSet.input["enemyLevel"] = 75 configSet.input["enemyIsBoss"] = "Uber" - local code = encodeConfigSet(configSet, configTab) - local imported, err = decodeConfigSet(code, configTab, "Multi") + local code = configTab:EncodeConfigSet(configSet) + local imported, err = configTab:DecodeConfigSet(code, "Multi") assert.is_nil(err) assert.is_not_nil(imported) assert.are.equal(true, imported.input["usePowerCharges"]) @@ -143,8 +82,8 @@ describe("TestConfigSetCodec", function() local configTab = build.configTab local configSet = configTab.configSets[configTab.activeConfigSetId] configSet.title = "Original Title" - local code = encodeConfigSet(configSet, configTab) - local imported, err = decodeConfigSet(code, configTab, "Custom Name") + local code = configTab:EncodeConfigSet(configSet) + local imported, err = configTab:DecodeConfigSet(code, "Custom Name") assert.is_nil(err) assert.is_not_nil(imported) assert.are.equal("Custom Name", imported.title) @@ -153,9 +92,9 @@ describe("TestConfigSetCodec", function() it("export and re-import of an unmodified config set succeeds", function() local configTab = build.configTab local configSet = configTab.configSets[configTab.activeConfigSetId] - local code = encodeConfigSet(configSet, configTab) + local code = configTab:EncodeConfigSet(configSet) assert.is_true(#code > 0) - local imported, err = decodeConfigSet(code, configTab, "Imported") + local imported, err = configTab:DecodeConfigSet(code, "Imported") assert.is_nil(err) assert.is_not_nil(imported) assert.are.equal("Imported", imported.title) @@ -163,7 +102,7 @@ describe("TestConfigSetCodec", function() it("returns error for garbage input", function() local configTab = build.configTab - local _, err = decodeConfigSet("!!!not_valid!!!", configTab, "Test") + local _, err = configTab:DecodeConfigSet("!!!not_valid!!!", "Test") assert.is_not_nil(err) end) @@ -171,7 +110,7 @@ describe("TestConfigSetCodec", function() local configTab = build.configTab local xmlText = "" local code = common.base64.encode(Deflate(xmlText)):gsub("+", "-"):gsub("/", "_") - local _, err = decodeConfigSet(code, configTab, "Test") + local _, err = configTab:DecodeConfigSet(code, "Test") assert.is_not_nil(err) end) end) diff --git a/src/Classes/ConfigTab.lua b/src/Classes/ConfigTab.lua index ed1df638a3..88e573dc31 100644 --- a/src/Classes/ConfigTab.lua +++ b/src/Classes/ConfigTab.lua @@ -623,50 +623,110 @@ local ConfigTabClass = newClass("ConfigTab", "UndoHandler", "ControlHost", "Cont self.controls.scrollBar = new("ScrollBarControl", {"TOPRIGHT",self,"TOPRIGHT"}, {0, 0, 18, 0}, 50, "VERTICAL", true) end) -function ConfigTabClass:Load(xml, fileName) - self.activeConfigSetId = 1 - self.configSets = { } - self.configSetOrderList = { 1 } - - local function setInputAndPlaceholder(node, configSetId) - if node.elem == "Input" then - if not node.attrib.name then - launch:ShowErrMsg("^1Error parsing '%s': 'Input' element missing name attribute", fileName) - return true - end - if node.attrib.number then - self.configSets[configSetId].input[node.attrib.name] = tonumber(node.attrib.number) - elseif node.attrib.string then - if node.attrib.name == "enemyIsBoss" then - self.configSets[configSetId].input[node.attrib.name] = node.attrib.string:lower():gsub("(%l)(%w*)", function(a,b) return s_upper(a)..b end) - :gsub("Uber Atziri", "Boss"):gsub("Shaper", "Pinnacle"):gsub("Sirus", "Pinnacle") - -- backwards compat <=3.20, Uber Atziri Flameblast -> Atziri Flameblast - elseif node.attrib.name == "presetBossSkills" then - self.configSets[configSetId].input[node.attrib.name] = node.attrib.string:gsub("^Uber ", "") - else - self.configSets[configSetId].input[node.attrib.name] = node.attrib.string - end - elseif node.attrib.boolean then - self.configSets[configSetId].input[node.attrib.name] = node.attrib.boolean == "true" +function ConfigTabClass:BuildConfigSetXML(configSet, configSetId) + local xmlNode = { elem = "ConfigSet", attrib = { title = configSet.title } } + if configSetId then + xmlNode.attrib.id = tostring(configSetId) + end + for k, v in pairs(configSet.input) do + if v ~= self:GetDefaultState(k, type(v)) then + local node = { elem = "Input", attrib = { name = k } } + if type(v) == "number" then + node.attrib.number = tostring(v) + elseif type(v) == "boolean" then + node.attrib.boolean = tostring(v) else - launch:ShowErrMsg("^1Error parsing '%s': 'Input' element missing number, string or boolean attribute", fileName) - return true - end - elseif node.elem == "Placeholder" then - if not node.attrib.name then - launch:ShowErrMsg("^1Error parsing '%s': 'Placeholder' element missing name attribute", fileName) - return true + node.attrib.string = tostring(v) end - if node.attrib.number then - self.configSets[configSetId].placeholder[node.attrib.name] = tonumber(node.attrib.number) - elseif node.attrib.string then - self.configSets[configSetId].input[node.attrib.name] = node.attrib.string + t_insert(xmlNode, node) + end + end + for k, v in pairs(configSet.placeholder) do + local node = { elem = "Placeholder", attrib = { name = k } } + if type(v) == "number" then + node.attrib.number = tostring(v) + else + node.attrib.string = tostring(v) + end + t_insert(xmlNode, node) + end + return xmlNode +end + +function ConfigTabClass:EncodeConfigSet(configSet) + local xmlText = common.xml.ComposeXML(self:BuildConfigSetXML(configSet)) + return common.base64.encode(Deflate(xmlText)):gsub("+", "-"):gsub("/", "_") +end + +function ConfigTabClass:LoadConfigSetNode(node, configSet) + local attrib = node.attrib or { } + if node.elem == "Input" then + if not attrib.name then + return "'Input' element missing name attribute" + end + if attrib.number then + configSet.input[attrib.name] = tonumber(attrib.number) + elseif attrib.string then + if attrib.name == "enemyIsBoss" then + configSet.input[attrib.name] = attrib.string:lower():gsub("(%l)(%w*)", function(a,b) return s_upper(a)..b end) + :gsub("Uber Atziri", "Boss"):gsub("Shaper", "Pinnacle"):gsub("Sirus", "Pinnacle") + -- backwards compat <=3.20, Uber Atziri Flameblast -> Atziri Flameblast + elseif attrib.name == "presetBossSkills" then + configSet.input[attrib.name] = attrib.string:gsub("^Uber ", "") else - launch:ShowErrMsg("^1Error parsing '%s': 'Placeholder' element missing number", fileName) - return true + configSet.input[attrib.name] = attrib.string end + elseif attrib.boolean then + configSet.input[attrib.name] = attrib.boolean == "true" + else + return "'Input' element missing number, string or boolean attribute" + end + elseif node.elem == "Placeholder" then + if not attrib.name then + return "'Placeholder' element missing name attribute" + end + if attrib.number then + configSet.placeholder[attrib.name] = tonumber(attrib.number) + elseif attrib.string then + configSet.placeholder[attrib.name] = attrib.string + else + return "'Placeholder' element missing number or string attribute" + end + end +end + +function ConfigTabClass:LoadConfigSet(xmlConfigSet, configSetId, title) + local attrib = xmlConfigSet.attrib or { } + local configSet = self:NewConfigSet(configSetId, title or attrib.title or "Default") + for _, child in ipairs(xmlConfigSet) do + local errMsg = self:LoadConfigSetNode(child, configSet) + if errMsg then + return nil, errMsg end end + return configSet +end + +function ConfigTabClass:DecodeConfigSet(code, title) + local xmlText = Inflate(common.base64.decode(code:gsub("-", "+"):gsub("_", "/"))) + if not xmlText or #xmlText == 0 then + return nil, "Invalid code" + end + local parsedXML, errMsg = common.xml.ParseXML(xmlText) + if errMsg or not parsedXML or not parsedXML[1] or parsedXML[1].elem ~= "ConfigSet" then + return nil, "Invalid config set code" + end + return self:LoadConfigSet(parsedXML[1], nil, title or ((parsedXML[1].attrib or { }).title) or "Imported") +end + +function ConfigTabClass:Load(xml, fileName) + self.activeConfigSetId = 1 + self.configSets = { } + self.configSetOrderList = { 1 } + + local function showConfigSetParseError(errMsg) + launch:ShowErrMsg("^1Error parsing '%s': %s", fileName, errMsg) + end -- Catch special case of empty Config if xml.empty then @@ -677,14 +737,18 @@ function ConfigTabClass:Load(xml, fileName) if not self.configSets[1] then self:NewConfigSet(1, "Default") end - setInputAndPlaceholder(node, 1) + local errMsg = self:LoadConfigSetNode(node, self.configSets[1]) + if errMsg then + showConfigSetParseError(errMsg) + return + end else - local configSetId = tonumber(node.attrib.id) - self:NewConfigSet(configSetId, node.attrib.title or "Default") - self.configSetOrderList[index] = configSetId - for _, child in ipairs(node) do - setInputAndPlaceholder(child, configSetId) + local configSet, errMsg = self:LoadConfigSet(node, tonumber((node.attrib or { }).id)) + if errMsg then + showConfigSetParseError(errMsg) + return end + self.configSetOrderList[index] = configSet.id end end @@ -717,32 +781,7 @@ function ConfigTabClass:Save(xml) activeConfigSet = tostring(self.activeConfigSetId) } for _, configSetId in ipairs(self.configSetOrderList) do - local configSet = self.configSets[configSetId] - local child = { elem = "ConfigSet", attrib = { id = tostring(configSetId), title = configSet.title } } - t_insert(xml, child) - - for k, v in pairs(configSet.input) do - if v ~= self:GetDefaultState(k, type(v)) then - local node = { elem = "Input", attrib = { name = k } } - if type(v) == "number" then - node.attrib.number = tostring(v) - elseif type(v) == "boolean" then - node.attrib.boolean = tostring(v) - else - node.attrib.string = tostring(v) - end - t_insert(child, node) - end - end - for k, v in pairs(configSet.placeholder) do - local node = { elem = "Placeholder", attrib = { name = k } } - if type(v) == "number" then - node.attrib.number = tostring(v) - else - node.attrib.string = tostring(v) - end - t_insert(child, node) - end + t_insert(xml, self:BuildConfigSetXML(self.configSets[configSetId], configSetId)) end end @@ -969,31 +1008,7 @@ function ConfigTabClass:OpenConfigSetManagePopup() end function ConfigTabClass:OpenExportConfigSetPopup(configSet) - local xmlNode = { elem = "ConfigSet", attrib = { title = configSet.title } } - for k, v in pairs(configSet.input) do - if v ~= self:GetDefaultState(k, type(v)) then - local node = { elem = "Input", attrib = { name = k } } - if type(v) == "number" then - node.attrib.number = tostring(v) - elseif type(v) == "boolean" then - node.attrib.boolean = tostring(v) - else - node.attrib.string = tostring(v) - end - t_insert(xmlNode, node) - end - end - for k, v in pairs(configSet.placeholder) do - local node = { elem = "Placeholder", attrib = { name = k } } - if type(v) == "number" then - node.attrib.number = tostring(v) - else - node.attrib.string = tostring(v) - end - t_insert(xmlNode, node) - end - local xmlText = common.xml.ComposeXML(xmlNode) - local code = common.base64.encode(Deflate(xmlText)):gsub("+", "-"):gsub("/", "_") + local code = self:EncodeConfigSet(configSet) local controls = { } controls.label = new("LabelControl", nil, {0, 20, 0, 16}, "Config set code:") controls.edit = new("EditControl", nil, {0, 40, 350, 18}, code, nil, "%Z") @@ -1022,35 +1037,11 @@ function ConfigTabClass:OpenImportConfigSetPopup() controls.import = new("ButtonControl", nil, {-45, 85, 80, 20}, "Import", function() local buf = controls.edit.buf if #buf == 0 then return end - local xmlText = Inflate(common.base64.decode(buf:gsub("-", "+"):gsub("_", "/"))) - if not xmlText then - controls.msg.label = "^1Invalid code" + local newConfigSet, errMsg = self:DecodeConfigSet(buf, controls.name.buf) + if errMsg then + controls.msg.label = "^1" .. errMsg return end - local parsedXML, errMsg = common.xml.ParseXML(xmlText) - if errMsg or not parsedXML or not parsedXML[1] or parsedXML[1].elem ~= "ConfigSet" then - controls.msg.label = "^1Invalid config set code" - return - end - local xmlConfigSet = parsedXML[1] - local newConfigSet = self:NewConfigSet(nil, controls.name.buf) - for _, child in ipairs(xmlConfigSet) do - if child.elem == "Input" and child.attrib.name then - if child.attrib.number then - newConfigSet.input[child.attrib.name] = tonumber(child.attrib.number) - elseif child.attrib.boolean then - newConfigSet.input[child.attrib.name] = child.attrib.boolean == "true" - elseif child.attrib.string then - newConfigSet.input[child.attrib.name] = child.attrib.string - end - elseif child.elem == "Placeholder" and child.attrib.name then - if child.attrib.number then - newConfigSet.placeholder[child.attrib.name] = tonumber(child.attrib.number) - elseif child.attrib.string then - newConfigSet.placeholder[child.attrib.name] = child.attrib.string - end - end - end t_insert(self.configSetOrderList, newConfigSet.id) self.modFlag = true self:AddUndoState()