From 36d4b71c9d71ac890669acb9427c6db8ec391563 Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Mon, 27 Jul 2026 10:12:33 +1000 Subject: [PATCH 01/12] Fix paste of simplex amulet --- spec/System/TestItemParse_spec.lua | 47 ++++++++++++++++++++++++++++++ src/Classes/ItemsTab.lua | 9 ++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/spec/System/TestItemParse_spec.lua b/spec/System/TestItemParse_spec.lua index d337f63b51..53775da0cd 100644 --- a/spec/System/TestItemParse_spec.lua +++ b/spec/System/TestItemParse_spec.lua @@ -715,6 +715,53 @@ describe("TestAdvancedItemParse #item", function() return build.calcsTab.mainEnv.modDB:Sum("INC", { flags = ModFlag.Spell }, "Damage") end + it("scales advanced-copy Simplex Amulet explicit mods on the first parse", function() + local rawItem = [[ + Item Class: Amulets + Rarity: Rare + Grim Collar + Simplex Amulet + -------- + Quality (Critical Modifiers): +20% (augmented) + -------- + Requirements: + Level: 64 + -------- + Item Level: 87 + -------- + Allocates Force of Darkness (enchant) + -------- + { Implicit Modifier } + -2 Prefix Modifiers allowed + -1 Suffix Modifier allowed + Implicit Modifiers Cannot Be Changed + 100% increased Explicit Modifier magnitudes + -------- + { Prefix Modifier "The Elder's" (Tier: 1) — Damage, Chaos — 100% Increased } + Gain 13(3-5)% of Non-Chaos Damage as extra Chaos Damage + { Suffix Modifier "of Destruction" (Tier: 1) — Damage, Critical — 120% Increased } + +70(35-38)% to Global Critical Strike Multiplier + { Suffix Modifier "of Amassment" — Drop — 100% Increased } + 20(17-20)% increased Quantity of Items found + -------- + Mirrored + -------- + Split + -------- + Shaper Item + Elder Item + ]] + + build.itemsTab:CreateDisplayItemFromRaw(rawItem, true) + local firstItem = build.itemsTab.displayItem + assert.are.equals("Gain 13% of Non-Chaos Damage as extra Chaos Damage", firstItem.explicitModLines[1].line) + assert.are.equals(2, firstItem.explicitModLines[1].valueScalar) + assert.are.equals("+70% to Global Critical Strike Multiplier", firstItem.explicitModLines[2].line) + assert.are.equals(2.2, firstItem.explicitModLines[2].valueScalar) + assert.are.equals("20% increased Quantity of Items found", firstItem.explicitModLines[3].line) + assert.are.equals(2, firstItem.explicitModLines[3].valueScalar) + end) + it("scales matching implicit mods by modifier magnitude", function() -- 130% * 1.7 = 221 build.itemsTab:CreateDisplayItemFromRaw([[ diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 7171ea2db2..c0dbf3dae8 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -1844,10 +1844,13 @@ function ItemsTabClass:SetDisplayItem(item) end end end - self.controls.displayItemInfluence:SetSel(influence1, true) -- Don't call the selection function for the first influence dropdown as the second dropdown isn't properly set yet. - self.controls.displayItemInfluence2:SetSel(influence2) -- The selection function for the second dropdown properly handles everything for both dropdowns + -- The item already has its parsed influences, so initialising these controls must not + -- run their selection functions. Doing so re-crafts advanced-copy rare items and loses + -- their imported rolls and modifier-magnitude scaling. + self.controls.displayItemInfluence:SetSel(influence1, true) + self.controls.displayItemInfluence2:SetSel(influence2, true) 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 From bf34c2446618df1904ddc335a7ba1cd78d74bed6 Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Mon, 27 Jul 2026 10:39:13 +1000 Subject: [PATCH 02/12] Crucible and mod order --- spec/System/TestItemParse_spec.lua | 130 +++++++++++++++++++++++++++-- src/Classes/Item.lua | 47 +++++++++-- 2 files changed, 164 insertions(+), 13 deletions(-) diff --git a/spec/System/TestItemParse_spec.lua b/spec/System/TestItemParse_spec.lua index 53775da0cd..302640c459 100644 --- a/spec/System/TestItemParse_spec.lua +++ b/spec/System/TestItemParse_spec.lua @@ -694,6 +694,106 @@ describe("TestAdvancedItemParse #item", function() Note: ~b/o 2 chaos ]]) end) + + it("parses allocated Crucible passive skills from advanced copy", function() + local item = new("Item", [[ + Item Class: Bows + Rarity: Rare + Brimstone Arch + Synthesised Citadel Bow + -------- + Item Level: 83 + -------- + { Allocated Crucible Passive Skill (Tier: 1) } + +1 to Level of Socketed Dexterity Gems + { Allocated Crucible Passive Skill (Tier: 3) } + -3% to Critical Strike Chance + +100% to Global Critical Strike Multiplier + { Allocated Crucible Passive Skill (Tier: 2) } + 20% reduced Flask Charges gained + Flasks applied to you have 15% increased Effect + { Allocated Crucible Passive Skill (Tier: 1) } + Rampage + (You gain Rampage bonuses for Killing multiple Enemies in quick succession) + -------- + Synthesised Item + ]]) + + assert.are.equals(6, #item.crucibleModLines) + assert.are.equals(0, #item.explicitModLines) + local actualLines = { } + for _, modLine in ipairs(item.crucibleModLines) do + assert.is_true(modLine.crucible) + table.insert(actualLines, modLine.line) + end + assert.are.same({ + "+1 to Level of Socketed Dexterity Gems", + "-3% to Critical Strike Chance", + "+100% to Global Critical Strike Multiplier", + "20% reduced Flask Charges gained", + "Flasks applied to you have 15% increased Effect", + "Rampage", + }, actualLines) + end) + + it("normalises socketed gem requirements and crafted mod order on import", function() + local item = new("Item", [[ + Item Class: Bows + Rarity: Rare + Brimstone Arch + Synthesised Citadel Bow + -------- + Requirements: + Level: 82 + Str: 126 (unmet) + Dex: 185 (unmet) + Int: 129 (unmet) + -------- + Sockets: W-W-W-W-W-W + -------- + Item Level: 83 + -------- + { Prefix Modifier "Paragon's" (Tier: 1) — Gem } + +1 to Level of Socketed Gems + { Master Crafted Prefix Modifier "Upgraded" — Gem } + +2 to Level of Socketed Support Gems + { Master Crafted Prefix Modifier "Upgraded" — Damage, Elemental, Cold, Chaos } + Gain 16(14-16)% of Cold Damage as Extra Chaos Damage + { Suffix Modifier "of the Underground" (Tier: 1) } + Non-Aura Vaal Skills require 40% reduced Souls Per Use + { Suffix Modifier "of the Order" (Tier: 1) — Damage } + 14(12-14)% chance to deal Double Damage + { Suffix Modifier "of Destruction" (Tier: 1) — Damage, Critical } + +38(35-38)% to Global Critical Strike Multiplier + ]]) + + assert.are.same({ str = 0, dex = 185, int = 0 }, { + str = item.requirements.strMod, + dex = item.requirements.dexMod, + int = item.requirements.intMod, + }) + local expectedLines = { + "+1 to Level of Socketed Gems", + "+38% to Global Critical Strike Multiplier", + "Non-Aura Vaal Skills require 40% reduced Souls Per Use", + "+2 to Level of Socketed Support Gems", + "Gain (14-16)% of Cold Damage as Extra Chaos Damage", + "(12-14)% chance to deal Double Damage", + } + local importedLines = { } + for _, modLine in ipairs(item.explicitModLines) do + table.insert(importedLines, modLine.line) + end + assert.are.same(expectedLines, importedLines) + + item:Craft() + local rebuiltLines = { } + for _, modLine in ipairs(item.explicitModLines) do + table.insert(rebuiltLines, modLine.line) + end + assert.are.same(expectedLines, rebuiltLines) + end) + describe("mod magnitude scaling", function() before_each(function() newBuild() @@ -754,12 +854,30 @@ describe("TestAdvancedItemParse #item", function() build.itemsTab:CreateDisplayItemFromRaw(rawItem, true) local firstItem = build.itemsTab.displayItem - assert.are.equals("Gain 13% of Non-Chaos Damage as extra Chaos Damage", firstItem.explicitModLines[1].line) - assert.are.equals(2, firstItem.explicitModLines[1].valueScalar) - assert.are.equals("+70% to Global Critical Strike Multiplier", firstItem.explicitModLines[2].line) - assert.are.equals(2.2, firstItem.explicitModLines[2].valueScalar) - assert.are.equals("20% increased Quantity of Items found", firstItem.explicitModLines[3].line) - assert.are.equals(2, firstItem.explicitModLines[3].valueScalar) + local scalarsByLine = { } + for _, modLine in ipairs(firstItem.explicitModLines) do + scalarsByLine[modLine.line] = modLine.valueScalar + end + assert.are.equals(2, scalarsByLine["Gain 13% of Non-Chaos Damage as extra Chaos Damage"]) + assert.are.equals(2.2, scalarsByLine["+70% to Global Critical Strike Multiplier"]) + assert.are.equals(2, scalarsByLine["20% increased Quantity of Items found"]) + + firstItem.prefixes[1].range = 1 + firstItem:Craft() + local rebuiltScalars = { } + for _, modLine in ipairs(firstItem.explicitModLines) do + if modLine.line:find("Non%-Chaos Damage") then + rebuiltScalars.chaos = modLine.valueScalar + elseif modLine.line:find("Global Critical Strike Multiplier") then + rebuiltScalars.critical = modLine.valueScalar + elseif modLine.line:find("Quantity of Items found") then + rebuiltScalars.quantity = modLine.valueScalar + end + end + assert.are.equals(2, rebuiltScalars.chaos) + -- Craft() bakes the catalyst into the rebuilt line before Simplex scaling. + assert.are.equals(2, rebuiltScalars.critical) + assert.are.equals(2, rebuiltScalars.quantity) end) it("scales matching implicit mods by modifier magnitude", function() diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index 67476cf1f8..68a937f917 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -61,6 +61,28 @@ local function getCatalystScalar(catalystId, mod, quality) return 1 end +local function sortCraftedModLines(modLines) + local sourceOrder = { } + for index, modLine in ipairs(modLines) do + sourceOrder[modLine] = index + end + table.sort(modLines, function(a, b) + local aSaved = a.crafted or a.custom + local bSaved = b.crafted or b.custom + if aSaved ~= bSaved then + return not aSaved + elseif not aSaved and a.order ~= b.order then + if not a.order then + return false + elseif not b.order then + return true + end + return a.order < b.order + end + return sourceOrder[a] < sourceOrder[b] + end) +end + local influenceInfo = itemLib.influenceInfo.all local ItemClass = newClass("Item", function(self, raw, rarity, highQuality) @@ -501,6 +523,9 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) end end end + if fullModName:match("^Allocated Crucible Passive Skill") then + linePrefix = linePrefix .. "{crucible}" + end if modTags and modTags ~= "" then linePrefix = linePrefix .. "{tags:" .. modTags:lower():gsub("%s+", "") .. "}" end @@ -890,6 +915,8 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) -- 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 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*)") @@ -1107,6 +1134,13 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) if self.baseName and self.title then self.name = self.title .. ", " .. self.baseName:gsub(" %(.+%)","") end + if self.base and #self.sockets > 0 then + -- In-game requirement totals include requirements from socketed gems. + -- Derive the item's attribute requirements from its base and local mods instead. + self.requirements.str = self.base.req.str or 0 + self.requirements.dex = self.base.req.dex or 0 + self.requirements.int = self.base.req.int or 0 + end if self.base and not self.requirements.level then if importedLevelReq and #self.sockets == 0 then -- Requirements on imported items can only be trusted for items with no sockets @@ -1123,7 +1157,7 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) end end end - if self.advancedCopy then + if self.advancedCopy or self.crafted then -- apply mod magnitude boost to matching mods if #self.modMagnitudeMods > 0 then for _, modMagnitudeMod in ipairs(self.modMagnitudeMods) do @@ -1174,6 +1208,9 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) end end end + if self.advancedCopy and #self.explicitModLines > 1 then + sortCraftedModLines(self.explicitModLines) + end self.affixLimit = 0 if self.crafted then if not self.affixes then @@ -1734,12 +1771,7 @@ function ItemClass:Craft() elseif mod.type == "Suffix" then modLine.suffix = true end - for l = 1, #self.explicitModLines + 1 do - if not self.explicitModLines[l] or self.explicitModLines[l].order > order then - t_insert(self.explicitModLines, l, modLine) - break - end - end + t_insert(self.explicitModLines, modLine) statOrder[order] = modLine end end @@ -1751,6 +1783,7 @@ function ItemClass:Craft() for _, mod in ipairs(savedMods) do t_insert(self.explicitModLines, mod) end + sortCraftedModLines(self.explicitModLines) self:BuildAndParseRaw() end From a4ae4d0d78a7f8812d3ae3c1312f741bad0b7ba6 Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Tue, 28 Jul 2026 11:48:20 +1000 Subject: [PATCH 03/12] Much finer roll range Timeless jewels have a range of 8k possible values so having it use only 100 incremements meant that it would round the final seed to whole numbers in large increments #10017 --- spec/System/TestItemParse_spec.lua | 64 ++++++++++++++++++++++++------ src/Classes/Item.lua | 55 +++++++++++++++++-------- src/Classes/ItemsTab.lua | 4 +- 3 files changed, 93 insertions(+), 30 deletions(-) diff --git a/spec/System/TestItemParse_spec.lua b/spec/System/TestItemParse_spec.lua index 302640c459..44e4b7c31a 100644 --- a/spec/System/TestItemParse_spec.lua +++ b/spec/System/TestItemParse_spec.lua @@ -794,6 +794,37 @@ describe("TestAdvancedItemParse #item", function() assert.are.same(expectedLines, rebuiltLines) end) + it("preserves rolls from large advanced-copy ranges", function() + local item = new("Item", [[ + Item Class: Jewels + Rarity: Unique + Elegant Hubris + Timeless Jewel + -------- + Limited to: 1 Historic + Radius: Large + -------- + Item Level: 86 + -------- + { Unique Modifier } + Commissioned 150720(2000-160000) coins to commemorate Chitus(Cadiro-Victario) + Passives in radius are Conquered by the Eternal Empire + (Conquered Passive Skills cannot be modified by other Jewels) + Historic + -------- + They believed themselves better than the past, but that confidence brought about nightmare. + -------- + Place into an allocated Jewel Socket on the Passive Skill Tree. Right click to remove from the Socket. + ]]) + + local seedLine = itemLib.applyRange(item.explicitModLines[1].line, item.explicitModLines[1].range) + assert.are.equals("Commissioned 150720 coins to commemorate Chitus", seedLine) + + item:BuildAndParseRaw() + seedLine = itemLib.applyRange(item.explicitModLines[1].line, item.explicitModLines[1].range) + assert.are.equals("Commissioned 150720 coins to commemorate Chitus", seedLine) + end) + describe("mod magnitude scaling", function() before_each(function() newBuild() @@ -861,23 +892,30 @@ describe("TestAdvancedItemParse #item", function() assert.are.equals(2, scalarsByLine["Gain 13% of Non-Chaos Damage as extra Chaos Damage"]) assert.are.equals(2.2, scalarsByLine["+70% to Global Critical Strike Multiplier"]) assert.are.equals(2, scalarsByLine["20% increased Quantity of Items found"]) + assert.are.equals(5, firstItem.prefixes[1].range) + assert.are.equals(11.667, firstItem.suffixes[1].range) + assert.are.equals(1, firstItem.suffixes[2].range) + + -- Changing a different affix must not normalise either legacy roll. + firstItem.suffixes[2].range = 0 + firstItem:Craft() + local rebuiltScalarsByLine = { } + for _, modLine in ipairs(firstItem.explicitModLines) do + rebuiltScalarsByLine[modLine.line] = modLine.valueScalar + end + assert.are.equals(2, rebuiltScalarsByLine["Gain 13% of Non-Chaos Damage as extra Chaos Damage"]) + assert.are.equals(2.2, rebuiltScalarsByLine["+70% to Global Critical Strike Multiplier"]) + assert.are.equals(2, rebuiltScalarsByLine["17% increased Quantity of Items found"]) - firstItem.prefixes[1].range = 1 + -- Editing the legacy affix itself deliberately returns it to the current range. + firstItem.suffixes[1].range = 1 firstItem:Craft() - local rebuiltScalars = { } + local editedScalarsByLine = { } for _, modLine in ipairs(firstItem.explicitModLines) do - if modLine.line:find("Non%-Chaos Damage") then - rebuiltScalars.chaos = modLine.valueScalar - elseif modLine.line:find("Global Critical Strike Multiplier") then - rebuiltScalars.critical = modLine.valueScalar - elseif modLine.line:find("Quantity of Items found") then - rebuiltScalars.quantity = modLine.valueScalar - end + editedScalarsByLine[modLine.line] = modLine.valueScalar end - assert.are.equals(2, rebuiltScalars.chaos) - -- Craft() bakes the catalyst into the rebuilt line before Simplex scaling. - assert.are.equals(2, rebuiltScalars.critical) - assert.are.equals(2, rebuiltScalars.quantity) + assert.are.equals(2, editedScalarsByLine["Gain 13% of Non-Chaos Damage as extra Chaos Damage"]) + assert.are.equals(2.2, editedScalarsByLine["+38% to Global Critical Strike Multiplier"]) end) it("scales matching implicit mods by modifier magnitude", function() diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index 68a937f917..5792dd3b4e 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -83,6 +83,20 @@ local function sortCraftedModLines(modLines) end) end +local function getAdvancedCopyRange(value, min, max) + value = tonumber(value) + min = tonumber(min) + max = tonumber(max) + local delta = max - min + if delta <= 0 then + return delta + end + -- Preserve enough decimal places to reconstruct any discrete value in the + -- interval. Three places is insufficient for large Timeless Jewel ranges. + local precision = m_max(3, m_floor(math.log(delta, 10)) + 1) + return delta, round((value - min) / delta, precision) +end + local influenceInfo = itemLib.influenceInfo.all local ItemClass = newClass("Item", function(self, raw, rarity, highQuality) @@ -675,14 +689,14 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) elseif specName == "Implicit" then self.implicit = true elseif specName == "Prefix" then - local range, affix = specVal:match("{range:([%d.]+)}(.+)") + local range, affix = specVal:match("{range:([^}]+)}(.+)") 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.]+)}(.+)") + local range, affix = specVal:match("{range:([^}]+)}(.+)") range = range or ((affix or specVal) ~= "None" and main.defaultItemAffixQuality) t_insert(self.suffixes, { modId = affix or specVal, @@ -892,6 +906,9 @@ 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 @@ -920,16 +937,18 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) 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 + local delta, precisionRange = getAdvancedCopyRange(value, min, max) line = line:gsub(value .. "%(" .. range:gsub("%-", "%%-") .. "%)", value) - if delta > bestPrecisionDelta then - bestPrecisionRange = round((value - min) / delta, 3) + if precisionRange and delta > bestPrecisionDelta then + bestPrecisionRange = precisionRange bestPrecisionDelta = delta end 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 = bestPrecisionDelta > 0 and bestPrecisionRange or 0.5, }) self.pendingAffixList = {} else @@ -942,12 +961,12 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) 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 - if delta > bestPrecisionDelta then - bestPrecisionRange = round((value - min) / delta, 3) + local delta, precisionRange = getAdvancedCopyRange(value, min, max) + if precisionRange and delta > bestPrecisionDelta then + bestPrecisionRange = precisionRange bestPrecisionDelta = delta end - if bestPrecisionRange > 1 or bestPrecisionRange < 0 then + if precisionRange and (precisionRange > 1 or precisionRange < 0) then line = line:gsub(value .. "%(" .. range:gsub("%-", "%%-") .. "%)", value) else line = line:gsub(value .. "%(" .. range:gsub("%-", "%%-") .. "%)", (tonumber(value) < 0 and "+" or "") .. "(" .. range .. ")") @@ -1533,10 +1552,10 @@ function ItemClass:BuildRaw() 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) + t_insert(rawLines, "Prefix: " .. (affix.range and ("{range:" .. round(affix.range,6) .. "}") or "") .. 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) + t_insert(rawLines, "Suffix: " .. (affix.range and ("{range:" .. round(affix.range,6) .. "}") or "") .. affix.modId) end end if self.catalyst and self.catalyst > 0 then @@ -1562,7 +1581,7 @@ function ItemClass:BuildRaw() local function writeModLine(modLine) local line = modLine.line 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 @@ -1752,9 +1771,8 @@ function ItemClass:Craft() self.nameSuffix = self.nameSuffix .. " " .. mod.affix end self.requirements.level = m_max(self.requirements.level or 0, m_floor(mod.level * 0.8)) - local rangeScalar = getCatalystScalar(self.catalyst, mod, self.catalystQuality) for i, line in ipairs(mod) do - line = itemLib.applyRange(line, affix.range or 0.5, rangeScalar) + line = itemLib.applyRange(line, affix.range or 0.5) local order = mod.statOrder[i] if statOrder[order] then -- Combine stats @@ -1765,7 +1783,12 @@ function ItemClass:Craft() return tonumber(num) + tonumber(other) end) else - local modLine = { line = line, order = order, type = mod.type } + local modLine = { + line = line, + order = order, + type = mod.type, + modTags = mod.modTags or { }, + } if mod.type == "Prefix" then modLine.prefix = true elseif mod.type == "Suffix" then diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index c0dbf3dae8..10f803d4f2 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -2056,7 +2056,9 @@ 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 range = m_min(1, m_max(0, item[outputTable][outputIndex].range 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 From faf5f5ef4a3f9a7d00118eba6cae8b8fa73bd4d1 Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Tue, 28 Jul 2026 11:49:44 +1000 Subject: [PATCH 04/12] Fix flask import Flasks had a bunch of extra lines that weren't needed --- spec/System/TestItemParse_spec.lua | 64 ++++++++++++++++++++++++++++++ src/Classes/Item.lua | 8 +++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/spec/System/TestItemParse_spec.lua b/spec/System/TestItemParse_spec.lua index 44e4b7c31a..f77804c65e 100644 --- a/spec/System/TestItemParse_spec.lua +++ b/spec/System/TestItemParse_spec.lua @@ -794,6 +794,70 @@ describe("TestAdvancedItemParse #item", function() assert.are.same(expectedLines, rebuiltLines) end) + it("filters flask base properties and parses fixed-value advanced rolls", function() + local item = new("Item", [[ + Item Class: Utility Flasks + Rarity: Unique + Soul Catcher + Quartz Flask + -------- + Quality: +20% (augmented) + Lasts 7.20 (augmented) Seconds + Consumes 30 of 60 Charges on use + Currently has 59 Charges + +10% chance to Suppress Spell Damage + (40% of Damage from Suppressed Hits and Ailments they inflict is prevented) + Phasing + (While you have Phasing, your movement is not blocked by Enemies) + -------- + Requirements: + Level: 27 + -------- + Item Level: 79 + -------- + Used when Charges reach full (enchant) + -------- + { Unique Modifier — Mana } + Cannot gain Mana during effect + { Unique Modifier } + 93(60-80)% increased Damage with Vaal Skills during effect + { Unique Modifier } + Non-Aura Vaal Skills require 25% reduced Souls Per Use during Effect + { Unique Modifier } + Vaal Skills used during effect have 40(10)% reduced Soul Gain Prevention Duration + -------- + Freedom is for the privileged, even in death. + + This item can be transformed on the Altar of Sacrifice along with Vial of the Ghost + -------- + Right click to drink. Can only hold charges while in belt. Refills as you kill monsters. + ]]) + + assert.are.equals(2, #item.buffModLines) + assert.are.same({ + "+10% chance to Suppress Spell Damage", + "Phasing", + }, { + item.buffModLines[1].line, + item.buffModLines[2].line, + }) + assert.are.equals(1, #item.enchantModLines) + assert.are.equals("Used when Charges reach full", item.enchantModLines[1].line) + assert.are.equals(0, #item.implicitModLines) + assert.are.equals(4, #item.explicitModLines) + assert.are.same({ + "Cannot gain Mana during effect", + "93% increased Damage with Vaal Skills during effect", + "Non-Aura Vaal Skills require 25% reduced Souls Per Use during Effect", + "Vaal Skills used during effect have 40% reduced Soul Gain Prevention Duration", + }, { + item.explicitModLines[1].line, + item.explicitModLines[2].line, + item.explicitModLines[3].line, + item.explicitModLines[4].line, + }) + end) + it("preserves rolls from large advanced-copy ranges", function() local item = new("Item", [[ Item Class: Jewels diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index 5792dd3b4e..913bfbe37d 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -478,11 +478,17 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) self[influenceItemMap[line]] = true elseif line == "Requirements:" then -- nothing to do - elseif line:match("^%(%a+") then + elseif line:match("^%(%a+") or line:match("^%(%d+%%? of ") then -- Reminder text, nothing to parse 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("^Lasts .+ 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 From 2a186343e9170944fb6833393d893efe6eedfc1c Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Tue, 28 Jul 2026 13:20:44 +1000 Subject: [PATCH 05/12] Cleanup --- spec/System/TestItemParse_spec.lua | 205 +++++++---------------------- src/Classes/Item.lua | 66 ++++------ src/Classes/ItemsTab.lua | 4 +- 3 files changed, 72 insertions(+), 203 deletions(-) diff --git a/spec/System/TestItemParse_spec.lua b/spec/System/TestItemParse_spec.lua index f77804c65e..360d43f045 100644 --- a/spec/System/TestItemParse_spec.lua +++ b/spec/System/TestItemParse_spec.lua @@ -696,55 +696,31 @@ describe("TestAdvancedItemParse #item", function() end) it("parses allocated Crucible passive skills from advanced copy", function() - local item = new("Item", [[ - Item Class: Bows - Rarity: Rare - Brimstone Arch - Synthesised Citadel Bow - -------- - Item Level: 83 - -------- + local item = new("Item", raw([[ { Allocated Crucible Passive Skill (Tier: 1) } - +1 to Level of Socketed Dexterity Gems - { Allocated Crucible Passive Skill (Tier: 3) } -3% to Critical Strike Chance +100% to Global Critical Strike Multiplier - { Allocated Crucible Passive Skill (Tier: 2) } - 20% reduced Flask Charges gained - Flasks applied to you have 15% increased Effect { Allocated Crucible Passive Skill (Tier: 1) } Rampage (You gain Rampage bonuses for Killing multiple Enemies in quick succession) - -------- - Synthesised Item - ]]) + ]], "Citadel Bow")) - assert.are.equals(6, #item.crucibleModLines) + assert.are.equals(3, #item.crucibleModLines) assert.are.equals(0, #item.explicitModLines) - local actualLines = { } - for _, modLine in ipairs(item.crucibleModLines) do - assert.is_true(modLine.crucible) - table.insert(actualLines, modLine.line) - end assert.are.same({ - "+1 to Level of Socketed Dexterity Gems", "-3% to Critical Strike Chance", "+100% to Global Critical Strike Multiplier", - "20% reduced Flask Charges gained", - "Flasks applied to you have 15% increased Effect", "Rampage", - }, actualLines) + }, { + item.crucibleModLines[1].line, + item.crucibleModLines[2].line, + item.crucibleModLines[3].line, + }) end) - it("normalises socketed gem requirements and crafted mod order on import", function() - local item = new("Item", [[ - Item Class: Bows - Rarity: Rare - Brimstone Arch - Synthesised Citadel Bow - -------- + it("ignores attribute requirements from socketed gems", function() + local item = new("Item", raw([[ Requirements: - Level: 82 Str: 126 (unmet) Dex: 185 (unmet) Int: 129 (unmet) @@ -752,133 +728,77 @@ describe("TestAdvancedItemParse #item", function() Sockets: W-W-W-W-W-W -------- Item Level: 83 - -------- - { Prefix Modifier "Paragon's" (Tier: 1) — Gem } - +1 to Level of Socketed Gems - { Master Crafted Prefix Modifier "Upgraded" — Gem } - +2 to Level of Socketed Support Gems - { Master Crafted Prefix Modifier "Upgraded" — Damage, Elemental, Cold, Chaos } - Gain 16(14-16)% of Cold Damage as Extra Chaos Damage - { Suffix Modifier "of the Underground" (Tier: 1) } - Non-Aura Vaal Skills require 40% reduced Souls Per Use - { Suffix Modifier "of the Order" (Tier: 1) — Damage } - 14(12-14)% chance to deal Double Damage - { Suffix Modifier "of Destruction" (Tier: 1) — Damage, Critical } - +38(35-38)% to Global Critical Strike Multiplier - ]]) + ]], "Citadel Bow")) assert.are.same({ str = 0, dex = 185, int = 0 }, { str = item.requirements.strMod, dex = item.requirements.dexMod, int = item.requirements.intMod, }) + end) + + it("uses crafted mod order for advanced-copy imports", function() + local item = new("Item", raw([[ + Item Level: 83 + { Prefix Modifier "Paragon's" (Tier: 1) — Gem } + +1 to Level of Socketed Gems + { Master Crafted Prefix Modifier "Upgraded" — Gem } + +2 to Level of Socketed Support Gems + { Suffix Modifier "of Destruction" (Tier: 1) — Damage, Critical } + +38(35-38)% to Global Critical Strike Multiplier + ]], "Citadel Bow")) local expectedLines = { "+1 to Level of Socketed Gems", "+38% to Global Critical Strike Multiplier", - "Non-Aura Vaal Skills require 40% reduced Souls Per Use", "+2 to Level of Socketed Support Gems", - "Gain (14-16)% of Cold Damage as Extra Chaos Damage", - "(12-14)% chance to deal Double Damage", } - local importedLines = { } - for _, modLine in ipairs(item.explicitModLines) do - table.insert(importedLines, modLine.line) - end - assert.are.same(expectedLines, importedLines) + assert.are.same(expectedLines, { + item.explicitModLines[1].line, + item.explicitModLines[2].line, + item.explicitModLines[3].line, + }) item:Craft() - local rebuiltLines = { } - for _, modLine in ipairs(item.explicitModLines) do - table.insert(rebuiltLines, modLine.line) - end - assert.are.same(expectedLines, rebuiltLines) + assert.are.same(expectedLines, { + item.explicitModLines[1].line, + item.explicitModLines[2].line, + item.explicitModLines[3].line, + }) end) it("filters flask base properties and parses fixed-value advanced rolls", function() local item = new("Item", [[ - Item Class: Utility Flasks Rarity: Unique Soul Catcher Quartz Flask -------- - Quality: +20% (augmented) Lasts 7.20 (augmented) Seconds Consumes 30 of 60 Charges on use Currently has 59 Charges +10% chance to Suppress Spell Damage (40% of Damage from Suppressed Hits and Ailments they inflict is prevented) Phasing - (While you have Phasing, your movement is not blocked by Enemies) -------- - Requirements: - Level: 27 - -------- - Item Level: 79 - -------- - Used when Charges reach full (enchant) - -------- - { Unique Modifier — Mana } - Cannot gain Mana during effect - { Unique Modifier } - 93(60-80)% increased Damage with Vaal Skills during effect { Unique Modifier } - Non-Aura Vaal Skills require 25% reduced Souls Per Use during Effect + Consumes Maximum Charges to use { Unique Modifier } Vaal Skills used during effect have 40(10)% reduced Soul Gain Prevention Duration - -------- - Freedom is for the privileged, even in death. - - This item can be transformed on the Altar of Sacrifice along with Vial of the Ghost - -------- - Right click to drink. Can only hold charges while in belt. Refills as you kill monsters. ]]) assert.are.equals(2, #item.buffModLines) - assert.are.same({ - "+10% chance to Suppress Spell Damage", - "Phasing", - }, { - item.buffModLines[1].line, - item.buffModLines[2].line, - }) - assert.are.equals(1, #item.enchantModLines) - assert.are.equals("Used when Charges reach full", item.enchantModLines[1].line) assert.are.equals(0, #item.implicitModLines) - assert.are.equals(4, #item.explicitModLines) - assert.are.same({ - "Cannot gain Mana during effect", - "93% increased Damage with Vaal Skills during effect", - "Non-Aura Vaal Skills require 25% reduced Souls Per Use during Effect", - "Vaal Skills used during effect have 40% reduced Soul Gain Prevention Duration", - }, { - item.explicitModLines[1].line, - item.explicitModLines[2].line, - item.explicitModLines[3].line, - item.explicitModLines[4].line, - }) + assert.are.equals(2, #item.explicitModLines) + assert.are.equals("Consumes Maximum Charges to use", item.explicitModLines[1].line) + assert.are.equals("Vaal Skills used during effect have 40% reduced Soul Gain Prevention Duration", item.explicitModLines[2].line) end) it("preserves rolls from large advanced-copy ranges", function() local item = new("Item", [[ - Item Class: Jewels Rarity: Unique Elegant Hubris Timeless Jewel - -------- - Limited to: 1 Historic - Radius: Large - -------- - Item Level: 86 - -------- { Unique Modifier } Commissioned 150720(2000-160000) coins to commemorate Chitus(Cadiro-Victario) - Passives in radius are Conquered by the Eternal Empire - (Conquered Passive Skills cannot be modified by other Jewels) - Historic - -------- - They believed themselves better than the past, but that confidence brought about nightmare. - -------- - Place into an allocated Jewel Socket on the Passive Skill Tree. Right click to remove from the Socket. ]]) local seedLine = itemLib.applyRange(item.explicitModLines[1].line, item.explicitModLines[1].range) @@ -912,74 +832,47 @@ describe("TestAdvancedItemParse #item", function() it("scales advanced-copy Simplex Amulet explicit mods on the first parse", function() local rawItem = [[ - Item Class: Amulets Rarity: Rare Grim Collar Simplex Amulet - -------- Quality (Critical Modifiers): +20% (augmented) - -------- - Requirements: - Level: 64 - -------- - Item Level: 87 - -------- - Allocates Force of Darkness (enchant) - -------- { Implicit Modifier } -2 Prefix Modifiers allowed -1 Suffix Modifier allowed - Implicit Modifiers Cannot Be Changed 100% increased Explicit Modifier magnitudes - -------- { Prefix Modifier "The Elder's" (Tier: 1) — Damage, Chaos — 100% Increased } Gain 13(3-5)% of Non-Chaos Damage as extra Chaos Damage { Suffix Modifier "of Destruction" (Tier: 1) — Damage, Critical — 120% Increased } +70(35-38)% to Global Critical Strike Multiplier { Suffix Modifier "of Amassment" — Drop — 100% Increased } 20(17-20)% increased Quantity of Items found - -------- - Mirrored - -------- - Split - -------- Shaper Item Elder Item ]] build.itemsTab:CreateDisplayItemFromRaw(rawItem, true) local firstItem = build.itemsTab.displayItem - local scalarsByLine = { } - for _, modLine in ipairs(firstItem.explicitModLines) do - scalarsByLine[modLine.line] = modLine.valueScalar + local function findModLine(line) + for _, modLine in ipairs(firstItem.explicitModLines) do + if modLine.line == line then + return modLine + end + end end - assert.are.equals(2, scalarsByLine["Gain 13% of Non-Chaos Damage as extra Chaos Damage"]) - assert.are.equals(2.2, scalarsByLine["+70% to Global Critical Strike Multiplier"]) - assert.are.equals(2, scalarsByLine["20% increased Quantity of Items found"]) - assert.are.equals(5, firstItem.prefixes[1].range) - assert.are.equals(11.667, firstItem.suffixes[1].range) - assert.are.equals(1, firstItem.suffixes[2].range) + assert.are.equals(2, findModLine("Gain 13% of Non-Chaos Damage as extra Chaos Damage").valueScalar) + assert.are.equals(2.2, findModLine("+70% to Global Critical Strike Multiplier").valueScalar) -- Changing a different affix must not normalise either legacy roll. firstItem.suffixes[2].range = 0 firstItem:Craft() - local rebuiltScalarsByLine = { } - for _, modLine in ipairs(firstItem.explicitModLines) do - rebuiltScalarsByLine[modLine.line] = modLine.valueScalar - end - assert.are.equals(2, rebuiltScalarsByLine["Gain 13% of Non-Chaos Damage as extra Chaos Damage"]) - assert.are.equals(2.2, rebuiltScalarsByLine["+70% to Global Critical Strike Multiplier"]) - assert.are.equals(2, rebuiltScalarsByLine["17% increased Quantity of Items found"]) + assert.are.equals(2, findModLine("Gain 13% of Non-Chaos Damage as extra Chaos Damage").valueScalar) + assert.are.equals(2.2, findModLine("+70% to Global Critical Strike Multiplier").valueScalar) -- Editing the legacy affix itself deliberately returns it to the current range. firstItem.suffixes[1].range = 1 firstItem:Craft() - local editedScalarsByLine = { } - for _, modLine in ipairs(firstItem.explicitModLines) do - editedScalarsByLine[modLine.line] = modLine.valueScalar - end - assert.are.equals(2, editedScalarsByLine["Gain 13% of Non-Chaos Damage as extra Chaos Damage"]) - assert.are.equals(2.2, editedScalarsByLine["+38% to Global Critical Strike Multiplier"]) + assert.are.equals(2, findModLine("Gain 13% of Non-Chaos Damage as extra Chaos Damage").valueScalar) + assert.are.equals(2.2, findModLine("+38% to Global Critical Strike Multiplier").valueScalar) end) it("scales matching implicit mods by modifier magnitude", function() diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index 913bfbe37d..31915ac395 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -67,36 +67,17 @@ local function sortCraftedModLines(modLines) sourceOrder[modLine] = index end table.sort(modLines, function(a, b) - local aSaved = a.crafted or a.custom - local bSaved = b.crafted or b.custom - if aSaved ~= bSaved then - return not aSaved - elseif not aSaved and a.order ~= b.order then - if not a.order then - return false - elseif not b.order then - return true - end - return a.order < b.order + local aGroup = (a.crafted or a.custom) and 2 or 1 + local bGroup = (b.crafted or b.custom) and 2 or 1 + if aGroup ~= bGroup then + return aGroup < bGroup + elseif aGroup == 1 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 -local function getAdvancedCopyRange(value, min, max) - value = tonumber(value) - min = tonumber(min) - max = tonumber(max) - local delta = max - min - if delta <= 0 then - return delta - end - -- Preserve enough decimal places to reconstruct any discrete value in the - -- interval. Three places is insufficient for large Timeless Jewel ranges. - local precision = m_max(3, m_floor(math.log(delta, 10)) + 1) - return delta, round((value - min) / delta, precision) -end - local influenceInfo = itemLib.influenceInfo.all local ItemClass = newClass("Item", function(self, raw, rarity, highQuality) @@ -485,8 +466,8 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) end elseif self.base and self.base.flask and ( line:match("^Lasts .+ Seconds$") - or line:match("^Consumes [%d.]+ of [%d.]+ Charges on use$") - or line:match("^Currently has [%d.]+ Charges$") + 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 @@ -943,10 +924,10 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) 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, precisionRange = getAdvancedCopyRange(value, min, max) + local delta = tonumber(max) - min line = line:gsub(value .. "%(" .. range:gsub("%-", "%%-") .. "%)", value) - if precisionRange and delta > bestPrecisionDelta then - bestPrecisionRange = precisionRange + if delta > bestPrecisionDelta then + bestPrecisionRange = round((value - min) / delta, 3) bestPrecisionDelta = delta end end @@ -967,12 +948,12 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) for value, range in line:gmatch("(%-?%d+%.?%d*)%((%-?%d+%.?%d*%-%-?%d+%.?%d*)%)") do local min, max = range:match("(%-?%d+%.?%d*)%-(%-?%d+%.?%d*)") - local delta, precisionRange = getAdvancedCopyRange(value, min, max) - if precisionRange and delta > bestPrecisionDelta then - bestPrecisionRange = precisionRange + local delta = tonumber(max) - min + if delta > bestPrecisionDelta then + bestPrecisionRange = round((value - min) / delta, 6) bestPrecisionDelta = delta end - if precisionRange and (precisionRange > 1 or precisionRange < 0) then + 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 .. ")") @@ -1233,7 +1214,7 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) end end end - if self.advancedCopy and #self.explicitModLines > 1 then + if self.advancedCopy and (#self.prefixes > 0 or #self.suffixes > 0) and #self.explicitModLines > 1 then sortCraftedModLines(self.explicitModLines) end self.affixLimit = 0 @@ -1558,10 +1539,10 @@ function ItemClass:BuildRaw() 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,6) .. "}") or "") .. affix.modId) + t_insert(rawLines, "Prefix: " .. (affix.range and ("{range:" .. round(affix.range,3) .. "}") or "") .. affix.modId) end for i, affix in ipairs(self.suffixes or { }) do - t_insert(rawLines, "Suffix: " .. (affix.range and ("{range:" .. round(affix.range,6) .. "}") or "") .. affix.modId) + t_insert(rawLines, "Suffix: " .. (affix.range and ("{range:" .. round(affix.range,3) .. "}") or "") .. affix.modId) end end if self.catalyst and self.catalyst > 0 then @@ -1789,12 +1770,7 @@ function ItemClass:Craft() return tonumber(num) + tonumber(other) end) else - local modLine = { - line = line, - order = order, - type = mod.type, - modTags = mod.modTags or { }, - } + local modLine = { line = line, order = order, type = mod.type, modTags = mod.modTags or { } } if mod.type == "Prefix" then modLine.prefix = true elseif mod.type == "Suffix" then @@ -1812,7 +1788,9 @@ function ItemClass:Craft() for _, mod in ipairs(savedMods) do t_insert(self.explicitModLines, mod) end - sortCraftedModLines(self.explicitModLines) + 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 10f803d4f2..2144c03676 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -1844,9 +1844,7 @@ function ItemsTabClass:SetDisplayItem(item) end end end - -- The item already has its parsed influences, so initialising these controls must not - -- run their selection functions. Doing so re-crafts advanced-copy rare items and loses - -- their imported rolls and modifier-magnitude scaling. + -- Initialising these controls must not re-craft the parsed item. self.controls.displayItemInfluence:SetSel(influence1, true) self.controls.displayItemInfluence2:SetSel(influence2, true) self.controls.displayItemQualityEdit:SetText(item.quality) From c3bc432a0bfc2a643837d2842a5a82347cc61706 Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Tue, 28 Jul 2026 13:43:54 +1000 Subject: [PATCH 06/12] ModCache --- src/Data/ModCache.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Data/ModCache.lua b/src/Data/ModCache.lua index dfdc865eb2..4b18c1d056 100755 --- a/src/Data/ModCache.lua +++ b/src/Data/ModCache.lua @@ -1341,6 +1341,7 @@ c["+33% to Cold Resistance"]={{[1]={flags=0,keywordFlags=0,name="ColdResist",typ c["+33% to Critical Strike Multiplier if Dexterity is higher than Intelligence"]={{[1]={[1]={type="Condition",var="DexHigherThanInt"},flags=0,keywordFlags=0,name="CritMultiplier",type="BASE",value=33}},nil} c["+33% to Fire Resistance"]={{[1]={flags=0,keywordFlags=0,name="FireResist",type="BASE",value=33}},nil} c["+33% to Global Critical Strike Multiplier"]={{[1]={[1]={type="Global"},flags=0,keywordFlags=0,name="CritMultiplier",type="BASE",value=33}},nil} +c["+33% to Lightning Resistance"]={{[1]={flags=0,keywordFlags=0,name="LightningResist",type="BASE",value=33}},nil} c["+33% to all Elemental Resistances"]={{[1]={flags=0,keywordFlags=0,name="ElementalResist",type="BASE",value=33}},nil} c["+330 to Accuracy Rating"]={{[1]={flags=0,keywordFlags=0,name="Accuracy",type="BASE",value=330}},nil} c["+333 to Accuracy Rating"]={{[1]={flags=0,keywordFlags=0,name="Accuracy",type="BASE",value=333}},nil} From 946758dd2bfac431385a9bbeaab6aacbbd125227 Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Tue, 28 Jul 2026 14:03:21 +1000 Subject: [PATCH 07/12] Fix paste of range damage mods --- spec/System/TestItemParse_spec.lua | 15 +++++++++++++++ src/Classes/Item.lua | 14 ++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/spec/System/TestItemParse_spec.lua b/spec/System/TestItemParse_spec.lua index 360d43f045..6872dd6d7d 100644 --- a/spec/System/TestItemParse_spec.lua +++ b/spec/System/TestItemParse_spec.lua @@ -809,6 +809,21 @@ describe("TestAdvancedItemParse #item", function() assert.are.equals("Commissioned 150720 coins to commemorate Chitus", seedLine) end) + it("preserves independently rolled values on the same modifier line", function() + local item = new("Item", [[ + Rarity: Unique + Prismweave + Rustic Sash + { Unique Modifier — Damage, Elemental, Fire, Attack } + Adds 16(14-16) to 32(30-32) Fire Damage to Attacks + { Unique Modifier — Damage, Elemental, Cold, Attack } + Adds 10(10-12) to 27(24-28) Cold Damage to Attacks + ]]) + + assert.are.equals("Adds (14-16) to (30-32) Fire Damage to Attacks", item.explicitModLines[1].line) + assert.are.equals("Adds 10 to 27 Cold Damage to Attacks", item.explicitModLines[2].line) + end) + describe("mod magnitude scaling", function() before_each(function() newBuild() diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index 31915ac395..a233257155 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -942,15 +942,23 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) -- 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") + 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, 6) + bestPrecisionRange = rollRange bestPrecisionDelta = delta end if bestPrecisionRange > 1 or bestPrecisionRange < 0 then @@ -959,7 +967,9 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) line = line:gsub(value .. "%(" .. range:gsub("%-", "%%-") .. "%)", (tonumber(value) < 0 and "+" or "") .. "(" .. range .. ")") 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 From 17bd8d530f2d2b105451b71daf5d80eec3819de2 Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Tue, 28 Jul 2026 14:04:56 +1000 Subject: [PATCH 08/12] Fix ordering of fractured mods + keep fractured state --- spec/System/TestItemParse_spec.lua | 28 +++++++++++++++++----------- src/Classes/Item.lua | 19 +++++++++++++------ src/Classes/ItemsTab.lua | 2 +- 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/spec/System/TestItemParse_spec.lua b/spec/System/TestItemParse_spec.lua index 6872dd6d7d..d0236aaed5 100644 --- a/spec/System/TestItemParse_spec.lua +++ b/spec/System/TestItemParse_spec.lua @@ -737,32 +737,38 @@ describe("TestAdvancedItemParse #item", function() }) end) - it("uses crafted mod order for advanced-copy imports", function() + it("orders fractured mods first and crafted mods last", function() local item = new("Item", raw([[ Item Level: 83 - { Prefix Modifier "Paragon's" (Tier: 1) — Gem } - +1 to Level of Socketed Gems - { Master Crafted Prefix Modifier "Upgraded" — Gem } - +2 to Level of Socketed Support Gems - { Suffix Modifier "of Destruction" (Tier: 1) — Damage, Critical } - +38(35-38)% to Global Critical Strike Multiplier - ]], "Citadel Bow")) + { Fractured Prefix Modifier "Cheetah's" (Tier: 2) — Speed } + 30% increased Movement Speed + { Prefix Modifier "Athlete's" (Tier: 1) — Life } + +128(115-129) to maximum Life + { Master Crafted Prefix Modifier "Upgraded" (Rank: 2) — Mana } + +43(35-44) to maximum Mana + { Suffix Modifier "of the Jaguar" (Tier: 3) — Attribute } + +41(38-42) to Dexterity + ]], "Dragonscale Boots")) local expectedLines = { - "+1 to Level of Socketed Gems", - "+38% to Global Critical Strike Multiplier", - "+2 to Level of Socketed Support Gems", + "30% increased Movement Speed", + "+41 to Dexterity", + "+128 to maximum Life", + "+(35-44) to maximum Mana", } assert.are.same(expectedLines, { item.explicitModLines[1].line, item.explicitModLines[2].line, item.explicitModLines[3].line, + item.explicitModLines[4].line, }) + item:Craft() item:Craft() assert.are.same(expectedLines, { item.explicitModLines[1].line, item.explicitModLines[2].line, item.explicitModLines[3].line, + item.explicitModLines[4].line, }) end) diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index a233257155..ca16976391 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -67,11 +67,11 @@ local function sortCraftedModLines(modLines) sourceOrder[modLine] = index end table.sort(modLines, function(a, b) - local aGroup = (a.crafted or a.custom) and 2 or 1 - local bGroup = (b.crafted or b.custom) and 2 or 1 + 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 == 1 and a.order ~= b.order then + 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] @@ -676,18 +676,24 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) elseif specName == "Implicit" then self.implicit = true elseif specName == "Prefix" then + local fractured = specVal:match("^{fractured}") and true + specVal = specVal:gsub("^{fractured}", "") local range, affix = specVal:match("{range:([^}]+)}(.+)") range = range or ((affix or specVal) ~= "None" and main.defaultItemAffixQuality) t_insert(self.prefixes, { modId = affix or specVal, range = tonumber(range), + fractured = fractured, }) elseif specName == "Suffix" then + local fractured = specVal:match("^{fractured}") and true + specVal = specVal:gsub("^{fractured}", "") local range, affix = specVal:match("{range:([^}]+)}(.+)") range = range or ((affix or specVal) ~= "None" and main.defaultItemAffixQuality) t_insert(self.suffixes, { modId = affix or specVal, range = tonumber(range), + fractured = fractured, }) elseif specName == "Implicits" then implicitLines = specToNumber(specVal) or 0 @@ -936,6 +942,7 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) -- Legacy modifiers can roll outside the current data range. Keep the -- extrapolated range so crafting a different affix doesn't normalise it. range = bestPrecisionDelta > 0 and bestPrecisionRange or 0.5, + fractured = modLine.fractured, }) self.pendingAffixList = {} else @@ -1549,10 +1556,10 @@ function ItemClass:BuildRaw() 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) + t_insert(rawLines, "Prefix: " .. (affix.fractured and "{fractured}" or "") .. (affix.range and ("{range:" .. round(affix.range,3) .. "}") or "") .. 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) + t_insert(rawLines, "Suffix: " .. (affix.fractured and "{fractured}" or "") .. (affix.range and ("{range:" .. round(affix.range,3) .. "}") or "") .. affix.modId) end end if self.catalyst and self.catalyst > 0 then @@ -1780,7 +1787,7 @@ function ItemClass:Craft() return tonumber(num) + tonumber(other) end) else - local modLine = { line = line, order = order, type = mod.type, modTags = mod.modTags or { } } + local modLine = { line = line, order = order, type = mod.type, modTags = mod.modTags or { }, fractured = affix.fractured } if mod.type == "Prefix" then modLine.prefix = true elseif mod.type == "Suffix" then diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 2144c03676..e7b2fdba96 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -734,7 +734,7 @@ holding Shift will put it in the second.]]) return range end drop = new("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 From 70b90931e55c612a43c847e3b0e39f6f6d8519b8 Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Tue, 28 Jul 2026 14:45:33 +1000 Subject: [PATCH 09/12] Unique mods order --- spec/System/TestItemParse_spec.lua | 36 +++++++++++++++++++++++++++++ src/Classes/Item.lua | 37 +++++++++++++++++++++++------- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/spec/System/TestItemParse_spec.lua b/spec/System/TestItemParse_spec.lua index d0236aaed5..b37f0d92f7 100644 --- a/spec/System/TestItemParse_spec.lua +++ b/spec/System/TestItemParse_spec.lua @@ -830,6 +830,42 @@ describe("TestAdvancedItemParse #item", function() assert.are.equals("Adds 10 to 27 Cold Damage to Attacks", item.explicitModLines[2].line) end) + it("orders advanced-copy unique modifiers by their database stat order", function() + local item = new("Item", [[ + Rarity: Unique + Geofri's Sanctuary + Elegant Ringmail + { Unique Modifier — Life } + +66(60-70) to maximum Life + { Unique Modifier — Defences, Energy Shield } + +31(30-40) to maximum Energy Shield + { Unique Modifier — Defences, Armour, Energy Shield } + 63(50-75)% increased Armour and Energy Shield + { Unique Modifier — Life, Defences, Energy Shield } + Zealot's Oath + { Unique Modifier — Defences, Energy Shield } + +2 maximum Energy Shield per 5 Strength + { Unique Modifier — Elemental, Resistance } + +18(14-18)% to all Elemental Resistances + ]]) + + assert.are.same({ + "(50-75)% increased Armour and Energy Shield", + "+(30-40) to maximum Energy Shield", + "+(60-70) to maximum Life", + "+(14-18)% to all Elemental Resistances", + "+2 maximum Energy Shield per 5 Strength", + "Zealot's Oath", + }, { + item.explicitModLines[1].line, + item.explicitModLines[2].line, + item.explicitModLines[3].line, + item.explicitModLines[4].line, + item.explicitModLines[5].line, + item.explicitModLines[6].line, + }) + end) + describe("mod magnitude scaling", function() before_each(function() newBuild() diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index ca16976391..df454418db 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -61,6 +61,14 @@ 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 @@ -1157,6 +1165,24 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) if self.baseName and self.title then self.name = self.title .. ", " .. self.baseName:gsub(" %(.+%)","") end + if self.advancedCopy and (self.rarity == "UNIQUE" or self.rarity == "RELIC") then + if not uniqueModStatOrder then + uniqueModStatOrder = { exact = { }, normalised = { } } + for _, mod in pairs(data.itemMods.ItemExclusive) 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.base and #self.sockets > 0 then -- In-game requirement totals include requirements from socketed gems. -- Derive the item's attribute requirements from its base and local mods instead. @@ -1231,7 +1257,7 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) end end end - if self.advancedCopy and (#self.prefixes > 0 or #self.suffixes > 0) and #self.explicitModLines > 1 then + if self.advancedCopy and #self.explicitModLines > 1 then sortCraftedModLines(self.explicitModLines) end self.affixLimit = 0 @@ -1306,11 +1332,6 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) end end if self.mutatedLines then - local function normalise(line) - return line:gsub("%d+%.?%d*", "#") - :gsub("%(%-?#%-#%)", "#"):lower() - :gsub("\n", " ") - end -- Match both sides so the same checkbox can apply or revert the transformation. for origModId, foulModId in pairs(self.mutatedLines) do local function checkMod(modId, newModId, mutated) @@ -1323,10 +1344,10 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) local matchingLines = {} local matchedLines = {} for _, line in ipairs(lines) do - local statLine = normalise(line) + local statLine = normaliseModLine(line) for _, modLine in ipairs(self.explicitModLines) do if not matchedLines[modLine] - and normalise(modLine.line:gsub("\n", " ")) == statLine + and normaliseModLine(modLine.line) == statLine and self:CheckModLineVariant(modLine) then matchedLines[modLine] = true t_insert(matchingLines, modLine) From 508a4785d9c4b06b68a034272bfccb1696486c98 Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Tue, 28 Jul 2026 14:55:20 +1000 Subject: [PATCH 10/12] Dark Monarch --- spec/System/TestItemParse_spec.lua | 15 +++++++++++++++ src/Classes/Item.lua | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/spec/System/TestItemParse_spec.lua b/spec/System/TestItemParse_spec.lua index b37f0d92f7..182894241e 100644 --- a/spec/System/TestItemParse_spec.lua +++ b/spec/System/TestItemParse_spec.lua @@ -866,6 +866,21 @@ describe("TestAdvancedItemParse #item", function() }) end) + it("keeps the selected value from advanced-copy enum ranges", function() + local item = new("Item", [[ + Rarity: Unique + The Dark Monarch + Lich's Circlet + { Unique Modifier } + Maximum number of Raised Zombies (Animated Weapons-Holy Armaments) is Doubled + Cannot have Minions other than Raised Zombies (Animated Weapons-Holy Armaments) + ]]) + + assert.are.equals("Maximum number of Raised Zombies is Doubled", item.explicitModLines[1].line) + assert.are.equals("Cannot have Minions other than Raised Zombies", item.explicitModLines[2].line) + assert.is_true(#item.explicitModLines[1].modList > 0) + end) + describe("mod magnitude scaling", function() before_each(function() newBuild() diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index df454418db..b6e07a21bb 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -961,7 +961,7 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) local hasIndependentRolls -- Replace non-number ranges as unsupported - line = line:gsub("(%a+)%([%a%s]+%-[%a%s]+%)", "%1") + line = line:gsub("(%a+)%s*%([%a%s]+%-[%a%s]+%)", "%1") local advancedCopyLine = line for value, range in line:gmatch("(%-?%d+%.?%d*)%((%-?%d+%.?%d*%-%-?%d+%.?%d*)%)") do From 6ac94499d9ff2d8b56d08fd02020a8ac8628332d Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Tue, 28 Jul 2026 14:55:26 +1000 Subject: [PATCH 11/12] memory strands --- spec/System/TestItemParse_spec.lua | 19 +++++++++++++++++++ src/Classes/Item.lua | 5 +++++ src/Classes/ItemsTab.lua | 6 +++++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/spec/System/TestItemParse_spec.lua b/spec/System/TestItemParse_spec.lua index 182894241e..1f06b27e87 100644 --- a/spec/System/TestItemParse_spec.lua +++ b/spec/System/TestItemParse_spec.lua @@ -881,6 +881,25 @@ describe("TestAdvancedItemParse #item", function() assert.is_true(#item.explicitModLines[1].modList > 0) end) + it("parses Memory Strands as an item property", function() + local item = new("Item", [[ + Rarity: Magic + Imperial Maul of Revitalization + Weapon Range: 1.3 metres + Memory Strands: 70 + Item Level: 85 + { Suffix Modifier "of Revitalization" (Tier: 1) — Life, Attack } + Grants 28(27-30) Life per Enemy Hit + ]]) + + assert.are.equals(70, item.memoryStrands) + assert.are.equals(1, #item.explicitModLines) + + item:BuildAndParseRaw() + assert.are.equals(70, item.memoryStrands) + assert.are.equals(1, #item.explicitModLines) + end) + describe("mod magnitude scaling", function() before_each(function() newBuild() diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index b6e07a21bb..9cbef6ea82 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -571,6 +571,8 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) self.uniqueID = specVal elseif specName == "Item Level" then self.itemLevel = specToNumber(specVal) + elseif specName == "Memory Strands" then + self.memoryStrands = specToNumber(specVal) elseif specName == "Requires Class" then self.classRestriction = specVal elseif specName:match("Quality %([%a%s]+ Modifiers%)") then @@ -1603,6 +1605,9 @@ function ItemClass:BuildRaw() if self.itemLevel then t_insert(rawLines, "Item Level: " .. self.itemLevel) end + if self.memoryStrands then + t_insert(rawLines, "Memory Strands: " .. self.memoryStrands) + end local function writeModLine(modLine) local line = modLine.line if modLine.range and line:match("%(%-?[%d%.]+%-%-?[%d%.]+%)") then diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index e7b2fdba96..35a69fa86d 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -4275,7 +4275,6 @@ function ItemsTabClass:AddItemTooltip(tooltip, item, slot, dbMode, maxWidth) end end end - if item.catalyst and item.catalyst > 0 and item.catalyst <= #catalystQualityFormat and item.catalystQuality and item.catalystQuality > 0 then tooltip:AddLine(fontSizeBig, s_format(catalystQualityFormat[item.catalyst], item.catalystQuality), "FONTIN SC") tooltip:AddSeparator(10) @@ -4312,6 +4311,11 @@ function ItemsTabClass:AddItemTooltip(tooltip, item, slot, dbMode, maxWidth) end tooltip:AddSeparator(10) + if item.memoryStrands then + tooltip:AddLine(fontSizeBig, colorCodes.CRAFTED.."Memory Strands: ^7"..item.memoryStrands, "FONTIN SC") + tooltip:AddSeparator(10) + end + if item.talismanTier then tooltip:AddLine(fontSizeBig, "^x7F7F7FTalisman Tier ^xFFFFFF"..item.talismanTier, "FONTIN SC") tooltip:AddSeparator(10) From 753f47d666380524c6d4943ea62478b2e6f75610 Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Tue, 28 Jul 2026 15:09:10 +1000 Subject: [PATCH 12/12] Replica Dragonfangs --- spec/System/TestItemParse_spec.lua | 23 +++++++++++++++++++++++ src/Classes/Item.lua | 17 ++++++++++++++--- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/spec/System/TestItemParse_spec.lua b/spec/System/TestItemParse_spec.lua index 1f06b27e87..a62c26c806 100644 --- a/spec/System/TestItemParse_spec.lua +++ b/spec/System/TestItemParse_spec.lua @@ -881,6 +881,29 @@ describe("TestAdvancedItemParse #item", function() assert.is_true(#item.explicitModLines[1].modList > 0) end) + it("parses punctuated enum and descending numeric ranges", function() + local gemItem = new("Item", [[ + Rarity: Unique + Replica Dragonfang's Flight + Onyx Amulet + { Unique Modifier } + +3 to Level of all Lightning Tendrils(Fireball-Mana-Infused Staff) Gems + ]]) + assert.are.equals("+3 to Level of all Lightning Tendrils Gems", gemItem.explicitModLines[1].line) + assert.is_true(#gemItem.explicitModLines[1].modList > 0) + + local requirementItem = new("Item", [[ + Rarity: Unique + Replica Dragonfang's Flight + Onyx Amulet + { Unique Modifier } + Items and Gems have 8(10-5)% reduced Attribute Requirements + ]]) + assert.are.equals("Items and Gems have (5-10)% reduced Attribute Requirements", requirementItem.explicitModLines[1].line) + assert.are.equals("Items and Gems have 8% reduced Attribute Requirements", + itemLib.applyRange(requirementItem.explicitModLines[1].line, requirementItem.explicitModLines[1].range)) + end) + it("parses Memory Strands as an item property", function() local item = new("Item", [[ Rarity: Magic diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index 9cbef6ea82..89ce9c09fd 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -940,6 +940,9 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) 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*)") + if tonumber(min) > tonumber(max) then + min, max = max, min + end local delta = tonumber(max) - min line = line:gsub(value .. "%(" .. range:gsub("%-", "%%-") .. "%)", value) if delta > bestPrecisionDelta then @@ -962,12 +965,20 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) local firstRollRange local hasIndependentRolls - -- Replace non-number ranges as unsupported - line = line:gsub("(%a+)%s*%([%a%s]+%-[%a%s]+%)", "%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*)") + if tonumber(min) > tonumber(max) then + min, max = max, min + end local delta = tonumber(max) - min local rollRange = delta > 0 and round((value - min) / delta, 6) or 0.5 if firstRollRange and firstRollRange ~= rollRange then @@ -981,7 +992,7 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) 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 hasIndependentRolls then