From 687d90efa677351c134a48a9c44c394455c67d41 Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:45:42 +0300 Subject: [PATCH 1/3] Add support for creating foulborn items from DB --- spec/System/TestFoulborn_spec.lua | 220 ++++++++ src/Classes/Item.lua | 179 +++++- src/Classes/ItemDBControl.lua | 18 +- src/Classes/ItemsTab.lua | 160 +++++- src/Data/ModCache.lua | 1 + src/Data/ModFoulbornMap.jsonc | 908 ++++++++++++++++++++++++++++++ src/Data/Uniques/gloves.lua | 4 +- src/Modules/Data.lua | 9 +- src/Modules/Main.lua | 4 +- 9 files changed, 1449 insertions(+), 54 deletions(-) create mode 100644 spec/System/TestFoulborn_spec.lua create mode 100644 src/Data/ModFoulbornMap.jsonc diff --git a/spec/System/TestFoulborn_spec.lua b/spec/System/TestFoulborn_spec.lua new file mode 100644 index 00000000000..dbb978021f8 --- /dev/null +++ b/spec/System/TestFoulborn_spec.lua @@ -0,0 +1,220 @@ +describe("TestFoulborn", function() + -- spell-checker: disable + -- Voll's Devotion has two foulborn-convertible mods in ModFoulbornMap.jsonc: + -- "30% reduced Power Charge Duration" -> "Gain a Power Charge every Second..." + -- "30% reduced Endurance Charge Duration" -> "Lose all Power Charges..." + local powerBase = "30% reduced Power Charge Duration" + local powerFoulborn = "Gain a Power Charge every Second if you haven't lost Power Charges Recently" + -- the mod ids the two sides of the transformation resolve to + local powerBaseId = "PowerChargeDurationUniqueAmulet14" + local powerFoulbornId = "MutatedUniqueAmulet14GainPowerChargesNotLostRecently" + + local function vollsDevotion() + return new("Item", [[ + Rarity: Unique + Voll's Devotion + Agate Amulet + Implicits: 1 + +(16-24) to Strength and Intelligence + +(20-30) to maximum Energy Shield + +(30-40) to maximum Life + +(15-20)% to all Elemental Resistances + 30% reduced Endurance Charge Duration + 30% reduced Power Charge Duration + Gain an Endurance Charge when you lose a Power Charge + ]]) + end + + -- find a mod line by its exact displayed text + local function findByLine(item, text) + for _, modLine in ipairs(item.explicitModLines) do + if modLine.line == text then + return modLine + end + end + end + + -- toggle foulborn on or off + local function toggle(item, modLine) + item:MutateMod(modLine.modId, modLine.newModId, not modLine.mutated) + end + + it("marks convertible mods with their foulborn transformation", function() + local item = vollsDevotion() + local modLine = findByLine(item, powerBase) + assert.is_not_nil(modLine) + assert.are.equals(powerBaseId, modLine.modId) + assert.are.equals(powerFoulbornId, modLine.newModId) + -- offered but not applied by default, so the line is in its base form + assert.is_falsy(modLine.mutated) + end) + + it("does not mark non-convertible mods", function() + local item = vollsDevotion() + local plainMod = findByLine(item, "Gain an Endurance Charge when you lose a Power Charge") + assert.is_not_nil(plainMod) + assert.is_nil(plainMod.modId) + assert.is_nil(plainMod.newModId) + end) + + it("converts the mod line to its foulborn text when activated", function() + local item = vollsDevotion() + toggle(item, findByLine(item, powerBase)) + + -- the base line is replaced by the foulborn line, which now offers the + -- reverse transformation + assert.is_nil(findByLine(item, powerBase)) + local mutated = findByLine(item, powerFoulborn) + assert.is_not_nil(mutated) + assert.is_true(mutated.mutated) + assert.are.equals(powerFoulbornId, mutated.modId) + assert.are.equals(powerBaseId, mutated.newModId) + end) + + it("flags the item as foulborn and prefixes the title once a mod is mutated", function() + local item = vollsDevotion() + -- an unmodified item is a plain unique + assert.is_falsy(item.foulborn) + assert.are.equals("Voll's Devotion", item.title) + + toggle(item, findByLine(item, powerBase)) + assert.is_true(item.foulborn) + assert.are.equals("Foulborn Voll's Devotion", item.title) + + -- reverting the last mutated mod drops the flag and the prefix again + toggle(item, findByLine(item, powerFoulborn)) + assert.is_falsy(item.foulborn) + assert.are.equals("Voll's Devotion", item.title) + end) + + it("keeps the mutated state through BuildRaw", function() + local item = vollsDevotion() + toggle(item, findByLine(item, powerBase)) + + local raw = item:BuildRaw() + assert.is_truthy(raw:find("{mutated}" .. powerFoulborn, 1, true)) + + -- re-importing the built text preserves the foulborn conversion + local reimported = new("Item", raw) + local mutated = findByLine(reimported, powerFoulborn) + assert.is_not_nil(mutated) + assert.is_true(mutated.mutated) + assert.is_nil(findByLine(reimported, powerBase)) + -- the foulborn flag and title prefix survive the round-trip + assert.is_true(reimported.foulborn) + assert.are.equals("Foulborn Voll's Devotion", reimported.title) + end) + + it("reverts to the base mod when deactivated", function() + local item = vollsDevotion() + toggle(item, findByLine(item, powerBase)) + assert.is_not_nil(findByLine(item, powerFoulborn)) + + toggle(item, findByLine(item, powerFoulborn)) + + local base = findByLine(item, powerBase) + assert.is_not_nil(base) + assert.is_falsy(base.mutated) + assert.is_nil(findByLine(item, powerFoulborn)) + end) + + it("only converts the activated mod when several are convertible", function() + local item = vollsDevotion() + toggle(item, findByLine(item, powerBase)) + + -- the endurance charge mod stays in its base form and is still offered + local endurance = findByLine(item, "30% reduced Endurance Charge Duration") + assert.is_not_nil(endurance) + assert.is_falsy(endurance.mutated) + end) + + it("leaves items without a foulborn mapping untouched", function() + -- same mod text, but on a rare: mutatedLines is only populated for + -- uniques listed in the foulborn map + local item = new("Item", "Rarity: Rare\nTest Subject\nAgate Amulet\n30% reduced Power Charge Duration") + assert.is_nil(item.mutatedLines) + assert.is_nil(item.explicitModLines[1].modId) + end) + + -- Meginord's Vise maps a single mod to a two-line foulborn replacement: + -- "+# to Strength" -> "+700 Strength Requirement\n(10-15)% chance to deal Triple Damage" + local viseBase = "+50 to Strength" + local viseFoulbornId = "MutatedUniqueGlovesStr2StrengthRequirementAndTripleDamageChance" + + local function meginordsVise() + return new("Item", [[ + Rarity: Unique + Meginord's Vise + Steel Gauntlets + +50 to Strength + 100% increased Knockback Distance + ]]) + end + + local function linesAllByModId(item, modId) + local out = {} + for _, modLine in ipairs(item.explicitModLines) do + if modLine.modId == modId then + table.insert(out, modLine) + end + end + return out + end + + local function hasMod(item, name) + for _, modLine in ipairs(item.explicitModLines) do + for _, mod in ipairs(modLine.modList or {}) do + if mod.name == name then + return true + end + end + end + return false + end + + it("expands a single mod into two lines when a 1->2 foulborn mod is activated", function() + local item = meginordsVise() + + -- one base mod carries the whole multi-line transformation + local base = findByLine(item, viseBase) + assert.is_not_nil(base) + assert.are.equals(viseFoulbornId, base.newModId) + assert.is_falsy(base.mutated) + + toggle(item, base) + + -- the base line is gone, replaced by one mod line per stat line, each + -- offering the reverse transformation back to the single base mod + assert.is_nil(findByLine(item, viseBase)) + local mutated = linesAllByModId(item, viseFoulbornId) + assert.are.equals(2, #mutated) + + local seen = {} + for _, modLine in ipairs(mutated) do + assert.is_true(modLine.mutated) + assert.are.equals(viseFoulbornId, modLine.modId) + seen[modLine.line] = true + end + assert.is_true(seen["+700 Strength Requirement"]) + assert.is_true(seen["(10-15)% chance to deal Triple Damage"]) + + -- both stat lines contribute their own parsed mod + assert.is_true(hasMod(item, "StrRequirement")) + assert.is_true(hasMod(item, "TripleDamageChance")) + end) + + it("collapses the expanded lines back to the base mod when deactivated", function() + local item = meginordsVise() + toggle(item, findByLine(item, viseBase)) + assert.are.equals(2, #linesAllByModId(item, viseFoulbornId)) + + -- deactivating from either expanded line restores the single base mod + toggle(item, findByLine(item, "(10-15)% chance to deal Triple Damage")) + + local restored = findByLine(item, viseBase) + assert.is_not_nil(restored) + assert.is_falsy(restored.mutated) + assert.is_nil(findByLine(item, "+700 Strength Requirement")) + assert.is_false(hasMod(item, "TripleDamageChance")) + end) +end) diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index 6d9c0c5062b..7780f3be092 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -80,9 +80,19 @@ for _, curInfluenceInfo in ipairs(influenceInfo) do end local lineFlags = { - ["crafted"] = true, ["crucible"] = true, ["custom"] = true, ["eater"] = true, ["enchant"] = true, - ["exarch"] = true, ["fractured"] = true, ["implicit"] = true, ["scourge"] = true, ["synthesis"] = true, - ["mutated"] = true, ["unscalable"] = true + ["crafted"] = true, + ["crucible"] = true, + ["custom"] = true, + ["eater"] = true, + ["enchant"] = true, + ["exarch"] = true, + ["fractured"] = true, + ["implicit"] = true, + ["scourge"] = true, + ["synthesis"] = true, + ["mutated"] = true, + ["unscalable"] = true, + ["mutateActive"] = true } -- Special function to store unique instances of modifier on specific item slots @@ -377,6 +387,9 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) self.requirements.dex = 0 self.requirements.int = 0 self.baseLines = { } + -- assume the item is not foulborn. if it is, this will be set to true when + -- a mod has the mutated tag + self.foulborn = false local importedLevelReq local flaskBuffLines local tinctureBuffLines @@ -468,7 +481,8 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) local possibleLineFlags = fullModName:gsub("Foulborn", "Mutated"):match("(.*)Modifier.*") if possibleLineFlags then for flag in possibleLineFlags:gmatch("%a+") do - if lineFlags[flag:lower()] then + local flagLower = flag:lower() + if lineFlags[flagLower] then linePrefix = linePrefix .. "{" .. flag:lower() .. "}" end end @@ -695,6 +709,8 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) for tag in val:gmatch("[%a_]+") do t_insert(modLine.modTags, tag) end + elseif k == "originalLine" then + modLine.originalLine = val elseif k == "range" then modLine.range = tonumber(val) elseif k == "corruptedRange" then @@ -771,8 +787,14 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) if not (self.rarity == "NORMAL" or self.rarity == "MAGIC") then self.title = self.name end - if self.title and self.title:find("Foulborn") then - self.foulborn = true + if self.rarity == "UNIQUE" and self.title + -- the foulborn transformation on this item + -- increases the radius, which isn't a mod line + -- in PoB, which makes it incompatible with this + -- approach + and not self.title:lower():find("might of the meek") then + self.mutatedLines = data.foulbornMap[self.title:gsub("^[Ff]oulborn ", "")] + end self.type = base.type self.base = base @@ -892,20 +914,19 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) end local rangedLine = itemLib.applyRange(line, 1, catalystScalar, modLine.corruptedRange) local modList, extra = modLib.parseMod(rangedLine) - if (not modList or extra) and self.rawLines[l+1] then + if (not modList or extra) and self.rawLines[l + 1] then -- Try to combine it with the next line local nextLine = self.rawLines[l+1]:gsub("%b{}", ""):gsub(" ?%(%l+%)","") local combLine = line.." "..nextLine rangedLine = itemLib.applyRange(combLine, 1, catalystScalar, modLine.corruptedRange) modList, extra = modLib.parseMod(rangedLine, true) if modList and not extra then - line = line.."\n"..nextLine + line = line .. "\n" .. nextLine l = l + 1 else modList, extra = modLib.parseMod(rangedLine) end end - local lineLower = line:lower() if lineLower == "implicit modifiers cannot be changed" then self.implicitsCannotBeChanged = true @@ -1095,6 +1116,65 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) self.variantAlt5 = m_min(#self.variantList, self.variantAlt5 or #self.variantList) end end + local function normalise(line) + return line:gsub("%d+%.?%d*", "#") + :gsub("%(%-?#%-#%)", "#"):lower() + :gsub("\n", " ") + end + -- iterate through mutated mod transformations for this item. both sides of + -- the transformation are used to mark mod lines which match lines on the + -- modifier + if self.mutatedLines then + for origModId, foulModId in pairs(self.mutatedLines) do + local function checkMod(modId, newModId, mutated) + local originalMod = mutated and data.itemMods.Foulborn[modId] or data.itemMods.Item[modId] + if not originalMod then + ConPrintf("mod not found while testing mutated mods %s, %s", modId, mutated) + return + end + local matchingLines = {} + local lineCount = 0 + for _, statDesc in pairs(originalMod.tradeHashes) do + local statLine = normalise(table.concat(statDesc, " ")) + if statLine ~= "" then + lineCount = lineCount + 1 + for _, modLine in ipairs(self.explicitModLines) do + if normalise(modLine.line:gsub("\n", " ")) == statLine and + self:CheckModLineVariant(modLine) then + table.insert(matchingLines, modLine) + end + end + end + end + if lineCount == #matchingLines then + for _, modLine in ipairs(matchingLines) do + modLine.modId = modId + modLine.newModId = newModId + if mutated then + modLine.mutated = true + end + end + end + end + -- check if there are foulborn source mods + checkMod(origModId, foulModId) + -- check if there are foulborn mods which can be reverted, and mark + -- them as mutated as in case they are missing the flag + checkMod(foulModId, origModId, true) + end + end + for _, v in ipairs(self.explicitModLines) do + if v.mutated then + self.foulborn = true + end + end + -- ensure that foulborn items have the name prefix + local hasFoulbornPrefix = self.title and self.title:lower():find("^foulborn ") + if self.foulborn and not hasFoulbornPrefix then + self.title = "Foulborn " .. self.title + elseif not self.foulborn and hasFoulbornPrefix then + self.title = self.title:gsub("[Ff]oulborn ", "") + end if not self.quality then self:NormaliseQuality() if highQuality then @@ -1109,6 +1189,44 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) end end +---@param modId string The id which will be present on the removed mod lines +---@param newModId string Id of the new mod which is used to get the new mod lines +---@param mutatedValue boolean? Whether the new mod is a mutated line. Also determines what table the new mod is taken from. +function ItemClass:MutateMod(modId, newModId, mutatedValue) + local newMod = mutatedValue and data.itemMods.Foulborn[newModId] or data.itemMods.ItemExclusive[newModId] + if not newMod then + ConPrintf("Invalid mod id given to MutateMod: %s, %s, %s", modId, newModId, mutatedValue) + return + end + local i = 1 + -- where we will reinsert the mod lines + local insertIdx + local variantList + while self.explicitModLines[i] do + local modLine = self.explicitModLines[i] + if modLine.modId == modId and self:CheckModLineVariant(modLine) then + if not insertIdx then + insertIdx = i + variantList = modLine.variantList + end + table.remove(self.explicitModLines, i) + else + i = i + 1 + end + end + for _, line in pairs(newMod.tradeHashes) do + local lineString = table.concat(line, " ") + if lineString ~= "" then + local modLine = { line = lineString, modTags = newMod.modTags, variantList = variantList, mutated = mutatedValue } + local rangedLine = itemLib.applyRange(lineString, main.defaultItemAffixQuality, 1) + local modList, extra = modLib.parseMod(rangedLine) + modLine.modList = modList + modLine.extra = extra + table.insert(self.explicitModLines, insertIdx, modLine) + end + end + self:BuildAndParseRaw() +end function ItemClass:NormaliseQuality() if self.base and (self.base.armour or self.base.weapon or self.base.flask or self.base.tincture) then if not self.quality then @@ -1279,9 +1397,17 @@ function ItemClass:BuildRaw() if modLine.crucible then line = "{crucible}" .. line end + -- ggg tag for cultivated/foulborn mod if modLine.mutated then line = "{mutated}" .. line end + -- pob markers for application and removal of mutated mod + if modLine.mutateActive then + line = "{mutateActive}" .. line + end + if modLine.originalLine then + line = string.format("{originalLine:%s}", modLine.originalLine) .. line + end if modLine.fractured then line = "{fractured}" .. line end @@ -1474,7 +1600,7 @@ function ItemClass:Craft() end function ItemClass:CheckModLineVariant(modLine) - return not modLine.variantList + return not modLine.variantList or modLine.variantList[self.variant] or (self.hasAltVariant and modLine.variantList[self.variantAlt]) or (self.hasAltVariant2 and modLine.variantList[self.variantAlt2]) @@ -1861,22 +1987,25 @@ function ItemClass:BuildModList() self.classRestriction = modLine.line:gsub("{variant:([%d,]+)}", ""):match("Requires Class (.+)") end -- handle understood modifier variable properties - if not modLine.extra then - if modLine.range then - -- Check if line actually has a range - if modLine.line:find("%((%-?%d+%.?%d*)%-(%-?%d+%.?%d*)%)") then - local strippedModeLine = modLine.line:gsub("\n"," ") - local catalystScalar = getCatalystScalar(self.catalyst, modLine, self.catalystQuality) - -- Put the modified value into the string - local line = itemLib.applyRange(strippedModeLine, modLine.range, catalystScalar, modLine.corruptedRange) - -- Check if we can parse it before adding the mods - local list, extra = modLib.parseMod(line) - if list and not extra then - modLine.modList = list - t_insert(self.rangeLineList, modLine) - end - end + if not modLine.extra and modLine.range and + -- Check if line actually has a range + modLine.line:find("%((%-?%d+%.?%d*)%-(%-?%d+%.?%d*)%)") then + local strippedModeLine = modLine.line:gsub("\n", " ") + local catalystScalar = getCatalystScalar(self.catalyst, modLine, self.catalystQuality) + -- Put the modified value into the string + local line = itemLib.applyRange(strippedModeLine, modLine.range, catalystScalar, modLine.corruptedRange) + -- Check if we can parse it before adding the mods + local list, extra = modLib.parseMod(line) + if list and not extra then + modLine.modList = list + modLine.showSlider = true + t_insert(self.rangeLineList, modLine) end + -- mutated mod transformation available + elseif modLine.modId and modLine.newModId then + t_insert(self.rangeLineList, modLine) + end + if not modLine.extra then for _, mod in ipairs(modLine.modList) do mod = modLib.setSource(mod, self.modSource) baseList:AddMod(mod) diff --git a/src/Classes/ItemDBControl.lua b/src/Classes/ItemDBControl.lua index 8cd0eab1c26..24d0db7491f 100644 --- a/src/Classes/ItemDBControl.lua +++ b/src/Classes/ItemDBControl.lua @@ -137,7 +137,15 @@ function ItemDBClass:DoesItemMatchFilters(item) local found = false local mode = self.controls.searchMode.selIndex if mode == 1 or mode == 2 then - local err, match = PCall(string.matchOrPattern, item.name:lower(), searchStr) + -- check if any explicit mods have a mutated line + local searchName = item.name:lower() + for _, modLine in ipairs(item.explicitModLines) do + if modLine.newModId then + searchName = "foulborn " .. searchName + break + end + end + local err, match = PCall(string.matchOrPattern, searchName, searchStr) if not err and match then found = true end @@ -158,7 +166,13 @@ function ItemDBClass:DoesItemMatchFilters(item) end end for _, line in pairs(item.explicitModLines) do - local err, match = PCall(string.matchOrPattern, line.line:lower(), searchStr) + -- concatenate alternate lines for mutated items + local newMod = line.newModId and (data.itemMods.ItemExclusive[line.newModId] or data.itemMods.Foulborn[line.newModId]) + local searchLine = line.line:lower() + if newMod then + searchLine = searchLine .. table.concat(newMod) + end + local err, match = PCall(string.matchOrPattern, searchLine, searchStr) if not err and match then found = true break diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 7d8148e28be..09d67553577 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -940,36 +940,137 @@ holding Shift will put it in the second.]]) end -- Section: Modifier Range - self.controls.displayItemSectionRange = new("Control", {"TOPLEFT",self.controls.displayItemSectionCustom,"BOTTOMLEFT"}, {0, 0, 0, function() + local labelFontSize = 14 + self.controls.displayItemSectionRange = new("Control", { "TOPLEFT", self.controls.displayItemSectionCustom, "BOTTOMLEFT" }, { 0, 0, 0, function() if not self.displayItem or not self.displayItem.rangeLineList[1] then return 0 end if main.showAllItemAffixes and self.displayItem.rarity == "UNIQUE" then - local count = #self.displayItem.rangeLineList - return count * 22 + 4 + local height = 0 + for i = 1, #self.displayItem.rangeLineList do + local label = self.controls["displayItemStackedRangeLine" .. i] + height = height + math.max(22, (label.lineCount or 0) * labelFontSize) + end + return height + 4 else return 28 end end}) + local foulbornIcon = NewImageHandle() + foulbornIcon:Load("Assets/breachicon.png") self.controls.displayItemRangeLine = new("DropDownControl", {"TOPLEFT",self.controls.displayItemSectionRange,"TOPLEFT"}, {0, 0, 350, 18}, nil, function(index, value) self.controls.displayItemRangeSlider.val = self.displayItem.rangeLineList[index].range end) self.controls.displayItemRangeLine.shown = function() return self.displayItem and self.displayItem.rangeLineList[1] ~= nil and not (main.showAllItemAffixes and self.displayItem.rarity == "UNIQUE") end - self.controls.displayItemRangeSlider = new("SliderControl", {"LEFT",self.controls.displayItemRangeLine,"RIGHT"}, {8, 0, 100, 18}, function(val) - self.displayItem.rangeLineList[self.controls.displayItemRangeLine.selIndex].range = val + local function getSelectedModLine() + return self.controls.displayItemRangeLine:GetSelValue() and self.controls.displayItemRangeLine:GetSelValue().modLine or nil + end + local box = new("CheckBoxControl", { "LEFT", self.controls.displayItemRangeLine, "RIGHT", true }, { 0, 0, 18 }, nil) + box.changeFunc = function(val) + local line = getSelectedModLine() + if line and line.modId and line.newModId then + self.displayItem:MutateMod(line.modId, line.newModId, not line.mutated) + self:UpdateDisplayItemTooltip() + self:UpdateCustomControls() + self:UpdateDisplayItemRangeLines() + end + end + box.RealDraw = box.Draw + function box:Draw(...) + local x, y = self:GetPos() + SetDrawColor(1, 1, 1) + DrawImage(foulbornIcon, x - 24, y - 1, 20, 20) + return box:RealDraw(...) + end + + box.shown = function() + local line = getSelectedModLine() + -- hack: set property according to item. the state is not a prop and so can't be set as a function value + if line and line.mutated then + box.state = true + else + box.state = false + end + return not main.showAllItemAffixes and line and line.modId and line.newModId + end + -- fix spacing + box.x = function() + if box:IsShown() then + return 25 + else + return 8 + end + end + self.controls.displayItemMutatedCheckbox = box + local slider = new("SliderControl", { "LEFT", self.controls.displayItemMutatedCheckbox, "RIGHT", true }, { 4, 0, 100, 18 }, function(val) + local line = getSelectedModLine() + line.range = val self.displayItem:BuildAndParseRaw() self:UpdateDisplayItemTooltip() self:UpdateCustomControls() end) + slider.shown = function() + local modLine = not main.showAllItemAffixes and self.displayItem and self.displayItem.rarity == "UNIQUE" and self.displayItem.rangeLineList[self.controls.displayItemRangeLine.selIndex] + if modLine then + -- it's possible for the range to change while this slider is + -- hidden, so correct the slider to show the same value + slider.val = modLine.range or slider.val + return modLine.showSlider + else + return false + end + end + self.controls.displayItemRangeSlider = slider for i = 1, 20 do - local baseControl = i == 1 and self.controls.displayItemSectionRange or self.controls["displayItemStackedRangeSlider"..(i-1)] + local baseControl = self.controls.displayItemSectionRange - self.controls["displayItemStackedRangeSlider"..i] = new("SliderControl", {"TOPLEFT",baseControl,"TOPLEFT"}, {0, function() - return i == 1 and 2 or 22 - end, 100, 18}, function(val) + -- layout: [box] <- [slider] <- text + + local box = new("CheckBoxControl", { "TOPLEFT", baseControl, "TOPLEFT", true }, { 0, 0, 18 }, nil, function(val) + local line = self.displayItem and self.displayItem.rangeLineList[i] + if line and line.modId and line.newModId then + self.displayItem:MutateMod(line.modId, line.newModId, not line.mutated) + self:UpdateDisplayItemTooltip() + self:UpdateCustomControls() + end + end) + -- fix spacing + box.x = function() + if box:IsShown() then + return 24 + else + return 0 + end + end + box.y = function() + local prevBox = self.controls["displayItemStackedMutatedCheckbox" .. (i - 1)] + local prevLabel = self.controls["displayItemStackedRangeLine" .. (i - 1)] + return i == 1 and 2 or prevBox:GetProperty("y") + math.max(22, (prevLabel.lineCount or 0) * 14) + end + box.shown = function() + local line = (self.displayItem and self.displayItem.rangeLineList[i]) or nil + -- hack: set property according to item. the state is not a prop and so can't be set as a function value + if line and line.mutated then + box.state = true + else + box.state = false + end + return main.showAllItemAffixes and line and line.modId and line.newModId + end + box.RealDraw = box.Draw + function box:Draw(...) + local x, y = self:GetPos() + SetDrawColor(1, 1, 1) + DrawImage(foulbornIcon, x - 24, y, 20, 20) + return box:RealDraw(...) + end + + self.controls["displayItemStackedMutatedCheckbox" .. i] = box + + local slider = new("SliderControl", { "LEFT", box, "RIGHT", true }, { 4, 0, 100, 18 }, function(val) if self.displayItem and self.displayItem.rangeLineList[i] then self.displayItem.rangeLineList[i].range = val self.displayItem:BuildAndParseRaw() @@ -977,21 +1078,35 @@ holding Shift will put it in the second.]]) self:UpdateCustomControls() end end) - self.controls["displayItemStackedRangeLine"..i] = new("LabelControl", {"LEFT",self.controls["displayItemStackedRangeSlider"..i],"RIGHT"}, {8, -2, 350, 14}, function() - if self.displayItem and self.displayItem.rangeLineList[i] then - return "^7" .. self.displayItem.rangeLineList[i].line + slider.shown = function() + local modLine = main.showAllItemAffixes and self.displayItem and self.displayItem.rarity == "UNIQUE" and self.displayItem.rangeLineList[i] + if modLine then + -- it's possible for the range to change while this slider is + -- hidden, so correct the slider to show the same value + slider.val = modLine.range or slider.val + return modLine.showSlider + else + return false end - return "" - end) - self.controls["displayItemStackedRangeSlider"..i].shown = function() - return main.showAllItemAffixes and self.displayItem and self.displayItem.rarity == "UNIQUE" and self.displayItem.rangeLineList[i] ~= nil end - self.controls["displayItemStackedRangeLine"..i].shown = function() - return self.controls["displayItemStackedRangeSlider"..i]:IsShown() + self.controls["displayItemStackedRangeSlider" .. i] = slider + + self.controls["displayItemStackedRangeLine" .. i] = new("LabelControl", { "LEFT", slider, "RIGHT", true }, { 4, -2, 350, labelFontSize }, function() + local modLine = self.displayItem.rangeLineList[i] + if self.displayItem and modLine then + local colour = modLine.mutateActive and colorCodes.MUTATED or "^7" + local text = table.concat(main:WrapString(modLine.line, labelFontSize, 370), "\n") + local _, lineCount = text:gsub("\n", "") + self.controls["displayItemStackedRangeLine" .. i].lineCount = lineCount + 1 + return colour .. text + end + return "" + end) + self.controls["displayItemStackedRangeLine" .. i].shown = function() + return slider:IsShown() or box:IsShown() end end - -- Tooltip anchor self.controls.displayItemTooltipAnchor = new("Control", {"TOPLEFT",self.controls.displayItemSectionRange,"BOTTOMLEFT"}) @@ -2004,10 +2119,9 @@ end function ItemsTabClass:UpdateDisplayItemRangeLines() if self.displayItem and self.displayItem.rangeLineList[1] then wipeTable(self.controls.displayItemRangeLine.list) - for i, modLine in ipairs(self.displayItem.rangeLineList) do - t_insert(self.controls.displayItemRangeLine.list, modLine.line) - if self.controls["displayItemStackedRangeSlider"..i] then - self.controls["displayItemStackedRangeSlider"..i].val = modLine.range + for _, modLine in ipairs(self.displayItem.rangeLineList) do + if (modLine.modId and modLine.newModId) or modLine.range then + t_insert(self.controls.displayItemRangeLine.list, { modLine = modLine, label = modLine.line }) end end self.controls.displayItemRangeLine.selIndex = 1 diff --git a/src/Data/ModCache.lua b/src/Data/ModCache.lua index 18082b7621c..e7ad69ee5ff 100755 --- a/src/Data/ModCache.lua +++ b/src/Data/ModCache.lua @@ -1882,6 +1882,7 @@ c["0% to maximum Fire Resistance"]={{[1]={flags=0,keywordFlags=0,name="FireResis c["0% to maximum Lightning Resistance"]={{[1]={flags=0,keywordFlags=0,name="LightningResistMax",type="BASE",value=0}},"% to "} c["0.00 seconds to Avian's Flight Duration"]={{[1]={flags=0,keywordFlags=0,name="Duration",type="BASE",value=0}},".00 seconds to Avian's Flight "} c["0.00 seconds to Avian's Might Duration"]={{[1]={flags=0,keywordFlags=0,name="Duration",type="BASE",value=0}},".00 seconds to Avian's Might "} +c["0.2% of Attack Damage Leeched as Mana"]={{[1]={flags=1,keywordFlags=0,name="DamageManaLeech",type="BASE",value=0.2}},nil} c["0.2% of Attack Damage Leeched as Mana per Power Charge"]={{[1]={[1]={type="Multiplier",var="PowerCharge"},flags=1,keywordFlags=0,name="DamageManaLeech",type="BASE",value=0.2}},nil} c["0.2% of Chaos Damage Leeched as Life"]={{[1]={flags=0,keywordFlags=0,name="ChaosDamageLifeLeech",type="BASE",value=0.2}},nil} c["0.2% of Cold Damage Leeched as Life"]={{[1]={flags=0,keywordFlags=0,name="ColdDamageLifeLeech",type="BASE",value=0.2}},nil} diff --git a/src/Data/ModFoulbornMap.jsonc b/src/Data/ModFoulbornMap.jsonc new file mode 100644 index 00000000000..5205a0c6c32 --- /dev/null +++ b/src/Data/ModFoulbornMap.jsonc @@ -0,0 +1,908 @@ +// spell-checker: disable +// https://www.poewiki.net/wiki/Foulborn_unique_item +// const rows = Array.from( +// document.querySelectorAll("table.wikitable > tbody:nth-child(3) > tr"), +// ); +// // gather every Modifier page referenced +// const pages = new Set(); +// const specs = rows.map((it) => { +// const unique = it.querySelector("a").innerText.replace("รถ", "o"); +// const orig = it.querySelector(".tc.-mod > a"); +// const foul = it.querySelector(".tc.-foulborn > a"); +// const s = { +// unique, +// orig: orig.title.replace("Modifier:", ""), +// foul: foul.title.replace("Modifier:", ""), +// }; +// if (!s.orig) { +// console.log(it.querySelector(".tc.-mod > a")); +// } +// pages.add(s.orig); +// pages.add(s.foul); +// return s; +// }); +// // resolve page name -> true id via Cargo. mediawiki pages don't allow +// // consecutive underscores and so the page names are garbled +// async function resolveIds(pages) { +// const map = {}; +// const arr = [...pages]; +// for (let i = 0; i < arr.length; i += 50) { +// const where = arr +// .slice(i, i + 50) +// .map((p) => `"${p}"`) +// .join(","); +// const url = +// `/w/api.php?action=cargoquery&format=json&limit=500` + +// `&tables=mods&fields=mods._pageTitle=page,mods.id=id` + +// `&where=${encodeURIComponent(`mods._pageTitle IN (${where})`)}`; +// const data = await (await fetch(url)).json(); +// for (const { title } of data.cargoquery) map[title.page] = title.id; +// } +// return map; +// } +// const idMap = await resolveIds(pages); +// const out = {}; +// for (const s of specs) { +// if (out[s.unique] === undefined) { +// out[s.unique] = {}; +// } +// if (!idMap[s.orig]) { +// console.log("MODIFIER NOT IN RESPONSE", s.orig); +// } +// if (!idMap[s.foul]) { +// console.log("MODIFIER NOT IN RESPONSE", s.foul); +// } +// out[s.unique][idMap[s.orig]] = idMap[s.foul]; +// } +// console.log(JSON.stringify(out)); +{ + "Voll's Devotion": { + "PowerChargeDurationUniqueAmulet14": "MutatedUniqueAmulet14GainPowerChargesNotLostRecently", + "EnduranceChargeDurationUniqueAmulet14": "MutatedUniqueAmulet14LosePowerChargesOnMaxPowerCharges" + }, + "Warped Timepiece": { + "MovementVelocityUniqueAmulet20": "MutatedUniqueAmulet20CurseEffectTemporalChains" + }, + "The Aylardex": { + "IncreasedLifeUnique__15": "MutatedUniqueAmluet24EldritchBattery" + }, + "Xoph's Heart": { + "NearbyEnemiesCoveredInAshUnique__1": "MutatedUniqueAmulet37NearbyEnemiesDebilitated", + "FireResistUnique__9": "MutatedUniqueAmulet37PhysicalDamageTakenAsFire" + }, + "Xoph's Blood": { + "FireResistUnique__9": "MutatedUniqueAmulet37PhysicalDamageTakenAsFire", + "KeystoneAvatarOfFireUnique__1": "MutatedUniqueAmulet38KeystoneElementalOverload" + }, + "The Halcyon": { + "IncreasedDamageIfFrozenRecentlyUnique__1": "MutatedUniqueAmulet39CannotBeFrozen", + "ColdResistUnique__11": "MutatedUniqueAmulet39PhysicalDamageTakenAsCold" + }, + "The Pandemonius": { + "ColdResistUnique__11": "MutatedUniqueAmulet39PhysicalDamageTakenAsCold", + "ColdDamagePercentUnique___11": "MutatedUniqueAmulet40CannotBeChilled" + }, + "Voice of the Storm": { + "LightningNonCriticalStrikesLuckyUnique__1": "MutatedUniqueAmulet41EnemyExtraDamageRolls", + "MaximumManaUnique__3": "MutatedUniqueAmulet41MaximumLightningResistance" + }, + "Choir of the Storm": { + "MaximumManaUnique__3": "MutatedUniqueAmulet41MaximumLightningResistance", + "CriticalChanceIncreasedByUncappedLightningResistanceUnique__1": "MutatedUniqueAmulet42ManaIncreasedPerOvercappedLightningResistUniqueAmulet42" + }, + "Eye of Chayula": { + "MaximumLifeUniqueAmulet6": "MutatedUniqueUniqueAmulet6ReducedMaximumMana", + "ItemFoundRarityIncreaseUniqueAmulet6": "MutatedUniqueUniqueAmulet6IncreasedGoldFound" + }, + "Presence of Chayula": { + "MaximumLifeConvertedToEnergyShieldUnique__1": "MutatedUniqueAmulet43RecoupEnergyShieldInsteadOfLife", + "ChaosResistUnique__5": "MutatedUniqueAmulet43ChaosDamageTakenRecoupedAsLife" + }, + "Maligaro's Restraint": { + "AddedLightningDamageUniqueBelt12": "MutatedUniqueBelt12ConvertLightningDamageToChaos" + }, + "Belt of the Deceiver": { + "ReducedCriticalStrikeDamageTakenUniqueBelt13": "MutatedUniqueBelt13CurseEffectOnYou", + "NearbyEnemiesAreIntimidatedUnique__1": "MutatedUniqueBelt13NearbyEnemiesAreUnnerved" + }, + "Dyadian Dawn": { + "FasterBurnFromAttacksEnemiesUniqueBelt14": "MutatedUniqueBelt14AllDamageCanIgnite", + "LifeLeechPermyriadFromAttacksAgainstChilledEnemiesUniqueBelt14": "MutatedUniqueBelt14MaximumLifeOnChillPercent" + }, + "Umbilicus Immortalis": { + "LifeRegenerationRatePercentUnique__1": "MutatedUniqueBelt19AnimalCharmFlaskChargesEvery3Secondsage", + "CannotBeAffectedByFlasksUnique__1": "MutatedUniqueBelt19FlaskChargesUsed" + }, + "Soul Tether": { + "MaximumEnergyShieldAsPercentageOfLifeUnique__1": "MutatedUniqueBelt21AnimalCharmLeechPercentIsInstant", + "KeystoneSoulTetherUnique__1": "MutatedUniqueBelt21EverlastingSacrifice" + }, + "Meginord's Girdle": { + "BeltFlaskLifeRecoveryRateUniqueBelt4": "MutatedUniqueBelt4PercentageStrength" + }, + "Headhunter": { + "DamageOnRareMonstersUniqueBelt7": "MutatedUniqueBelt7CullingStrike", + "GainRareMonsterModsOnKillUniqueBelt7_": "MutatedUniqueBelt7GainSoulEaterStackOnHit", + "StrengthUniqueBelt7": "MutatedUniqueBelt7RareAndUniqueEnemiesHaveIcons" + }, + "The Snowblind Grace": { + "LocalIncreasedEvasionRatingPercentUnique__5": "MutatedUniqueBodyDex10EvasionRatingPer10PlayerLife", + "ArcticArmourBuffEffectUnique__1_": "MutatedUniqueBodyDex10PurityOfIceNoReservation" + }, + "The Perfect Form": { + "LocalIncreasedEvasionRatingPercentUnique__4": "MutatedUniqueBodyDex11EvasionRatingPer10PlayerLife", + "KeystonePhaseAcrobaticsUnique__1": "MutatedUniqueBodyDex11GhostDance" + }, + "Ashrend": { + "RangedWeaponPhysicalDamagePlusPercentUnique__1": "MutatedUniqueBodyDex3MeleeFireDamage" + }, + "Bronn's Lithe": { + "IncreasedAttackSpeedUniqueBodyDex5": "MutatedUniqueBodyDex5MovementSkillCooldown" + }, + "Queen of the Forest": { + "MovementVeolcityUniqueBodyDex6": "MutatedUniqueBodyDex6DamageTaken", + "MovementVelicityPerEvasionUniqueBodyDex6": "MutatedUniqueBodyDex6ProjectileSpeedPercentPerEvasionRatingUpToCap" + }, + "Kintsugi": { + "IncreasedEvasionIfHitRecentlyUnique___1": "MutatedUniqueBodyDex8ChanceToSuppressIfYouHaveSuppressedRecently", + "ReducedDamageIfNotHitRecentlyUnique__1": "MutatedUniqueBodyDex8SuppressionPreventionIfYouHaventSuppressedRecently" + }, + "Carcass Jack": { + "AreaDamageUniqueBodyDexInt1": "MutatedUniqueBodyDexInt1AuraEffectOnEnemies", + "AreaOfEffectUniqueBodyDexInt1": "MutatedUniqueBodyDexInt1DisplaySocketedGemsSupportedByIntensify" + }, + "Cloak of Defiance": { + "IncreasedManaUniqueBodyDexInt2": "MutatedUniqueBodyDexInt2EldritchBattery", + "LocalIncreasedEvasionAndEnergyShieldUniqueBodyDexInt2": "MutatedUniqueBodyDexInt2GainManaAsExtraEnergyShield" + }, + "Victario's Influence": { + "IncreasedAuraRadiusUniqueBodyDexInt4": "MutatedUniqueBodyDexInt4NonCurseAuraDuration" + }, + "The Coming Calamity": { + "HeraldsAlwaysCost45Unique__1": "MutatedUniqueBodyInt12HeraldEffectOnSelf", + "StunAvoidancePerHeraldUnique__1": "MutatedUniqueBodyInt12HeraldOfDoom" + }, + "Skin of the Loyal": { + "LocalIncreaseSocketedGemLevelUnique__5": "MutatedUniqueBodyInt13SocketedGemQuality", + "AllDefencesUnique__2": "MutatedUniqueBodyInt13IncreasedAllResistances" + }, + "Skin of the Lords": { + "AllDefencesUnique__2": "MutatedUniqueBodyInt14IncreasedAllResistances", + "LocalIncreaseSocketedGemLevelUnique__11_": "MutatedUniqueBodyInt14aSocketedGemQuality" + }, + "Dialla's Malefaction": { + "SocketedGemsInBlueSocketEffectUnique__1": "MutatedUniqueBodyInt16BlueSocketGemsIgnoreAttributeRequirements", + "LocalCanSocketIgnoringColourUnique__1": "MutatedUniqueBodyInt16LocalIncreaseSocketedGemLevel" + }, + "Ghostwrithe": { + "ChaosResistUnique__26": "MutatedUniqueBodyInt21ChaosDamageTakenRecoupedAsLifeActual", + "MaximumLifeConvertedToEnergyShieldUnique__2": "MutatedUniqueBodyInt21MaximumEnergyShieldIsEqualToPercentOfMaximumLife" + }, + "Cloak of Flame": { + "BurnDurationUniqueBodyInt2": "MutatedUniqueBodyInt2DamageWhileIgnited", + "FireResistUniqueBodyInt2": "MutatedUniqueBodyInt2FireDamageLifeLeechPermyriad" + }, + "The Covenant": { + "LifeCostAsManaCostUnique__1": "MutatedUniqueBodyInt3BloodMagic" + }, + "Soul Mantle": { + "DisplaySocketedGemGetsSpellTotemBodyInt7": "MutatedUniqueBodyInt7SupportedByFlamewood" + }, + "Vis Mortis": { + "MinionElementalDamageAddedAsChaosUnique__1": "MutatedUniqueBodyInt9MinionHasUnholyMight" + }, + "Bramblejack": { + "IncreasedLifeUniqueBodyStr2": "MutatedUniqueBodyStr2LocalPhysicalDamageReductionRating" + }, + "Solaris Lorica": { + "ChaosDamageTakenUniqueBodyStr4": "MutatedUniqueBodyStr4ElementalDamageTakenAsChaos" + }, + "Greed's Embrace": { + "ItemFoundRarityIncreaseUniqueBodyStr5": "MutatedUniqueBodyStr5ExperienceIncrease" + }, + "Lioneye's Vision": { + "SocketedGemsSupportedByPierceUniqueBodyStr6": "MutatedUniqueBodyStr6ChanceToAvoidProjectiles" + }, + "The Brass Dome": { + "NoMaximumLifePerStrengthUnique__2": "MutatedUniqueBodyStr7GainNoInherentBonusFromStrength", + "MaximumElementalResistanceUnique__1__": "MutatedUniqueBodyStr7PrismaticBulwark" + }, + "Cherrubim's Maleficence": { + "IncreasedLifeLeechRateUniqueBodyStrDex4": "MutatedUniqueBodyStrDex4PhysicalDamageTakenAsChaos" + }, + "Gruthkul's Pelt": { + "SpellsAreDisabledUnique__1": "MutatedUniqueBodyStrDex7WarcriesAreDisabled", + "IncreasedPhysicalDamagePercentUnique__4": "MutatedUniqueBodyStrDex8AttackDamage" + }, + "Ambu's Charge": { + "AllResistancesUniqueBodyStrInt2": "MutatedUniqueBodyStrInt2MaximumEnduranceCharges" + }, + "Goldwyrm": { + "ItemFoundRarityIncreaseUnique__10": "MutatedUniqueBootsDex2IncreasedGold" + }, + "Victario's Flight": { + "MovementVelocityUnique__19": "MutatedUniqueBootsDex3ActionSpeedReduction" + }, + "Skyforth": { + "PowerChargeOnCriticalStrikeChanceUnique__1": "MutatedUniqueBootsInt7PowerChargeOnCriticalStrikeChance" + }, + "The Infinite Pursuit": { + "CannotBeStunnedWhileBleedingUnique__1": "MutatedUniqueBootsStr6ImmuneToElementalAilmentsWhileBleeding", + "NoExtraBleedDamageWhileMovingUnique__1": "MutatedUniqueBootsStr6IncreasedArmourWhileBleeding" + }, + "The Red Trail": { + "FrenzyChargeOnHitWhileBleedingUnique__1": "MutatedUniqueBottsStr7GainPowerChargeOnHitWhileBleeding", + "PhysicalDamageReductionWhileNotMovingUnique__1": "MutatedUniqueBootsStr7GainEnduranceChargeEveryXSecondsWhileStationary" + }, + "Darkray Vectors": { + "EvasionRatingPerFrenzyChargeUniqueBootsStrDex2": "MutatedUniqueBootsStrDex2IncreasedAccuracyPerFrenzy" + }, + "Alberon's Warpath": { + "PercentageStrengthUniqueBootsStrInt2": "MutatedUniqueBootsStrInt2PercentageIntelligence" + }, + "Null's Inclination": { + "CastSocketedMinionSpellsOnKillUniqueBow12": "MutatedUniqueBow12DisplaySupportedBySummonPhantasm", + "AddedChaosDamageUniqueBow12": "MutatedUniqueBow12MinionAddedChaosDamage", + "ChaosResistUniqueBow12": "MutatedUniqueBow12SummonWrithingWormEveryXMs" + }, + "Xoph's Inception": { + "AlwaysPierceBurningEnemiesUnique__1": "MutatedUniqueBow18DisplaySupportedByReturningProjectiles", + "PhysicalAddedAsFireUnique__3": "MutatedUniqueBow18FasterIgnite" + }, + "Xoph's Nurture": { + "ConvertPhysicalToFireUnique__1": "MutatedUniqueBow19AllDamageCanIgnite", + "SupportedByIgniteProliferationUnique1": "MutatedUniqueBow19SupportedByImmolate" + }, + "Quill Rain": { + "ProjectileSpeedUniqueBow4_": "MutatedUniqueBow4AreaOfEffect", + "ManaGainPerTargetUnique__2": "MutatedUniqueBow4BowAttacksUsableWithoutMana" + }, + "Chin Sol": { + "PhysicalBowDamageCloseRangeUniqueBow6": "MutatedUniqueBow6ChinsolDamageAgainstEnemiesOutsideCloseRange", + "KnockbackCloseRangeUniqueBow6": "MutatedUniqueBow6ProjectilesPierceAllNearbyTargets" + }, + "Rive": { + "CausesBleedingUnique__2Updated": "MutatedUniqueClaw13CrushOnHitChance", + "IncreaseBleedDurationPerIntelligenceUnique__1": "MutatedUniqueClaw13PhysicalSkillEffectDurationPerIntelligence" + }, + "Hand of Thought and Motion": { + "CriticalStrikeChancePerIntelligenceUnique__1": "MutatedUniqueClaw16AccuracyRatingPercentPer25Intelligence", + "RecoverPercentMaxLifeOnKillUnique__3": "MutatedUniqueClaw16PercentageStrength" + }, + "Hand of Wisdom and Action": { + "IncreasedAttackSpeedPerDexterityUnique__1": "MutatedUniqueClaw17CriticalStrikeMultiplierPer25Dexterity", + "LifeLeechFromAttacksPermyriadUnique__1": "MutatedUniqueClaw17PercentageStrength" + }, + "Al Dhih": { + "LocalIncreasedPhysicalDamagePercentUniqueClaw6": "MutatedUniqueClaw6ChaosDamage" + }, + "The Consuming Dark": { + "ChaosDamageChanceToPoisonUnique__1": "MutatedUniqueDagger10ChaosDamageCanIgnite" + }, + "Song of the Sirens": { + "FishingLureTypeUniqueFishingRod1": "MutatedUniqueFishingRod1FishingLureType", + "FishingExoticFishUniqueFishingRod1": "MutatedUniqueFishingRod1FishingMutatedFish" + }, + "Reefbane": { + "IncreasedCastSpeedUnique__3": "MutatedUniqueFishingRod2AvoidInterruptionWhileCasting", + "FishingLureTypeUnique__1__": "MutatedUniqueFishingRod2FishingLureType" + }, + "Maligaro's Virtuosity": { + "CriticalStrikeMultiplierIs250Unique__1": "MutatedUniqueGlovesDex2ActionSpeedMinimum90", + "CriticalStrikeChanceUniqueGlovesDex2": "MutatedUniqueGlovesDex2CriticalStrikesNonDamagingAilmentEffect" + }, + "Facebreaker": { + "CriticalMultiplierUniqueGlovesDexInt2": "MutatedUniqueGlovesDexInt2UnarmedAreaOfEffect" + }, + "Snakebite": { + "IncreasedLifeUniqueGlovesDexInt5": "MutatedUniqueGlovesDexInt5LocalEnergyShield" + }, + "Shadows and Dust": { + "ManaLeechPermyriadUniqueGlovesDexInt6": "MutatedUniqueGlovesDexInt6BlindEffect" + }, + "The Embalmer": { + "SupportedByVileToxinsUnique__1": "MutatedUniqueGlovesDexInt7FasterPoisonDamage", + "PoisonDurationUnique__2": "MutatedUniqueGlovesDexInt7PoisonSpread" + }, + "Sadima's Touch": { + "ItemFoundRarityIncreaseUnique__7": "MutatedUniqueGlovesInt1IncreasedGold" + }, + "Kalisa's Grace": { + "SupportedByFasterCastUnique__1": "MutatedUniqueGlovesInt4DisplaySocketedGemsSupportedByFocusedChannelling", + "GainCriticalStrikeChanceOnManaSpentUnique__1": "MutatedUniqueGlovesInt4GainManaCostReductionOnManaSpent" + }, + "Voidbringer": { + "SpellCriticalStrikeChanceUniqueGlovesInt6": "MutatedUniqueGlovesInt6ChaosDamagePerCorruptedItem" + }, + "Kaom's Spirit": { + "FireResistUnique__30": "MutatedUniqueGlovesStr12RageLossDelay" + }, + "Flesh and Spirit": { + "HealOnRampageUniqueGlovesStrDex5": "MutatedUniqueGlovesStrDex5VaalSkillDuration" + }, + "Wyrmsign": { + "ItemActsAsConcentratedAOESupportUnique__1": "MutatedUniqueGlovesStrDex7SupportedByManaforgedArrows" + }, + "Shackles of the Wretched": { + "StunRecoveryUniqueGlovesStrInt1": "MutatedUniqueGlovesStrInt1SelfCurseDuration" + }, + "Null and Void": { + "ManaRegenerationUniqueGlovesStrInt2": "MutatedUniqueGlovesStrInt2LifeRegenerationRatePercentage" + }, + "Goldrim": { + "AllResistancesUniqueHelmetDex3": "MutatedUniqueHelmetDex3ChaosResistance" + }, + "Starkonja's Head": { + "IncreasedLifeUniqueHelmetDex4": "MutatedUniqueHelmetDex4IncreasedMana" + }, + "Alpha's Howl": { + "ManaReservationEfficiencyUniqueHelmetDex5_": "MutatedUniqueHelmetDex5LifeReservationEfficiency", + "LocalIncreaseSocketedAuraGemLevelUniqueHelmetDex5": "MutatedUniqueHelmetDex5LocalIncreaseSocketedMinionGemLevel" + }, + "Malachai's Simula": { + "SpellsDoubleDamageChanceUnique__1": "MutatedUniqueHelmetDexInt1MinionDoubleDamage" + }, + "The Three Dragons": { + "LightningFreezesUniqueHelmetDexInt4": "MutatedUniqueHelmetDexInt4ChaosDamageCanFreeze", + "ColdIgnitesUniqueHelmetDexInt4_": "MutatedUniqueHelmetDexInt4ChaosDamageCanIgnite", + "FireShocksUniqueHelmetDexInt4": "MutatedUniqueHelmetDexInt4ChaosDamageCanShock" + }, + "Crown of the Pale King": { + "LifeLeechPermyriadUniqueHelmetDexInt6": "MutatedUniqueHelmetDexInt6RetaliationSkillCooldownRecoveryRate" + }, + "Doedre's Scorn": { + "IncreaseSocketedCurseGemLevelUniqueHelmetInt9": "MutatedUniqueHelmetInt9WeaponTreeSupportImpendingDoom" + }, + "The Formless Flame": { + "FireResistUnique__10": "MutatedUniqueHelmetStr4FireDamageTakenAsPhysical", + "ArmourIncreasedByUncappedFireResistanceUnique__1": "MutatedUniqueHelmetStr4TotemLifeIncreasedByOvercappedFireResistance" + }, + "The Formless Inferno": { + "FireResistUnique__10": "MutatedUniqueHelmetStr4FireDamageTakenAsPhysical", + "SupportedByInfernalLegionUnique__1": "MutatedUniqueHelmetStr5SupportedByMinionLife" + }, + "Devoto's Devotion": { + "MovementVelocityUniqueHelmetStrDex2": "MutatedUniqueHelmetStrDex2AttackSpeedWithMovementSkills", + "DexterityUniqueHelmetStrDex2": "MutatedUniqueHelmetStrDex2ChanceToSuppressSpells" + }, + "Deidbell": { + "SkillsExertAttacksDoNotCountChanceUnique__1": "MutatedUniqueHelmetStrDex3WarcryBuffEffect", + "DexterityUniqueHelmetStrDex3": "MutatedUniqueHelmetStrDex3WarcryCooldownSpeed" + }, + "Veil of the Night": { + "AllDefencesUniqueHelmetStrInt4_": "MutatedUniqueHelmetStrInt4MaximumLifeIncreasePercent" + }, + "Kitava's Thirst": { + "ChanceToCastOnManaSpentUnique__1": "MutatedUniqueHelmetStrInt6ChanceToCastOnManaSpent" + }, + "Voll's Vision": { + "LifeRegenerationPerMinuteWhileNoCorruptedItemsUnique__1": "MutatedUniqueHelmStrInt7LifeRegenerationPercentAppliesToEnergyShieldWithNoCorruptedItems", + "IncreasedLifeWhileNoCorruptedItemsUnique__1": "MutatedUniqueHelmStrInt7MaximumEnergyShieldAsPercentageOfLifeWithNoCorruptItems" + }, + "Might of the Meek": { + "PassiveEffectivenessJewelUnique__1_": "MutatedUniqueJewel112Small" + }, + "Unnatural Instinct": { + "AllocatedNonNotablesGrantNothingUnique__1_": "MutatedUniqueJewel125AllocatedNotablePassiveSkillsInRadiusDoNothing", + "GrantsStatsFromNonNotablesInRadiusUnique__1": "MutatedUniqueJewel125GrantsAllBonusesOfUnallocatedNotablesInRadius" + }, + "Stormshroud": { + "LightningResistUnique__28": "MutatedUniqueJewel173ShockDuration", + "ShockAvoidanceAllElementalAilmentsUnique__1": "MutatedUniqueJewel173ShockEffect" + }, + "Firesong": { + "SelfIgniteDurationAllElementalAilmentsUnique__1": "MutatedUniqueJewel174BurnDurationForJewel", + "FireResistUnique__31": "MutatedUniqueJewel174FireDamageOverTimeMultiplier" + }, + "Witchbane": { + "IntelligenceUnique__31": "MutatedUniqueJewel175CurseDuration", + "ImmuneToCursesRemainingDurationUnique__1": "MutatedUniqueJewel175CurseEnemiesExplode25%Chaos" + }, + "Ancestral Vision": { + "DexterityUnique__26": "MutatedUniqueJewel177ChanceToSuppressSpells", + "ModifiersToSuppressionApplyToAilmentAvoidUnique__1": "MutatedUniqueJewel177SpellDamageSuppressed" + }, + "Inspired Learning": { + "StealRareModUniqueJewel3": "MutatedUniqueJewel3GainRandomRareMonsterModOnKillWhileXSmallPassivesAllocatedInRadius" + }, + "Lioneye's Fall": { + "UniqueJewelMeleeToBow": "MutatedUniqueJewel5EvasionModifiersInRadiusAreTransformedToArmour" + }, + "Intuitive Leap": { + "JewelUniqueAllocateDisconnectedPassives": "MutatedUniqueJewel6KeystoneCanBeAllocatedInMassiveRadiusWithoutBeingConnected" + }, + "The Red Dream": { + "FireResistAlsoGrantsEnduranceChargeOnKillJewelUnique__1": "MutatedUniqueJewel85FireResistAlsoGrantsMaximumLifePercent", + "ChaosDamageAsPortionOfFireDamageUnique__1": "MutatedUniqueJewel86ExtraDamageRollsWithFireIfBlockedAttackRecently" + }, + "The Red Nightmare": { + "ChaosDamageAsPortionOfFireDamageUnique__1": "MutatedUniqueJewel86ExtraDamageRollsWithFireIfBlockedAttackRecently", + "FireResistConvertedToBlockChanceScaledJewelUnique__1_": "MutatedUniqueJewel86UniqueJewelFireResistAlsoGrantsConvertFireToChaos" + }, + "The Green Dream": { + "ColdResistAlsoGrantsFrenzyChargeOnKillJewelUnique__1": "MutatedUniqueJewel87ColdResistAlsoGrantsMaximumManaPercent", + "ChaosDamageAsPortionOfColdDamageUnique__1": "MutatedUniqueJewel88ExtraDamageRollsWithColdIfSuppressedRecently" + }, + "The Green Nightmare": { + "ChaosDamageAsPortionOfColdDamageUnique__1": "MutatedUniqueJewel88ExtraDamageRollsWithColdIfSuppressedRecently", + "ColdResistConvertedToDodgeChanceScaledJewelUnique__1": "MutatedUniqueJewel88UniqueJewelColdResistAlsoGrantsConvertColdToChaos" + }, + "The Blue Dream": { + "LightningResistAlsoGrantsPowerChargeOnKillJewelUnique__1": "MutatedUniqueJewel89LightningResistAlsoGrantsMaximumESPercent", + "ChaosDamageAsPortionOfLightningDamageUnique__1": "MutatedUniqueJewel90ExtraDamageRollsWithLightningIfBlockedSpellRecently" + }, + "The Blue Nightmare": { + "ChaosDamageAsPortionOfLightningDamageUnique__1": "MutatedUniqueJewel90ExtraDamageRollsWithLightningIfBlockedSpellRecently", + "LightningResistConvertedToSpellBlockChanceScaledJewelUnique__1": "MutatedUniqueJewel90UniqueJewelLightningResistAlsoGrantsConvertLightningToChaos" + }, + "Moonbender's Wing": { + "LocalIncreasedPhysicalDamagePercentUniqueOneHandAxe8": "MutatedUniqueOneHandAxe8LocalIncreasedAttackSpeed" + }, + "Rigwald's Savagery": { + "DualWieldingPhysicalDamageUnique__1": "MutatedUniqueOneHandAxe9MeleeHitsCannotBeEvadedWhileWieldingSword" + }, + "Frostbreath": { + "LocalIncreasedAttackSpeedUnique__11": "MutatedUniqueOneHandMace10LocalCriticalStrikeChance" + }, + "Mjolner": { + "AdditionalChainUniqueOneHandMace3": "MutatedUniqueOneHandMace3AreaOfEffect", + "CastSocketedLightningSpellsOnHit": "MutatedUniqueOneHandMace3LightningBoltOnHit" + }, + "Rigwald's Command": { + "IncreasedAccuracyUnique__2": "MutatedUniqueOneHandSword15DualWieldingSpellBlockForJewel" + }, + "Severed in Sleep": { + "MinionWitherOnHitUnique__1": "MutatedUniqueOneHandSword22MinionBaseCriticalStrikeChance", + "MinionChaosResistanceUnique___1": "MutatedUniqueOneHandSword22MinionUnholyMightChance" + }, + "United in Dream": { + "MinionsRecoverLifeOnKillingPoisonedEnemyUnique__1_": "MutatedUniqueOneHandSword23MinionSkillGemQuality", + "MinionChaosResistanceUnique__2__": "MutatedUniqueOneHandSword23MinionUnholyMightChance" + }, + "Drillneck": { + "IncreasedAttackSpeedUniqueQuiver3": "MutatedUniqueQuiver3ImpaleEffect" + }, + "Rearguard": { + "ProjectileSpeedUniqueQuiver4": "MutatedUniqueQuiver4BowStunThresholdReduction" + }, + "Soul Strike": { + "IncreasedEnergyShieldUniqueQuiver7": "MutatedUniqueQuiver7MaximumLifeConvertedToEnergyShield", + "ReducedEnergyShieldDelayUniqueQuiver7": "MutatedUniqueQuiver7StartEnergyShieldRechargeOnSkillChance" + }, + "Gifts from Above": { + "CriticalStrikeChanceUniqueRing11_": "MutatedUniqueRing11ConsecratedGroundEffect" + }, + "Shavronne's Revelation": { + "LeftRingSlotMaximumEnergyShieldUnique__1": "MutatedUniqueRing13LeftRingSlotEvasionRating", + "RightRingSlotMaximumManaUnique__1": "MutatedUniqueRing13RightRingSlotArmour" + }, + "Ming's Heart": { + "MaximumLifeUniqueRing16": "MutatedUniqueRing16DisablesOtherRingSlot" + }, + "Romira's Banquet": { + "IncreasedAccuracyUniqueRing17": "MutatedUniqueRing17IncreasedMaximumPowerCharges" + }, + "Berek's Pass": { + "DamageWhileIgnitedUniqueRing18": "MutatedUniqueRing18ActionSpeedMinimumWhileIgnited", + "ArmourWhileFrozenUniqueRing18": "MutatedUniqueRing18RecoupWhileFrozen" + }, + "Berek's Grip": { + "LifeLeechPermyriadOnFrozenEnemiesUniqueRing19": "MutatedUniqueRing19EnemiesShockedByHitsAreDebilitated", + "EnergyShieldLeechPermyriadOnFrozenEnemiesUniqueRing19": "MutatedUniqueRing19FrozenMonstersTakePercentIncreasedDamage" + }, + "Berek's Respite": { + "ShockNearbyEnemyOnShockedKillUniqueRing20": "MutatedUniqueRing20ShockedEnemiesExplode", + "IgniteNearbyEnemyOnIgnitedKillUniqueRing20": "MutatedUniqueRing20IgnitedEnemiesExplode" + }, + "Mokou's Embrace": { + "ColdResistUniqueRing24": "MutatedUniqueRing24MaximumFireResist" + }, + "Kikazaru": { + "LifeRegenerationPerLevelUnique__1": "MutatedUniqueRing26ManaPerLevel" + }, + "Timeclasp": { + "SkillEffectDurationUnique__2_": "MutatedUniqueRing27DebuffTimePassed" + }, + "Ventor's Gamble": { + "IncreasedLifeUniqueRing32": "MutatedUniqueRing32EnergyShieldAndMana" + }, + "Heartbound Loop": { + "LifeRegenerationUniqueRing33": "MutatedUniqueRing33MinionSkillManaCost" + }, + "Le Heup of All": { + "AllDamageUniqueRing6": "MutatedUniqueRing6AllDefences", + "ItemFoundRarityIncreaseUniqueRing6": "MutatedUniqueRing6CriticalStrikeMultiplier" + }, + "Thief's Torment": { + "ReducedCurseEffectUniqueRing7": "MutatedUniqueRing7NonDamagingAilmentEffectOnSelf" + }, + "Lori's Lantern": { + "EnemyExtraDamageRollsOnLowLifeUniqueRing9": "MutatedUniqueRing9ExtraDamageRollsWhileLowLife", + "MovementVelocityOnLowLifeUniqueRing9": "MutatedUniqueRing9IncreasedAttackSpeedWhenOnLowLife" + }, + "Singularity": { + "SpellAddedLightningDamageUnique__2": "MutatedUniqueSceptre13ColdDamageOverTimeMultiplier" + }, + "Chalice of Horrors": { + "BlockChanceVersusCursedEnemiesUnique__1": "MutatedUniqueShieldDex4ChaosDamageOverTimeMultiplier" + }, + "Great Old One's Ward": { + "SpellBlockPercentageUniqueShieldDex6": "MutatedUniqueShieldDex6ImpaleChanceForJewel" + }, + "Kiloava's Bluster": { + "AdditionalBlockChanceUnique__10": "MutatedUniqueShieldDex9DegenDamageTaken", + "TreatResistancesAsMaxChanceUnique__1": "MutatedUniqueShieldDex9TreatElementalResistanceAsInverted" + }, + "Rathpith Globe": { + "SpellCriticalStrikeChancePerLifeUnique__1": "MutatedUniqueShieldInt1DamageOverTimePer100PlayerMaxLife", + "SpellDamagePerLifeUnique__1": "MutatedUniqueShieldInt1NonDamagingAilmentWithCritsEffectPer100MaxLife" + }, + "Matua Tupuna": { + "LocalIncreaseSocketedMinionGemLevelUniqueShieldInt2": "MutatedUniqueShieldInt2LocalIncreaseSocketedAuraLevel" + }, + "Kongming's Stratagem": { + "FireDamageToBlindEnemies__1": "MutatedUniqueShieldInt6EnchantmentBlind" + }, + "Malachai's Loop": { + "IncreasedSpellDamagePerPowerChargeUnique__1": "MutatedUniqueShieldInt7DodgeChancePerPowerCharge" + }, + "Esh's Mirror": { + "ShockProliferationUnique__2": "MutatedUniqueShieldInt8AlwaysShockLowLifeEnemies", + "LightningResistUnique__7": "MutatedUniqueShieldInt8DamageCannotBeReflected" + }, + "Esh's Visage": { + "ChaosDamageDoesNotBypassESNotLowLifeOrManaUnique__1": "MutatedUniqueShieldInt9ChaosDamageDoesNotBypassESWhileNotLowMana", + "LightningResistUnique__8": "MutatedUniqueShieldInt9DamageCannotBeReflected" + }, + "The Anticipation": { + "GainArmourIfBlockedRecentlyUnique__1": "MutatedUniqueShieldStr8ArmourAppliesToElementalIfBlockedRecently", + "AdditionalBlockChanceUnique__2": "MutatedUniqueShieldStr8MaximumBlockChance" + }, + "The Surrender": { + "AdditionalBlockChanceUnique__2": "MutatedUniqueShieldStr8MaximumBlockChance", + "GainLifeOnBlockUnique__1": "MutatedUniqueShieldStr9GainEnergyShieldOnBlock" + }, + "The Squire": { + "AllSocketsAreWhiteUniqueShieldStrDex7_": "MutatedUniqueShieldStrDex7LocalGemsSocketedHaveNoAttributeRequirements", + "IncreaseSocketedSupportGemQualityUnique__1___": "MutatedUniqueShieldStrDex7LocalIncreaseSocketedGemLevel" + }, + "The Oppressor": { + "SpellDamageSuppressedUnique__2": "MutatedUniqueShieldStrDex8MonsterChanceToAvoid", + "BaseBlockDamageTakenUnique__1___": "MutatedUniqueShieldStrDex8SpellBlockPercentage" + }, + "Rise of the Phoenix": { + "MovementVelocityOnLowLifeUniqueShieldStrInt5": "MutatedUniqueShieldStrInt5CastSpeedOnLowLife", + "LifeRegenerationUniqueShieldStrInt5": "MutatedUniqueShieldStrInt5FlatEnergyShieldRegenerationPerMinute" + }, + "Sire of Shards": { + "IncreasedProjectileDamageUniqueStaff10": "MutatedUniqueStaff10DisplaySocketedSkillsChain" + }, + "Uul-Netol's Kiss": { + "CurseLevel10VulnerabilityOnHitUnique__1": "MutatedUniqueTwoHandAxe11WarcriesExertAnAdditionalAttack", + "ExertedAttackDamageUnique__1": "MutatedUniqueTwoHandAxe11WarcryCooldownSpeed" + }, + "Uul-Netol's Embrace": { + "LocalChanceToBleedUnique__1": "MutatedUniqueTwoHandAxe12FasterBleedDamage", + "LocalReducedAttackSpeedUnique__3": "MutatedUniqueTwoHandAxe12PercentageIntelligence" + }, + "The Harvest": { + "LocalIncreasedPhysicalDamagePercentUniqueTwoHandAxe8": "MutatedUniqueTwoHandAxe8SpellDamage" + }, + "Kongor's Undying Rage": { + "AlwaysHits": "MutatedUniqueTwoHandMace6KeystoneBattlemage", + "NoBonusesFromCriticalStrikes": "MutatedUniqueTwoHandMace6LoseLifePercentOnCrit" + }, + "Jorrhast's Blacksteel": { + "NumberOfAdditionalAnimateWeaponCopiesUniqueTwoHandMace8": "MutatedUniqueTwoHandMace8DoubleAnimateWeaponLimit", + "IncreasedCastSpeedUniqueTwoHandMace8": "MutatedUniqueTwoHandMace8IncreasedMinionDamageIfYouHitEnemy" + }, + "Edge of Madness": { + "LocalIncreaseSocketedActiveSkillGemLevelUniqueTwoHandSword7_": "MutatedUniqueTwoHandSword7AccuracyRatingPerLevel" + }, + "Doomsower": { + "VaalPactIfAllSocketedGemsAreRedUniqueTwoHandSword8": "MutatedUniqueTwoHandSword8ImmortalAmbitionIfAllSocketsRed", + "AttackSkillsHavePhysToExtraFireDamagePerSocketedRedGemUniqueTwoHandSword8": "MutatedUniqueTwoHandSword8RecoupedAsLifePerRedGem" + }, + "The Dancing Dervish": { + "LocalIncreasedPhysicalDamagePercentUnique__7": "MutatedUniqueTwoHandSword9LocalLightningDamage" + }, + "Ashcaller": { + "SpellAddedFireDamageUnique__3": "MutatedUniqueWand14MinionPhysicalDamageAddedAsFire" + }, + "Tulborn": { + "SpellAddedColdDamageUnique__7": "MutatedUniqueWand15GlobalIncreaseColdSpellSkillGemLevel", + "GainPowerChargeOnKillingFrozenEnemyUnique__1": "MutatedUniqueWand15PowerChargeOnManaSpent" + }, + "Tulfall": { + "GainPowerChargeOnKillingFrozenEnemyUnique__1": "MutatedUniqueWand15PowerChargeOnManaSpent", + "AddedColdDamagePerPowerChargeUnique__2": "MutatedUniqueWand16ColdDamageOverTimeMultiplierPerPowerCharge" + }, + "Midnight Bargain": { + "LifeReservationUniqueWand2": "MutatedUniqueWand2LifeAndEnergyShieldDegenPerMinion", + "MaximumMinionCountUniqueWand2Updated": "MutatedUniqueWand2MaximumGolems" + }, + "Piscator's Vigil": { + "LocalCriticalStrikeChanceUniqueWand6_": "MutatedUniqueWand6WeaponTreeFishingWishEffectOfAncientFish" + }, + "Reverberation Rod": { + "LocalIncreaseSocketedGemLevelUniqueWand8": "MutatedUniqueWand8SupportedByAwakenedSpellCascade" + }, + "Abyssus": { + "MeleeWeaponCriticalStrikeMultiplierUniqueHelmetStr3": "MutatedUniqueHelmetStr3BleedDotMultiplier" + }, + "Aegis Aurora": { + "WeaponElementalDamageUniqueShieldStrInt4": "MutatedUniqueShieldStrInt4DamageCannotBeReflected" + }, + "Anathema": { + "PowerChargeOnCurseUnique__1": "MutatedUniqueRing75CurseDuration" + }, + "Apep's Rage": { + "SpellAddedChaosDamageUniqueWand7": "MutatedUniqueWand7AddedChaosDamageFromManaCost" + }, + "Asenath's Gentle Touch": { + "CurseOnHitTemporalChainsUnique__1": "MutatedUniqueGlovesInt3PunishmentOnHit" + }, + "Asenath's Mark": { + "IncreasedAttackSpeedUniqueIntHelmet2": "MutatedUniqueHelmetInt2GlobalCooldownRecovery" + }, + "Astral Projector": { + "NovaSpellsAreaOfEffectUnique__1": "MutatedUniqueRing64GlobalEnergyShieldPercent" + }, + "Astramentis": { + "PhysicalAttackDamageReducedUniqueAmulet8": "MutatedUniqueAmulet8ChaosResistance" + }, + "Aurseize": { + "AllResistancesUniqueGlovesStrDex2": "MutatedUniqueGlovesStrDex2IncreasedGold" + }, + "Auxium": { + "ManaLeechPermyriadPerPowerChargeUniqueBelt5_": "MutatedUniqueBelt5IncreasedEnergyShieldPerPowerCharge" + }, + "Badge of the Brotherhood": { + "TravelSkillCooldownRecoveryPerFrenzyChargeUnique__1": "MutatedUniqueAmulet57MovementVelocityPerFrenzyCharge" + }, + "Belly of the Beast": { + "AllResistancesUniqueBodyStrDex1": "MutatedUniqueBodyStrDex1MaximumRage" + }, + "Call of the Brotherhood": { + "SpellChanceToShockFrozenEnemiesUniqueRing34": "MutatedUniqueRing34GainPowerChargeOnKillingFrozenEnemy" + }, + "Cospri's Malice": { + "SpellAddedColdDamageUnique__4": "MutatedUniqueOneHandSword21IncreasedWeaponElementalDamagePercent" + }, + "Cospri's Will": { + "ChanceToPoisonCursedEnemiesOnHitUnique__1": "MutatedUniqueBodyDex9WitherOnHitChanceVsCursedEnemies" + }, + "Cybil's Paw": { + "LifeGainedOnSpellHitUniqueClaw7": "MutatedUniqueClaw7RecoverEnergyShieldFromEvasionOnBlock" + }, + "Darkscorn": { + "PhysicalDamageConvertToChaosUniqueBow5": "MutatedUniqueBow5UnholyMightOnCritChance" + }, + "Death Rush": { + "MaximumLifeOnKillPercentUnique__5": "MutatedUniqueRing12AdditionalVaalSoulOnKill" + }, + "Death's Hand": { + "PowerChargeOnStunUniqueSceptre10": "MutatedUniqueSceptre10PowerChargeOnStunUniqueSceptre10" + }, + "Death's Harp": { + "LocalIncreasedPhysicalDamagePercentUniqueBow3": "MutatedUniqueBow3ChaosDamageAsPortionOfDamage" + }, + "Death's Oath": { + "IncreasedAttackSpeedUniqueBodyStr3": "MutatedUniqueBodyStr3WitheredEffect" + }, + "Defiance of Destiny": { + "GainMissingLifeOnHitUnique__1": "MutatedUniqueAmulet76GainMissingManaPercentWhenHit" + }, + "Doomfletch": { + "LocalIncreasedAttackSpeedUniqueBow11": "MutatedUniqueBow11SupportedByPrismaticBurst" + }, + "Doon Cuebiyari": { + "ArmourPerStrengthInOffHandUniqueSceptre6": "MutatedUniqueSceptre6EnergyShieldPerStrengthIfInOffHand", + "DamagePerStrengthInMainHandUniqueSceptre6": "MutatedUniqueSceptre6ManaPerStrengthIfInMainHand" + }, + "Doryani's Fist": { + "ChanceToShockUniqueGlovesStr4": "MutatedUniqueGlovesStr4SapChance" + }, + "Dream Fragments": { + "ManaRegenerationUniqueRing5": "MutatedUniqueRing5StunRecovery" + }, + "Dreamfeather": { + "LocalAddedPhysicalDamageUniqueOneHandSword9": "MutatedUniqueOneHandSword9AttackSpeedPer200Accuracy" + }, + "Emperor's Vigilance": { + "SpellBlockPercentageUnique__3_": "MutatedUniqueShieldStrInt13LocalMaximumQuality" + }, + "Fleshcrafter": { + "MinionEnergyShieldRechargeDelayUnique__1": "MutatedUniqueBodyInt20MinionLeechEnergyShieldFromElementalDamage" + }, + "Gang's Momentum": { + "MovementVelocityUniqueBootsStrInt3": "MutatedUniqueBootsStrInt3MovementVelocityWhileIgnited" + }, + "Heatshiver": { + "ManaRegenerationUniqueDexHelmet2": "MutatedUniqueHelmetDex2ConvertColdToFire" + }, + "Hinekora's Sight": { + "IncreasedEvasionRatingUnique__6_": "MutatedUniqueAmulet31LightRadius" + }, + "Hyrri's Ire": { + "ChanceToSuppressSpellsUniqueBodyDex1": "MutatedUniqueBodyDex1SpellDamageSuppressed" + }, + "Incandescent Heart": { + "IncreasedLifeUniqueBodyStrInt5": "MutatedUniqueBodyStrInt5LightRadiusAppliesToAccuracy" + }, + "Infernal Mantle": { + "CriticalStrikeChanceUniqueBodyInt4": "MutatedUniqueBodyInt4GainManaAsExtraEnergyShield" + }, + "Inpulsa's Broken Heart": { + "IncreasedDamageIfShockedRecentlyUnique__1": "MutatedUniqueBodyDexInt6PurityOfLightningNoReservation" + }, + "Kaom's Primacy": { + "CullingStrike": "MutatedUniqueTwoHandAxe1MaximumEnduranceCharges" + }, + "Kaom's Roots": { + "IncreasedLifeUniqueBootsStr2": "MutatedUniqueBootsStr2Strength" + }, + "Lightning Coil": { + "IncreasedLifeUniqueBodyStrDex2": "MutatedUniqueBodyStrDex2ChaosDamageTakenAsLightning" + }, + "Lioneye's Remorse": { + "IncreasedLifeUniqueShieldStr1": "MutatedUniqueShieldStr1MaximumLifeAddedAsArmour" + }, + "Maata's Teaching": { + "LocalCriticalStrikeChanceUnique__21": "MutatedUniqueSceptre25MinionCriticalStrikeMultiplier" + }, + "Mageblood": { + "MagicUtilityFlasksAlwaysApplyUnique__1": "MutatedUniqueBelt43MagicUtilityFlasksAlwaysApplyRightmost" + }, + "Maloney's Mechanism": { + "IncreasedAttackSpeedUnique__4_": "MutatedUniqueQuiver15SupportedByArrowNova" + }, + "Meginord's Vise": { + "StrengthUniqueGlovesStr2": "MutatedUniqueGlovesStr2StrengthRequirementAndTripleDamageChance" + }, + "Mind of the Council": { + "MaximumManaUnique___2": "MutatedUniqueHelmetInt11PhysicalDamageRemovedFromManaBeforeLife" + }, + "Ngamahu's Flame": { + "PenetrateEnemyFireResistUnique__1": "MutatedUniqueTwoHandAxe14AttackAdditionalProjectiles" + }, + "Prism Guardian": { + "SocketedItemsHaveReducedReservationUniqueShieldStrInt2": "MutatedUniqueShieldStrInt2SocketedGemQuality" + }, + "Pyre": { + "BurnDamageUniqueRing15": "MutatedUniqueRing15ColdDamageTakenAsFire" + }, + "Razor of the Seventh Sun": { + "IncreasedMeleePhysicalDamageAgainstIgnitedEnemiesUnique__1": "MutatedUniqueOneHandSword20LocalWeaponMoreIgniteDamage" + }, + "Rebuke of the Vaal": { + "LocalIncreasedAttackSpeedOneHandSword3": "MutatedUniqueOneHandSword3TrapThrowSpeed" + }, + "Repentance": { + "PercentageStrengthUnique__5": "MutatedUniqueGlovesStrInt4PercentageIntelligence" + }, + "Rigwald's Quills": { + "IncreasedProjectileDamageUnique___4": "MutatedUniqueQuiver10ChanceToAggravateBleed" + }, + "Rime Gaze": { + "ItemActsAsConcentratedAOESupportUniqueHelmetInt4": "MutatedUniqueHelmetInt4SupportedByFrigidBond" + }, + "Saffell's Frame": { + "SpellBlockPercentageUniqueShieldStrInt1": "MutatedUniqueShieldStrInt1EnergyShieldIncreasedByChaosResistance" + }, + "Scold's Bridle": { + "ReducedCastSpeedUniqueHelmetInt8": "MutatedUniqueHelmetInt8ManaCostReduction" + }, + "Seven-League Step": { + "MovementVelocityUnique___6": "MutatedUniqueBootsDex5ActionSpeedReduction" + }, + "Shavronne's Wrappings": { + "ReducedEnergyShieldDelayUniqueBodyInt1": "MutatedUniqueBodyInt1SupportedByLivingLightning" + }, + "Soulthirst": { + "IncreasedFlaskDurationUnique__1": "MutatedUniqueBelt18ManaRecoveryRate" + }, + "Sunblast": { + "TrapDurationUniqueBelt6": "MutatedUniqueBelt6TrapAreaOfEffect" + }, + "The Baron": { + "AdditionalZombiesPerXStrengthUnique__1": "MutatedUniqueHelmetStr6AdditionalZombiesPerXIntelligence", + "ZombiesLeechLifeToYouAt1000StrengthUnique__1": "MutatedUniqueHelmetStr6ZombiesLeechEnergyShieldToYouAt1000Intelligence" + }, + "The Bringer of Rain": { + "DisplaySocketedGemGetsFasterAttackUniqueHelmetStrDex4": "MutatedUniqueHelmetStrDex4SupportedBySadism" + }, + "The Fourth Vow": { + "PhysicalDamageBypassesEnergyShieldUnique__1": "MutatedUniqueBodyStrInt15NonChaosDamageBypassEnergyShieldPercent" + }, + "The Gull": { + "IncreasedLifeUnique__46": "MutatedUniqueHelmetDexInt3MaximumLifeConvertedToEnergyShield" + }, + "The Iron Fortress": { + "BlockChancePer50StrengthUnique__1": "MutatedUniqueBodyStr9SpellBlockPer50Strength", + "ExtraRollsSpellBlockUnique__1": "MutatedUniqueBodyStr9AttackBlockLuck" + }, + "The Magnate": { + "BeltIncreasedFlaskChargesGainedUniqueBelt2": "MutatedUniqueBelt2FlaskEffect" + }, + "The Poet's Pen": { + "AddsPhysicalDamagePer3PlayerLevelsUnique__1_": "MutatedUniqueWand18SpellAddedPhysicalDamagePerLevel" + }, + "The Searing Touch": { + "IncreasedCastSpeedUniqueStaff1": "MutatedUniqueStaff1SearingBondTotemsAllowed" + }, + "The Whispering Ice": { + "SpellDamagePerIntelligenceUniqueStaff12": "MutatedUniqueStaff12AreaOfEffectPer20Int" + }, + "Thunderfist": { + "LightningAilmentEffectUnique__1": "MutatedUniqueGlovesDexInt3HeraldOfThunderBuffEffect" + }, + "Tinkerskin": { + "GainFrenzyChargeOnTrapTriggeredUnique__1": "MutatedUniqueBodyDexInt5TrapSkillCooldownCount" + }, + "Tremor Rod": { + "SupportedByRemoteMineUniqueStaff11": "MutatedUniqueStaff11AnimalCharmMineAuraEffect" + }, + "Utula's Hunger": { + "IncreasedLifeNoLifeModifiersUnique__1": "MutatedUniqueBodyStr13MaximumEnergyShieldIfNoDefenceModifiersOnEquipment" + }, + "Vaal Caress": { + "OnslaughtOnVaalSkillUseUniqueGlovesStrDex4": "MutatedUniqueGlovesStrDex4AdrenalineOnVaalSkillUse" + }, + "Veruso's Battering Rams": { + "CannotBeShockedWhileMaximumEnduranceChargesUnique_1": "MutatedUniqueGlovesStr7CannotBeIgnitedAtMaxEnduranceCharges" + }, + "Void Battery": { + "CriticalStrikeChanceUniqueWand3": "MutatedUniqueWand3AreaOfEffectPerPowerCharge" + }, + "Voll's Protector": { + "MaximumManaUniqueBodyStrInt1": "MutatedUniqueBodyStrInt1ChaosResistance" + }, + "Warrior's Legacy": { + "RingAttackSpeedUnique__1": "MutatedUniqueRing63MaximumLifeIncreasePercent" + }, + "Windscream": { + "CurseAreaOfEffectUnique__3": "MutatedUniqueBootsStr1CurseImmunity" + }, + "Ylfeban's Trickery": { + "ShockedGroundWhenHitUnique__1": "MutatedUniqueHelmetInt10AdditiveSpellModifiersApplyToRetaliationAttackDamage" + }, + "Zahndethus' Cassock": { + "LightRadiusUniqueBodyInt8": "MutatedUniqueBodyInt8ProfaneGroundInsteadOfConsecratedGround" + }, + "Khatal's Weeping": { + "LifeFlaskPassiveChargeGainOnLowLifeUnique__1": "MutatedUniqueUniqueAmulet85LifeFlaskChargesEvery3Seconds", + "IncreasedLifeUnique__127": "MutatedUniqueUniqueAmulet85PercentIncreasedLife" + }, + "Khatal's Geyser": { + "ManaFlaskEffectsAreNotRemovedAtFullManaUnique__1": "MutatedUniqueUniqueAmulet81ManaFlaskChargesEvery3Seconds", + "IncreasedManaUnique__31": "MutatedUniqueUniqueAmulet81PercentIncreasedMana" + }, + "The Desecrated Chalice": { + "ChaosDamageLifeLeechPermyriadUnique__3": "MutatedUniqueUniqueTwoHandMace16ChaosResistance", + "LocalCriticalStrikeChanceUnique__27": "MutatedUniqueUniqueTwoHandMace16LocalAttackSpeed", + "PhysicalAddedAsChaosIfUsedAmethystFlaskRecentlyUnique__1": "MutatedUniqueUniqueTwoHandMace16PhysicalAddedAsChaos" + }, + "The Sacred Chalice": { + "LocalCriticalStrikeChanceUnique__28": "MutatedUniqueUniqueTwoHandMace15LocalAttackSpeed", + "PhysicalAddedAsColdIfUsedSapphireFlaskRecentlyUnique__1": "MutatedUniqueUniqueTwoHandMace15PhysicalAddedAsCold", + "PhysicalAddedAsFireIfUsedRubyFlaskRecentlyUnique__1": "MutatedUniqueUniqueTwoHandMace15PhysicalAddedAsFire", + "PhysicalAddedAsLightningIfUsedTopazFlaskRecentlyUnique__1": "MutatedUniqueUniqueTwoHandMace15PhysicalAddedAsLightning" + }, + "Saresh's Darkness": { + "PoisonDurationUnique__3": "MutatedUniqueUniqueBelt52AvoidPoison", + "ChaosResistUnique__37": "MutatedUniqueUniqueBelt52ChaosDamage" + }, + "Solerai's Radiance": { + "FireResistUnique__38": "MutatedUniqueUniqueBelt55FireDamage", + "IgniteDurationUnique__4": "MutatedUniqueUniqueBelt55IgniteDurationOnYou" + }, + "The Bane of Hope": { + "PoisonDurationUnique__3": "MutatedUniqueUniqueBow27AttackSpeed", + "ChaosResistUnique__37": "MutatedUniqueUniqueBow27ImpalesChanceToLastAdditionalHit" + }, + "The Flame of Hope": { + "FireDamagePercentUnique__15": "MutatedUniqueUniqueBow26FireDamageOverTimeMultiplier", + "LocalIncreasedAttackSpeedUnique__45": "MutatedUniqueUniqueBow26LocalCriticalStrikeChance" + } +} \ No newline at end of file diff --git a/src/Data/Uniques/gloves.lua b/src/Data/Uniques/gloves.lua index 613d47966ac..0e98f59fcba 100644 --- a/src/Data/Uniques/gloves.lua +++ b/src/Data/Uniques/gloves.lua @@ -1071,7 +1071,9 @@ Requires Level 31, 25 Dex, 25 Int {variant:2}+(25-45)% to Global Critical Strike Multiplier {variant:3}+(20-30)% to Global Critical Strike Multiplier (100-130)% increased Evasion and Energy Shield -0.2% of Physical Attack Damage Leeched as Mana +{variant:1}0.2% of Physical Attack Damage Leeched as Mana +{variant:2}0.2% of Physical Attack Damage Leeched as Mana +{variant:3}0.2% of Attack Damage Leeched as Mana Creates a Smoke Cloud on Rampage Gain Unholy Might for 3 seconds on Rampage Rampage diff --git a/src/Modules/Data.lua b/src/Modules/Data.lua index 2a1e5cad783..7ac777822dd 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -3,7 +3,7 @@ -- Module: Data -- Contains static data used by other modules. -- - +local dkjson = require("dkjson") LoadModule("Data/Global") local m_min = math.min @@ -1235,4 +1235,11 @@ data.itemMods.WatchersEye = unsortedMods LoadModule("Data/Uniques/Special/Generated") LoadModule("Data/Uniques/Special/New") +data.foulbornMap = dkjson.decode(io.open("Data/ModFoulbornMap.jsonc", "r"):read("*a")) +for _, mappings in ipairs(data.foulbornMap) do + for orig, foul in pairs(mappings) do + assert(data.itemMods.Item[orig], "original " .. orig) + assert(data.itemMods.Foulborn[foul], "foulborn " .. foul) + end +end data.flavourText = LoadModule("Data/FlavourText") diff --git a/src/Modules/Main.lua b/src/Modules/Main.lua index 94a9c5e88ae..6867c8b98c1 100644 --- a/src/Modules/Main.lua +++ b/src/Modules/Main.lua @@ -154,7 +154,7 @@ function main:Init() local function loadItemDBs() for type, typeList in pairsYield(data.uniques) do for _, raw in pairs(typeList) do - newItem = new("Item", raw, "UNIQUE", true) + local newItem = new("Item", raw, "UNIQUE", true) if newItem.base then self.uniqueDB.list[newItem.name] = newItem elseif launch.devMode then @@ -167,7 +167,7 @@ function main:Init() ConPrintf("Uniques loaded") for _, raw in pairsYield(data.rares) do - newItem = new("Item", raw, "RARE", true) + local newItem = new("Item", raw, "RARE", true) if newItem.base then if newItem.crafted then if newItem.base.implicit and #newItem.implicitModLines == 0 then From 12ce6c84917fdd6282ddc2bdb3952c46ac54da73 Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:06:05 +0300 Subject: [PATCH 2/3] Defeat passage for once and for all --- src/Classes/Item.lua | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index 7780f3be092..e12aae1d052 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -1134,14 +1134,30 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) end local matchingLines = {} local lineCount = 0 + local function findMatching(statLine) + lineCount = lineCount + 1 + for _, modLine in ipairs(self.explicitModLines) do + if normalise(modLine.line:gsub("\n", " ")) == statLine and + self:CheckModLineVariant(modLine) then + table.insert(matchingLines, modLine) + end + end + end for _, statDesc in pairs(originalMod.tradeHashes) do local statLine = normalise(table.concat(statDesc, " ")) if statLine ~= "" then - lineCount = lineCount + 1 - for _, modLine in ipairs(self.explicitModLines) do - if normalise(modLine.line:gsub("\n", " ")) == statLine and - self:CheckModLineVariant(modLine) then - table.insert(matchingLines, modLine) + findMatching(statLine) + end + end + -- if there was no match there's probably an issue where pob + -- splits a stat description into multiple mod lines, so + -- search for each separately + if #matchingLines == 0 then + lineCount = 0 + for _, statDesc in pairs(originalMod.tradeHashes) do + for _, descPart in ipairs(statDesc) do + if descPart ~= "" then + findMatching(normalise(descPart)) end end end From ed76aba4676a80c337f4ca50ffb886d8fc898c65 Mon Sep 17 00:00:00 2001 From: vaisest <4550061+vaisest@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:33:34 +0300 Subject: [PATCH 3/3] Cleanup --- src/Classes/Item.lua | 10 +--------- src/Classes/ItemsTab.lua | 2 +- src/Modules/Data.lua | 17 ++++++++++------- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index e12aae1d052..74facf8c2e6 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -92,7 +92,6 @@ local lineFlags = { ["synthesis"] = true, ["mutated"] = true, ["unscalable"] = true, - ["mutateActive"] = true } -- Special function to store unique instances of modifier on specific item slots @@ -1127,7 +1126,7 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) if self.mutatedLines then for origModId, foulModId in pairs(self.mutatedLines) do local function checkMod(modId, newModId, mutated) - local originalMod = mutated and data.itemMods.Foulborn[modId] or data.itemMods.Item[modId] + local originalMod = mutated and data.itemMods.Foulborn[modId] or data.itemMods.ItemExclusive[modId] if not originalMod then ConPrintf("mod not found while testing mutated mods %s, %s", modId, mutated) return @@ -1417,13 +1416,6 @@ function ItemClass:BuildRaw() if modLine.mutated then line = "{mutated}" .. line end - -- pob markers for application and removal of mutated mod - if modLine.mutateActive then - line = "{mutateActive}" .. line - end - if modLine.originalLine then - line = string.format("{originalLine:%s}", modLine.originalLine) .. line - end if modLine.fractured then line = "{fractured}" .. line end diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 09d67553577..e4e36724e9f 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -1095,7 +1095,7 @@ holding Shift will put it in the second.]]) self.controls["displayItemStackedRangeLine" .. i] = new("LabelControl", { "LEFT", slider, "RIGHT", true }, { 4, -2, 350, labelFontSize }, function() local modLine = self.displayItem.rangeLineList[i] if self.displayItem and modLine then - local colour = modLine.mutateActive and colorCodes.MUTATED or "^7" + local colour = modLine.mutated and colorCodes.MUTATED or "^7" local text = table.concat(main:WrapString(modLine.line, labelFontSize, 370), "\n") local _, lineCount = text:gsub("\n", "") self.controls["displayItemStackedRangeLine" .. i].lineCount = lineCount + 1 diff --git a/src/Modules/Data.lua b/src/Modules/Data.lua index 7ac777822dd..968b383d056 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -1235,11 +1235,14 @@ data.itemMods.WatchersEye = unsortedMods LoadModule("Data/Uniques/Special/Generated") LoadModule("Data/Uniques/Special/New") -data.foulbornMap = dkjson.decode(io.open("Data/ModFoulbornMap.jsonc", "r"):read("*a")) -for _, mappings in ipairs(data.foulbornMap) do - for orig, foul in pairs(mappings) do - assert(data.itemMods.Item[orig], "original " .. orig) - assert(data.itemMods.Foulborn[foul], "foulborn " .. foul) - end -end +local mapFile = io.open("Data/ModFoulbornMap.jsonc", "r") +data.foulbornMap = dkjson.decode(mapFile:read("*a")) +mapFile:close() +-- debug assert: check if each mod exists in exports +-- for _, mappings in pairs(data.foulbornMap) do +-- for orig, foul in pairs(mappings) do +-- assert(data.itemMods.Item[orig], "original " .. orig) +-- assert(data.itemMods.Foulborn[foul], "foulborn " .. foul) +-- end +-- end data.flavourText = LoadModule("Data/FlavourText")