diff --git a/spec/System/TestItemParse_spec.lua b/spec/System/TestItemParse_spec.lua index 4890207cea..089198bcb4 100644 --- a/spec/System/TestItemParse_spec.lua +++ b/spec/System/TestItemParse_spec.lua @@ -392,6 +392,13 @@ describe("TestItemParse", function() assert.truthy(item.explicitModLines[1].synthesis) end) + it("unscalable", function() + local item = new("Item", raw("{unscalable}+8 to Strength")) + assert.truthy(item.explicitModLines[1].unscalable) + item = new("Item", raw("+8 to Strength - Unscalable Value")) + assert.truthy(item.explicitModLines[1].unscalable) + end) + it("multiple bases", function() local item = new("Item", [[ Ashcaller @@ -465,3 +472,160 @@ describe("TestItemParse", function() assert.are.equal("+1500 to Armour", item.buffModLines[1].line) end) end) + +describe("TestAdvancedItemParse #item", function() + local function raw(s, base) + base = base or "Plate Vest" + return "Rarity: Rare\nName\n"..base.."\n"..s + end + + it("parses to craft", function() + local item = new("Item", raw([[ + { Prefix Modifier "Fecund" (Tier: 1) — Life } + +142(130-144) to maximum Life + ]], "Cord Belt")) + assert.are.equals("IncreasedLife9", item.prefixes[1].modId) + assert.are.equals(0.857, item.prefixes[1].range) + assert.are.equals("life", item.explicitModLines[1].modTags[1]) + item = new("Item", raw([[ + { Master Crafted Suffix Modifier "of Craft" (Rank: 3) — Elemental, Cold, Resistance } + +35(29-35)% to Cold Resistance + ]], "Cord Belt")) + assert.truthy(item.explicitModLines[1].crafted) + end) + + it("parses correct range", function() + local item = new("Item", raw([[ + { Prefix Modifier "Freezing" (Tier: 5) — Damage, Elemental, Cold, Caster — 8% Increased } + Adds 17(16-20) to 35(30-36) Cold Damage to Spells + ]], "Void Sceptre")) + assert.are.equals("Adds 17 to 35 Cold Damage to Spells", 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 "Warlock's" (Tier: 4) — Mana, Damage, Caster } + 32(30-37)% increased Spell Damage + +46(42-47) to maximum Mana + ]], "Royal Staff")) + assert.are.equals("SpellDamageAndManaOnTwoHandWeapon4", item.prefixes[1].modId) + assert.are.equals(0.286, item.prefixes[1].range) + assert.are.equals(0.8, 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 + ]], "Royal Staff")) + assert.are_not.equals("mana", item.explicitModLines[3].modTags[1]) + 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) + ]], "Onyx 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(6, 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) + ]], "Onyx Amulet")) + assert.are.equals(113, item.baseModList[1].value) + assert.are.equals(0.75, item.explicitModLines[1].range) + assert.are.equals(6, 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("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/src/Classes/Item.lua b/src/Classes/Item.lua index d63d7374f9..e4d7818c33 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -12,6 +12,7 @@ local m_floor = math.floor local dmgTypeList = {"Physical", "Lightning", "Cold", "Fire", "Chaos"} local catalystList = {"Abrasive", "Accelerating", "Dextral", "Fertile", "Imbued", "Intrinsic", "Noxious", "Prismatic", "Sinistral", "Tempering", "Turbulent", "Unstable"} +local catalystDescriptorList = {"Attack", "Speed", "Suffix", "Life and Mana", "Caster", "Attribute", "Physical and Chaos", "Resistance", "Prefix", "Defense", "Elemental", "Critical"} local catalystTags = { { "attack" }, { "speed" }, @@ -28,6 +29,9 @@ local catalystTags = { } local function getCatalystScalar(catalystId, mod, quality) + if mod.unscalable then + return 1 + end local tags = mod.modTags local affixType = mod.type if not catalystId or type(catalystId) ~= "number" or not catalystTags[catalystId] or not tags or type(tags) ~= "table" or #tags == 0 then @@ -78,7 +82,7 @@ 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 + ["mutated"] = true, ["unscalable"] = true } -- Special function to store unique instances of modifier on specific item slots @@ -376,6 +380,7 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) local deferJewelRadiusIndexAssignment local gameModeStage = "FINDIMPLICIT" local foundExplicit, foundImplicit + local linePrefix = "" while self.rawLines[l] do local line = self.rawLines[l] @@ -387,6 +392,7 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) elseif tinctureBuffLines and tinctureBuffLines[line] then tinctureBuffLines[line] = nil elseif line == "--------" then + linePrefix = "" self.checkSection = true elseif line == "Split" then self.split = true @@ -405,7 +411,44 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) self[influenceItemMap[line]] = true elseif line == "Requirements:" then -- nothing to do + elseif line:match("^%(") then + -- Reminder text, nothing to parse + elseif line:match("^{ ") then + -- We're parsing advanced copy/paste format + linePrefix = "" + 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 ~= "" then + for modId, modData in pairs(self.affixes) do + if modData.affix == modName and self:CanHaveMod(modData) then + if modData.type == "Prefix" then + self.pendingAffix = { modId = modId, table = self.prefixes } + elseif modData.type == "Suffix" then + self.pendingAffix = { modId = modId, table = self.suffixes } + end + end + end + 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 if self.checkSection then if gameModeStage == "IMPLICIT" then if foundImplicit then @@ -424,7 +467,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" @@ -439,6 +482,13 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) self.itemLevel = specToNumber(specVal) elseif specName == "Requires Class" then self.classRestriction = 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 @@ -739,7 +789,51 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) foundImplicit = true gameModeStage = "IMPLICIT" end - local catalystScalar = getCatalystScalar(self.catalyst, modLine, 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.pendingAffix then + local bestPrecisionDelta = 0 + local bestPrecisionRange = 0 + for value, range in line:gmatch("(%d+)%((%d+%-%d+)%)") do + -- Find advanced copy paste format: 45(40-50) + local min, max = range:match("(%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.pendingAffix.table, { + modId = self.pendingAffix.modId, + range = tonumber(bestPrecisionRange), + }) + self.pendingAffix = nil + else + local bestPrecisionDelta = 0 + local bestPrecisionRange = 0 + for value, range in line:gmatch("(%d+)%((%d+%-%d+)%)") do + local min, max = range:match("(%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("%-", "%%-") .. "%)", "(" .. range .. ")") + end + end + if bestPrecisionRange < 1 and bestPrecisionRange > 0 then + modLine.range = tonumber(bestPrecisionRange) + end + end local rangedLine = itemLib.applyRange(line, 1, catalystScalar) local modList, extra = modLib.parseMod(rangedLine) if (not modList or extra) and self.rawLines[l+1] then @@ -1141,6 +1235,9 @@ function ItemClass:BuildRaw() if modLine.synthesis then line = "{synthesis}" .. line end + if modLine.unscalable then + line = "{unscalable}" .. line + end if modLine.variantList then local varSpec for varId in pairs(modLine.variantList) do @@ -1837,3 +1934,25 @@ function ItemClass:BuildModList() self.modList = self:BuildModListForSlotNum(baseList) end end + +function ItemClass:CanHaveMod(mod) + local keyMap, includeTags = { }, { } + for index, key in ipairs(mod.weightKey) do + keyMap[key] = index + end + -- check for uniques with off-tag mods + if data.casterTagCrucibleUniques[self.title] then + includeTags["caster_unique_weapon"] = true + end + if data.minionTagCrucibleUniques[self.title] then + includeTags["minion_unique_weapon"] = true + end + if self.canHaveOnlySupportSkillsCrucibleTree then + return keyMap["crucible_unique_staff"] and mod.weightVal[keyMap["crucible_unique_staff"]] ~= 0 + elseif self.canHaveShieldCrucibleTree then + return self:GetModSpawnWeight(mod, { ["crucible_unique_helmet"] = true, ["shield"] = true }) > 0 + elseif self.canHaveTwoHandedSwordCrucibleTree then + return self:GetModSpawnWeight(mod, { ["two_hand_weapon"] = true }, { ["one_hand_weapon"] = true }) > 0 + end + return self:GetModSpawnWeight(mod, includeTags) > 0 +end diff --git a/src/Classes/ItemsTab.lua b/src/Classes/ItemsTab.lua index 24a9f8b32c..7f4f1f89f3 100644 --- a/src/Classes/ItemsTab.lua +++ b/src/Classes/ItemsTab.lua @@ -3039,30 +3039,9 @@ function ItemsTabClass:AddCrucibleModifierToDisplayItem() end return table.concat(label, "/") end - local function itemCanHaveMod(mod) - local keyMap, includeTags = { }, { } - for index, key in ipairs(mod.weightKey) do - keyMap[key] = index - end - -- check for uniques with off-tag mods - if data.casterTagCrucibleUniques[self.displayItem.title] then - includeTags["caster_unique_weapon"] = true - end - if data.minionTagCrucibleUniques[self.displayItem.title] then - includeTags["minion_unique_weapon"] = true - end - if self.displayItem.canHaveOnlySupportSkillsCrucibleTree then - return keyMap["crucible_unique_staff"] and mod.weightVal[keyMap["crucible_unique_staff"]] ~= 0 - elseif self.displayItem.canHaveShieldCrucibleTree then - return self.displayItem:GetModSpawnWeight(mod, { ["crucible_unique_helmet"] = true, ["shield"] = true }) > 0 - elseif self.displayItem.canHaveTwoHandedSwordCrucibleTree then - return self.displayItem:GetModSpawnWeight(mod, { ["two_hand_weapon"] = true }, { ["one_hand_weapon"] = true }) > 0 - end - return self.displayItem:GetModSpawnWeight(mod, includeTags) > 0 - end local function buildCrucibleMods() for i, mod in pairs(self.build.data.crucible) do - if itemCanHaveMod(mod) then + if self.displayItem:CanHaveMod(mod) then -- item mod must match the whole mod, whether that's one line or two if itemModMap[checkLineForAllocates(mod[1], self.build.spec.nodes)] and ((mod[2] and itemModMap[checkLineForAllocates(mod[2], self.build.spec.nodes)]) or not mod[2]) then -- for multi nodes, if the first location is taken, use second