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/4] 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/4] 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/4] 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") From 05f9dafe2021f44d557c2f3c6b07c67fb6355ed4 Mon Sep 17 00:00:00 2001 From: LocalIdentity Date: Fri, 24 Jul 2026 15:32:44 +1000 Subject: [PATCH 4/4] Fix various issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kitava’s Thirst now uses PoB’s real modifier lines instead of trade-search hashes and remains reversible. Generated modifier groups prevent Green Nightmare’s unsupported neighboring line from consuming the replacement. Alternate Foulborn modifier searches are case-insensitive and preserve spaces between multiline descriptions. Removed redundant manual parsing, the unused originalLine field, and the commented debug block. Reset mutatedLines during reparsing and avoided creating normalization helpers for unrelated items. --- spec/System/TestFoulborn_spec.lua | 54 +++++++++++++++ spec/System/TestItemDBControl_spec.lua | 24 +++++++ src/Classes/Item.lua | 96 ++++++++++++-------------- src/Classes/ItemDBControl.lua | 4 +- src/Modules/Data.lua | 7 -- 5 files changed, 123 insertions(+), 62 deletions(-) diff --git a/spec/System/TestFoulborn_spec.lua b/spec/System/TestFoulborn_spec.lua index 00c03983e48..9eb68c9e397 100644 --- a/spec/System/TestFoulborn_spec.lua +++ b/spec/System/TestFoulborn_spec.lua @@ -229,4 +229,58 @@ describe("TestFoulborn", function() assert.is_nil(findByLine(item, "+700 Strength Requirement")) assert.is_false(hasMod(item, "TripleDamageChance")) end) + + it("keeps Kitava's Thirst transformations reversible", function() + local item = new("Item", [[ + Rarity: Unique + Kitava's Thirst + Zealot Helmet + Variant: Pre 3.11.0 + Variant: Current + Selected Variant: 2 + 50% chance to Trigger Socketed Spells when you Spend at least 100 Mana on an + Upfront Cost to Use or Trigger a Skill, with a 0.1 second Cooldown + ]]) + local baseId = "ChanceToCastOnManaSpentUnique__1" + local foulbornId = "MutatedUniqueHelmetStrInt6ChanceToCastOnManaSpent" + local base = linesAllByModId(item, baseId)[1] + + toggle(item, base) + + local transformed = linesAllByModId(item, foulbornId) + assert.are.equals(1, #transformed) + assert.are.equals(baseId, transformed[1].newModId) + + toggle(item, transformed[1]) + assert.are.equals(1, #linesAllByModId(item, baseId)) + end) + + it("keeps Foulborn lines separate from unsupported modifiers", function() + local item = new("Item", [[ + Rarity: Unique + The Green Nightmare + Viridian Jewel + Variant: Pre 3.16.0 + Variant: Pre 3.21.0 + Variant: Current + Selected Variant: 1 + Radius: Large + {variant:1,2}Passives granting Cold Resistance or all Elemental Resistances in Radius + {variant:1,2}also grant an equal chance to gain a Frenzy Charge on Kill + {variant:1}Passives granting Cold Resistance or all Elemental Resistances in Radius + {variant:1}also grant Chance to Suppress Spell Damage at 35% of its value + ]]) + local baseId = "ColdResistConvertedToDodgeChanceScaledJewelUnique__1" + local foulbornId = "MutatedUniqueJewel88UniqueJewelColdResistAlsoGrantsConvertColdToChaos" + local base = linesAllByModId(item, baseId)[1] + + toggle(item, base) + + local transformed = linesAllByModId(item, foulbornId) + assert.are.equals(1, #transformed) + assert.are.equals(baseId, transformed[1].newModId) + + toggle(item, transformed[1]) + assert.are.equals(1, #linesAllByModId(item, baseId)) + end) end) diff --git a/spec/System/TestItemDBControl_spec.lua b/spec/System/TestItemDBControl_spec.lua index b6e4e702187..85dc8efa4b8 100644 --- a/spec/System/TestItemDBControl_spec.lua +++ b/spec/System/TestItemDBControl_spec.lua @@ -51,4 +51,28 @@ describe("ItemDBControl", function() assert.are.equal(-120, worseItem.measuredPower) assert.are.equal(-math.huge, invalidItem.measuredPower) end) + + it("searches Foulborn modifier text without case sensitivity", function() + local item = new("Item", [[ + Rarity: Unique + Kitava's Thirst + Zealot Helmet + Variant: Pre 3.11.0 + Variant: Current + Selected Variant: 2 + 50% chance to Trigger Socketed Spells when you Spend at least 100 Mana on an + Upfront Cost to Use or Trigger a Skill, with a 0.1 second Cooldown + ]]) + local control = new("ItemDBControl", nil, { 0, 0, 100, 100 }, { + build = { + characterLevel = 100, + }, + }, { + list = { item }, + }, "UNIQUE") + control.controls.search.buf = "life on an upfront cost" + control.controls.searchMode.selIndex = 3 + + assert.is_true(control:DoesItemMatchFilters(item)) + end) end) diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index a633a27f8d4..5b6635d0a7f 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -400,9 +400,8 @@ 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 + self.mutatedLines = nil local importedLevelReq local flaskBuffLines local tinctureBuffLines @@ -497,7 +496,7 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) for flag in possibleLineFlags:gmatch("%a+") do local flagLower = flag:lower() if lineFlags[flagLower] then - linePrefix = linePrefix .. "{" .. flag:lower() .. "}" + linePrefix = linePrefix .. "{" .. flagLower .. "}" end end end @@ -723,8 +722,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 == "modGroup" then + modLine.modGroup = val elseif k == "range" then self.advancedCopy = true modLine.range = tonumber(val) @@ -813,7 +812,6 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) -- 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 @@ -933,7 +931,11 @@ 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 + local nextModGroup = self.rawLines[l + 1] + and self.rawLines[l + 1]:match("{modGroup:([^}]+)}") + -- Only combine generated lines that came from the same modifier. + if (not modList or extra) and self.rawLines[l + 1] + and modLine.modGroup == nextModGroup then -- Try to combine it with the next line local nextLine = self.rawLines[l+1]:gsub("%b{}", ""):gsub(" ?%(%l+%)","") local combLine = line.." "..nextLine @@ -1241,15 +1243,13 @@ 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 + 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) local originalMod = mutated and data.itemMods.Foulborn[modId] or data.itemMods.ItemExclusive[modId] @@ -1257,37 +1257,27 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) ConPrintf("mod not found while testing mutated mods %s, %s", modId, mutated) return 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 - 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)) + local function findMatchingLines(lines) + local matchingLines = {} + local matchedLines = {} + for _, line in ipairs(lines) do + local statLine = normalise(line) + for _, modLine in ipairs(self.explicitModLines) do + if not matchedLines[modLine] + and normalise(modLine.line:gsub("\n", " ")) == statLine + and self:CheckModLineVariant(modLine) then + matchedLines[modLine] = true + t_insert(matchingLines, modLine) + break end end end + return #lines == #matchingLines and matchingLines end - if lineCount == #matchingLines then + -- Some modifier descriptions are represented by separate lines in PoB. + local matchingLines = findMatchingLines({ table.concat(originalMod, " ") }) + or findMatchingLines(originalMod) + if matchingLines then for _, modLine in ipairs(matchingLines) do modLine.modId = modId modLine.newModId = newModId @@ -1355,17 +1345,13 @@ function ItemClass:MutateMod(modId, newModId, mutatedValue) 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 + table.insert(self.explicitModLines, insertIdx, { + line = table.concat(newMod, "\n"), + modTags = newMod.modTags, + variantList = variantList, + mutated = mutatedValue, + modGroup = newModId, + }) self:BuildAndParseRaw() end function ItemClass:NormaliseQuality() @@ -1542,6 +1528,10 @@ function ItemClass:BuildRaw() if modLine.mutated then line = "{mutated}" .. line end + if modLine.modGroup then + local modGroup = "{modGroup:" .. modLine.modGroup .. "}" + line = modGroup .. line:gsub("\n", "\n" .. modGroup) + end if modLine.fractured then line = "{fractured}" .. line end diff --git a/src/Classes/ItemDBControl.lua b/src/Classes/ItemDBControl.lua index 24d0db7491f..f8a56af8282 100644 --- a/src/Classes/ItemDBControl.lua +++ b/src/Classes/ItemDBControl.lua @@ -166,11 +166,11 @@ function ItemDBClass:DoesItemMatchFilters(item) end end for _, line in pairs(item.explicitModLines) do - -- concatenate alternate lines for mutated items + -- Include the other side of Foulborn transformations in modifier searches. 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) + searchLine = searchLine .. table.concat(newMod, " "):lower() end local err, match = PCall(string.matchOrPattern, searchLine, searchStr) if not err and match then diff --git a/src/Modules/Data.lua b/src/Modules/Data.lua index 6be8a50773d..3d5b8b4f878 100644 --- a/src/Modules/Data.lua +++ b/src/Modules/Data.lua @@ -1241,11 +1241,4 @@ LoadModule("Data/Uniques/Special/New") 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")