diff --git a/spec/System/TestItemMods_spec.lua b/spec/System/TestItemMods_spec.lua index 3aa079e67b9..9353baa6789 100644 --- a/spec/System/TestItemMods_spec.lua +++ b/spec/System/TestItemMods_spec.lua @@ -7,6 +7,776 @@ describe("TetsItemMods", function() -- newBuild() takes care of resetting everything in setup() end) + local function createBaseChangeRing() + build.itemsTab:CreateDisplayItemFromRaw([[ + Rarity: Rare + Test Ring + Coral Ring + Crafted: true + Prefix: None + Prefix: None + Prefix: None + Suffix: None + Suffix: None + Suffix: None + Implicits: 1 + +(20-30) to maximum Life + ]]) + end + + it("removes structured affixes that cannot spawn on a changed base", function() + build.itemsTab:CreateDisplayItemFromRaw([[ + Rarity: Rare + Test Boots + Sorcerer Boots + Crafted: true + Prefix: {range:0.25}LocalIncreasedEnergyShield6 + Prefix: {range:0.75}IncreasedLife6 + Prefix: None + Suffix: None + Suffix: None + Suffix: None + Quality: 20 + Sockets: B-B-B-B + LevelReq: 67 + Implicits: 0 + +33 to maximum Energy Shield + +72 to maximum Life + ]]) + local source = build.itemsTab.displayItem + local targetBase = { name = "Iron Greaves", base = build.data.itemBases["Iron Greaves"] } + local candidate, removedAffixes = build.itemsTab:CreateBaseChangeCandidate(source, targetBase) + + assert.is_not_nil(candidate) + assert.are.equals("Iron Greaves", candidate.baseName) + assert.are.equals(1, #removedAffixes) + assert.is_truthy(removedAffixes[1].label:find("maximum Energy Shield", 1, true)) + assert.are.equals("None", candidate.prefixes[1].modId) + assert.are.equals("IncreasedLife6", candidate.prefixes[2].modId) + assert.are.equals(0.75, candidate.prefixes[2].range) + assert.is_nil(candidate.armourData.EnergyShieldBasePercentile) + assert.are.equals(0, candidate.armourData.EnergyShield) + assert.is_true(candidate.armourData.Armour > 0) + end) + + it("refreshes compatibility and applies an unsaved item when changing armour subtype", function() + build.itemsTab:CreateDisplayItemFromRaw([[ + Rarity: Rare + New Item + Conquest Lamellar + Crafted: true + Prefix: {range:0.5}LocalBaseArmourAndEvasionRating5 + Prefix: {range:1}LocalIncreasedArmourAndEvasion4 + Prefix: {range:1}LocalBaseArmourAndLife2 + Suffix: {range:0.5}Dexterity5 + Suffix: {range:0.5}Strength5 + Suffix: {range:0.5}ChanceToSuppressSpellsHigh3 + Quality: 20 + Sockets: G=G=G=G=G=G + LevelReq: 84 + Implicits: 0 + +116 to Armour + +116 to Evasion Rating + 67% increased Armour and Evasion + +48 to Armour + +28 to maximum Life + +30 to Dexterity + +30 to Strength + +15% chance to Suppress Spell Damage + ]]) + local source = build.itemsTab.displayItem + build.itemsTab:ChangeDisplayItemBase() + local popup = main.popups[1] + local controls = popup.controls + local initialPopupY = popup:GetProperty("y") + local initialPopupHeight = popup:GetProperty("height") + local energyShieldTypeIndex + for index, typeName in ipairs(controls.type.list) do + if typeName == "Body Armour: Energy Shield" then + energyShieldTypeIndex = index + break + end + end + + assert.is_not_nil(energyShieldTypeIndex) + local typeCount = #controls.type.list + local selectedType = controls.type.selValue + controls.search:SetText("Regalia", true) + assert.are.equals(typeCount, #controls.type.list) + assert.are.equals(selectedType, controls.type.selValue) + assert.are.equals(0, #controls.base.list) + assert.are.equals("^x7F7F7F", controls.base.defaultText) + assert.is_truthy(controls.status:GetProperty("label"):find("Select a base", 1, true)) + assert.is_false(controls.otherStatus:IsShown()) + assert.is_falsy(controls.save:IsEnabled()) + controls.type:SelectIndex(energyShieldTypeIndex) + assert.is_true(#controls.base.list > 1) + for _, baseEntry in ipairs(controls.base.list) do + assert.is_truthy(baseEntry.name:find("Regalia", 1, true)) + end + controls.search:SetText("", true) + assert.are.equals("Body Armour: Energy Shield", controls.type.selValue) + assert.are.equals("Twilight Regalia", controls.base.selValue.name) + assert.is_false(controls.implicitStatus:IsShown()) + assert.is_truthy(controls.status:GetProperty("label"):find("Explicits:", 1, true)) + assert.is_truthy(controls.status:GetProperty("label"):find("6 incompatible modifiers will be removed", 1, true)) + assert.is_truthy(controls.removedAffix1:GetProperty("label"):find("(Prefix)", 1, true)) + assert.is_truthy(controls.removedAffix6:GetProperty("label"):find("(Suffix)", 1, true)) + assert.is_truthy(controls.otherStatus:GetProperty("label"):find("Other:", 1, true)) + local baseX = controls.base:GetPos() + local explicitStatusX = controls.status:GetPos() + local otherStatusX = controls.otherStatus:GetPos() + local removedAffixX = controls.removedAffix1:GetPos() + assert.are.equals(baseX, explicitStatusX) + assert.are.equals(baseX, otherStatusX) + assert.are.equals(baseX + 15, removedAffixX) + assert.are.equals(initialPopupY, popup:GetProperty("y")) + assert.is_true(popup:GetProperty("height") > initialPopupHeight) + + local originalWrapString = main.WrapString + finally(function() + main.WrapString = originalWrapString + end) + local wrapCalls = 0 + main.WrapString = function(self, text, height, width) + wrapCalls = wrapCalls + 1 + local splitIndex = text:find(" ", math.floor(#text / 2)) + return splitIndex and { text:sub(1, splitIndex - 1), text:sub(splitIndex + 1) } or { text } + end + controls.base:SelectIndex(controls.base.selIndex) + local preparedWrapCalls = wrapCalls + popup:Draw({ x = 0, y = 0, width = 1920, height = 1080 }) + popup:Draw({ x = 0, y = 0, width = 1920, height = 1080 }) + assert.are.equals(preparedWrapCalls, wrapCalls) + local wrappedLabel = controls.removedAffix1:GetProperty("label") + local firstAffixY = controls.removedAffix1:GetProperty("y") + local secondAffixY = controls.removedAffix2:GetProperty("y") + local expandedPopupHeight = popup:GetProperty("height") + main.WrapString = originalWrapString + + assert.is_truthy(wrappedLabel:find("\n", 1, true)) + assert.is_true(secondAffixY > firstAffixY + 20) + assert.is_true(expandedPopupHeight > 310) + assert.are.equals(initialPopupY, popup:GetProperty("y")) + assert.is_true(controls.save:IsEnabled()) + controls.save.onClick() + + assert.are.equals("Twilight Regalia", build.itemsTab.displayItem.baseName) + assert.are.equals("Energy Shield", build.itemsTab.displayItem.base.subType) + for index = 1, 3 do + assert.are.equals("None", build.itemsTab.displayItem.prefixes[index].modId) + assert.are.equals("None", build.itemsTab.displayItem.suffixes[index].modId) + end + assert.are.equals("Conquest Lamellar", source.baseName) + end) + + it("shows type, base, and implicit columns with shared base scrolling", function() + createBaseChangeRing() + build.itemsTab:ChangeDisplayItemBase() + local popup = main.popups[1] + local controls = popup.controls + + assert.are.equals("Type", controls.type.colList[1].label) + assert.are.equals("Base", controls.base.colList[1].label) + assert.are.equals("Implicit", controls.base.colList[2].label) + + local implicitBaseIndex + for index, baseEntry in ipairs(controls.base.list) do + if baseEntry.base.implicit then + implicitBaseIndex = index + break + end + end + assert.is_not_nil(implicitBaseIndex) + local baseEntry = controls.base.list[implicitBaseIndex] + assert.are.equals(baseEntry.name, controls.base:GetRowValue(1, implicitBaseIndex, baseEntry)) + assert.are.equals(baseEntry.base.implicit:gsub("\n", " / "), controls.base:GetRowValue(2, implicitBaseIndex, baseEntry)) + + controls.search:SetText("Ruby Ring", true) + assert.are.equals(1, #controls.base.list) + assert.are.equals("Ruby Ring", controls.base.selValue.name) + controls.search:SetText("Fire Resistance", true) + assert.is_true(#controls.base.list > 0) + for _, filteredBase in ipairs(controls.base.list) do + assert.is_truthy((filteredBase.base.implicit or ""):find("Fire Resistance", 1, true)) + end + controls.search:SetText("", true) + + popup:SelectControl(controls.base) + local originalIsKeyDown = IsKeyDown + finally(function() + _G.IsKeyDown = originalIsKeyDown + end) + _G.IsKeyDown = function(key) + return key == "CTRL" + end + popup:ProcessInput({ { type = "KeyDown", key = "f" } }, { x = 0, y = 0, width = 1920, height = 1080 }) + assert.are.equal(controls.search, popup.selControl) + assert.is_true(controls.search.hasFocus) + _G.IsKeyDown = originalIsKeyDown + + local selectedType = controls.type.selValue + for _, filteredBase in ipairs(controls.base.list) do + local belongsToSelectedType = false + for _, typeBase in ipairs(build.data.itemBaseLists[selectedType]) do + if filteredBase == typeBase then + belongsToSelectedType = true + break + end + end + assert.is_true(belongsToSelectedType) + end + main:ClosePopup() + end) + + it("keeps the damage search applied across consecutive shield type clicks", function() + build.itemsTab:CreateDisplayItemFromRaw([[ + Rarity: Rare + Test Shield + Titanium Spirit Shield + Crafted: true + Implicits: 0 + ]]) + build.itemsTab:ChangeDisplayItemBase() + local popup = main.popups[1] + local controls = popup.controls + local viewPort = { x = 0, y = 0, width = 1920, height = 1080 } + local originalGetCursorPos = GetCursorPos + finally(function() + _G.GetCursorPos = originalGetCursorPos + main:ClosePopup() + end) + assert.are.equals("Shield: Energy Shield", controls.type.selValue) + controls.search:SetText("damage", true) + for _, subType in ipairs({ "Evasion", "Evasion/Energy Shield", "Evasion" }) do + local index = isValueInArray(controls.type.list, "Shield: " .. subType) + assert.is_not_nil(index) + local x, y = controls.type:GetPos() + local rowRegion = controls.type:GetRowRegion() + _G.GetCursorPos = function() + return x + rowRegion.x + 10, y + rowRegion.y + (index - 0.5) * controls.type.rowHeight - controls.type.controls.scrollBarV.offset + end + popup:ProcessInput({ { type = "KeyDown", key = "LEFTBUTTON" }, { type = "KeyUp", key = "LEFTBUTTON" } }, viewPort) + assert.are.equals("Shield: " .. subType, controls.type.selValue) + assert.is_true(#controls.base.list > 0) + for _, entry in ipairs(controls.base.list) do + assert.are.equals(subType, entry.base.subType) + assert.is_truthy(entry.base.implicit:lower():find("damage", 1, true)) + end + end + assert.are.equals(controls.type, popup.selControl) + end) + + it("scrolls the hovered base once regardless of focus and keeps page keys on the focused list", function() + createBaseChangeRing() + build.itemsTab:ChangeDisplayItemBase() + local popup = main.popups[1] + local list = popup.controls.base + local scrollBar = list.controls.scrollBarV + local x, y = list:GetPos() + local originalGetCursorPos = GetCursorPos + finally(function() + _G.GetCursorPos = originalGetCursorPos + main:ClosePopup() + end) + _G.GetCursorPos = function() return x + 30, y + 40 end + local viewPort = { x = 0, y = 0, width = 1920, height = 1080 } + list:Draw(viewPort, true) + for _, focus in ipairs({ "search", "base", "none" }) do + popup:SelectControl(popup.controls[focus]) + for _, key in ipairs({ "WHEELDOWN", "WHEELUP" }) do + scrollBar:SetOffset(80) + popup:ProcessInput({ { type = "KeyUp", key = key } }, viewPort) + assert.are.equals(key == "WHEELDOWN" and 120 or 40, scrollBar.offset, focus) + end + end + popup:SelectControl(list) + _G.GetCursorPos = function() return 0, 0 end + popup:ProcessInput({ { type = "KeyUp", key = "PAGEDOWN" } }, viewPort) + assert.are.equals(80, scrollBar.offset) + end) + + it("replaces native implicits while preserving custom modifier text", function() + build.itemsTab:CreateDisplayItemFromRaw([[ + Rarity: Rare + Test Ring + Coral Ring + Unique ID: imported-item-id + Crafted: true + Prefix: None + Prefix: None + Prefix: None + Suffix: None + Suffix: None + Suffix: None + Item Level: 84 + Implicits: 2 + +(20-30) to maximum Life + {custom}+1 to Maximum Power Charges + {custom}+10 to Intelligence + ]]) + local source = build.itemsTab.displayItem + source.id = 42 + local targetBase = { name = "Ruby Ring", base = build.data.itemBases["Ruby Ring"] } + build.itemsTab:ChangeDisplayItemBase() + local controls = main.popups[1].controls + assert.is_false(controls.implicitStatus:IsShown()) + main:ClosePopup() + local candidate, removedAffixes, removedInfluences, removedOtherMods = build.itemsTab:CreateBaseChangeCandidate(source, targetBase) + + assert.is_not_nil(candidate) + assert.are.equals("Ruby Ring", candidate.baseName) + assert.are.equals(42, candidate.id) + assert.are.equals(0, #removedAffixes) + assert.are.equals(0, #removedInfluences) + assert.are.equals(0, #removedOtherMods) + assert.is_nil(candidate.uniqueID) + assert.are.equals("+(20-30)% to Fire Resistance", candidate.implicitModLines[1].line) + assert.are.equals(2, #candidate.implicitModLines) + assert.are.equals("+1 to Maximum Power Charges", candidate.implicitModLines[2].line) + assert.is_true(candidate.implicitModLines[2].custom) + assert.are.equals("+10 to Intelligence", candidate.explicitModLines[1].line) + assert.is_true(candidate.explicitModLines[1].custom) + end) + + it("replaces native base implicits only when retaining Eldritch implicits", function() + local exarch = "{exarch}{range:0.25}Bone Offering has (6-7)% increased Effect" + local eater = "{eater}Regenerate 0.2% of Life per second per Endurance Charge" + local targetName = "Two-Toned Boots (Armour/Energy Shield)" + local target = { name = targetName, base = build.data.itemBases[targetName] } + for _, implicits in ipairs({ { }, { exarch }, { eater }, { exarch, eater } }) do + build.itemsTab:CreateDisplayItemFromRaw("Rarity: Rare\nTest Boots\nSorcerer Boots\nCrafted: true\nSearing Exarch Item\nEater of Worlds Item\nImplicits: " .. #implicits .. "\n" .. table.concat(implicits, "\n")) + local source = build.itemsTab.displayItem + local sourceRaw = source:BuildRaw() + local candidate = build.itemsTab:CreateBaseChangeCandidate(source, target) + assert.is_not_nil(candidate) + assert.are.equals(math.max(#implicits, 1), #candidate.implicitModLines) + if #implicits == 0 then + assert.are.equals(target.base.implicit, candidate.implicitModLines[1].line) + else + for index, implicit in ipairs(source.implicitModLines) do + local retained = candidate.implicitModLines[index] + assert.are.equals(implicit.line, retained.line) + assert.are.equals(implicit.range, retained.range) + assert.are.equals(implicit.exarch, retained.exarch) + assert.are.equals(implicit.eater, retained.eater) + end + local reloaded = new("Item"):Item(candidate:BuildRaw()) + local roundTrip = build.itemsTab:CreateBaseChangeCandidate(reloaded, { name = source.baseName, base = source.base }) + assert.is_not_nil(roundTrip) + assert.are.equals(#implicits, #roundTrip.implicitModLines) + -- A previously crafted item may still contain the old additive combination. + table.insert(reloaded.implicitModLines, 1, { line = target.base.implicit }) + reloaded:BuildAndParseRaw() + local corrected = build.itemsTab:CreateBaseChangeCandidate(reloaded, target) + assert.is_not_nil(corrected) + assert.are.equals(#implicits, #corrected.implicitModLines) + assert.are.equals(candidate:BuildRaw(), corrected:BuildRaw()) + end + assert.are.equals(sourceRaw, source:BuildRaw()) + end + end) + + it("shows the implicit replacement notice only for a selected base with native implicits", function() + build.itemsTab:CreateDisplayItemFromRaw([[ + Rarity: Rare + Test Boots + Sorcerer Boots + Crafted: true + Searing Exarch Item + Eater of Worlds Item + Implicits: 2 + {exarch}Bone Offering has (6-7)% increased Effect + {eater}Regenerate 0.2% of Life per second per Endurance Charge + ]]) + build.itemsTab:ChangeDisplayItemBase() + local controls = main.popups[1].controls + finally(function() main:ClosePopup() end) + assert.is_false(controls.implicitStatus:IsShown()) + controls.type:SelectIndex(isValueInArray(controls.type.list, "Boots: Armour/Energy Shield")) + for _, name in ipairs({ "Two-Toned Boots (Armour/Energy Shield)", "Soldier Boots", "Two-Toned Boots (Armour/Energy Shield)" }) do + for index, entry in ipairs(controls.base.list) do + if entry.name == name then + controls.base:SelectIndex(index) + break + end + end + assert.are.equals(name, controls.base.selValue.name) + assert.are.equals(name ~= "Soldier Boots", controls.implicitStatus:IsShown()) + end + controls.search:SetText("no matching boots", true) + assert.is_false(controls.implicitStatus:IsShown()) + controls.search:SetText("Two-Toned", true) + controls.save.onClick() + assert.are.equals(2, #build.itemsTab.displayItem.implicitModLines) + build.itemsTab:ChangeDisplayItemBase() + assert.is_false(main.popups[1].controls.implicitStatus:IsShown()) + end) + + it("removes every structured affix affected by special base rules", function() + build.itemsTab:CreateDisplayItemFromRaw([[ + Rarity: Rare + Test Ring + Coral Ring + Crafted: true + Prefix: {range:0.5}IncreasedLife6 + Prefix: None + Prefix: None + Suffix: {range:0.5}FireResist6 + Suffix: None + Suffix: None + Implicits: 1 + +(20-30) to maximum Life + +92 to maximum Life + +39% to Fire Resistance + ]]) + local ratchetingBase = { name = "Ratcheting Ring", base = build.data.itemBases["Ratcheting Ring"] } + local candidate, removedAffixes, _, removedOtherMods = build.itemsTab:CreateBaseChangeCandidate(build.itemsTab.displayItem, ratchetingBase) + + assert.is_true(removedAffixes.resetPrefixes) + assert.is_true(removedAffixes.resetSuffixes) + assert.are.equals(2, #removedAffixes) + assert.are.equals(0, #removedOtherMods) + assert.is_truthy(removedAffixes[1].label:find("(Prefix)", 1, true)) + assert.is_truthy(removedAffixes[2].label:find("(Suffix)", 1, true)) + assert.are.equals(0, #candidate.explicitModLines) + assert.are.equals(0, #candidate.prefixes) + assert.are.equals(6, #candidate.suffixes) + build.itemsTab:SetDisplayItem(candidate) + assert.is_true(build.itemsTab.controls.displayItemChangeBase:IsShown()) + + build.itemsTab:CreateDisplayItemFromRaw([[ + Rarity: Rare + Test Ring + Ratcheting Ring + Crafted: true + Suffix: {range:0.5}Strength5 + Suffix: None + Suffix: None + Suffix: None + Suffix: None + Suffix: None + Implicits: 3 + -3 Prefix Modifiers allowed + +3 Suffix Modifiers allowed + Implicit Modifiers Cannot Be Changed + +30 to Strength + ]]) + local coralBase = { name = "Coral Ring", base = build.data.itemBases["Coral Ring"] } + candidate, removedAffixes = build.itemsTab:CreateBaseChangeCandidate(build.itemsTab.displayItem, coralBase) + + assert.is_true(removedAffixes.resetPrefixes) + assert.is_true(removedAffixes.resetSuffixes) + assert.are.equals(1, #removedAffixes) + assert.are.equals(0, #candidate.explicitModLines) + end) + + it("shows special base slot resets as two status lines", function() + createBaseChangeRing() + build.itemsTab:ChangeDisplayItemBase() + local controls = main.popups[1].controls + for index, baseEntry in ipairs(controls.base.list) do + if baseEntry.name == "Ratcheting Ring" then + controls.base:SelectIndex(index) + break + end + end + + local status = controls.status:GetProperty("label") + assert.is_truthy(status:find("The new base changes prefix and suffix rules. All modifiers will be reset.\nThere are no modifiers in the affected slots.", 1, true)) + main:ClosePopup() + end) + + it("resets carried influences when either base grants influence", function() + build.itemsTab:CreateDisplayItemFromRaw([[ + Rarity: Rare + Test Amulet + Astrolabe Amulet + Crafted: true + Prefix: None + Prefix: None + Prefix: None + Suffix: None + Suffix: None + Suffix: None + Implicits: 2 + Implicit Modifiers Cannot Be Changed + Has Elder, Shaper and all Conqueror Influences + ]]) + local amberBase = { name = "Amber Amulet", base = build.data.itemBases["Amber Amulet"] } + local candidate, _, removedInfluences = build.itemsTab:CreateBaseChangeCandidate(build.itemsTab.displayItem, amberBase) + + assert.is_true(removedInfluences.reset) + assert.are.equals(6, #removedInfluences) + for _, influence in ipairs(itemLib.influenceInfo.default) do + assert.is_falsy(candidate[influence.key]) + end + + build.itemsTab:CreateDisplayItemFromRaw([[ + Rarity: Rare + Test Amulet + Amber Amulet + Searing Exarch Item + Crafted: true + Prefix: None + Prefix: None + Prefix: None + Suffix: None + Suffix: None + Suffix: None + Implicits: 1 + +(20-30) to Strength + ]]) + local astrolabeBase = { name = "Astrolabe Amulet", base = build.data.itemBases["Astrolabe Amulet"] } + candidate, _, removedInfluences = build.itemsTab:CreateBaseChangeCandidate(build.itemsTab.displayItem, astrolabeBase) + + assert.is_true(removedInfluences.reset) + assert.are.equals(1, #removedInfluences) + assert.are.equals("Searing Exarch", removedInfluences[1]) + assert.is_falsy(candidate.cleansing) + for _, influence in ipairs(itemLib.influenceInfo.default) do + assert.is_true(candidate[influence.key]) + end + + build.itemsTab:CreateDisplayItemFromRaw([[ + Rarity: Rare + Test Amulet + Amber Amulet + Crafted: true + Prefix: None + Prefix: None + Prefix: None + Suffix: None + Suffix: None + Suffix: None + Implicits: 1 + +(20-30) to Strength + ]]) + build.itemsTab:ChangeDisplayItemBase() + local controls = main.popups[1].controls + for index, baseEntry in ipairs(controls.base.list) do + if baseEntry.name == "Astrolabe Amulet" then + controls.base:SelectIndex(index) + break + end + end + assert.is_false(controls.otherStatus:IsShown()) + assert.is_false(controls.otherStatus2:IsShown()) + assert.is_false(controls.otherStatus3:IsShown()) + main:ClosePopup() + end) + + it("reports and removes unclassified explicit modifier text", function() + build.itemsTab:CreateDisplayItemFromRaw([[ + Rarity: Rare + Test Ring + Coral Ring + Crafted: true + Prefix: None + Prefix: None + Prefix: None + Suffix: None + Suffix: None + Suffix: None + Implicits: 1 + +(20-30) to maximum Life + 10% increased Damage + {custom}+10 to Intelligence + ]]) + local rubyBase = { name = "Ruby Ring", base = build.data.itemBases["Ruby Ring"] } + local candidate, removedAffixes, _, removedOtherMods = build.itemsTab:CreateBaseChangeCandidate(build.itemsTab.displayItem, rubyBase) + + assert.are.equals(0, #removedAffixes) + assert.are.same({ "10% increased Damage" }, removedOtherMods) + assert.are.equals(1, #candidate.explicitModLines) + + build.itemsTab:ChangeDisplayItemBase() + local controls = main.popups[1].controls + for index, baseEntry in ipairs(controls.base.list) do + if baseEntry.name == "Ruby Ring" then + controls.base:SelectIndex(index) + break + end + end + local otherStatus = controls.otherStatus:GetProperty("label") + local additionalOtherStatus = controls.otherStatus2:GetProperty("label") + assert.is_truthy(otherStatus:find("1 unclassified explicit modifier will be removed:\n", 1, true)) + assert.is_truthy(otherStatus:find("10% increased Damage", 1, true)) + assert.is_truthy(additionalOtherStatus:find("Modifiers added via 'Add Modifier' persist, but are not checked", 1, true)) + local _, otherStatusY = controls.otherStatus:GetPos() + local _, additionalOtherStatusY = controls.otherStatus2:GetPos() + assert.are.equals(otherStatusY + 2 * 20 + 7, additionalOtherStatusY) + main:ClosePopup() + end) + + it("hides base changing for unsupported, unique, hidden, and over-capacity items", function() + for _, baseName in ipairs({ "Small Life Flask", "Cobalt Jewel", "Prismatic Tincture", "Battering Uulgraft", "Cured Quiver" }) do + build.itemsTab:CreateDisplayItemFromRaw("Rarity: Rare\nTest Item\n" .. baseName .. "\nCrafted: true\nImplicits: 0") + assert.is_false(build.itemsTab.controls.displayItemChangeBase:IsShown(), baseName) + end + + build.itemsTab:CreateDisplayItemFromRaw([[ + Rarity: Unique + Test Ring + Coral Ring + Crafted: true + Implicits: 1 + +(20-30) to maximum Life + ]]) + assert.is_false(build.itemsTab.controls.displayItemChangeBase:IsShown()) + + build.itemsTab:CreateDisplayItemFromRaw([[ + Rarity: Rare + Test Ring + Coral Ring + Crafted: true + Prefix: None + Prefix: None + Prefix: None + Suffix: None + Suffix: None + Suffix: None + Implicits: 1 + +(20-30) to maximum Life + {custom}+1 Prefix Modifier allowed + ]]) + assert.is_false(build.itemsTab.controls.displayItemChangeBase:IsShown()) + end) + + it("rebuilds base-granted sockets when changing ring bases", function() + build.itemsTab:CreateDisplayItemFromRaw([[ + Rarity: Rare + Test Ring + Unset Ring + Crafted: true + Prefix: None + Prefix: None + Prefix: None + Suffix: None + Suffix: None + Suffix: None + Sockets: B + Implicits: 1 + Has 1 Socket + ]]) + local coralBase = { name = "Coral Ring", base = build.data.itemBases["Coral Ring"] } + local unsetBase = { name = "Unset Ring", base = build.data.itemBases["Unset Ring"] } + local coralRing = build.itemsTab:CreateBaseChangeCandidate(build.itemsTab.displayItem, coralBase) + local unsetRing = build.itemsTab:CreateBaseChangeCandidate(coralRing, unsetBase) + + assert.are.equals(0, #coralRing.sockets) + assert.are.equals(1, #unsetRing.sockets) + end) + + it("does not mutate the display item while the change-base popup is open", function() + createBaseChangeRing() + local source = build.itemsTab.displayItem + local sourceRaw = source:BuildRaw() + + assert.is_true(build.itemsTab.controls.displayItemChangeBase:IsShown()) + assert.are.equal(build.itemsTab.controls.displayItemAddCustom, build.itemsTab.controls.displayItemChangeBase.anchor.other) + assert.are.equals("TOPRIGHT", build.itemsTab.controls.displayItemChangeBase.anchor.otherPoint) + build.itemsTab:ChangeDisplayItemBase() + assert.is_not_nil(main.popups[1]) + main.popups[1].controls.cancel.onClick() + + assert.are.equal(source, build.itemsTab.displayItem) + assert.are.equals(sourceRaw, build.itemsTab.displayItem:BuildRaw()) + build.itemsTab:ChangeDisplayItemBase() + local controls = main.popups[1].controls + controls.search:SetText("Ruby Ring", true) + source.itemLevel = 99 + controls.save.onClick() + assert.are.equal(source, build.itemsTab.displayItem) + assert.is_falsy(controls.save:IsEnabled()) + assert.is_truthy(controls.status:GetProperty("label"):find("item changed", 1, true)) + main:ClosePopup() + end) + + it("builds each hovered base preview only once", function() + createBaseChangeRing() + build.itemsTab:ChangeDisplayItemBase() + local controls = main.popups[1].controls + local rubyRingIndex + for index, baseEntry in ipairs(controls.base.list) do + if baseEntry.name == "Ruby Ring" then + rubyRingIndex = index + break + end + end + assert.is_not_nil(rubyRingIndex) + + local originalCreateCandidate = build.itemsTab.CreateBaseChangeCandidate + local candidateBuildCount = 0 + finally(function() + build.itemsTab.CreateBaseChangeCandidate = originalCreateCandidate + end) + build.itemsTab.CreateBaseChangeCandidate = function(self, ...) + candidateBuildCount = candidateBuildCount + 1 + return originalCreateCandidate(self, ...) + end + local tooltip = new("Tooltip"):Tooltip() + local rubyRing = controls.base.list[rubyRingIndex] + controls.base:AddValueTooltip(tooltip, rubyRingIndex, rubyRing) + controls.base:AddValueTooltip(tooltip, rubyRingIndex, rubyRing) + + assert.are.equals(1, candidateBuildCount) + main:ClosePopup() + end) + + it("keeps base changes isolated until save and preserves identity through undo and reload", function() + createBaseChangeRing() + local storedItem = build.itemsTab.displayItem + build.itemsTab:AddDisplayItem() + local itemId = storedItem.id + local source = new("Item"):Item(storedItem:BuildRaw()) + source.id = itemId + build.itemsTab:SetDisplayItem(source) + local sourceRaw = source:BuildRaw() + build.itemsTab:ChangeDisplayItemBase() + local controls = main.popups[1].controls + local rubyRingIndex + for index, baseEntry in ipairs(controls.base.list) do + if baseEntry.name == "Ruby Ring" then + rubyRingIndex = index + break + end + end + + assert.is_not_nil(rubyRingIndex) + controls.base:SelectIndex(rubyRingIndex) + assert.is_true(controls.save:IsEnabled()) + controls.save.onClick() + + assert.are.equals("Coral Ring", build.itemsTab.items[itemId].baseName) + assert.are.equals("Ruby Ring", build.itemsTab.displayItem.baseName) + assert.is_true(build.itemsTab.controls.displayItemChangeBase:IsShown()) + assert.are.equals("Coral Ring", source.baseName) + assert.are.equals(sourceRaw, source:BuildRaw()) + + build.itemsTab:ChangeDisplayItemBase() + controls = main.popups[1].controls + for index, baseEntry in ipairs(controls.base.list) do + if baseEntry.name == "Sapphire Ring" then + controls.base:SelectIndex(index) + break + end + end + controls.save.onClick() + assert.are.equals("Sapphire Ring", build.itemsTab.displayItem.baseName) + assert.is_true(build.itemsTab.controls.displayItemChangeBase:IsShown()) + build.itemsTab:AddDisplayItem() + assert.are.equals("Sapphire Ring", build.itemsTab.items[itemId].baseName) + assert.are.equals(itemId, build.itemsTab.slots["Ring 1"].selItemId) + build.itemsTab:Undo() + assert.are.equals("Coral Ring", build.itemsTab.items[itemId].baseName) + build.itemsTab:Redo() + assert.are.equals("Sapphire Ring", build.itemsTab.items[itemId].baseName) + loadBuildFromXML(build:SaveDB("code")) + assert.are.equals("Sapphire Ring", build.itemsTab.items[itemId].baseName) + assert.are.equals(itemId, build.itemsTab.slots["Ring 1"].selItemId) + end) + it("shows versioned reusable variant groups", function() build.itemsTab:CreateDisplayItemFromRaw([[ Rarity: Unique diff --git a/src/Classes/ItemBaseListControl.lua b/src/Classes/ItemBaseListControl.lua new file mode 100644 index 00000000000..536193599c6 --- /dev/null +++ b/src/Classes/ItemBaseListControl.lua @@ -0,0 +1,102 @@ +-- Path of Building +-- +-- Class: Item Base List Control +-- List control for selecting item base types and bases. +-- + +local t_insert = table.insert + +local headerFontSize = 16 +local bodyFontSize = 14 +local implicitTextOffset = 8 +local headerHeight = 20 + +---@class ItemBaseListControl: ListControl +local ItemBaseListClass = newClass("ItemBaseListControl", "ListControl") + +---@param anchor Anchor? +---@param rect Rect +---@param list table +---@param listMode "TYPE"|"BASE" +---@param baseColumnWidth number? +---@param onSelectCallback function? +---@param addValueTooltipCallback function? +function ItemBaseListClass:ItemBaseListControl(anchor, rect, list, listMode, baseColumnWidth, onSelectCallback, addValueTooltipCallback) + self:ListControl(anchor, rect, 20, "VERTICAL", false, list) + self.listMode = listMode + self.onSelectCallback = onSelectCallback + self.addValueTooltipCallback = addValueTooltipCallback + self.colLabels = true + self.colLabelHeight = headerHeight + self.showRowSeparators = true + + local contentWidth = rect[3] - 20 + if listMode == "TYPE" then + self.colList = { + { label = "Type", width = contentWidth, headerFontSize = headerFontSize, fontSize = bodyFontSize }, + } + elseif listMode == "BASE" then + assert(baseColumnWidth, "Base mode requires a base column width") + self.defaultText = "^x7F7F7F" + self.forceTooltip = true + self.tooltipAnchorFullRow = true + self.colList = { + { label = "Base", width = baseColumnWidth, headerFontSize = headerFontSize, fontSize = bodyFontSize }, + { label = "Implicit", width = contentWidth - baseColumnWidth, textOffset = implicitTextOffset, headerFontSize = headerFontSize, fontSize = bodyFontSize }, + } + else + error("Invalid item base list mode: " .. tostring(listMode)) + end + return self +end + +function ItemBaseListClass:GetRowValue(column, index, value) + if self.listMode == "TYPE" then + return value + elseif column == 1 then + return value.name + end + + local implicitLines = { } + if value.base.implicit then + for line in value.base.implicit:gmatch("[^\n]+") do + t_insert(implicitLines, line) + end + end + return #implicitLines > 0 and table.concat(implicitLines, " / ") or "None" +end + +function ItemBaseListClass:OnSelect(index, value) + if self.onSelectCallback then + self.onSelectCallback(index, value) + end +end + +-- Wheel input is handled on hover, including while the search field has focus. +function ItemBaseListClass:OnKeyUp(key) + if key == "WHEELDOWN" or key == "WHEELUP" then + return self + end + return self.ListControl.OnKeyUp(self, key) +end + +function ItemBaseListClass:OnHoverKeyUp(key) + if key == "WHEELDOWN" or key == "WHEELUP" then + self.ListControl.OnKeyUp(self, key) + end +end + +function ItemBaseListClass:AddValueTooltip(tooltip, index, value) + if self.addValueTooltipCallback then + self.addValueTooltipCallback(tooltip, index, value) + else + tooltip:Clear(true) + end +end + +function ItemBaseListClass:SetList(list) + self.list = list + self.selIndex = nil + self.selValue = nil + self.controls.scrollBarV:SetOffset(0) +end diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 0b3852aba58..9d121983a36 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -85,6 +85,277 @@ local function buildModSortList() return sortList, sortStats end +-- Families with dedicated affix pools or special state need their own conversion contract. +-- Keep this list in sync if a future item family stops using the shared Item affix pool. +local baseChangeUnsupportedTypes = { + Flask = true, + Graft = true, + Jewel = true, + Tincture = true, +} + +local armourBaseTypes = { + { key = "Armour", min = "ArmourBaseMin", max = "ArmourBaseMax" }, + { key = "Evasion", min = "EvasionBaseMin", max = "EvasionBaseMax" }, + { key = "EnergyShield", min = "EnergyShieldBaseMin", max = "EnergyShieldBaseMax" }, + { key = "Ward", min = "WardBaseMin", max = "WardBaseMax" }, +} + +local function getBaseImplicitLines(base) + local lines = { } + if base and base.implicit then + for line in base.implicit:gmatch("[^\n]+") do + t_insert(lines, line) + end + end + return lines +end + +local function getBaseChangeImplicitInfo(item) + local hasEldritchImplicits + for _, modLine in ipairs(item.implicitModLines) do + if modLine.exarch or modLine.eater then + hasEldritchImplicits = true + break + end + end + local nativeCount = 0 + for index, line in ipairs(getBaseImplicitLines(item.base)) do + local modLine = item.implicitModLines[index] + if not modLine or modLine.line ~= line or modLine.exarch or modLine.eater then + -- Eldritch implicits replace native ones; older crafted items may still have both. + return hasEldritchImplicits and nativeCount or nil, hasEldritchImplicits + end + nativeCount = index + end + return nativeCount, hasEldritchImplicits +end + +local function filterItemBaseLists(typeList, baseLists, searchText) + local search = searchText:lower():match("^%s*(.-)%s*$") + if search == "" then + return baseLists + end + + local filteredBaseLists = { } + for _, typeName in ipairs(typeList) do + local matchingBases = { } + for _, baseEntry in ipairs(baseLists[typeName]) do + local implicitLines = getBaseImplicitLines(baseEntry.base) + local implicitText = #implicitLines > 0 and table.concat(implicitLines, " / ") or "None" + if baseEntry.name:lower():find(search, 1, true) or implicitText:lower():find(search, 1, true) then + t_insert(matchingBases, baseEntry) + end + end + filteredBaseLists[typeName] = matchingBases + end + return filteredBaseLists +end + +local function buildBaseImplicitModLines(base, sourceItem) + local sourceImplicitById = { } + if sourceItem and sourceItem.base and sourceItem.base.implicitIds then + for index, modId in ipairs(sourceItem.base.implicitIds) do + sourceImplicitById[modId] = sourceItem.implicitModLines[index] + end + end + + local implicitModLines = { } + for implicitIndex, line in ipairs(getBaseImplicitLines(base)) do + local modList, extra = modLib.parseMod(line) + local modLine = { + line = line, + extra = extra, + modList = modList or { }, + modTags = base.implicitModTypes and base.implicitModTypes[implicitIndex] or { }, + } + local modId = base.implicitIds and base.implicitIds[implicitIndex] + local sourceImplicit = modId and sourceImplicitById[modId] + if sourceImplicit then + modLine.range = type(sourceImplicit.range) == "table" and copyTable(sourceImplicit.range) or sourceImplicit.range + modLine.valueScalar = sourceImplicit.valueScalar + modLine.corruptedRange = sourceImplicit.corruptedRange + modLine.disabled = sourceImplicit.disabled + end + t_insert(implicitModLines, modLine) + end + return implicitModLines +end + +local function baseHasArmourType(base, armourType) + return base and base.armour and ((base.armour[armourType.min] or 0) > 0 or (base.armour[armourType.max] or 0) > 0) +end + +local function getBaseAffixPool(base) + return base and ((base.subType and data.itemMods[base.type .. base.subType]) or data.itemMods[base.type] or data.itemMods.Item) +end + +local function getSpecialBaseChangeRules(base) + local rules = { resetPrefixes = false, resetSuffixes = false, resetInfluences = false } + for _, line in ipairs(getBaseImplicitLines(base)) do + local lineLower = line:lower() + if lineLower:match("prefix modifiers? allowed") or lineLower:match("prefix modifier magnitudes") or lineLower:match("effect of prefixes") then + rules.resetPrefixes = true + end + if lineLower:match("suffix modifiers? allowed") or lineLower:match("suffix modifier magnitudes") or lineLower:match("effect of suffixes") then + rules.resetSuffixes = true + end + if lineLower:match("explicit modifier magnitudes") then + rules.resetPrefixes = true + rules.resetSuffixes = true + end + if lineLower == "has elder, shaper and all conqueror influences" then + rules.resetInfluences = true + end + end + return rules +end + +local function isSupportedBaseChangeTarget(sourceItem, targetBase) + return targetBase + and sourceItem.base.type == targetBase.type + and not baseChangeUnsupportedTypes[targetBase.type] + and not targetBase.hidden + and not targetBase.enchant + and not targetBase.cannotBeAnointed + and sourceItem.affixes == getBaseAffixPool(targetBase) +end + +local function hasAlternativeBaseChangeTarget(item, itemData) + if not itemData then + return true + end + local sourceAvailable + local alternativeAvailable + for _, typeName in ipairs(itemData.itemBaseTypeList) do + for _, baseEntry in ipairs(itemData.itemBaseLists[typeName]) do + if isSupportedBaseChangeTarget(item, baseEntry.base) then + if baseEntry.name == item.baseName then + sourceAvailable = true + else + alternativeAvailable = true + end + if sourceAvailable and alternativeAvailable then + return true + end + end + end + end + return false +end + +local function getBaseChangeEligibility(item, itemData) + if not item or not item.base or not item.baseName then + return false, "The item does not have a supported base." + elseif not item.crafted or (item.rarity ~= "MAGIC" and item.rarity ~= "RARE") then + return false, "Base changing is available for PoB-crafted Magic and Rare items." + elseif item.isUnique or item.rareLikeUnique or item.foilType or item.variantList or item.usesVariantGroups then + return false, "Unique and variant-driven items cannot change base." + elseif baseChangeUnsupportedTypes[item.type] then + return false, "This item family needs special base-conversion rules." + elseif item.base.hidden then + return false, "Legacy bases that are not in the base selector cannot change base." + elseif (item.affixLimit or 0) > 6 or #(item.prefixes or { }) + #(item.suffixes or { }) > 6 then + return false, "Items with more than six structured modifier slots cannot change base." + elseif item.corrupted or item.scourge or item.mirrored or item.split or item.synthesised or item.vestigial or item.foulborn then + return false, "Corrupted, mirrored, split, synthesised, vestigial, and foulborn items cannot change base." + elseif item.base.enchant or item.base.cannotBeAnointed then + return false, "Bases with built-in enchantment rules cannot change base." + end + + if getBaseChangeImplicitInfo(item) == nil then + return false, "The item's native implicit modifiers cannot be identified safely." + end + if not hasAlternativeBaseChangeTarget(item, itemData) then + return false, "No compatible replacement bases are available." + end + return true +end + +local function getAffixExtraTags(item, outputTable, outputIndex) + local extraTags = { } + for _, tableName in ipairs({ "prefixes", "suffixes" }) do + local list = item[tableName] + for index = 1, (list.limit or (item.affixLimit / 2)) do + if tableName ~= outputTable or index ~= outputIndex then + local affix = list[index] + local mod = affix and item.affixes[affix.modId] + if mod and mod.tags then + for _, tag in ipairs(mod.tags) do + extraTags[tag] = true + end + end + end + end + end + return extraTags +end + +local function describeBaseChangeAffix(item, tableName, affix) + local mod = item.affixes and item.affixes[affix.modId] + local lines = { } + if mod then + for _, line in ipairs(mod) do + t_insert(lines, itemLib.applyRange(line, affix.range or 0.5)) + end + else + t_insert(lines, affix.modId) + end + return { + modId = affix.modId, + label = "(" .. (affix.fractured and "Fractured " or "") .. (tableName == "prefixes" and "Prefix" or "Suffix") .. ") " .. table.concat(lines, " / "), + } +end + +local function getUnclassifiedExplicitLines(item) + local normalizedItem = new("Item"):Item(item:BuildRaw()) + normalizedItem:Craft() + local structuredLineCounts = { } + for _, modLine in ipairs(normalizedItem.explicitModLines) do + if not modLine.custom and not modLine.crafted then + structuredLineCounts[modLine.line] = (structuredLineCounts[modLine.line] or 0) + 1 + end + end + + local unclassifiedLines = { } + for _, modLine in ipairs(item.explicitModLines) do + if not modLine.custom and not modLine.crafted then + local lineCount = structuredLineCounts[modLine.line] or 0 + if lineCount > 0 then + structuredLineCounts[modLine.line] = lineCount - 1 + else + t_insert(unclassifiedLines, modLine.line) + end + end + end + return unclassifiedLines +end + +local function removeStructuredAffixes(sourceItem, item, tableName, removedAffixes) + for index, affix in ipairs(sourceItem[tableName]) do + if affix.modId ~= "None" then + t_insert(removedAffixes, describeBaseChangeAffix(sourceItem, tableName, affix)) + end + item[tableName][index] = { modId = "None" } + end +end + +local function layoutBaseChangeLabel(control, text, width, y, color) + control.shown = text ~= nil + if not text then + return y + end + local lines = { } + for sourceLine in text:gmatch("[^\n]+") do + for _, line in ipairs(main:WrapString(sourceLine, 16, width)) do + t_insert(lines, (color or "") .. line) + end + end + control.label = table.concat(lines, "\n") + control.y = y + return y + m_max(#lines, 1) * 20 +end + ---@class ItemsTab: UndoHandler, ControlHost, Control ---@field displayItem Item? local ItemsTabClass = newClass("ItemsTab", "UndoHandler", "ControlHost", "Control") @@ -984,9 +1255,9 @@ holding Shift will put it in the second.]]) end -- Section: Custom modifiers - -- if either Custom or Crucible mod buttons are shown, create the control for the list of mods + -- if any customisation button is shown, create the control for the list of mods self.controls.displayItemSectionCustom = new("Control"):Control({"TOPLEFT",self.controls.displayItemSectionAffix,"BOTTOMLEFT",true}, {0, 0, 0, function() - return (self.controls.displayItemAddCustom:IsShown() or self.controls.displayItemAddCrucible:IsShown()) and 28 + self.displayItem.customCount * 22 or 0 + return (self.controls.displayItemAddCustom:IsShown() or self.controls.displayItemChangeBase:IsShown() or self.controls.displayItemAddCrucible:IsShown()) and 28 + self.displayItem.customCount * 22 or 0 end}) self.controls.displayItemSectionCustom.shown = function() return self.displayItem ~= nil @@ -997,11 +1268,22 @@ holding Shift will put it in the second.]]) self.controls.displayItemAddCustom.shown = function() return self.displayItem and (self.displayItem.rarity == "MAGIC" or self.displayItem.rarity == "RARE" or (self.displayItem.rareLikeUnique and self.displayItem.rareLikeUnique.supportsCustomModifiers)) end + self.controls.displayItemChangeBase = new("ButtonControl"):ButtonControl({"TOPLEFT",self.controls.displayItemAddCustom,"TOPRIGHT",true}, {8, 0, 120, 20}, "Change base...", function() + self:ChangeDisplayItemBase() + end) + self.controls.displayItemChangeBase.shown = function() + return getBaseChangeEligibility(self.displayItem, self.build.data) + end + self.controls.displayItemChangeBase.tooltipFunc = function(tooltip) + tooltip:Clear() + tooltip:AddLine(16, "^7Change this item's base while retaining compatible modifiers.") + end -- Section: Crucible modifiers - -- if the Add modifier button is not shown, take its place, otherwise move it to the right of it + -- Move to the right of the customisation buttons that are currently shown. self.controls.displayItemAddCrucible = new("ButtonControl"):ButtonControl({"TOPLEFT",self.controls.displayItemSectionCustom,"TOPLEFT"}, {function() - return (self.controls.displayItemAddCustom:IsShown() and 128) or 0 + local x = self.controls.displayItemAddCustom:IsShown() and 128 or 0 + return x + (self.controls.displayItemChangeBase:IsShown() and 128 or 0) end, 0, 150, 20}, "Add Crucible mod...", function() self:AddCrucibleModifierToDisplayItem() end) @@ -2216,7 +2498,7 @@ function ItemsTabClass:UpdateAffixControls() end function ItemsTabClass:UpdateAffixControl(control, item, affixType, outputTable, outputIndex, powerCache) - local extraTags = { } + local extraTags = getAffixExtraTags(item, outputTable, outputIndex) local excludeGroups = { } local allowDuplicateGroups = item.rareLikeUnique and item.rareLikeUnique.allowDuplicateGroups for _, table in ipairs({"prefixes","suffixes"}) do @@ -2227,11 +2509,6 @@ function ItemsTabClass:UpdateAffixControl(control, item, affixType, outputTable, if mod.group and not allowDuplicateGroups then excludeGroups[mod.group] = true end - if mod.tags then - for _, tag in ipairs(mod.tags) do - extraTags[tag] = true - end - end end end end @@ -2618,6 +2895,363 @@ function ItemsTabClass:OpenItemSetManagePopup() main:OpenPopup(630, 290, "Manage Item Sets", controls) end +function ItemsTabClass:CreateBaseChangeCandidate(sourceItem, targetBaseEntry) + local eligible, reason = getBaseChangeEligibility(sourceItem, self.build.data) + if not eligible then + return nil, nil, nil, nil, reason + elseif not targetBaseEntry or not isSupportedBaseChangeTarget(sourceItem, targetBaseEntry.base) then + return nil, nil, nil, nil, "The selected base is not compatible with this item." + end + + local targetBase = targetBaseEntry.base + local sourceBaseImplicitCount, hasEldritchImplicits = getBaseChangeImplicitInfo(sourceItem) + local sourceRules = getSpecialBaseChangeRules(sourceItem.base) + local targetRules = getSpecialBaseChangeRules(targetBase) + local resetPrefixes = sourceRules.resetPrefixes or targetRules.resetPrefixes + local resetSuffixes = sourceRules.resetSuffixes or targetRules.resetSuffixes + local resetInfluences = sourceRules.resetInfluences or targetRules.resetInfluences + local item = new("Item"):Item(sourceItem:BuildRaw()) + item.uniqueID = nil + local removedAffixes = { resetPrefixes = resetPrefixes, resetSuffixes = resetSuffixes } + if resetPrefixes then + removeStructuredAffixes(sourceItem, item, "prefixes", removedAffixes) + end + if resetSuffixes then + removeStructuredAffixes(sourceItem, item, "suffixes", removedAffixes) + end + local removedOtherMods = getUnclassifiedExplicitLines(sourceItem) + item.baseName = targetBaseEntry.name + item.implicitModLines = hasEldritchImplicits and { } or buildBaseImplicitModLines(targetBase, sourceItem) + for index = sourceBaseImplicitCount + 1, #sourceItem.implicitModLines do + t_insert(item.implicitModLines, copyTable(sourceItem.implicitModLines[index])) + end + + local removedInfluences = { reset = resetInfluences } + for _, curInfluenceInfo in ipairs(influenceInfo) do + if item[curInfluenceInfo.key] and (resetInfluences or not targetBase.influenceTags or not targetBase.influenceTags[curInfluenceInfo.key]) then + item[curInfluenceInfo.key] = false + t_insert(removedInfluences, curInfluenceInfo.display) + end + end + + local sockets = { } + local socketLimit = targetBase.socketLimit or 0 + for index = 1, m_min(#sourceItem.sockets, socketLimit) do + t_insert(sockets, copyTable(sourceItem.sockets[index])) + end + item.sockets = sockets + -- Absolute defence values belong to the old base; shared percentiles are restored after crafting. + item.armourData = { } + item = new("Item"):Item(item:BuildRaw()) + -- A capacity-changing base can leave source-side None slots beyond the new limits. + for _, list in ipairs({ item.prefixes, item.suffixes }) do + local limit = list.limit or (item.affixLimit / 2) + for index = #list, limit + 1, -1 do + list[index] = nil + end + end + + local removedInPass + repeat + removedInPass = false + for _, tableName in ipairs({ "prefixes", "suffixes" }) do + local expectedType = tableName == "prefixes" and "Prefix" or "Suffix" + local list = item[tableName] + for index = 1, (list.limit or (item.affixLimit / 2)) do + local affix = list[index] + if affix and affix.modId ~= "None" then + local mod = item.affixes[affix.modId] + local valid = mod and mod.type == expectedType and item:CanHaveMod(mod, getAffixExtraTags(item, tableName, index)) + if not valid then + t_insert(removedAffixes, describeBaseChangeAffix(sourceItem, tableName, affix)) + list[index] = { modId = "None" } + removedInPass = true + end + end + end + end + until not removedInPass + + item:Craft() + if targetBase.armour then + item.armourData = { } + for _, armourType in ipairs(armourBaseTypes) do + if baseHasArmourType(targetBase, armourType) then + local percentile = 1 + if baseHasArmourType(sourceItem.base, armourType) and sourceItem.armourData then + percentile = sourceItem.armourData[armourType.key .. "BasePercentile"] or 1 + end + item.armourData[armourType.key .. "BasePercentile"] = percentile + end + end + item:BuildModList() + item = new("Item"):Item(item:BuildRaw()) + end + item.id = sourceItem.id + item.note = sourceItem.note + item.source = sourceItem.source + return item, removedAffixes, removedInfluences, removedOtherMods +end + +function ItemsTabClass:ChangeDisplayItemBase() + local sourceItem = self.displayItem + local eligible = getBaseChangeEligibility(sourceItem, self.build.data) + if not eligible then + return + end + + local sourceRaw = sourceItem:BuildRaw() + local sourceId = sourceItem.id + local sourceBaseName = sourceItem.baseName + local _, hasEldritchImplicits = getBaseChangeImplicitInfo(sourceItem) + local hasOtherMods = #sourceItem.enchantModLines > 0 + or #sourceItem.scourgeModLines > 0 + or #sourceItem.classRequirementModLines > 0 + or #sourceItem.buffModLines > 0 + or #sourceItem.crucibleModLines > 0 + for _, modLine in ipairs(sourceItem.explicitModLines) do + if modLine.custom or modLine.crafted then + hasOtherMods = true + break + end + end + local baseLists = { } + local typeList = { } + local sourceTypeIndex + local sourceBaseIndex + for _, typeName in ipairs(self.build.data.itemBaseTypeList) do + local filteredList = { } + for _, baseEntry in ipairs(self.build.data.itemBaseLists[typeName]) do + if isSupportedBaseChangeTarget(sourceItem, baseEntry.base) then + t_insert(filteredList, baseEntry) + end + end + if #filteredList > 0 then + t_insert(typeList, typeName) + baseLists[typeName] = filteredList + for index, baseEntry in ipairs(filteredList) do + if baseEntry.name == sourceBaseName then + sourceTypeIndex = #typeList + sourceBaseIndex = index + end + end + end + end + if not sourceTypeIndex then + return + end + + local controls = { } + local state = { + candidate = nil, + error = nil, + removedAffixes = { }, + removedInfluences = { }, + removedOtherMods = { }, + } + local function getSelectedBase() + return controls.base and controls.base.selValue + end + + local popupWidth = 900 + local searchWidth = 350 + local searchY = 20 + local selectorY = 50 + local selectorRowHeight = 20 + local selectorHeaderHeight = 20 + local selectorHeight = selectorRowHeight * 10 + selectorHeaderHeight + 4 + local selectorLeft = 20 + local typeListWidth = 245 + local selectorGap = 10 + local baseListX = selectorLeft + typeListWidth + selectorGap + local baseListWidth = popupWidth - baseListX - 20 + local baseColumnWidth = 200 + local filteredBaseLists = baseLists + + local statusX = baseListX + local removedAffixX = statusX + 15 + local statusTextWidth = popupWidth - statusX - 20 + local affixTextWidth = popupWidth - removedAffixX - 20 + local statusSectionGap = 7 + local statusStartY = selectorY + selectorHeight + 18 + local function getImplicitStatusText() + local targetBaseEntry = getSelectedBase() + if not hasEldritchImplicits or not state.candidate or not targetBaseEntry or targetBaseEntry.name == sourceBaseName or not targetBaseEntry.base.implicit then + return + end + local lines = { } + for _, modLine in ipairs(state.candidate.implicitModLines) do + t_insert(lines, modLine.line) + end + return "^7Implicits: ^x80FF80Existing Eldritch implicits replace the new base's implicits:", table.concat(lines, "\n") + end + local function getExplicitStatusText() + local targetBaseEntry = getSelectedBase() + if not targetBaseEntry then + return "^7Explicits: ^x7F7F7FSelect a base." + elseif state.error then + return "^7Explicits: " .. colorCodes.NEGATIVE .. state.error + elseif targetBaseEntry and targetBaseEntry.name == sourceBaseName then + return "^7Explicits: ^x7F7F7FSelect a different base." + elseif state.removedAffixes.resetPrefixes or state.removedAffixes.resetSuffixes then + local category = state.removedAffixes.resetPrefixes and state.removedAffixes.resetSuffixes and "prefix and suffix" or state.removedAffixes.resetPrefixes and "prefix" or "suffix" + local removalCount = #state.removedAffixes + local removalText = removalCount > 0 and removalCount .. " modifier" .. (removalCount == 1 and "" or "s") .. " will be removed:" or "There are no modifiers in the affected slots." + return "^7Explicits: " .. colorCodes.NEGATIVE .. "The new base changes " .. category .. " rules. All modifiers will be reset.\n" .. removalText + elseif #state.removedAffixes == 0 then + return "^7Explicits: ^x80FF80All prefix and suffix modifiers are compatible." + else + return "^7Explicits: " .. colorCodes.NEGATIVE .. #state.removedAffixes .. " incompatible modifier" .. (#state.removedAffixes == 1 and "" or "s") .. " will be removed:" + end + end + local function getOtherStatusMessages() + local targetBaseEntry = getSelectedBase() + if not targetBaseEntry or targetBaseEntry.name == sourceBaseName then + return { } + end + local messages = { } + if state.removedInfluences.reset then + if #state.removedInfluences > 0 then + t_insert(messages, colorCodes.NEGATIVE .. "These influences will be removed: " .. table.concat(state.removedInfluences, ", ") .. ".") + end + elseif #state.removedInfluences > 0 then + t_insert(messages, colorCodes.NEGATIVE .. "Unsupported influences will be removed: " .. table.concat(state.removedInfluences, ", ") .. ".") + end + if #state.removedOtherMods > 0 then + local removedOtherModLines = { colorCodes.NEGATIVE .. #state.removedOtherMods .. " unclassified explicit modifier" .. (#state.removedOtherMods == 1 and "" or "s") .. " will be removed:" } + for _, modLine in ipairs(state.removedOtherMods) do + t_insert(removedOtherModLines, colorCodes.NEGATIVE .. " " .. modLine) + end + t_insert(messages, table.concat(removedOtherModLines, "\n")) + end + if hasOtherMods then + t_insert(messages, "^xFFB040Modifiers added via 'Add Modifier' persist, but are not checked for in-game compatibility") + end + return messages + end + local popup + local function updateLayout() + local y = statusStartY + local implicitStatus, implicitLines = getImplicitStatusText() + y = layoutBaseChangeLabel(controls.implicitStatus, implicitStatus, statusTextWidth, y) + y = layoutBaseChangeLabel(controls.implicitMods, implicitLines, affixTextWidth, y, "^x80FF80") + if implicitStatus then + y = y + 2 + end + y = layoutBaseChangeLabel(controls.status, getExplicitStatusText(), statusTextWidth, y) + 2 + for index = 1, 6 do + local affix = state.removedAffixes[index] + y = layoutBaseChangeLabel(controls["removedAffix" .. index], affix and affix.label, affixTextWidth, y, colorCodes.NEGATIVE) + end + local messages = getOtherStatusMessages() + if #messages > 0 then + y = y + 5 + end + for index = 1, 3 do + local message = messages[index] + if message and index > 1 then + y = y + statusSectionGap + end + y = layoutBaseChangeLabel(controls[index == 1 and "otherStatus" or "otherStatus" .. index], + message and (index == 1 and "^7Other: " or "") .. message, statusTextWidth, y) + end + controls.save.y = y + 11 + controls.cancel.y = y + 11 + if popup then + popup.height = y + 46 + end + end + + local function updateCandidate() + local targetBaseEntry = getSelectedBase() + if not targetBaseEntry then + state.candidate = nil + state.error = nil + state.removedAffixes = { } + state.removedInfluences = { } + state.removedOtherMods = { } + else + state.candidate, state.removedAffixes, state.removedInfluences, state.removedOtherMods, state.error = self:CreateBaseChangeCandidate(sourceItem, targetBaseEntry) + state.removedAffixes = state.removedAffixes or { } + state.removedInfluences = state.removedInfluences or { } + state.removedOtherMods = state.removedOtherMods or { } + end + updateLayout() + end + + local function addBaseTooltip(tooltip, value) + if not value then + tooltip:Clear(true) + elseif tooltip:CheckForUpdate(value) then + tooltip:Clear() + local candidate = value == getSelectedBase() and state.candidate or self:CreateBaseChangeCandidate(sourceItem, value) + if candidate then + self:AddItemTooltip(tooltip, candidate, nil, true) + end + end + end + controls.type = new("ItemBaseListControl"):ItemBaseListControl({"TOPLEFT",nil,"TOPLEFT"}, {selectorLeft, selectorY, typeListWidth, selectorHeight}, typeList, "TYPE", nil, function(_, value) + controls.base:SetList(filteredBaseLists[value] or { }) + if not controls.base:SelectIndex(1) then + updateCandidate() + end + end) + controls.base = new("ItemBaseListControl"):ItemBaseListControl({"TOPLEFT",nil,"TOPLEFT"}, {baseListX, selectorY, baseListWidth, selectorHeight}, filteredBaseLists[typeList[sourceTypeIndex]], "BASE", baseColumnWidth, function() + updateCandidate() + end, function(tooltip, _, value) + addBaseTooltip(tooltip, value) + end) + local function updateSearch(searchText) + local selectedType = controls.type.selValue + local selectedBase = controls.base.selValue + filteredBaseLists = filterItemBaseLists(typeList, baseLists, searchText) + controls.base:SetList(filteredBaseLists[selectedType] or { }) + local selectedBaseIndex = isValueInArray(controls.base.list, selectedBase) + if not (selectedBaseIndex and controls.base:SelectIndex(selectedBaseIndex)) and not controls.base:SelectIndex(1) then + updateCandidate() + end + end + controls.search = new("EditControl"):EditControl({"TOP",nil,"TOP"}, {0, searchY, searchWidth, 20}, "", nil, "%c", 100, updateSearch, nil, nil, true) + controls.search:SetPlaceholder("Search base or implicit") + + controls.implicitStatus = new("LabelControl"):LabelControl({"TOPLEFT",nil,"TOPLEFT"}, {statusX, 0, 0, 16}, "") + controls.implicitMods = new("LabelControl"):LabelControl({"TOPLEFT",nil,"TOPLEFT"}, {removedAffixX, 0, 0, 16}, "") + controls.status = new("LabelControl"):LabelControl({"TOPLEFT",nil,"TOPLEFT"}, {statusX, 0, 0, 16}, "") + for index = 1, 6 do + controls["removedAffix" .. index] = new("LabelControl"):LabelControl({"TOPLEFT",nil,"TOPLEFT"}, {removedAffixX, 0, 0, 16}, "") + end + for index = 1, 3 do + controls[index == 1 and "otherStatus" or "otherStatus" .. index] = new("LabelControl"):LabelControl({"TOPLEFT",nil,"TOPLEFT"}, {statusX, 0, 0, 16}, "") + end + controls.save = new("ButtonControl"):ButtonControl(nil, {-55, 0, 100, 20}, "Change base", function() + if self.displayItem ~= sourceItem or self.displayItem.id ~= sourceId or self.displayItem.baseName ~= sourceBaseName or self.displayItem:BuildRaw() ~= sourceRaw then + state.error = "The item changed while this dialog was open. Reopen Change base." + state.candidate = nil + updateLayout() + return + end + local targetBaseEntry = getSelectedBase() + if state.candidate and targetBaseEntry and targetBaseEntry.name ~= sourceBaseName then + self:SetDisplayItem(state.candidate) + main:ClosePopup() + end + end) + controls.save.enabled = function() + local targetBaseEntry = getSelectedBase() + return state.candidate and not state.error and targetBaseEntry and targetBaseEntry.name ~= sourceBaseName + end + controls.cancel = new("ButtonControl"):ButtonControl(nil, {55, 0, 100, 20}, "Cancel", function() + main:ClosePopup() + end) + controls.type:SelectIndex(sourceTypeIndex) + controls.base:SelectIndex(sourceBaseIndex) + local initialPopupHeight = controls.save.y + 35 + local popupTop = m_floor((main.screenH - initialPopupHeight) / 2) + popup = main:OpenPopup(popupWidth, initialPopupHeight, "Change Item Base", controls, nil, "search") + popup:SetFindControl(controls.search) + popup.y = popupTop +end + -- Opens the item crafting popup function ItemsTabClass:CraftItem() local controls = { } @@ -2654,14 +3288,7 @@ function ItemsTabClass:CraftItem() if raritySel >= 3 then item.title = controls.title.buf:match("%S") and controls.title.buf or "New Item" end - if base.base.implicit then - local implicitIndex = 1 - for line in base.base.implicit:gmatch("[^\n]+") do - local modList, extra = modLib.parseMod(line) - t_insert(item.implicitModLines, { line = line, extra = extra, modList = modList or { }, modTags = base.base.implicitModTypes and base.base.implicitModTypes[implicitIndex] or { } }) - implicitIndex = implicitIndex + 1 - end - end + item.implicitModLines = buildBaseImplicitModLines(base.base) item:NormaliseQuality() item:BuildAndParseRaw() return item diff --git a/src/Classes/ListControl.lua b/src/Classes/ListControl.lua index 55407583503..0415af359ea 100644 --- a/src/Classes/ListControl.lua +++ b/src/Classes/ListControl.lua @@ -5,6 +5,9 @@ -- -- This is an abstract base class; derived classes can supply these properties and methods to configure the list control: -- .label [Adds a label above the top left corner] +-- .colList [Column definitions; fontSize, headerFontSize, and textOffset customize text rendering] +-- .colLabelHeight [Overrides the default 18-unit column header height] +-- .tooltipAnchorFullRow [Positions value tooltips outside the full visible row instead of the hovered column text] -- .dragTargetList [List of controls that can receive drag events from this list control] -- .showRowSeparators [Shows separators between rows] -- :GetRowValue(column, index, value) [Required; called to retrieve the text for the given column of the given list value] @@ -125,11 +128,12 @@ end function ListClass:GetRowRegion() local width, height = self:GetSize() + local colLabelHeight = self.colLabels and (self.colLabelHeight or 18) or 0 return { x = 2, - y = self.colLabels and 20 or 2, + y = 2 + colLabelHeight, width = self.scroll and width - 20 or width, - height = height - 4 - (self.scroll and self.scrollH and 16 or 0) - (self.colLabels and 18 or 0), + height = height - 4 - (self.scroll and self.scrollH and 16 or 0) - colLabelHeight, } end @@ -137,6 +141,7 @@ function ListClass:Draw(viewPort, noTooltip) local x, y = self:GetPos() local width, height = self:GetSize() local rowHeight = self.rowHeight + local colLabelHeight = self.colLabels and (self.colLabelHeight or 18) or 0 local list = self.list local colOffset = 0 @@ -216,32 +221,40 @@ function ListClass:Draw(viewPort, noTooltip) local maxIndex = m_min(m_floor((scrollOffsetV + height) / rowHeight + 1), #list) for colIndex, column in ipairs(self.colList) do local colFont = self:GetColumnProperty(column, "font") or "VAR" - local clipWidth = DrawStringWidth(textHeight, colFont, "...") + local colFontSize = self:GetColumnProperty(column, "fontSize") or textHeight + local textOffsetX = self:GetColumnProperty(column, "textOffset") or 0 + local colTextOffsetY = m_floor((rowHeight - colFontSize) / 2) + local clipWidth = DrawStringWidth(colFontSize, colFont, "...") colOffset = column._offset - scrollOffsetH local colWidth = column._width local relX = cursorX - (x + 2) local relY = cursorY - (y + 2) for index = minIndex, maxIndex do - local lineY = rowHeight * (index - 1) - scrollOffsetV + (self.colLabels and 18 or 0) + local lineY = rowHeight * (index - 1) - scrollOffsetV + colLabelHeight local value = list[index] local text = self:GetRowValue(colIndex, index, value) local icon = nil if self.GetRowIcon then icon = self:GetRowIcon(colIndex, index, value) end - local textWidth = DrawStringWidth(textHeight, colFont, text) - if textWidth > colWidth - 2 then - local clipIndex = DrawStringCursorIndex(textHeight, colFont, text, colWidth - clipWidth - 2, 0) + local textWidth = DrawStringWidth(colFontSize, colFont, text) + if textWidth > colWidth - textOffsetX - 2 then + local clipIndex = DrawStringCursorIndex(colFontSize, colFont, text, colWidth - textOffsetX - clipWidth - 2, 0) text = text:sub(1, clipIndex - 1) .. "..." - textWidth = DrawStringWidth(textHeight, colFont, text) + textWidth = DrawStringWidth(colFontSize, colFont, text) end if not scrollBarV.dragging and (not self.selDragActive or (self.CanDragToValue and self:CanDragToValue(index, value, self.otherDragSource))) then if relX >= colOffset and relX < (self.scroll and width - 20 or width) and relY >= 0 and relY >= lineY and relY < height - 2 - (self.scroll and self.scrollH and 18 or 0) and relY < lineY + rowHeight then ttIndex = index ttValue = value - ttX = x + 2 + colOffset ttY = lineY + y + 2 - ttWidth = m_max(textWidth + 8, relX - colOffset) + if self.tooltipAnchorFullRow then + ttX = x + rowRegion.x + ttWidth = rowRegion.width + else + ttX = x + 2 + colOffset + ttWidth = m_max(textWidth + 8, relX - colOffset) + end end end if self.showRowSeparators then @@ -284,35 +297,37 @@ function ListClass:Draw(viewPort, noTooltip) end -- TODO: handle icon size properly, for now assume they are 16x16 if icon == nil then - DrawString(colOffset, lineY + textOffsetY, "LEFT", textHeight, colFont, text) + DrawString(colOffset + textOffsetX, lineY + colTextOffsetY, "LEFT", colFontSize, colFont, text) else - DrawImage(icon, colOffset, lineY, 16, 16) - DrawString(colOffset + 16 + 2, lineY + textOffsetY, "LEFT", textHeight, colFont, text) + DrawImage(icon, colOffset + textOffsetX, lineY, 16, 16) + DrawString(colOffset + textOffsetX + 16 + 2, lineY + colTextOffsetY, "LEFT", colFontSize, colFont, text) end end if self.colLabels then - local mOver = relX >= colOffset and relX <= colOffset + colWidth and relY >= 0 and relY <= 18 + local mOver = relX >= colOffset and relX <= colOffset + colWidth and relY >= 0 and relY <= colLabelHeight if mOver and self:GetColumnProperty(column, "sortable") then SetDrawColor(1, 1, 1) - DrawImage(nil, colOffset, 1, colWidth, 18) + DrawImage(nil, colOffset, 1, colWidth, colLabelHeight) SetDrawColor(0.33, 0.33, 0.33) - DrawImage(nil, colOffset + 1, 2, colWidth - 2, 16) + DrawImage(nil, colOffset + 1, 2, colWidth - 2, colLabelHeight - 2) else SetDrawColor(0.5, 0.5, 0.5) - DrawImage(nil, colOffset, 1, colWidth, 18) + DrawImage(nil, colOffset, 1, colWidth, colLabelHeight) SetDrawColor(0.15, 0.15, 0.15) - DrawImage(nil, colOffset + 1, 2, colWidth - 2, 16) + DrawImage(nil, colOffset + 1, 2, colWidth - 2, colLabelHeight - 2) end local label = self:GetColumnProperty(column, "label") if label and #label > 0 then + local headerFontSize = self:GetColumnProperty(column, "headerFontSize") or 12 + local headerTextY = m_floor((colLabelHeight - headerFontSize) / 2) + 1 SetDrawColor(1, 1, 1) - DrawString(colOffset + colWidth/2, 4, "CENTER_X", 12, "VAR", label) + DrawString(colOffset + colWidth/2, headerTextY, "CENTER_X", headerFontSize, "VAR", label) end end end if #self.list == 0 and self.defaultText then SetDrawColor(1, 1, 1) - DrawString(2, 2, "LEFT", 14, self.font, self.defaultText) + DrawString(2, 2 + colLabelHeight, "LEFT", 14, self.font, self.defaultText) end if self.selDragIndex then local lineY = rowHeight * (self.selDragIndex - 1) - scrollOffsetV diff --git a/src/Classes/PopupDialog.lua b/src/Classes/PopupDialog.lua index 1e54e8c695b..70f7fea08ab 100644 --- a/src/Classes/PopupDialog.lua +++ b/src/Classes/PopupDialog.lua @@ -6,6 +6,7 @@ local m_floor = math.floor ---@class PopupDialog: ControlHost, Control +---@field findControl? Control local PopupDialogClass = newClass("PopupDialog", "ControlHost", "Control") function PopupDialogClass:PopupDialog(width, height, title, controls, enterControl, defaultControl, @@ -69,7 +70,22 @@ function PopupDialogClass:Draw(viewPort) self:DrawControls(viewPort) end +---@param control Control +---@return PopupDialog +function PopupDialogClass:SetFindControl(control) + self.findControl = control + return self +end + function PopupDialogClass:ProcessInput(inputEvents, viewPort) + if self.findControl then + for _, event in ipairs(inputEvents) do + if event.type == "KeyDown" and event.key == "f" and IsKeyDown("CTRL") then + self:SelectControl(self.findControl) + break + end + end + end self:ProcessControlsInput(inputEvents, viewPort) for id, event in ipairs(inputEvents) do if event.type == "KeyDown" then