diff --git a/spec/System/TestItemParse_spec.lua b/spec/System/TestItemParse_spec.lua index 37f90839c6..335a8e3df0 100644 --- a/spec/System/TestItemParse_spec.lua +++ b/spec/System/TestItemParse_spec.lua @@ -1107,7 +1107,7 @@ describe("TestAdvancedItemParse #item", function() ]], "Ancestral Tiara")) assert.are.equals("LocalIncreasedEnergyShieldAndLife4", item.prefixes[1].modId) assert.are.equals(0, item.prefixes[1].range) - assert.are.equals(0.833, item.explicitModLines[2].range) + assert.are.equals(0.833333, item.explicitModLines[2].range) end) it("resets linePrefix", function() @@ -1262,6 +1262,91 @@ describe("TestAdvancedItemParse #item", function() Note: ~b/o 2 chaos ]]) end) + + it("preserves independently rolled affix values when crafting", function() + local item = new("Item"):Item(raw([[ + { Fractured Prefix Modifier "Frigid" (Tier: 4) — Damage, Elemental, Cold, Attack } + Adds 7(7-8) to 14(12-14) Cold damage to Attacks + ]], "Refined Bracers")) + + assert.are.equals("AddedColdDamage4", item.prefixes[1].modId) + assert.are.same({ 0, 1 }, item.prefixes[1].range) + assert.is_true(item.prefixes[1].fractured) + item:Craft() + assert.are.equals("Adds 7 to 14 Cold damage to Attacks", item.explicitModLines[1].line) + assert.is_true(item.explicitModLines[1].fractured) + end) + + it("parses fixed advanced-copy values from a legacy Prism Guardian", function() + local item = new("Item"):Item([[ + Rarity: Unique + Prism Guardian + Sectarian Crest Shield + { Unique Modifier } + +1 to Maximum Spirit per 25(50) Maximum Life + ]]) + + assert.are.equals("+1 to Maximum Spirit per 25 Maximum Life", item.explicitModLines[1].line) + end) + + it("preserves a Heroic Tragedy seed and selected commander", function() + local item = new("Item"):Item([[ + Rarity: Unique + Heroic Tragedy + Timeless Jewel + { Unique Modifier } + Remembrancing 7321(100-8000) songworthy deeds by the line of Vorana(Vorana-Olroth) + ]]) + + assert.are.equals("Remembrancing 7321 songworthy deeds by the line of Vorana", + itemLib.applyRange(item.explicitModLines[1].line, item.explicitModLines[1].range)) + item:BuildAndParseRaw() + assert.are.equals("Remembrancing 7321 songworthy deeds by the line of Vorana", + itemLib.applyRange(item.explicitModLines[1].line, item.explicitModLines[1].range)) + end) + + it("orders advanced-copy unique modifiers by database stat order", function() + local item = new("Item"):Item([[ + Rarity: Unique + Evergrasping Ring + Pearl Ring + { Implicit Modifier — Caster, Speed } + 8(7-10)% increased Cast Speed + { Unique Modifier — Chaos } + Enemies in your Presence Gain 8(6-12)% of Damage as Extra Chaos Damage + { Unique Modifier — Chaos } + Allies in your Presence Gain 22(15-25)% of Damage as Extra Chaos Damage + { Unique Modifier — Mana } + +91(60-100) to maximum Mana + ]]) + + assert.are.same({ + "+(60-100) to maximum Mana", + "Allies in your Presence Gain (15-25)% of Damage as Extra Chaos Damage", + "Enemies in your Presence Gain (6-12)% of Damage as Extra Chaos Damage", + }, { + item.explicitModLines[1].line, + item.explicitModLines[2].line, + item.explicitModLines[3].line, + }) + end) + + it("filters flask state and base-property lines", function() + local item = new("Item"):Item([[ + Rarity: Unique + Opportunity + Ultimate Life Flask + Recovers 2061 (augmented) Life over 4.20 Seconds + Consumes 4 (augmented) of 75 Charges on use + Currently has 0 Charges + { Unique Modifier } + Cannot be Used manually + ]]) + + assert.are.equals(1, #item.explicitModLines) + assert.are.equals("Cannot be Used manually", item.explicitModLines[1].line) + end) + describe("mod magnitude scaling", function() before_each(function() newBuild() diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index e4998d962e..76bee2cfd8 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -62,6 +62,31 @@ local function getCatalystScalar(catalystId, mod, quality) return 1 end +local function normaliseModLine(line) + return line:gsub("%d+%.?%d*", "#") + :gsub("%(%-?#%-#%)", "#"):lower() + :gsub("\n", " ") +end + +local uniqueModStatOrder + +local function sortCraftedModLines(modLines) + local sourceOrder = { } + for index, modLine in ipairs(modLines) do + sourceOrder[modLine] = index + end + table.sort(modLines, function(a, b) + local aGroup = (a.crafted or a.custom) and 3 or a.fractured and 1 or 2 + local bGroup = (b.crafted or b.custom) and 3 or b.fractured and 1 or 2 + if aGroup ~= bGroup then + return aGroup < bGroup + elseif aGroup < 3 and a.order ~= b.order then + return (a.order or math.huge) < (b.order or math.huge) + end + return sourceOrder[a] < sourceOrder[b] + end) +end + ---@class Item local ItemClass = newClass("Item") @@ -660,6 +685,12 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) while self.rawLines[l] and not self.rawLines[l]:match("%)$") do l = l + 1 end + elseif self.base and self.base.flask and ( + line:match("^Recovers .+ over .+ Seconds?$") + or line:match("^Consumes %d+.- of %d+.- Charges on use$") + or line:match("^Currently has %d+ Charges$") + ) then + -- In-game flask state and base properties aren't modifier lines. elseif line:match("^{ ") then -- We're parsing advanced copy/paste format self.advancedCopy = true @@ -858,19 +889,27 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) self.crafted = true elseif specName == "Implicit" then self.implicit = true - elseif specName == "Prefix" then - local range, affix = specVal:match("{range:([%d.]+)}(.+)") - range = range or ((affix or specVal) ~= "None" and main.defaultItemAffixQuality) - t_insert(self.prefixes, { - modId = affix or specVal, - range = tonumber(range), - }) - elseif specName == "Suffix" then - local range, affix = specVal:match("{range:([%d.]+)}(.+)") - range = range or ((affix or specVal) ~= "None" and main.defaultItemAffixQuality) - t_insert(self.suffixes, { + elseif specName == "Prefix" or specName == "Suffix" then + local affixes = specName == "Prefix" and self.prefixes or self.suffixes + local fractured = specVal:match("^{fractured}") and true + specVal = specVal:gsub("^{fractured}", "") + local range, affix = specVal:match("{range:([^}]+)}(.+)") + if range and range:find(",", 1, true) then + local ranges = { } + for value in range:gmatch("[^,]+") do + t_insert(ranges, tonumber(value)) + end + range = ranges + else + range = tonumber(range) + end + if not range and (affix or specVal) ~= "None" then + range = main.defaultItemAffixQuality + end + t_insert(affixes, { modId = affix or specVal, - range = tonumber(range), + range = range, + fractured = fractured, }) elseif specName == "Implicits" then implicitLines = specToNumber(specVal) or 0 @@ -1082,33 +1121,44 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) else catalystScalar = getCatalystScalar(self.catalyst, modLine, self.catalystQuality) end + -- Advanced copy uses current(base) for fixed-value modifiers, + -- in addition to the current(min-max) form handled below. + line = line:gsub("(%-?%d+%.?%d*)%((%-?%d+%.?%d*)%)", "%1") if self.pendingAffixList and #self.pendingAffixList > 0 then if #self.pendingAffixList > 1 then -- Probably a conqueror or Essence mod since the mod name is the same for all of them -- Try to match the line against one of the mods there - local valueStrippedLine = line:gsub("%-?%d+%.?%d*%(", "("):gsub("%-?%d+%.?%d*", "#") + local rangeLine = line:gsub("%-?%d+%.?%d*%(", "(") + local valueStrippedLine = rangeLine:gsub("%-?%d+%.?%d*", "#") + local exactAffix + local fallbackAffix for _, pendingAffix in ipairs(self.pendingAffixList) do local modData = self.affixes[pendingAffix.modId] for _, modDataLine in ipairs(modData) do - -- Prefer the exact match - if line == modDataLine then - self.pendingAffixList = { pendingAffix } + if line == modDataLine or rangeLine == modDataLine then + exactAffix = pendingAffix break end - if valueStrippedLine == modDataLine:gsub("%-?%d+%.?%d*", "#") then - self.pendingAffixList = { pendingAffix } - break + if not fallbackAffix and valueStrippedLine == modDataLine:gsub("%-?%d+%.?%d*", "#") then + fallbackAffix = pendingAffix end end + if exactAffix then + break + end end + self.pendingAffixList = { exactAffix or fallbackAffix or self.pendingAffixList[1] } end -- Use rolling Delta/Range in case one range is 1-3 and another is 1-100 so we get the finest precision possible local bestPrecisionDelta = -1 local bestPrecisionRange = -1 + local rollRanges = { } + local affixMod = self.affixes[self.pendingAffixList[1].modId] + modLine.order = affixMod and affixMod.statOrder[1] for value, range in line:gmatch("(%-?%d+%.?%d*)%((%-?%d+%.?%d*%-%-?%d+%.?%d*)%)") do - -- Find advanced copy paste format: 45(40-50) local min, max = range:match("(%-?%d+%.?%d*)%-(%-?%d+%.?%d*)") local delta = tonumber(max) - min + t_insert(rollRanges, delta > 0 and round((value - min) / delta, 6) or 0.5) line = line:gsub(value .. "%(" .. range:gsub("%-", "%%-") .. "%)", value) if delta > bestPrecisionDelta then bestPrecisionRange = round((value - min) / delta, 3) @@ -1117,34 +1167,49 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) end t_insert(self.pendingAffixList[1].table, { modId = self.pendingAffixList[1].modId, - range = bestPrecisionRange >= 0 and bestPrecisionRange <= 1 and bestPrecisionRange or 0.5, + -- Legacy modifiers can roll outside the current data range. Keep the + -- extrapolated range so crafting a different affix doesn't normalise it. + range = #rollRanges > 1 and rollRanges or bestPrecisionDelta > 0 and bestPrecisionRange or 0.5, + fractured = modLine.fractured, }) self.pendingAffixList = {} else -- Use rolling Delta/Range in case one range is 1-3 and another is 1-100 so we get the finest precision possible local bestPrecisionDelta = -1 local bestPrecisionRange = -1 + local firstRollRange + local hasIndependentRolls - -- Replace non-number ranges as unsupported - line = line:gsub("(%a+)%([%a%s]+%-[%a%s]+%)", "%1") - - -- Strip single values like 25(50) -> 25 - line = line:gsub("(%d+)%((%d+)%)", "%1") + -- Advanced copy only provides the endpoints for enum ranges; keep the selected value. + line = line:gsub("(%s*)(%b())", function(space, range) + if range:find("-", 1, true) and not range:find("%d") then + return "" + end + return space .. range + end) + local advancedCopyLine = line for value, range in line:gmatch("(%-?%d+%.?%d*)%((%-?%d+%.?%d*%-%-?%d+%.?%d*)%)") do local min, max = range:match("(%-?%d+%.?%d*)%-(%-?%d+%.?%d*)") local delta = tonumber(max) - min + local rollRange = delta > 0 and round((value - min) / delta, 6) or 0.5 + if firstRollRange and firstRollRange ~= rollRange then + hasIndependentRolls = true + end + firstRollRange = firstRollRange or rollRange if delta > bestPrecisionDelta then - bestPrecisionRange = round((value - min) / delta, 3) + bestPrecisionRange = rollRange bestPrecisionDelta = delta end if bestPrecisionRange > 1 or bestPrecisionRange < 0 then line = line:gsub(value .. "%(" .. range:gsub("%-", "%%-") .. "%)", value) else - line = line:gsub(value .. "%(" .. range:gsub("%-", "%%-") .. "%)", (tonumber(value) < 0 and "+" or "") .. "(" .. range .. ")") + line = line:gsub(value .. "%(" .. range:gsub("%-", "%%-") .. "%)", (tonumber(value) < 0 and "+" or "") .. "(" .. min .. "-" .. max .. ")") end end - if bestPrecisionRange <= 1 and bestPrecisionRange >= 0 then + if hasIndependentRolls then + line = advancedCopyLine:gsub("(%-?%d+%.?%d*)%(%-?%d+%.?%d*%-%-?%d+%.?%d*%)", "%1") + elseif bestPrecisionRange <= 1 and bestPrecisionRange >= 0 then modLine.range = bestPrecisionRange end end @@ -1525,6 +1590,27 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) self.runes = { } end end + if self.advancedCopy and (self.rarity == "UNIQUE" or self.rarity == "RELIC") and not self:UsesVersionedOrGroupedVariants() then + if not uniqueModStatOrder then + uniqueModStatOrder = { exact = { }, normalised = { } } + for _, mod in pairs(data.itemMods.Exclusive) do + for index, line in ipairs(mod) do + local exactLine = line:lower():gsub("\n", " ") + local statLine = normaliseModLine(line) + uniqueModStatOrder.exact[exactLine] = m_min(uniqueModStatOrder.exact[exactLine] or math.huge, mod.statOrder[index]) + uniqueModStatOrder.normalised[statLine] = m_min(uniqueModStatOrder.normalised[statLine] or math.huge, mod.statOrder[index]) + end + end + end + for _, modLine in ipairs(self.explicitModLines) do + local exactLine = modLine.line:lower():gsub("\n", " ") + modLine.order = uniqueModStatOrder.exact[exactLine] + or uniqueModStatOrder.normalised[normaliseModLine(modLine.line)] + end + end + if self.advancedCopy and #self.explicitModLines > 1 then + sortCraftedModLines(self.explicitModLines) + end if self.advancedCopy or self.crafted then -- apply mod magnitude boost to matching mods if #self.modMagnitudeMods > 0 then @@ -1770,11 +1856,13 @@ function ItemClass:BuildRaw() end if self.crafted then t_insert(rawLines, "Crafted: true") - for i, affix in ipairs(self.prefixes or { }) do - t_insert(rawLines, "Prefix: " .. (affix.range and ("{range:" .. round(affix.range,3) .. "}") or "") .. affix.modId) + for _, affix in ipairs(self.prefixes or { }) do + local range = affix.range and "{range:" .. (type(affix.range) == "table" and table.concat(affix.range, ",") or round(affix.range, 3)) .. "}" or "" + t_insert(rawLines, "Prefix: " .. (affix.fractured and "{fractured}" or "") .. range .. affix.modId) end - for i, affix in ipairs(self.suffixes or { }) do - t_insert(rawLines, "Suffix: " .. (affix.range and ("{range:" .. round(affix.range,3) .. "}") or "") .. affix.modId) + for _, affix in ipairs(self.suffixes or { }) do + local range = affix.range and "{range:" .. (type(affix.range) == "table" and table.concat(affix.range, ",") or round(affix.range, 3)) .. "}" or "" + t_insert(rawLines, "Suffix: " .. (affix.fractured and "{fractured}" or "") .. range .. affix.modId) end end if self.catalyst and self.catalyst > 0 then @@ -1816,7 +1904,7 @@ function ItemClass:BuildRaw() line = displayValueScalar and itemLib.applyRange(modLine.line, modLine.range or main.defaultItemAffixQuality, displayValueScalar, modLine.corruptedRange) or modLine.line end if modLine.range and line:match("%(%-?[%d%.]+%-%-?[%d%.]+%)") then - line = "{range:" .. round(modLine.range, 3) .. "}" .. line + line = "{range:" .. round(modLine.range, 6) .. "}" .. line end if modLine.corruptedRange then line = "{corruptedRange:" .. round(modLine.corruptedRange, 2) .. "}" .. line @@ -2128,7 +2216,7 @@ function ItemClass:Craft() return tonumber(num) + tonumber(other) end) else - local modLine = { line = line, order = order, type = mod.type, modTags = mod.modTags or { }, unscalable = mod.unscalable } + local modLine = { line = line, order = order, type = mod.type, modTags = mod.modTags or { }, unscalable = mod.unscalable, fractured = affix.fractured } modLine[mod.type:lower()] = true for l = 1, #self.explicitModLines + 1 do if not self.explicitModLines[l] or self.explicitModLines[l].order > order then @@ -2147,6 +2235,9 @@ function ItemClass:Craft() for _, mod in ipairs(savedMods) do t_insert(self.explicitModLines, mod) end + if #self.explicitModLines > 1 then + sortCraftedModLines(self.explicitModLines) + end self:BuildAndParseRaw() end diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index d355d22742..54a30006b2 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -818,7 +818,7 @@ holding Shift will put it in the second.]]) return range end drop = new("DropDownControl"):DropDownControl({ "TOPLEFT", prev, "TOPLEFT" }, { i == 1 and 40 or 0, 0, 418, 20 }, nil, function(index, value) - local affix = { modId = "None" } + local affix = { modId = "None", fractured = self.displayItem[drop.outputTable][drop.outputIndex].fractured } if value.modId then affix.modId = value.modId affix.range = slider.val @@ -2001,7 +2001,7 @@ function ItemsTabClass:SetDisplayItem(item) self.controls.displayItemSocketRuneEdit:SetText(item.itemSocketCount) self.controls.displayItemSocketJewelEdit:SetText(item.jewelSocketCount) self.controls.displayItemQualityEdit:SetText(item.quality) - self.controls.displayItemCatalyst:SetSel((item.catalyst or 0) + 1) + self.controls.displayItemCatalyst:SetSel((item.catalyst or 0) + 1, true) if item.catalystQuality then self.controls.displayItemCatalystQualityEdit:SetText(m_max(item.catalystQuality, 0)) else @@ -2196,7 +2196,7 @@ function ItemsTabClass:UpdateRuneControls() end end -function ItemsTabClass:UpdateAffixControl(control, item, type, outputTable, outputIndex, powerCache) +function ItemsTabClass:UpdateAffixControl(control, item, affixType, outputTable, outputIndex, powerCache) local extraTags = { } local excludeGroups = { } for _, table in ipairs({"prefixes","suffixes"}) do @@ -2224,7 +2224,7 @@ function ItemsTabClass:UpdateAffixControl(control, item, type, outputTable, outp end local affixList = { } for modId, mod in pairs(item.affixes) do - if mod.type == type and not excludeGroups[mod.group] and item:GetModSpawnWeight(mod, extraTags) > 0 then + if mod.type == affixType and not excludeGroups[mod.group] and item:GetModSpawnWeight(mod, extraTags) > 0 then t_insert(affixList, modId) end end @@ -2332,7 +2332,10 @@ function ItemsTabClass:UpdateAffixControl(control, item, type, outputTable, outp if control.list[control.selIndex].haveRange then control.slider.divCount = #control.list[control.selIndex].modList local index = isValueInArray(control.list[control.selIndex].modList, selAffix) - local range = item[outputTable][outputIndex].range or 0.5 + -- Imported legacy rolls can sit outside the current 0-1 affix range. + -- Keep that value on the affix, but show the nearest slider endpoint. + local affixRange = item[outputTable][outputIndex].range + local range = m_min(1, m_max(0, type(affixRange) == "table" and affixRange[1] or affixRange or 0.5)) -- Avoid exact integer boundary that slider:GetDivVal's ceil would assign to the previous segment if range == 0 and index > 1 then range = 1e-4 diff --git a/src/Modules/ItemTools.lua b/src/Modules/ItemTools.lua index 9914a561b2..376c5ba500 100644 --- a/src/Modules/ItemTools.lua +++ b/src/Modules/ItemTools.lua @@ -75,14 +75,18 @@ end -- Apply range value (0 to 1) to a modifier that has a range: "(x-x)" or "(x-x) to (x-x)" ---@param line string ----@param range number +---@param range number|number[] ---@param valueScalar number? ---@param baseValueScalar number? function itemLib.applyRange(line, range, valueScalar, baseValueScalar) -- stripLines down to # in place of any number and store numbers inside values also remove all + signs are kept if value is positive local values = { } + local rangeIndex = 0 + local ranges = type(range) == "table" and range local strippedLine = line:gsub("([%+-]?)%((%-?%d+%.?%d*)%-(%-?%d+%.?%d*)%)", function(sign, min, max) - local value = min + range * (tonumber(max) - min) + rangeIndex = rangeIndex + 1 + local valueRange = ranges and (ranges[rangeIndex] or 0.5) or range + local value = min + valueRange * (tonumber(max) - min) if sign == "-" then value = value * -1 end return (sign == "+" and value > 0 ) and sign..tostring(value) or tostring(value) end)