diff --git a/spec/System/TestImportReimport_spec.lua b/spec/System/TestImportReimport_spec.lua new file mode 100644 index 0000000000..cdcf6392a9 --- /dev/null +++ b/spec/System/TestImportReimport_spec.lua @@ -0,0 +1,251 @@ +describe("TestImportReimport", function() + local DEFAULT_CHARACTER_LEVEL = 12 + local DEFAULT_ITEM_LEVEL = 10 + local TEST_IMPORT_ITEM_ID = "test-import-item-1" + + before_each(function() + newBuild() + end) + + local function makeGemProperties(level) + return { + { name = "Level", values = { { tostring(level), 0 } } }, + { name = "Quality", values = { { "+0%", 0 } } }, + } + end + + local function makeGemEntry(support, typeLine, level, socketedItems) + return { + support = support, + typeLine = typeLine, + properties = makeGemProperties(level), + socketedItems = socketedItems, + } + end + + -- Build a minimal import item so the tests stay focused on state, not fixture noise. + local function makeImportItem(itemTypeLine, inventoryId, itemId) + return { + id = itemId or TEST_IMPORT_ITEM_ID, + frameType = 0, + name = "", + typeLine = itemTypeLine, + inventoryId = inventoryId, + ilvl = DEFAULT_ITEM_LEVEL, + properties = {}, + } + end + + -- Build a minimal import payload so the tests stay focused on state, not fixture noise. + local function buildImportPayload(items, skills) + return { + level = DEFAULT_CHARACTER_LEVEL, + equipment = items, + skills = skills, + } + end + + local function reimportSkillsWithOptions(itemTypeLine, inventoryId, skills, clearItems) + build.importTab.controls.charImportItemsClearSkills.state = true + build.importTab.controls.charImportItemsClearItems.state = clearItems + build.importTab:ImportItemsAndSkills(buildImportPayload({ + makeImportItem(itemTypeLine, inventoryId), + }, skills)) + runCallback("OnFrame") + end + + local function reimportSingleGemWithOptions(itemTypeLine, inventoryId, gemName, clearItems) + reimportSkillsWithOptions(itemTypeLine, inventoryId, { + makeGemEntry(false, gemName, 20), + }, clearItems) + end + + local function reimportSingleGem(itemTypeLine, inventoryId, gemName) + reimportSingleGemWithOptions(itemTypeLine, inventoryId, gemName, false) + end + + local function assertReimportPreservesSkillSubstate(itemTypeLine, inventoryId, gemName, fieldName, fieldValue) + build.skillsTab:PasteSocketGroup(string.format([[ +%s 20/0 1 +]], gemName)) + runCallback("OnFrame") + + local socketGroup = build.skillsTab.socketGroupList[1] + local srcInstance = socketGroup.displaySkillList[1].activeEffect.srcInstance + srcInstance[fieldName] = fieldValue + srcInstance[fieldName.."Calcs"] = fieldValue + build.modFlag = true + build.buildFlag = true + runCallback("OnFrame") + + reimportSingleGem(itemTypeLine, inventoryId, gemName) + + socketGroup = build.skillsTab.socketGroupList[1] + srcInstance = socketGroup.displaySkillList[1].activeEffect.srcInstance + assert.are.equal(fieldValue, srcInstance[fieldName]) + assert.are.equal(fieldValue, srcInstance[fieldName.."Calcs"]) + end + + it("preserves full DPS state and manually disabled gems when reimporting items and skills", function() + build.skillsTab:PasteSocketGroup([[ +Slot: Gloves +Dark Effigy 1/0 1 +Controlled Destruction 1/0 DISABLED 1 +]]) + runCallback("OnFrame") + + local socketGroup = build.skillsTab.socketGroupList[1] + socketGroup.includeInFullDPS = true + socketGroup.mainActiveSkill = 2 + runCallback("OnFrame") + + build.importTab.controls.charImportItemsClearSkills.state = true + build.importTab.controls.charImportItemsClearItems.state = false + build.importTab:ImportItemsAndSkills(buildImportPayload({ + makeImportItem("Wrapped Cap", "Helm"), + }, { + makeGemEntry(false, "Dark Effigy", 2, { + makeGemEntry(true, "Controlled Destruction", 1), + }), + })) + runCallback("OnFrame") + + socketGroup = build.skillsTab.socketGroupList[1] + assert.is_true(socketGroup.includeInFullDPS) + assert.are.equal(2, socketGroup.mainActiveSkill) + assert.are.equal(2, socketGroup.gemList[1].level) + assert.is_false(socketGroup.gemList[2].enabled) + end) + + it("preserves full DPS state and disabled gems when reimporting with deleted equipment", function() + build.skillsTab:PasteSocketGroup([[ +Dark Effigy 1/0 1 +Controlled Destruction 1/0 DISABLED 1 +]]) + runCallback("OnFrame") + + local socketGroup = build.skillsTab.socketGroupList[1] + socketGroup.includeInFullDPS = true + socketGroup.mainActiveSkill = 2 + runCallback("OnFrame") + + reimportSkillsWithOptions("Wrapped Cap", "Helm", { + makeGemEntry(false, "Dark Effigy", 2, { + makeGemEntry(true, "Controlled Destruction", 1), + }), + }, true) + + socketGroup = build.skillsTab.socketGroupList[1] + assert.is_true(socketGroup.includeInFullDPS) + assert.are.equal(2, socketGroup.mainActiveSkill) + assert.are.equal(2, socketGroup.gemList[1].level) + assert.is_false(socketGroup.gemList[2].enabled) + end) + + it("preserves two socket groups when reimporting items and skills", function() + build.skillsTab:PasteSocketGroup([[ +Dark Effigy 1/0 1 +Controlled Destruction 1/0 DISABLED 1 +]]) + runCallback("OnFrame") + + build.skillsTab:PasteSocketGroup([[ +Fireball 20/0 1 +]]) + runCallback("OnFrame") + + local darkEffigyGroup = build.skillsTab.socketGroupList[1] + darkEffigyGroup.includeInFullDPS = true + darkEffigyGroup.mainActiveSkill = 2 + local fireballGroup = build.skillsTab.socketGroupList[2] + fireballGroup.enabled = false + runCallback("OnFrame") + + build.importTab.controls.charImportItemsClearSkills.state = true + build.importTab.controls.charImportItemsClearItems.state = false + build.importTab:ImportItemsAndSkills(buildImportPayload({ + makeImportItem("Wrapped Cap", "Helm", "test-import-item-helmet"), + makeImportItem("Linen Wraps", "Gloves", "test-import-item-gloves"), + }, { + makeGemEntry(false, "Dark Effigy", 1, { + makeGemEntry(true, "Controlled Destruction", 1), + }), + makeGemEntry(false, "Fireball", 20), + })) + runCallback("OnFrame") + + local groupsByGem = {} + for _, socketGroup in ipairs(build.skillsTab.socketGroupList) do + groupsByGem[socketGroup.gemList[1].nameSpec] = socketGroup + end + + assert.are.equal(2, #build.skillsTab.socketGroupList) + assert.is_not_nil(groupsByGem["Dark Effigy"]) + assert.is_not_nil(groupsByGem.Fireball) + assert.is_true(groupsByGem["Dark Effigy"].includeInFullDPS) + assert.are.equal(2, groupsByGem["Dark Effigy"].mainActiveSkill) + assert.is_false(groupsByGem.Fireball.enabled) + end) + + it("preserves skill part selection when reimporting items and skills", function() + assertReimportPreservesSkillSubstate("Twig Focus", "Offhand", "Dark Effigy", "skillPart", 2) + end) + + it("preserves stage count when reimporting items and skills", function() + assertReimportPreservesSkillSubstate("Withered Wand", "Weapon", "Flameblast", "skillStageCount", 8) + end) + + it("preserves minion skill when reimporting items and skills", function() + assertReimportPreservesSkillSubstate("Linen Wraps", "Gloves", "Skeletal Sniper", "skillMinionSkill", 2) + end) + + it("preserves minion skill stat set when reimporting items and skills", function() + build.skillsTab:PasteSocketGroup([[ +Skeletal Sniper 20/0 1 +]]) + runCallback("OnFrame") + + local socketGroup = build.skillsTab.socketGroupList[1] + local activeEffect = socketGroup.displaySkillList[1].activeEffect + local grantedEffectId = activeEffect.grantedEffect.id + local srcInstance = activeEffect.srcInstance + srcInstance.skillMinionSkill = 2 + srcInstance.skillMinionSkillCalcs = 2 + srcInstance.skillMinionSkillStatSetIndexLookup = { [grantedEffectId] = { [2] = 3 } } + srcInstance.skillMinionSkillStatSetIndexLookupCalcs = { [grantedEffectId] = { [2] = 2 } } + + reimportSingleGem("Linen Wraps", "Gloves", "Skeletal Sniper") + + socketGroup = build.skillsTab.socketGroupList[1] + activeEffect = socketGroup.displaySkillList[1].activeEffect + grantedEffectId = activeEffect.grantedEffect.id + srcInstance = activeEffect.srcInstance + assert.are.equal(2, srcInstance.skillMinionSkill) + assert.are.equal(2, srcInstance.skillMinionSkillCalcs) + assert.are.equal(3, srcInstance.skillMinionSkillStatSetIndexLookup[grantedEffectId][2]) + assert.are.equal(2, srcInstance.skillMinionSkillStatSetIndexLookupCalcs[grantedEffectId][2]) + end) + + it("preserves active skill stat set when reimporting items and skills", function() + build.skillsTab:PasteSocketGroup([[ +Fireball 20/0 1 +]]) + runCallback("OnFrame") + + local socketGroup = build.skillsTab.socketGroupList[1] + local activeEffect = socketGroup.displaySkillList[1].activeEffect + local grantedEffectId = activeEffect.grantedEffect.id + local srcInstance = activeEffect.srcInstance + srcInstance.statSet = { [grantedEffectId] = 3 } + srcInstance.statSetCalcs = { [grantedEffectId] = 2 } + + reimportSingleGem("Linen Wraps", "Gloves", "Fireball") + + socketGroup = build.skillsTab.socketGroupList[1] + activeEffect = socketGroup.displaySkillList[1].activeEffect + grantedEffectId = activeEffect.grantedEffect.id + srcInstance = activeEffect.srcInstance + assert.are.equal(3, srcInstance.statSet[grantedEffectId]) + assert.are.equal(2, srcInstance.statSetCalcs[grantedEffectId]) + end) +end) diff --git a/spec/System/TestItemMods_spec.lua b/spec/System/TestItemMods_spec.lua index e29122b662..cc435f3b7c 100644 --- a/spec/System/TestItemMods_spec.lua +++ b/spec/System/TestItemMods_spec.lua @@ -246,7 +246,7 @@ describe("TetsItemMods", function() {range:1}(15-20)% increased Cold Damage per 1% Missing Cold Resistance, up to a maximum of 300% {range:1}(15-20)% increased Fire Damage per 1% Missing Fire Resistance, up to a maximum of 300%]]) build.itemsTab:AddDisplayItem() - build.skillsTab:PasteSocketGroup("Slot: Weapon 1\nFireball 20/0 Default 1\n") + build.skillsTab:PasteSocketGroup("Slot: Weapon 1\nFireball 20/0 1\n") runCallback("OnFrame") assert.are_not.equals(340, build.calcsTab.mainEnv.modDB:Sum("INC", "FireDamage")) diff --git a/src/Classes/ImportTab.lua b/src/Classes/ImportTab.lua index b94cc52731..f6ea8cc44c 100644 --- a/src/Classes/ImportTab.lua +++ b/src/Classes/ImportTab.lua @@ -5,6 +5,7 @@ -- local ipairs = ipairs local t_insert = table.insert +local t_remove = table.remove local b_rshift = bit.rshift local band = bit.band local m_max = math.max @@ -815,6 +816,97 @@ function ImportTabClass:ImportPassiveTreeAndJewels(charData) main:SetWindowTitleSubtext(string.format("%s (%s, %s, %s)", self.build.buildName, charData.name, charData.class, charData.league)) end +local SOCKET_GROUP_REIMPORT_KEY_SEPARATOR = "\31" + +local function getSocketGroupReimportKey(socketGroup) + -- Use a rarely-used separator to avoid accidental collisions when concatenating fields. + local gemNameParts = { } + for _, gem in ipairs(socketGroup.gemList) do + t_insert(gemNameParts, (gem.nameSpec or ""):lower()) + end + return table.concat({ + tostring(#socketGroup.gemList), + table.concat(gemNameParts, SOCKET_GROUP_REIMPORT_KEY_SEPARATOR), + }, SOCKET_GROUP_REIMPORT_KEY_SEPARATOR) +end + +local function snapshotSocketGroupReimportState(socketGroup, isMainGroup) + local gemStates = { } + for gemIndex, gem in ipairs(socketGroup.gemList) do + gemStates[gemIndex] = { + enabled = gem.enabled, + count = gem.count, + statSet = gem.statSet and copyTable(gem.statSet), + statSetCalcs = gem.statSetCalcs and copyTable(gem.statSetCalcs), + skillPart = gem.skillPart, + skillPartCalcs = gem.skillPartCalcs, + skillStageCount = gem.skillStageCount, + skillStageCountCalcs = gem.skillStageCountCalcs, + skillMineCount = gem.skillMineCount, + skillMineCountCalcs = gem.skillMineCountCalcs, + skillMinion = gem.skillMinion, + skillMinionCalcs = gem.skillMinionCalcs, + skillMinionItemSet = gem.skillMinionItemSet, + skillMinionItemSetCalcs = gem.skillMinionItemSetCalcs, + skillMinionSkill = gem.skillMinionSkill, + skillMinionSkillCalcs = gem.skillMinionSkillCalcs, + skillMinionSkillStatSetIndexLookup = gem.skillMinionSkillStatSetIndexLookup and copyTable(gem.skillMinionSkillStatSetIndexLookup), + skillMinionSkillStatSetIndexLookupCalcs = gem.skillMinionSkillStatSetIndexLookupCalcs and copyTable(gem.skillMinionSkillStatSetIndexLookupCalcs), + enableGlobal1 = gem.enableGlobal1, + enableGlobal2 = gem.enableGlobal2, + } + end + return { + enabled = socketGroup.enabled, + includeInFullDPS = socketGroup.includeInFullDPS, + groupCount = socketGroup.groupCount, + label = socketGroup.label, + mainActiveSkill = socketGroup.mainActiveSkill, + mainActiveSkillCalcs = socketGroup.mainActiveSkillCalcs, + gemStates = gemStates, + isMainGroup = isMainGroup, + } +end + +local function applyGemReimportState(gem, state) + gem.enabled = state.enabled + gem.count = state.count + gem.statSet = state.statSet and copyTable(state.statSet) + gem.statSetCalcs = state.statSetCalcs and copyTable(state.statSetCalcs) + gem.skillPart = state.skillPart + gem.skillPartCalcs = state.skillPartCalcs + gem.skillStageCount = state.skillStageCount + gem.skillStageCountCalcs = state.skillStageCountCalcs + gem.skillMineCount = state.skillMineCount + gem.skillMineCountCalcs = state.skillMineCountCalcs + gem.skillMinion = state.skillMinion + gem.skillMinionCalcs = state.skillMinionCalcs + gem.skillMinionItemSet = state.skillMinionItemSet + gem.skillMinionItemSetCalcs = state.skillMinionItemSetCalcs + gem.skillMinionSkill = state.skillMinionSkill + gem.skillMinionSkillCalcs = state.skillMinionSkillCalcs + gem.skillMinionSkillStatSetIndexLookup = state.skillMinionSkillStatSetIndexLookup and copyTable(state.skillMinionSkillStatSetIndexLookup) + gem.skillMinionSkillStatSetIndexLookupCalcs = state.skillMinionSkillStatSetIndexLookupCalcs and copyTable(state.skillMinionSkillStatSetIndexLookupCalcs) + gem.enableGlobal1 = state.enableGlobal1 + gem.enableGlobal2 = state.enableGlobal2 +end + +local function applySocketGroupReimportState(socketGroup, state) + socketGroup.enabled = state.enabled + socketGroup.includeInFullDPS = state.includeInFullDPS + socketGroup.groupCount = state.groupCount + socketGroup.label = state.label + socketGroup.mainActiveSkill = state.mainActiveSkill + socketGroup.mainActiveSkillCalcs = state.mainActiveSkillCalcs + if state.gemStates then + for gemIndex, gemState in ipairs(state.gemStates) do + if socketGroup.gemList[gemIndex] then + applyGemReimportState(socketGroup.gemList[gemIndex], gemState) + end + end + end +end + function ImportTabClass:ImportItemsAndSkills(charData) local charItemData = charData.equipment if self.controls.charImportItemsClearItems.state then @@ -827,8 +919,10 @@ function ImportTabClass:ImportItemsAndSkills(charData) local mainSkillEmpty = #self.build.skillsTab.socketGroupList == 0 local skillOrder + local preservedSocketGroupStateByKey if self.controls.charImportItemsClearSkills.state then skillOrder = { } + preservedSocketGroupStateByKey = { } for _, socketGroup in ipairs(self.build.skillsTab.socketGroupList) do for _, gem in ipairs(socketGroup.gemList) do if gem.grantedEffect and not gem.grantedEffect.support then @@ -836,6 +930,11 @@ function ImportTabClass:ImportItemsAndSkills(charData) end end end + for index, socketGroup in ipairs(self.build.skillsTab.socketGroupList) do + local key = getSocketGroupReimportKey(socketGroup) + preservedSocketGroupStateByKey[key] = preservedSocketGroupStateByKey[key] or { } + t_insert(preservedSocketGroupStateByKey[key], snapshotSocketGroupReimportState(socketGroup, index == self.build.mainSocketGroup)) + end wipeTable(self.build.skillsTab.socketGroupList) end self.charImportStatus = colorCodes.POSITIVE.."Items and skills successfully imported." @@ -1010,6 +1109,22 @@ function ImportTabClass:ImportItemsAndSkills(charData) end end) end + if preservedSocketGroupStateByKey then + local restoredMainSocketGroup + for index, socketGroup in ipairs(self.build.skillsTab.socketGroupList) do + local stateList = preservedSocketGroupStateByKey[getSocketGroupReimportKey(socketGroup)] + if stateList and stateList[1] then + local state = t_remove(stateList, 1) + applySocketGroupReimportState(socketGroup, state) + if state.isMainGroup then + restoredMainSocketGroup = index + end + end + end + if restoredMainSocketGroup then + self.build.mainSocketGroup = restoredMainSocketGroup + end + end if mainSkillEmpty then self.build.mainSocketGroup = self:GuessMainSocketGroup() end