diff --git a/spec/System/TestItemParse_spec.lua b/spec/System/TestItemParse_spec.lua index 80923e0c55..ee09fc9318 100644 --- a/spec/System/TestItemParse_spec.lua +++ b/spec/System/TestItemParse_spec.lua @@ -449,3 +449,193 @@ describe("TestItemParse", function() end) end) + +describe("TestAdvancedItemParse #item", function() + local function raw(s, base) + base = base or "Arcane Raiment" + return "Rarity: Rare\nName\n"..base.."\n"..s + end + + it("parses to craft", function() + local item = new("Item", raw([[ + { Prefix Modifier "Azure" (Tier: 7) - Mana } + +31(25-34) to maximum Mana + ]], "Refined Bracers")) + assert.are.equals("IncreasedMana3", item.prefixes[1].modId) + assert.are.equals(0.667, item.prefixes[1].range) + assert.are.equals("mana", item.explicitModLines[1].modTags[1]) + end) + + it("parses correct range", function() + local item = new("Item", raw([[ + { Desecrated Prefix Modifier "Frigid" (Tier: 6) - Damage, Elemental, Cold, Attack } + Adds 8(7-8) to 13(12-14) Cold damage to Attacks + ]], "Refined Bracers")) + assert.are.equals("Adds 8 to 13 Cold damage to Attacks", item.explicitModLines[1].line) + end) + + -- GGG scales each mod line separately here, but PoB scales them both together, so this parsing is a bit wonky + it("parses multi-line mod", function() + local item = new("Item", raw([[ + { Prefix Modifier "Bishop's" (Tier: 3) — Life, Defences } + 27(27-32)% increased Energy Shield + +31(26-32) to maximum Life + ]], "Ancestral Tiara")) + assert.are.equals("LocalIncreasedEnergyShieldAndLife4", item.prefixes[1].modId) + assert.are.equals(0, item.prefixes[1].range) + assert.are.equals(0.833, item.explicitModLines[2].range) + end) + + it("resets linePrefix", function() + local item = new("Item", raw([[ + { Prefix Modifier "Warlock's" (Tier: 4) — Mana, Damage, Caster } + 32(30-37)% increased Spell Damage + +46(42-47) to maximum Mana + -------- + +15 to maximum life + ]], "Voltaic Staff")) + assert.are_not.equals("mana", item.explicitModLines[3].modTags[1]) + end) + + it("resets linePostfix", function() + local item = new("Item", raw([[ + { Corruption Enhancement — Mana } + 24(20-30)% increased Mana Regeneration Rate + -------- + +15 to maximum life + ]])) + assert.falsy(item.explicitModLines[1].enchant) + end) + + it("parses vaaled catalyst", function() + local item = new("Item", raw([[ + Quality (Attribute Modifiers): +19% (augmented) + { Unique Modifier — Attribute — 19% Increased } + +120(80-100) to all Attributes + (Attributes are Strength, Dexterity, and Intelligence) + ]], "Stellar Amulet")) + assert.are.equals(142, item.baseModList[1].value) + -- assert.falsy(item.explicitModLines[1].range) -- Not sure why this is returning 0.5 + assert.are.equals(12, item.catalyst) + assert.are.equals(19, item.catalystQuality) + end) + + it("parses vaaled catalyst within range", function() + local item = new("Item", raw([[ + Quality (Attribute Modifiers): +19% (augmented) + { Unique Modifier — Attribute — 19% Increased } + +95(80-100) to all Attributes + (Attributes are Strength, Dexterity, and Intelligence) + ]], "Stellar Amulet")) + assert.are.equals(113, item.baseModList[1].value) + assert.are.equals(0.75, item.explicitModLines[1].range) + assert.are.equals(12, item.catalyst) + assert.are.equals(19, item.catalystQuality) + end) + + it("doesn't scale unscalable", function() + local item = new("Item", raw([[ + Quality (Life and Mana Modifiers): +20% (augmented) + { Unique Modifier — Life, Defences, Energy Shield, Minion, Gem } + Socketed Golem Skills gain 20% of Maximum Life as Extra Maximum Energy Shield — Unscalable Value + ]])) + assert.are.equals(20, item.baseModList[1].value.mod.value) + end) + + it("correctly matches conqueror mod", function() + local item = new("Item", raw([[ + { Suffix Modifier "of the Conquest" (Tier: 1) — Elemental, Cold } + 10(8-10)% chance to Avoid Cold Damage from Hits + (No chance to avoid damage can be higher than 75%) + Warlord Item + ]])) + assert.are.equals(10, item.baseModList[1].value) + -- assert.are.equals(1, item.explicitModLines[1].range) -- Not sure why this is returning 0.5 + end) + + it("parses enchant correctly #enchant", function() + local item = new("Item", raw([[ + { Corrupted Enhancement } + +8(6-10)% to Fire Resistance + ]])) + assert.are.equals(8, item.enchantModLines[1].modList[1].value) + end) + + it("parses enchant with tags correctly #enchant", function() + local item = new("Item", raw([[ + { Corrupted Enhancement - Energy Shield } + +8(6-10)% to Fire Resistance + ]])) + assert.are.equals(8, item.enchantModLines[1].modList[1].value) + assert.are.equals("energyshield", item.enchantModLines[1].modTags[1]) + end) + + it("parses junk", function() + local godTestItem = new("Item", [[ + Item Class: Sceptres + Rarity: Unique + Nebulis + Synthesised Void Sceptre + -------- + Sceptre + Physical Damage: 50-76 + Critical Strike Chance: 7.30% + Attacks per Second: 1.25 + Weapon Range: 1.1 metres + Memory Strands: 58 + -------- + Requirements: + Level: 68 + Str: 104 + Int: 122 + -------- + Sockets: B R + -------- + Item Level: 87 + -------- + +30% to Fire Resistance (scourge) + 22% reduced Global Defences (scourge) + (Armour, Evasion Rating and Energy Shield are the standard Defences) (scourge) + -------- + 8% increased Explicit Cold Modifier magnitudes (enchant) + Has 1 White Socket (enchant) + -------- + { Searing Exarch Implicit Modifier (Lesser) } + Tempest Shield has 15(15-17)% increased Buff Effect + { Implicit Modifier — Damage, Critical — 106% Increased } + +15(15-17)% to Global Critical Strike Multiplier + -------- + { Prefix Modifier "Freezing" (Tier: 5) — Damage, Elemental, Cold, Caster — 8% Increased } + Adds 17(16-20) to 35(30-36) Cold Damage to Spells + { Prefix Modifier "Beetle's" (Tier: 6) — Defences, Armour } + 9(6-13)% increased Armour + 7(6-7)% increased Stun and Block Recovery + { Master Crafted Prefix Modifier "Upgraded" — Life, Defences, Armour } + 21(18-21)% increased Armour + +18(17-19) to maximum Life + { Unique Modifier } + 106(60-120)% increased Implicit Modifier magnitudes — Unscalable Value + (Implicit Modifiers are those that come from an item's type, rather than its random properties) + { Master Crafted Suffix Modifier "of Craft" (Rank: 3) — Elemental, Cold, Resistance } + +35(29-35)% to Cold Resistance + { Fractured Prefix Modifier "Thorny" (Tier: 2) — Damage, Physical } + Reflects 3(1-4) Physical Damage to Melee Attackers + { Prefix Modifier "Veiled" } + Veiled Prefix + Searing Exarch Item + -------- + { Allocated Crucible Passive Skill (Tier: 2) } + Adds 2 to 6 Physical Damage to Spells + -------- + Synthesised Item + -------- + Corrupted + -------- + Scourged + -------- + Hinekora's Lock + -------- + Note: ~b/o 2 chaos + ]]) + end) +end) \ No newline at end of file diff --git a/spec/System/TestItemParse_spec.lua.rej b/spec/System/TestItemParse_spec.lua.rej new file mode 100644 index 0000000000..29cbba05d1 --- /dev/null +++ b/spec/System/TestItemParse_spec.lua.rej @@ -0,0 +1,42 @@ +diff a/spec/System/TestItemParse_spec.lua b/spec/System/TestItemParse_spec.lua (rejected hunks) +@@ -525,6 +525,16 @@ describe("TestAdvancedItemParse #item", function() + assert.are_not.equals("mana", item.explicitModLines[3].modTags[1]) + end) + ++ it("resets linePostfix", function() ++ local item = new("Item", raw([[ ++ { Corruption Enhancement — Mana } ++ 24(20-30)% increased Mana Regeneration Rate ++ -------- ++ +15 to maximum life ++ ]])) ++ assert.falsy(item.explicitModLines[1].enchant) ++ end) ++ + it("parses vaaled catalyst", function() + local item = new("Item", raw([[ + Quality (Attribute Modifiers): +19% (augmented) +@@ -571,6 +581,23 @@ describe("TestAdvancedItemParse #item", function() + -- assert.are.equals(1, item.explicitModLines[1].range) -- Not sure why this is returning 0.5 + end) + ++ it("parses enchant correctly #enchant", function() ++ local item = new("Item", raw([[ ++ { Corrupted Enhancement } ++ +8(6-10)% to Fire Resistance ++ ]])) ++ assert.are.equals(8, item.enchantModLines[1].modList[1].value) ++ end) ++ ++ it("parses enchant with tags correctly #enchant", function() ++ local item = new("Item", raw([[ ++ { Corrupted Enhancement - Energy Shield } ++ +8(6-10)% to Fire Resistance ++ ]])) ++ assert.are.equals(8, item.enchantModLines[1].modList[1].value) ++ assert.are.equals("energyshield", item.enchantModLines[1].modTags[1]) ++ end) ++ + it("parses junk", function() + local godTestItem = new("Item", [[ + Item Class: Sceptres diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index dbd9438ea4..81718154d2 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -12,10 +12,11 @@ local m_floor = math.floor local dmgTypeList = {"Physical", "Lightning", "Cold", "Fire", "Chaos"} local catalystList = {"Flesh", "Neural", "Carapace", "Uul-Netol's", "Xoph's", "Tul's", "Esh's", "Chayula's", "Reaver", "Sibilant", "Skittering", "Adaptive"} +local catalystDescriptorList = {"Life", "Mana", "Defence", "Physical", "Fire", "Cold", "Lightning", "Chaos", "Attack", "Caster", "Speed", "Attribute"} local catalystTags = { { "life" }, { "mana" }, - { "defences" }, + { "defences", "armour", "evasion", "energyshield" }, { "physical" }, { "fire" }, { "cold" }, @@ -29,7 +30,11 @@ local catalystTags = { local minimumReqLevel = { } -local function getCatalystScalar(catalystId, tags, quality) +local function getCatalystScalar(catalystId, mod, quality) + if mod.unscalable then + return 1 + end + local tags = mod.modTags if not catalystId or type(catalystId) ~= "number" or not catalystTags[catalystId] or not tags or type(tags) ~= "table" or #tags == 0 then return 1 end @@ -59,7 +64,7 @@ local ItemClass = newClass("Item", function(self, raw, rarity, highQuality) end) local lineFlags = { - ["custom"] = true, ["fractured"] = true, ["desecrated"] = true, ["mutated"] = true, ["enchant"] = true, ["implicit"] = true, ["rune"] = true, + ["custom"] = true, ["fractured"] = true, ["desecrated"] = true, ["mutated"] = true, ["enchant"] = true, ["implicit"] = true, ["rune"] = true, ["unscalable"] = true } local function baseHasImplicitLine(base, line) @@ -329,6 +334,9 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) end end if self.rawLines[l] then + if self.rawLines[l] == "--------" then + l = l + 1 + end -- Determine if "Unidentified" item local unidentified = false if self.rarity == "UNIQUE" then @@ -383,6 +391,8 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) local deferJewelRadiusIndexAssignment local gameModeStage = "FINDIMPLICIT" local foundExplicit, foundImplicit + local linePrefix = "" + local linePostfix = "" while self.rawLines[l] do local line = self.rawLines[l] @@ -391,6 +401,8 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) elseif charmBuffLines and charmBuffLines[line] then charmBuffLines[line] = nil elseif line == "--------" then + linePrefix = "" + linePostfix = "" self.checkSection = true elseif line == "Sanctified" then self.sanctified = true @@ -406,7 +418,68 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) self.desecrated = true elseif line == "Requirements:" then -- nothing to do + elseif line:match("^%(%a+") then + -- Reminder text, nothing to parse + while self.rawLines[l] and not self.rawLines[l]:match("%)$") do + l = l + 1 + end + elseif line:match("^{ ") then + -- We're parsing advanced copy/paste format + linePrefix = "" + linePostfix = "" + self.crafted = true + local fullModName, modTags, increasedAmt = line:match("^{ (.-) %- (.-) %- (%d*).*}$") + if not fullModName then + fullModName, modTags = line:match("^{ (.-) %- (.-) }$") + end + if not fullModName then + fullModName = line:match("^{ (.-) }$") + end + local modName = fullModName:match("^.*Modifier \"(.*)\"") + if modName and modName ~= "" and self.affixes then + self.pendingAffixList = { } + local backupAffixList = { } + for modId, modData in pairs(self.affixes) do + if modData.affix == modName then + if self:GetModSpawnWeight(modData) > 0 then + if modData.type == "Prefix" then + t_insert(self.pendingAffixList, { modId = modId, table = self.prefixes }) + elseif modData.type == "Suffix" then + t_insert(self.pendingAffixList, { modId = modId, table = self.suffixes }) + end + else + -- Conqueror mods can't natively spawn on items, so we'll use those if we don't find a match otherwise + if modData.type == "Prefix" then + t_insert(backupAffixList, { modId = modId, table = self.prefixes }) + elseif modData.type == "Suffix" then + t_insert(backupAffixList, { modId = modId, table = self.suffixes }) + end + end + end + end + if #self.pendingAffixList == 0 and #backupAffixList > 0 then + self.pendingAffixList = backupAffixList + end + if #self.pendingAffixList == 0 and #backupAffixList == 0 then + -- Could be a veiled, temple, or other custom mod, so just keep it around + linePrefix = "{custom}" + end + elseif fullModName:match("(.*)Enhancement.*") then + linePostfix = " (enchant)" + end + local possibleLineFlags = fullModName:match("(.*)Modifier.*") + if possibleLineFlags then + for flag in possibleLineFlags:gmatch("%a+") do + if lineFlags[flag:lower()] then + linePrefix = linePrefix .. "{" .. flag:lower() .. "}" + end + end + end + if modTags and modTags ~= "" then + linePrefix = linePrefix .. "{tags:" .. modTags:lower():gsub("%s+", "") .. "}" + end else + line = linePrefix .. line .. linePostfix local lineIsBaseImplicit = mode == "GAME" and baseHasImplicitLine(self.base, line) if self.checkSection then if gameModeStage == "IMPLICIT" then @@ -426,7 +499,7 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) end self.checkSection = false end - local specName, specVal = line:match("^([%a ]+:?): (.+)$") + local specName, specVal = line:match("^([%a %(%)]+:?): (.+)$") if specName then if specName == "Class:" then specName = "Requires Class" @@ -445,6 +518,13 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) self.charmLimit = specToNumber(specVal) elseif specName == "Spirit" then self.spiritValue = specToNumber(specVal) + elseif specName:match("Quality %(%a+ Modifiers%)") then + self.catalystQuality = specToNumber(specVal:match("(%d+)%%")) + for i=1, #catalystDescriptorList do + if specName:match("Quality %(([%a%s]+) Modifiers%)") == catalystDescriptorList[i] then + self.catalyst = i + end + end elseif specName == "Quality" then self.quality = specToNumber(specVal) elseif specName == "Sockets" then @@ -761,7 +841,71 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) foundImplicit = true gameModeStage = "IMPLICIT" end - local catalystScalar = getCatalystScalar(self.catalyst, modLine.modTags, self.catalystQuality) + local catalystScalar = 1 + if line:match(" %- Unscalable Value$") then + line = line:gsub(" %- Unscalable Value$", "") + modLine.unscalable = true + else + catalystScalar = getCatalystScalar(self.catalyst, modLine, self.catalystQuality) + end + if self.pendingAffixList and #self.pendingAffixList > 0 then + if #self.pendingAffixList > 1 then + -- Probably a conqueror mod since the mod name is the same for all of them + -- Try to match the line against one of the mods there + local valueStrippedLine = line:gsub("%-?%d+%.?%d*%(", "("):gsub("%-?%d+%.?%d*", "#") + for _, pendingAffix in ipairs(self.pendingAffixList) do + local modData = self.affixes[pendingAffix.modId] + for _, modDataLine in ipairs(modData) do + if valueStrippedLine == modDataLine:gsub("%-?%d+%.?%d*", "#") then + self.pendingAffixList = { pendingAffix } + break + end + end + end + end + -- Use rolling Delta/Range in case one range is 1-3 and another is 1-100 so we get the finest precision possible + local bestPrecisionDelta = -1 + local bestPrecisionRange = -1 + for value, range in line:gmatch("(%-?%d+%.?%d*)%((%-?%d+%.?%d*%-%-?%d+%.?%d*)%)") do + -- Find advanced copy paste format: 45(40-50) + local min, max = range:match("(%-?%d+%.?%d*)%-(%-?%d+%.?%d*)") + local delta = tonumber(max) - min + line = line:gsub(value .. "%(" .. range:gsub("%-", "%%-") .. "%)", value) + if delta > bestPrecisionDelta then + bestPrecisionRange = round((value - min) / delta, 3) + bestPrecisionDelta = delta + end + end + t_insert(self.pendingAffixList[1].table, { + modId = self.pendingAffixList[1].modId, + range = tonumber(bestPrecisionRange), + }) + self.pendingAffixList = {} + else + -- Use rolling Delta/Range in case one range is 1-3 and another is 1-100 so we get the finest precision possible + local bestPrecisionDelta = -1 + local bestPrecisionRange = -1 + + -- Replace non-number ranges as unsupported + line = line:gsub("(%a+)%([%a%s]+%-[%a%s]+%)", "%1") + + for value, range in line:gmatch("(%-?%d+%.?%d*)%((%-?%d+%.?%d*%-%-?%d+%.?%d*)%)") do + local min, max = range:match("(%-?%d+%.?%d*)%-(%-?%d+%.?%d*)") + local delta = tonumber(max) - min + if delta > bestPrecisionDelta then + bestPrecisionRange = round((value - min) / delta, 3) + bestPrecisionDelta = delta + end + if bestPrecisionRange > 1 or bestPrecisionRange < 0 then + line = line:gsub(value .. "%(" .. range:gsub("%-", "%%-") .. "%)", value) + else + line = line:gsub(value .. "%(" .. range:gsub("%-", "%%-") .. "%)", (tonumber(value) < 0 and "+" or "") .. "(" .. range .. ")") + end + end + if bestPrecisionRange <= 1 and bestPrecisionRange >= 0 then + modLine.range = tonumber(bestPrecisionRange) + end + 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 @@ -1210,6 +1354,9 @@ function ItemClass:BuildRaw() if modLine.mutated then line = "{mutated}" .. line end + if modLine.unscalable then + line = "{unscalable}" .. line + end if modLine.variantList then local varSpec for varId in pairs(modLine.variantList) do @@ -1401,7 +1548,7 @@ function ItemClass:Craft() self.nameSuffix = self.nameSuffix .. " " .. mod.affix end self.requirements.level = m_max(self.requirements.level or 0, m_floor(mod.level * 0.8)) - local rangeScalar = getCatalystScalar(self.catalyst, mod.modTags, self.catalystQuality) + local rangeScalar = getCatalystScalar(self.catalyst, mod, self.catalystQuality) for i, line in ipairs(mod) do line = itemLib.applyRange(line, affix.range or 0.5, rangeScalar) local order = mod.statOrder[i] @@ -1784,7 +1931,7 @@ function ItemClass:BuildModList() -- 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.modTags, self.catalystQuality) + 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 @@ -1923,4 +2070,4 @@ function ItemClass:BuildModList() self.modList = self:BuildModListForSlotNum(baseList) end self.socketedSoulCoreEffectModifier = calcLocal(baseList, "SocketedSoulCoreEffect", "INC", 0) / 100 -end +end \ No newline at end of file diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 10a6f179e9..2648154597 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -32,7 +32,7 @@ local baseSlots = { "Weapon 1", "Weapon 2", "Helmet", "Body Armour", "Gloves", " local catalystQualityFormat = { "^x7F7F7FQuality (Life Modifiers): "..colorCodes.MAGIC.."+%d%% (augmented)", "^x7F7F7FQuality (Mana Modifiers): "..colorCodes.MAGIC.."+%d%% (augmented)", - "^x7F7F7FQuality (Defense Modifiers): "..colorCodes.MAGIC.."+%d%% (augmented)", + "^x7F7F7FQuality (Defence Modifiers): "..colorCodes.MAGIC.."+%d%% (augmented)", "^x7F7F7FQuality (Physical): "..colorCodes.MAGIC.."+%d%% (augmented)", "^x7F7F7FQuality (Fire Modifiers): "..colorCodes.MAGIC.."+%d%% (augmented)", "^x7F7F7FQuality (Cold Modifiers): "..colorCodes.MAGIC.."+%d%% (augmented)", @@ -1233,11 +1233,6 @@ function ItemsTabClass:Draw(viewPort, inputEvents) if event.key == "v" and IsKeyDown("CTRL") then local newItem = Paste() if newItem then - if newItem:find("{ ", 0, true) then - main:OpenConfirmPopup("Warning", "\"Advanced Item Descriptions\" (Ctrl+Alt+c) are unsupported.\n\nAbort paste?", "OK", function() - self:SetDisplayItem() - end) - end self:CreateDisplayItemFromRaw(newItem, true) end if self.displayItem and IsKeyDown("SHIFT") then @@ -1941,7 +1936,13 @@ function ItemsTabClass:UpdateAffixControl(control, item, type, outputTable, outp end if control.list[control.selIndex].haveRange then control.slider.divCount = #control.list[control.selIndex].modList - control.slider.val = (isValueInArray(control.list[control.selIndex].modList, selAffix) - 1 + (item[outputTable][outputIndex].range or 0.5)) / control.slider.divCount + local index = isValueInArray(control.list[control.selIndex].modList, selAffix) + local range = item[outputTable][outputIndex].range or 0.5 + -- Avoid exact integer boundary that slider:GetDivVal's ceil would assign to the previous segment + if range == 0 and index > 1 then + range = 1e-4 + end + control.slider.val = (index - 1 + range) / control.slider.divCount if control.slider.divCount == 1 then control.slider.divCount = nil end